8.2. Service Mesh:Istio、Linkerd 与 Cilium Mesh
服务网格解决的不是「请求怎么送到」,而是「送到之后怎么加密、怎么重试、怎么切流、怎么证明它按预期工作」。它把这些事从每个服务的代码里挪到一组可统一配置的代理上,代价是多一层数据面、多一套控制面,以及一套必须自己运维的证书体系。
- 层级:
L4 · 技术栈
- 难度:
进阶
- 预计:
70 分钟
- 前置:
Service 与 DNS、Ingress、NetworkPolicy
你将学到
服务网格的五项核心能力,以及它们分别落在 L4 还是 L7
sidecar、ambient、eBPF 三种数据面形态的取舍
Istio、Linkerd、Cilium、Kuma、Consul 的定位差异与选型依据
mTLS 的证书链路,
PeerAuthentication与AuthorizationPolicy如何配合灰度发布、故障注入,以及与 Ingress / Gateway API 的边界
8.2.1. 为什么需要服务网格
微服务拆开之后,每个服务都得自己实现超时、重试、熔断、指标、链路追踪和传输加密。麻烦不在于写不出来,而在于每个团队、每种语言各写一遍:Go 的 SDK 和 Java 的 SDK 语义不一致,安全团队无法证明「集群内所有流量都已加密」,一次重试策略调整要几十个服务重新发版。
服务网格的做法是把这些能力下沉:控制面统一下发配置和证书,数据面在 Pod 内或节点上拦截流量,应用仍然收到普通 TCP/HTTP 请求。应用不需要改代码,但换来的是「多一跳、多一套要运维的组件」。
能力 |
应用自己做 |
网格做 |
代价与边界 |
|---|---|---|---|
mTLS |
引库、管证书、轮换 |
控制面签发短期证书,代理间 TLS |
只保护代理到代理,进程内仍是明文 |
重试 / 超时 |
SDK 配置,各语言不同 |
|
重试会放大故障,非幂等接口必须关 |
流量分割 |
发布系统改权重 |
按 subset 权重、Header、Cookie 切流 |
依赖版本标签,L4 连接会「粘」在旧后端 |
可观测性 |
埋点、上报 |
代理自动产出指标、访问日志、trace |
指标基数与采样成本要提前规划 |
授权 |
应用内 RBAC |
按工作负载身份(SPIFFE)放行 |
不等于多租户隔离,也不替代应用鉴权 |
并不是所有集群都需要网格。服务数量少、语言栈统一、已有成熟 SDK 时,网格的收益可能抵不过它的运维成本;反过来,当「证明东西向流量全部加密」变成合规要求,或者需要按版本精细切流时,自己实现往往更贵。
8.2.2. 核心概念
8.2.2.1. 控制面与数据面
控制面只做三件事:把声明式配置翻译成数据面能懂的协议(Istio 用 xDS)、给每个工作负载签发身份证书、把配置推给正确的代理。数据面才是真正处理包的那一层。
图:业务流量不经过控制面,控制面只发证书与配置;控制面挂掉时既有连接继续工作,但新配置不再生效。
Istio 的 istiod 合并了 Pilot(xDS)、Citadel(证书)、Galley(配置校验)的职责;Linkerd 拆成 destination(服务发现与路由)、identity(证书)、proxy-injector(注入)三个 Deployment;Cilium 则由 operator 加每节点 agent 组成,控制面更贴近内核。
8.2.2.2. Sidecar、ambient 与无 sidecar
Sidecar 是每 Pod 注入一个代理,L4/L7 全在 Pod 内完成。隔离清晰、L7 能力完整、可以按 Pod 配资源;代价是每个 Pod 多 50–100Mi 内存和启动延迟,注入后必须重建 Pod,Job 还会因为代理不退出而永远不结束。
Ambient(Istio) 把 L4 收到节点级的 ztunnel,需要 L7 时才给命名空间或服务挂 waypoint 代理。它不注入 Pod、不重启工作负载,资源随节点数而不是 Pod 数增长;代价是 L7 能力要显式部署 waypoint,排障路径也更长。
Cilium 用 eBPF 在 socket 层做 L3/L4 策略与负载均衡,需要 L7 时把流量重定向到节点级 Envoy,根本没有 per-Pod 代理。
形态 |
每 Pod 开销 |
L7 能力 |
适用 |
|---|---|---|---|
Sidecar |
有,随 Pod 数线性增长 |
完整(路由、重试、AuthZ、Wasm) |
需要细粒度 L7 策略 |
Ambient |
无,L4 在节点级 |
需 waypoint,覆盖度略小 |
大集群、只想先拿 mTLS |
eBPF(Cilium) |
无 |
L7 策略与 Gateway API,功能少于 Istio |
已用 Cilium,不想加代理 |
8.2.3. mTLS 与授权
Istio 的身份是 SPIFFE 格式的 spiffe://cluster.local/ns/<ns>/sa/<sa>,证书由 istiod 签发、默认 24 小时轮换,sidecar 通过 SDS 取用。默认的 PERMISSIVE 模式同时接受明文与 mTLS,方便迁移;要真正强制加密,必须切到 STRICT。
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: default
namespace: demo
spec:
mtls:
mode: STRICT
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: allow-frontend
namespace: demo
spec:
selector:
matchLabels: {app: demoapp}
action: ALLOW
rules:
- from:
- source:
principals: ["cluster.local/ns/demo/sa/frontend"]
to:
- operation: {methods: ["GET"], paths: ["/api/*"]}
两个对象职责不同:PeerAuthentication 只决定「连接是否必须用 mTLS」,AuthorizationPolicy 决定「这个身份能调哪些方法、哪些路径」。一旦命名空间里出现 ALLOW 策略,未被任何规则匹配的请求就会被拒绝;DENY 的优先级高于 ALLOW,排查「为什么 403」时先看有没有遗留的 DENY。
警告
mTLS 不等于授权
开了 STRICT 只说明链路加密、身份可信,任何持有合法证书的工作负载仍然能调用你的服务。真正的访问控制要落在 AuthorizationPolicy 上,并且用「默认拒绝 + 明确放行」的写法,而不是先放行再补规则。
8.2.3.1. 流量分割与故障注入
灰度靠两层对象:DestinationRule 用标签把后端分成 subset,VirtualService 决定权重和匹配条件。同一个 Service 下两个版本的 Pod 必须带可区分的标签,否则 subset 选不中。
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata: {name: demoapp, namespace: demo}
spec:
host: demoapp
subsets:
- {name: v1, labels: {version: v1}}
- {name: v2, labels: {version: v2}}
---
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata: {name: demoapp, namespace: demo}
spec:
hosts: [demoapp]
http:
- match:
- headers: {x-canary: {exact: "true"}}
route:
- destination: {host: demoapp, subset: v2}
- route:
- destination: {host: demoapp, subset: v1}
weight: 90
- destination: {host: demoapp, subset: v2}
weight: 10
retries: {attempts: 2, perTryTimeout: 2s, retryOn: "5xx,reset"}
timeout: 10s
Header 匹配优先于权重,所以测试账号可以稳定命中新版本,其余流量仍按 90/10 分流。retries 要谨慎:对一个已经过载的后端重试三次,等于把压力放大三倍;非幂等的 POST 一般应显式排除。
故障注入是同一套路由规则里的 fault 字段,用来在上线前验证超时、降级和告警是否真的生效:
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata: {name: demoapp-fault, namespace: demo}
spec:
hosts: [demoapp]
http:
- fault:
abort: {percentage: {value: 50}, httpStatus: 503}
route:
- destination: {host: demoapp, subset: v2}
8.2.3.2. 与 Ingress、Gateway API 的边界
南北向归 Ingress / Gateway,东西向归网格。入口需要公网 IP、TLS 终止、WAF、全局限流,这些不属于网格的职责。
Gateway API 正在统一两者:
Gateway+HTTPRoute既能描述入口,也能描述网格内路由。Istio 支持把HTTPRoute的parentRefs指向网格 Service,Cilium 也原生实现了 Gateway API。不要在网格里重造入口:把 ingress-nginx 换成 mesh gateway 之前,先确认 WAF、证书、限流这些能力有对等替代。
注意加密边界:如果入口网关不在网格内,从网关到后端那一段是明文;把网关纳入网格,或者在网关上终止 TLS 后重新以 mTLS 连后端。
端口命名决定协议识别:Service 端口必须叫
http、http2、grpc之类,网格才能按 L7 处理,否则只做 L4 转发。
8.2.4. 选型:五个项目怎么放
项目 |
数据面 |
强项 |
弱项 |
适合 |
|---|---|---|---|---|
Istio |
Envoy sidecar / ambient |
功能最全:L7 路由、AuthZ、多集群、Wasm、Gateway API |
复杂度最高,升级与排障成本大 |
中大型团队,需要细粒度策略 |
Linkerd |
Rust 编写的 linkerd2-proxy |
轻量、默认 mTLS、延迟低、运维简单 |
L7 策略与路由能力较少,无 ambient |
想快速拿到 mTLS 与指标 |
Cilium |
eBPF + 节点级 Envoy |
与 CNI 一体、无 sidecar、性能好 |
依赖内核与 CNI,L7 弱于 Istio |
已经用 Cilium,不想再加代理 |
Kuma |
Envoy |
单控制面管多网格、支持 VM、多租户 |
社区与生态较小 |
混合云、跨集群统一策略 |
Consul |
Envoy |
服务发现跨 VM 与 K8s,intentions 模型清晰 |
需引入 Consul 全家桶,2023 年起改为 BUSL 许可 |
已有 Consul 的异构环境 |
选型先问三个问题:集群里是否已经跑着 Cilium?团队有没有人愿意维护控制面?需要的是 L7 授权,还是只要 mTLS 加指标?Linkerd 与 Istio 的差距主要在 L7 策略和生态,不在基础加密——只要 mTLS 和指标,Linkerd 的运维成本明显更低。
8.2.5. 动手实验
实验:Istio 最小闭环(安装 → 注入 → mTLS → 灰度 → 故障注入)
目标:装一个 demo 版 Istio,给命名空间开启注入,用 STRICT 验证 mTLS 真的生效,再做 90/10 灰度与 50% 故障注入。
前置:Kubernetes 1.27+、istioctl 与集群版本匹配、curl 镜像可拉取。
8.2.5.1. 步骤 1 · 安装控制面
istioctl install --set profile=demo -y
kubectl -n istio-system get pod
NAME READY STATUS RESTARTS AGE
istiod-7d9f8c6b5d-4xk2p 1/1 Running 0 2m
istio-ingressgateway-... 1/1 Running 0 2m
demo profile 会一并装 ingress/egress gateway,适合实验;生产用 default 或 minimal,并通过 revision 做控制面灰度升级。升级前先跑 istioctl x precheck,它会检查 CRD 版本与集群兼容性。
8.2.5.2. 步骤 2 · 部署两个版本并注入 sidecar
apiVersion: apps/v1
kind: Deployment
metadata: {name: demoapp-v1, namespace: demo, labels: {app: demoapp, version: v1}}
spec:
replicas: 2
selector: {matchLabels: {app: demoapp, version: v1}}
template:
metadata: {labels: {app: demoapp, version: v1}}
spec:
containers:
- {name: app, image: "ikubernetes/demoapp:v1.0", ports: [{containerPort: 80}]}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: demoapp-v2, namespace: demo, labels: {app: demoapp, version: v2}}
spec:
replicas: 1
selector: {matchLabels: {app: demoapp, version: v2}}
template:
metadata: {labels: {app: demoapp, version: v2}}
spec:
containers:
- {name: app, image: "ikubernetes/demoapp:v1.1", ports: [{containerPort: 80}]}
---
apiVersion: v1
kind: Service
metadata: {name: demoapp, namespace: demo}
spec:
selector: {app: demoapp}
ports: [{name: http, port: 80, targetPort: 80}]
kubectl create namespace demo
kubectl label namespace demo istio-injection=enabled
kubectl apply -f demoapp.yaml
kubectl -n demo get pod -L version
NAME READY STATUS AGE VERSION
demoapp-v1-6c9d7f8b5-2xj9p 2/2 Running 1m v1
demoapp-v1-6c9d7f8b5-9k4tq 2/2 Running 1m v1
demoapp-v2-5b8f6d9c4-h7m2s 2/2 Running 1m v2
2/2 说明 istio-proxy 注入成功。如果只有 1/1,通常是命名空间标签在 Pod 创建之后才加,kubectl rollout restart 即可。
8.2.5.3. 步骤 3 · 验证 mTLS 真的在工作
先应用前面那份 peer-authn-strict.yaml,再准备两个客户端:default 命名空间里的 plain(不注入),demo 命名空间里的 meshed(注入)。
kubectl run plain --image=curlimages/curl:8.10.1 --restart=Never --command -- sleep 3600
kubectl -n demo run meshed --image=curlimages/curl:8.10.1 --restart=Never --command -- sleep 3600
kubectl wait --for=condition=Ready pod/plain pod/meshed -n default --timeout=60s
kubectl exec plain -- curl -sS -m 3 http://demoapp.demo/
kubectl -n demo exec meshed -- curl -sS -m 3 http://demoapp.demo/ | head -3
curl: (56) Recv failure: Connection reset by peer
<!DOCTYPE html>
<html>...
第一条失败、第二条成功,就是 STRICT 生效的最直接证据:明文连接在握手阶段被代理拒绝。再补一条证书视角的检查:
istioctl proxy-config secret deploy/demoapp-v1 -n demo
istioctl proxy-status
proxy-config secret 里 default 证书的 VALID 应为 true,proxy-status 里所有代理应为 SYNCED。
8.2.5.4. 步骤 4 · 90/10 灰度与故障注入
kubectl apply -f vs-canary.yaml
for i in $(seq 1 20); do
kubectl -n demo exec meshed -- curl -s http://demoapp/ | grep -o 'v1\.[01]'
done | sort | uniq -c
理想结果是 18 次 v1.0、2 次 v1.1。偏差较大时先看 istioctl proxy-config route deploy/meshed -n demo,确认权重真的下发到了客户端代理——流量分割是在调用方的 sidecar 上执行的,改服务端的规则不会影响别人怎么调它。
把 vs-fault.yaml 应用后,重复请求应看到约一半 503;验证完记得删掉:
kubectl apply -f vs-fault.yaml && sleep 2
for i in $(seq 1 10); do kubectl -n demo exec meshed -- curl -s -o /dev/null -w '%{http_code}\n' http://demoapp/; done | sort | uniq -c
kubectl delete -f vs-fault.yaml
5 200
5 503
验证清单
kubectl -n demo get pod全部2/2,istioctl proxy-status全部SYNCEDSTRICT 后非注入命名空间访问失败,注入命名空间访问成功
istioctl proxy-config secret中证书VALID,且AuthorizationPolicy拒绝未授权身份20 次请求里约 2 次命中
v1.1故障注入期间约一半请求返回 503,删除后恢复
8.2.6. 常见问题与排错
现象 |
可能原因 |
处理方式 |
|---|---|---|
Pod 只有 |
命名空间未打注入标签,或 label 在 Pod 创建后才加 |
检查 |
注入后所有请求 503 |
端口未命名或 |
端口名改成 |
STRICT 后探针失败、Pod 反复重启 |
探针路径被授权策略拦截,或 sidecar 未就绪 |
用 |
Job / CronJob 永远不结束 |
sidecar 进程不随主容器退出 |
该 Job 关闭注入,或启用 native sidecar |
灰度权重不生效 |
改的是服务端规则,客户端没收到;或 L4 连接复用 |
看调用方 |
|
集群里已有旧版 Istio 或残留 CRD |
|
访问集群外服务失败 |
|
创建 |
8.2.7. 进阶挑战
用 Gateway API 的
HTTPRoute替掉VirtualService做同样的 90/10 灰度,对比两者在 Header 匹配和权重表达上的差异。给 demoapp 加
RequestAuthentication,要求 JWT 中groups含ops才允许DELETE,并验证无 token 时返回 401 而不是 403。对整个命名空间做 STRICT 迁移演练:用
istioctl analyze找出会受影响的工作负载,记录哪些必须排除(例如不兼容的 Job 或第三方组件)。
8.2.8. 小结
网格把 mTLS、重试、切流、指标、授权从应用代码搬到数据面,收益是统一,成本是层级与运维。
sidecar 能力最全,ambient 与 eBPF 用节点级代理换掉每 Pod 开销,但要接受 L7 覆盖度的折损。
mTLS 由
PeerAuthentication强制,授权由AuthorizationPolicy表达,两者不能互相替代。灰度在调用方 sidecar 上执行,L4 连接复用会让权重失真;排障顺序是状态 → 配置 → 证书 → 路由。
南北向交给 Ingress / Gateway,东西向交给网格,Gateway API 是两者正在收敛的共同语言。
延伸阅读