《MMO 服务器开发》4.2 蓝绿、灰度与滚动发布

蓝绿、灰度、滚动是三种不同的取舍,不是三种写法。这一节先把四个维度——切换速度、资源占用、回滚难度、状态兼容——摊开对照,再把每种策略编码成一个可回放的发布计划,用一份能跑的 Go 代码,把「蓝绿一次切换、灰度分档爬升、滚动逐台替换」变成可断言的发布过程与回滚路径。

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 则守另一件事:不管哪种策略,回滚路径的终点必须是流量归零,不能停在半路。

本节小结

这一节把「怎么把新版本换上去」拆成三件事:

  1. 三种策略是四组取舍,不是三种写法:蓝绿切换快、回滚易但费容量;灰度影响面小但要求双版本长期兼容;滚动省容量但切换渐进、回滚麻烦。
  2. 取舍要能被回放:把策略编码成「拉起 / 切换 / 退场」的发布计划,回放它就能算出切换步数、额外容量峰值与回滚步数——那张对照表的数字是代码算出来的,不是估的。
  3. 结论要能被断言:三种策略的取舍排序、回滚路径的终点,都被写成会失败的测试,防止下一个人无意中破坏设计约束。

三种策略都会在某一刻让旧实例「走」——但旧实例不能一收到信号就立刻消失,它得先把在途请求排空、把状态落库。下一节就讲这件事:优雅停服,以及有状态服务的代次交接。

阅读导航:上一节:4.1 容器化与编排 · 下一节:4.3 优雅停服与有状态服务发布 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标