《游戏服务端编程实践》1.4.3 云原生架构演进

从不可变镜像、配置外置、健康探针与滚动发布讲起,分析长连接游戏服在 Kubernetes 上的难点、无状态与有状态服务的部署差异、HPA 的适用边界与小团队成本,并逐项解释一份 Deployment 与 Service 关键字段示例及其常见配置陷阱。

一、容器化改变的不只是打包方式

容器最直接的价值是“镜像即交付物”:构建一次,测试、预发、生产使用同一个镜像,不再出现“这台机器能跑、那台不能跑”。但云原生真正改变的是部署单元和生命周期管理方式。

维度传统部署容器化部署
交付物二进制 + 启动脚本 + 机器配置不可变镜像
配置写在机器或启动参数里外置为环境变量、ConfigMap、Secret
扩缩容手工申请机器、改配置声明副本数,由控制器收敛
健康判断进程活着就算健康探针分别判断就绪与存活
发布停服替换、脚本滚动滚动更新、分批替换
回滚找回旧二进制和配置切回旧镜像标签

这些变化对无状态服务很友好,对游戏服却不全是好事。游戏服的状态、长连接和会话保持,会与容器“随时可被杀掉、随时可被替换”的假设发生冲突。

二、不可变镜像与配置外置

不可变镜像要求镜像里不写环境相关的配置。数据库地址、区服 ID、跨服地址、开关参数都应该通过环境变量、ConfigMap 或配置中心注入。这样做的好处是同一个镜像可以在不同环境验证,出问题时也能确定“代码没变,只有配置变了”。

配置外置也会带来新问题:配置版本和镜像版本不再天然一致。发布时必须同时记录镜像 tag 和配置版本,否则回滚镜像后可能读到不兼容的新配置。常见做法是给配置加版本号,启动时校验必填项和范围,不合法就快速失败,而不是带着半套配置对外服务。

2.1 镜像标签与发布记录

不要在生产使用 latest 标签。latest 会随构建漂移,导致同一个 Deployment 在不同时间拉到不同代码,回滚时也无法确定“上一个版本”到底是什么。更稳妥的标签是日期加提交号,例如 2026.11.04-a1b2c3d。

记录项用途
镜像 tag确认代码版本
镜像 digest防止同名 tag 被覆盖
配置版本确认参数兼容性
数据库迁移版本确认回滚是否会遇到 schema 不兼容
发布人/时间事故复盘与审计

发布记录不是形式主义。没有它,回滚就只能靠猜;有了它,才能回答“这次故障对应哪次构建、哪版配置、哪次迁移”。

2.2 Secret 与配置的边界

配置和密钥要分开管理。普通配置可以放 ConfigMap,密码、证书、令牌放 Secret,并限制读取权限。不要把密钥写进镜像,也不要把 Secret 当普通配置打印到日志。

Secret 只解决“怎么注入”,不解决“怎么轮换”。数据库密码轮换时,需要支持新旧密码并存的过渡窗口,否则一次轮换会让所有 Pod 同时连接失败。

三、健康探针:就绪与存活要分开

Kubernetes 有两类关键探针。readinessProbe 决定 Pod 是否接收流量,失败时只从 Service 端点摘除;livenessProbe 决定是否重启容器,失败时 kubelet 会杀掉并重建。两者混用是常见事故源:把业务接口当存活探针,数据库抖动时所有 Pod 一起重启,故障被放大。

探针回答的问题失败后果适合检查
readinessProbe现在能接流量吗从端点摘除,不重启监听已建立、依赖可用、初始化完成
livenessProbe进程还活着吗重启容器进程是否卡死、死锁
startupProbe启动完成了吗暂缓其他探针加载地图、预热缓存

探针应该轻量、稳定、不依赖外部业务链路。用一次真实的登录请求做存活检查,会把数据库和下游的抖动传导成整集群重启。

四、滚动发布与优雅终止

滚动发布的目标是“替换实例时不丢请求”。对无状态 HTTP 服务,靠 readiness 摘除端点、等待在途请求结束即可;对长连接游戏服,还要考虑连接迁移、会话保持和客户端重连。

一个安全的顺序是:新 Pod 启动并通过 readiness、旧 Pod 进入 terminating、端点控制器把它从 Service 摘除、preStop 等待一段时间让负载均衡刷新、kubelet 发送 SIGTERM、应用执行优雅停机、超过宽限期后 SIGKILL。

这里最容易配错的是 terminationGracePeriodSeconds。它必须大于“preStop 时间 + 应用保存时间 + 退出时间”。如果应用保存需要 30 秒,宽限期却只有 30 秒,那么保存还没完成就会被 SIGKILL。

4.1 分批发布与连接迁移

长连接服务的滚动发布不能一次替换太多实例。即使 maxUnavailable: 0,如果同一分片的多个副本同时进入终止流程,玩家仍会集中掉线。更稳的做法是按分片或按可用区分批,每批只动一个实例,并观察重连 QPS 和保存成功率。

批次策略优点风险
固定批次大小简单、易观测大区服可能一批就过重
按分片滚动故障域小需要分片控制器配合
按可用区滚动跨区容灾成本高、容量要求高
金丝雀发布影响面最小需要流量路由和对比指标

连接迁移是另一个选项:先让新实例准备好,再把玩家逐个迁过去,最后停旧实例。它适合有路由表的网关,不适合直接按连接做负载均衡的架构。

五、游戏服在 Kubernetes 上的难点

游戏服和普通 Web 服务最大的差异是连接是有状态的,而且连接本身承载了会话。这带来四个具体难点。

难点表现应对
长连接TCP 连接生命周期长,滚动发布时集中断开分批发布、连接迁移、客户端快速重连
会话保持同一玩家必须回到同一房间或分片一致性哈希、路由表、会话粘滞
有状态分片每个分片加载不同地图或数据StatefulSet、稳定网络标识、分片控制器
节点本地缓存缓存与节点绑定,重建后需预热启动预热、缓存分层、可降级读源库

长连接还意味着 HPA 不能只看 CPU。连接数、tick 延迟、消息积压往往比 CPU 更早反映容量问题。

5.1 StatefulSet 能做什么、不能做什么

StatefulSet 提供三样东西:稳定的 Pod 名称、稳定的网络标识、按序创建和终止。它适合“每个副本有独立身份”的分片服务,例如 world-0、world-1 各自持有一张地图。

能力StatefulSet 是否提供
稳定 Pod 名称提供
稳定 DNS 名称提供(配合 headless Service)
有序滚动更新提供
自动迁移分片数据不提供
自动重平衡玩家不提供
跨节点本地缓存复制不提供

分片迁移、玩家重平衡、缓存预热都要业务自己实现。把 StatefulSet 当成“有状态服务的自动挡”,是另一个常见误解。

六、无状态与有状态服务的部署策略

服务类型典型代表部署单元扩缩容发布策略
无状态登录、支付回调、管理后台Deployment直接增减副本滚动更新
长连接网关网关、聊天接入Deployment + 会话路由按连接数扩容分批摘流、连接迁移
有状态分片世界、房间、战斗StatefulSet按分片迁移或分裂逐分片滚动、禁止并发替换
定时任务活动开启、结算CronJob / Job不适用幂等、防重跑

有状态服务不能像无状态服务那样“随便杀一个再起一个”。分片世界服被替换时,需要先把玩家迁移到其他分片,或者明确接受一段时间的不可用。StatefulSet 提供稳定的 Pod 名称和网络标识,但不会自动帮你迁移状态。

七、HPA 对游戏服是否适用

HPA 适合“负载可以水平分担、新副本能快速投入服务”的场景。登录服、HTTP API、无状态网关通常适合;有状态世界服和房间服则要谨慎。

原因有三点:新 Pod 加载地图和预热缓存可能需要几分钟,扩容速度跟不上流量尖峰;分片状态不能简单复制,扩容后还要做分片迁移;CPU 使用率低不代表还能接更多连接,长连接服务的瓶颈常在内存、文件描述符或单连接处理能力。

更合理的做法是:网关按连接数和带宽扩容,无状态服务按 CPU/QPS 扩容,有状态分片按预分片和迁移计划扩容。HPA 可以用,但要用能反映真实瓶颈的指标,并接受扩容有延迟。

八、一份 Deployment 与 Service 关键字段示例

下面的示例面向一个有状态世界服分片。它故意保留了 StatefulSet 的常见字段形态,但用 Deployment 展示滚动更新参数;真实分片部署应把 kind 换成 StatefulSet 并补上 serviceName 与 volumeClaimTemplates。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: world-server
  labels:
    app: world-server
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  selector:
    matchLabels:
      app: world-server
  template:
    metadata:
      labels:
        app: world-server
    spec:
      terminationGracePeriodSeconds: 45
      containers:
      - name: world
        image: registry.example.com/game/world-server:2026.11.04
        imagePullPolicy: IfNotPresent
        ports:
        - name: game
          containerPort: 9000
        env:
        - name: POD_NAME
          valueFrom:
            fieldRef:
              fieldPath: metadata.name
        - name: GAME_CONFIG
          value: /etc/game/config.yaml
        volumeMounts:
        - name: config
          mountPath: /etc/game
          readOnly: true
        readinessProbe:
          tcpSocket:
            port: game
          initialDelaySeconds: 5
          periodSeconds: 5
          failureThreshold: 2
        livenessProbe:
          httpGet:
            path: /healthz
            port: 8080
          initialDelaySeconds: 20
          periodSeconds: 10
          failureThreshold: 3
        lifecycle:
          preStop:
            exec:
              command: ["/bin/sh", "-c", "sleep 5"]
        resources:
          requests:
            cpu: "2"
            memory: "4Gi"
          limits:
            cpu: "4"
            memory: "8Gi"
      volumes:
      - name: config
        configMap:
          name: world-server-config
---
apiVersion: v1
kind: Service
metadata:
  name: world-server
spec:
  type: ClusterIP
  selector:
    app: world-server
  ports:
  - name: game
    port: 9000
    targetPort: game
  sessionAffinity: ClientIP
  sessionAffinityConfig:
    clientIP:
      timeoutSeconds: 10800

九、逐项解释关键字段

terminationGracePeriodSeconds: 45 给整个终止流程 45 秒。它必须覆盖 preStop 的 5 秒、应用停机协调器的预算以及进程退出时间。这个值不是越大越好:太大时故障 Pod 会长期占着节点,拖慢滚动发布。

readinessProbe 使用 tcpSocket 检查游戏端口是否已经监听。它比 HTTP 业务接口更轻,不会因为数据库抖动把整个集群摘空。initialDelaySeconds: 5 给进程绑定端口留出时间;地图加载更久的服应该改用 startupProbe,避免存活探针过早重启。

livenessProbe 指向独立的 /healthz,只检查进程内部是否还能响应,不查数据库、不查跨服。failureThreshold: 3 配合 10 秒周期,意味着持续 30 秒无响应才重启,避免瞬时抖动触发重启风暴。

preStop 执行 sleep 5,目的是在 kubelet 发送 SIGTERM 之前,给端点控制器和负载均衡一点时间把 Pod 摘掉。注意不要把 kill -TERM 1 放进 preStop 再 sleep:那会让应用在流量还没摘干净时就开始停机。

maxUnavailable: 0 与 maxSurge: 1 表示先起一个新 Pod,再替换旧 Pod,发布期间容量不下降。对有状态分片,这个策略还不够,需要按分片逐个替换,并确保同一分片不会同时存在两个写者。

sessionAffinity: ClientIP 让同一客户端 IP 尽量落到同一 Pod,适合没有外部路由表的简单场景。但它不是强保证:NAT、代理和 Pod 重建都会改变映射。真正的会话保持应该由网关或路由表按玩家 ID 维护。

十、云原生不是默认答案

Kubernetes 的收益在规模化和标准化,成本在控制面、网络、存储、镜像仓库、可观测性和值班。团队只有两三个人、只有几个区服、发布频率不高时,一台虚拟机加 Docker Compose 或 systemd 可能更便宜,也更容易排障。

条件更适合云原生更适合传统部署
团队规模有专职平台/运维开发兼运维
服务数量多服务、多环境少量单体服务
发布频率每天多次每周或每月
弹性需求明显波峰波谷容量稳定
故障容忍有值班和演练停机窗口可协调

云原生解决的是“规模化之后的协作和自动化”,不是“小团队的技术升级”。在没有观测、没有演练、没有明确容量模型的情况下上 K8s,通常只是把故障从应用层转移到平台层。

10.1 成本清单

成本项传统部署Kubernetes
控制面无托管集群费用或自建运维
网络安全组、负载均衡Ingress、CNI、Service、证书
存储本地盘或云盘StorageClass、PVC、快照
观测装 agent 即可Prometheus、日志、链路、告警
发布脚本或手工镜像仓库、CI、GitOps
人员开发兼运维平台或 SRE 投入

这张表不是劝退,而是提醒:上 K8s 之前要明确谁负责平台、谁负责值班、故障时谁来看控制面。没有这些角色,平台本身就会成为新的单点。

十一、失败场景:探针打业务接口

某世界服把 /player/info 配成存活探针。数据库主从切换时,这个接口耗时从 5 毫秒涨到 2 秒,超过探针超时,kubelet 判定容器不健康并重启。三个副本几乎同时重启,玩家全部掉线;重启后的 Pod 又要重新加载地图,恢复时间被进一步拉长。

这个事故里,数据库抖动本来只影响部分请求,探针把局部故障放大成了全服重启。正确做法是存活探针只检查进程自身,就绪探针检查“能否接新流量”,依赖健康交给熔断、限流和告警。

时间事件后果
T+0s数据库主从切换查询延迟上升
T+10s/player/info 超过探针超时三个副本同时判定失败
T+40skubelet 重启容器玩家全部掉线
T+90s新容器加载地图恢复时间被拉长
T+150s玩家集中重连登录和网关压力叠加

把业务接口当探针,等于让数据库的局部抖动拥有“重启全服”的权限。探针越贴近业务,越容易把依赖故障放大。

另一个常见事故是宽限期短于保存时间:应用收到 SIGTERM 后开始保存,30 秒宽限期到,SIGKILL 直接把进程杀掉,快照只写了一半。修复不是简单调大宽限期,而是同时缩短保存路径、把关键保存做成独立阶段,并让停机预算小于宽限期。

小结

容器化带来的是不可变交付、外置配置、探针和声明式发布。游戏服上云原生的难点集中在长连接、会话保持、有状态分片和本地缓存。无状态服务可以滚动扩缩,有状态分片要按分片迁移;HPA 只适合能快速投入服务的无状态部分。最后,云原生是一笔运维投资,小团队要先算清成本。

常见错误与边界

  • 把有状态房间服当无状态服务直接扩缩容,导致分片重复或状态丢失。
  • 用业务接口做存活探针,下游抖动引发全集群重启。
  • terminationGracePeriodSeconds 短于保存时间,SIGKILL 打断落盘。
  • preStop 里先杀进程再等待,流量还没摘干净就开始停机。
  • 只配资源 limits 不配 requests,调度和 HPA 都失去准确依据。
  • 认为 StatefulSet 会自动迁移状态;它只提供稳定标识,不提供数据迁移。
  • 小团队在没有观测和演练的情况下直接上 K8s,把应用故障变成平台故障。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist