­

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的基礎模型:

  1. 用戶在配置中心對配置進行修改並發布
  2. 配置中心通知Apollo客戶端有配置更新
  3. 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在用戶操作發布、回滾動作後實時通知到應用,並使最新配置生效
  • 項目管理員管理許可權介面

項目創建完,默認沒有分配配置的編輯和發布許可權,需要項目管理員進行授權。

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 的常用配置
  • 基礎架構的公共組件的配置,如監控,熔斷等公共組件配置