4.2 蓝绿、灰度与滚动发布
上一节把镜像和清单做完了,节点能被编排系统拉起、替换、扩缩。但「怎么把新版本换上去、旧版本怎么退下来」还没回答。这不是一个「用哪种工具」的问题——Kubernetes 的 Deployment 默认就带滚动更新——而是一个取舍问题:切换要多快、要多花多少机器、出事回滚有多难、新旧版本的状态兼不兼容。
游戏服务端在这四件事上比无状态 Web 服务苛刻得多。Web 服务可以「随便」滚动,因为实例之间没有状态;游戏节点里有地图实例、玩家、对局,一次不当的切换可能让两拨玩家看到不同的世界。所以发布策略的选择,本质上是在「影响面」和「成本」之间选一个点:影响面越小越安全,成本越高。
这一节先把三种策略放到四个维度上对照,再把它们编码成可回放的发布计划——不是画一张流程图,而是写一份能跑、能断言发布过程与回滚路径的代码。这样「蓝绿最快、滚动最省」就不再是口号,而是测试里会失败的断言。
三种策略,四个维度
- 蓝绿(blue-green):旧版本(blue)继续服务,新版本(green)整队拉起并验证,然后一次把流量全切过去。blue 不马上退,留作回滚兜底。特点是切换原子、回滚极快,代价是要准备一整套重复容量。
- 灰度(canary):新版本先接一小部分流量(比如 10%),观察指标,再逐档放大到 50%、100%,同时按比例退掉旧实例。坏版本的影响面被压到那一小部分,但两版本要长期并存,对状态兼容要求最高。
- 滚动(rolling):逐台替换,多起一台、切它那份流量、退掉一台旧的,如此循环。额外容量最省(只多一台),但切换是渐进的、最不原子,回滚也最麻烦——已经换掉的实例要一台台倒回去。
把这三种策略放到四个维度上,用稳态 6 副本的集群算出来的对照如下(数字来自下一节的代码,不是估的):
| 策略 | 切换速度(切换步数) | 额外容量(峰值多跑副本) | 回滚难度(倒回步数) | 状态兼容要求 |
|---|---|---|---|---|
| 蓝绿 | 1 步,原子 | 6(整队复制) | 1 步,切回即可 | 新旧版本须能同时读写同一份状态 |
| 灰度 | 3 步,渐进 | 3(约半队) | 3 步,逐档退回 | 最高,两版本长期并存 |
| 滚动 | 6 步,逐台 | 1(多起一台) | 6 步,逐台倒回 | 相邻两版本须双向兼容 |
这张表要读出的不是「哪个最好」,而是它们不可兼得:想要切换快、回滚易,就得付整队容量(蓝绿);想要省容量,就得接受切换渐进、回滚麻烦(滚动);想要影响面小,就得付长期双版本并存的状态兼容成本(灰度)。选哪个,取决于这次改动的风险有多大——改一个数值常量,滚动就够;改状态结构,值得上蓝绿或灰度。
把策略写成可回放的发布计划
流程图谁都会画,但画完没法验证。下面把三种策略编码成「发布计划」:一个 Step 列表,每步要么拉起新副本、要么切流量、要么退旧副本。回放计划就能算出四个维度的数字——切换步数、额外容量峰值、回滚步数。这段是 L1,go test 可以直接跑:
// file: internal/deploy/release.go
// tier: L1
// verify: go test ./internal/deploy/
// 4.2 的三种发布策略。蓝绿、灰度、滚动不是三种写法,而是三组取舍:切换多
// 快、多花多少容量、回滚多难、状态兼容要求多严。每种策略被编码成一个可回
// 放的「发布计划」,回放它就能算出取舍表里的数字——表是计划的结果,不是
// 拍脑袋的结论。
package deploy
// Strategy 是 4.2 对照的三种发布形态。
type Strategy string
const (
// BlueGreen 把新版本整队拉起、验证后一步切流量,回滚就是一步切回去。
BlueGreen Strategy = "blue-green"
// Canary 让新版本按递增比例接流量,同时按比例退掉旧实例,把坏版本的影响面压小。
Canary Strategy = "canary"
// Rolling 逐台替换,额外容量最省,但最难回滚。
Rolling Strategy = "rolling"
)
// StepKind 是发布计划里的一步动作。
type StepKind string
const (
StepProvision StepKind = "provision" // 拉起 N 个新版本副本
StepShift StepKind = "shift-traffic" // 把流量切给新版本
StepVerify StepKind = "verify" // 健康检查 / 冒烟
StepRetire StepKind = "retire" // 退掉 N 个旧版本副本
)
// Step 是一步动作。Percent 是这一步之后新版本累计持有的流量比例;Replicas
// 是这一步起停的副本数。
type Step struct {
Kind StepKind
Percent int
Replicas int
}
// Plan 是某种策略的发布计划。
type Plan struct {
Strategy Strategy
Steps []Step
}
// PlanFor 为服务 replicas 个副本的策略生成标准发布计划。
func PlanFor(s Strategy, replicas int) Plan {
switch s {
case BlueGreen:
// 整队拉起 green、验证,然后一步切换。
return Plan{s, []Step{
{Kind: StepProvision, Replicas: replicas},
{Kind: StepVerify},
{Kind: StepShift, Percent: 100},
{Kind: StepRetire, Replicas: replicas},
}}
case Canary:
// 新版本随流量爬升,旧实例按比例退场,两队合计不超过 1.5 倍规模。
half := (replicas + 1) / 2
return Plan{s, []Step{
{Kind: StepProvision, Replicas: 1},
{Kind: StepShift, Percent: 10},
{Kind: StepProvision, Replicas: half - 1},
{Kind: StepShift, Percent: 50},
{Kind: StepRetire, Replicas: replicas - half},
{Kind: StepProvision, Replicas: replicas - half},
{Kind: StepShift, Percent: 100},
{Kind: StepRetire, Replicas: half},
}}
case Rolling:
// 逐台替换:先多起一台、切它那份流量、再退掉一台旧实例,如此循环。
steps := make([]Step, 0, replicas*3)
for i := 1; i <= replicas; i++ {
steps = append(steps,
Step{Kind: StepProvision, Replicas: 1},
Step{Kind: StepShift, Percent: i * 100 / replicas},
Step{Kind: StepRetire, Replicas: 1},
)
}
return Plan{s, steps}
default:
return Plan{}
}
}
// Trace 是回放一份计划得到的代价,即 4.2 对照的四个维度。
type Trace struct {
// PeakExtraReplicas 是整个过程里最多同时多跑了几个副本:用钱买的额外容量。
PeakExtraReplicas int
// SwitchSteps 是流量切换的次数,越少切换越原子、越快。
SwitchSteps int
// RollbackSteps 是要恢复旧版本需要倒回的流量步数,也就是策略留下的回滚点。
RollbackSteps int
}
// Trace 把计划放到 replicas 个副本的稳态上回放。
func (p Plan) Trace(replicas int) Trace {
old, fresh := replicas, 0
peak, shifts := 0, 0
for _, s := range p.Steps {
switch s.Kind {
case StepProvision:
fresh += s.Replicas
case StepRetire:
old -= s.Replicas
case StepShift:
shifts++
}
if extra := old + fresh - replicas; extra > peak {
peak = extra
}
}
return Trace{PeakExtraReplicas: peak, SwitchSteps: shifts, RollbackSteps: shifts}
}
// Rollback 返回把流量倒回旧版本的动作序列,按顺序排列。
func (p Plan) Rollback(replicas int) []Step {
var pcts []int
for _, s := range p.Steps {
if s.Kind == StepShift {
pcts = append(pcts, s.Percent)
}
}
out := make([]Step, 0, len(pcts))
for i := len(pcts) - 1; i >= 0; i-- {
prev := 0
if i > 0 {
prev = pcts[i-1]
}
out = append(out, Step{Kind: StepShift, Percent: prev})
}
return out
}
Trace 是这份代码的关键:它把计划当程序回放一遍,跟踪「旧实例还有几个、新实例起了几个」,每一步都记录总副本数相对稳态多出来多少,取最大值就是额外容量峰值。蓝绿的峰值是整队(6),滚动是 1——这个差距就是取舍表里「额外容量」那一列。
发布过程与回滚路径都要断言
光有回放还不够,要把结论钉成断言。下面这组测试在稳态 6 副本上,对三种策略分别断言它们的切换步数、额外容量峰值和回滚步数,最后再断言一条跨策略的不变量——三种策略的取舍排序不能反:
// file: internal/deploy/release_test.go
// tier: L1
// verify: go test ./internal/deploy/
package deploy
import "testing"
const fleet = 6
func TestBlueGreenSwitchesInOneStep(t *testing.T) {
tr := PlanFor(BlueGreen, fleet).Trace(fleet)
if tr.SwitchSteps != 1 {
t.Errorf("blue-green switch steps = %d, want 1 (atomic switch)", tr.SwitchSteps)
}
if tr.PeakExtraReplicas != fleet {
t.Errorf("blue-green peak extra = %d, want %d (a full duplicate fleet)",
tr.PeakExtraReplicas, fleet)
}
if tr.RollbackSteps != 1 {
t.Errorf("blue-green rollback steps = %d, want 1 (flip back)", tr.RollbackSteps)
}
}
func TestCanaryRampsInSteps(t *testing.T) {
tr := PlanFor(Canary, fleet).Trace(fleet)
if tr.SwitchSteps < 3 {
t.Errorf("canary switch steps = %d, want >= 3 (gradual ramp)", tr.SwitchSteps)
}
if tr.PeakExtraReplicas >= fleet {
t.Errorf("canary peak extra = %d, must stay below a full duplicate (%d)",
tr.PeakExtraReplicas, fleet)
}
if tr.RollbackSteps != tr.SwitchSteps {
t.Errorf("canary rollback steps = %d, want %d (one rollback point per shift)",
tr.RollbackSteps, tr.SwitchSteps)
}
}
func TestRollingUsesLeastCapacity(t *testing.T) {
tr := PlanFor(Rolling, fleet).Trace(fleet)
if tr.PeakExtraReplicas != 1 {
t.Errorf("rolling peak extra = %d, want 1 (surge by one)", tr.PeakExtraReplicas)
}
if tr.SwitchSteps != fleet {
t.Errorf("rolling switch steps = %d, want %d (one per replica)", tr.SwitchSteps, fleet)
}
}
func TestTradeoffOrdering(t *testing.T) {
bg := PlanFor(BlueGreen, fleet).Trace(fleet)
ca := PlanFor(Canary, fleet).Trace(fleet)
ro := PlanFor(Rolling, fleet).Trace(fleet)
// 容量:滚动 < 灰度 < 蓝绿。
if !(ro.PeakExtraReplicas < ca.PeakExtraReplicas && ca.PeakExtraReplicas < bg.PeakExtraReplicas) {
t.Errorf("capacity order broken: rolling=%d canary=%d blue-green=%d",
ro.PeakExtraReplicas, ca.PeakExtraReplicas, bg.PeakExtraReplicas)
}
// 切换速度:蓝绿最原子,滚动最不原子。
if !(bg.SwitchSteps < ca.SwitchSteps && ca.SwitchSteps < ro.SwitchSteps) {
t.Errorf("switch-speed order broken: blue-green=%d canary=%d rolling=%d",
bg.SwitchSteps, ca.SwitchSteps, ro.SwitchSteps)
}
// 回滚:蓝绿最容易,滚动最难。
if !(bg.RollbackSteps < ro.RollbackSteps) {
t.Errorf("rollback order broken: blue-green=%d rolling=%d",
bg.RollbackSteps, ro.RollbackSteps)
}
}
func TestRollbackPathEndsAtZero(t *testing.T) {
for _, s := range []Strategy{BlueGreen, Canary, Rolling} {
p := PlanFor(s, fleet)
path := p.Rollback(fleet)
if len(path) != p.Trace(fleet).RollbackSteps {
t.Errorf("%s: rollback path length %d != rollback steps %d",
s, len(path), p.Trace(fleet).RollbackSteps)
}
if len(path) == 0 || path[len(path)-1].Percent != 0 {
t.Errorf("%s: rollback path must end with traffic back at 0", s)
}
}
}
本机实跑结果(go test ./internal/deploy/ -run 'BlueGreen|Canary|Rolling|Tradeoff|Rollback' -v,节选):
--- PASS: TestBlueGreenSwitchesInOneStep (0.00s)
--- PASS: TestCanaryRampsInSteps (0.00s)
--- PASS: TestRollingUsesLeastCapacity (0.00s)
--- PASS: TestTradeoffOrdering (0.00s)
--- PASS: TestRollbackPathEndsAtZero (0.00s)
ok aegis/internal/deploy 0.248s
TestTradeoffOrdering 是这三条断言里最有价值的一条:它把「滚动最省容量、蓝绿切换最快、蓝绿回滚最容易」这三句取舍结论,变成三个会失败的排序断言。哪天有人为了省事,把蓝绿改成「逐台切换」,bg.SwitchSteps < ro.SwitchSteps 立刻不成立,测试变红——他会在提交前就知道自己破坏了一条设计约束。TestRollbackPathEndsAtZero 则守另一件事:不管哪种策略,回滚路径的终点必须是流量归零,不能停在半路。
本节小结
这一节把「怎么把新版本换上去」拆成三件事:
- 三种策略是四组取舍,不是三种写法:蓝绿切换快、回滚易但费容量;灰度影响面小但要求双版本长期兼容;滚动省容量但切换渐进、回滚麻烦。
- 取舍要能被回放:把策略编码成「拉起 / 切换 / 退场」的发布计划,回放它就能算出切换步数、额外容量峰值与回滚步数——那张对照表的数字是代码算出来的,不是估的。
- 结论要能被断言:三种策略的取舍排序、回滚路径的终点,都被写成会失败的测试,防止下一个人无意中破坏设计约束。
三种策略都会在某一刻让旧实例「走」——但旧实例不能一收到信号就立刻消失,它得先把在途请求排空、把状态落库。下一节就讲这件事:优雅停服,以及有状态服务的代次交接。
阅读导航:上一节:4.1 容器化与编排 · 下一节:4.3 优雅停服与有状态服务发布 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。