Nginx 流量镜像与灰度发布:mirror 模块、影子流量与按比例分流

用 Nginx mirror 模块把生产流量复制到影子环境,结合按比例分流实现灰度发布与快速回滚,并讲清副作用隔离与性能开销。

线上出问题的代价远高于测试环境发现问题。可测试环境永远复现不出真实流量的样子:参数组合、并发节奏、数据规模、长尾请求,全都是合成流量无法覆盖的。流量镜像(traffic mirroring)解决的正是这件事——把真实请求原样复制一份发给新版本,让它在不影响用户的前提下接受真实流量的检验。本文先讲镜像模块的机制与副作用隔离,再讲如何用按比例分流做灰度,最后给出回滚与验证的完整闭环。

1. 流量镜像的价值与架构

一句话总结: 镜像把生产请求异步复制给影子服务,影子服务的响应被丢弃,因此可以在零用户风险下验证新版本的正确性与性能。

镜像与灰度的区别在于「谁的响应生效」:

方式用户是否受影响影子响应是否生效典型用途
流量镜像否否,直接丢弃新版本压测、数据一致性验证
按比例分流是,小比例用户是灰度发布、A/B 测试
蓝绿切换是,全量切换是版本切换与快速回滚

镜像的架构很直接:Nginx 在把请求转发给主上游的同时,另起一个内部子请求发给影子上游,不等待其响应。

upstream production {
    server 10.0.0.11:8080;
    keepalive 64;
}

upstream shadow {
    server 10.0.1.11:8080;    # 新版本所在环境
    keepalive 32;
}

影子环境必须与生产物理隔离:独立的数据库、独立的消息队列、独立的第三方调用凭证。否则镜像流量会真的写进生产数据,造成难以清理的脏数据。

2. mirror 模块详解

一句话总结: mirror 指令把请求体缓冲后异步转发给指定 URI,主请求不受其成功与否影响,配合 mirror_request_body 控制是否复制请求体。

ngx_http_mirror_module 自 Nginx 1.13.4 起内置,默认编译进主程序。核心指令只有三个。

location /api/ {
    # 镜像到内部 location
    mirror /mirror_shadow;
    # 是否把请求体也复制过去(默认 on)
    mirror_request_body on;

    proxy_pass http://production;
}

location = /mirror_shadow {
    internal;                       # 必须,禁止外部直接访问
    proxy_pass http://shadow$request_uri;
    # 影子环境不应改动生产侧上下文
    proxy_set_header Host shadow.internal;
    proxy_set_header X-Shadow-Request "1";
    proxy_set_header X-Original-Client $remote_addr;
}

几个必须理解的行为细节:

第一,镜像是异步的,不阻塞主请求。 Nginx 把镜像请求放入独立的上游处理流程,主请求拿到生产响应后即可返回,不会等待影子响应。这让镜像对用户延迟的影响接近于零(但仍会消耗 worker 的连接与内存)。

第二,镜像响应被完全丢弃。 影子服务返回 500 也不会影响用户。因此影子服务不需要高可用,可以随时重启。

第三,可以配置多个 mirror。 需要同时验证两套新版本时,写两条 mirror 指令即可:

location /api/ {
    mirror /mirror_v2;
    mirror /mirror_v3;
    proxy_pass http://production;
}

第四,mirror_request_body on 会把请求体读入内存缓冲。 上传大文件时这会显著增加内存占用。若影子服务不需要请求体(例如只做路由与鉴权验证),务必关掉。

# 大文件上传路径:关闭请求体镜像,避免内存膨胀
location /upload/ {
    mirror /mirror_shadow;
    mirror_request_body off;
    client_max_body_size 500m;
    proxy_pass http://production;
}

3. 影子流量的隔离与副作用

一句话总结: 影子环境最危险的副作用是写操作与外部调用,必须在影子侧或 Nginx 侧做强制拦截。

镜像流量是「真」流量,它会真的执行业务逻辑。若新版本会写数据库、发短信、扣库存,镜像就会造成真实的业务副作用。防护有三层:

第一层:影子服务自身做只读降级。 通过一个环境变量或请求头让应用进入只读模式。

location = /mirror_shadow {
    internal;
    proxy_pass http://shadow$request_uri;
    # 让应用识别影子请求并切换到只读通道
    proxy_set_header X-Shadow-Mode "readonly";
    proxy_set_header X-Shadow-Request-Id $request_id;
}
# 应用侧根据影子标记切换到影子数据源
# 伪代码语义:
#   if os.getenv("SHADOW_MODE") == "readonly":
#       db = shadow_readonly_replica
#       disable_outbound_sms()
#       disable_payment_gateway()

第二层:影子环境网络隔离。 用网络策略阻断影子环境访问生产数据库与第三方支付、短信接口。这一层是兜底,即使应用忘了降级也不会造成真实副作用。

第三层:Nginx 侧过滤危险请求。 只镜像读请求(GET、HEAD),把写请求排除在外。

# 只镜像安全方法,避免写操作被复制
map $request_method $mirror_uri {
    default      "";
    "GET"        "/mirror_shadow";
    "HEAD"       "/mirror_shadow";
}

server {
    location /api/ {
        mirror $mirror_uri;         # 空字符串表示不镜像
        proxy_pass http://production;
    }
}

mirror 的值为空字符串时不会发起镜像请求,这是官方文档明确支持的行为,也是控制镜像范围最轻量的手段。

4. 按比例分流与灰度

一句话总结: 灰度分流用变量决定上游,比例可按用户标识稳定哈希,也可按请求随机,前者体验一致后者统计简单。

分流与镜像不同:分流会让一部分用户的请求真的走到新版本,他们的响应来自新版本。因此必须保证同一用户始终落到同一版本,否则会出现「刷新一次换一个版本」的诡异体验。

# 按用户标识稳定分流:同一用户始终落到同一版本
split_clients "${remote_addr}${http_user_agent}" $variant {
    5%      "canary";
    *       "stable";
}

upstream canary_backend {
    server 10.0.1.11:8080;
}
upstream stable_backend {
    server 10.0.0.11:8080;
}

server {
    location /api/ {
        proxy_pass http://$variant_backend;
    }
}

上面的写法需要把 $variant 映射到实际上游名,用 map 完成:

map $variant $variant_backend {
    "canary"  "canary_backend";
    default   "stable_backend";
}

若希望按「用户 ID」而不是 IP 分流(同一 WiFi 下的用户 IP 相同,按 IP 会导致整栋楼一起切版本),应使用业务标识:

# 优先用登录态里的用户 ID,未登录时回退到 IP
map $cookie_user_id $shard_key {
    default   $remote_addr;
    "~."      $cookie_user_id;
}

也可以用 $request_id 做纯随机分流,适合只关心统计结果的场景,但用户体验会不一致,不适合面向 C 端的灰度。

比例调整时需要注意 split_clients 的一致性:修改百分比会导致大部分用户的分配结果发生变化。从 5% 调到 10% 时,原来在 5% 里的用户不一定还在里面。若要保证「只增不减」,应使用固定的分桶编号:

# 用哈希值的前若干位做分桶,比例调整时老用户保持稳定
map $cookie_user_id $bucket {
    default   "0";
    "~^(?<h>.)"  "$h";        # 简化示例:按首字符分桶
}
map $bucket $variant {
    default  "stable";
    "0"      "canary";
    "1"      "canary";
}

5. 灰度策略与回滚

一句话总结: 灰度必须可观测、可暂停、可秒级回滚,回滚方式优先选「切上游」而不是「改配置重载」。

一个可运营的灰度流程包含四个阶段:

  1. 内部放量:只对内部账号或带特定 Cookie 的请求启用新版本
  2. 小比例放量:1% → 5% → 20%,每档观察至少一个业务周期
  3. 全量前验证:核心指标与旧版本对齐,错误率不高于基线
  4. 全量切换:切换完成后保留旧版本一段时间以便回滚

放量控制推荐用请求头或 Cookie 显式指定,便于测试与人工干预:

map $cookie_canary $canary_backend {
    default   "stable_backend";
    "1"       "canary_backend";
}

map $http_x_canary $canary_by_header {
    default   $canary_backend;
    "on"      "canary_backend";
}

server {
    location /api/ {
        # 头部优先级高于 Cookie,方便压测与人工放量
        proxy_pass http://$canary_by_header;
        add_header X-Served-By $canary_by_header always;
    }
}

回滚的关键是不要依赖重载配置。nginx -s reload 会重建 worker,虽然不断连接,但在高负载下仍可能造成短暂抖动。更好的做法是用变量动态切换上游,然后通过 map 的数据源(如 OpenResty 的共享内存或 lua-resty-balancer)修改映射,实现秒级回滚。

# 用响应头标记版本,便于从日志统计灰度效果
log_format canary '$remote_addr "$request" $status '
                  'ver=$canary_by_header '
                  'upstream=$upstream_addr rt=$request_time';

access_log /var/log/nginx/access.log canary;
# 回滚:把灰度比例调回 0,只需改 map 后重载,无需改业务配置
sed -i 's/^    5%      "canary";/    0%      "canary";/' /etc/nginx/conf.d/canary.conf
nginx -t && nginx -s reload

# 回滚后确认灰度流量已归零
awk '$0 ~ /ver=canary/ {c++} END {print "canary requests:", c+0}' /var/log/nginx/access.log

6. 数据对比与验证

一句话总结: 灰度与镜像的结论都必须靠新旧版本的同口径数据对比得出,指标口径不一致会让结论完全错误。

镜像验证关注「新版本处理同样的请求,结果是否一致」;灰度验证关注「新版本面对真实用户,指标是否更优」。两类验证需要的观测手段不同。

镜像验证通常需要影子服务把处理结果与生产结果做差异比对:

# 影子服务把响应摘要写入对比日志,离线比对
# 日志格式示例(JSON 行):
#   {"rid":"a1b2","path":"/api/order/7788","prod_hash":"9f3c","shadow_hash":"9f3c","ms":12}
# 统计影子与生产结果不一致的比例
python3 - <<'PY'
import json
diff = total = 0
for line in open('/var/log/shadow/compare.log'):
    r = json.loads(line)
    total += 1
    if r['prod_hash'] != r['shadow_hash']:
        diff += 1
print(f"total={total} diff={diff} rate={diff/max(total,1):.4%}")
PY

灰度验证则看四类指标:

指标类别具体项判定标准
正确性5xx 比例、业务错误码不高于旧版本
延迟P50、P95、P99不显著劣化
资源CPU、内存、连接数在容量水位内
业务转化率、下单成功率无异常下跌

对比时必须同口径:同一时间段、同一流量类型、同一统计维度。用新旧版本各自的全量数据对比是常见的错误,因为两者的流量结构可能完全不同。

7. 性能开销与陷阱

一句话总结: 镜像的成本主要在连接与内存而非 CPU,分流的主要风险在用户一致性,两者都需要在放量前做容量评估。

镜像的开销来自三处:额外的上游连接、请求体的内存缓冲、影子服务的处理能力。镜像流量会让总出口流量翻倍,带宽与连接数都要按两倍预留。

# 限制镜像带来的额外压力:只镜像部分流量
split_clients "${remote_addr}${uri}" $mirror_sample {
    10%     "/mirror_shadow";
    *       "";
}
server {
    location /api/ {
        mirror $mirror_sample;      # 只镜像 10% 的流量
        proxy_pass http://production;
    }
}

这个技巧很实用:用 split_clients 给镜像本身做采样,让影子环境只需要承受生产流量的十分之一。

常见陷阱清单:

陷阱一:忘记 internal。 影子 location 暴露到公网,等于给攻击者一个无鉴权的入口。

陷阱二:镜像了写请求。 数据库出现重复订单。用 map $request_method 过滤。

陷阱三:影子环境与生产共享存储。 镜像的写操作污染生产数据。必须物理隔离。

陷阱四:分流比例改动导致用户漂移。 用固定分桶而非百分比。

陷阱五:忽略 $request_id 的透传。 镜像请求应带上原始请求 ID,否则无法把影子日志与生产日志关联起来。

location = /mirror_shadow {
    internal;
    proxy_pass http://shadow$request_uri;
    # 透传请求 ID,保证日志可关联
    proxy_set_header X-Request-Id $request_id;
    proxy_set_header X-Mirror-Of    $host;
}

8. 总结

环节要点
镜像语义异步复制、响应丢弃、不影响用户,多个 mirror 可并行
请求体mirror_request_body 默认开启,大文件路径务必关闭
副作用隔离应用只读降级、网络隔离、Nginx 过滤写方法三层防护
比例分流按业务标识稳定哈希,避免按 IP 导致整片用户同时切换
比例调整百分比改动会引起用户漂移,固定分桶可保证只增不减
回滚优先切上游变量而非重载,配合日志确认灰度流量归零
验证新旧版本同口径对比,镜像看一致性、灰度看业务指标
成本镜像使出口流量翻倍,用 split_clients 对镜像本身采样

流量镜像与灰度的本质都是「把风险控制在可承受范围内」:镜像让你在不冒风险的前提下发现问题,灰度让你在冒小风险的前提下验证价值。两者配合使用,才能把发布的信心建立在真实数据上。下一篇我们看地域路由与 GeoIP,那是把策略从「版本维度」扩展到「空间维度」的另一种分流方式。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nginx」更多文章

  1. Nginx 在服务网格中的角色:边车代理、mTLS 与 Envoy 取舍
  2. Nginx 大文件上传与请求体处理:缓冲、临时文件与断点续传
  3. Nginx 证书自动化与 ACME:certbot、DNS-01 通配符与自动续期