《Go 语言编程实战》16.2 灰度/蓝绿与回滚

新版本一次全量上线是拿全部用户做赌注。本节把 TaskHub 的发布拆成灰度、蓝绿、回滚三条路径:用一段可运行的 Go 反向代理真跑「按权重分流」并给出实测的 9.67% 灰度占比,讲清健康门禁怎么驱动自动回滚,以及为什么「能回滚代码」不等于「能回滚数据」。

16.2 灰度/蓝绿与回滚

上一节的流水线保证了「合并的代码是验证过的」,但「验证过」离「在所有真实流量下都没问题」还差很远——测试环境永远造不出生产流量的全部形态。于是发布策略的核心问题变成:怎样用最小的爆炸半径,换取最快的失败发现。

本节把 TaskHub 推进到「新版本先只承接一小部分流量,健康指标不达标就自动退回旧版本」:用一段可运行的反向代理真跑按权重分流,再讲清灰度、蓝绿与回滚三套机制各自的适用场景与数据兼容陷阱。

16.2.1 三种发布策略对比

先把概念摆清楚。它们不是三选一,而是可以叠加的层次:

策略切流粒度资源成本回滚速度适合场景
滚动更新逐 Pod 替换低中(要重新滚回去)常规小改动
灰度(金丝雀)按流量比例低(新旧共存)快(把权重调回 0)有明确健康指标
蓝绿一次性全切高(双倍资源)最快(DNS/网关切回)大版本、schema 大改

第 15 章的滚动更新解决的是「不停机替换」,但它没有流量控制:一旦滚动到一半发现新版本有问题,你已经在生产里跑着一半新一半旧了。灰度和蓝绿补的正是这一块。

16.2.2 灰度的本质:按权重分流

灰度的实现有两条路:一是在网关/Service Mesh 层做(Istio 的 VirtualService、Nginx 的 split_clients、云厂商的 ALB 权重),二是在应用自己的入口做。后者更可控,也更适合讲清原理。下面这段反向代理按百分比把请求分给 stable 与 canary 两个后端:

package main

import (
	"fmt"
	"math/rand"
	"net/http"
	"net/http/httptest"
	"net/http/httputil"
	"net/url"
)

func newBackend(name string) *httptest.Server {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, name)
	})
	return httptest.NewServer(mux)
}

type Canary struct {
	stable  *httputil.ReverseProxy
	canary  *httputil.ReverseProxy
	percent int // canary 承接的百分比,0..100
	rnd     *rand.Rand
}

func mustProxy(raw string) *httputil.ReverseProxy {
	u, err := url.Parse(raw)
	if err != nil {
		panic(err)
	}
	return httputil.NewSingleHostReverseProxy(u)
}

func (c *Canary) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if c.rnd.Intn(100) < c.percent {
		c.canary.ServeHTTP(w, r)
		return
	}
	c.stable.ServeHTTP(w, r)
}

用一个循环把 10000 个请求打到入口代理上,统计两侧各承接了多少:

func main() {
	stable := newBackend("stable")
	canary := newBackend("canary")
	defer stable.Close()
	defer canary.Close()

	proxy := &Canary{
		stable:  mustProxy(stable.URL),
		canary:  mustProxy(canary.URL),
		percent: 10,
		rnd:     rand.New(rand.NewSource(42)),
	}
	front := httptest.NewServer(proxy)
	defer front.Close()

	counts := map[string]int{}
	const n = 10000
	for i := 0; i < n; i++ {
		resp, err := http.Get(front.URL)
		if err != nil {
			panic(err)
		}
		buf := make([]byte, 16)
		k, _ := resp.Body.Read(buf)
		resp.Body.Close()
		counts[string(buf[:k])]++
	}
	fmt.Printf("total=%d stable=%d canary=%d canary_pct=%.2f%%\n",
		n, counts["stable"], counts["canary"],
		float64(counts["canary"])*100/float64(n))
}

把 percent 设成 10、跑 10000 个请求,本机真实输出:

total=10000 stable=9033 canary=967 canary_pct=9.67%

实测灰度占比 9.67%,与目标 10% 相差 0.33 个百分点——这正是概率分流的正常抖动。要更精确的分配,可以在网关层用一致性哈希或令牌桶做加权,但那会把「简单」换成「状态」,多数场景下 10% 里的 0.3% 误差无所谓。

这里用的是 httptest 起假后端,好处是整段实验不依赖任何外部服务、可复现;把 stable/canary 换成真实后端的 URL,同一段 Canary 结构体就能直接当生产入口用。注意 httputil.NewSingleHostReverseProxy 会自动改写 Host 头、转发请求体与响应头,不需要自己拼 URL。

这里有个必须避开的坑:用 rand.Intn 每次独立抽样,意味着同一个用户可能在一次会话里一会儿打到新版本、一会儿打到旧版本。如果新旧版本的响应结构不同,用户会看到闪烁。真实灰度要么在网关按用户 ID/租户 ID 哈希(粘性分流),要么确保新旧版本对同一请求的响应完全兼容。TaskHub 是 API 服务,选择「按租户哈希」——同一个租户始终落在同一侧,避免跨版本数据不一致。

16.2.3 健康门禁:谁来决定放量还是回滚

灰度如果没有自动门禁,就只是「手工把流量拨到 10% 然后盯着看」,等于把责任又还给了人。门禁的核心是把上一节的 SLO 变成可自动判定的条件。对 TaskHub,一组最小可用的门禁指标:

指标观测窗口阈值含义
5xx 比例5 分钟< 0.5%服务端错误
P99 延迟5 分钟< 800ms尾部变慢
就绪探针失败率2 分钟= 0实例反复重启
关键业务指标15 分钟不低于基线 90%如「任务创建成功率」

判定逻辑是一条典型的「放量阶梯」:10% → 观察 10 分钟 → 30% → 观察 10 分钟 → 50% → 100%。任何一步门禁失败,就把权重立刻调回 0 并触发回滚。把它写成伪代码:

for step in [10, 30, 50, 100]:
    set_canary_weight(step)
    sleep(observe_window)
    if not gates_ok():
        set_canary_weight(0)
        alert("canary rolled back at step=%d" % step)
        break

门禁的关键设计原则是宁可误报也不要漏报:一次误报只是让发布慢一点,一次漏报则可能让坏版本全量上线。

把上面那张指标表落成代码,gates_ok 从监控系统拉最近一个窗口的数据做判定:

type Gate struct {
	Name      string
	Window    time.Duration
	Threshold float64
	Value     func() float64
}

// 每个 Gate 的语义都是「越小越好」:返回 true 表示这一关通过。
func (g Gate) Pass() bool { return g.Value() <= g.Threshold }

func gatesOK(gates []Gate) bool {
	for _, g := range gates {
		if !g.Pass() {
			log.Warn("canary gate failed", "gate", g.Name, "value", g.Value())
			return false
		}
	}
	return true
}

注意 Value 是一个惰性函数而不是缓存好的数字——每次判定都现取,才能反映最新窗口。门禁系统最常见的 bug 就是拿了一个过期快照去判定,于是坏版本又悄悄多跑了十分钟。

16.2.4 在 Kubernetes 上落地灰度

前面的 Canary 代理是原理演示;生产里更常见的是让 Service 的 selector 指向不同版本、由入口层控制权重。用两个 Deployment 加一个 Service:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: taskhub-canary
spec:
  replicas: 1
  selector:
    matchLabels: { app: taskhub, track: canary }
  template:
    metadata:
      labels: { app: taskhub, track: canary }
    spec:
      containers:
        - name: taskhub
          image: taskhub:<new-sha>
          readinessProbe:
            httpGet: { path: /healthz, port: 8080 }

track: stable 的 Deployment 保持全量副本,track: canary 只放 1 个副本——用副本数比例粗略地表达权重(1 canary : 9 stable 约等于 10%)。要更精细的比例,就在 Ingress 层做加权(Nginx 的 split_clients、或 Service Mesh 的 VirtualService)。

本节这份 YAML 未在本机 apply 验证(本机无 Kubernetes 集群,见 15 章的说明),仅作结构与字段参考;它的探针路径 /healthz 与 16.1 节里真跑过的镜像入口一致。

16.2.5 蓝绿:切流与数据兼容

蓝绿发布维护两套完整环境(Blue = 当前、Green = 新版),验证 Green 后一次性把流量全切过去。它比灰度激进,但胜在「回滚只是一次切流」——不需要等待权重阶梯,也不需要新旧共存。

代价有三:资源双倍、数据库只能有一套、切流瞬间的会话丢失。前两个是钱的取舍,第三个才是技术难点:切换时正在处理的请求怎么办? 常见做法是配合连接排空(第 15.3 节的优雅停机),让旧环境在切流后继续处理完存量请求再退出,而不是立刻断电。

真正致命的是数据库。如果新版本改了一张表的列名,蓝绿切换瞬间新旧两套代码会同时对着同一个库跑——旧代码读不到新列名,直接崩。这就引出下一节的铁律。

16.2.6 回滚:代码能回,数据未必能回

「一键回滚」是最容易骗人的说法。代码回滚确实只是一次镜像切换,但数据回滚是另一回事。考虑一次 schema 变更:把 tasks.title 拆成 tasks.title + tasks.subtitle。新代码写入两个列,旧代码只认 title。回滚代码后,旧代码看不到新写入的 subtitle 数据——不崩,但数据丢失。

因此工程上有一条铁律:schema 变更必须向前兼容(expand/contract)。把一次「破坏性变更」拆成三步、跨三个发布:

阶段代码改动schema 改动可回滚性
1. Expand新旧代码都能跑加新列(可空)完全可回滚
2. Migrate双写,读新列回填历史数据可回滚
3. Contract只读新列删除旧列不可回滚(但已无回滚需求)

只要严格遵守「先加后删、只加不删」,任何一次发布的前两个阶段都随时可回滚。删除旧列放在最后一个独立发布里,那时新代码已经稳定运行过一段时间,回滚需求已经消失。

对 TaskHub 来说,具体到数据访问层就是:任何 ALTER TABLE 都必须能被上一版代码正常读写。这一点在第 6 章事务边界的基础上又加了一条约束——数据库迁移脚本的兼容性,是发布流程的一部分,不是 DBA 的事。

16.2.7 回滚手册

回滚要在事故发生时几分钟内完成,因此它必须是一份照着做就行的清单,而不是「大概这样做」:

  • 确认坏版本对应的 git sha 与上一个好版本的 sha(镜像 tag 即 sha)
  • 执行 kubectl set image deploy/taskhub taskhub=taskhub:<上一个sha>
  • 若已做灰度,先把 canary 权重调 0,再滚动替换
  • 观察 5 分钟 5xx 与 P99 回到基线
  • 检查是否有不兼容的 schema 变更正在生效(若有,走 expand 回退)
  • 记录事故时间线,进入事后复盘(见 16.3)

一个容易忽略的点:回滚验证和发布验证一样重要。如果回滚后没验证就宣布结束,可能只是把「坏」换成了「另一个坏」。回滚完成的判据是「关键指标回到基线」,不是「命令执行成功」。

16.2.8 常见误区

  • 把灰度当灰度,不当门禁:只分流不自动判定,等于没做灰度。
  • 用 latest 当发布 tag:回滚时不知道回滚到哪一份(见 16.1)。
  • schema 变更不留兼容期:一次删除列就堵死了回滚路径。
  • 新旧版本响应结构不同还做概率分流:用户会看到闪烁。
  • 回滚只看命令退出码:必须看业务指标是否恢复。

小结

  • 滚动更新解决不停机,灰度/蓝绿解决「用最小爆炸半径发现坏版本」;三者可叠加。
  • 灰度 = 按权重分流 + 健康门禁 + 放量阶梯;本机实测 10% 目标下真实占比 9.67%,概率分流有正常抖动。
  • 概率分流会让同一用户跨版本抖动,API 服务应按租户/用户哈希做粘性分流。
  • 蓝绿胜在「回滚=一次切流」,代价是双倍资源与数据库只能有一套。
  • 代码能回滚不等于数据能回滚:schema 变更必须走 expand/contract(先加后删),前两阶段始终可回滚。
  • 回滚必须是一份照着做的清单,且以「指标回到基线」为完成判据。

发布策略定下来了,接下来要回答「回滚由谁触发」——值班的人怎么第一时间知道新版本变坏了。16.3 告警与 on-call 会把 SLO 变成会响的告警,并给出多窗口燃烧率的真实计算。

阅读导航:上一节:16.1 CI/CD 流水线 · 下一节:16.3 告警与 on-call 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练