基于 git 和 CI/CD 的集中化配置管理服务

分享一种基于 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,整个服务也很轻,推荐大家尝试一下~

标签:GIT 发布于:2019-11-10 03:55:31