4.1 容器化与编排
前三章把 aegis 量出来了(压测与容量)、调优了(帧预算与 GC)、也看清了(日志、指标、链路)。但这一切都发生在开发机上——go run ./cmd/gateway、go run ./cmd/scene、开几个终端、改改配置文件。上线意味着要把这些进程交给一套别人也能启动、停止、替换、扩缩的系统,而业界给这套系统的事实标准是容器加编排。
容器解决的是「一个进程连同它的运行时一起打包」:镜像里既有二进制,也有它依赖的库、证书、时区,运行时环境从「这台机器上装了什么」变成「镜像里有什么」。编排解决的是「谁在哪里跑、跑几个、挂了怎么办」:调度、健康检查、扩缩、滚动替换。两者合起来,才有 4.2、4.3 要讲的发布与停服。但把游戏服务端塞进容器,和塞一个无状态 Web 服务不是一回事——游戏节点里有单写者状态:一块地图实例、一个玩家、一场对局,只能由一个进程说了算。这一节就围绕这条分界线展开:先把镜像做对,再写出「无状态用 Deployment、有状态用 StatefulSet」这份清单,最后把编排判断变成能跑的断言。
镜像分层与多阶段构建
镜像不是一个大文件,而是一叠只读层。Dockerfile 的每一条指令生成一层,层被内容寻址、可复用:只要某一层没变,重建时就命中缓存。这条特性决定了 Dockerfile 的写法——把变化频率最低的东西放最下面,变化最频繁的放最上面。
对 Go 项目来说,依赖(go.mod / go.sum)变化频率远低于源码,所以先拷 go.mod、先跑 go mod download,再去拷源码编译。这样改一行业务代码只会重建最上面那层,依赖层照旧命中缓存。这就是所谓「依赖层与代码层分离」。
多阶段构建解决另一个问题:编译需要整个 Go 工具链(几百 MB),运行只需要一个静态二进制(几 MB)。如果编译和运行在同一个镜像里,交付出去的镜像会带着编译器、源码、构建缓存——既大又危险。多阶段构建用两个 FROM:第一阶段叫 build,装工具链、编译;第二阶段是最终镜像,只 COPY --from=build 那一个二进制。下面这份是 L3 静态清单,本机不构建、不部署,只做语法检查:
# ---- build stage ----
FROM golang:1.26-alpine AS build
WORKDIR /src
# 先只拷 go.mod/go.sum,让依赖层单独缓存:改代码不会失效依赖层。
COPY go.mod go.sum ./
RUN go mod download
# 再拷源码并编译成静态链接的二进制。
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
-o /out/aegis-gateway ./cmd/gateway
# ---- runtime stage ----
# 运行镜像里没有编译器、没有 shell 工具链,只有二进制和证书。
FROM gcr.io/distroless/static:nonroot
WORKDIR /app
COPY --from=build /out/aegis-gateway /app/aegis-gateway
USER nonroot:nonroot
EXPOSE 9101 9102
ENTRYPOINT ["/app/aegis-gateway"]
几个刻意的选择:CGO_ENABLED=0 让二进制静态链接,运行镜像才能用 distroless(没有 libc);-trimpath 去掉构建机上的绝对路径;-ldflags="-s -w" 去掉符号表与调试信息,二进制更小;USER nonroot 让进程不以 root 运行;运行时镜像用 distroless/static 而不是 alpine,是因为它连 shell 都没有——攻击者进了容器也没东西可用。这些不是「更专业」的装饰,而是把「编译环境」和「运行环境」的边界画清楚后的自然结果。
无状态与有状态:两份清单
镜像做好了,接下来是编排清单。aegis 的角色分成两类,分界线就是卷三 1.1 立下的那条:它持有的状态有没有单写者。
- 网关是 Deployment。它只持每连接一份的连接态(心跳、重连票据、限速窗口),一个连接只在它自己身上有副本,三台网关各持各的、永远不会分叉。所以网关副本是「无差别」的:可以任意替换、任意扩缩、滚动更新时新旧副本可以并存。
- 场景、战斗、世界、目录是 StatefulSet。它们持单写者状态:一个地图实例、一场对局、一个玩家、一张路由表。这类副本有身份——
scene-0和scene-1不是同一回事,替换时必须一个走完再起下一个(OrderedReady),并且需要一个无头 Service 给每个 Pod 一个稳定 DNS 名。
本节与站内既有文章的分工如下,本节只讲「承载发布的镜像与清单」,发布策略本身留给 4.2,停服留给 4.3:
| 站内既有文章 | 侧重点 | 与本节的边界 |
|---|---|---|
/game-server-blue-green-deployment-for-live-games/ | 蓝绿发布的整体方案与流程 | 讲策略;本节讲承载它的镜像与工作负载类型 |
/game-server-stateful-service-deployment-architecture/ | 有状态服务部署架构 | 讲架构取舍;本节把取舍落成 StatefulSet 清单 |
/game-server-graceful-shutdown/ | 优雅停服的机制 | 讲停服四步;本节只定「排空预算必须小于宽限期」这条清单约束 |
先看无状态网关。它是 Deployment + Service,maxUnavailable: 0 保证滚动时可用副本数不掉:
# 无状态网关:Deployment + Service,副本可任意替换。本机不部署,本节为静态清单。
apiVersion: apps/v1
kind: Deployment
metadata: { name: gateway, labels: { app: aegis, role: gateway } }
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
selector: { matchLabels: { app: aegis, role: gateway } }
template:
metadata: { labels: { app: aegis, role: gateway } }
spec:
terminationGracePeriodSeconds: 30
containers:
- name: gateway
image: registry.aegis.local/aegis-gateway:0.4.0
ports: [{ name: health, containerPort: 9101 }]
readinessProbe: { httpGet: { path: /healthz, port: 9101 }, periodSeconds: 5 }
livenessProbe: { httpGet: { path: /livez, port: 9101 }, periodSeconds: 10 }
---
apiVersion: v1
kind: Service
metadata: { name: gateway }
spec:
selector: { app: aegis, role: gateway }
ports: [{ name: health, port: 9101, targetPort: 9101 }]
再看有状态场景。差别集中在三处:serviceName 指向一个 clusterIP: None 的无头 Service(给 Pod 稳定标识),podManagementPolicy: OrderedReady 让替换逐台进行,terminationGracePeriodSeconds 从 30 拉到 60——因为场景停服要排空在途请求并落库,比网关费时:
# 有状态场景节点:StatefulSet + 无头 Service,稳定标识 scene-0/scene-1,
# OrderedReady 逐台替换,天然配合代次交接。本机不部署,本节为静态清单。
apiVersion: v1
kind: Service
metadata: { name: scene-headless }
spec:
clusterIP: None
selector: { app: aegis, role: scene }
ports: [{ name: health, port: 9201, targetPort: 9201 }]
---
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: scene }
spec:
serviceName: scene-headless
replicas: 2
podManagementPolicy: OrderedReady
updateStrategy:
type: RollingUpdate
rollingUpdate: { partition: 0 }
selector: { matchLabels: { app: aegis, role: scene } }
template:
metadata: { labels: { app: aegis, role: scene } }
spec:
# 场景排空要落库,宽限期必须大于应用自己的 drain 预算(见 4.3)。
terminationGracePeriodSeconds: 60
containers:
- name: scene
image: registry.aegis.local/aegis-scene:0.4.0
ports: [{ name: health, containerPort: 9201 }]
readinessProbe: { httpGet: { path: /healthz, port: 9201 }, periodSeconds: 5 }
livenessProbe: { httpGet: { path: /livez, port: 9201 }, periodSeconds: 10 }
两份清单最容易被忽略的一条差异是宽限期与排空预算的关系。terminationGracePeriodSeconds 是编排系统给进程的墙钟上限:超过它,Pod 会被 SIGKILL。进程自己的排空预算(4.3 会真跑)必须严格小于这个上限,否则排到一半就被砍,数据没落库。网关是 30 对 20,场景是 60 对 45,都留了余量。
把编排不变量钉成断言
清单是 YAML,YAML 改错了不会报错——它会安静地部署出一套错的东西。所以把清单里的判断抽成模型、写成测试,比写注释有用得多。下面这段是 L1,go test 可以直接跑。internal/deploy/manifest.go 把「哪个角色是 StatefulSet」「排空预算要小于宽限期」编码成 Validate():
// file: internal/deploy/manifest.go
// tier: L1
// verify: go test ./internal/deploy/
// 4.1 的编排不变量。一个角色是 Deployment 还是 StatefulSet,不取决于它是
// 不是"数据库",而取决于它持有的状态有没有单写者(卷三 1.1):网关只持每
// 连接一份的连接态,可任意替换,是 Deployment;场景 / 战斗 / 世界 / 目录各
// 持单写者状态,需要稳定标识与有序替换,是 StatefulSet。
package deploy
import "fmt"
// Kind 是角色被部署成的工作负载类型。
type Kind string
const (
KindDeployment Kind = "Deployment"
KindStatefulSet Kind = "StatefulSet"
)
// Role 是 aegis 集群的一类进程,对应卷三 1.1 的五节点边界表。
type Role string
const (
RoleGateway Role = "gateway"
RoleScene Role = "scene"
RoleBattle Role = "battle"
RoleWorld Role = "world"
RoleDirectory Role = "directory"
)
// Stateful 报告该角色是否必须用 StatefulSet:持单写者状态的(场景实例、一
// 场对局、一个玩家、需要稳定成员身份做选主的目录)都是;只有网关不是。
func (r Role) Stateful() bool { return r != RoleGateway }
// Probe 是就绪 / 存活探针。
type Probe struct {
Path string
Port int
}
// Manifest 是本章校验用到的那部分清单字段。YAML 是权威来源,本模型是断言
// 的落点。
type Manifest struct {
Role Role
Kind Kind
Name string
Image string
Replicas int
ServiceName string // StatefulSet 必填:无头 Service,给 Pod 稳定标识
GracePeriodSeconds int // terminationGracePeriodSeconds
DrainBudgetSeconds int // 应用自己排空 + 落库的预算,必须小于宽限期
Readiness Probe
Liveness Probe
}
// Validate 检查一份清单在发布前必须满足的不变量。
func (m Manifest) Validate() error {
if m.Name == "" {
return fmt.Errorf("manifest has no name")
}
if m.Image == "" {
return fmt.Errorf("%s: no image", m.Name)
}
if m.Replicas < 1 {
return fmt.Errorf("%s: replicas must be >= 1", m.Name)
}
if m.Readiness.Path == "" || m.Readiness.Port == 0 {
return fmt.Errorf("%s: a readiness probe is required", m.Name)
}
want := KindDeployment
if m.Role.Stateful() {
want = KindStatefulSet
}
if m.Kind != want {
return fmt.Errorf("%s: role %s must be a %s, not %s", m.Name, m.Role, want, m.Kind)
}
if m.Kind == KindStatefulSet && m.ServiceName == "" {
return fmt.Errorf("%s: a StatefulSet needs a headless ServiceName", m.Name)
}
if m.DrainBudgetSeconds > m.GracePeriodSeconds {
return fmt.Errorf("%s: drain budget %ds exceeds grace period %ds",
m.Name, m.DrainBudgetSeconds, m.GracePeriodSeconds)
}
return nil
}
// ReferenceManifests 返回 4.1 用到的两份清单:无状态网关与有状态场景。
func ReferenceManifests() []Manifest {
return []Manifest{
{
Role: RoleGateway, Kind: KindDeployment, Name: "gateway",
Image: "registry.aegis.local/aegis-gateway:0.4.0", Replicas: 3,
GracePeriodSeconds: 30, DrainBudgetSeconds: 20,
Readiness: Probe{"/healthz", 9101}, Liveness: Probe{"/livez", 9101},
},
{
Role: RoleScene, Kind: KindStatefulSet, Name: "scene",
Image: "registry.aegis.local/aegis-scene:0.4.0", Replicas: 2,
ServiceName: "scene-headless",
GracePeriodSeconds: 60, DrainBudgetSeconds: 45,
Readiness: Probe{"/healthz", 9201}, Liveness: Probe{"/livez", 9201},
},
}
}
配套的测试断言四条不变量:两份清单都合法、网关是无状态 Deployment、场景是有头 Service 的 StatefulSet、排空预算超宽限期会被拒:
// file: internal/deploy/manifest_test.go
// tier: L1
// verify: go test ./internal/deploy/
package deploy
import "testing"
func byName(ms []Manifest, name string) Manifest {
for _, m := range ms {
if m.Name == name {
return m
}
}
return Manifest{}
}
func TestReferenceManifestsAreValid(t *testing.T) {
ms := ReferenceManifests()
if len(ms) != 2 {
t.Fatalf("ReferenceManifests() = %d, want 2", len(ms))
}
for _, m := range ms {
if err := m.Validate(); err != nil {
t.Errorf("%s: %v", m.Name, err)
}
}
}
func TestGatewayIsStatelessDeployment(t *testing.T) {
gw := byName(ReferenceManifests(), "gateway")
if gw.Kind != KindDeployment || gw.Role.Stateful() || gw.ServiceName != "" {
t.Errorf("gateway must be a stateless Deployment, got kind=%s stateful=%v svc=%q",
gw.Kind, gw.Role.Stateful(), gw.ServiceName)
}
}
func TestSceneIsStatefulSetWithStableIdentity(t *testing.T) {
sc := byName(ReferenceManifests(), "scene")
if sc.Kind != KindStatefulSet || !sc.Role.Stateful() || sc.ServiceName == "" {
t.Errorf("scene must be a StatefulSet with a headless ServiceName, got kind=%s svc=%q",
sc.Kind, sc.ServiceName)
}
}
func TestDrainBudgetMustFitGracePeriod(t *testing.T) {
m := byName(ReferenceManifests(), "scene")
m.DrainBudgetSeconds = m.GracePeriodSeconds + 1
if err := m.Validate(); err == nil {
t.Fatal("a drain budget larger than the grace period must be rejected")
}
}
本机实跑结果(go test ./internal/deploy/ -run 'Manifest|Gateway|Scene|Drain' -v,节选):
--- PASS: TestReferenceManifestsAreValid (0.00s)
--- PASS: TestGatewayIsStatelessDeployment (0.00s)
--- PASS: TestSceneIsStatefulSetWithStableIdentity (0.00s)
--- PASS: TestDrainBudgetMustFitGracePeriod (0.00s)
ok aegis/internal/deploy 0.269s
TestDrainBudgetMustFitGracePeriod 守的那条最值得单独说:它是「排空预算小于宽限期」这条架构约束的可失败形式。哪天有人把场景的排空预算调到 90 秒、却忘了把宽限期从 60 拉上去,测试会立刻变红——而不是等上线时 Pod 被 SIGKILL、玩家数据没落库,才回头查为什么。编排清单的价值不在于写得好看,而在于把清单里会出错的地方变成会失败的测试。
本节小结
这一节把「容器化与编排」拆成三件事:
- 镜像靠分层与多阶段构建做小做安全:依赖层与代码层分离让缓存命中,编译环境与运行环境分离让交付物只留一个静态二进制,
distroless+ 非 root 把攻击面压到最小。 - 工作负载类型由状态的所有权决定:无状态网关用 Deployment(副本可任意替换),有状态场景 / 战斗 / 世界 / 目录用 StatefulSet(稳定标识、
OrderedReady逐台替换、无头 Service)。 - 编排不变量要能失败:角色与类型必须匹配、StatefulSet 必须有无头 Service、排空预算必须小于宽限期——这三条都被编码进
Validate()与测试。
镜像与清单是发布的地基,但地基之上才是真正容易出事的部分:怎么把新版本换上去、旧版本怎么退下来。下一节就讲蓝绿、灰度、滚动这三种发布策略,各自换来什么、付出什么。
阅读导航:上一节:目录 · 下一节:4.2 蓝绿、灰度与滚动发布 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。