背景与问题
在 Kubernetes 集群里做发布,很多团队一开始靠 Deployment 的滚动更新就够了。但业务一复杂,问题马上会冒出来:
- 新版本功能要先给 5% 用户试用
- 某些地区、某些 Header、某些测试账号要优先命中新版本
- 新版本偶发超时,不能把老版本一起拖死
- 下游服务抖动时,希望快速隔离,避免级联故障
- 回滚要尽量快,最好不用重新发版
这时候,单靠 Kubernetes 原生的 Service 和 Deployment,通常不够细。它能解决“副本如何替换”,但不擅长解决“流量如何精细控制”和“故障如何在网络层隔离”。
所以常见做法是:
- Kubernetes 负责工作负载编排
- Service Mesh 负责流量治理、熔断、超时、重试、可观测性
这篇文章我会从“排障与止血”的角度来讲,而不是只讲概念。因为真实场景里,灰度发布失败往往不是配置不会写,而是:
- 流量没有按预期切走
- 新版本明明只有 10% 流量,却把数据库打满
- 一个下游慢接口导致整个调用链雪崩
- 回滚了镜像,问题却没消失,因为路由规则还在
下面我们就按实战路径,一步步搭一个最小可运行方案,并说明怎么定位问题。
背景架构图
flowchart LR
U[用户请求] --> GW[Ingress / Gateway]
GW --> VS[VirtualService 路由规则]
VS --> V1[app v1 Pod]
VS --> V2[app v2 Pod]
V1 --> S1[下游 payment]
V2 --> S1
S1 --> DB[(Database)]
subgraph Kubernetes Cluster
GW
VS
V1
V2
S1
end
subgraph Service Mesh
P1[Envoy Sidecar]
P2[Envoy Sidecar]
P3[Envoy Sidecar]
end
核心原理
1. Kubernetes 解决的是“实例管理”
Kubernetes 的强项是:
- 调度 Pod
- 副本伸缩
- 滚动更新
- 服务发现
- 自愈
比如你有两个版本:
app-v1app-v2
只要它们都挂在一个 Service 后面,Kubernetes 会把流量“平均”分发到后端 Pod。但这里的“平均”并不等于“按业务规则分流”。
它不知道:
- 哪些用户应该命中新版本
- 哪些请求要强制进 v1
- 某版本出现异常后,应该快速切 0 流量
2. Service Mesh 解决的是“流量治理”
以 Istio 为例,Service Mesh 常见能力包括:
- 按权重分流
- 按 Header / Cookie / URI 路由
- 超时控制
- 重试策略
- 熔断/连接池限制
- 故障注入
- mTLS
- 遥测与追踪
你可以把它理解成:应用和网络治理逻辑被下沉到了 Sidecar/数据平面。业务代码不用每个服务都自己实现这些逻辑。
3. 灰度发布和故障隔离是两件相关但不同的事
很多人会把这两个概念混在一起。实际上:
灰度发布关注的是:
- 流量怎么有控制地进新版本
- 风险怎么逐步放大
- 问题怎么快速回退
故障隔离关注的是:
- 新版本挂了,别拖垮老版本
- 一个慢依赖挂了,别把线程池、连接池耗尽
- 异常流量不要扩散到全链路
它们通常需要同时设计,否则就会出现一种经典事故:
明明只给 v2 切了 10% 流量,但 v2 调用下游时没有限流和超时,结果重试风暴把 payment 服务打挂,最后 v1 也一起超时。
4. 关键对象之间的关系
以 Istio 为例,最常见的三个对象:
- DestinationRule:定义子集、连接池、熔断、TLS
- VirtualService:定义路由规则、权重、Header 匹配、重试、超时
- Gateway:定义入口流量如何进入 Mesh
关系图可以简单理解为:
flowchart TD
A[Gateway] --> B[VirtualService]
B --> C1[subset v1]
B --> C2[subset v2]
D[DestinationRule] --> C1
D --> C2
C1 --> E[Deployment app-v1]
C2 --> F[Deployment app-v2]
现象复现
先构造一个很典型的故障场景:
app-v1是稳定版本app-v2引入了一个对payment服务的慢调用- 灰度时先放 20% 流量到 v2
- 结果发现整体 RT 飙升,错误率上升
常见现象:
- 网关 5xx 增加
- v2 Pod CPU 不高,但请求堆积
- payment 服务连接数暴涨
- 调用链里出现大量重试
- 回滚镜像后,部分流量还是命中旧规则
这就是本文要解决的核心:不是只有“怎么发”,还要有“出问题怎么止血”。
实战代码(可运行)
下面以一个最小可运行方案演示。假设你已经有一个 Kubernetes 集群,并安装了 Istio。
命名空间统一使用
gray-demo
1. 创建命名空间并开启 Sidecar 注入
kubectl create namespace gray-demo
kubectl label namespace gray-demo istio-injection=enabled --overwrite
2. 部署两个版本的应用
这里用最容易运行的 hashicorp/http-echo 做演示,一个返回 v1,一个返回 v2。
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-v1
namespace: gray-demo
spec:
replicas: 2
selector:
matchLabels:
app: demo-app
version: v1
template:
metadata:
labels:
app: demo-app
version: v1
spec:
containers:
- name: app
image: hashicorp/http-echo:0.2.3
args:
- "-text=hello from v1"
ports:
- containerPort: 5678
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-v2
namespace: gray-demo
spec:
replicas: 2
selector:
matchLabels:
app: demo-app
version: v2
template:
metadata:
labels:
app: demo-app
version: v2
spec:
containers:
- name: app
image: hashicorp/http-echo:0.2.3
args:
- "-text=hello from v2"
ports:
- containerPort: 5678
---
apiVersion: v1
kind: Service
metadata:
name: demo-app
namespace: gray-demo
spec:
selector:
app: demo-app
ports:
- port: 80
targetPort: 5678
name: http
应用部署:
kubectl apply -f app.yaml
3. 创建 Istio Gateway 与路由规则
3.1 Gateway
apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata:
name: demo-gateway
namespace: gray-demo
spec:
selector:
istio: ingressgateway
servers:
- port:
number: 80
name: http
protocol: HTTP
hosts:
- "*"
3.2 DestinationRule
用标签定义两个子集,并配置连接池和异常实例摘除的基础能力。
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: demo-app
namespace: gray-demo
spec:
host: demo-app.gray-demo.svc.cluster.local
trafficPolicy:
connectionPool:
tcp:
maxConnections: 50
http:
http1MaxPendingRequests: 20
maxRequestsPerConnection: 10
outlierDetection:
consecutive5xxErrors: 3
interval: 5s
baseEjectionTime: 30s
maxEjectionPercent: 100
subsets:
- name: v1
labels:
version: v1
- name: v2
labels:
version: v2
3.3 VirtualService:20% 灰度到 v2
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: demo-app
namespace: gray-demo
spec:
hosts:
- "*"
gateways:
- demo-gateway
http:
- route:
- destination:
host: demo-app.gray-demo.svc.cluster.local
subset: v1
weight: 80
- destination:
host: demo-app.gray-demo.svc.cluster.local
subset: v2
weight: 20
timeout: 2s
retries:
attempts: 1
perTryTimeout: 1s
应用配置:
kubectl apply -f gateway.yaml
kubectl apply -f destination-rule.yaml
kubectl apply -f virtual-service.yaml
4. 验证灰度是否生效
获取 Ingress IP:
kubectl -n istio-system get svc istio-ingressgateway
假设 EXTERNAL-IP 是 1.2.3.4,执行:
for i in $(seq 1 20); do
curl -s http://1.2.3.4/
done
你应该看到返回结果中大部分是 hello from v1,少部分是 hello from v2。
如果想统计比例,可以这样做:
for i in $(seq 1 100); do
curl -s http://1.2.3.4/
done | sort | uniq -c
5. 按请求头定向灰度
真实场景里,我更推荐先做“定向灰度”,比如内部测试账号或特定 Header 先走 v2,而不是一上来全量随机抽样。
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: demo-app
namespace: gray-demo
spec:
hosts:
- "*"
gateways:
- demo-gateway
http:
- match:
- headers:
x-canary:
exact: "true"
route:
- destination:
host: demo-app.gray-demo.svc.cluster.local
subset: v2
- route:
- destination:
host: demo-app.gray-demo.svc.cluster.local
subset: v1
weight: 100
timeout: 2s
验证:
curl -s -H "x-canary: true" http://1.2.3.4/
curl -s http://1.2.3.4/
这样可以确保测试请求稳定命中新版本,不会被随机权重干扰。
6. 故障隔离:对下游服务设置超时、重试与熔断
故障隔离的核心不是“把重试开大”,而是限制影响范围。
一个更稳妥的思路是:
- 短超时
- 少量重试
- 限制并发和连接
- 发现异常实例后快速摘除
下面给一个典型配置片段:
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: payment
namespace: gray-demo
spec:
hosts:
- payment.gray-demo.svc.cluster.local
http:
- route:
- destination:
host: payment.gray-demo.svc.cluster.local
timeout: 800ms
retries:
attempts: 1
perTryTimeout: 300ms
retryOn: gateway-error,connect-failure,refused-stream
---
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: payment
namespace: gray-demo
spec:
host: payment.gray-demo.svc.cluster.local
trafficPolicy:
connectionPool:
tcp:
maxConnections: 20
http:
http1MaxPendingRequests: 10
maxRequestsPerConnection: 5
outlierDetection:
consecutive5xxErrors: 2
interval: 5s
baseEjectionTime: 1m
maxEjectionPercent: 100
这里有几个经验值值得注意:
- 超时要短于上游调用链预算
- 重试次数不要过大
- 连接池限制不是保守,是保护系统
- 异常摘除不要太慢
我当时踩过一个坑:某个服务把 attempts 配成了 3,看起来不大,但它后面又调用了两个服务,结果问题一来就出现链路级放大。最后看监控才发现,真正打垮下游的不是原始流量,而是重试流量。
请求流转时序图
sequenceDiagram
participant User
participant Gateway
participant Envoy as Sidecar/Envoy
participant App as demo-app v2
participant Payment as payment service
User->>Gateway: HTTP Request
Gateway->>Envoy: 按 VirtualService 匹配
Envoy->>App: 路由到 v2
App->>Payment: 调用 payment
alt payment 正常
Payment-->>App: 200 OK
App-->>Envoy: 正常响应
Envoy-->>Gateway: 返回结果
Gateway-->>User: 200
else payment 超时/5xx
Payment-->>App: timeout/5xx
App-->>Envoy: 失败
Envoy->>Payment: 按策略最多重试 1 次
Envoy-->>Gateway: 若仍失败则快速返回
Gateway-->>User: 5xx / fallback
end
定位路径
排障时不要一上来就改 YAML。建议按下面这条路径走,效率很高。
1. 先看 Kubernetes 层是否健康
kubectl get pods -n gray-demo -o wide
kubectl get svc -n gray-demo
kubectl describe pod <pod-name> -n gray-demo
kubectl logs <pod-name> -n gray-demo
确认:
- Pod 是否 Ready
- 镜像是否正确
- 标签是否符合预期
- Service selector 是否选中了正确 Pod
这里有个高频坑:Deployment 的 version: v2 标签写了,但 Pod 模板里没写,结果 subset 永远匹配不到。
2. 再看 Istio 配置是否真正下发
kubectl get virtualservice,destinationrule,gateway -n gray-demo
istioctl proxy-status
查看某个 Pod 的路由配置:
istioctl proxy-config routes <pod-name> -n gray-demo
istioctl proxy-config clusters <pod-name> -n gray-demo
重点确认:
- VirtualService 是否已被 Sidecar 感知
- subset 是否存在
- host 名称是否写对
- Gateway 是否绑定成功
3. 看入口流量是否命中预期规则
如果是 Header 灰度,先验证请求头是否真的进来了:
curl -v -H "x-canary: true" http://1.2.3.4/
很多“灰度不生效”其实不是 Mesh 的问题,而是:
- CDN 把 Header 丢了
- Ingress 前面还有一层代理没透传
- 测试流量走的不是这个网关地址
4. 看是不是重试风暴或连接池打满
如果 RT 变高、错误变多,但应用日志没什么明显异常,很可能是 Sidecar 层已经在重试或排队。
看指标时要重点关注:
- 请求总量与重试量
- 5xx 比例
- P95/P99 延迟
- 下游连接数
- pending requests
- 熔断或异常摘除次数
如果你接了 Prometheus / Grafana,建议至少观察:
istio_requests_totalistio_request_duration_millisecondsistio_tcp_connections_opened_total
5. 判断是版本问题还是依赖问题
一个非常有效的办法:把 v2 流量临时切 0,但保留实例不删。
如果切 0 后整体恢复:
- 问题大概率在 v2 版本逻辑
- 或 v2 的调用模式与 v1 不一致
如果切 0 后还不恢复:
- 问题可能在下游依赖
- 或全局流量策略本身配置有误
- 或网关层就已经有异常
止血时,这一步特别关键。
止血方案
线上事故时,不要一边分析一边全量变更。建议按风险最小原则止血。
方案一:立即切回 100% 到 v1
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: demo-app
namespace: gray-demo
spec:
hosts:
- "*"
gateways:
- demo-gateway
http:
- route:
- destination:
host: demo-app.gray-demo.svc.cluster.local
subset: v1
weight: 100
- destination:
host: demo-app.gray-demo.svc.cluster.local
subset: v2
weight: 0
这比重新发布镜像更快,因为它只改路由。
方案二:关闭高风险重试
如果下游已经抖动,继续重试通常只会放大故障。可临时将重试关小甚至关闭:
retries:
attempts: 0
方案三:缩小连接池和请求并发,保护下游
如果 payment 或数据库已经接近极限,宁可让上游快速失败,也不要把整个系统拖死。
方案四:只保留定向灰度
把随机 20% 改成 Header 灰度,只允许测试用户进入新版本。这样业务影响面最小。
常见坑与排查
坑 1:subset 配了,但一个流量都进不去
现象
- VirtualService 看起来正常
- 但 v2 没有任何请求
排查
检查 DestinationRule 的 subset 标签和 Pod 标签是否完全一致。
kubectl get pods -n gray-demo --show-labels
kubectl get destinationrule demo-app -n gray-demo -o yaml
原因
version: v2写成了ver: v2- Deployment metadata 写了标签,但 template 没写
- host 名称不是 FQDN,命名空间不一致时匹配失败
坑 2:灰度比例不准
现象
- 配了 90/10,但测试几次感觉全是 v1
排查
先把样本量拉大:
for i in $(seq 1 200); do curl -s http://1.2.3.4/; done | sort | uniq -c
原因
- 样本太小
- 客户端连接复用导致请求分布不均
- 会话保持/代理缓存影响结果
建议
灰度验证不要靠“肉眼点几次页面”,要看监控统计。
坑 3:回滚镜像后问题还在
现象
- 已经把 Deployment 回滚了
- 但依然看到异常流量或 5xx
排查
检查 Mesh 配置是否还保留了旧路由、超时、重试、故障注入规则。
kubectl get virtualservice,destinationrule -A
原因
发布对象不止 Deployment,还有流量规则。很多事故是“应用回滚了,网络策略没回滚”。
坑 4:重试把下游打挂
现象
- 错误率先升高,随后下游 CPU、连接数飙升
排查
看下游请求量是否明显高于入口请求量;检查 VirtualService 的 retries。
建议
- 只对幂等请求做重试
- 控制重试次数
- 设置更短的 perTryTimeout
- 下游异常时优先快速失败
坑 5:Sidecar 没注入,规则完全不生效
现象
- Pod 正常运行
- 但流量治理像没生效一样
排查
kubectl get pod <pod-name> -n gray-demo -o jsonpath='{.spec.containers[*].name}'
看是否包含 istio-proxy。
原因
- 命名空间没打注入标签
- Pod 创建时还没开启自动注入
- 使用了排除注入的注解
安全/性能最佳实践
灰度发布和故障隔离,别只盯功能正确,还要考虑安全和系统开销。
1. 开启 mTLS,避免服务间明文通信
在集群内部,很多人会忽略这点,觉得“内网就安全”。但在多租户、跨节点、跨可用区场景下,mTLS 很有必要。
一个基础示例:
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: default
namespace: gray-demo
spec:
mtls:
mode: STRICT
注意边界条件:
- 老服务未入网格时,直接开 STRICT 可能导致通信中断
- 需要逐步推进,先盘点哪些服务已经注入 Sidecar
2. 不要把超时设得“看起来很安全”
很多团队喜欢配一个很大的超时,比如 30 秒,觉得这样不容易报错。实际上这会:
- 占住线程/连接更久
- 放大排队
- 让故障暴露更慢
- 让上游雪崩更严重
更合理的做法是基于调用链预算设置超时,比如:
- 用户接口总预算 2 秒
- 当前服务最多花 800ms
- 单次下游调用最多 300~500ms
3. 重试只对幂等接口开启
像查询类请求,重试通常可接受;但支付、下单、库存扣减,必须非常谨慎。
如果接口不是天然幂等,就不要仅靠 Mesh 层机械重试。
4. 灰度优先按人群,不要一上来按随机流量
对于中级以上复杂业务,我更建议灰度顺序是:
- 内部员工 / 测试账号
- 特定租户 / 区域 / 白名单用户
- 小比例随机流量
- 分阶段扩大
- 全量
这样更容易定位问题来源,也更便于止血。
5. 把“回滚路由”做成预案
线上最怕的是:知道怎么救火,但没人敢改。
建议提前准备:
- 100% 切回 v1 的 YAML
- 关闭重试的 YAML
- 降低连接池的 YAML
- 只保留 Header 灰度的 YAML
最好放在版本库里,出问题直接 apply。
6. 监控要区分版本维度
如果没有 v1 / v2 维度的指标,你很难判断问题到底在哪个版本。
至少要能按这些维度看数据:
- 服务名
- 版本号
- 响应码
- 延迟分位数
- 重试次数
- 下游依赖
一次完整的灰度与隔离发布流程建议
这部分是我比较推荐的落地顺序,适合中型团队直接照搬。
stateDiagram-v2
[*] --> 部署v2实例
部署v2实例 --> 定向灰度
定向灰度 --> 观察指标
观察指标 --> 小流量灰度: 指标正常
观察指标 --> 回切v1: 指标异常
小流量灰度 --> 扩大比例: 持续稳定
小流量灰度 --> 回切v1: 错误率上升
扩大比例 --> 全量发布
回切v1 --> 排查修复
排查修复 --> 部署v2实例
全量发布 --> [*]
推荐执行清单
- 先部署 v2,但不放量
- 用 Header 或白名单账号定向验证
- 确认日志、指标、追踪正常
- 先 5% 或 10% 放量
- 观察 15~30 分钟,确认:
- 错误率没升高
- P95/P99 没显著变差
- 下游连接数没异常
- 再扩大到 25%、50%、100%
- 任一步异常,先切回 v1,再分析原因
总结
基于 Kubernetes 与 Service Mesh 做灰度发布,真正的价值不只是“能切流量”,而是:
- 能精细控制谁进入新版本
- 能在异常时快速止血
- 能把故障限制在局部,而不是扩散到全链路
如果你只记住几个最实用的建议,我建议是这几条:
- 灰度先做人群定向,再做随机比例
- 超时要短,重试要少,连接池要有限制
- 回滚不只看 Deployment,也要回滚路由规则
- subset 标签、Sidecar 注入、Gateway 绑定,是最先排查的三件事
- 止血优先级高于分析,先把 v2 切 0 再说
最后给一个边界条件:
如果你的系统规模还很小、服务依赖简单、发布频率不高,那么先用 Kubernetes 原生滚动更新也完全合理,不必为了“高级架构”而引入不必要复杂度。
但一旦你开始面对多版本并存、精细灰度、依赖抖动和链路级故障,Service Mesh 几乎会从“可选项”变成“基础设施”。
真正的实战,不是把 YAML 写出来,而是出了问题你知道先看哪、先改哪、先保谁。这个能力,才是灰度发布和故障隔离设计最值钱的部分。