Go服务做灰度发布,核心其实就一句话:在HTTP中间件里提前识别流量特征,把请求导向不同版本的handler。不依赖外部网关(比如Nginx、Istio)的时候,http.ServeMux 那套显然不够用,得自己动手写路由分发逻辑。常见的做法是用一个 http.Handler 把两套handler包起来,按规则选一个执行。
常见的分流依据有这么几种:Header(比如 X-Release-Version: v2)、Cookie(比如把 user_id=12345 哈希后取模)、Query 参数(比如 ?beta=1),或者直接按IP段来分。有一点要特别提醒:别只靠URL路径来区分(比如 /api/v2/xxx),那叫版本共存,不叫灰度。
实操上,有几个关键点:
- 分流逻辑必须放在最外层的中间件,千万不能让业务handler先执行了副作用(比如数据库写入、发消息)之后再跳转,那就乱了。
- 用
ctx.Value把灰度标识透传下去,比如ctx = context.WithValue(r.Context(), grayKey, "v2"),下游就能据此打日志或者调用对应的依赖。 - 分流的时候别做耗时操作,比如查Redis、远程鉴权什么的,否则所有请求都会被拖慢。真有需要,就用本地缓存加定期刷新。
如何安全切换灰度比例而不停机
硬编码一个 if rand.Float64() < 0.1 是典型的反模式。改个比例就得重新发版,而且没法按用户维度精准控制。真正可用的方式是让灰度策略外置,运行时热加载。
推荐两种轻量方案:
- 用本地JSON配置文件加
fsnotify监听变更。配置里包含version和weight(比如{"v2": 0.15}),每次请求都读取一个原子变量atomic.LoadPointer指向的策略快照,效率很高。 - 对接一个简单的HTTP接口(比如
GET /api/gray/config),用time.Ticker每30秒拉取一次。失败时就沿用旧策略,别用长连接或WebSocket,会增加运维复杂度。
一个关键点:权重计算必须用 hash(user_id) % 100 < 15 这类确定性算法,确保同一用户始终命中同一版本,否则前端的状态会错乱。
灰度环境下的日志与链路怎么对齐
灰度流量混在主干日志里,不加区分的话,排查问题会非常痛苦。重点不是“打更多日志”,而是让每条日志都自带灰度上下文。
实操要点:
- 在入口中间件中,从请求里提取灰度标识(比如
r.Header.Get("X-Gray-Version")),然后注入到日志字段里。比如用zerolog.Ctx(r.Context()).Str("gray", version).Msg("req start")。 - OpenTracing 或 OpenTelemetry 的
Span里必须设 tag:span.SetTag("gray.version", version),否则链路追踪无法过滤灰度调用栈。 - 禁止在灰度handler里用
log.Printf等无上下文的输出。它不继承 request context,灰度标识会丢失。
一个容易被忽略的地方:数据库SQL日志、第三方SDK的回调日志,如果没显式传入 context,也会漏掉灰度标记。得检查这些库是否支持 context.Context 参数。
灰度回滚为什么不能只靠重启服务
重启Go进程会丢连接、中断长轮询、清空内存缓存,对在线用户很不友好。真正的灰度回滚,是“让新版本流量归零,但进程继续跑”。
这意味着:
- 分流策略必须支持
weight=0,并且代码里明确处理这个分支(比如直接 fallback 到v1 handler,而不是 panic 或返回503)。 - 健康检查接口(比如
/healthz)需要暴露当前生效的灰度版本和权重,供监控系统抓取。K8s 的LivenessProbe不能只看端口通不通。 - 如果灰度版调用了不兼容的下游服务(比如新版依赖一个未上线的 gRPC 接口),那应该在 handler 入口做快速失败检测(比如
grpc.DialContext带短 timeout),而不是等业务逻辑走到一半才报错。
最常踩的坑是:灰度开关存在内存里,但没配好信号量或 mutex,多 goroutine 并发更新导致策略错乱。用 sync.RWMutex 保护策略结构体,读多写少场景下性能影响极小。