用 Docker 搭建灰度发布的完整流程
之前有次发版比较头铁,容器一把全量替换,结果新代码里一个慢查询把数据库拖垮了,接口响应从几十毫秒飙到七八秒,手忙脚乱回滚折腾了半个多小时。从那以后我就把灰度发布这套流程搭起来了。说实话这玩意儿没什么高深的,就是两个容器加一个 Nginx,但它确实把发版的心理压力降下来不少——最坏情况也就是一小部分用户受影响,回滚就是改两行配置的事。
整套东西只需要 docker-compose 和一个 nginx.conf,下面是我现在在用的完整流程。演示用的服务是我自己写的一个极简 node 脚本,响应的 JSON 里带个 version 字段,方便看请求到底落到了哪个版本,实际用的时候换成你自己的镜像就行。
整体思路
新旧两个版本的容器同时跑着,前面用 Nginx 按比例分流,新版本先吃 10% 的流量,观察一段时间没问题就放大到 30%、50%,最后全量、下掉旧容器。业务不中断,回滚成本几乎为零。
用 docker-compose 把新旧版本一起跑起来
services:
app-v1:
image: registry.example.com/myapp:1.2.3
restart: unless-stopped
expose:
- "3000"
app-v2:
image: registry.example.com/myapp:1.3.0
restart: unless-stopped
expose:
- "3000"
nginx:
image: nginx:stable-alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app-v1
- app-v2
两个应用只 expose 不对外映射端口,只有 Nginx 开了 80,这样灰度期间没人能绕过网关直接摸到后端。registry.example.com 换成你自己的仓库地址;如果服务器在国内 pull 很慢,先配个镜像加速,不然卡在这步能把人急死。
Nginx 按权重分流
upstream backend {
server app-v1:3000 weight=9; # <--- 旧版本,占 90%
server app-v2:3000 weight=1; # <--- 新版本,先放 10% 的量
}
server {
listen 80;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
weight 就是轮询比例,9:1 大概就是十分之一的请求落到新版本上。改完配置不用重启容器,执行 docker compose exec nginx nginx -s reload 就行,reload 是热加载,已有连接不受影响,比 restart 优雅多了。之后多请求几次接口,不出问题的话就能看到部分响应里已经是新版本号了。
让同一个用户固定在同一个版本上
按权重轮询有个坑:同一个用户连续刷新几下,可能一会儿 v1 一会儿 v2,如果两个版本接口结构有差异,前端就会出现各种玄学问题。用 split_clients 按 IP 做一致性分流可以解决:
upstream stable { server app-v1:3000; }
upstream beta { server app-v2:3000; }
split_clients "${remote_addr}" $backend {
10% beta;
* stable;
}
server {
listen 80;
location / {
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
这样同一个 IP 永远落到同一个版本,比例也基本按配置来。如果登录用户有唯一标识,把 ${remote_addr} 换成按 cookie 分流会更准,毕竟好几个人共享一个出口 IP 的场景太常见了。
按名单放人进来(可选)
还有一种常见需求是不看比例,让指定的人先体验。加一个 map 判断 cookie 就行(upstream 那两行保留,替换掉分流方式即可):
map $cookie_beta $backend {
"on" beta;
default stable;
}
用户在控制台敲一句 document.cookie = "beta=on" 就切到新版本,体验完把 cookie 删了就回去了。内测群里发句说明,比解释半天灰度原理省事。
完整的发版流程走一遍
# 1. 拉新镜像
docker compose pull app-v2
# 2. 把新版本跑起来,此时它还没接到任何流量
docker compose up -d app-v2
# 3. 从 nginx 容器里探一下,确认新版本活着
docker compose exec nginx wget -qO- http://app-v2:3000/healthz
# 4. nginx.conf 给 v2 加上 weight=1,reload,开始吃 10% 流量
docker compose exec nginx nginx -s reload
# 5. 观察没问题就逐步放大:weight 改成 3、5、9,每次改完 reload
# 6. 全量:把 v1 那行删掉或注释掉,再 reload
docker compose exec nginx nginx -s reload
# 7. 确认没问题,下掉旧容器
docker compose stop app-v1 && docker compose rm -f app-v1
观察阶段主要看错误率和响应时间,建议日志里把版本号打出来,不然两个容器混在一起报错根本分不清是谁的锅。另外别只看平均值,10% 流量里的问题很容易被整体平均数稀释掉,最好按版本分开统计。
出了问题怎么回滚
回滚就是反向操作:把 v2 那行从 upstream 里删掉,reload,十秒钟的事,打到新版本的用户下次请求自动回到旧版本。这也是灰度最大的价值——以前全量出问题,回滚是一场事故;现在回滚就是改两行配置。
不过有一种情况配置回滚救不了,就是下面这些坑。
几个踩过的坑
数据库迁移别跟着容器启动自动跑。 我第一次灰度就栽在这:新版本镜像的启动脚本里带了 migrate,容器一启动把某张表的字段改了名,旁边还在服务的旧版本当场开始报 500。之后我的规矩是灰度期间迁移只做加法(加表、加字段),改类型、删字段这类操作一律等全量稳定之后手动执行。
Session 别存容器本地。 存内存里的话,被分到不同版本的用户会互相踢登录态,表现为反复掉线。要么把 session 挪到 Redis,要么用上面的 split_clients 把用户钉死在一个版本上。
upstream 加上失败摘除。 写成 server app-v2:3000 weight=1 max_fails=3 fail_timeout=10s;,新版本真崩了 Nginx 会暂时把它摘掉,不至于让那 10% 的用户一直撞 500。
遇到 502 先 reload 一下。 Nginx 是在加载配置时解析 upstream 域名的,中途容器重建导致 IP 变了就可能 502,reload 一次让它重新解析就好。
这套东西技术上真没什么难度,但它带来的变化很实际:我现在敢在白天发版了,因为清楚地知道最坏情况波及多少人、多少秒能退回来。至于 K8s 那套更完整的方案,等哪天觉得两个容器加一个 Nginx 真的不够用了,再上也不迟。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。