Nginx 灰度发布实战:流量切分、A/B 测试与平滑上线完整指南

为什么你需要灰度发布

个人站长改版站点,最常见的做法是——本地调好,直接传到服务器覆盖,然后听天由命。结果经常是这样:上线十分钟后收到一封邮件说某个页面 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_failsfail_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%,你也能在出问题的第一时间定位到原因并切回去。

Last modification:September 16th, 2026 at 12:23 pm

Leave a Comment