灰度发布与回滚:把故障成本降两个数量级
影响面乘以持续时间等于故障成本。五种发布策略对比、三个灰度维度、必须提前定好的回滚判据,以及数据层的 Expand-Contract 模式。
一个「小改动」上线,5 分钟后订单量掉了 40%。回滚花了 25 分钟——因为不知道该怎么快速回退,先查日志、再找发布包、再手动改镜像 tag。
事后算账:修复代码只写了 10 行,上线 30 秒,故障 30 分钟。 这个比例说明问题不在开发,而在发布方式。
灰度发布的核心不是「高级」,而是把「全量替换」变成「可控放量」——让问题在影响 1% 用户时就被发现,而不是 100% 出事。
一、五种发布策略
| 策略 | 原理 | 资源消耗 | 回滚速度 | 适用 |
|---|---|---|---|---|
| 停机发布 | 停旧起新 | 1× | 慢(重新部署) | 内部系统、可接受停机 |
| 滚动发布 | 逐个替换 Pod | 1.2× | 中 | k8s 默认 |
| 蓝绿发布 | 两套环境,流量切换 | 2× | 秒级 | 关键服务、预算充足 |
| 金丝雀/灰度 | 小比例放量,逐步扩 | 1.1× | 快 | 大部分线上服务 |
| A/B 测试 | 按用户特征分流 | 1.2× | 快 | 需要业务指标对比 |
滚动发布的问题
k8s 的默认策略就是滚动,很多人以为这就够了:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
但它有个根本问题:流量是均匀打散的。新版本第一个 Pod 起来后,立刻就会接到 1/N 的流量。如果新版本有问题,在「滚动到一半」的时候,你已经有 50% 的流量在跑坏代码。
而且滚动过程中新旧版本共存,如果两个版本的接口不兼容,会出现随机失败。
滚动发布的定位是「降低发布风险」,不是「控制发布风险」。 真正要可控,需要灰度。
二、灰度发布:三个维度
维度 1:按流量比例
最常用。用 Service Mesh 或 Ingress 做权重分流:
# Istio VirtualService
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: api
spec:
hosts: [api.example.com]
http:
- route:
- destination:
host: api
subset: v1
weight: 95
- destination:
host: api
subset: v2
weight: 5
配合两个 Deployment(v1 和 v2 label),逐步调整权重:5% → 20% → 50% → 100%。
关键是每一档要「观察足够久」。5% 流量跑 10 分钟,如果是 1000 QPS,那就是 5000 次请求——足够暴露 1% 概率的错误。
维度 2:按用户特征
不是按比例随机,而是按「用户 ID 尾号」「地域」「设备」分流:
# 只让内部员工先看到新版本(最安全的灰度)
http:
- match:
- headers:
x-internal-user:
exact: "true"
route:
- destination: { host: api, subset: v2 }
- route:
- destination: { host: api, subset: v1 }
或者按 cookie:
-- Nginx 按 cookie 分流
set $target "v1";
if ($cookie_gray = "v2") {
set $target "v2";
}
proxy_pass http://api_$target;
推荐顺序:内部员工 → 1% 白名单用户 → 5% 随机 → 50% → 100%。
内部员工先看,因为出了问题他们能直接反馈,而且影响面最小。
维度 3:按请求特征
# 只让只读接口走新版本,写接口留旧版本
http:
- match:
- method: { exact: GET }
route:
- destination: { host: api, subset: v2 }
- route:
- destination: { host: api, subset: v1 }
这个适合「新版本只改了查询逻辑」这类场景,风险更低。
三、Kubernetes 原生方案:没有灰度
k8s 原生不支持按比例分流。 Service 的负载均衡是轮询,没有权重概念。
三种替代:
方案 A:两个 Deployment + 按副本数比例
# v1 有 19 个副本,v2 有 1 个 → 约 5% 流量
apiVersion: apps/v1
kind: Deployment
metadata: { name: api-v2 }
spec:
replicas: 1
selector: { matchLabels: { app: api, version: v2 } }
template:
metadata:
labels: { app: api, version: v2 }
spec:
containers:
- name: api
image: api:v2
问题:比例是粗粒度的。 1/20 = 5%,想调到 2% 做不到。而且副本数变化会随时改变比例。
方案 B:Nginx Ingress 的 canary 注解
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api-canary
annotations:
nginx.ingress.kubernetes.io/canary: "true"
nginx.ingress.kubernetes.io/canary-weight: "5"
spec:
rules:
- host: api.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: api-v2, port: { number: 80 } }
改 canary-weight 的注解值就调整了比例。
还可以按 header:
nginx.ingress.kubernetes.io/canary-by-header: "x-gray"
nginx.ingress.kubernetes.io/canary-by-header-value: "true"
方案 C:Service Mesh(Istio / Linkerd)
最灵活,支持权重、header、query、source label 等所有维度。代价是引入 sidecar(每个 Pod +150MB 内存 + 1~2ms 延迟)。
四、回滚:必须一条命令
这是灰度发布里最容易被忽略、但最重要的部分。
k8s 原生回滚
# 看历史
kubectl rollout history deployment/api -n prod
# 回滚到上一个版本
kubectl rollout undo deployment/api -n prod
# 回滚到指定 revision
kubectl rollout undo deployment/api -n prod --to-revision=42
但 rollout undo 有几个限制:
- 历史版本数量有限。默认
revisionHistoryLimit: 10,超过就找不到了。 - 回滚后
current会变成新的 revision,历史顺序会乱。 - 只回滚 Deployment spec,不回滚 ConfigMap / Secret。如果配置也改了,回滚不彻底。
spec:
revisionHistoryLimit: 20 # 调大一点
灰度场景的回滚
如果用的是「两个 Deployment + 权重」方案,回滚就是把权重调回 0:
kubectl patch virtualservice api -n prod --type=json \
-p='[{"op":"replace","path":"/spec/http/0/route/0/weight","value":100},
{"op":"replace","path":"/spec/http/0/route/1/weight","value":0}]'
秒级生效,不动任何 Pod。 这是灰度最大的价值——回滚成本和发布成本一样低。
数据层回滚:最难的部分
代码回滚容易,数据回滚几乎不可能:
| 变更 | 能否回滚 |
|---|---|
| 新增字段(可空) | 能(留着不用) |
| 删除字段 | 不能(数据已丢) |
| 改字段类型 | 需要反向转换,可能丢精度 |
| 数据迁移 | 需要备份或双写 |
解决方案是「Expand-Contract」:所有数据库变更拆成三步,每步都向前兼容。
第 1 次发布:
- 加新字段 new_col(可空)
- 代码双写 old_col 和 new_col
- 读还是读 old_col
← 此时回滚代码是安全的,old_col 仍有完整数据
第 2 次发布:
- 读改为 new_col
- 继续双写
← 此时回滚仍安全,old_col 还在
第 3 次发布(确认稳定后):
- 停止写 old_col
- 下一个迭代再删 old_col
任何一步都能安全回滚,因为旧字段一直保留着。
五、自动回滚:让机制代替人
人工回滚的平均耗时是 15~30 分钟(发现 → 决策 → 执行)。自动回滚能压到 2 分钟。
方案 A:k8s 的 readinessProbe 自动阻断
spec:
progressDeadlineSeconds: 300 # 5 分钟没进展就标记失败
strategy:
rollingUpdate:
maxUnavailable: 0 # 关键:不允许任何不可用
maxSurge: 1
maxUnavailable: 0 意味着新 Pod 必须 ready 之后才停旧 Pod。如果新版本起不来,滚动会卡住,旧版本继续服务——天然的保护。
方案 B:Argo Rollouts 的自动化分析
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: api
spec:
replicas: 10
strategy:
canary:
steps:
- setWeight: 5
- pause: { duration: 5m }
- analysis:
templates:
- templateName: success-rate
- setWeight: 20
- pause: { duration: 5m }
- analysis:
templates:
- templateName: success-rate
- setWeight: 50
- pause: { duration: 10m }
- setWeight: 100
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: success-rate
spec:
metrics:
- name: success-rate
interval: 1m
successCondition: result[0] >= 0.95
failureLimit: 1
provider:
prometheus:
address: http://prometheus:9090
query: |
sum(rate(http_server_requests_total{status!~"5..",version="canary"}[2m]))
/ sum(rate(http_server_requests_total{version="canary"}[2m]))
如果成功率低于 95%,Argo 自动回滚。 这是灰度发布自动化的标准做法。
方案 C:自己写的健康检查脚本
不引入 Argo 的话,可以在 CI 里做:
#!/bin/bash
# canary-deploy.sh
set -e
deploy() { kubectl set image deployment/api-canary api=$IMAGE; }
rollback() {
echo "!! 回滚"
kubectl scale deployment/api-canary --replicas=0
exit 1
}
check_metrics() {
local rate
rate=$(curl -s "http://prometheus:9090/api/v1/query?query=\
sum(rate(http_server_requests_total{status=~\"5..\",version=\"canary\"}[2m]))\
/sum(rate(http_server_requests_total{version=\"canary\"}[2m]))" \
| jq -r '.data.result[0].value[1] // "0"')
echo " 错误率: $rate"
awk -v r="$rate" 'BEGIN { exit !(r < 0.05) }'
}
echo "1. 部署 canary"
deploy
echo "2. 等待就绪"
kubectl rollout status deployment/api-canary --timeout=120s || rollback
echo "3. 观察 5 分钟"
for i in $(seq 1 5); do
sleep 60
echo " 第 $i 分钟"
check_metrics || { echo " 错误率超标"; rollback; }
done
echo "4. 扩到 50%"
kubectl scale deployment/api-canary --replicas=5
sleep 300
check_metrics || rollback
echo "5. 全量"
kubectl scale deployment/api --replicas=0
kubectl set image deployment/api api=$IMAGE
kubectl rollout status deployment/api --timeout=300s
echo "完成"
六、灰度期间的四个观察指标
不能只看「有没有报错」。要看四组:
1. 黄金信号对比
新版本错误率 vs 旧版本错误率 → 差异 > 1% 就该停
新版本 P99 vs 旧版本 P99 → 差异 > 30% 就该查
用 Prometheus 按 version label 分组对比:
sum by (version) (rate(http_server_requests_total{status=~"5.."}[5m]))
/ sum by (version) (rate(http_server_requests_total[5m]))
2. 业务指标
技术指标正常但业务指标跌了,同样要停:
下单成功率、支付转化率、页面停留时长
这类指标需要埋点,但价值最高。技术监控看的是「服务活着」,业务监控看的是「服务有用」。
3. 资源指标
新版本的 CPU / 内存是否显著高于旧版本:
avg by (version) (rate(container_cpu_usage_seconds_total{container="api"}[5m]))
新版本内存涨 30%,说明可能有泄漏或者缓存配置变了。
4. 日志异常
sum by (version) (rate(logback_events_total{level="error"}[5m]))
即使这些错误没有导致 5xx(比如被 catch 住了),也说明有问题。
七、什么时候该回滚
提前定好判据,不要临场决策:
| 条件 | 动作 |
|---|---|
| 错误率 > 5% 持续 3 分钟 | 立即回滚 |
| P99 涨幅 > 100% 持续 5 分钟 | 立即回滚 |
| 核心业务指标跌 > 20% | 立即回滚 |
| 容器 OOM / 频繁重启 | 立即回滚 |
| 错误率涨 1~5%,但有明确原因 | 观察,修完再放量 |
| 单条日志报错但不影响请求 | 记录,继续观察 |
关键心态:灰度期间出问题,第一反应是回滚,不是排查。 先止损,再定位。这两件事可以并行——回滚的同时拉日志分析。
八、发布清单
把灰度发布固化成流程:
发布前
[ ] 数据库变更走 Expand-Contract,已向前兼容
[ ] 有明确的回滚命令,且演练过
[ ] 监控看板已建好(新旧版本对比)
[ ] 告警阈值确认(错误率、P99、业务指标)
[ ] 通知相关方(客服、运营)
发布中
[ ] 部署 canary 副本(1 个或 5% 流量)
[ ] 观察 5 分钟:错误率、P99、日志
[ ] 扩到 20%,观察 5 分钟
[ ] 扩到 50%,观察 10 分钟
[ ] 全量,观察 30 分钟
发布后
[ ] 确认旧版本 Pod 已全部下线
[ ] 检查资源使用是否正常
[ ] 更新版本记录(谁发的、什么时间、改了什么)
小结
灰度发布的价值可以用一个不等式表达:
影响面 × 持续时间 = 故障成本
灰度把「影响面」从 100% 降到 1%~5%,把「持续时间」从「人工发现 + 人工回滚」降到「自动检测 + 自动回滚」。两个维度一起降,故障成本能降两个数量级。
三件事按优先级:
- 回滚要一条命令且演练过(成本最低,收益最大)
- 数据库变更要向前兼容(不做这个,代码回滚也救不了)
- 灰度 + 自动分析(成熟度最高,但需要投入)
如果只能做第一件,那就把回滚做好。能 2 分钟回滚的系统,比不能回滚的完美设计更可靠。
正在加载评论…