首页 > 技术文章 > Apollo 分布式配置中心理论到实践

zhengzhaoxiang 2020-11-14 16:27 原文

携程开源的配置管理中心统一管理各种应用配置的基础服务组件),能够集中化管理应用的不同环境,不同集群的配置,配置修改后能够实时推送到应用端,适合微服务配置管理场景。Apollo包括服务端客户端

在系统架构中,配置中心是整个微服务基础架构体系中的一个组件,如下图,它的功能看上去并不起眼,无非就是配置的管理和存取,但它是整个微服务架构中不可或缺的一环。

集中管理配置,那么就要将应用的配置作为一个单独的服务抽离出来。一个合格的配置中心满足如下需求:① 配置项容易读取和修改;② 添加新配置简单直接;③ 执行对配置的修改的监视以把控风险;④ 可以查看配置修改的历史纪录;⑤ 不同部署环境支持隔离

一、Apollo 特性


【1】统一管理不同环境、不同集群的配置:Apollo 提供了一个统一界面集中式管理不同环境(environment)、不同集群(cluster)、不同命名空间(namespace)的配置。同一份代码部署在不同的集群,可以有不同的配置,比如 zk的地址等。通过命名空间(namespace)可以很方便地支持多个不同应用共享同一份配置,同时还允许应用对共享的配置进行覆盖。
【2】配置修改实时生效用户在 Apollo 修改完配置并发布后,客户端能实时(1s)接收到最新的配置,并通知到应用程序。
【3】版本发布管理:所有的配置发布都有版本概念,从而可以方便地支持配置的回滚。
【4】灰度发布:支持配置的灰度发布,比如点了发布后,只对部分应用实例生效,观察一段时间没问题后再推给所有应用实例。
【5】权限管理、发布审核、操作审计:应用和配置的管理都有完善的权限管理机制,对配置的管理还分为编辑和发布两个环节,从而减少人为的错误。所有的操作都有审计日志,可以方便地追踪问题。
【6】客户端配置信息监控:可以在界面上方便地看到配置在被哪些实例使用。
【7】提供开放平台API:Apollo 自身提供了比较完善的统一配置管理界面,支持多环境、多数据中心配置管理、权限、流程治理等特性。不过 Apollo 处于通用性考虑,不会对配置的修改做过多限制,只要符合基本的格式就能保存,不会针对不同的配置值进行针对性校验,如数据库用户名、密码、Redis服务地址等。对于这类应用配置,Apollo 支持应用方通过开放平台API在 Apollo进行配置的修改和发布,并且具备完善的授权和权限控制。

二、执行流程


在 Apollo 配置中心修改配置(服务端),应用程序通过 Apollo客户端从配置中心拉取配置信息。用户通过 Apollo配置中心修改或发布配置后,会有两种机制来保证应用程序来获取最新配置:一种是 Apollo配置中心向客户端推送最新配置;另一种是 Apollo客户端会定时从 Apollo配置中心拉取最新的配置,通过以上两种机制共同保证应用程序能及时获取到配置。

三、安装 Apollo


【运行时环境】:JDK1.8、MySQL5.6.5:Apollo的表结构对 timestamp使用了多个 default声明,所以需要5.6.5以上版本。
【下载jar包】链接 点击 download,下载如下三个.zip包进行解压。

【创建ApolloConfigDB数据库】Apollo 服务端共需要两个数据库:ApolloPortalDBApolloConfigDBApolloPortalDB只需要在生产环境部署一个即可,而 ApolloConfigDB需要在每个环境部署一套。创建 ApolloPortalDB  sql脚本链接

source ApolloProtalDB_initialization.sql

验证ApolloConfigDB】导入的所有表如下:
 
【创建ApolloPortalDB数据库】sql脚本链接

source ApolloConfigDB__initialization.sql

验证ApolloPortalDB】导入的所有表如下:
 

四、启动 Apollo


【1】确保端口未被占用:Apollo 默认会启动3个服务,分别使用8070,8080,8090端口,请确保这3个端口当前被使用。
【2】启动 apollo-configservice,在 Apollo目录下执行执行如下命令,可以通过 -Dserver.port=8080修改默认端口。

java -Xms256m -Xmx256m -Dspring.datasource.url=jdbc:mysql://localhost:3306/ApolloConfigDB?characterEncoding=utf8 -Dspring.datasource.username=root -Dspring.datasource.password=root -jar apollo-configservice-1.3.0.jar

【3】启动 apollo-adminservice,可通过 -Dserver.port=8090修改默认端口

1 java -Xms256m -Xmx256m -Dspring.datasource.url=jdbc:mysql://localhost:3306/ApolloConfigDB?characterEncoding=utf8 -Dspring.datasource.username=root -Dspring.datasource.password=root -jar apollo-adminservice-1.3.0.jar

【4】启动 apollo-portal,可通过 -Dserver.port=8070修改默认端口

java -Xms256m -Xmx256m -Ddev_meta=http://localhost:8080/ -Dserver.port=8070 -Dspring.datasource.url=jdbc:mysql://localhost:3306/ApolloPortalDB?characterEncoding=utf8 -Dspring.datasource.username=root -Dspring.datasource.password=root -jar apollo-portal-1.3.0.jar

【5】也可以使用提供的 runApollo.bat快速启动三个服务(修改数据库连接地址,数据库以及密码)

1 echo
2    set url="localhost:3306"
3    set username="root"
4    set password="root"
5 
6 start "configService" java -Xms256m -Xmx256m -Dapollo_profile=github -Dspring.datasource.url=jdbc:mysql://%url%/ApolloConfigDB?characterEncoding=utf8 -Dspring.datasource.username=%username% -Dspring.datasource.password=%password% -Dlogging.file=.\logs\apollo-configservice.log -jar .\apollo-configservice-1.3.0.jar
7 start "adminService" java -Xms256m -Xmx256m -Dapollo_profile=github -Dspring.datasource.url=jdbc:mysql://%url%/ApolloConfigDB?characterEncoding=utf8 -Dspring.datasource.username=%username% -Dspring.datasource.password=%password% -Dlogging.file=.\logs\apollo-adminservice.log -jar .\apollo-adminservice-1.3.0.jar
8 start "ApolloPortal" java -Xms256m -Xmx256m -Dapollo_profile=github,auth -Ddev_meta=http://localhost:8080/ -Dserver.port=8070 -Dspring.datasource.url=jdbc:mysql://%url%/ApolloPortalDB?characterEncoding=utf8 -Dspring.datasource.username=%username% -Dspring.datasource.password=%password% -Dlogging.file=.\logs\apollo-portal.log -jar .\apollo-portal-1.3.0.jar

【6】待启动成功后,访问管理页面[http://localhost:8070/] 用户名 apollo,密码 admin

五、发布配置


【1】点击管理页面的 “创建项目


【2】点击“新增配置

【3】提前我们需要配置的 key-value 键值对

【4】提交之后,配置默认是“未发布”状态,需要点击“发布” 让配置生效。也可以对配置进行修改,修改后也需要发布。

六、客户端配置


【1】在项目的 pom.xml文件中添加 Apollo客户端依赖:

1 <dependency>
2     <groupId>com.ctrip.framework.apollo</groupId>
3     <artifactId>apollo-client</artifactId>
4     <version>1.1.0</version>
5 </dependency>

【2】Apollo客户端连接 Apollo服务端需要配置如下3点:

1 #配置文件中定义的项目名称
2 -Dapp.id=apollo-quickstart 
3 #项目的执行环境
4 -Denv=DEV 
5 #Apollo服务的运行地址
6 -Ddev_meta=http://localhost:8080

【3】获取 Apollo配置界面配置的属性,例如我们配置的 sms.enable,获取代码如下:

七、Apollo 工作原理



上图简要描述了 Apollo的总体架构,我们从下往上进行分析:
【1】Config Service 提供配置的读取推送等功能,服务对象是 Apollo客户端
【2】Admin Service 提供配置的修改发布等功能,服务对象是 Apollo Portal(管理界面);
【3】Eureka/Consul等提供服务注册和发现,为了简单起见,目前 Eureka在部署时和 Config Service是在一个 JVM进程中的;
【4】Config Service Admin Service都是多实例、无状态部署,所以需要将自己注册到 Eureka中并保持心跳
【5】在 Eureka 之上有一层 Meta Sever 用于封装 Eureka 的服务发现接口;
【6】Client 通过域名访问 MetaServer 获取 Config Service 服务列表(IP+Port),而后直接通过 IP+Port访问服务,同时在 Client 侧会做 load balance、错误重试;
【7】Portal 通过域名访问 MetaServer 获取 Admin Server服务列表(IP+Port),而后直接通过 IP+Port访问服务,同时在Portal侧会做 load balance、错误重试;
【8】为了简化部署,我们实际上会把 Config Service、Eureka 和 Meta Server三个逻辑角色部署在同一个 JVM进程中;

八、核心概念


【1】application:这个很好理解,就是实际使用配置的应用,Apollo客户端在运行时需要知道当前应用是谁,从而获取对应的配置;关键字:appid
【2】environment(环境)配置对应的环境,Apollo客户端在运行时需要知道当前应用处于哪个环境,从而可以去获取应用的配置;关键字:env
【3】cluster(集群)一个应用下不同实例的分组,比如典型的可以按照数据中心分,把上海机房的应用实例分为一个集群,把北京机房的应用实例分为另一个实例;关键字:cluster
【4】namespace(命名空间):一个应用下不同配置的分组,可以简单地把 namespace类比为文件,不同类型的配置存放在不同的文件中,如数据库配置文件,PRC配置文件,应用自身的配置文件等。可以定义一个公共的namespace,其它namespace继承该公共配置即可。关键字:namespace

九、项目管理


【添加部门】:apollo 默认部门有两个。增加自己部门步骤:点击“管理员工具”-“系统参数


在  key中输入 organizations 查看已配置的部门,并根据该格式添加自己的部门;

【用户管理】 :apollo 默认提供一个超级管理员:apollo,我们也可以通过“管理员工具”-“用户管理”添加用户

例如我们创建一个 zzx用户

那么我们再创建项目的时候,就可以将项目与用户进行关联,如下:当使用该用户登录 Apollo时,只能看到分配给自己的项目。

也可以给新用户授权已有项目的权限,点击到需授权的项目:点击“授权

【删除项目】:如果要删除整个项目,点击右上角的 "管理员工具"– "删除应用、集群…"

十、配置管理-Namespace


添加 Namespace:Namespace作为配置的分类,可当成一个配置文件。以添加 RocketMQ配置为例,添加 “spring-rocketmq” Namespace配置 RocketMQ相关信息。

【1】添加项目私有Namespace:spring-rocketmq,进入项目首页,点击左下脚的 “添加Namespace”。

共包括两项:关联公共Namespace和创建 Namespace,这里选择“创建Namespace”,就会跳出如下界面,最后记得发布;

【2】添加配置项:当添加配置时,也可以直接将项目配置文件中的 key-value 键值对直接 cope到 Apollo中,最后记得发布;

【3】从项目中获取私有的 namespace,需要制定获取的 namespace名称;

 1 public class GetConfigTest {
 2     public static void main(String[] args) throws InterruptedException {
 3         //获取 Apollop 配置类
 4         //Config appConfig = ConfigService.getAppConfig();
 5         Config appConfig = ConfigService.getConfig("spring-rocketmq");
 6         //模拟 web 服务
 7         while(true){
 8             Thread.sleep(100L);
 9             //获取 Apollo 服务端我们添加的 sms.enable配置,并给一个默认值
10             String property = appConfig.getProperty("test", null);
11             System.out.println("sms.enable = " + property);
12         }
13     }
14 }

【4】创建公共的 namespace,我们创建一个公共的项目来配置公共的 namespace;


【5】如果想让 apollo-quickstart继承公共的 namespace,选择“关联公共Namespace

【6】我们查看 apollo-quickstart拥有的配置时,会发现 common_namespace 配置,我们可以覆盖公共配置中的属性,如果要修改公共配置就需要进入公共配置类中继续修改。

十一、配置管理-集群管理


集群管理:再有些情况下,应用有需求对不同的集群做不同的配置,比如部署在 A机房的应用连接的 RocketMQ服务器地址和部署在 B机房的应用连接的 RocketMQ服务器地址不一样。另外在项目开发过程中,也可以为不同的开发人员创建不同的集群来满足开发人员的自定义配置。

【1】点击 “添加集群

【2】输入新集群名称,选择环境并提交:添加上海金桥数据中心为例

【3】项目中就会多新增的集群环境,并且默认会有该项目私有的namespace,公共的配置也可以通过关联进行获取。

【4】通过同步进行集群之间的数据同步;在有数据的集群中点击同步,然后选择目标集群。如下:需点击“同步”按钮。回到目标集群中点击“发布”即可。

十二、配置发布原理


在配置中心中,一个重要的功能就是配置发布后实时推送到客户端。下面简要看一下这块时怎么设计实现的。

【1】用户在 Portal操作配置发布;
【2】Portal 调用 Admin Service的接口操作发布;
【3】Admin Service发布配置后,发送 ReleaseMessage给各个 Config Service
【4】Config Service收到 ReleaseMessage后,通知对应的客户端;

发送 ReleaseMessage:Admin Service在配置发布后,需要通知所有的 Config Service有配置发布,从而 Config Service可以通知对应的客户端来拉取最新的配置。从概念上来看,这是一个典型的消息使用场景,Admin Service作为 producer(生产者)发出消息,各个 Config Service作为consumer(消费者)消费消息。通过一个消息队列组件(Message Queue)就能很好的实现 Admin ServiceConfig Service的解耦。

在实现上,考虑到 Apollo的实际使用场景,以及为了尽可能减少外部依赖,我们没有采用外部的消息中间件,而是通过数据库实现了一个简单的消息队列。具体实现方式如下:
【1】Admin Service在配置发布后会往 ReleaseMessage表插入一条消息记录,消息内容就是配置发布的AppId+Cluster+Namespace

SELECT * FROM ApolloConfigDB.ReleaseMessage


消息发送类:DatabaseMessageSende

ReleaseMessage newMessage = releaseMessageRepository.save(new ReleaseMessage(message));

【2】Config Service 有一个线程会每秒扫描一次 ReleaseMessage表,看看是否有新的消息记录。消息扫描类:ReleaseMessageScanner

1 List<ReleaseMessage> releaseMessages =
2         releaseMessageRepository.findFirst500ByIdGreaterThanOrderByIdAsc(maxIdScanned);
3 //通知
4 fireMessageScanned(releaseMessages);

【3】Config Service 如果发现有新的消息记录,那么就会通知到所有的消息监听器。

 1 private void fireMessageScanned(List<ReleaseMessage> messages) {
 2     for (ReleaseMessage message : messages) {
 3       for (ReleaseMessageListener listener : listeners) {
 4         try {
 5           listener.handleMessage(message, Topics.APOLLO_RELEASE_TOPIC);
 6         } catch (Throwable ex) {
 7           Tracer.logError(ex);
 8           logger.error("Failed to invoke message listener {}", listener.getClass(), ex);
 9         }
10       }
11     }
12 }

然后调用消息监听类的 handleMessage方法:NotificationControllerV2

 1   @Override
 2   public void handleMessage(ReleaseMessage message, String channel) {
 3     logger.info("message received - channel: {}, message: {}", channel, message);
 4 
 5     String content = message.getMessage();
 6     Tracer.logEvent("Apollo.LongPoll.Messages", content);
 7     if (!Topics.APOLLO_RELEASE_TOPIC.equals(channel) || Strings.isNullOrEmpty(content)) {
 8       return;
 9     }
10 
11     String changedNamespace = retrieveNamespaceFromReleaseMessage.apply(content);
12 
13     if (Strings.isNullOrEmpty(changedNamespace)) {
14       logger.error("message format invalid - {}", content);
15       return;
16     }
17 
18     if (!deferredResults.containsKey(content)) {
19       return;
20     }
21 
22     //create a new list to avoid ConcurrentModificationException
23     List<DeferredResultWrapper> results = Lists.newArrayList(deferredResults.get(content));
24 
25     ApolloConfigNotification configNotification = new ApolloConfigNotification(changedNamespace, message.getId());
26     configNotification.addMessage(content, message.getId());
27 }

NotificationControllerV2 得到配置发布的 AppId+Cluster+Namespace后,会通知对应的客户端:

十三、Config Service通知客户端


上面简要描述了 NotificationControllerV2是如何得知有配置发布的,那 NotificationControllerV2在得知有配置发布后是如何通知到客户端的呢?实现方式如下:
【1】客户端会发起一个 Http请求到 Config Service的 notifications/v2接口NotificationControllerV2

 1 @RestController
 2 @RequestMapping("/notifications/v2")
 3 public class NotificationControllerV2 implements ReleaseMessageListener {
 4   private static final Logger logger = LoggerFactory.getLogger(NotificationControllerV2.class);
 5   private final Multimap<String, DeferredResultWrapper> deferredResults =
 6       Multimaps.synchronizedSetMultimap(TreeMultimap.create(String.CASE_INSENSITIVE_ORDER, Ordering.natural()));
 7   private static final Splitter STRING_SPLITTER =
 8       Splitter.on(ConfigConsts.CLUSTER_NAMESPACE_SEPARATOR).omitEmptyStrings();
 9   private static final Type notificationsTypeReference =
10       new TypeToken<List<ApolloConfigNotification>>() {
11       }.getType();

【2】客户端发送请求类:RemoteConfigLongPollService 链接

 1 private void doLongPollingRefresh(String appId, String cluster, String dataCenter) {
 2     final Random random = new Random();
 3     ServiceDTO lastServiceDto = null;
 4 //      循环执行,直到停止或线程中断
 5     while (!m_longPollingStopped.get() && !Thread.currentThread().isInterrupted()) {
 6       if (!m_longPollRateLimiter.tryAcquire(5, TimeUnit.SECONDS)) {
 7         //wait at most 5 seconds
 8         try {
 9           TimeUnit.SECONDS.sleep(5);
10         } catch (InterruptedException e) {
11         }
12       }
13       Transaction transaction = Tracer.newTransaction("Apollo.ConfigService", "pollNotification");
14       String url = null;
15       try {
16         if (lastServiceDto == null) {
17             //获取所有的ConfigServer地址
18           List<ServiceDTO> configServices = getConfigServices();
19           lastServiceDto = configServices.get(random.nextInt(configServices.size()));
20         }
21 //        组装url(notifications/v2)
22         url =
23             assembleLongPollRefreshUrl(lastServiceDto.getHomepageUrl(), appId, cluster, dataCenter,
24                 m_notifications);
25  
26         logger.debug("Long polling from {}", url);
27         HttpRequest request = new HttpRequest(url);
28         request.setReadTimeout(LONG_POLLING_READ_TIMEOUT);
29  
30         transaction.addData("Url", url);
31  
32         final HttpResponse<List<ApolloConfigNotification>> response =
33             m_httpUtil.doGet(request, m_responseType);
34  
35         logger.debug("Long polling response: {}, url: {}", response.getStatusCode(), url);
36         if (response.getStatusCode() == 200 && response.getBody() != null) {
37           updateNotifications(response.getBody());
38           updateRemoteNotifications(response.getBody());
39           transaction.addData("Result", response.getBody().toString());
40           //通知对应的RemoteConfigRepository
41           notify(lastServiceDto, response.getBody());
42         }
43  
44         //try to load balance
45         if (response.getStatusCode() == 304 && random.nextBoolean()) {
46           lastServiceDto = null;
47         }
48  
49         m_longPollFailSchedulePolicyInSecond.success();
50         transaction.addData("StatusCode", response.getStatusCode());
51         transaction.setStatus(Transaction.SUCCESS);
52       } catch (Throwable ex) {
53         lastServiceDto = null;
54         Tracer.logEvent("ApolloConfigException", ExceptionUtil.getDetailMessage(ex));
55         transaction.setStatus(ex);
56         long sleepTimeInSecond = m_longPollFailSchedulePolicyInSecond.fail();
57         logger.warn(
58             "Long polling failed, will retry in {} seconds. appId: {}, cluster: {}, namespaces: {}, long polling url: {}, reason: {}",
59             sleepTimeInSecond, appId, cluster, assembleNamespaces(), url, ExceptionUtil.getDetailMessage(ex));
60         try {
61           TimeUnit.SECONDS.sleep(sleepTimeInSecond);
62         } catch (InterruptedException ie) {
63           //ignore
64         }
65       } finally {
66         transaction.complete();
67       }
68     }
69   }

NotificationControllerV2 不会立即返回结果,而是把请求挂起。考虑到会有数万客户端向服务端发起长连,因此在服务端使用了async servlet(Spring DeferredResult)来服务Http Long Polling请求。如果在60秒内没有该客户端关心的配置发布,那么会返回 Http状态码 304给客户端。

如果有该客户端关心的配置发布,NotificationControllerV2 会调用 DeferredResult的 setResult方法,传入有配置变化的 namespace信息,同时该请求会立即返回。客户端从返回的结果中获取到配置变化的 namespace后,会立即请求 Config Service获取该 namespace的最新配置。

十四、客户端读取设计


除了之前介绍的客户端和服务端保持一个长连接,从而能第一时间获得配置更新的推送外,客户端还会定时从 Apollo配置中心服务端拉取应用的最新配置
【1】这是一个备用机制,为了防止推送机制失效导致配置不更新;
【2】客户端定时拉取会上报本地版本,所以一般情况下,对于定时拉取的操作,服务端都会返回304 - Not Modified;
【3】定时频率默认为每 5分钟拉取一次,客户端也可以通过在运行时指定 System Property: apollo.refreshInterval来覆盖,单位为分钟;

十五、Apollo应用于分布式系统


【1】客户端需要继承 Apollo 客户端的依赖

1 <dependency>
2     <groupId>com.ctrip.framework.apollo</groupId>
3     <artifactId>apollo-client</artifactId>
4     <version>1.1.0</version>
5 </dependency>

【2】在Spring Boot application.propertiesapplication.yml中配置

 1 #与配置Apollo配置页面中的 APPID保持一致
 2 app.id=account-service
 3 #开启apollo客户端
 4 apollo.bootstrap.enabled = true
 5 #配置 namespace,与配置Apollo配置页面中的namespace保持一致
 6 apollo.bootstrap.namespaces = application,micro_service.spring-boot-http,spring-rocketmq,micro_service.spring-boot-druid
 7 #配置 meta-server 客户端用来获取配置的地址,是 环境.meta 一般项目都会给不同环境创建不通的配置文件
 8 dev.meta=http://localhost:8080
 9 #集群和环境 跟正常项目一样配置
10 profile=dev

本地缓存路径:Apollo客户端会把从服务端获取到的配置在本地文件系统缓存一份,用于在遇到服务不可用,或网络不通的时候,依然能从本地恢复配置,不影响应用正常运行。本地配置文件会以下面的文件名格式放置于配置的本地缓存路径下:{appId}+{cluster}+{namespace}.properties 可以通过如下方式指定缓存路径,通过Java System Property 的 apollo.cacheDir:-Dapollo.cacheDir=/opt/data/apollo-config
【3】启用配置:在应用的启动类添加 @EnableApolloConfig注解即可:

1 @SpringBootApplication(scanBasePackages = “com.pbteach.account”)
2 @EnableApolloConfig
3 public class AccountApplication {

【4】读取配置信息:通过 @Value注解读取;

1 @Value("${sms.enable}")
2 private Boolean smsEnable;

十六、环境部署


生产环境部署:当一个项目要上线部署到生产环境时,项目的配置比如数据库连接RocketMQ地址等都会发生变化,这时候就需要通过 Apollo为生产环境添加自己的配置。
企业部署方案:在企业中常用的部署方案为:Apollo-adminserviceApollo-configservice两个服务分别在线上环境(pro),仿真环境(uat)和开发环境(dev)各部署一套,Apollo-portal 做为管理端只部署一套,统一管理上述三套环境。

修改端口时,除了修改脚本中的端口,还需要修改数据库中的Eureka信息:

UPDATE ServerConfig SET Value = “http://localhost:8081/eureka/” WHERE key = “eureka.service.url”;

【添加 pro环境】:【1】服务配置项统一存储在 ApolloPortalDB.ServerConfig表中,可以通过管理员工具 - 系统参数

在该页面配置输入:apollo.portal.envs 点击“查询”添加 pro (可支持的环境列表)

【2】启动 ApolloPortal:Apollo Portal需要在不同环境访问不同 meta service(apollo-configservice)地址,所以我们需要在配置中提供这些信息。记得关闭之前的 Apollo Portal。

-Ddev_meta=http://localhost:8080/ -Dpro_meta=http://localhost:8081/

【3】登录 Apollo Portal界面,进入项目,点击“补缺环境” 我们添加的 pro环境就出来了,也可以"补缺 namespace"如下:

【4】在配置文件中增加 pro.meta=http://localhost:8081 修改 服务的启动参数为:-Denv=pro

十七、灰度发布


在页面直接点击

推荐阅读