影子流量全过程
目标不是「Go 写得更漂亮」,而是:前端零改动,旧栈与新栈对同一请求给出可证明的等价结果,再切用户流量。
下面按实际做过的顺序写。例子里的主机、端口、路径都是占位符。
1. 先冻契约,再写实现
大爆炸切换(一天换掉整个认证中台)风险不可控:OAuth、混合 token、审计副作用会绑在一起炸。
所以先做三件事,再动业务代码:
- 把旧服务的 HTTP 行为冻成语言无关的契约测试(status / header / body,外加必须发生的 DB 副作用)。
- 新服务按契约复刻,而不是按「更合理的 REST」重设计。
- 用影子双跑当权威判据:同一请求打旧栈和新栈,差异记录下来,不靠人工点点看。
切流路径用 Strangler:共享同一套存储,按上下文分阶段替换,每阶段可独立回滚。
| 阶段 | 做什么 | 为什么这个顺序 |
|---|---|---|
| 0 | 锁契约测试 | 没有回归基线不动手 |
| 1 | 旁路、无状态、静态 token | 先验证新栈和部署 |
| 2 | 身份 / 组织树 | 暴露隐式作用域和软删 |
| 3 | 授权 | 越权是红线 |
| 4 | 认证 + OAuth | 最难的放最后 |
| 5 | 下线旧栈 | 闸门全绿再切 |
2. 影子是「旁路对比」,不是「镜像写」
用户请求仍然只在旧栈终结。影子层额外把同一条读请求打到新栈,比较:
- HTTP status
- 需要冻结的响应头
- body(规范化后再逐字节比)
- 如有需要:审计行、登录痕迹等 DB 副作用
生产切流时反过来:一条请求只进一个上游。Nginx 不做 mirror、不做双 proxy_pass、不做失败自动换栈重试——否则登录、发码、写审计会被打两遍。
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 |
| 新栈未实现 501 | 标 GoRED,和「契约不一致」分开 | 直接 fatal 掉整个窗口 |
停机条件(出现就停采集,先查库):
- 已登记端点在新栈 501 / panic
- 新栈写完之后树/缓存读到旧数据(多半是 Redis 逻辑库不一致)
- 写流量或带副作用的 GET 被镜像出去
- 采集进程空转重启、游标被意外重置、日志权限漂移
6. 闸门绿了,才能切用户流量
影子通过只证明「预发读路径等价」。用户流量切换是另一份批准。
切流原则:
- 一请求一上游。 按
$request_id做 30 / 50 / 70 分流,不要 cookie、不要客户端指定后端。 - 失败禁止自动换另一个栈重试。
- 旧栈进程保持可回滚。
- 切换脚本要有文件锁、先备份、
nginx -t失败立即恢复。 - 回滚到「全旧栈」时,不要被新栈 worker / 配置异常堵住——回滚必须比前进更容易。
- 更高比例需要新的批准和新的闸门证据,不能沿用上一周的报告。
建议档位:legacy-only → new-30 → new-50 → new-70 → new-100,最后才把周期任务所有权交给新栈。
7. 对外发布时要拿掉什么
写公开文档或 skill 时,下面这些不要出现:
- 真实域名、证书路径、内网 IP、跳板机
- 部署用户家目录、systemd 实名、生产 Redis 库号
- DSN、HMAC、Open API token、Cookie、Authorization
- 可定位到客户或员工的账号、公司库名
- 能直接打到生产的命令(比较器必须默认拒绝生产入口)
可以保留的是方法:契约冻结、读路径白名单、副作用 GET 拦截、单一写者、XOR 任务、闸门字段、一请求一上游。