运维知识
悠悠
2026年10月5日

别再一把梭哈!Istio 灰度发布实战:从 1% 流量到秒级回滚,附完整 YAML

凌晨一点多,新版订单服务刚上线,监控群就开始刷屏:接口超时、下单失败率上涨、客服反馈用户无法提交订单。

更麻烦的是,新旧版本混在同一个 Deployment 里滚动更新。我想把流量切回旧版本,却发现旧 Pod 已经被逐渐替换,回滚镜像、拉起容器、等待探针,前后折腾了十几分钟。

后来我把发布方式改成了 Istio 灰度,新旧版本独立部署,流量从内部测试、1%、5%、10%慢慢放开。真遇到问题时,只改一条流量规则就能止血,不需要现场表演“手速回滚”。

我现在对灰度发布有一个很直接的理解:

灰度发布不是慢慢上线,而是用最小流量验证风险,并且随时保留一条能快速撤退的路。

这篇文章不绕概念,我直接拿 order-api 订单服务做一遍。工作负载怎么拆、Istio 规则怎么写、流量怎么验证、监控看什么、出了问题怎么退,全部放进来。

新旧版本一定要拆开,别全塞进一个 Deployment

很多人做 Istio 灰度时,会继续使用 Kubernetes 的滚动更新:

kubectl set image deployment/order-api \
  order-api=registry.example.com/mall/order-api:1.9.0 \
  -n prod

这条命令本身没有问题,但它更适合普通滚动发布。新旧 Pod 由同一个 Deployment 管理,我很难独立控制副本数,也不方便让 Istio 根据版本标签稳定分流。

我更习惯把稳定版和灰度版拆成两个 Deployment:

  • order-api-v1:线上稳定版本
  • order-api-v2:准备验证的新版本
  • 两边都有 app: order-api
  • 用 version: v1 和 version: v2 区分版本
  • Service 只选择 app: order-api,不要选择具体版本

完整配置如下:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-api-v1
  namespace: prod
  labels:
    app: order-api
    version: v1
spec:
  replicas: 3
  revisionHistoryLimit: 3
  selector:
    matchLabels:
      app: order-api
      version: v1
  template:
    metadata:
      labels:
        app: order-api
        version: v1
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: order-api
          image: registry.example.com/mall/order-api:1.8.4
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              containerPort: 8080
          env:
            - name: RELEASE_VERSION
              value: v1
          resources:
            requests:
              cpu: 200m
              memory: 256Mi
            limits:
              cpu: "1"
              memory: 1Gi
          startupProbe:
            httpGet:
              path: /health
              port: http
            periodSeconds: 5
            failureThreshold: 30
          readinessProbe:
            httpGet:
              path: /ready
              port: http
            periodSeconds: 5
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /health
              port: http
            periodSeconds: 10
            failureThreshold: 3
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-api-v2
  namespace: prod
  labels:
    app: order-api
    version: v2
spec:
  replicas: 1
  revisionHistoryLimit: 3
  selector:
    matchLabels:
      app: order-api
      version: v2
  template:
    metadata:
      labels:
        app: order-api
        version: v2
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: order-api
          image: registry.example.com/mall/order-api:1.9.0
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              containerPort: 8080
          env:
            - name: RELEASE_VERSION
              value: v2
          resources:
            requests:
              cpu: 200m
              memory: 256Mi
            limits:
              cpu: "1"
              memory: 1Gi
          startupProbe:
            httpGet:
              path: /health
              port: http
            periodSeconds: 5
            failureThreshold: 30
          readinessProbe:
            httpGet:
              path: /ready
              port: http
            periodSeconds: 5
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /health
              port: http
            periodSeconds: 10
            failureThreshold: 3
---
apiVersion: v1
kind: Service
metadata:
  name: order-api
  namespace: prod
spec:
  selector:
    app: order-api
  ports:
    - name: http
      appProtocol: http
      port: 8080
      targetPort: http

这里有个看着不起眼、实际经常把人坑住的地方:Service 的 selector 只能写 app: order-api。

如果顺手加了 version: v1,v2 Pod 就不会进入 Service 的 EndpointSlice。后面的 Istio 配置写得再漂亮,也分不到一条请求。

镜像也不要使用 latest。同一个标签对应不同镜像,排查时连 Pod 到底运行了什么都说不清。生产环境最好固定版本号,条件允许再固定到镜像 digest。

别急着切流量,先确认 Sidecar 真的进来了

如果使用的是 Istio Sidecar 数据平面,我会先检查命名空间的自动注入状态:

kubectl get namespace prod --show-labels

没有启用注入,可以执行:

kubectl label namespace prod istio-injection=enabled --overwrite

需要注意,给命名空间增加标签不会给已经运行的 Pod 补上 Sidecar。现有工作负载需要重新创建 Pod,新发布的两个 Deployment 则会在创建时自动注入。

kubectl apply -f order-api-workloads.yaml

kubectl wait \
  --for=condition=available \
  --timeout=180s \
  deployment/order-api-v1 \
  deployment/order-api-v2 \
  -n prod

我一般会直接看容器名称:

kubectl get pod -n prod -l app=order-api \
  -o jsonpath='{range .items[*]}{.metadata.name}{" => "}{.spec.containers[*].name}{"\n"}{end}'

正常能看到业务容器和 istio-proxy。如果只有业务容器,先别往下操作。没有 Sidecar 的调用链路不会按照网格内的 VirtualService 分流,配半天规则,流量依旧像没看见一样。

使用 revision 标签安装 Istio 的环境,命名空间可能使用的是 istio.io/rev,这时不要再随手加 istio-injection=enabled。两个注入机制混着改,很容易让不同 Pod 接入不同控制面版本。

DestinationRule 负责认人,VirtualService 负责分流

两份 Deployment 已经有了,但 Istio 还不知道谁是稳定版、谁是灰度版。

我会先创建 DestinationRule,通过 Pod 标签定义两个 subset:

apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
  name: order-api
  namespace: prod
spec:
  host: order-api.prod.svc.cluster.local
  subsets:
    - name: stable
      labels:
        version: v1
    - name: canary
      labels:
        version: v2

这里的对应关系很简单:

stable  -> version: v1
canary  -> version: v2

我没有在示例里顺手塞入连接池、熔断、异常实例驱逐这些参数。原因也很现实,灰度发布已经在改变流量,再同时修改超时、重试和连接池,出问题时根本分不清是新代码有问题,还是流量策略有问题。

已有成熟参数可以继续使用,但别从网上复制一段 outlierDetection 就直接扔进生产。灰度版本只有一个副本时,一旦被异常驱逐,可能直接变成无健康上游。

然后创建 VirtualService。我通常不会刚上来就给真实用户流量,而是先留一个请求头入口,让测试人员和内部账号固定进入 v2:

apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: order-api
  namespace: prod
spec:
  hosts:
    - order-api.prod.svc.cluster.local
  http:
    - name: internal-canary
      match:
        - headers:
            x-canary:
              exact: "true"
      route:
        - destination:
            host: order-api.prod.svc.cluster.local
            subset: canary
            port:
              number: 8080
          headers:
            response:
              set:
                x-release-version: canary

    - name: default-weighted-route
      route:
        - destination:
            host: order-api.prod.svc.cluster.local
            subset: stable
            port:
              number: 8080
          weight: 95
          headers:
            response:
              set:
                x-release-version: stable

        - destination:
            host: order-api.prod.svc.cluster.local
            subset: canary
            port:
              number: 8080
          weight: 5
          headers:
            response:
              set:
                x-release-version: canary

带有下面这个请求头的流量会全部进入 v2:

x-canary: true

其他请求按照 95% 和 5%分配。两组 weight 最好明确加起来等于 100,值班时一眼就能看明白,没必要让大脑在凌晨做数学题。

路由规则是从上到下匹配的。请求头规则必须放在普通权重规则前面。如果把没有 match 的默认规则放在最上面,所有请求都会提前命中,下面的内部灰度规则也就成了摆设。

配置发布顺序不对,可能人为制造 503

我在生产环境吃过这个亏。

有人把 VirtualService 和 DestinationRule 同时提交,某些 Sidecar 先收到了引用 canary subset 的路由,定义这个 subset 的配置却还没有同步过来。时间可能只有几秒,但高流量业务的几秒已经能产生一批 503。

稳妥的顺序是:

kubectl apply -f order-api-destination-rule.yaml

istioctl analyze -n prod
istioctl proxy-status

kubectl apply -f order-api-virtual-service.yaml

istioctl analyze -n prod
istioctl proxy-status

也就是先让目标存在,再让流量规则引用它。

删除配置时顺序反过来:先从 VirtualService 中移除 subset 引用,确认配置完成同步,再删除 DestinationRule 和对应工作负载。

这类问题在 Istio 官方流量管理最佳实践里叫“先建立、后使用”的思路。我觉得不只 Istio,负载均衡器、服务发现、DNS 切换都适用。生产变更最怕目标还没准备好,入口已经冲过去了。

5%到底有没有生效,别靠刷新两次页面判断

权重分流是概率结果,不是每 100 个请求里严格安排 5 个给 v2。

请求量只有十几个时,可能一个都没进 v2,也可能一下进去三四个。我见过有人请求了五次,发现五次都是 v1,当场判断 Istio 配置不生效,这就有点冤枉 Envoy 了。

我会先检查 Pod 标签和 Service Endpoint:

kubectl get pod -n prod -l app=order-api -L version

kubectl get endpointslice -n prod \
  -l kubernetes.io/service-name=order-api \
  -o wide

再创建一个带 Sidecar 的临时测试 Pod:

kubectl run traffic-test \
  -n prod \
  --image=curlimages/curl:8.10.1 \
  --restart=Never \
  --command -- sleep 3600

kubectl wait \
  --for=condition=Ready \
  --timeout=120s \
  pod/traffic-test \
  -n prod

验证请求头灰度:

kubectl exec -n prod traffic-test -c traffic-test -- \
  curl -si \
  -H 'x-canary: true' \
  http://order-api:8080/version

响应头里应该出现:

x-release-version: canary

再连续请求 200 次,看看默认流量比例:

kubectl exec -n prod traffic-test -c traffic-test -- sh -c \
  'for i in $(seq 1 200); do curl -sS http://order-api:8080/version; echo; done' |
  sort |
  uniq -c

如果 /version 会返回 v1 或 v2,大致能看到接近 95:5 的分布。200 次请求依旧只是功能验证,不能拿来证明比例绝对准确。

规则看着没问题,流量就是不走时,我常用这几条命令:

istioctl analyze -n prod

istioctl proxy-status

istioctl proxy-config routes traffic-test -n prod

istioctl proxy-config cluster traffic-test \
  -n prod \
  --fqdn order-api.prod.svc.cluster.local

istioctl analyze 负责找配置错误,proxy-status 看 Sidecar 与控制面的同步状态,后两条直接看 Envoy 收到的路由和集群配置。

如果调用方没有 Sidecar,查 order-api 自己的 Sidecar没有意义。流量路由发生在调用端 Envoy,排查对象应该是调用方 Pod,或者承接入口流量的 Ingress Gateway。

放量不要只盯着百分比,样本量更重要

我现在常用的节奏大致是这样:

阶段灰度流量主要动作
内部验证仅请求头进入 v2冒烟、核心链路、日志核对
小流量1%检查启动错误、5xx、依赖异常
初步放量5%对比稳定版和灰度版指标
扩大验证10%~25%观察资源、数据库、缓存命中率
半量验证50%观察容量和业务指标
全量100%保留 v1,继续观察一个窗口
收尾v1 下线清理规则、Deployment 和临时配置

这个表不能机械照搬。

假设服务只有 20 QPS,1%流量每秒才 0.2 个请求,十分钟大约 120 个样本。某些低频接口可能一条都碰不到,这时候守着 1%观察半小时,心理很踏实,实际上什么也没验证出来。

服务有 5000 QPS 时,1%就是 50 QPS,新版本几分钟就能承受上万次请求。放量节奏要结合请求量、错误预算和业务风险,不是领导说“先切 5%”就永远切 5%。

为了减少手工编辑 YAML,我会准备一个简单脚本。下面脚本对应前面的 VirtualService,其中 http[1] 是默认权重路由:

#!/usr/bin/env bash

set -euo pipefail

canary="${1:-}"

if [[ ! "${canary}" =~ ^[0-9]+$ ]]; then
  echo "用法:$0 <0-100>"
  exit 1
fi

if (( canary < 0 || canary > 100 )); then
  echo "灰度权重必须在 0 到 100 之间"
  exit 1
fi

stable=$((100 - canary))

kubectl patch virtualservice order-api \
  -n prod \
  --type=json \
  -p="[
    {
      \"op\": \"replace\",
      \"path\": \"/spec/http/1/route/0/weight\",
      \"value\": ${stable}
    },
    {
      \"op\": \"replace\",
      \"path\": \"/spec/http/1/route/1/weight\",
      \"value\": ${canary}
    }
  ]"

kubectl get virtualservice order-api \
  -n prod \
  -o jsonpath='{range .spec.http[1].route[*]}{.destination.subset}{"="}{.weight}{"\n"}{end}'

保存成 rollout-weight.sh 后执行:

chmod +x rollout-weight.sh

./rollout-weight.sh 1
./rollout-weight.sh 5
./rollout-weight.sh 10

不过这个脚本更适合发布窗口里的受控操作。如果生产环境由 Argo CD、Flux 管理,直接 patch 的结果可能很快被 Git 中的旧配置覆盖。

我的做法是让 Git 配置始终作为最终事实来源,脚本用于验证和紧急止血,同时马上提交对应变更。要不然人刚把流量切到 0%,Argo CD 又热心地恢复成 50%,这种场面多少有点黑色幽默。

监控别只看总错误率,要把版本标签拆出来

一次灰度发布里,最容易骗人的就是聚合指标。

假设 v2 只承接 5%流量,它自己的错误率已经达到 6%,摊到整个服务上只有 0.3%左右。如果告警阈值是总错误率超过 1%,监控大屏还绿得挺健康,灰度版本其实已经冒烟了。

我会把 destination_version 拆出来,单独比较 v1 和 v2。

每个版本的请求量:

sum by (destination_version) (
  rate(
    istio_requests_total{
      reporter="destination",
      destination_service_name="order-api"
    }[5m]
  )
)

每个版本的 5xx 比例:

sum by (destination_version) (
  rate(
    istio_requests_total{
      reporter="destination",
      destination_service_name="order-api",
      response_code=~"5.."
    }[5m]
  )
)
/
sum by (destination_version) (
  rate(
    istio_requests_total{
      reporter="destination",
      destination_service_name="order-api"
    }[5m]
  )
)

每个版本的 P95 延迟:

histogram_quantile(
  0.95,
  sum by (le, destination_version) (
    rate(
      istio_request_duration_milliseconds_bucket{
        reporter="destination",
        destination_service_name="order-api"
      }[5m]
    )
  )
)

环境中的指标标签可能因为 Istio Telemetry 配置有所差异,执行前可以先在 Prometheus 中查看 istio_requests_total 实际带了哪些标签。

技术指标之外,我还会看业务指标:

  • 下单成功率有没有下降
  • 支付重复调用有没有增加
  • 库存扣减失败率是否变化
  • 消息积压是否突然上涨
  • 数据库慢查询和连接数是否异常
  • Redis 命中率有没有明显下滑
  • v2 的 CPU、内存、GC 是否高于 v1
  • 日志中是否出现新异常类型

我更喜欢设置“相对阈值”,例如 v2 的 5xx 比例连续 5 分钟比 v1 高 0.5 个百分点,或者 P95 延迟超过 v1 的 1.2 倍,满足最小样本量后自动暂停放量。

只看固定阈值不太公平。大促期间稳定版本身就可能变慢,灰度版是否退化,最好拿同一时间窗口的 v1 做对照。

那次 5%灰度,差点被总览大盘骗过去

有次订单服务发布 v2,晚上 21:40 开始操作。

内部测试人员带 x-canary: true 请求,创建订单、取消订单、查询详情都正常。21:55 切到 1%,技术指标没明显变化。22:10 放到 5%,服务总错误率从 0.08%上涨到 0.31%,仍然低于 1%的告警线。

看总览大盘,好像没什么事。

我顺手把面板按 destination_version 拆开,发现 v2 的 5xx 已经到了 5.7%,并且错误集中在使用优惠券的订单。普通订单正常,内部冒烟测试刚好没有覆盖这个组合。

日志中出现的是数据库字段不存在。继续对照发布记录才发现,新版依赖 coupon_source 字段,数据库变更只执行了部分分片,还有两个分片没有完成迁移。

如果当时继续等总错误率告警,可能要放到 20%甚至更高才会触发。到那时候受影响的就不是零星用户了。

我把默认流量改成 stable 100、canary 0,错误率很快恢复。整个止血过程不到一分钟,新版 Pod 没有删除,研发还能继续带请求头复现问题。

后面的处理也没多复杂:补齐数据库迁移,核对全部分片,再重新从内部请求头和 1%开始。真正值得记住的不是“数据库漏了字段”,而是两件事:

聚合指标会稀释灰度版本的问题;应用发布和数据库变更必须保证向前、向后兼容。

数据库字段变更尽量采用 expand-contract:

  • 先增加可空字段,让旧代码还能运行
  • 再发布兼容新旧结构的应用
  • 完成数据回填
  • 确认所有实例已经切换
  • 最后再收紧约束或删除旧字段

灰度只能控制应用流量,救不了不兼容的数据库结构。

权重改成 0%,不一定代表彻底回滚

这是我特别想提醒的一点。

前面的 VirtualService 有两条规则:

  • 带 x-canary: true 的请求固定进入 v2
  • 普通请求按照权重分流

运行下面的命令:

./rollout-weight.sh 0

只能让普通请求不再进入 v2。带灰度请求头的流量依然会命中前面的规则,继续访问 canary。

如果是严重故障,我会直接应用一份只保留 stable 的紧急回滚配置:

apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: order-api
  namespace: prod
spec:
  hosts:
    - order-api.prod.svc.cluster.local
  http:
    - name: emergency-stable-only
      route:
        - destination:
            host: order-api.prod.svc.cluster.local
            subset: stable
            port:
              number: 8080
          weight: 100
          headers:
            response:
              set:
                x-release-version: stable

执行并检查同步状态:

kubectl apply -f order-api-virtual-service-rollback.yaml

istioctl analyze -n prod
istioctl proxy-status
istioctl proxy-config routes traffic-test -n prod

我不会在切流后立刻把 v2 缩容到 0。配置下发存在传播时间,也可能还有正在处理的请求。通常会等指标归零、Sidecar 同步完成,再根据接口超时和连接情况留出几分钟排空时间。

gRPC 长连接、WebSocket、长时间 TCP 会话更要注意。流量权重控制的是新建请求或连接,已经建立的长连接不会因为 YAML 改了就瞬间搬到 v1。遇到这种业务,需要配合连接最大生命周期、客户端重连机制或者网关侧连接治理来完成回切。

这些坑,我建议发布前逐条看一遍

不要让 subset 标签和 Pod 标签对不上。

DestinationRule 写的是 version: v2,Pod 却标成 release: v2,Envoy 找不到健康端点,常见结果就是 503 或 no healthy upstream。

不要让 Service 只选择稳定版本。

Service selector 带着 version: v1 时,v2 不会进入服务端点。

不要把默认规则放在请求头规则前面。

上面的宽泛规则一旦命中,下面更精确的规则就没有机会执行。

不要一次变更多类参数。

发布新镜像时顺便改超时、重试、熔断和连接池,故障定位会变成猜谜。能拆成多个变更窗口就拆开。

不要把重试当成免费的保险。

灰度版本本来已经变慢,再配置多次重试,可能把下游流量放大几倍。错误暂时被遮住了,数据库却先扛不住。

不要随便镜像写请求。

流量镜像适合无副作用的查询。下单、扣款、发消息这类写请求被镜像后,可能产生重复数据。想做影子流量,需要业务端支持幂等或明确丢弃写操作。

不要只给稳定版配置 HPA。

两个 Deployment 需要分别评估副本数和扩缩容策略。灰度从 5%放到 50%,v2 还是一个副本,代码没出问题,容量先出问题了。

不要在全量后马上删除 v1。

我习惯保留一个观察窗口。全量后的故障可能来自缓存逐渐击穿、连接池耗尽、内存泄漏,这些问题不会在切到 100%的第一秒出现。

不要忽略端口协议识别。

Service 端口名建议使用 http、http2、grpc 这类协议前缀,或者设置正确的 appProtocol。协议识别错误可能导致部分 HTTP 路由能力无法按预期工作。

不要忘记 GitOps 回写。

命令行止血后不更新 Git,控制器可能把旧规则重新同步回来。线上状态和仓库状态分叉,下一次发布还会再踩一遍。

我实际使用的灰度发布检查清单

发布前,我会确认这些内容:

  • v1、v2 使用独立 Deployment
  • 镜像版本固定,不使用 latest
  • Pod 同时具有 app 和 version 标签
  • Service 能选中两个版本
  • v2 readiness probe 已通过
  • 调用方和服务端 Sidecar 注入正常
  • DestinationRule 已提前创建
  • VirtualService 中的 host、subset、端口正确
  • 数据库和消息格式兼容新旧版本
  • Grafana 已按版本拆分指标
  • 日志中能识别发布版本和请求 ID
  • 回滚 YAML 已准备,不是出事后临时手写
  • 发布人、观察人和业务确认人在场

放量过程中,我会记录:

  • 每次调整权重的时间
  • v1、v2 实际请求量
  • 两个版本的 5xx、P95、P99
  • CPU、内存、GC、线程池和连接池
  • 数据库、Redis、MQ 指标
  • 核心业务成功率
  • 是否达到最小样本量
  • 下一阶段的放量条件
  • 明确的停止线和回滚线

全量完成也不代表下班。我还会做一轮收尾:

  • v2 保持 100%观察一个完整业务窗口
  • 确认没有流量继续进入 v1
  • 清理临时请求头和调试响应头
  • 再下线 v1 Deployment
  • 更新发布记录和架构文档
  • 复盘异常、误判和手工操作
  • 把能自动化的步骤放进发布流水线

说到底,Istio 只负责把流量按规则送到目标版本。代码是否兼容、容量是否够用、数据能不能回退、监控能不能看见问题,这些事情它不会替我们兜底。

真正靠谱的灰度发布,不是把 weight: 5 写进 YAML 就结束了,而是有明确的流量入口、有可比较的指标、有停止条件,还有一条经过验证的回滚路径。

相关规则可以对照 Istio 官方的 VirtualService API 参考、流量管理最佳实践和流量管理说明。

资料说明:本文涉及的官方文档内容均结合生产实践重新整理。Content was rephrased for compliance with licensing restrictions.

如果这篇文章对你有帮助,别忘了点赞转发支持一下!想了解更多运维实战经验和技术干货,记得关注微信公众号@运维躬行录,领取学习大礼包!!!我会持续分享更多接地气的运维知识和踩坑经验。让我们一起在运维这条路上互相学习,共同进步!

公众号:运维躬行录
个人博客:躬行笔记

凌晨一点多,新版订单服务刚上线,监控群就开始刷屏:接口超时、下单失败率上涨、客服反馈用户无法提交订单。

更麻烦的是,新旧版本混在同一个 Deployment 里滚动更新。我想把流量切回旧版本,却发现旧 Pod 已经被逐渐替换,回滚镜像、拉起容器、等待探针,前后折腾了十几分钟。

后来我把发布方式改成了 Istio 灰度,新旧版本独立部署,流量从内部测试、1%、5%、10%慢慢放开。真遇到问题时,只改一条流量规则就能止血,不需要现场表演“手速回滚”。

我现在对灰度发布有一个很直接的理解:

灰度发布不是慢慢上线,而是用最小流量验证风险,并且随时保留一条能快速撤退的路。

这篇文章不绕概念,我直接拿 order-api 订单服务做一遍。工作负载怎么拆、Istio 规则怎么写、流量怎么验证、监控看什么、出了问题怎么退,全部放进来。

新旧版本一定要拆开,别全塞进一个 Deployment

很多人做 Istio 灰度时,会继续使用 Kubernetes 的滚动更新:

kubectl set image deployment/order-api \
  order-api=registry.example.com/mall/order-api:1.9.0 \
  -n prod

这条命令本身没有问题,但它更适合普通滚动发布。新旧 Pod 由同一个 Deployment 管理,我很难独立控制副本数,也不方便让 Istio 根据版本标签稳定分流。

我更习惯把稳定版和灰度版拆成两个 Deployment:

  • order-api-v1:线上稳定版本
  • order-api-v2:准备验证的新版本
  • 两边都有 app: order-api
  • 用 version: v1 和 version: v2 区分版本
  • Service 只选择 app: order-api,不要选择具体版本

完整配置如下:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-api-v1
  namespace: prod
  labels:
    app: order-api
    version: v1
spec:
  replicas: 3
  revisionHistoryLimit: 3
  selector:
    matchLabels:
      app: order-api
      version: v1
  template:
    metadata:
      labels:
        app: order-api
        version: v1
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: order-api
          image: registry.example.com/mall/order-api:1.8.4
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              containerPort: 8080
          env:
            - name: RELEASE_VERSION
              value: v1
          resources:
            requests:
              cpu: 200m
              memory: 256Mi
            limits:
              cpu: "1"
              memory: 1Gi
          startupProbe:
            httpGet:
              path: /health
              port: http
            periodSeconds: 5
            failureThreshold: 30
          readinessProbe:
            httpGet:
              path: /ready
              port: http
            periodSeconds: 5
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /health
              port: http
            periodSeconds: 10
            failureThreshold: 3
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-api-v2
  namespace: prod
  labels:
    app: order-api
    version: v2
spec:
  replicas: 1
  revisionHistoryLimit: 3
  selector:
    matchLabels:
      app: order-api
      version: v2
  template:
    metadata:
      labels:
        app: order-api
        version: v2
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: order-api
          image: registry.example.com/mall/order-api:1.9.0
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              containerPort: 8080
          env:
            - name: RELEASE_VERSION
              value: v2
          resources:
            requests:
              cpu: 200m
              memory: 256Mi
            limits:
              cpu: "1"
              memory: 1Gi
          startupProbe:
            httpGet:
              path: /health
              port: http
            periodSeconds: 5
            failureThreshold: 30
          readinessProbe:
            httpGet:
              path: /ready
              port: http
            periodSeconds: 5
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /health
              port: http
            periodSeconds: 10
            failureThreshold: 3
---
apiVersion: v1
kind: Service
metadata:
  name: order-api
  namespace: prod
spec:
  selector:
    app: order-api
  ports:
    - name: http
      appProtocol: http
      port: 8080
      targetPort: http

这里有个看着不起眼、实际经常把人坑住的地方:Service 的 selector 只能写 app: order-api。

如果顺手加了 version: v1,v2 Pod 就不会进入 Service 的 EndpointSlice。后面的 Istio 配置写得再漂亮,也分不到一条请求。

镜像也不要使用 latest。同一个标签对应不同镜像,排查时连 Pod 到底运行了什么都说不清。生产环境最好固定版本号,条件允许再固定到镜像 digest。

别急着切流量,先确认 Sidecar 真的进来了

如果使用的是 Istio Sidecar 数据平面,我会先检查命名空间的自动注入状态:

kubectl get namespace prod --show-labels

没有启用注入,可以执行:

kubectl label namespace prod istio-injection=enabled --overwrite

需要注意,给命名空间增加标签不会给已经运行的 Pod 补上 Sidecar。现有工作负载需要重新创建 Pod,新发布的两个 Deployment 则会在创建时自动注入。

kubectl apply -f order-api-workloads.yaml

kubectl wait \
  --for=condition=available \
  --timeout=180s \
  deployment/order-api-v1 \
  deployment/order-api-v2 \
  -n prod

我一般会直接看容器名称:

kubectl get pod -n prod -l app=order-api \
  -o jsonpath='{range .items[*]}{.metadata.name}{" => "}{.spec.containers[*].name}{"\n"}{end}'

正常能看到业务容器和 istio-proxy。如果只有业务容器,先别往下操作。没有 Sidecar 的调用链路不会按照网格内的 VirtualService 分流,配半天规则,流量依旧像没看见一样。

使用 revision 标签安装 Istio 的环境,命名空间可能使用的是 istio.io/rev,这时不要再随手加 istio-injection=enabled。两个注入机制混着改,很容易让不同 Pod 接入不同控制面版本。

DestinationRule 负责认人,VirtualService 负责分流

两份 Deployment 已经有了,但 Istio 还不知道谁是稳定版、谁是灰度版。

我会先创建 DestinationRule,通过 Pod 标签定义两个 subset:

apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
  name: order-api
  namespace: prod
spec:
  host: order-api.prod.svc.cluster.local
  subsets:
    - name: stable
      labels:
        version: v1
    - name: canary
      labels:
        version: v2

这里的对应关系很简单:

stable  -> version: v1
canary  -> version: v2

我没有在示例里顺手塞入连接池、熔断、异常实例驱逐这些参数。原因也很现实,灰度发布已经在改变流量,再同时修改超时、重试和连接池,出问题时根本分不清是新代码有问题,还是流量策略有问题。

已有成熟参数可以继续使用,但别从网上复制一段 outlierDetection 就直接扔进生产。灰度版本只有一个副本时,一旦被异常驱逐,可能直接变成无健康上游。

然后创建 VirtualService。我通常不会刚上来就给真实用户流量,而是先留一个请求头入口,让测试人员和内部账号固定进入 v2:

apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: order-api
  namespace: prod
spec:
  hosts:
    - order-api.prod.svc.cluster.local
  http:
    - name: internal-canary
      match:
        - headers:
            x-canary:
              exact: "true"
      route:
        - destination:
            host: order-api.prod.svc.cluster.local
            subset: canary
            port:
              number: 8080
          headers:
            response:
              set:
                x-release-version: canary

    - name: default-weighted-route
      route:
        - destination:
            host: order-api.prod.svc.cluster.local
            subset: stable
            port:
              number: 8080
          weight: 95
          headers:
            response:
              set:
                x-release-version: stable

        - destination:
            host: order-api.prod.svc.cluster.local
            subset: canary
            port:
              number: 8080
          weight: 5
          headers:
            response:
              set:
                x-release-version: canary

带有下面这个请求头的流量会全部进入 v2:

x-canary: true

其他请求按照 95% 和 5%分配。两组 weight 最好明确加起来等于 100,值班时一眼就能看明白,没必要让大脑在凌晨做数学题。

路由规则是从上到下匹配的。请求头规则必须放在普通权重规则前面。如果把没有 match 的默认规则放在最上面,所有请求都会提前命中,下面的内部灰度规则也就成了摆设。

配置发布顺序不对,可能人为制造 503

我在生产环境吃过这个亏。

有人把 VirtualService 和 DestinationRule 同时提交,某些 Sidecar 先收到了引用 canary subset 的路由,定义这个 subset 的配置却还没有同步过来。时间可能只有几秒,但高流量业务的几秒已经能产生一批 503。

稳妥的顺序是:

kubectl apply -f order-api-destination-rule.yaml

istioctl analyze -n prod
istioctl proxy-status

kubectl apply -f order-api-virtual-service.yaml

istioctl analyze -n prod
istioctl proxy-status

也就是先让目标存在,再让流量规则引用它。

删除配置时顺序反过来:先从 VirtualService 中移除 subset 引用,确认配置完成同步,再删除 DestinationRule 和对应工作负载。

这类问题在 Istio 官方流量管理最佳实践里叫“先建立、后使用”的思路。我觉得不只 Istio,负载均衡器、服务发现、DNS 切换都适用。生产变更最怕目标还没准备好,入口已经冲过去了。

5%到底有没有生效,别靠刷新两次页面判断

权重分流是概率结果,不是每 100 个请求里严格安排 5 个给 v2。

请求量只有十几个时,可能一个都没进 v2,也可能一下进去三四个。我见过有人请求了五次,发现五次都是 v1,当场判断 Istio 配置不生效,这就有点冤枉 Envoy 了。

我会先检查 Pod 标签和 Service Endpoint:

kubectl get pod -n prod -l app=order-api -L version

kubectl get endpointslice -n prod \
  -l kubernetes.io/service-name=order-api \
  -o wide

再创建一个带 Sidecar 的临时测试 Pod:

kubectl run traffic-test \
  -n prod \
  --image=curlimages/curl:8.10.1 \
  --restart=Never \
  --command -- sleep 3600

kubectl wait \
  --for=condition=Ready \
  --timeout=120s \
  pod/traffic-test \
  -n prod

验证请求头灰度:

kubectl exec -n prod traffic-test -c traffic-test -- \
  curl -si \
  -H 'x-canary: true' \
  http://order-api:8080/version

响应头里应该出现:

x-release-version: canary

再连续请求 200 次,看看默认流量比例:

kubectl exec -n prod traffic-test -c traffic-test -- sh -c \
  'for i in $(seq 1 200); do curl -sS http://order-api:8080/version; echo; done' |
  sort |
  uniq -c

如果 /version 会返回 v1 或 v2,大致能看到接近 95:5 的分布。200 次请求依旧只是功能验证,不能拿来证明比例绝对准确。

规则看着没问题,流量就是不走时,我常用这几条命令:

istioctl analyze -n prod

istioctl proxy-status

istioctl proxy-config routes traffic-test -n prod

istioctl proxy-config cluster traffic-test \
  -n prod \
  --fqdn order-api.prod.svc.cluster.local

istioctl analyze 负责找配置错误,proxy-status 看 Sidecar 与控制面的同步状态,后两条直接看 Envoy 收到的路由和集群配置。

如果调用方没有 Sidecar,查 order-api 自己的 Sidecar没有意义。流量路由发生在调用端 Envoy,排查对象应该是调用方 Pod,或者承接入口流量的 Ingress Gateway。

放量不要只盯着百分比,样本量更重要

我现在常用的节奏大致是这样:

阶段灰度流量主要动作
内部验证仅请求头进入 v2冒烟、核心链路、日志核对
小流量1%检查启动错误、5xx、依赖异常
初步放量5%对比稳定版和灰度版指标
扩大验证10%~25%观察资源、数据库、缓存命中率
半量验证50%观察容量和业务指标
全量100%保留 v1,继续观察一个窗口
收尾v1 下线清理规则、Deployment 和临时配置

这个表不能机械照搬。

假设服务只有 20 QPS,1%流量每秒才 0.2 个请求,十分钟大约 120 个样本。某些低频接口可能一条都碰不到,这时候守着 1%观察半小时,心理很踏实,实际上什么也没验证出来。

服务有 5000 QPS 时,1%就是 50 QPS,新版本几分钟就能承受上万次请求。放量节奏要结合请求量、错误预算和业务风险,不是领导说“先切 5%”就永远切 5%。

为了减少手工编辑 YAML,我会准备一个简单脚本。下面脚本对应前面的 VirtualService,其中 http[1] 是默认权重路由:

#!/usr/bin/env bash

set -euo pipefail

canary="${1:-}"

if [[ ! "${canary}" =~ ^[0-9]+$ ]]; then
  echo "用法:$0 <0-100>"
  exit 1
fi

if (( canary < 0 || canary > 100 )); then
  echo "灰度权重必须在 0 到 100 之间"
  exit 1
fi

stable=$((100 - canary))

kubectl patch virtualservice order-api \
  -n prod \
  --type=json \
  -p="[
    {
      \"op\": \"replace\",
      \"path\": \"/spec/http/1/route/0/weight\",
      \"value\": ${stable}
    },
    {
      \"op\": \"replace\",
      \"path\": \"/spec/http/1/route/1/weight\",
      \"value\": ${canary}
    }
  ]"

kubectl get virtualservice order-api \
  -n prod \
  -o jsonpath='{range .spec.http[1].route[*]}{.destination.subset}{"="}{.weight}{"\n"}{end}'

保存成 rollout-weight.sh 后执行:

chmod +x rollout-weight.sh

./rollout-weight.sh 1
./rollout-weight.sh 5
./rollout-weight.sh 10

不过这个脚本更适合发布窗口里的受控操作。如果生产环境由 Argo CD、Flux 管理,直接 patch 的结果可能很快被 Git 中的旧配置覆盖。

我的做法是让 Git 配置始终作为最终事实来源,脚本用于验证和紧急止血,同时马上提交对应变更。要不然人刚把流量切到 0%,Argo CD 又热心地恢复成 50%,这种场面多少有点黑色幽默。

监控别只看总错误率,要把版本标签拆出来

一次灰度发布里,最容易骗人的就是聚合指标。

假设 v2 只承接 5%流量,它自己的错误率已经达到 6%,摊到整个服务上只有 0.3%左右。如果告警阈值是总错误率超过 1%,监控大屏还绿得挺健康,灰度版本其实已经冒烟了。

我会把 destination_version 拆出来,单独比较 v1 和 v2。

每个版本的请求量:

sum by (destination_version) (
  rate(
    istio_requests_total{
      reporter="destination",
      destination_service_name="order-api"
    }[5m]
  )
)

每个版本的 5xx 比例:

sum by (destination_version) (
  rate(
    istio_requests_total{
      reporter="destination",
      destination_service_name="order-api",
      response_code=~"5.."
    }[5m]
  )
)
/
sum by (destination_version) (
  rate(
    istio_requests_total{
      reporter="destination",
      destination_service_name="order-api"
    }[5m]
  )
)

每个版本的 P95 延迟:

histogram_quantile(
  0.95,
  sum by (le, destination_version) (
    rate(
      istio_request_duration_milliseconds_bucket{
        reporter="destination",
        destination_service_name="order-api"
      }[5m]
    )
  )
)

环境中的指标标签可能因为 Istio Telemetry 配置有所差异,执行前可以先在 Prometheus 中查看 istio_requests_total 实际带了哪些标签。

技术指标之外,我还会看业务指标:

  • 下单成功率有没有下降
  • 支付重复调用有没有增加
  • 库存扣减失败率是否变化
  • 消息积压是否突然上涨
  • 数据库慢查询和连接数是否异常
  • Redis 命中率有没有明显下滑
  • v2 的 CPU、内存、GC 是否高于 v1
  • 日志中是否出现新异常类型

我更喜欢设置“相对阈值”,例如 v2 的 5xx 比例连续 5 分钟比 v1 高 0.5 个百分点,或者 P95 延迟超过 v1 的 1.2 倍,满足最小样本量后自动暂停放量。

只看固定阈值不太公平。大促期间稳定版本身就可能变慢,灰度版是否退化,最好拿同一时间窗口的 v1 做对照。

那次 5%灰度,差点被总览大盘骗过去

有次订单服务发布 v2,晚上 21:40 开始操作。

内部测试人员带 x-canary: true 请求,创建订单、取消订单、查询详情都正常。21:55 切到 1%,技术指标没明显变化。22:10 放到 5%,服务总错误率从 0.08%上涨到 0.31%,仍然低于 1%的告警线。

看总览大盘,好像没什么事。

我顺手把面板按 destination_version 拆开,发现 v2 的 5xx 已经到了 5.7%,并且错误集中在使用优惠券的订单。普通订单正常,内部冒烟测试刚好没有覆盖这个组合。

日志中出现的是数据库字段不存在。继续对照发布记录才发现,新版依赖 coupon_source 字段,数据库变更只执行了部分分片,还有两个分片没有完成迁移。

如果当时继续等总错误率告警,可能要放到 20%甚至更高才会触发。到那时候受影响的就不是零星用户了。

我把默认流量改成 stable 100、canary 0,错误率很快恢复。整个止血过程不到一分钟,新版 Pod 没有删除,研发还能继续带请求头复现问题。

后面的处理也没多复杂:补齐数据库迁移,核对全部分片,再重新从内部请求头和 1%开始。真正值得记住的不是“数据库漏了字段”,而是两件事:

聚合指标会稀释灰度版本的问题;应用发布和数据库变更必须保证向前、向后兼容。

数据库字段变更尽量采用 expand-contract:

  • 先增加可空字段,让旧代码还能运行
  • 再发布兼容新旧结构的应用
  • 完成数据回填
  • 确认所有实例已经切换
  • 最后再收紧约束或删除旧字段

灰度只能控制应用流量,救不了不兼容的数据库结构。

权重改成 0%,不一定代表彻底回滚

这是我特别想提醒的一点。

前面的 VirtualService 有两条规则:

  • 带 x-canary: true 的请求固定进入 v2
  • 普通请求按照权重分流

运行下面的命令:

./rollout-weight.sh 0

只能让普通请求不再进入 v2。带灰度请求头的流量依然会命中前面的规则,继续访问 canary。

如果是严重故障,我会直接应用一份只保留 stable 的紧急回滚配置:

apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: order-api
  namespace: prod
spec:
  hosts:
    - order-api.prod.svc.cluster.local
  http:
    - name: emergency-stable-only
      route:
        - destination:
            host: order-api.prod.svc.cluster.local
            subset: stable
            port:
              number: 8080
          weight: 100
          headers:
            response:
              set:
                x-release-version: stable

执行并检查同步状态:

kubectl apply -f order-api-virtual-service-rollback.yaml

istioctl analyze -n prod
istioctl proxy-status
istioctl proxy-config routes traffic-test -n prod

我不会在切流后立刻把 v2 缩容到 0。配置下发存在传播时间,也可能还有正在处理的请求。通常会等指标归零、Sidecar 同步完成,再根据接口超时和连接情况留出几分钟排空时间。

gRPC 长连接、WebSocket、长时间 TCP 会话更要注意。流量权重控制的是新建请求或连接,已经建立的长连接不会因为 YAML 改了就瞬间搬到 v1。遇到这种业务,需要配合连接最大生命周期、客户端重连机制或者网关侧连接治理来完成回切。

这些坑,我建议发布前逐条看一遍

不要让 subset 标签和 Pod 标签对不上。

DestinationRule 写的是 version: v2,Pod 却标成 release: v2,Envoy 找不到健康端点,常见结果就是 503 或 no healthy upstream。

不要让 Service 只选择稳定版本。

Service selector 带着 version: v1 时,v2 不会进入服务端点。

不要把默认规则放在请求头规则前面。

上面的宽泛规则一旦命中,下面更精确的规则就没有机会执行。

不要一次变更多类参数。

发布新镜像时顺便改超时、重试、熔断和连接池,故障定位会变成猜谜。能拆成多个变更窗口就拆开。

不要把重试当成免费的保险。

灰度版本本来已经变慢,再配置多次重试,可能把下游流量放大几倍。错误暂时被遮住了,数据库却先扛不住。

不要随便镜像写请求。

流量镜像适合无副作用的查询。下单、扣款、发消息这类写请求被镜像后,可能产生重复数据。想做影子流量,需要业务端支持幂等或明确丢弃写操作。

不要只给稳定版配置 HPA。

两个 Deployment 需要分别评估副本数和扩缩容策略。灰度从 5%放到 50%,v2 还是一个副本,代码没出问题,容量先出问题了。

不要在全量后马上删除 v1。

我习惯保留一个观察窗口。全量后的故障可能来自缓存逐渐击穿、连接池耗尽、内存泄漏,这些问题不会在切到 100%的第一秒出现。

不要忽略端口协议识别。

Service 端口名建议使用 http、http2、grpc 这类协议前缀,或者设置正确的 appProtocol。协议识别错误可能导致部分 HTTP 路由能力无法按预期工作。

不要忘记 GitOps 回写。

命令行止血后不更新 Git,控制器可能把旧规则重新同步回来。线上状态和仓库状态分叉,下一次发布还会再踩一遍。

我实际使用的灰度发布检查清单

发布前,我会确认这些内容:

  • v1、v2 使用独立 Deployment
  • 镜像版本固定,不使用 latest
  • Pod 同时具有 app 和 version 标签
  • Service 能选中两个版本
  • v2 readiness probe 已通过
  • 调用方和服务端 Sidecar 注入正常
  • DestinationRule 已提前创建
  • VirtualService 中的 host、subset、端口正确
  • 数据库和消息格式兼容新旧版本
  • Grafana 已按版本拆分指标
  • 日志中能识别发布版本和请求 ID
  • 回滚 YAML 已准备,不是出事后临时手写
  • 发布人、观察人和业务确认人在场

放量过程中,我会记录:

  • 每次调整权重的时间
  • v1、v2 实际请求量
  • 两个版本的 5xx、P95、P99
  • CPU、内存、GC、线程池和连接池
  • 数据库、Redis、MQ 指标
  • 核心业务成功率
  • 是否达到最小样本量
  • 下一阶段的放量条件
  • 明确的停止线和回滚线

全量完成也不代表下班。我还会做一轮收尾:

  • v2 保持 100%观察一个完整业务窗口
  • 确认没有流量继续进入 v1
  • 清理临时请求头和调试响应头
  • 再下线 v1 Deployment
  • 更新发布记录和架构文档
  • 复盘异常、误判和手工操作
  • 把能自动化的步骤放进发布流水线

说到底,Istio 只负责把流量按规则送到目标版本。代码是否兼容、容量是否够用、数据能不能回退、监控能不能看见问题,这些事情它不会替我们兜底。

真正靠谱的灰度发布,不是把 weight: 5 写进 YAML 就结束了,而是有明确的流量入口、有可比较的指标、有停止条件,还有一条经过验证的回滚路径。

相关规则可以对照 Istio 官方的 VirtualService API 参考、流量管理最佳实践和流量管理说明。

资料说明:本文涉及的官方文档内容均结合生产实践重新整理。Content was rephrased for compliance with licensing restrictions.

如果这篇文章对你有帮助,别忘了点赞转发支持一下!想了解更多运维实战经验和技术干货,记得关注微信公众号@运维躬行录,领取学习大礼包!!!我会持续分享更多接地气的运维知识和踩坑经验。让我们一起在运维这条路上互相学习,共同进步!

公众号:运维躬行录
个人博客:躬行笔记

文章目录

博主介绍

热爱技术的云计算运维工程师,Python全栈工程师,分享开发经验与生活感悟。
欢迎关注我的微信公众号@运维躬行录,领取海量学习资料

微信二维码