跳转到内容
123xiao | 无名键客

《集群架构实战:基于 Kubernetes 与 Service Mesh 的灰度发布和故障隔离设计》

字数: 0 阅读时长: 1 分钟

背景与问题

在 Kubernetes 集群里做发布,很多团队一开始靠 Deployment 的滚动更新就够了。但业务一复杂,问题马上会冒出来:

  • 新版本功能要先给 5% 用户试用
  • 某些地区、某些 Header、某些测试账号要优先命中新版本
  • 新版本偶发超时,不能把老版本一起拖死
  • 下游服务抖动时,希望快速隔离,避免级联故障
  • 回滚要尽量快,最好不用重新发版

这时候,单靠 Kubernetes 原生的 Service 和 Deployment,通常不够细。它能解决“副本如何替换”,但不擅长解决“流量如何精细控制”和“故障如何在网络层隔离”。

所以常见做法是:

  • Kubernetes 负责工作负载编排
  • Service Mesh 负责流量治理、熔断、超时、重试、可观测性

这篇文章我会从“排障与止血”的角度来讲,而不是只讲概念。因为真实场景里,灰度发布失败往往不是配置不会写,而是:

  1. 流量没有按预期切走
  2. 新版本明明只有 10% 流量,却把数据库打满
  3. 一个下游慢接口导致整个调用链雪崩
  4. 回滚了镜像,问题却没消失,因为路由规则还在

下面我们就按实战路径,一步步搭一个最小可运行方案,并说明怎么定位问题。


背景架构图

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-v1
  • app-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_total
  • istio_request_duration_milliseconds
  • istio_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. 灰度优先按人群,不要一上来按随机流量

对于中级以上复杂业务,我更建议灰度顺序是:

  1. 内部员工 / 测试账号
  2. 特定租户 / 区域 / 白名单用户
  3. 小比例随机流量
  4. 分阶段扩大
  5. 全量

这样更容易定位问题来源,也更便于止血。


5. 把“回滚路由”做成预案

线上最怕的是:知道怎么救火,但没人敢改。

建议提前准备:

  • 100% 切回 v1 的 YAML
  • 关闭重试的 YAML
  • 降低连接池的 YAML
  • 只保留 Header 灰度的 YAML

最好放在版本库里,出问题直接 apply。


6. 监控要区分版本维度

如果没有 v1 / v2 维度的指标,你很难判断问题到底在哪个版本。

至少要能按这些维度看数据:

  • 服务名
  • 版本号
  • 响应码
  • 延迟分位数
  • 重试次数
  • 下游依赖

一次完整的灰度与隔离发布流程建议

这部分是我比较推荐的落地顺序,适合中型团队直接照搬。

stateDiagram-v2
    [*] --> 部署v2实例
    部署v2实例 --> 定向灰度
    定向灰度 --> 观察指标
    观察指标 --> 小流量灰度: 指标正常
    观察指标 --> 回切v1: 指标异常
    小流量灰度 --> 扩大比例: 持续稳定
    小流量灰度 --> 回切v1: 错误率上升
    扩大比例 --> 全量发布
    回切v1 --> 排查修复
    排查修复 --> 部署v2实例
    全量发布 --> [*]

推荐执行清单

  1. 先部署 v2,但不放量
  2. 用 Header 或白名单账号定向验证
  3. 确认日志、指标、追踪正常
  4. 先 5% 或 10% 放量
  5. 观察 15~30 分钟,确认:
    • 错误率没升高
    • P95/P99 没显著变差
    • 下游连接数没异常
  6. 再扩大到 25%、50%、100%
  7. 任一步异常,先切回 v1,再分析原因

总结

基于 Kubernetes 与 Service Mesh 做灰度发布,真正的价值不只是“能切流量”,而是:

  • 能精细控制谁进入新版本
  • 能在异常时快速止血
  • 能把故障限制在局部,而不是扩散到全链路

如果你只记住几个最实用的建议,我建议是这几条:

  1. 灰度先做人群定向,再做随机比例
  2. 超时要短,重试要少,连接池要有限制
  3. 回滚不只看 Deployment,也要回滚路由规则
  4. subset 标签、Sidecar 注入、Gateway 绑定,是最先排查的三件事
  5. 止血优先级高于分析,先把 v2 切 0 再说

最后给一个边界条件:
如果你的系统规模还很小、服务依赖简单、发布频率不高,那么先用 Kubernetes 原生滚动更新也完全合理,不必为了“高级架构”而引入不必要复杂度。
但一旦你开始面对多版本并存、精细灰度、依赖抖动和链路级故障,Service Mesh 几乎会从“可选项”变成“基础设施”。

真正的实战,不是把 YAML 写出来,而是出了问题你知道先看哪、先改哪、先保谁。这个能力,才是灰度发布和故障隔离设计最值钱的部分。


分享到:

上一篇
《Java 中线程池参数调优与任务拒绝策略实战:从业务压测到生产配置落地》
下一篇
《从源码到部署:基于开源项目 Superset 搭建企业级数据可视化平台的实战指南》