一、容器化改变的不只是打包方式
容器最直接的价值是“镜像即交付物”:构建一次,测试、预发、生产使用同一个镜像,不再出现“这台机器能跑、那台不能跑”。但云原生真正改变的是部署单元和生命周期管理方式。
| 维度 | 传统部署 | 容器化部署 |
|---|---|---|
| 交付物 | 二进制 + 启动脚本 + 机器配置 | 不可变镜像 |
| 配置 | 写在机器或启动参数里 | 外置为环境变量、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+40s | kubelet 重启容器 | 玩家全部掉线 |
| T+90s | 新容器加载地图 | 恢复时间被拉长 |
| T+150s | 玩家集中重连 | 登录和网关压力叠加 |
把业务接口当探针,等于让数据库的局部抖动拥有“重启全服”的权限。探针越贴近业务,越容易把依赖故障放大。
另一个常见事故是宽限期短于保存时间:应用收到 SIGTERM 后开始保存,30 秒宽限期到,SIGKILL 直接把进程杀掉,快照只写了一半。修复不是简单调大宽限期,而是同时缩短保存路径、把关键保存做成独立阶段,并让停机预算小于宽限期。
小结
容器化带来的是不可变交付、外置配置、探针和声明式发布。游戏服上云原生的难点集中在长连接、会话保持、有状态分片和本地缓存。无状态服务可以滚动扩缩,有状态分片要按分片迁移;HPA 只适合能快速投入服务的无状态部分。最后,云原生是一笔运维投资,小团队要先算清成本。
常见错误与边界
- 把有状态房间服当无状态服务直接扩缩容,导致分片重复或状态丢失。
- 用业务接口做存活探针,下游抖动引发全集群重启。
terminationGracePeriodSeconds短于保存时间,SIGKILL 打断落盘。- preStop 里先杀进程再等待,流量还没摘干净就开始停机。
- 只配资源 limits 不配 requests,调度和 HPA 都失去准确依据。
- 认为 StatefulSet 会自动迁移状态;它只提供稳定标识,不提供数据迁移。
- 小团队在没有观测和演练的情况下直接上 K8s,把应用故障变成平台故障。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。