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