小Cの已经记不起来的博客

用 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 真的不够用了,再上也不迟。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放