灰度发布与回滚:把故障成本降两个数量级

影响面乘以持续时间等于故障成本。五种发布策略对比、三个灰度维度、必须提前定好的回滚判据,以及数据层的 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 有几个限制:

  1. 历史版本数量有限。默认 revisionHistoryLimit: 10,超过就找不到了。
  2. 回滚后 current 会变成新的 revision,历史顺序会乱。
  3. 只回滚 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%,把「持续时间」从「人工发现 + 人工回滚」降到「自动检测 + 自动回滚」。两个维度一起降,故障成本能降两个数量级。

三件事按优先级:

  1. 回滚要一条命令且演练过(成本最低,收益最大)
  2. 数据库变更要向前兼容(不做这个,代码回滚也救不了)
  3. 灰度 + 自动分析(成熟度最高,但需要投入)

如果只能做第一件,那就把回滚做好。能 2 分钟回滚的系统,比不能回滚的完美设计更可靠。

查看「工程效能」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。