Go 语言 配置中心 实战:从原理到落地
起因
最近手上的项目从单实例拆成了四个实例,配置文件也跟着从一份变成了四份。上周想临时调一下日志级别排查线上问题,结果是改四个 yaml、重新构建、挨个重启,折腾完小半个小时没了,问题早就凉透了。改到第三次的时候实在忍不了,决定花个周末把这事儿彻底解决。
现成的方案不少,Nacos、Apollo、Consul,个个都挺能打。Apollo 功能最全,但部署得带上 MySQL 和一堆管理端服务;Nacos 也得自己养一套存储。对我们这种小项目来说属实有点杀鸡用牛刀(也可能是我瞎怕麻烦吧...)。最后选了 etcd,原因很简单:我们本来就靠它做服务注册,顺手把配置也塞进去,一个组件两件事全干了。
原理:配置中心到底在干嘛
说白了就三件事:
- 集中存储:配置别再散落在各台机器上,统一放一个地方,带版本,改错了能翻旧账。
- 变更推送:配置改了之后,正在跑的进程要能感知到,不用重启。
- 容错:配置中心挂了,进程得能靠手里最后一份配置继续活着。
推送一般两种路子:轮询和长连接。轮询就是进程每隔几秒问一次“配置变了没”,实现简单但延迟高、还浪费;etcd 走的是基于 gRPC stream 的 watch,服务端一有变动立刻推过来,基本没有延迟。Apollo 用的 HTTP 长轮询算是个折中,客户端挂起请求三十秒,有变更就提前返回。我们这种规模,watch 足够了。
动手:先把配置读起来
etcd 的 Go 客户端就一个,go.etcd.io/etcd/client/v3,直接拉下来,拉不动的先看看 GOPROXY 配了没:
go env -w GOPROXY=https://goproxy.cn,direct
go get go.etcd.io/etcd/client/v3
连接、读一份配置,代码大概长这样,DialTimeout 记得设上,不然连不上会干等着卡死:
cli, err := clientv3.New(clientv3.Config{
Endpoints: []string{"127.0.0.1:2379"},
DialTimeout: 5 * time.Second,
})
if err != nil {
log.Fatalf("connect etcd failed: %v", err)
}
defer cli.Close()
resp, err := cli.Get(context.Background(), "/myapp/config")
if err != nil {
log.Fatalf("get config failed: %v", err)
}
if len(resp.Kvs) == 0 {
log.Fatal("config not found")
}
fmt.Println(string(resp.Kvs[0].Value))
不出问题的话就没有问题了,/myapp/config 里我放的是一段 JSON:
{
"log_level": "info",
"mysql_dsn": "user:pass@tcp(127.0.0.1:3306)/myapp",
"read_timeout": 3000
}
用 json.Unmarshal 解到结构体里就行,没什么好说的。
热更新:watch 才是灵魂
只读一次那跟本地文件没啥区别,配置中心的价值全在 watch 上。思路是起一个 goroutine 盯着 key,一有变动就重新解析,回调给业务方:
func WatchConfig(cli *clientv3.Client, key string, onUpdate func([]byte)) {
for {
rch := cli.Watch(context.Background(), key)
for wresp := range rch {
for _, ev := range wresp.Events {
if ev.Type == clientv3.EventTypePut {
onUpdate(ev.Kv.Value)
}
}
}
// 能走到这儿说明 watch 断了,歇两秒重连
time.Sleep(2 * time.Second)
}
}
业务方注册回调的时候别干重活,改日志级别这种几行代码的事儿就很合适:
WatchConfig(cli, "/myapp/config", func(val []byte) {
var cfg Config
if err := json.Unmarshal(val, &cfg); err != nil {
log.Printf("parse new config failed: %v", err)
return
}
level, _ := logrus.ParseLevel(cfg.LogLevel)
logrus.SetLevel(level)
logrus.Infof("config updated, log level: %s", cfg.LogLevel)
})
改完 etcdctl put /myapp/config '...',进程日志立刻就打出来了,那一刻还是挺爽的。
踩过的几个坑
watch 会收到历史事件。服务重启后如果从旧 revision 开始听,之前所有人改过配置的事件会给你重放一遍。解决办法是先 Get 拿到当前 revision,再带着 clientv3.WithRev(resp.Header.Revision + 1) 去 watch,只听新的。
新配置可能是坏的。运维手一抖把 JSON 写残了,解析失败还好,就怕解析成功但值离谱,比如超时时间填了个 0,请求全超。所以回调里必须做校验,不合法就打日志报警、保留旧配置,千万别让脏配置生效。
etcd 挂了怎么办。watch 断了进程还在跑,用的还是内存里那份旧配置,问题不大;但进程要是重启就连不上配置中心了。所以每次成功拉到配置就往本地文件写一份缓存,启动的时候优先连 etcd,连不上就读本地文件,保证服务能起来。
小结
整套东西拢共不到两百行代码,没依赖什么大家伙,但集中管理、热更新、容错三件事都齐了。当然它没有权限控制、没有灰度发布、也没有回滚界面,这些等真需要的时候再上 Nacos 也不迟。工具嘛,够用就好,没准儿等项目长大了,今天这套代码就是最好的练手货。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。