为什么你需要灰度发布
个人站长改版站点,最常见的做法是——本地调好,直接传到服务器覆盖,然后听天由命。结果经常是这样:上线十分钟后收到一封邮件说某个页面 502,或者某个功能在手机上完全点不动,而这时候全量用户已经都看到了问题版本。你想回滚,发现自己没备份,或者备份是三天前的,一滚就把这三天的内容更新全丢了。
灰度发布(Gray Release / Canary Release)解决的就是这个问题:让新版本先只对一小部分流量生效,观察一段时间,确认没问题再逐步放大到全量;一旦发现异常,把流量切回老版本,影响面控制在那一小部分用户里。
很多人以为灰度发布是大厂的专利,需要 Kubernetes、Service Mesh、完整的 CI/CD 平台。其实不是。Nginx 本身就能做一套相当完整的灰度方案,不需要引入任何额外组件,配置加起来也就几十行。这篇文章讲清楚四种实用的切分方式,以及怎么组合使用。
方式一:按比例切分——split_clients
最基础的灰度形式是「新版本承接 10% 的流量」。Nginx 的 split_clients 模块专门干这个,它基于请求的一个特征值(通常是客户端 IP 或者 Cookie)做哈希,然后按百分比分配。关键在于同一个用户每次都会落到同一个版本——不会出现「刷新一下变成另一个版本」的诡异体验。
# 按客户端 IP 哈希,10% 的访问走新版本
split_clients "${remote_addr}AAA" $backend_pool {
10% "new_backend";
* "old_backend";
}
upstream old_backend {
server 127.0.0.1:8080;
}
upstream new_backend {
server 127.0.0.1:8081;
}
server {
listen 443 ssl http2;
server_name www.example.com;
location / {
proxy_pass http://$backend_pool;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
}这里有几个细节值得展开。
"${remote_addr}AAA" 里的 AAA 不是装饰。它是有意加的盐(salt)。split_clients 对输入字符串做 MurmurHash,如果直接用 $remote_addr,那么同一台机器在不同 Nginx 配置、不同站点上会落到完全相同的分桶——本来只想让 10% 的用户看到 A 站的新版,结果 B 站的那 10% 恰好是同一批人,测试样本被污染。加盐能让不同业务的分配互相独立。你想让多个灰度实验互不干扰,就换不同的盐。
哈希源的选择很关键。用 $remote_addr 的问题在于,同一个用户可能在不同网络下访问,IP 变了就会被分到另一个版本,登录态跳来跳去。更稳的做法是用一个持久化的 Cookie:
# 首次访问时种一个灰度分组 Cookie,后续一直沿用
map $cookie_gray_group $gray_key {
"" $request_id; # 没有 Cookie 时用请求 ID 兜底
default $cookie_gray_group;
}
split_clients "${gray_key}ZZZ" $backend_pool {
10% "new_backend";
* "old_backend";
}配合后端在响应里种下 gray_group 这个 Cookie,用户的分组就稳定下来了。
百分比调整要注意生效时机。改完 split_clients 里的百分比之后,nginx -s reload 就能生效,不需要重启。但是哈希结果会整体重排——原来是 10% 的用户在新版,改成 20% 之后,那 20% 并不包含原来的 10%,而是重新算的。这意味着部分用户会「从新版退回旧版」。对大多数场景无所谓,但如果新版本已经产生了用户数据(比如新版的表单提交),用户退回去可能看到不一致的状态。需要平滑放大时,正确做法是保留旧分组、只增加新分组。
方式二:按用户特征切分——map 精确控制
比例切分适合大流量场景,个人站点的流量往往不够,10% 可能一天就几十个 IP,根本看不出问题。更实用的是按具体特征点名测试:让固定的一批人(你自己、几个信得过的老用户、或者特定地区)先看到新版本。
# 白名单式灰度:这些 IP 走新版本
geo $gray_user {
default 0;
203.0.113.10 1; # 你自己
203.0.113.20 1; # 内测用户 A
198.51.100.0/24 1; # 整个办公网段
}
map $gray_user $backend_pool {
1 "new_backend";
0 "old_backend";
}
server {
location / {
proxy_pass http://$backend_pool;
}
}geo 模块的好处是支持 CIDR 网段,写起来比一堆 if 清爽得多,而且匹配是经过优化的,几十上百条规则也不影响性能。
也可以按 Cookie 或者请求头来点名,这在做前端 A/B 测试时特别方便——测试同学在浏览器里手动种个 Cookie 就能切版本,不用改服务器配置:
map $cookie_beta_test $backend_pool {
"enabled" "new_backend";
default "old_backend";
}
# 或者按自定义请求头(配合浏览器插件使用)
map $http_x_beta_test $backend_pool {
"1" "new_backend";
default "old_backend";
}这种方式的额外好处是可以用来做真正的 A/B 测试:让一半用户看到红色按钮、一半看到蓝色按钮,然后对比转化率。原理一样,只是切分维度从「新旧版本」变成「方案 A / 方案 B」。
方式三:按 upstream 权重滚动——weight 参数
如果新旧版本其实是同一套代码的两个实例(比如只是配置不同、或者数据库连接池参数不同),用 upstream 权重更直接:
upstream app_pool {
server 127.0.0.1:8080 weight=9; # 老版本占 90%
server 127.0.0.1:8081 weight=1; # 新版本占 10%
}但一定要知道这个词的边界:weight 是按请求数分配的,不是按用户分配的。同一个用户刷新十次,大概率七次看到老版、三次看到新版。这在有状态的 Web 应用里是灾难——用户前一个请求存在旧版的 session 里,下一个请求打到新版,session 读不到,直接被踢回登录页。
所以 weight 只适合无状态的服务:纯静态资源、无 session 的 API、只读接口。要做有状态的灰度,必须用前面基于 IP/Cookie 哈希的方式,保证同一用户黏在同一个后端。这个区别是灰度配置里最常见的事故来源。
另外,如果是靠 weight 来做版本切换,回滚就是改权重再 reload,非常快。但要注意在途请求——reload 时 Nginx 会优雅关闭旧 worker,正在处理的请求会跑完,不会中断。这是 Nginx 相比直接重启服务的核心优势。
方式四:Nginx Plus 之外的免费方案也能做「自动灰度」
Nginx 开源版没有原生的健康检查主动探测(health_check 是 Plus 功能),但可以通过 max_fails 和 fail_timeout 实现被动熔断,这对灰度很关键:
upstream new_backend {
server 127.0.0.1:8081 max_fails=3 fail_timeout=30s;
}
upstream old_backend {
server 127.0.0.1:8080;
}max_fails=3 fail_timeout=30s 的含义是:30 秒内如果有 3 次请求失败,这台后端会被标记为不可用,接下来的 30 秒内不再分配请求给它。在灰度场景下,如果新版本的服务挂掉,流量会自动全部落回老版本,用户感知到的只是稍微慢了一点点,而不是看到错误页。
配合上前面 split_clients 的 10%,效果是「10% 流量打新版本,新版本一旦连续失败就被临时踢出,30 秒后重试」。这已经是一个能用的自动降级机制了。
如果想要更主动的探测,可以用 nginx_upstream_check_module(淘宝开源的第三方模块),它给开源版 Nginx 加上了主动健康检查。但需要重新编译 Nginx,对个人站长来说成本略高,非必需。
把灰度做完整:几个容易被忽略的配套动作
一是日志必须能区分版本。否则灰度期间出了问题,你无法判断是新版还是旧版引起的。最省事的做法是在后端响应头里打出标记,或者在 Nginx 日志格式里加上 $backend_pool:
log_format gray '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'pool=$backend_pool upstream=$upstream_addr';
access_log /var/log/nginx/access.log gray;这样事后一条 grep "pool=new_backend" access.log | grep " 500 " 就能立刻知道新版本是不是在报错。没有这个标记,灰度等于盲测——你只能凭感觉判断,而那通常不靠谱。
二是静态资源要跟着版本走。前端文件(CSS、JS)如果新旧版本共用同一批 URL,灰度用户在切回旧版时会拿到新版的缓存文件,页面直接崩。解决办法是给静态资源的 URL 加上版本号或者内容哈希(app.20260916.js),配合正确的 Cache-Control 长时间缓存。这样新旧两版各用各的文件,互不干扰。
三是要有一个明确的「放量节奏」。比较稳妥的节奏是:
1% → 观察 30 分钟 → 5% → 观察 1 小时 → 20% → 观察半天
→ 50% → 观察一天 → 100%每一步的观察指标至少要看三个:错误率(5xx 占比)、响应时间(TTFB 或者 P95)、以及核心业务指标(登录成功率、下单转化率之类,取决于你的站点做什么)。只看错误率是不够的——很多问题不会直接报 500,而是表现为「能打开但慢了三倍」,这种只有对比两组的响应时间才发现得了。
四是准备好一键回滚。灰度的价值全在回滚够快。回滚本身很简单(把 split_clients 的百分比改成 0,或者把 map 全部指向旧版,然后 reload),但要保证回滚不需要改代码、不需要重新部署。如果你的回滚流程是「SSH 上去改配置 → reload」,那你就得确保这个操作在压力下也不会出错——最好写成脚本:
#!/bin/bash
# rollback.sh —— 一键把灰度流量全部切回旧版
set -e
CONF=/etc/nginx/conf.d/gray.conf
cp $CONF ${CONF}.bak.$(date +%Y%m%d%H%M%S)
# 把百分比改成 0
sed -i 's/^\(\s*\)[0-9]\+%\(\s*\)"new_backend"/\10%\2"new_backend"/' $CONF
nginx -t && nginx -s reload
echo "已回滚到全量旧版本: $(date)"脚本里 nginx -t 这一步不能省。灰度期间最忌讳的就是为了抢时间跳过配置检查直接 reload,一旦配置有语法错误,Nginx 拒绝加载,你会同时失去回滚能力和新版能力,比不回滚更糟。
小结
Nginx 的灰度方案,本质是把「路由决策」从应用层挪到了接入层。它不需要额外组件,配置直观,reload 不中断连接,回滚几乎是瞬时的。对个人站长来说,这套东西的价值不在于「技术上多先进」,而在于它把一次改版的风险从「全站」缩小到了「百分之几」。
如果你只想做一件事,那就是:给关键的灰度配置加上日志标记。没有标记的灰度不叫灰度,叫「凭运气上线」。有了标记,哪怕只是最朴素的 split_clients 10%,你也能在出问题的第一时间定位到原因并切回去。