1. 程式人生 > 程式設計 >Apollo在基礎架構中的實踐經驗

Apollo在基礎架構中的實踐經驗

本文來自李偉超同學的投稿,如果你有好的文章也歡迎聯絡我。

微服務配置中心 Apollo 使用指南,以下檔案根據 apollo wiki 整理而來,部分最佳實踐說明和程式碼改造基於筆者的工作經驗整理而來,如有問題歡迎溝通。

配置中心

在拆分為微服務架構前,曾經的單體應用只需要管理一套配置。而拆分為微服務後,每一個系統都有自己的配置,並且都各不相同,而且因為服務治理的需要,有些配置還需要能夠動態改變,如業務引數調整或需要熔斷限流等功能,配置中心就是解決這個問題的。

配置的基本概念

  • 配置是獨立於程式的只讀變數
    • 同個應用在不同的配置有不同的行為
    • 應用不應該改變配置
  • 配置伴隨應用的整個生命週期
    • 初始化引數和執行引數
  • 配置可以有多種載入方式
  • 配置需要治理
    • 許可權控制(應用級別、編輯釋出隔離等)
    • 多環境叢集配置管理
    • 框架類元件配置管理

配置中心

  • 配置註冊與反註冊
  • 配置治理
  • 配置變更訂閱

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 的關係是為使用者提供一個方便的服務介面,用於配置屬性源並從它們中解析屬性。

Apollo 簡介

簡介

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 基於開源模式開發,開源地址:github.com/ctripcorp/a…

基礎模型

如下即是Apollo的基礎模型:

  1. 使用者在配置中心對配置進行修改併發布
  2. 配置中心通知Apollo客戶端有配置更新
  3. Apollo客戶端從配置中心拉取最新的配置、更新本地配置並通知到應用

圖片

Apollo 架構說明

Apollo 專案本身就使用了 Spring Boot & Spring Cloud 開發。

服務端

圖片

上圖簡要描述了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 程式中。

客戶端

圖片

  • 客戶端和服務端保持了一個長連線,從而能第一時間獲得配置更新的推送。
  • 客戶端還會定時從 Apollo 配置中心服務端拉取應用的最新配置。
    • 這是一個fallback機制,為了防止推送機制失效導致配置不更新
    • 客戶端定時拉取會上報本地版本,所以一般情況下,對於定時拉取的操作,服務端都會返回304 - Not Modified
    • 定時頻率預設為每5分鐘拉取一次,客戶端也可以通過在執行時指定 System Property: apollo.refreshInterval 來覆蓋,單位為分鐘。
  • 客戶端從Apollo配置中心服務端獲取到應用的最新配置後,會儲存在記憶體中
  • 客戶端會把從服務端獲取到的配置在本地檔案系統快取一份
    • 在遇到服務不可用,或網路不通的時候,依然能從本地恢復配置
  • 應用程式從Apollo客戶端獲取最新的配置、訂閱配置更新通知

長連線實現上是使用的非同步+輪詢實現,具體實現的解析請檢視下面兩篇文章

service notifications

client polling

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自動切換到其它存活的資料中心 |

Apollo 使用說明

使用說明

Apollo使用指南

Java客戶端使用指南

最佳實踐

在 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 ([email protected])
 * @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) {
        }
        return null;
    }
}
複製程式碼

動態重新整理

支援 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 ([email protected])
 * @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 ([email protected])
 * @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,len)) {
                return true;
            }
        }
        return false;
    }
}
複製程式碼

Apollo 最佳實踐 - 配置治理

許可權控制

由於配置能改變程式的行為,不正確的配置甚至能引起災難,所以對配置的修改必須有比較完善的許可權控制。應用和配置的管理都有完善的許可權管理機制,對配置的管理還分為了編輯和釋出兩個環節,從而減少人為的錯誤。所有的操作都有審計日誌,可以方便地追蹤問題

  • everyone 要有自己的賬戶(最主要的前置條件)
  • 每一個專案都至少有一個 owner(專案管理員,專案管理員擁有以下許可權)
    • 可以管理專案的許可權分配
    • 可以建立叢集
    • 可以建立 Namespace
  • 專案管理員(owner)根據組織結構分配配置許可權
    • 編輯許可權允許使用者在 Apollo 介面上建立、修改、刪除配置
      • 配置修改後只在 Apollo 介面上變化,不會影響到應用實際使用的配置
    • 釋出許可權允許使用者在 Apollo 介面上釋出、回滾配置
      • 配置只有在釋出、回滾動作後才會被應用實際使用到
      • Apollo在使用者操作釋出、回滾動作後實時通知到應用,並使最新配置生效
  • 專案管理員管理許可權介面

圖片

專案建立完,預設沒有分配配置的編輯和釋出許可權,需要專案管理員進行授權。

  1. 點選application這個namespace的授權按鈕

圖片

  1. 分配修改許可權

圖片

  1. 分配發布許可權

圖片

Namespace

Namespace 許可權分類

apollo 獲取許可權分類分為私有的和公共的。

  • private (私有的)

private許可權的Namespace,只能被所屬的應用獲取到。一個應用嘗試獲取其它應用private的Namespace,Apollo會報“404”異常。

  • public (公共的)

public許可權的Namespace,能被任何應用獲取。

Namespace 的分類

Namespace 有三種型別,私有型別,公共型別,關聯型別(繼承型別)。

Apollo 私有型別 Namespace 使用說明

私有型別的 Namespace 具有 private 許可權。例如服務預設的“application” Namespace 就是私有型別。

  1. 使用場景
  • 服務自身的配置(如資料庫、業務行為等配置)
  1. 如何使用私有型別 Namespace

一個應用下不同配置的分組,可以簡單地把namespace類比為檔案,不同型別的配置存放在不同的檔案中,如資料庫配置檔案,業務屬性配置,配置檔案等

Apollo 公共型別 Namespace 使用說明

公共型別的 Namespace 具有 public 許可權。公共型別的 Namespace 相當於遊離於應用之外的配置,且通過 Namespace 的名稱去標識公共 Namespace,所以公共的 Namespace 的名稱必須全域性唯一。

  1. 使用場景
  • 部門級別共享的配置
  • 小組級別共享的配置
  • 幾個專案之間共享的配置
  • 中介軟體客戶端的配置
  1. 如何使用公共型別 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 的常用配置
  • 基礎架構的公共元件的配置,如監控,熔斷等公共元件配置