分享一种基于 git 和 CI/CD 的集中化配置管理服务。这种方案最大的好处就是,简单直接,可以快速先把配置管理的坑儿占好。
功能点
首先,我们先整理一下集中化配置管理的主要 feature:
可以记录、审核配置的修改
支持多种环境(生产、测试、开发、演示等等)
修改配置之后,应用的配置能够及时得到更新
主要思路
我们的主要思路是:将配置服务直接写成一个独立的 webserver,webserver 对外提供 http 接口,配置直接写在 webserver 的代码当中,每次提交代码时通过 CI/CD 自动发布。
这样做的好处是:
可以直接通过 git 来记录、审核配置数据的修改,每次有人要修改配置时,直接提 PR,leader review 通过之后合并到 master 分支
代码合并到 master 分支之后,通过 CI/CD 自动发布到线上
可能大家会有下面的一些顾虑:
不应该直接在代码当中硬编码 MySQL 的账号、密码之类的敏感数据,这样是不安全的
简单通过 http 接口来读取配置,效率不高
针对第一个问题,我们算是使用了点 “反模式” 吧。代码肯定是要确保在私有代码库当中的,你需要授权才能够访问代码库,从这个角度来说,直接在代码里面写配置数据其实也不是大问题,特别是在产品研发初期,这个时候团队规模也不大。而且,像 gitlab、github 这类的服务,本身就有很好的权限管理机制,加上 git 本身就是版本管理工具,为什么不充分使用一下呢。
第二个问题呢,其实和第一个一样:初期,服务压力较小,配置数据不复杂,通过 http 接口来读取配置,性能其实没有大问题。
这种方案的意义就在于把这个配置管理的坑儿先占上,确保各个服务是通过统一的接口来读取配置的,日后可以慢慢优化。 实践发现,随着产品迭代,这种方案能够持续的时间还是挺长的,投入成本还很小。
主要功能设计和实现
我们自己的项目使用 Node.js 开发的,所以下面以 Node.js 为例,来说一下具体设计。
首先,说一下 webserver 的接口设计,接口要尽可能简化,我们只提供了一个接口:
GET /api/profiles/:profile HTTP/1.1 |
profile 参数表示你想要的环境,比如:
你想要测试环境的配置,应该发送 GET /api/profiles/dev
如果想要同事 Jack 的本地开发环境配置,你应该发送 GET /api/profiles/jack-local-dev
返回的数据自然应该是 json 数据,比如像下面这种:
{ "revision": "5d41402abc4b2a76b9719d911017c592", "config": { "debug": true, "wechat": { "appId": "xxxx", "secret": "xxxxx" }, "mysql": { "host": "localhost", "port": 3306 } } } |
revision 表示配置的版本,config 就是实际的配置数据啦。
根据上面的设计,我们的 webserver 服务的代码库大概是下面这样的:
├── Dockerfile ├── README.md ├── app.js └── config ├── dev.yml ├── prod.yml └── jack-local-dev.yml |
其中:
有一个 app.js ,里面封装了 http 接口
有一个 config 文件夹,里面放置不同环境的配置文件。我们推荐使用 .yml 文件,.yml 文件写起配置其实更清爽,当然 json 也可以
再有一个 Dockerfile 用于配置镜像打包和自动发布
实现这样一个接口,app.js 的代码也比较简单,大概就像下面这样:
const fs = require('fs'); const yaml = require('js-yaml'); const hash = require('object-hash'); const express = require('express'); const app = express(); app.get('/api/profiles/:profile', (req, res) => { const path = `${__dirname}/config/${req.params.profile}.yml` fs.readFile(path, { "ecoding": "utf-8" }, (e, content) => { if (e) { return res.status(500).json({ errorId: 'internal-server-error', errorMsg: e.message }); } const config = yaml.safeLoad(content); const revision = hash(config); res.json({ config, revision }); }); }); app.get('/ping', (req, res) => res.send('pong')); const PORT = 8080; app.listen(PORT, () => { console.log('listening on port', PORT); }); |
当然你也可以在上面加一些性能上的优化哈,特别是加载 yaml 文件的部分。除了读取 yaml 配置文件的内容外,里面还通过 object-hash 来计算了配置的 revision,方便客户端来检查配置数据的版本更新。
提供统一的客户端 library
主体设计和实现就是上面说的这些内容了。不过,还有一项工作很重要,就是提供统一的客户端 library。当大家使用同样的客户端 library 来读取配置的时候,配置管理的坑儿才能算真正占好,后面才方便替换配置管理服务的技术方案。
library 设计
首先说一下这个 library 的接口设计吧
config.get(path) |
提供一个 get 方法,注意:
参数里面应该是一个 path,准确的说应该是一个 property path
这个方法应该是 同步执行 的,所以下面我提供了一个 sync 方法,专门用来同步配置数据
假设完整的配置数据是这样的:
{ "mysql": { "host": "111.111.11.11", "port": 3306, "username": "root", "password": "123456" }, "redis": { "host": "111.111.11.12", "port": 6379 }, "wechat": { "appId": "wx888888888" }, "secret": "foobar" } |
那么通过 get 方法应该能够做到下面这些事情:
config.get("mysql") // => {"host": "111.111.11.11", "port": 3306, ...} config.get("wechat.appId") // => "wx888888888" config.get() // => {"mysql": {...}, "wechat": {...}, ...} |
也就是说,大家可以通过 get 方法灵活的获取到配置数据的某一部分。这块我们使用了 object-path 这个模块。
config.sync(host, profile, token) |
提供一个 sync 方法,用来初始话和轮训同步配置数据
config.on(event, listener) |
应该提供事件回调接口,用来检测是否有数据发生变化,这个接口在 Node.js 服务中有一定用处,其他的同步的技术框架应该就不需要了。
config.mock(object) |
最后,应该有一个 mock 方法,方便支持自动化测试
一些补充内容
这里想补充说明的是,关于 sync 方法的一些小问题。上面说到 get 方法应该是一个同步方法,毕竟如果读取配置信息也要异步的话,那对工程的来说复杂度反而增加了。
所以我多设计了一个 sync 方法。在 Node.js 项目中,应用启动之前,应该先调用 sync 方法,轮训同步配置数据。这样保证 get 方法被调用的时候,始终是能够返回数据的。
还有一点就是,sync 方法被调用的时候,应该先发一个 同步的 http 方法来获取数据,这块我们使用了 sync-request 来实现。
最后,补充一下主要的实现代码,供大家参考:
const EventEmitter = require('events').EventEmitter; const objectPath = require('object-path'); class Config { constructor(interval) { this.interval = interval || 5000; this.emitter = new EventEmitter(); } sync(host, profile, token) { this.host = host; this.profile = profile; this.token = token; this.data = loadConfigSync(); // 首先同步获取配置数据 setTimeOut(() => this.watch(), this.interval); // 之后,定时轮训数据 } get(path) { return objectPath.get(this.data.config, path); } loadConfigSync() { // 这部分代码就先省略了~ } async loadConfigAsync() { // 这部分代码就先省略了~ } async watch() { const result = await this.loadConfigAsync(); if (result.revision !== this.data.revision) { this.data = result; this.emitter.emit('update', this.data.config); } setTimeout(() => this.watch(), this.interval); } } module.exports = new Config(); 使用客户端 library 的一般套路: // server.js const config = require('config-module-name'); // 1. 调用 sync 方法加载配置 config.sync(process.env.CONFIG_HOST, process.env.CONFIG_PROFILE, process.env.CONFIG_TOKEN); // 2. 启动实际项目的 WebServer const server = new WebServer(); server.serve(); |
增加配置覆盖功能
上面的 webserver 设计还是简单了一些,因为平时我们配置服务的时候,经常会有一系列通用的配置,而每个环境里面可能各有一些少量特殊的配置。
为了解决这个问题,我们在前面的方案基础之上,开发了一个简单的配置覆盖功能。我们是这么做的:
在 config 文件夹当中提供一个 defaul.yml 配置文件,在这个文件当中去保存通用的配置数据
假设,现在要访问 dev 环境的配置,webserver 就把 dev.yml 和 default.yml 配置文件都读取出来,将 dev.yml 和 default.yml 重合的部分 merge 到一起,这块我们使用的一个叫做 deepmerge 的模块来实现的
现在举一个实际的例子,假设生产(prod)和开发环境(dev)就数据库的名称不同,没有增加配置覆盖功能之前,配置文件是这样的:
# prod.yml mysql: host: localhost port: 3306 username: root password: root database: prod # dev.yml mysql: host: localhost port: 3306 username: root password: root database: de |
增加了配置覆盖的功能之后,配置文件变成了下面这个样子:
# default.yml mysql: host: localhost port: 3306 usrename: root password: root # prod.yml mysql: database: prod # dev.yml mysql: database: dev |
在实际的项目当中,增加配置覆盖的一个最大好处是,有新的同事加入项目时,他需要增加的配置内容就会少很多,而不需要全量的 copy 一份别人的配置文件,主体的配置都可以放到 default.yml 文件中。
安全问题
这个方案现在还有一些明显的安全问题:
接口访问没有增加鉴权
有些数据就是不希望写到代码当中去,该怎么办
关于接口鉴权,我们的解决方案是提供一个 token 列表,token 是常量的 UUID 或者随机字符串即可。另外强制要求使用 https 来访问接口,不要直接在前端读取配置。
如果有些数据就是不希望写到代码当中去,改怎么办?
我们建议增加一个环境变量注入的 feature,比如配置文件改写成这样:
mysql: password: ${MYSQL_PASSWORD} |
接口在返回数据之前,增加一道工序,将上面的 ${MYSQL_PASSWORD} 这类的表达式解析出来,然后将环境变量注入进去。我们目前是使用正则表达式简单粗暴的处理的,大概就是这样:
const traverse = require('traverse'); const delimeter = /\$\{(.+?)\}/g; function enjectEnv(config) { return traverse(config).map(value => { value.replace(delimeter, (match, p1) => { return process.env[p1] || ""; }) }) } |
通过这种方式,你就可以通过环境变量去配置一些敏感信息了。
总结
总结一下,这样一个方案,主要的工作:
基于 git 和 CI/CD 搭建配置服务
提供统一的客户端 library
扩展功能,增加配置覆盖机制
提供简单的接口鉴权和环境变量注入
这样一个方案,其实在产品初期阶段应该足够好用了。这种方案的好处就是快速占坑,将配置管理机制固化下来。整套方案充分使用了 git 和 CI/CD,整个服务也很轻,推荐大家尝试一下~
上文内容不用于商业目的,如涉及知识产权问题,请权利人联系博为峰小编(021-64471599-8017),我们将立即处理。