Apollo在基礎架構中的實踐經驗
- 2019 年 11 月 25 日
- 筆記
本文來自李偉超同學的投稿,如果你有好的文章也歡迎聯繫我。 微服務配置中心 Apollo 使用指南,以下文檔根據 apollo wiki 整理而來,部分最佳實踐說明和程式碼改造基於筆者的工作經驗整理而來,如有問題歡迎溝通。
1 配置中心
在拆分為微服務架構前,曾經的單體應用只需要管理一套配置。而拆分為微服務後,每一個系統都有自己的配置,並且都各不相同,而且因為服務治理的需要,有些配置還需要能夠動態改變,如業務參數調整或需要熔斷限流等功能,配置中心就是解決這個問題的。
1.1 配置的基本概念
- 配置是獨立於程式的只讀變數
- 同個應用在不同的配置有不同的行為
- 應用不應該改變配置
- 配置伴隨應用的整個生命周期
- 初始化參數和運行參數
- 配置可以有多種載入方式
- 配置需要治理
- 許可權控制(應用級別、編輯發布隔離等)
- 多環境集群配置管理
- 框架類組件配置管理
1.2 配置中心
- 配置註冊與反註冊
- 配置治理
- 配置變更訂閱
1.3 Spring Environment
Environment 是 Spring 容器中對於應用環境兩個關鍵因素(profile & properties)的一個抽象。
- profile
profile 是一個邏輯的分組,當 bean 向容器中註冊的時候,僅當配置激活時生效。
## 配置文件使用 spring.profiles.active=xxx
## 硬編碼註解形式使用 @org.springframework.context.annotation.Profile
- properties
Properties 在幾乎所有應用程式中都扮演著重要的角色,並且可能來自各種各樣的來源:properties 文件、JVM系統屬性、系統環境變數、JNDI、Servlet Context 參數、ad-hoc Properties 對象、Map 等等。Environment 與 Properties 的關係是為用戶提供一個方便的服務介面,用於配置屬性源並從它們中解析屬性。
- Spring 中的擴展點
- spring framework 提供了便捷的方式添加自定義數據源策略添加到 Spring Enviroment 中,如 @PropertySource。https://docs.spring.io/spring/docs/5.1.8.RELEASE/spring-framework-reference/core.html#beans-using-propertysource
- spring boot 提供了相關的擴展方式,如 EnviromentPostProcessor 相關的。https://docs.spring.io/spring-boot/docs/2.1.6.RELEASE/reference/html/boot-features-external-config.html
- spring boot 同時也提供在開始之前自定義環境擴展。https://docs.spring.io/spring-boot/docs/2.1.6.RELEASE/reference/html/howto-spring-boot-application.html#howto-customize-the-environment-or-application-context
2 Apollo 簡介
2.1 簡介
Apollo(阿波羅)是攜程框架部門研發的開源配置管理中心,能夠集中化管理應用不同環境、不同集群的配置,配置修改後能夠實時推送到應用端,並且具備規範的許可權、流程治理等特性。
Apollo 支援4個維度管理 Key-Value 格式的配置:
- application (應用)
這個很好理解,就是實際使用配置的應用,Apollo 客戶端在運行時需要知道當前應用是誰,從而可以去獲取對應的配置。每個應用都需要有唯一的身份標識,我們認為應用身份是跟著程式碼走的,所以需要在程式碼中配置,具體資訊請參見 Java 客戶端使用指南。
- environment (環境)
配置對應的環境,Apollo 客戶端在運行時需要知道當前應用處於哪個環境,從而可以去獲取應用的配置。我們認為環境和程式碼無關,同一份程式碼部署在不同的環境就應該能夠獲取到不同環境的配置,所以環境默認是通過讀取機器上的配置(server.properties中的env屬性)指定的,不過為了開發方便,我們也支援運行時通過 System Property 等指定,具體資訊請參見Java客戶端使用指南。
- cluster (集群)
一個應用下不同實例的分組,比如典型的可以按照數據中心分,把上海機房的應用實例分為一個集群,把北京機房的應用實例分為另一個集群。對不同的cluster,同一個配置可以有不一樣的值,如 zookeeper 地址。集群默認是通過讀取機器上的配置(server.properties中的idc屬性)指定的,不過也支援運行時通過 System Property 指定,具體資訊請參見Java客戶端使用指南。
- namespace (命名空間)
一個應用下不同配置的分組,可以簡單地把 namespace 類比為文件,不同類型的配置存放在不同的文件中,如資料庫配置文件,RPC配置文件,應用自身的配置文件等。應用可以直接讀取到公共組件的配置 namespace,如 DAL,RPC 等。應用也可以通過繼承公共組件的配置 namespace 來對公共組件的配置做調整,如DAL的初始資料庫連接數。
同時,Apollo 基於開源模式開發,開源地址:https://github.com/ctripcorp/apollo
2.2 基礎模型
如下即是Apollo的基礎模型:
- 用戶在配置中心對配置進行修改並發布
- 配置中心通知Apollo客戶端有配置更新
- Apollo客戶端從配置中心拉取最新的配置、更新本地配置並通知到應用

3 Apollo 架構說明
Apollo 項目本身就使用了 Spring Boot & Spring Cloud 開發。
3.1 服務端

上圖簡要描述了Apollo的總體設計,我們可以從下往上看:
- Config Service 提供配置的讀取、推送等功能,服務對象是Apollo客戶端。
- Admin Service 提供配置的修改、發布等功能,服務對象是Apollo Portal(管理介面)。
- Config Service 和 Admin Service 都是多實例、無狀態部署,所以需要將自己註冊到 Eureka 中並保持心跳
- 在 Eureka 之上我們架了一層 Meta Server 用於封裝 Eureka 的服務發現介面 Client 通過域名訪問 Meta Server 獲取 Config Service 服務列表(IP+Port),而後直接通過 IP+Port 訪問服務,同時在 Client 側會做 load balance、錯誤重試
- Portal 通過域名訪問 Meta Server 獲取 Admin Service 服務列表(IP+Port),而後直接通過 IP+Port 訪問服務,同時在 Portal 側會做 load balance、錯誤重試
- 為了簡化部署,我們實際上會把 Config Service、Eureka 和 Meta Server 三個邏輯角色部署在同一個 JVM 進程中。
3.2 客戶端

- 客戶端和服務端保持了一個長連接,從而能第一時間獲得配置更新的推送。
- 客戶端還會定時從 Apollo 配置中心服務端拉取應用的最新配置。
- 這是一個fallback機制,為了防止推送機制失效導致配置不更新
- 客戶端定時拉取會上報本地版本,所以一般情況下,對於定時拉取的操作,服務端都會返回304 – Not Modified
- 定時頻率默認為每5分鐘拉取一次,客戶端也可以通過在運行時指定 System Property: apollo.refreshInterval 來覆蓋,單位為分鐘。
- 客戶端從Apollo配置中心服務端獲取到應用的最新配置後,會保存在記憶體中
- 客戶端會把從服務端獲取到的配置在本地文件系統快取一份
- 在遇到服務不可用,或網路不通的時候,依然能從本地恢復配置
- 應用程式從Apollo客戶端獲取最新的配置、訂閱配置更新通知
長連接實現上是使用的非同步+輪詢實現
4 Apollo 高可用部署
在 Apollo 架構說明中我們提到過 client 和 portal 都是在客戶端負載均衡,根據 ip+port 訪問服務,所以 config service 和 admin service 是無狀態的,可以水平擴展的,portal service 根據使用 slb 綁定多台伺服器達到切換,meta server 同理。
場景 |
影響 |
降級 |
原因 |
---|---|---|---|
某台config service下線 |
無影響 |
|
Config service無狀態,客戶端重連其它config service |
所有config service下線 |
客戶端無法讀取最新配置,Portal無影響 |
客戶端重啟時,可以讀取本地快取配置文件 |
|
某台admin service下線 |
無影響 |
|
Admin service無狀態,Portal重連其它admin service |
所有admin service下線 |
客戶端無影響,portal無法更新配置 |
|
|
某台portal下線 |
無影響 |
|
Portal域名通過slb綁定多台伺服器,重試後指向可用的伺服器 |
全部portal下線 |
客戶端無影響,portal無法更新配置 |
|
|
某個數據中心下線 |
無影響 |
|
多數據中心部署,數據完全同步,Meta Server/Portal域名通過slb自動切換到其它存活的數據中心 |
5 Apollo 使用說明
5.1 使用說明
Apollo使用指南
https://github.com/ctripcorp/apollo/wiki/Apollo%E4%BD%BF%E7%94%A8%E6%8C%87%E5%8D%97
Java客戶端使用指南
https://github.com/ctripcorp/apollo/wiki/Java%E5%AE%A2%E6%88%B7%E7%AB%AF%E4%BD%BF%E7%94%A8%E6%8C%87%E5%8D%97
5.2 最佳實踐
在 Spring Boot & Spring Cloud 中使用。
- 每個應用都需要有唯一的身份標識,我們認為應用身份是跟著程式碼走的,所以需要在程式碼中配置。關於應用身份標識,應用標識對第三方中間件應該是統一的,擴展支援 apollo 身份標識和 spring.application.name 一致(具體查看 fusion-config-apollo 中程式碼),其他中間件同理。
- 應用開發過程中如使用程式碼中的配置,應該充分利用 Spring Environment Profile,增加本地邏輯分組 local,非開發階段關閉 local 邏輯分組。同時關閉 apollo 遠程獲取配置,在 VM options 中增加 -Denv=local。

以下程式碼是擴展 apollo 應用標識使用 spring.application.name,並增加監控配置,監控一般是基礎架構團隊提供的功能,從基礎框架硬編碼上去,業務側做到完全無感知。
import com.ctrip.framework.apollo.ConfigService; import com.ctrip.framework.apollo.spring.config.PropertySourcesConstants; import com.ctrip.framework.foundation.internals.io.BOMInputStream; import com.ctrip.framework.foundation.internals.provider.DefaultApplicationProvider; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplicationRunListener; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.core.annotation.Order; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.EnumerablePropertySource; import org.springframework.core.env.PropertiesPropertySource; import org.springframework.util.StringUtils; import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.util.Properties; import java.util.Set; /** * ApolloSpringApplicationRunListener * <p> * SpringApplicationRunListener 介面說明 * https://blog.csdn.net/u011179993/article/details/51555690https://blog.csdn.net/u011179993/article/details/51555690 * * @author Weichao Li (liweichao0102@gmail.com) * @since 2019-08-15 */ @Order(value = ApolloSpringApplicationRunListener.APOLLO_SPRING_APPLICATION_RUN_LISTENER_ORDER) @Slf4j public class ApolloSpringApplicationRunListener implements SpringApplicationRunListener { public static final int APOLLO_SPRING_APPLICATION_RUN_LISTENER_ORDER = 1; private static final String APOLLO_APP_ID_KEY = "app.id"; private static final String SPRINGBOOT_APPLICATION_NAME = "spring.application.name"; private static final String CONFIG_CENTER_INFRA_NAMESPACE = "infra.monitor"; public ApolloSpringApplicationRunListener(SpringApplication application, String[] args) { } /** * 剛執行run方法時 */ @Override public void starting() { } /** * 環境建立好時候 * * @param env 環境資訊 */ @Override public void environmentPrepared(ConfigurableEnvironment env) { Properties props = new Properties(); props.put(PropertySourcesConstants.APOLLO_BOOTSTRAP_ENABLED, true); props.put(PropertySourcesConstants.APOLLO_BOOTSTRAP_EAGER_LOAD_ENABLED, true); env.getPropertySources().addFirst(new PropertiesPropertySource("apolloConfig", props)); // 初始化appId this.initAppId(env); // 初始化基礎架構提供的默認配置,需在項目中關聯公共 namespaces this.initInfraConfig(env); } /** * 上下文建立好的時候 * * @param context 上下文 */ @Override public void contextPrepared(ConfigurableApplicationContext context) { } /** * 上下文載入配置時候 * * @param context 上下文 */ @Override public void contextLoaded(ConfigurableApplicationContext context) { } @Override public void started(ConfigurableApplicationContext context) { } @Override public void running(ConfigurableApplicationContext context) { } @Override public void failed(ConfigurableApplicationContext context, Throwable exception) { } /** * 初始化 apollo appId * * @param env 環境資訊 */ private void initAppId(ConfigurableEnvironment env) { String apolloAppId = env.getProperty(APOLLO_APP_ID_KEY); if (StringUtils.isEmpty(apolloAppId)) { //此處需要判斷一下 meta-inf 下的文件中的 app id apolloAppId = getAppIdByAppPropertiesClasspath(); if (StringUtils.isEmpty(apolloAppId)) { String applicationName = env.getProperty(SPRINGBOOT_APPLICATION_NAME); if (!StringUtils.isEmpty(applicationName)) { System.setProperty(APOLLO_APP_ID_KEY, applicationName); } else { throw new IllegalArgumentException("config center must config app.id in " + DefaultApplicationProvider.APP_PROPERTIES_CLASSPATH); } } else { System.setProperty(APOLLO_APP_ID_KEY, apolloAppId); } } else { System.setProperty(APOLLO_APP_ID_KEY, apolloAppId); } } /** * 初始化基礎架構提供的配置 * * @param env 環境資訊 */ private void initInfraConfig(ConfigurableEnvironment env) { com.ctrip.framework.apollo.Config apolloConfig = ConfigService.getConfig(CONFIG_CENTER_INFRA_NAMESPACE); Set<String> propertyNames = apolloConfig.getPropertyNames(); if (propertyNames != null && propertyNames.size() > 0) { Properties properties = new Properties(); for (String propertyName : propertyNames) { properties.setProperty(propertyName, apolloConfig.getProperty(propertyName, null)); } EnumerablePropertySource enumerablePropertySource = new PropertiesPropertySource( CONFIG_CENTER_INFRA_NAMESPACE, properties); env.getPropertySources().addLast(enumerablePropertySource); } } /** * 從 apollo 默認配置文件中取 app.id 的值,調整優先順序在 spring.application.name 之前 * * @return apollo app id */ private String getAppIdByAppPropertiesClasspath() { try { InputStream in = Thread.currentThread().getContextClassLoader() .getResourceAsStream(DefaultApplicationProvider.APP_PROPERTIES_CLASSPATH); if (in == null) { in = DefaultApplicationProvider.class .getResourceAsStream(DefaultApplicationProvider.APP_PROPERTIES_CLASSPATH); } Properties properties = new Properties(); if (in != null) { try { properties.load(new InputStreamReader(new BOMInputStream(in), StandardCharsets.UTF_8)); } finally { in.close(); } } if (properties.containsKey(APOLLO_APP_ID_KEY)) { String appId = properties.getProperty(APOLLO_APP_ID_KEY); log.info("App ID is set to {} by app.id property from {}", appId, DefaultApplicationProvider.APP_PROPERTIES_CLASSPATH); return appId; } } catch (Throwable ignore) { } } }
5.3 動態刷新
支援 Apollo 配置自動刷新類型,支援 @Value @RefreshScope @ConfigurationProperties 以及日誌級別的動態刷新。具體程式碼查看下文鏈接。
- @Value
@Value Apollo 本身就支援了動態刷新,需要注意的是如果@Value 使用了 SpEL 表達式,動態刷新會失效。
支援動態刷新
@Value("${simple.xxx}") private String simpleXxx;
不支援動態刷新
@Value("#{'${simple.xxx}'.split(',')}") private List<String> simpleXxxs;
- @RefreshScope
RefreshScope(org.springframework.cloud.context.scope.refresh)是 Spring Cloud 提供的一種特殊的 scope 實現,用來實現配置、實例熱載入。
動態實現過程:
配置變更時,調用 refreshScope.refreshAll() 或指定 bean。提取標準參數(System,jndi,Servlet)之外所有參數變數,把原來的Environment里的參數放到一個新建的 Spring Context 容器下重新載入,完事之後關閉新容器。提取更新過的參數(排除標準參數) ,比較出變更項,發布環境變更事件,RefreshScope 用新的環境參數重新生成Bean。重新生成的過程很簡單,清除 refreshscope 快取幷銷毀 Bean,下次就會重新從 BeanFactory 獲取一個新的實例(該實例使用新的配置)。
- @ConfigurationProperties
apollo 默認是不支援 ConfigurationProperties 刷新的,這塊需要配合 EnvironmentChangeEvent 刷新的。
- 日誌級別
apollo 默認是不支援日誌級別刷新的,這塊需要配合 EnvironmentChangeEvent 刷新的。
- EnvironmentChangeEvent(Spring Cloud 提供)
當觀察到 EnvironmentChangeEvent 時,它將有一個已更改的鍵值列表,應用程式將使用以下內容:1,重新綁定上下文中的任何 @ConfigurationProperties bean,程式碼見org.springframework.cloud.context.properties.ConfigurationPropertiesRebinder。2,為logging.level.*中的任何屬性設置記錄器級別,程式碼見 org.springframework.cloud.logging.LoggingRebinder。
支援動態刷新
import com.ctrip.framework.apollo.model.ConfigChangeEvent; import com.ctrip.framework.apollo.spring.annotation.ApolloConfigChangeListener; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeansException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cloud.context.environment.EnvironmentChangeEvent; import org.springframework.cloud.context.scope.refresh.RefreshScope; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.context.annotation.Configuration; /** * LoggerConfiguration * * @author Weichao Li (liweichao0102@gmail.com) * @since 2019/11/14 */ @Configuration @Slf4j public class ApolloRefreshConfiguration implements ApplicationContextAware { private ApplicationContext applicationContext; @Autowired private RefreshScope refreshScope; @ApolloConfigChangeListener private void onChange(ConfigChangeEvent changeEvent) { applicationContext.publishEvent(new EnvironmentChangeEvent(changeEvent.changedKeys())); refreshScope.refreshAll(); } @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { this.applicationContext = applicationContext; } }
注意原有配置如果有日誌級別需要初始化。
import com.ctrip.framework.apollo.Config; import com.ctrip.framework.apollo.spring.annotation.ApolloConfig; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.logging.LogLevel; import org.springframework.boot.logging.LoggingSystem; import org.springframework.context.annotation.Configuration; import javax.annotation.PostConstruct; import java.util.Set; /** * logging 初始化 * * @author Weichao Li (liweichao0102@gmail.com) * @since 2019/11/14 */ @Configuration @Slf4j public class LoggingConfiguration { private static final String LOGGER_TAG = "logging.level."; private static final String DEFAULT_LOGGING_LEVEL = "info"; @Autowired private LoggingSystem loggingSystem; @ApolloConfig private Config config; @PostConstruct public void changeLoggingLevel() { Set<String> keyNames = config.getPropertyNames(); for (String key : keyNames) { if (containsIgnoreCase(key, LOGGER_TAG)) { String strLevel = config.getProperty(key, DEFAULT_LOGGING_LEVEL); LogLevel level = LogLevel.valueOf(strLevel.toUpperCase()); loggingSystem.setLogLevel(key.replace(LOGGER_TAG, ""), level); } } } private static boolean containsIgnoreCase(String str, String searchStr) { if (str == null || searchStr == null) { return false; } int len = searchStr.length(); int max = str.length() - len; for (int i = 0; i <= max; i++) { if (str.regionMatches(true, i, searchStr, 0, len)) { return true; } } return false; } }
6 Apollo 最佳實踐 – 配置治理
6.1 許可權控制
由於配置能改變程式的行為,不正確的配置甚至能引起災難,所以對配置的修改必須有比較完善的許可權控制。應用和配置的管理都有完善的許可權管理機制,對配置的管理還分為了編輯和發布兩個環節,從而減少人為的錯誤。所有的操作都有審計日誌,可以方便地追蹤問題
- everyone 要有自己的賬戶(最主要的前置條件)
- 每一個項目都至少有一個 owner(項目管理員,項目管理員擁有以下許可權)
- 可以管理項目的許可權分配
- 可以創建集群
- 可以創建 Namespace
- 項目管理員(owner)根據組織結構分配配置許可權
- 編輯許可權允許用戶在 Apollo 介面上創建、修改、刪除配置
- 配置修改後只在 Apollo 介面上變化,不會影響到應用實際使用的配置
- 發布許可權允許用戶在 Apollo 介面上發布、回滾配置
- 配置只有在發布、回滾動作後才會被應用實際使用到
- Apollo在用戶操作發布、回滾動作後實時通知到應用,並使最新配置生效
- 編輯許可權允許用戶在 Apollo 介面上創建、修改、刪除配置
- 項目管理員管理許可權介面

項目創建完,默認沒有分配配置的編輯和發布許可權,需要項目管理員進行授權。
1 點擊application這個namespace的授權按鈕

2 分配修改許可權

3 分配發布許可權

6.2 Namespace
Namespace 許可權分類
apollo 獲取許可權分類分為私有的和公共的。
- private (私有的)
private許可權的Namespace,只能被所屬的應用獲取到。一個應用嘗試獲取其它應用private的Namespace,Apollo會報「404」異常。
- public (公共的)
public許可權的Namespace,能被任何應用獲取。
Namespace 的分類
Namespace 有三種類型,私有類型,公共類型,關聯類型(繼承類型)。
Apollo 私有類型 Namespace 使用說明
私有類型的 Namespace 具有 private 許可權。例如服務默認的「application」 Namespace 就是私有類型。
1 使用場景
- 服務自身的配置(如資料庫、業務行為等配置)
2 如何使用私有類型 Namespace
一個應用下不同配置的分組,可以簡單地把namespace類比為文件,不同類型的配置存放在不同的文件中,如資料庫配置文件,業務屬性配置,配置文件等
Apollo 公共類型 Namespace 使用說明
公共類型的 Namespace 具有 public 許可權。公共類型的 Namespace 相當於遊離於應用之外的配置,且通過 Namespace 的名稱去標識公共 Namespace,所以公共的 Namespace 的名稱必須全局唯一。
1 使用場景
- 部門級別共享的配置
- 小組級別共享的配置
- 幾個項目之間共享的配置
- 中間件客戶端的配置
2 如何使用公共類型 Namespace
- 程式碼侵入型
@EnableApolloConfig({"application", "poizon-infra.jaeger"})
- 配置方式形式
# will inject 'application' namespace in bootstrap phase apollo.bootstrap.enabled = true # will inject 'application', 'poizon-infra.jaeger' namespaces in bootstrap phase apollo.bootstrap.namespaces = application,poizon-infra.jaeger
Apollo 關聯類型 Namespace 使用說明
關聯類型又可稱為繼承類型,關聯類型具有 private 許可權。關聯類型的 Namespace 繼承於公共類型的 Namespace,用於覆蓋公共 Namespace 的某些配置。
使用建議
- 基礎框架部分的統一配置,如 DAL 的常用配置
- 基礎架構的公共組件的配置,如監控,熔斷等公共組件配置