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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。