小站的灰度发布:用Nginx分流控制上线风险

一个人维护站点,最怕的不是服务器挂了,是"改了一下全站打不开"。尤其是那种深夜爬起来改 Nginx 配置、改完直接 reload、刷一下浏览器发现 502 的场面。大公司靠蓝绿部署、金丝雀发布解决这个问题,听起来门槛很高,但拆开看,核心思路简单得惊人:别一次性把新东西给所有人,先给一小部分,看着没事再扩大。小站完全能落地,而且不需要任何额外的机器。

这篇文章讲我在几个个人站上做灰度发布的具体做法——用 Nginx 的 map 和 split_clients 分流、用 cookie 记住用户的分组、怎么判断"没事"、什么时候必须放弃、以及哪些改动根本不该走灰度。

一、先分清灰度、金丝雀、蓝绿、A/B

这四个词经常被混着用,但它们解决的问题不同,混用会带来错误的心理预期。

灰度发布(gray release)是个大概念:新版本先给一部分流量,逐步扩大到全部。它关心的是"风险控制"。

金丝雀发布(canary)是灰度的具体形态:先放极小比例(1%~5%)的流量到新版本,这批用户就是"矿工的金丝雀",一有问题就回滚。它关心的是"尽早发现异常"。

蓝绿部署需要两套完整环境,切换时把流量整体从蓝切到绿,出问题立刻切回来。它关心的是"秒级回滚",代价是资源翻倍——个人站基本不具备这个条件。

A/B 测试的目的不是控制风险,而是比较效果(两个标题哪个点击率高)。它需要统计显著性和分流稳定性,是产品实验,不是发布策略。

对个人站长来说,真正实用的是小比例金丝雀 + 按 cookie 固定分组。原因很直接:你没有第二套完整环境,但你有能力让 5% 的访客走新配置、95% 走旧配置,并且让同一批人始终在同一组(这样才能排除"同一个人一会儿新一会儿旧"造成的困惑)。

二、分流的三种实现,从便宜到贵

第一种:按比例随机分流,用 split_clients。这是 Nginx 自带模块,配置极其简单:

split_clients "${remote_addr}${http_user_agent}" $variant {
    5%      new;
    *       old;
}

server {
    location / {
        root /var/www/site_$variant;
        try_files $uri $uri/ /index.php?$args;
    }
}

注意 key 用的是 IP 加 UA 的拼接,而不是随机数。split_clients 对这个 key 做哈希,所以同一个访客每次都会落到同一组——这正是我们想要的稳定性。如果 key 只用 $request_id 这种每次都变的值,同一个人会在两组之间来回跳,缓存、登录状态、埋点数据全乱。

这个方案的优点是不需要动业务代码、不需要第二台机器。缺点是两个版本必须在同一台机器上(不同目录),而且只适合"文件级"的差异,比如静态页面、模板文件、CSS。它没法让同一套 PHP 代码走不同的逻辑分支(除非你用不同的 fastcgi 端口)。

第二种:按 cookie 固定分组。上面的做法有个问题:用户清掉 cookie 或者换网络(手机切 WiFi/4G)就会换组。更稳的做法是第一次访问时打一个分组 cookie,之后完全按 cookie 走:

map $cookie_ab_group $variant {
    "new"   new;
    "old"   old;
    default $default_variant;   # 没有 cookie 时用 split_clients 的默认值
}

# 在首次访问时下发 cookie(1 = 5% 概率进 new)
map $cookie_ab_group $set_group {
    ""      $assigned;          # 由下面的 split_clients 算出来
}

split_clients "${remote_addr}${http_user_agent}" $assigned {
    5%      new;
    *       old;
}

add_header Set-Cookie "ab_group=$assigned; Path=/; Max-Age=2592000; SameSite=Lax" always;

这样用户第一次来被分配一个组,cookie 保 30 天,之后一直在这个组。缺点是要注意 add_header 的继承规则——只要 location 里写了任何 add_header,父层的全部失效,这个坑我在安全响应头上也踩过一次,配置时把 cookie 那行放在最内层或者统一抽成 include。

第三种:按用户 ID 或后台开关分流。如果你是唯一的管理员,最实用的其实是这个:在配置里放一个"白名单",你自己的账号永远走新版,其他所有人走旧版。

map $cookie_user_token $variant {
    "换成你的token"   new;
    default          old;
}

这样你能在真实流量下反复看新版,随时切换,其他人完全无感。确认没问题后,把 default 改成 new 就完成了一次发布。这是个人站最高性价比的灰度方式,一行配置。

三、"没事"怎么判断:三个可观测指标

灰度发布最大的陷阱不是配置,而是"我以为没事"。没有指标,灰度就是自欺欺人。个人站不需要 Grafana,但至少要有三个数。

指标一:5xx 比例。在 Nginx 日志里按状态码统计,新旧两组分开算。这是最硬的指标,因为配置错误、代码崩、权限问题,基本都是 5xx。一条命令就够:

awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head

如果想让新旧分组分开看,得在日志里加一个字段。用 log_format 加 $variant:

log_format ab '$remote_addr - $remote_user [$time_local] "$request" '
              '$status $body_bytes_sent $request_time "$http_referer" '
              '"$http_user_agent" variant=$variant';

# 用 grep 分别统计
grep 'variant=new' access.log | awk '$9 ~ /^5/' | wc -l

指标二:响应时间的尾部。不要看平均值,平均值会被大量快速请求掩盖。要看 p95 或 p99。一个土办法是统计超过 2 秒的请求数占比:

grep 'variant=new' access.log | awk '$NF ~ /variant=new/ {print}' | awk '{if ($10 > 2) slow++} END {print slow+0}'

(注意 log_format 里 $request_time 是倒数第三列,实际写脚本时按自己的格式对齐列号,别照抄这一行。)

指标三:核心业务动作有没有减少。对内容站来说,是文章页的独立访客数、注册数、提交评论数、下载数。如果新版加载更快但因为 JS 报错导致评论框不显示,前两个指标全部正常,只有这个指标会掉。

判断标准要事先定好,不能事后找解释。我自己的规则是:新组的 5xx 比例超过旧组 2 倍、或 p95 超过旧组 30% 以上、或核心动作下降 20% 以上,任一命中就回滚,不看原因,先回滚再说。原因可以事后分析,流量不能事后挽回。

四、观察多久才算安全

个人站流量小,这是个真实困难:5% 的流量在日 PV 2000 的站上一天只有 100 次访问,可能一天都不出现一次 5xx,因为问题只在某个特定页面或特定浏览器上出现。

所以我的策略是"按样本量而不是按时间"决定观察窗口。定一个最低样本数,比如 300 个新组请求再加 24 小时,两个条件都满足才扩大比例。这样避免了两类错误:流量小的站看了两小时就宣布成功(样本不足),或者大站硬等三天(没必要)。

扩大比例的顺序我一般走 5% → 20% → 50% → 100%,每一档至少一个观察窗口。到 100% 之后不要立刻删旧版本,留 24 小时——因为可能有个把用户还在加载旧缓存,或者 CDN 还没完全刷新。

还有一个值得单独说的:扩到 50% 是最危险的一步。因为这时一半流量在新版,一旦出问题,影响面已经很大,而回滚也要等缓存过期。所以在 50% 那一档,值得多等半天,并且挑在白天、自己在电脑前的时候做,别在深夜。

五、回滚比发布更重要

灰度发布的价值全在回滚上。如果回滚要半小时,那灰度就没意义——半小时足够把问题暴露给 50% 的用户。

回滚速度取决于你改了什么,几种情况差别很大:

改 Nginx 配置:最快。nginx -t 通过后 nginx -s reload 是热加载,不中断连接,通常 1 秒内生效。旧配置留一个 .bak,回滚就是把它拷回来再 reload。这个流程我甚至写成了脚本,一个命令切回:

cp /etc/nginx/sites-enabled/site.conf.bak /etc/nginx/sites-enabled/site.conf \
  && nginx -t && nginx -s reload

改静态文件(CSS/JS/模板):也很快,但要注意浏览器和 CDN 缓存。如果你的 CSS 用了长缓存(max-age=31536000),回滚后用户还在用旧版——这就是为什么资源文件名要带版本号或内容哈希。不解决缓存,回滚等于没做,这是最容易被忽略的一条。

改数据库结构:最慢,也最危险。ALTER TABLE 加字段可以回滚(删字段),但删字段、改类型这种就回不去了。所以数据库变更的原则是永远向前兼容:先加新字段(不删旧的),新代码写新字段、旧代码继续用旧字段,等灰度结束、确认稳定之后,才在下一个维护窗口删旧字段。这样任何时刻回滚代码都能跑,不需要回滚数据库。

这条"向前兼容"的规则是整套灰度体系里最值钱的一条经验。只要所有变更都保持可回滚,你就永远不会有"改坏了回不去"的深夜。

六、哪些改动不该走灰度

灰度不是万能的,有几类改动走灰度反而出错更慢。

第一,SEO 相关的核心改动。canonical、robots、hreflang、URL 结构这些,给 5% 的爬虫看新规则、95% 看旧规则,只会让搜索引擎困惑:同一个页面两个版本,指向不同 canonical,最坏的结果是两边都不收录。这类改动必须一次性全站生效,靠"先在测试环境验证 + 提交后立刻用 GSC 的 URL 检查工具确认"来控制风险,而不是靠灰度。

第二,影响全站的统计与埋点。如果新旧两版埋点不同,你会得到两套不可比的数据,之后所有的分析都要打折。埋点改动应该在灰度之前完成,让两个版本共用同一套埋点。

第三,登录态与支付等一致性要求高的流程。同一个人在两步操作中落到不同版本,可能导致状态不一致(比如新版写了新 session 格式,旧版读不懂)。这类改造要么整站一次性切,要么保证两个版本的数据格式互相兼容。

第四,极小改动。改一个错别字、调一下行高,直接发,别折腾。灰度的配置成本和观察成本是真实的,只值得用在"不确定会不会出问题"的改动上。

七、把灰度写成习惯

我现在的实际做法已经很简化了:

一、凡是改模板、改配置、改 Nginx 这种"可能全站打不开"的操作,先用 cookie 白名单只让自己走新版,在真实流量下盯半小时;

二、白天上班时间改,不在深夜和出差时改;

三、每次改动前把配置文件 cp 一份带日期的备份,改完 nginx -t 必须过;

四、凡是数据库结构变更,一律"先加后删",跨两个发布周期;

五、改完第二天回头看一次日志的 5xx 和错误日志,不看的改动等于没做完。

这五条听着很朴素,但它们覆盖了个人站 90% 的事故场景。真正的事故往往不是"想不到的技术难点",而是"改完没看、出了问题半小时不知道、知道了又不知道怎么退回去"。灰度发布、金丝雀、蓝绿这些名词的价值,本质上是逼你把"怎么退回去"这个问题在动手之前就想清楚。对一个人维护的站点来说,这一点的价值超过任何性能优化。

Last modification:October 27th, 2026 at 07:26 pm

Leave a Comment