课程目录(第 26 章 / 共 33 章)
课程/进阶:生产实践

服务网格:Istio 流量治理与 mTLS

26 章 / 共 33·22 分钟·进阶服务网格IstiomTLS灰度发布

用 Istio 把灰度、超时重试、熔断与服务间加密从应用代码搬到代理层,并想清楚这套东西什么时候不该上。

学完这一章,你将能够

  • 说清数据面与控制面的分工,以及 sidecar 与 ambient 两种模式的取舍
  • 用 VirtualService 与 DestinationRule 做出 90/10 灰度、按 header 路由与熔断
  • 用 PeerAuthentication 打开 STRICT mTLS,并定位「一边加密一边明文」的 503
  • 判断自己的团队在什么阶段不该引入服务网格

服务网格解决什么问题

Service 解决「怎么找到」,Ingress 解决「怎么分」,CNI 与 NetworkPolicy 解决「能不能通」。但生产上还有一类问题不在网络层,而在每个应用的进程内部:想让 10% 流量走新版本,Java 组用 Spring Cloud 写一版、Go 组用 gRPC 拦截器写一版、Python 组干脆拆成两个域名;想让服务间加密,每个服务都要自己加载证书、处理轮转;某个下游变慢,调用方没有超时,故障顺着调用链放大。

它们的共同点是:与业务无关,却要在每一种语言、每一个服务里重复实现一遍。 服务网格把这类逻辑从应用进程搬到应用旁边的代理进程里统一执行,应用只管发普通 HTTP/gRPC 请求。

需求传统做法(各语言 SDK)服务网格做法
灰度发布每个语言各写一套权重逻辑VirtualService 写权重,与语言无关
服务间加密应用自己加载证书、做 TLS 握手代理自动签发并轮转证书,做双向 TLS
超时重试熔断各语言库不同,参数不统一DestinationRule 统一下发
调用链追踪每个服务自己埋点、传 header代理自动注入并上报 span
身份与鉴权用 Pod IP 白名单基于工作负载证书的身份(SPIFFE)

什么时候不该用

网格的复杂度是实打实的:多一个控制面、多一层代理、多一套 CRD,还有需要长期跟进的升级节奏。

你的处境判断
服务少(十几个以内)、技术栈单一框架自带的超时重试或 Ingress 注解就够
没有专职平台 / SREistiod、代理版本、CRD 升级是长期负担,出事时没人读得懂 Envoy 配置
延迟预算极紧每跳多一次代理转发,开销取决于版本、CPU 限制与流量模型,必须自己压测
只想要「服务间加密」cert-manager 加网关终止 TLS 往往更简单,见 安全加固
还在手工 kubectl apply 管 YAML先做 GitOps,否则网格配置很快失控

数据面与控制面:sidecar 与 ambient

控制面istiod:读取 Service、EndpointSlice 与网格 CRD,把配置翻译后推给代理,同时充当证书签发机构。数据面是真正处理每个数据包的代理,有两种形态。

sidecar 模式:给每个 Pod 额外注入一个 istio-proxy 容器(Envoy),Pod 的进出流量被重定向到它,应用无感知,功能最全但每个 Pod 都多一个容器。ambient 模式是更新的方向(Istio 1.24 起生产可用,是否可用取决于你的版本):不再逐 Pod 注入,而是每个节点跑一个 ztunnel(DaemonSet)负责四层 mTLS 与授权,需要七层能力(按 header 路由、重试)时再给命名空间或服务按需部署 waypoint 代理。

text
sidecar 模式(每个 Pod 一个代理)        ambient 模式(每个节点一个 ztunnel)
  ┌────────┐   ┌────────┐              Pod A ──► ztunnel ──mTLS──► ztunnel ──► Pod B
  │ app    │   │ app    │                          ▲ 需要七层时再挂 waypoint
  │  ▼     │   │  ▲     │
  │ Envoy ─┼───┼─ Envoy │   加密区间 = sidecar ──► sidecar(应用↔本机代理是明文)
  └────────┘   └────────┘
       └── mTLS:工作负载证书 + SPIFFE 身份,自动轮转

一句话取舍:sidecar 功能全但重,ambient 轻但要确认版本与场景是否支持;ambient 可按命名空间逐步接入,不必一次性改造全集群。

安装与注入

先装控制面。istioctl 最省事,版本号按最新 release 替换(本文写作时是 1.29.x 一线):

bash
curl -L https://istio.io/downloadIstio | sh -
cd istio-1.29.1 && export PATH="$PWD/bin:$PATH"
istioctl version --remote=false
istioctl install --set profile=demo -y
kubectl -n istio-system get pods

istioctl version --remote=false 只打印本地客户端版本;kubectl -n istio-system get podsistiod 显示 1/1 Running 就算装好。profile=demo 会额外装 Kiali、Jaeger、Prometheus 等示例插件,适合学习;生产用 profile=default 再按需装可观测组件。用 Helm 也行,对应 istio/baseistio/istiodistio/gateway 三个 chart(先查最新版本)。

动手练习:注入代理并确认

bash
kubectl create namespace demo
kubectl label namespace demo istio-injection=enabled
kubectl -n demo create deployment api --image=hashicorp/http-echo:1.0 -- -listen=:5678 -text=hello

# 标签只对之后创建的 Pod 生效,已有 Pod 必须重建
kubectl -n demo rollout restart deployment/api
kubectl -n demo rollout status deployment/api

# 输出 Pod 里所有容器名:有 istio-proxy 才算注入成功
kubectl -n demo get pod -l app=api -o jsonpath='{.items[0].spec.containers[*].name}'; echo

# 让 Istio 检查配置
istioctl analyze -n demo

输出形如 api istio-proxy 说明注入成功;只有 api 说明没生效(命名空间标签拼错、Pod 有 sidecar.istio.io/inject: "false" 注解,或打了标签没重启 Pod)。istioctl analyze -n demo 看到 No validation issues found 就说明没有明显问题——它能抓出「VirtualService 指向不存在的网关」「DestinationRule 引用了没有 subset 的 host」这类跑起来才发现的错误。ambient 模式则把命名空间标签换成 istio.io/dataplane-mode=ambient,用 kubectl -n istio-system get ds ztunnel 确认节点代理,七层用 istioctl waypoint apply -n demo --enroll-namespace 按需开启(命令名随版本变化,旧版本为 istioctl x waypoint apply)。

端口名必须带协议前缀

Istio 靠端口名判断协议:httphttp2grpctcptls 开头(如 httphttp-api)才算七层。名字写成 webbackend 会被当成裸 TCP,此时 header 路由、重试、超时全部静默失效——不报错,只是不生效。

流量治理实操:灰度、路由与熔断

准备两个版本的 api:Service 用普通的 apiselector: app: api、端口 5678端口名写 `http`,写法见 第 7 章 Service),Deployment 用标签 version: v1 / version: v2 区分。先写 api-v1.yaml,再复制成 api-v2.yaml,把 name、两处 v1-text 改成 v2:

yaml
apiVersion: apps/v1
kind: Deployment
metadata: {name: api-v1, namespace: demo}
spec:
  selector: {matchLabels: {app: api, version: v1}}
  template:
    metadata: {labels: {app: api, version: v1}}
    spec:
      containers:
        - name: api
          image: hashicorp/http-echo:1.0
          args: ["-listen=:5678", "-text=hello from api v1"]
          ports:
            - {name: http, containerPort: 5678}

DestinationRule 存成 api-dr.yaml,它定义「有哪些版本可选」,并挂上连接池与熔断策略:

yaml
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata: {name: api, namespace: demo}
spec:
  host: api
  trafficPolicy:
    connectionPool: {tcp: {maxConnections: 100}}
    outlierDetection: {consecutive5xxErrors: 5, interval: 10s, baseEjectionTime: 30s, maxEjectionPercent: 50}
  subsets:
    - {name: v1, labels: {version: v1}}
    - {name: v2, labels: {version: v2}}

outlierDetection 就是熔断:某后端在 interval 窗口内连续 consecutive5xxErrors 次返回 5xx,就把它踢出负载均衡列表 baseEjectionTime 那么久,最多踢掉 maxEjectionPercent 的实例。下面这份存成 api-vs.yaml,它定义「怎么分」:先按 header 分流,其余按 90/10 权重,并带上总超时与重试。

yaml
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata: {name: api, namespace: demo}
spec:
  hosts: [api]
  http:
    - match:
        - headers:
            x-canary:
              exact: "true"
      route:
        - destination:
            host: api
            subset: v2
    - route:
        - destination:
            host: api
            subset: v1
          weight: 90
        - destination:
            host: api
            subset: v2
          weight: 10
      timeout: 3s
      retries:
        attempts: 2
        perTryTimeout: 1s
        retryOn: 5xx,reset,connect-failure

三个要点:hosts: [api] 必须与请求用的名字对得上;http 列表按顺序匹配、第一条命中即停止(与 Ingress 的「更长前缀优先」不同,别把兜底规则写在前面);subset 必须先在 DestinationRule 里定义,否则 503。验证权重:

bash
kubectl -n demo apply -f api-v1.yaml -f api-v2.yaml -f api-dr.yaml -f api-vs.yaml
kubectl -n demo run client --image=curlimages/curl:8.10.1 --restart=Never -- sleep 3600
kubectl -n demo exec client -- sh -c 'for i in $(seq 1 20); do curl -s http://api:5678; echo; done'
kubectl -n demo exec client -- curl -s -H 'x-canary: true' http://api:5678

20 次请求里约 18 次 v1、2 次 v2。权重是概率不是精确比例,样本太少偏差很大,几百次请求才看得出比例;带 x-canary: true 时应该稳定命中 v2。

Gateway:外部流量怎么进来

Gateway 描述「监听哪个端口、接受哪些域名」,VirtualService 描述「进来之后怎么路由」:

yaml
apiVersion: networking.istio.io/v1
kind: Gateway
metadata: {name: demo-gateway, namespace: demo}
spec:
  selector: {istio: ingressgateway}
  servers:
    - {port: {number: 80, name: http, protocol: HTTP}, hosts: ["kube101.local"]}

再写一份 VirtualService,关键是 hosts 换成外部域名、gateways 指向这个 Gateway

yaml
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata: {name: api-gateway, namespace: demo}
spec:
  hosts: ["kube101.local"]
  gateways: ["demo-gateway"]
  http:
    - route: [{destination: {host: api, port: {number: 5678}}}]
bash
INGRESS_HOST=$(kubectl -n istio-system get svc istio-ingressgateway -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
curl -s -H "Host: kube101.local" "http://$INGRESS_HOST/api"

selector 选中网关数据面 Pod(默认装的 istio-ingressgatewayistio: ingressgateway 标签),gateways 把这份 VirtualService 绑到 Gateway 上;如果 EXTERNAL-IP<pending>(kind 或裸金属常见),改用 NodePort 或 kubectl port-forward,见 集群网络。注意:Istio 自己的 Gateway CRD 与 Gateway API 里的同名对象不是一回事,下面单独讲。

Gateway API:网格入口的标准接口

Istio 的 GatewayVirtualService 是它自己的 CRD,用了就与 Istio 绑定。Gateway APIgateway.networking.k8s.io)是 Kubernetes 官方的下一代入口标准,Istio、ingress-nginx、kgateway 都实现了它——写法统一,换实现时路由不用重写。

text
GatewayClass(用哪个实现:istio / kgateway / nginx)


Gateway(平台团队:监听端口、TLS 证书、允许哪些命名空间接入)
      │ parentRefs

HTTPRoute(业务团队:host、路径、header 匹配、权重)
      │ backendRefs

Service → Pod(进了网格就自动带 mTLS 与代理治理)

用 Istio 当网关实现时,gatewayClassNameistioallowedRoutes 决定哪些命名空间能把自己的 HTTPRoute 挂上来:

yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata: {name: main-gateway, namespace: istio-system}
spec:
  gatewayClassName: istio
  listeners:
    - name: http
      hostname: "*.example.com"
      port: 80
      protocol: HTTP
      allowedRoutes:
        namespaces:
          from: Selector
          selector: {matchLabels: {gateway-access: main-gateway}}
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: {name: api, namespace: demo}
spec:
  parentRefs:
    - {name: main-gateway, namespace: istio-system}
  hostnames: ["api.example.com"]
  rules:
    - matches:
        - path: {type: PathPrefix, value: /api}
      filters:
        - type: URLRewrite
          urlRewrite: {path: {type: ReplacePrefixMatch, replacePrefixMatch: /}}
      timeouts: {request: 30s, backendRequest: 10s}
      backendRefs:
        - {name: api, port: 5678}
bash
kubectl label namespace demo gateway-access=main-gateway
kubectl -n demo describe httproute api     # Conditions 里 Accepted=True 才算挂上
GATEWAY_HOST=$(kubectl -n istio-system get gateway main-gateway -o jsonpath='{.status.addresses[0].value}')
curl -H "Host: api.example.com" "http://$GATEWAY_HOST/api"

allowedRoutes.namespaces.from: Selector 加命名空间标签,就是跨命名空间共享网关的授权机制:平台团队掌握监听端口与证书,业务团队只能在自己的命名空间写路由。生产上别用 All,否则任何命名空间都能抢你的域名。

Istio Ingress 与 kgateway 怎么分工

两者都能做入口,判断标准是「入口流量要不要复用网格能力」:

场景选谁理由
只要七层路由,不打算上网格ingress-nginx注解生态成熟、运维最省事,见 Ingress
入口流量要走网格治理(mTLS、熔断、指标)Istio 的 ingressgatewaygatewayClassName: istio请求进来就是网格内流量,DestinationRuleAuthorizationPolicy 直接生效
新集群,要角色分离与跨命名空间共享网关kgateway 等 Gateway API 实现原生 HTTPRoute,不依赖私有注解
AI 推理负载,需要 Inference Extensionkgateway提供面向推理的扩展

常见组合是边缘用 Gateway API 统一入口、网格只管东西向:入口网关终止外部 TLS(证书交给 cert-manager,见 安全加固),进入集群后再由 Istio 的 mTLS 接管服务间加密。两者职责不同,不是二选一。

参考:reference/k8s-in-action/network/istio/gateway-api/README.mdreference/k8s-in-action/network/kgateway/SKILL.md

mTLS:加密与身份

代理之间使用双向 TLS(mTLS):双方都要出示由 istiod 签发的工作负载证书,证书身份(SPIFFE ID,形如 spiffe://cluster.local/ns/demo/sa/default)来自 Pod 的 ServiceAccount。PeerAuthentication 控制服务端是否强制 mTLS:

yaml
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata: {name: default, namespace: demo}
spec:
  mtls:
    mode: STRICT
模式行为适用
PERMISSIVE同时接受 mTLS 与明文默认值,迁移期兼容没有代理的客户端
STRICT只接受 mTLS,明文被拒该命名空间全部注入代理后的目标状态
DISABLE不要求 mTLS明确不需要加密的端口

写在 istio-system(网格 root namespace)是全局默认,写在业务命名空间只影响本命名空间,也可以用 portLevelMtls 只针对某个端口。验证是否真的在加密:

bash
POD=$(kubectl -n demo get pod -l app=api -o jsonpath='{.items[0].metadata.name}')
istioctl x describe pod "$POD" -n demo        # 打印生效的 PeerAuthentication 与 mTLS 模式
istioctl proxy-config secret "$POD" -n demo   # 看到 default 与 ROOTCA 证书,说明已下发

早期文档里的 istioctl authn tls-check 在较新版本中已被移除(子命令是否存在取决于版本),现代做法就是上面这两条。ambient 模式下四层加密由 ztunnel 承担,用 istioctl ztunnel-config workloads 确认工作负载是否已接入。

经典故障:一边 STRICT,一边没有代理

现象:api 侧配了 mtls: STRICT,某个客户端 Pod 没注入 istio-proxy(命名空间没打标签、打了标签没重启、或有 sidecar.istio.io/inject: "false"),它发出的是明文 HTTP;请求到达 api 的代理后要求 TLS 握手,明文连接直接被拒,客户端看到 503,代理日志里出现 TLS 握手失败或 connection reset by peer

原因:mTLS 是双向的,只有双方都有代理和证书才能握手成功STRICT 不会为对方降级。

bash
kubectl -n demo get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.containers[*].name}{"\n"}{end}'
kubectl -n demo logs <目标 Pod> -c istio-proxy --tail=50

先把 mode 临时改回 PERMISSIVE 验证「改了就通」,再补齐注入(打标签 + kubectl rollout restart),确认所有 Pod 都有 istio-proxy 后切回 STRICT。灰度期一半注入一半没注入,表现是「一半请求成功、一半 503」,比全挂更难查。

可观测与代价取舍

因为每个请求都经过代理,网格不埋点就能拿到服务间的黄金指标:延迟(istio_request_duration_milliseconds)、流量(istio_requests_total)、错误(同指标上的 response_code 标签)、饱和度(连接池与代理自身资源)。Kiali 画服务拓扑并校验网格配置,Jaeger 看单条调用链定位慢在哪一段,Prometheus 采集并存储这些指标;demo profile 装好后用 istioctl dashboard kialiistioctl dashboard jaegeristioctl dashboard prometheus 打开。注意它们是代理视角的指标,不知道业务逻辑做了什么,应用自身的指标与日志仍要做,完整链路见 可观测性

维度代价缓解
资源开销sidecar 模式每个 Pod 多一个 Envoy 容器,随 Pod 数增长istio-proxy 设 requests/limits;评估 ambient
延迟每跳多一次代理转发,取决于版本、CPU 与流量模型压测后再上,别只看功能不看数字
升级istiod、数据面代理、CRD 都有兼容矩阵按官方支持矩阵小步升级,先控制面再滚动重建数据面
调试要在重定向规则、代理配置、应用日志三层之间切换istioctl analyzeproxy-configproxy-status、Kiali
安全边界注入的代理通常需要额外权限(如 NET_ADMIN安全加固

常见坑与排错

网格的排错顺序与普通网络问题不同:先确认代理在不在链路上,再看代理有没有收到配置,最后看请求为什么被拒

现象原因怎么确认怎么办
503,日志里 UF(upstream connection failure)端口不对、应用没监听,或 mTLS 不匹配kubectl -n demo logs <pod> -c istio-proxykubectl -n demo get endpointslices核对容器端口与 targetPort,再查 mTLS 模式
upstream connect error or disconnect/reset before headers. reset reason: connection termination目标应用没起来、端口写错,或端口名没写协议前缀kubectl -n demo get svc api -o yaml 看端口名、kubectl -n demo get pods 看 READY端口名改成 http / http-<name>,修好应用
Pod 里看不到 istio-proxy命名空间没打 istio-injection=enabled、Pod 有 sidecar.istio.io/inject: "false",或打标签后没重启kubectl get ns demo --show-labelskubectl -n demo get pod -o jsonpath='{.items[*].spec.containers[*].name}'打标签 + kubectl rollout restart deployment
一半请求 503、一半正常新旧 Pod 注入状态不一致上面那条 jsonpath 逐个 Pod 看容器名全部重启;或先把 PeerAuthenticationPERMISSIVE
切到 STRICT 后立刻大面积 503调用方没有代理,只能发明文istioctl x describe pod <pod> -n demo 看对端注入状态补齐注入后切回 STRICT,别长期留在 PERMISSIVE
istio-ingressgatewayEXTERNAL-IP 一直 <pending>裸金属或 kind 没有云负载均衡器kubectl -n istio-system get svc istio-ingressgateway装 MetalLB(见 集群网络)或用 NodePort / port-forward
路由不生效,请求打到默认后端hosts 与请求 Host 不匹配,或没绑定 gatewaysistioctl analyze -n demokubectl -n demo get virtualservice api -o yaml对齐 hosts,把 gateways 指向正确的 Gateway
改了 VirtualService 行为没变代理还没收到配置推送istioctl proxy-status 看对应代理是否 SYNCED等推送,或检查是否改错了命名空间

三个最容易造成事故的细节

  • 端口名不带协议前缀:七层功能静默失效,不报错,只是路由和重试不生效。写清单时就把端口名定成 httphttp-api
  • 打了注入标签没重启 Pod:标签只对之后创建的 Pod 生效。STRICT 打开后表现为「一半请求 503」。
  • 生产用 `profile=demo`:它带示例插件和较宽松配置,只适合学习环境;生产用 default 再按需装组件。

自测题

自测:为什么服务网格能跨语言做灰度,SDK 方案不行?(点击展开答案)

因为网格把治理逻辑放在应用进程之外的代理里:请求先经过代理,代理按 VirtualService 决定这次发往哪个 subset,应用收到的是普通 HTTP 请求——它不知道自己被灰度了,也不需要任何 SDK 支持。SDK 方案要求治理逻辑跑在应用进程内,各语言一套实现,参数与语义很难对齐,升级要挨个服务改代码再发布。代价是:跨语言的成本从「改应用」变成「加一跳代理」,开销转移到基础设施侧,同时你得维护控制面。

自测:为什么「一边 STRICT、一边没注入 sidecar」一定会失败?(点击展开答案)

STRICT 的语义是「这个工作负载接受 mTLS」。没有代理的客户端发的是明文 HTTP,它在 TCP 层能连上端口,但目标代理在 TLS 握手阶段就要求对方出示证书,明文连接无法完成握手,请求被拒,客户端看到 503。这不是配置错误,而是策略与事实不符:mTLS 是双向的,必须双方都有代理和证书。PERMISSIVE 存在的意义就是迁移期同时容忍这两种客户端。排查顺序:先确认相关 Pod 都有 istio-proxy,再看对端 PeerAuthentication 模式,最后看代理日志里的握手失败。

自测:什么情况下不该引入服务网格?(点击展开答案)

三条判据,满足任意一条就该先停下:服务少且技术栈单一(十几个服务、同一种语言,框架自带的超时重试就够);没有专职平台团队istiod、代理版本、CRD 升级是长期负担,故障时没人读得懂 Envoy 配置);延迟预算极紧且无法压测(每跳多一次转发,收益要先算数字)。另外两种常见误用是:只想给入口加 TLS(用网关终止 TLS 更简单),以及还没有 GitOps 就想上网格(配置会迅速失控)。反过来,当你有一批跨语言服务、需要统一 mTLS 与细粒度灰度、并且有人能维护控制面时,网格的收益才开始大于成本。

小结

  • 网格把「服务间怎么通信」——灰度、超时重试熔断、mTLS、可观测——从应用代码搬到代理层,因此与语言无关。
  • 控制面 istiod 下发配置与签发证书;数据面分 sidecar(每 Pod 一个 Envoy)与 ambient(每节点一个 ztunnel,七层按需加 waypoint)。
  • 注入靠命名空间标签 istio-injection=enabled必须重启 Pod,用 kubectl get pod -o jsonpath 确认 istio-proxy 在容器列表里。
  • VirtualService 管怎么分,DestinationRule 管有哪些版本和熔断,Gateway 管外部入口;四层入口标准仍是 Ingress
  • PeerAuthenticationSTRICT 只在所有调用方都注入代理后才安全;代价是每 Pod 一个代理的资源开销、每跳延迟与调试复杂度,先算账再上。

练习

  1. 把权重改成 50/50,用 200 次请求统计两个版本的比例,解释为什么小样本下偏差很大。
  2. VirtualServicetimeout 改成 100ms,观察请求是否开始 504,再用 istioctl proxy-config route 确认配置已下发。
  3. 打开 STRICT 后手动给某个 Pod 加 sidecar.istio.io/inject: "false" 并重启,复现 503,从 istio-proxy 日志里找到对应的握手失败记录。

网格让服务间通信变得可控,但「谁能访问谁」还需要授权策略与镜像供应链防护,见 安全加固;网格指标要接进告警体系才有意义,见 可观测性;连不上时先按 排障手册 的顺序走一遍。