
说起金丝雀发布,我自己也是吃过亏的。之前在一家电商公司,有一次大促前夜做版本更新,直接全量切换,结果新版本有个缓存的 bug,商品价格显示异常,虽然只持续了十几分钟就回滚了,但那十几分钟的订单全部要人工核对,运营那边差点没把我们骂死。后来我们就开始研究灰度发布的方案,金丝雀发布就是其中最经典的一种。
这个名字听起来挺文艺的,其实来源很朴素。早年间煤矿工人下矿井之前,会带一只金丝雀进去,因为金丝雀对有毒气体特别敏感,如果金丝雀没事,说明矿井安全;如果金丝雀出了问题,矿工就赶紧撤。
放到软件发布的场景里,道理是一样的。你不是一次性把新版本推给所有用户,而是先放一小部分流量(比如 5%、10%)到新版本上,观察一段时间,看看有没有报错、性能有没有下降、用户反馈怎么样。如果一切正常,再逐步扩大流量比例,最终完成全量切换。如果新版本有问题,影响的也只是那一小部分用户,及时回滚就行了,损失可控。
跟全量发布比起来,金丝雀发布的好处很明显:
当然它也不是万能的,比如涉及到数据库结构变更的发布,单靠流量层面的灰度是不够的,还需要配合数据层面的兼容方案。不过大多数场景下,金丝雀发布已经够用了。
用 Nginx 做金丝雀发布,核心就是利用 Nginx 的反向代理和负载均衡能力,把流量按照一定的规则分发到不同的后端服务上。
简单来说就是你有两组后端服务:一组跑的是稳定的老版本(我们叫它 stable),一组跑的是待验证的新版本(我们叫它 canary)。Nginx 作为入口,根据你定义的规则,决定每个请求应该转发到哪一组。
规则可以有很多种玩法:
实际生产中,这几种方式经常组合使用。比如先让内部员工全部走新版本测试一轮,没问题了再按权重放 5% 的外部流量进来。
我这里用一个比较贴近真实场景的例子来演示。假设我们有一个 Web 应用,当前稳定版本跑在 8080 端口,新版本跑在 8081 端口。Nginx 监听 80 端口作为入口。
服务器环境:
先确认 Nginx 安装好了:
nginx -v
# nginx version: nginx/1.20.1如果没装的话:
# CentOS/Rocky
yum install -y nginx
# Ubuntu/Debian
apt install -y nginx为了演示方便,我用两个简单的后端来模拟。实际生产中这两个就是你的应用服务,可能是 Tomcat、Node.js、Go 服务之类的。
用 Python 快速起两个模拟服务:
# 终端1 - 模拟稳定版本(v1)
mkdir -p /tmp/stable && echo "This is STABLE version v1.0" > /tmp/stable/index.html
cd /tmp/stable && python3 -m http.server 8080
# 终端2 - 模拟金丝雀版本(v2)
mkdir -p /tmp/canary && echo "This is CANARY version v2.0" > /tmp/canary/index.html
cd /tmp/canary && python3 -m http.server 8081这是最简单也是最常用的方式。通过 Nginx 的 upstream 模块,给不同的后端设置不同的权重,实现按比例分流。
编辑 Nginx 配置文件:
# /etc/nginx/conf.d/canary.conf
upstream backend {
# 稳定版本,权重 9,大约承担 90% 的流量
server 127.0.0.1:8080 weight=9;
# 金丝雀版本,权重 1,大约承担 10% 的流量
server 127.0.0.1:8081 weight=1;
}
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}检查配置并重载:
nginx -t
nginx -s reload验证一下效果,多请求几次看看分布:
for i in $(seq 1 20); do curl -s http://localhost; done你会发现大概每 10 次请求里有 1 次会返回 canary 版本的内容,其余都是 stable 版本。
这种方式的好处是简单粗暴,配置改起来也方便。想调整比例的时候,改一下 weight 值,reload 就生效了。比如观察了一段时间没问题,想把金丝雀流量提到 30%:
upstream backend {
server 127.0.0.1:8080 weight=7;
server 127.0.0.1:8081 weight=3;
}不过这种方式有个问题,就是同一个用户可能一会儿访问到老版本,一会儿访问到新版本,体验不太一致。如果你的应用对这个比较敏感,就需要用下面的方案了。
这个方案可以保证同一个用户始终访问同一个版本,体验更一致。思路是通过 Cookie 来标记用户应该走哪个版本。
# /etc/nginx/conf.d/canary_cookie.conf
upstream stable {
server 127.0.0.1:8080;
}
upstream canary {
server 127.0.0.1:8081;
}
server {
listen 80;
server_name your-domain.com;
# 根据 cookie 判断走哪个版本
set $backend "stable";
if ($http_cookie ~* "canary=true") {
set $backend "canary";
}
location / {
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}这样配置之后,默认所有请求都走 stable 版本。只有当请求的 Cookie 里带了 canary=true 的时候,才会转发到 canary 版本。
怎么给用户种上这个 Cookie 呢?有几种方式:
1、手动在浏览器里加,适合内部测试:
打开浏览器开发者工具,在 Console 里执行:
document.cookie = "canary=true; path=/";2、通过后端接口设置,适合灰度平台对接:
后端提供一个接口,根据用户 ID 或者其他规则,决定是否给用户种上 canary Cookie。
3、通过 Nginx 自身来做随机分配并种 Cookie:
# /etc/nginx/conf.d/canary_split.conf
upstream stable {
server 127.0.0.1:8080;
}
upstream canary {
server 127.0.0.1:8081;
}
# 利用 split_clients 模块按比例分配
split_clients "${remote_addr}${remote_port}" $variant {
10% canary;
* stable;
}
server {
listen 80;
server_name your-domain.com;
# 如果已经有 cookie 了,就按 cookie 走
set $target $variant;
if ($http_cookie ~* "canary_version=canary") {
set $target "canary";
}
if ($http_cookie ~* "canary_version=stable") {
set $target "stable";
}
location / {
# 给用户种上 cookie,下次来就固定走同一个版本了
add_header Set-Cookie "canary_version=$target; path=/; max-age=3600";
proxy_pass http://$target;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}这个配置用了 split_clients 模块,它会根据你指定的变量(这里用的是客户端 IP 和端口的组合)做哈希,然后按比例分配。第一次访问的时候,10% 的用户会被分到 canary,同时种上一个 Cookie。后续再访问的时候,就直接按 Cookie 走了,不会再重新分配。
split_clients 这个模块是 Nginx 自带的,不需要额外安装,很多人不知道有这个东西,其实挺好用的。
这种方式在 API 网关场景下用得比较多。前端或者调用方在请求头里带上一个标记,Nginx 根据这个标记来决定路由。
# /etc/nginx/conf.d/canary_header.conf
upstream stable {
server 127.0.0.1:8080;
}
upstream canary {
server 127.0.0.1:8081;
}
server {
listen 80;
server_name your-domain.com;
set $backend "stable";
# 根据自定义请求头判断
if ($http_x_canary = "true") {
set $backend "canary";
}
# 也可以根据 User-Agent 判断,比如特定版本的 App
if ($http_user_agent ~* "MyApp/2.0") {
set $backend "canary";
}
location / {
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}测试的时候带上自定义 Header:
# 走金丝雀版本
curl -H "X-Canary: true" http://localhost
# 走稳定版本(不带 header)
curl http://localhost这种方式的好处是控制粒度很细,而且不依赖 Cookie,适合服务间调用的场景。我们之前做微服务灰度的时候就是用的这种方式,在网关层根据请求头做路由,Header 可以在整个调用链路上透传。
有时候你想让特定网段的用户(比如公司内网)先体验新版本,可以基于 IP 来做分流。
# /etc/nginx/conf.d/canary_ip.conf
upstream stable {
server 127.0.0.1:8080;
}
upstream canary {
server 127.0.0.1:8081;
}
# 定义一个 geo 变量,根据客户端 IP 来赋值
geo $canary_ip {
default 0;
192.168.1.0/24 1; # 内网测试网段
10.0.100.0/24 1; # 办公网络
# 也可以指定单个 IP
203.0.113.50 1;
}
server {
listen 80;
server_name your-domain.com;
set $backend "stable";
if ($canary_ip = "1") {
set $backend "canary";
}
location / {
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}geo 模块也是 Nginx 内置的,不需要额外安装。它可以根据客户端 IP 地址来设置变量的值,支持 CIDR 格式的网段,用起来很方便。
不过要注意一点,如果你的 Nginx 前面还有一层负载均衡(比如云厂商的 SLB),那拿到的 remote_addr 可能是 SLB 的 IP 而不是真实客户端 IP。这时候需要用 http_x_forwarded_for 或者配置 set_real_ip_from 来获取真实 IP:
# 信任前置负载均衡的 IP
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;实际生产中,我一般会把上面几种方式组合起来用。比如下面这个配置,优先级是:请求头 > Cookie > IP > 默认权重分配。
# /etc/nginx/conf.d/canary_production.conf
upstream stable {
server 127.0.0.1:8080;
}
upstream canary {
server 127.0.0.1:8081;
}
geo $canary_by_ip {
default 0;
192.168.1.0/24 1;
}
split_clients "${remote_addr}" $canary_by_weight {
5% 1;
* 0;
}
map $http_x_canary $canary_by_header {
"true" 1;
default 0;
}
map $cookie_canary_version $canary_by_cookie {
"canary" 1;
default 0;
}
# 综合判断:任意一个条件命中就走 canary
map "$canary_by_header:$canary_by_cookie:$canary_by_ip:$canary_by_weight" $target_backend {
"~1" canary; # 只要有一个是 1 就走 canary
default stable;
}
server {
listen 80;
server_name your-domain.com;
# 添加响应头,方便排查问题(生产环境可以去掉)
add_header X-Backend $target_backend;
location / {
proxy_pass http://$target_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 超时设置
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
# 健康检查接口(可选)
location /health {
access_log off;
return 200 "OK";
}
}这个配置里有个小技巧,就是用 map 指令做多条件的组合判断。"~1" 是正则匹配,只要拼接出来的字符串里包含 1,就走 canary。这样写比嵌套一堆 if 要优雅得多,而且 Nginx 官方也不推荐在 location 里大量使用 if,容易出一些奇怪的问题。
另外我加了一个 X-Backend 的响应头,这个在调试的时候特别有用,你可以通过浏览器开发者工具或者 curl 看到当前请求走的是哪个版本:
curl -I http://localhost
# 看响应头里的 X-Backend 字段生产环境上线之后记得把这个响应头去掉,或者改成只在内网可见,别把内部信息暴露出去了。
光有配置还不够,金丝雀发布是一个完整的流程,我把我们团队实际在用的流程分享一下。
发布前的准备工作:
发布过程:
第一步,部署新版本到 canary 节点,但先不接入流量:
# 先把 canary 的权重设为 0 或者注释掉
upstream backend {
server 127.0.0.1:8080 weight=10;
server 127.0.0.1:8081 down; # 先标记为 down
}第二步,部署完成后,先让内部人员通过 Header 或 Cookie 的方式访问 canary 版本,做一轮冒烟测试。
第三步,冒烟测试通过后,开放小比例流量(比如 5%):
# 修改配置,开放 5% 流量
split_clients "${remote_addr}" $canary_by_weight {
5% 1;
* 0;
}nginx -t && nginx -s reload第四步,观察监控指标,重点关注:
第五步,如果一切正常,逐步扩大流量比例:5% → 10% → 30% → 50% → 100%。每次调整后至少观察 15-30 分钟。
如果发现问题需要回滚,操作也很简单:
# 方式一:把 canary 标记为 down
upstream backend {
server 127.0.0.1:8080 weight=10;
server 127.0.0.1:8081 down;
}
# 方式二:直接把 canary 的权重设为 0
split_clients "${remote_addr}" $canary_by_weight {
0% 1;
* 0;
}nginx -t && nginx -s reloadreload 是平滑重载,不会中断现有连接,所以不用担心回滚操作本身会造成影响。
还有几个容易踩坑的地方说一下:
1、Session 一致性问题。如果你的应用是有状态的(比如 Session 存在本地),用户从 stable 切到 canary 的时候可能会丢失登录状态。解决办法是把 Session 放到 Redis 之类的外部存储里,或者用 JWT 这种无状态的认证方式。
2、数据库兼容性。如果新版本涉及数据库表结构变更,一定要确保新老版本都能兼容当前的数据库结构。一般的做法是先做数据库变更(要向后兼容),再发布新版本代码。
3、静态资源的问题。如果前端静态资源也有变更,要注意 CDN 缓存的影响。建议静态资源用带 hash 的文件名,避免缓存导致新老版本的资源混用。
4、WebSocket 连接。如果你的应用用了 WebSocket,金丝雀发布的时候要注意长连接的处理。Nginx reload 不会断开已有的 WebSocket 连接,但新连接会按新的规则分配。
做金丝雀发布,监控是必不可少的。这里给一个简单的 Nginx 日志配置,方便你区分不同版本的请求:
# 自定义日志格式,加上 backend 标记
log_format canary_log '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'backend=$target_backend rt=$request_time';
server {
# ...
access_log /var/log/nginx/canary_access.log canary_log;
}这样在日志里就能看到每个请求走的是哪个后端,配合 ELK 或者 Grafana + Loki 之类的日志分析工具,可以很方便地对比两个版本的各项指标。
用 awk 简单统计一下各版本的请求量和错误率:
# 统计各版本请求量
awk '{for(i=1;i<=NF;i++) if($i ~ /^backend=/) print $i}' /var/log/nginx/canary_access.log | sort | uniq -c
# 统计各版本的 5xx 错误
awk '$9 >= 500 {for(i=1;i<=NF;i++) if($i ~ /^backend=/) print $i}' /var/log/nginx/canary_access.log | sort | uniq -c金丝雀发布说白了就是一个"小心试探"的过程,核心思想就是用最小的代价去验证新版本的稳定性。Nginx 作为实现工具,配置灵活,性能也好,基本上能覆盖大多数场景。
当然,如果你的业务规模比较大,服务数量比较多,可能需要更专业的灰度发布平台,比如结合 Kubernetes 的 Istio、或者用 OpenResty 做更复杂的流量治理。但对于大多数中小团队来说,Nginx 原生的这些能力已经足够了,不需要引入太多额外的复杂度。
我个人的建议是,不管你用什么方案,关键是要把发布流程规范化,每次发布都按照固定的步骤来,观察该观察的指标,该回滚的时候果断回滚。工具只是手段,流程和意识才是核心。
如果这篇文章对你有帮助,欢迎转发给身边的运维和开发同学,大家一起少踩坑。也欢迎关注我,后续会继续分享更多生产实践相关的内容。