灰度 20% 流量全挂了——Istio DestRule 一个 label 拼错引发的 503 血案

上篇讲了 Istio sidecar 启动慢是控制面配置没到,这篇我们来看另一个更沉默的陷阱:sidecar 启动好了、控制面也通了,但流量就是走不对。

场景:order-service 做灰度发布,80% v1 + 20% v2,部署上去后监控显示 v2 的 20% 流量全 503,v1 的 80% 完全正常。 路径:坐标(现象)→ 分层(Envoy access log → 控制面 XDS → 路由规则 → EDS endpoint)→ 路径(排查命令)→ 定位(误判 vs 真相)→ 标点(修复 + Check-list)

灰度上线,20% 的流量被导向新版本——监控报警了:order-service 的 /api/orders 接口有四分之一比例的请求返回 503。第一反应是 v2 Deployment 的代码有问题,回滚 v2 的 Deployment,回滚后 503 还在——不是新版代码的问题,是 Istio 的 subset 映射层出了问题。控制面和数据面都通了,但流量在终点站落不了地。

一句话总结: 灰度流量 503 的根因不是 v2 代码挂了——是 DestinationRule 的 subset label selector 没匹配到 Pod,Envoy 的 EDS(Endpoint Discovery Service)返回空 endpoint 列表导致 no_healthy_upstream。改 DestRule 的一个 label 值,503 归零。

以下排查基于 Istio 1.20,K8s v1.27,使用 sidecar 自动注入和 Istio 流量管理。


【坐标】现象:灰度 20% 流量全 503

Service 访问链

order-service 的架构很简单:客户端通过 ClusterIP order-service:8080 调用,前面挂了一个 VirtualService 做灰度路由:

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: order-service
spec:
  hosts:
  - order-service
  http:
  - match:
    - uri:
        prefix: /api/orders
    route:
    - destination:
        host: order-service
        subset: v1
      weight: 80
    - destination:
        host: order-service
        subset: v2
      weight: 20

配了 80% v1 + 20% v2 的灰度比例,两个 subset 由 DestinationRule 定义。

故障表现

Pod 状态与 503 请求

监控曲线显示:v1 的 80% 流量全部正常(200),v2 的 20% 流量全部 503。直接把 v2 Deployment 回滚到 v1 镜像,重新部署,503 还在——这证实了问题不在 v2 代码。

# 确认 Pod 都在
$ kubectl get pods -n prod -l app=order-service
NAME                              READY   STATUS    RESTARTS
order-service-v1-f9b8d-abc12      2/2     Running   0
order-service-v1-f9b8d-abc13      2/2     Running   0
order-service-v2-7c4d2-def34      2/2     Running   0

# 确认 Endpoint
$ kubectl get endpoints order-service
NAME             ENDPOINTS                                         AGE
order-service    10.244.1.15:8080,10.244.1.16:8080,10.244.2.8:8080 12h

Endpoint 里有 3 个 Pod(v1 ×2 + v2 ×1),看起来一切正常。但 503 还在持续——每 5 个请求就有 1 个失败。如果只看 kubectl 的视角,问题不可能存在——这恰恰是 Istio 排查最迷惑的地方。

第一直觉:灰度配比有问题?

团队第一反应是 VirtualService 的 weight 或 match 条件写错了。查了一遍 YAML,prefix: /api/orders 正确,weight: 90/10 正确,subset 名称和 DestRule 定义一致。VS 没问题,那问题在哪?


【分层】四层排查:Envoy 日志 → 控制面 XDS → 路由规则 → EDS endpoint

Envoy 路由链分层

排查 Istio 路由规则问题,不走标准的 Pod → CRI → Node → 集群四层。Istio 引入了一层新的映射——Envoy 内部的 XDS 配置链,排查时必须走完这层链路:

客户端请求 → VirtualService 匹配 → DestRule subset 解析 → EDS endpoint 查找 → Pod
                          ↑                        ↑                  ↑
                     Layer 1(路由规则)     Layer 2(控制面)   Layer 3(数据面)

Layer 0(入口):Envoy access log

Envoy access log 输出

跳过 Envoy access log 直接查控制面是 Istio 排查最常见的跳层。Envoy 的 access log 是第一手证据——它记录了请求进来后 Envoy 做了什么决定。

$ kubectl logs -n prod order-service-v1-f9b8d-abc12 -c istio-proxy \
  --tail=50 | grep "503"
[2026-07-11T10:15:22.123Z] "GET /api/orders HTTP/1.1" 503 NR ...
  cluster_not_found · "order-service|v2|v2-subset"

关键信息:cluster_not_found。Envoy 告诉你它找不到 v2 subset 对应的 cluster。不是连接超时、不是 v2 进程挂了——是 Envoy 压根不知道 v2 的 endpoint 在哪。Envoy 认为 v1 的 cluster 存在(返回 200),v2 的 cluster 找不到(返回 503)。

这不是 K8s endpoint 层面的问题——K8s 的 endpoint 记录的是 Pod IP:Port,而 Istio 在 K8s endpoint 之上还有一层 EDS 映射。Envoy 收到请求后,根据 VirtualService 找到对应 subset,再通过 EDS 从 subset 的 label selector 中查找 Pod。两个环节各有一套 endpoint 表。

Layer 1:控制面 XDS 配置是否下发

Envoy access log 定位到了 v2 subset 的 cluster 找不到,接下来查控制面——Pilot 是否把对应的 XDS 配置推给了 sidecar。

$ istioctl proxy-status order-service-v1-f9b8d-abc12
NAME                                   CDS        LDS        EDS        RDS        PILOT
order-service-v1-f9b8d-abc12.prod      SYNCED     SYNCED     STALE      SYNCED     istiod-65c8b

EDS 列为 STALE——控制面推了配置,但 Envoy 认为当前的内容过期了,正在等更新。持续 STALE 说明 EDS 层面有异常。

$ istioctl proxy-config cluster order-service-v1-f9b8d-abc12 \
  --direction outbound | grep -E "v1|v2"
order-service|v1|v1-subset   prod    -   10.244.1.15:8080,10.244.1.16:8080
order-service|v2|v2-subset   prod    -   <no endpoints>

Cluster 列表确认了:v1 的 cluster 有 2 个 endpoint,v2 的 cluster 显示 no endpoints。Pilot 收到了 DestRule 配置,也创建了对应的 EDS cluster,但 cluster 里一个 Pod 都没找到。

Layer 2:路由规则配置是否正确

配置已经下发、cluster 也已创建,但 cluster 为空——问题缩小到了 DestRule 的 subset label selector 与实际 Pod label 不匹配

查看 DestRule:

$ kubectl get destinationrule order-service -n prod -o yaml
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: order-service
spec:
  host: order-service
  subsets:
  - name: v1
    labels:
      version: v1
  - name: v2
    labels:
      version: v2_fix    # ← 这里!Pod 的 label 是 version: v2,不是 v2_fix

Pod 的实际 label:

$ kubectl get pods -n prod -l app=order-service --show-labels
NAME                              READY   LABELS
order-service-v1-f9b8d-abc12      2/2    app=order-service,version=v1
order-service-v1-f9b8d-abc13      2/2    app=order-service,version=v1
order-service-v2-7c4d2-def34      2/2    app=order-service,version=v2

version: v2version: v2_fix——一个字符的差异。DestRule 的 label selector 要求匹配 version: v2_fix,但集群中没有一个 Pod 有这个 label,EDS 把 service 的 endpoint 全都枚举了一遍也找不到匹配,最终返回空列表。

Layer 3:EDS 映射链路(根因解释)

为什么 kubectl get endpoints 有 v2 的 Pod IP,但 Envoy 认为它不存在?因为这是两层不同的映射:

K8s Service → kube-proxy → iptables/IPVS(kubectl get endpoints 看到的是这层)
K8s Service → DestRule subset → Pilot → EDS → Envoy cluster(Istio 的映射层)

Istio 注入 sidecar 后,所有 Pod 间流量被 Envoy 劫持,不再走 kube-proxy 的 iptables 规则。Envoy 收到请求后,先查自己的 XDS 配置(从 Pilot 下发),再根据配置决定转发目标。

这套配置链路的完整路径是:

VirtualService → destination.subset: v2 → DestRule 查 v2 的 labels → version: v2_fix → EDS 以 app=order-service,version=v2_fix 搜索 endpoint → 0 匹配 → cluster 为 no endpoints → Envoy 返回 cluster_not_found → 503

不是 K8s 的 endpoint 不对——是 Istio 自己的 endpoint 映射层(EDS)用了一个不存在的 label 去搜索,自然搜不到任何 Pod。


【路径】核心排查命令

排查命令

Envoy access log 是第一入口

不要跳过 Envoy 日志直接查控制面。Envoy 日志告诉你每个请求的处理结果——是 cluster 找不到、连接超时、还是 upstream 挂了。

# 查 503 日志
kubectl logs <pod> -c istio-proxy | grep "503"
# 输出示例:
# "GET /api/orders" 503 NR cluster_not_found · "order-service|v2|v2-subset"
#   ① 503 响应码 → 后端拒绝了请求
#   ② NR(NoRoute)→ Envoy 内部路由决策的结果码
#   ③ cluster_not_found → Envoy 找不到目标 cluster
#   ④ order-service|v2|v2-subset → 完整的 cluster 名

三层信息要从右往左读:先看 cluster 名确认是哪条路由,再看结果码(NR / UH / UF),最后看响应码。

控制面状态检查

istioctl proxy-status 查 XDS 同步状态——全列 SYNCED 才算正常。STALE 提示配置过期,NOT SENT 表示控制面根本还没推送。

istioctl proxy-status <pod>

Cluster endpoint 确认

istioctl proxy-config cluster <pod> --direction outbound | grep <service>
# 看 endpoint 数量——有 `<no endpoints>` 说明 subset 没匹配到 Pod

这一步是把排查从"控制面配了没有"推进到"配对了没有"的关键。Envoy 收到了路由规则配置,但规则说"去 v2_subset 找 endpoint 列表",EDS 找不着——那就是规则本身的 label selector 写错了。


【定位】大多数人都误判了两个地方

误判对比

❌ 误判 1:v2 代码挂了

灰度上线 + 部分流量 503 → 第一反应是"新版本有 bug"。于是回滚 deployment,结果 503 还在——浪费了 20 分钟。回滚加重新部署已经够快,但方向从根上就错了。

为什么很自然会想到代码问题?因为在传统部署中(无 Istio/无服务网格),流量路由就是 deployment 配的 Service selector 决定的。新版本 Pod 起来后不响应,Service 自然把流量转到新 Pod 上,503 说明新 Pod 有问题。

但在 Istio 的架构下,deployment 决定不了流量怎么走——是 VirtualService + DestinationRule 决定的。v2 的 Pod 本身是正常的(用端口转发直接访问 Pod IP 能返回 200),只是 Envoy 不把流量发过去。

# 绕过 sidecar 直接访问 v2 Pod
$ kubectl exec -it debug-pod -- curl order-service-v2-7c4d2-def34:8080/api/orders
{"status":"OK"}    # ← v2 代码正常,是 Envoy 不把流量传过来

❌ 误判 2:VirtualService weight 配错了

排除了代码问题后,团队转向检查 VirtualService:先怀疑 weight 分布、再怀疑 match 条件、再检查 VS 和 DestRule 的 host 是否一致——全查了一遍,全对。这一步花了 15 分钟排查无关方向。

✅ 正确方向:先看 Envoy 决定了什么,再看规则写了什么

正确的排查顺序应该是:

Envoy access log(怎么看请求)→ 结果码是什么(NR/UH/UF/DC)
→ 对应哪个 cluster → cluster 有 endpoint 吗
→ DestRule subset labels 匹配实际 Pod label 吗
→ 修复 label → 再次验证 access log

排查不是在查 K8s——是在查 Istio 的配置映射链。这条链的起点是 Envoy access log,不是 K8s endpoint table。


【标点】修复 + Check-list

配置修复

修复:改一个 label 值

把 DestRule 中 v2 subset 的 labels 从 version: v2_fix 改为 version: v2

apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: order-service
spec:
  host: order-service
  subsets:
  - name: v1
    labels:
      version: v1
  - name: v2
    labels:
      version: v2        # ← fix: 改回实际 Pod label

应用后:

$ kubectl apply -f destinationrule-fix.yaml

# 验证 EDS endpoint 恢复
$ istioctl proxy-config cluster order-service-v1-f9b8d-abc12 \
  --direction outbound | grep "v2"
order-service|v2|v2-subset   prod    -   10.244.2.8:8080

# 确认 503 消失
$ kubectl logs order-service-v1-f9b8d-abc12 -c istio-proxy \
  --tail=10 | grep "503"
# (无输出——503 归零)

效果:DestRule 应用后,Pilot 拿到新配置重新计算 EDS endpoint,v2 subset 的 cluster 从 no endpoints 变为 10.244.2.8:8080,灰度流量恢复正常。整个过程不需要重启 Pod,不需要重新部署——配置热生效。

排查 Istio 路由问题不是在查 K8s——是在查 Envoy 内部的配置映射链,起点是 access log,终点是 subset label 的实际匹配。

Check-list:同类问题排查清单

# 1. 查 Envoy access log——确认结果码和 cluster 名
kubectl logs <pod> -c istio-proxy | grep "503\|NR\|UH\|cluster_not_found"

# 2. 查控制面 XDS 同步状态
istioctl proxy-status <pod>

# 3. 查目标 cluster 的 endpoint 列表
istioctl proxy-config cluster <pod> --direction outbound | grep <service>

# 4. 查 DestRule 实际 label selector
kubectl get destinationrule <name> -n <ns> -o yaml | grep -A 3 "labels:"

# 5. 查 Pod 实际 label
kubectl get pods -n <ns> -l app=<app> --show-labels

# 6. 直接用 istioctl analyze 自动检测
istioctl analyze -n <ns>

把 DestRule 的 subset label 和 Pod label 做一次互相比对,基本 5 分钟定位。用 istioctl analyze 也能检测部分 label mismatch,但它是静态代码分析——YAML 里的 label 拼写对了但和 running Pod 不对应,它查不到,最终还是需要人工核对。

从这次故障中带走的

Istio 灰度故障排查,不要在 K8s 原生视角里找原因,要在 Envoy 的配置映射链里找原因。 kubectl get endpoints 有 Pod IP ≠ Envoy 上有对应 cluster。多了一层映射,就多了一层排查维度——而 Istio 这层的排查入口在 Envoy access log 里,不仔细看根本不知道方向偏了。

下篇我们聊一个更隐蔽的 Istio 网络问题:Gateway API 配置和 Ingress 兼容性排查。


附:完整命令清单

# Envoy access log(查 503 和结果码)
kubectl logs <pod> -c istio-proxy | grep -E "503|NR|cluster_not_found|no_healthy_upstream"

# Envoy access log(查所有请求结果码,含 200)
kubectl logs <pod> -c istio-proxy | grep "inbound\|outbound"

# 控制面 XDS 同步状态
istioctl proxy-status <pod>

# 目标 cluster endpoint 列表
istioctl proxy-config cluster <pod> --direction outbound | grep <service>

# 目标 cluster 的详细 endpoint 分布
istioctl proxy-config endpoints <pod> --cluster "outbound|8080||<service>"

# 当前 XDS 配置全量(诊断用)
istioctl proxy-config all <pod>

# 检查 DestRule
kubectl get destinationrule -n <ns> -o yaml

# 检查 Pod label
kubectl get pods -n <ns> -l app=<app> --show-labels

# 绕过 sidecar 直接访问 Pod IP(验证服务本身正常)
kubectl exec -it <debug-pod> -n <ns> -- curl <pod-ip>:<port>

# Istio 配置静态检查
istioctl analyze -n <ns>

# 查看 Pilot 配置推送日志
kubectl logs -n istio-system deploy/istiod | grep -E "push|eds|endpoint" | tail -20

📺 公众号「Ai拆代码的曹操」 🌟 知识星球「Ai拆代码的曹操」