背景脱敏:一个 Go 微服务架构的牌类游戏后端,约 20 个服务跑在 K8s 上,正式服每天三个定时批次全量滚动重启。牌类对局是强状态的:一桌四个人打到一半,承载这桌的进程被杀,这局就没了。本文记录怎么让游戏服在滚动更新时「打完手里这局再退」,以及中途踩到的几个反直觉的坑。
现象
改造前的实际状态很直白:Pod 层没有 terminationGracePeriodSeconds、没有 preStop;持客户端长连接的网关,Stop() 只做注册中心注销,不摘就绪、不排空、不关连接。游戏服是 strategy: Recreate + replicas: 1,发布时先杀光旧 Pod 再建新的,发布窗口内所有在打的对局直接撕断。
每天三次全量重启,等于每天三次把所有正在进行的牌局打断。
设计:等待放在 preStop 里,不放在进程里
第一步先把网关的退出改成四步编排,顺序是关键:
- 先摘就绪(readiness 置 false),让 Service endpoints 剔除本实例,新连接不再进来
- 注册中心注销,推送方不再按本实例寻址
- 排空窗口(默认 5s),给 endpoints 传播与在途下行推送留时间
- 关闭存量连接,客户端立刻重连到其他实例,好过等 Pod 被杀后靠 TCP 超时才发现
游戏服的排空则复杂得多,因为「等当前对局打完」可能要等几分钟。这里有个基础框架层面的限制:框架的 closeWithTimeout 硬编码 30s,任何写在 Close()/PreClose() 里的长等待都会被「超时跳过」,等于白等。
所以等待没有放在进程内,而是放在 K8s 的 preStop hook 里轮询:
wget -q -O- "http://127.0.0.1:${HEALTH_PORT}/drain" >/dev/null 2>&1 || true
waited=0
while [ "$waited" -lt 600 ]; do
wget -q -O- "http://127.0.0.1:${HEALTH_PORT}/drain/status" 2>/dev/null \
| grep -q '"human_desks":0' && break
sleep 2
waited=$((waited + 2))
done
进程侧只提供两个 GET 端点:/drain 触发排空(幂等,preStop 可能重试),/drain/status 返回还有几张有真人的桌。用 GET 而不是 POST,是因为运行时镜像是 alpine,busybox 的 wget 发 POST 各版本行为不一。
terminationGracePeriodSeconds 必须大于排空上界(600s 对应 660s),否则等到一半被 SIGKILL,对局撕断且没有收尾,比不排空更糟。
三套对局引擎统一一个外壳
项目里有三套对局引擎(四个传统玩法共用一套核心、另一套德州类引擎、一套多人常驻桌引擎)。它们「停止接收新玩家」的动作各不相同:向房间管理注销、撤掉空位广告、直连回落。但「翻标志 / 记时 / 出状态 JSON / 刷指标」这套外壳逐字相同,且 preStop 脚本依赖 JSON 字段名,三份各写一遍迟早漂移。
于是抽成一个共享包,差异部分由各引擎以 Config 注入:
type Config struct {
HumanDesks func() int // 仍有真人在座的桌数,preStop 的唯一放行判据
StopIntake func() // 停新进,各引擎自己实现,必须幂等
Ready *health.Gate // 就绪门,排空时摘掉
TotalDesks func() int // 可选,仅排查用
}
两处细节值得记:HumanDesks 必须能在 HTTP 协程上安全调用,不能遍历桌 actor 私有的裸 map;TotalDesks 用指针加 omitempty,未注入回调的引擎不出该字段,而不是出一个恒为 0 的假值,因为 0 是合法桌数,分不出「没有桌」和「没统计」。
租约池:滚动更新的前提
从 Recreate 改回 RollingUpdate 有个隐藏前提。当初用 Recreate,正是因为两个实例会撞同一个 SERVER_ID,向房间管理上报的 gRPC 地址互相覆盖。
解法是号段租约池:进程启动时从 Redis 按号段抢一个 ID 持有租约并续期,新旧 Pod 共存期各持各号。三件事成对生效,缺一即退化:
RollingUpdate+maxSurge:1/maxUnavailable:0,新 Pod 就绪后旧 Pod 才退drainPreStop,preStop 打/drain停新进,再轮询等真人桌清零- 号段池容量
SIZE ≥ 版本数 × replicas × 2,×2是滚动期新旧共存
第三条是后来用事故换来的。副本从 1 扩到 3 时池容量还是 4,滚动期 6 个 Pod 抢 4 个号,抢不到的回退静态 ID,三副本静态值相同,直接同 ID 双主:房间管理地址每秒震荡,补位失败 4900 次/分钟。修法除了扩容,还把租约续期改成:续期必须校验返回值与 owner;键蒸发时当拍 SetNX 抢回;被他人合法持有则 fail-fast 退进程,双主比重启更糟。
排空可观测:告警阈值要按生命周期倒推
排空期只有 preStop 每 2s 打一次状态,天然就是唯一需要高频观测的窗口,所以指标刷新直接挂在状态端点的 write 里,不另起定时器。
elapsedSeconds 未排空时必须恒为 0,否则「排空超上界」告警会在所有正常实例上恒为真。
告警阈值也踩过坑。原来两条排空告警按传统玩法的 600s 上界定的:game_drain_duration_seconds > 600 与 game_draining == 1 for 20m。对上界只有 120s、grace 只有 180s 的多人常驻桌引擎,两条都永远不响:duration 到不了 600,序列在 20 分钟前就随 SIGKILL 消失了。拆成按服务两组,把该引擎单列 > 90 / for: 0m,改 drainMaxSeconds 或 grace 时必须同步重算,这句写进了规则注释。
按玩法金丝雀铺开:一个引擎的语义完全不同
排空分三批推上正式服:传统四玩法先行,另一套德州类引擎跟进,多人常驻桌引擎最后。最后这个引擎最初的结论是「有意不开排空」:桌常驻、人流动,「桌上没人了」几乎永不成立。
后来推翻了:常驻桌的正确语义不是「等桌空」,是「等当前这一局结算完且不开下一局」。首版实现了「等」那一半,判据 inRound 由局末置 false。单看这段代码找不出毛病,漏掉的是:有活跃玩家时一局接一局,上一局刚置 false,下一局立刻置回 true,判据在两局之间只有毫秒级窗口,preStop 每 2s 轮询一次,撞上的概率近似 0。结果是排空必撞满 120s 再被 SIGKILL,比不做排空还慢。
修复后测试环境的读数:
打 /drain draining=true, human_desks=1
+0~18s human_desks=1 ← 等这一局
+20s human_desks=0
+20~44s human_desks=0(11 次)← 保持归零 = 不开下一局生效
正式服上玩家一直坐在桌上、判据却持续为 0 达 132 秒,正是要验的语义。配套加了结构性单测:每处 EndRound() 后 4 行内必须有 SetInRound(false)。
实验设计里的单信号误导
定位上面那个缺陷花掉的时间,几乎全部消耗在无效实验上。前两个假设都是只盯排空判据这一个信号想出来的,全错。真正有效的是同时采样两个来源:排空判据 + GM 报的对局相位。相位一路从「闲家追加注期」走到「庄家行动轮」,局根本没卡住,卡住的是判据。
同一个验证连做四次才拿到能下结论的数据:
| 版本 | 失败原因 | 伪装成什么 |
|---|---|---|
| v1 | 固定等 25s 才采样,触发时玩家已离场 | 「一直是 0」像放行很干脆 |
| v2 | rollout restart 换了 Pod,port-forward 指着旧 Pod,冒烟 3 秒 EOF |
判据短暂为 1 后归零,像等完了一局 |
| v3 | 有效性自检用 grep 错误字符串,日志为空时恒通过 |
「未报握手失败」,检查本身空跑 |
| v4 | 对照信号走 GM,GM 在两个实例间轮询,一半读数来自不持有该玩家的实例 | 把一次成立的实验判成无效 |
排空之外:进程内队列
排空做完后顺手做了一次全仓普查:16 处「消息进进程内 channel、后台 goroutine 异步消费」的模式,其中 10 处进程退出时真的丢数据。异步化会把 at-least-once 悄悄降级成 at-most-once,而代码读起来完全正常。注释写「进程崩溃时丢,可接受」不算回答,崩溃是异常,滚动发布是常态,每次发布每个副本都走一遍这条路径。
但按缓冲容量排优先级是错的。停机丢多少取决于停机那一刻的队列深度:会话持久化队列理论容量 52 万,正式服 7 天均值深度只有 0.617,从 P0 降到 P3。
教训
- 「等 X 结束」必须配「止 X 开始」。周期性活动的判据天然会被下一周期重置,只实现等不实现止,判据永远归不了零,失败形态是「撞满上界」,长得像超时而不像逻辑漏项。
- 观测到「状态不推进」时至少要两个独立信号,才能区分「被观测对象真的停了」与「观测它的指标坏了」,单信号下两种情况读数完全相同。
- 实验的有效性前提必须被断言,且要正面证据。「没找到错误字符串」不是证据;跟 Pod 生死赛跑的实验,快和慢都证明不了任何事。
- 扩副本必须审「按副本数分配的资源」,号段、端口、配额,滚动期按新旧共存算 ×2。
- 写「有意不做」时把前提一并写下,否则后来的人只读到结论,不知道前提早已不成立。