跳至内容
影子流量全过程

影子流量全过程

目标不是「Go 写得更漂亮」,而是:前端零改动,旧栈与新栈对同一请求给出可证明的等价结果,再切用户流量。

下面按实际做过的顺序写。例子里的主机、端口、路径都是占位符。

1. 先冻契约,再写实现

大爆炸切换(一天换掉整个认证中台)风险不可控:OAuth、混合 token、审计副作用会绑在一起炸。

所以先做三件事,再动业务代码:

  1. 把旧服务的 HTTP 行为冻成语言无关的契约测试(status / header / body,外加必须发生的 DB 副作用)。
  2. 新服务按契约复刻,而不是按「更合理的 REST」重设计。
  3. 用影子双跑当权威判据:同一请求打旧栈和新栈,差异记录下来,不靠人工点点看。

切流路径用 Strangler:共享同一套存储,按上下文分阶段替换,每阶段可独立回滚。

阶段做什么为什么这个顺序
0锁契约测试没有回归基线不动手
1旁路、无状态、静态 token先验证新栈和部署
2身份 / 组织树暴露隐式作用域和软删
3授权越权是红线
4认证 + OAuth最难的放最后
5下线旧栈闸门全绿再切

2. 影子是「旁路对比」,不是「镜像写」

用户请求仍然只在旧栈终结。影子层额外把同一条读请求打到新栈,比较:

  • HTTP status
  • 需要冻结的响应头
  • body(规范化后再逐字节比)
  • 如有需要:审计行、登录痕迹等 DB 副作用

生产切流时反过来:一条请求只进一个上游。Nginx 不做 mirror、不做双 proxy_pass、不做失败自动换栈重试——否则登录、发码、写审计会被打两遍。

影子双跑只证明读路径等价。写路径(登录、OAuth 发 token、任何 POST/PUT/PATCH/DELETE)禁止拿线上或共享库做双写。

3. 硬约束

这些规则是踩过坑以后留下的,不是风格偏好。

只允许 GET / HEAD。 比较器在代码里直接拒绝其他方法。

GET 不等于安全。 例如带 client_id 的 token 校验接口,可能在读请求里创建 OAuth grant。这类路径必须:

  • 网关层只进旧栈
  • 采集器永久拒绝回放
  • 比较器对查询参数做显式拦截

共享存储,单一写者。 共存期新旧栈看同一套 Postgres、同一逻辑 Redis、同一张 token 表。密钥必须一致,否则旧会话在新栈校验会假红。禁止在影子窗口里轮转密钥。

后台任务 XOR。 HTTP 还没切完时,周期任务只能由旧栈拥有。新栈的 worker owned_jobs 必须是空/none。两边同时跑同步任务会双写上游。

目标主机白名单。 比较器拒绝生产入口、拒绝生产进程端口、拒绝不在预发名单里的主机。只比两个 HTTP 基址,不要另开一条连着业务库的 DSN。

采集日志脱敏。 落盘的 collect.jsonl 只留打码后的路径模板,不写 token、Cookie、Authorization、响应体。

4. 怎么跑

分两层。一层是目录清单(没有活 token),一层是预发真实读流量(采集 → 汇总 → 闸门)。

目录双跑

比较器只对预发 loopback 发 GET/HEAD,不监听端口。

export RUBY_BASE_URL="http://127.0.0.1:<ruby-port>"
export GO_BASE_URL="http://127.0.0.1:<go-port>"

shadowcmp \
  --ruby "$RUBY_BASE_URL" \
  --go "$GO_BASE_URL" \
  --catalog configs/shadow-catalog.staging.json \
  --out reports/

实况采集

从预发访问日志跟读流量,游标按 inode + 字节偏移前进,只在完整换行后移动,避免 logrotate 丢请求。

shadowcmp collect --follow --cursor=true \
  --log /var/log/app-shadow/access.log \
  --out reports/collect

shadowcmp summarize --dir reports/collect
shadowcmp summarize --dir reports/collect --gate

闸门只认同时满足这些条件的事件:

  • 回放时旧栈 status 仍等于采集时看到的 status(排除过期会话)
  • 路径模板在已实现的读契约白名单
  • 结果是 equivalent,且窗口内 gate_diff = 0、gate_unreachable = 0、gate_events ≥ 1

互联网扫 .env / GraphQL 的探测记成 ungated,不能污染业务闸门。历史格式或过期事件可以用来看覆盖,但不能让闸门变绿或变红。

5. 差异怎么判

默认逐字节比 body。噪声必须显式登记,不能靠「看着差不多」。

现象处理不能怎么做
ORDER BY 的数组只在登记过的 JSON 路径按集合比外层列表默认仍要保序
同一资源 304 / 200去掉条件头,两边都拿新鲜 200 再比其他 status 分裂一律红
限流 429采样时避开或单独记账拿 429 当 golden
缓存是旧栈 Marshal 二进制比源数据,不比缓存字节新栈去读旧缓存 blob
新栈未实现 501GoRED,和「契约不一致」分开直接 fatal 掉整个窗口

停机条件(出现就停采集,先查库):

  • 已登记端点在新栈 501 / panic
  • 新栈写完之后树/缓存读到旧数据(多半是 Redis 逻辑库不一致)
  • 写流量或带副作用的 GET 被镜像出去
  • 采集进程空转重启、游标被意外重置、日志权限漂移

6. 闸门绿了,才能切用户流量

影子通过只证明「预发读路径等价」。用户流量切换是另一份批准。

切流原则:

  • 一请求一上游。$request_id 做 30 / 50 / 70 分流,不要 cookie、不要客户端指定后端。
  • 失败禁止自动换另一个栈重试。
  • 旧栈进程保持可回滚。
  • 切换脚本要有文件锁、先备份、nginx -t 失败立即恢复。
  • 回滚到「全旧栈」时,不要被新栈 worker / 配置异常堵住——回滚必须比前进更容易。
  • 更高比例需要新的批准新的闸门证据,不能沿用上一周的报告。

建议档位:legacy-onlynew-30new-50new-70new-100,最后才把周期任务所有权交给新栈。

7. 对外发布时要拿掉什么

写公开文档或 skill 时,下面这些不要出现:

  • 真实域名、证书路径、内网 IP、跳板机
  • 部署用户家目录、systemd 实名、生产 Redis 库号
  • DSN、HMAC、Open API token、Cookie、Authorization
  • 可定位到客户或员工的账号、公司库名
  • 能直接打到生产的命令(比较器必须默认拒绝生产入口)

可以保留的是方法:契约冻结、读路径白名单、副作用 GET 拦截、单一写者、XOR 任务、闸门字段、一请求一上游。