服务网格流量路由与韧性:把重试、超时和摘除放回一条时间轴
一次版本灰度看起来非常顺利:控制面显示 orders-v2 权重为 10%,两组 Pod 都 Ready,十次手工请求也大致符合预期。流量高峰到来后,orders-v2 的实际调用量却接近 40%。调用方 SDK、出口 sidecar 和入口 sidecar 各自重试一次,同一个用户请求最多扩成八次上游尝试;较慢的新版本更容易触发重试,于是“10% 路由权重”不再等于“10% 业务执行量”。
另一个现场中,工程师把故障称为“熔断未生效”。事实却是连接池已满,新请求在 outbound proxy 本地被拒绝,任何 orders 实例都没有收到它;与此同时,异常实例摘除也在工作,但健康实例承接了全部流量后继续过载。连接容量拒绝与 endpoint 错误摘除是两种不同机制。若不把 client deadline、代理排队、建连、每次 attempt、应用处理和响应逐段计时,增加重试只会让证据更乱、故障更大。
先画出一次请求真正经过的预算链
把请求想成下面这条时间轴,而不是一个模糊的 timeout:
client deadline
-> outbound proxy 等待连接/队列
-> route 选择 subset
-> load balancer 选择 endpoint
-> upstream attempt 1:连接 + 发送 + 等待响应
-> backoff
-> upstream attempt 2
-> inbound proxy
-> application
-> response连接超时限制的是与 endpoint 建立连接的时间;请求超时可能限制单次后端 attempt,也可能限制一整个请求;stream idle timeout 只在一段时间没有字节流动时触发;max stream duration 限制流总寿命。总 deadline 必须覆盖排队、所有 attempts、backoff 和响应传输。若客户端给 2 秒、mesh 每次 attempt 1 秒并允许 3 次,数学上就已经无法完成 3 次完整尝试;最终由哪一层先取消,决定客户端看到 deadline exceeded、503、reset 还是代理本地超时。
路由和负载均衡也不是一回事。路由先根据 host、path、header 或权重选择 orders-v1/orders-v2 这样的逻辑 destination/subset;负载均衡再从所选 subset 的健康 endpoint 集合中挑一个实例。权重控制长期请求选择概率,不保证任意 10 个请求恰好 9:1,更不保证业务写入量、CPU 或延迟按同样比例分配。长连接、多路复用、会话保持、重试换 endpoint 和 endpoint 数量都会改变观察结果。
建立 mesh-lab 与两个可辨认版本
用 Istio 作为可执行参考。先从 受支持发行线 选择并锁定 CLI、控制面和数据面 patch,再按 istioctl 安装指引 获取同版本工具。执行前保存 Kubernetes 版本、安装 profile、镜像 digest 和 Gateway API CRD 版本;下面的 demo profile 只用于隔离实验。
istioctl version --remote=false
istioctl x precheck
istioctl install --set profile=demo -y
kubectl create namespace mesh-lab
kubectl label namespace mesh-lab istio-injection=enabled在 mesh-lab 创建 client、orders-v1、orders-v2 三个 Deployment。两个后端都打 app: orders-v1,分别打 version: v1、version: v2;Service orders-v1 只按 app: orders-v1 选择,因此两个版本都进入 endpoint 集合。测试镜像必须固定 digest,并提供以下可观察行为:响应正文包含版本;/delay 接受毫秒级延迟;/fail 返回 503;POST /orders 用请求头中的幂等键去重。不要把这些调试端点暴露到公开环境。
kubectl -n mesh-lab rollout status deployment/client
kubectl -n mesh-lab rollout status deployment/orders-v1
kubectl -n mesh-lab rollout status deployment/orders-v2
kubectl -n mesh-lab get endpointslice -l kubernetes.io/service-name=orders-v1
istioctl proxy-status
kubectl -n mesh-lab exec deployment/client -c client -- sh -c 'for i in $(seq 1 20); do curl -fsS http://orders-v1/; done'无路由策略时,预期两个版本都可能被命中。必须保存每个版本的应用计数、目标代理访问日志和 endpoint 列表;只看客户端输出会漏掉重试和失败 attempt。若只有一个版本,先检查 Service selector、readiness、端口协议和 EndpointSlice,不要用权重配置掩盖发现故障。
用 subset 把版本选择与实例选择分开
DestinationRule 的 subset 根据 Pod label 定义逻辑版本;VirtualService 的 route 才决定进入哪个 subset。两者 host 必须指向同一服务语义,subset 名必须存在。先定义版本:
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: orders-v1
namespace: mesh-lab
spec:
host: orders-v1
subsets:
- name: v1
labels:
version: v1
- name: v2
labels:
version: v2
trafficPolicy:
loadBalancer:
simple: LEAST_REQUESTLEAST_REQUEST 倾向选择当前活动请求较少的 endpoint,适合处理时长差异明显的无状态请求,但它不是全局精确队列;每个代理看到自己的局部负载。ROUND_ROBIN 更易解释,却可能把新请求继续交给慢实例。RANDOM 成本低但短窗口波动更大。一致性哈希可提高相同 key 的粘性,却会在 endpoint 变化时重映射,并可能形成热点。LB 选择必须结合协议、多路复用、状态位置和故障恢复测试。
再发布 90/10 路由:
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: orders-v1
namespace: mesh-lab
spec:
hosts: ["orders-v1"]
http:
- route:
- destination:
host: orders-v1
subset: v1
weight: 90
- destination:
host: orders-v1
subset: v2
weight: 10
retries:
attempts: 0Istio 当前存在可由 mesh config 定制的 cluster-wide 默认 HTTP retry policy;没有在 VirtualService 写 retries 不等于没有重试。分布实验显式设置 attempts: 0,先消除失败重试对版本计数的干扰。检查 metadata.generation 后,还要检查 client proxy 实际 route 与 cluster;VirtualService/DestinationRule 没有可当作通用生效证据的 status.observedGeneration:
CLIENT_POD=$(kubectl -n mesh-lab get pod -l app=client -o jsonpath='{.items[0].metadata.name}')
kubectl -n mesh-lab get virtualservice orders-v1 -o jsonpath='{.metadata.generation}{"\n"}'
istioctl proxy-config routes "$CLIENT_POD" -n mesh-lab
istioctl proxy-config clusters "$CLIENT_POD" -n mesh-lab | grep 'orders-v1'最后从同一个 client 发送至少 1000 次短请求。演示判据可设为 v2 命中率落在 6% 到 14%,原始请求数、代理 upstream attempts 与两版本应用处理数三者相等;这是用于发现完全未命中、权重反转和意外 attempt 的宽容区间,不是统计学通用承诺。生产判据应按样本量、自然波动和 SLO 计算置信区间。
生产灰度不应从随机 10% 起步。先用只有受控测试身份携带的 header 定向到 v2,证明 v2 endpoint、身份授权、依赖和遥测都完整;再把 v2 置为 0% 的备用 subset,确认普通流量仍为 100% v1;随后按 1%、5%、10% 等批次增加权重。每个批次保存 route revision、目标 proxy 的 route/cluster 摘要、原始请求身份、upstream attempt 身份、两版本应用执行数和业务结果。若只按请求概率加权,同一用户可能在连续请求中跨版本;需要会话稳定时,应使用经过风险评估的一致性键,并验证键缺失、热点键和 endpoint 变化时的重映射,而不是假设权重天然等于租户或用户分组。
灰度的反向实验至少包含三类。删除 v2 的 endpoint label,预期定向请求稳定失败且普通 v1 流量不受影响;伪造外部可控的灰度 header,预期入口层将其删除或重写,不能让任意客户端自行加入新版本;让 v2 只在特定下游依赖上失败,预期停止条件依据 v2 的业务错误和 attempt 放大触发,而不是被总体成功率稀释。回滚先把 v2 权重归零并撤销定向规则,确认所有 client proxy 收敛,再排空已有长连接和异步任务,最后删除 subset 与工作负载。
反例是把 orders-v2 的 label 改错。API 对象仍可通过 schema 与 admission,client proxy 中的 route 也存在,但 subset 没有 endpoint,命中 v2 时出现 no healthy upstream/503。修复前不要把权重调回 v1 并宣布完成;应保存 EndpointSlice、代理 cluster endpoint、response flag 与 v2 应用零请求四项证据,证明失败发生在 endpoint 选择而不是应用。
超时必须同时约束单次尝试和总请求
Istio route 的 timeout 是请求级预算,retries.perTryTimeout 约束每次额外尝试。官方 请求超时任务 也提醒应用层重试会改变观测总时长。给实验路由设置 800ms 总预算、最多 2 次重试、每次 300ms:
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: orders-v1
namespace: mesh-lab
spec:
hosts: ["orders-v1"]
http:
- timeout: 800ms
retries:
attempts: 2
perTryTimeout: 300ms
retryOn: 5xx,reset,connect-failure
route:
- destination:
host: orders-v1
subset: v1
weight: 90
- destination:
host: orders-v1
subset: v2
weight: 10按当前 Istio HTTPRetry 参考,attempts: 2 明确表示最多 2 次重试,因此最多发出 1 次初始调用加 2 次重试;perTryTimeout 同时约束初始调用和每次重试。实际重试次数仍可能因 800ms 总 timeout、backoff 或客户端更短 deadline 而减少。实验发送 /delay,让后端每次处理 500ms,并让客户端也设置独立总 deadline:
kubectl -n mesh-lab exec deployment/client -c client -- curl --max-time 0.8 -sS -o /dev/null -w 'code=%{http_code} total=%{time_total}\n' http://orders-v1/delay?ms=500预期代理在约 300ms 取消单次 attempt,可能启动下一次,但客户端总耗时不应显著越过 800ms 加网络与调度容差。保存每次 attempt 的开始、取消、选择 endpoint、upstream service time、最终状态和客户端墙钟时间;不能仅凭 x-envoy-attempt-count 证明后端实际执行次数。
量化判据可以是 100 次请求的 P99 小于 950ms、每个请求上游 attempt 不超过配置上限、应用中的活跃请求在停止流量后 5 秒内回到基线。950ms 和 5 秒是实验值,生产值必须由客户端 deadline、网络基线和应用取消能力决定。若客户端在 800ms 返回,但应用活跃请求继续增长,说明取消没有传播或应用忽略取消;代理“按时失败”并没有保护后端容量。
连接超时另测:在隔离 namespace 使用专用故障代理或仅作用于测试 Pod 的网络故障阻断 SYN,确认 connect timeout 触发的是本地连接失败,而不是 HTTP request timeout。不要手改控制器维护的 EndpointSlice,也不要对共享节点下全局防火墙规则。streaming 接口则不能用短 request timeout 生搬硬套;应分别验证 headers、idle 与最大 stream duration,并确认心跳是否重置 idle 计时器。
重试预算来自 SLO 与容量,不来自“多试几次”
假设正常峰值为每秒 1000 个原始请求,每个请求最多 2 次额外尝试,理论最坏上游调用可达每秒 3000 次。若应用 SDK 也做 2 次额外尝试,最坏不是 5 次,而是 3 × 3 = 9 次;前方网关再重试一次则变成 18 次。实际还会受 deadline 和连接池限制,但容量设计不能依赖“通常来不及重试”。
重试预算应同时有比例和绝对上限。例如只允许额外 attempts 不超过原始请求的 10%,并把额外 attempts 限制为每秒不超过 100;两者任一先到即停止重试扩张,同时还要限制每个 client proxy 的并发 retry。指标至少区分原始请求数、upstream attempts、retry success、retry overflow、最终失败和被取消请求。持续 5 分钟的 attempts / original_requests 超过 1.1,或任一分钟额外 attempts 的速率超过绝对上限,就停止灰度。100/s 是对应前文 1000 RPS 演示基线的值,生产窗口与绝对值必须由容量余量决定。
只有满足幂等条件的操作才自动重试。GET 通常可重放,但也要警惕隐藏副作用;POST /orders 若在服务端提交后响应丢失,代理重试可能创建两笔订单。正反实验使用同一个幂等键:先让第一次写入成功但丢弃响应,再触发一次 retry,服务端写入计数必须仍为 1,客户端最终获得同一结果。去掉幂等键后,预期写入计数变成 2;这个反例应使自动 POST retry 的发布门禁失败,而不是被包装成“最终成功”。
应用、SDK、网关和 mesh 中只能有一个主要重试 owner,其他层保留更短的连接恢复或明确禁用。owner 要知道完整总 deadline 和业务幂等语义。若无法统一,至少给每层分配 attempt 上限和时间片,使最坏乘积仍在容量预算内,并把 x-envoy-attempt-count 一类内部诊断头限制在可信网络,不能让外部输入伪造治理证据。
连接池保护容量,异常摘除保护 endpoint 集合
在 DestinationRule 增加演示值:
trafficPolicy:
connectionPool:
tcp:
maxConnections: 20
connectTimeout: 200ms
http:
http1MaxPendingRequests: 20
http2MaxRequests: 50
maxRequestsPerConnection: 100
outlierDetection:
consecutive5xxErrors: 3
interval: 2s
baseEjectionTime: 10s
maxEjectionPercent: 50maxConnections 限制上游连接,http1MaxPendingRequests 限制等待连接的 HTTP/1 请求,http2MaxRequests 约束并发 HTTP/2 请求,maxRequestsPerConnection 促使连接轮换。它们过小会在代理本地快速拒绝,过大则把排队和内存压力推给应用。配置语义和默认值会随数据面版本变化,必须对照目标发行线的 Istio 流量管理概念 与 Envoy 配置转储确认。
容量正向实验把并发从 10 逐级升到 80,每级持续 60 秒,记录 active connection、pending、overflow、代理本地 503、应用实际并发和 P99。判据不是零失败,而是达到边界后拒绝有界、应用并发不再增长、撤掉压力后 30 秒内 pending 回到基线且无单调增长。若超限请求仍进入应用,检查策略是否作用于 client proxy、协议是否走预期 cluster;若停止后 pending 不降,检查取消和连接泄漏。
异常摘除按 endpoint 的被动失败计数工作。让 orders-v2 连续返回 503,其他 endpoint 正常。达到 3 次后,预期相应 endpoint 被暂时移出该 client proxy 的 LB 集合,剩余 endpoint 承载更多请求;首次摘除至少持续 baseEjectionTime,重复摘除会按被摘除次数延长。它不是全局共享的 CLOSED/OPEN/HALF_OPEN 开关,不同 client proxy 可能在不同时间观察和摘除。保存 endpoint-level counter、ejection 次数、实际隔离时长、剩余实例负载和恢复探测。
反例是所有 endpoint 都失败。maxEjectionPercent: 50 会限制可摘除比例,因而可能保留部分坏 endpoint;minHealthPercent 低于阈值时还可能禁用 outlier detection 并重新对全部 endpoint 负载均衡,其默认值为 0,不能把它想象成自动保底。若要区分代理本地的建连失败与上游真正返回的 5xx,应显式评估 splitExternalLocalOriginErrors 和 consecutiveLocalOriginFailures,否则两类故障会进入同一计数叙事。全体故障实验必须记录最终错误、是否仍选择已判异常 endpoint、是否无健康 upstream,不能写成“熔断后不再调用”。摘除比例过高会让短暂共同故障变成全服务不可达,过低则保留持续错误实例。官方 DestinationRule 参考 和 circuit breaking 实验 同时涉及连接池与 outlier detection,评审时要把两组证据分开。
故障注入用于验证假设,不是韧性本身
Istio route fault 可以注入延迟或 abort,但当前不能在同一个 client-side VirtualService 上把 fault 与 timeout/retry 组合成有效联测:启用 fault 时,该处的 timeout/retry 不会生效。第一阶段只用 route fault 验证应用降级、命中范围和观测链路;第二阶段恢复无 fault 的 route,在后端测试应用或独立上游故障代理制造 503/延迟,让 outbound client proxy 的 timeout/retry 真正执行。只有在故障点与重试点分离时,才能验证 1 秒上游延迟被 800ms 总 timeout 截断,或真实 503 是否触发 retryOn: 5xx。低层 EnvoyFilter 也能注入上游代理故障,但它版本脆弱,只适合锁定数据面版本的隔离实验,不能当通用发布模板。
故障注入对象和业务 route 同属生产数据面配置,错误 selector/header 会扩大影响。发布前的 server-side dry-run 只能验证 API schema 与 admission,不能证明命中范围或运行语义;发布后必须立即检查代理 route 并验证命中率。演示停止条件可设为注入比例偏离目标超过 2 个百分点、非目标请求出现任意 fault、attempts/original 超过 1.1 或 P99 越过 950ms。还要观察下游连接、线程池、数据库会话与队列深度,因为客户端超时后仍执行的孤儿请求会把“快速失败”变成后台容量泄漏。回滚时恢复上一个 route revision,而不是只删除 fault 字段后假设其他字段没变;数组或完整对象替换可能同时改变权重、超时和重试。
控制面断连也是必要实验。已有 proxy 通常继续使用 last-known route,真实流量不一定立刻失败;新权重、endpoint 和策略却无法传播。断连期间记录每个 proxy 的 xDS/config version、旧路由是否继续、endpoint 变化是否可见、证书续期边界;恢复后确认所有目标 proxy 的 route/cluster 配置与当前 metadata.generation 对应的声明收敛。控制面 Ready 恢复不能替代这组请求证据。
项目接入应形成一份故障预算合同
把配置与测试放在服务仓库中:
mesh-lab/
workloads/
client.yaml
orders-v1.yaml
orders-v2.yaml
traffic/
orders-v1-destination-rule.yaml
orders-v1-virtual-service.yaml
tests/
routing-distribution.sh
deadline-budget.sh
retry-idempotency.sh
capacity-ejection.sh合同至少记录:客户端总 deadline;mesh 总 timeout、per-try timeout、attempt 上限与 retry conditions;应用是否感知取消;哪些 method 有幂等键;连接/并发/pending 上限;outlier 阈值和最大摘除比例;每项的 owner 与回滚 revision。CI 校验字段和引用,隔离环境跑正反实验。对 VirtualService/DestinationRule,发布系统保存 metadata.generation、proxy route/cluster 版本与结果摘要;只有资源 API 确实提供 status condition 时才保存 condition/observed generation,不能伪造一个统一状态字段。
部署顺序应先创建 subset,再发布指向它的 route;先用 0% 或 header 定向证明 orders-v2 可达,再逐步提高权重。回滚先把权重归零,等待所有 proxy 收敛并排空连接,再删除 subset/workload。直接删除 orders-v2 而 route 仍有 10% 权重,会制造稳定的无 endpoint 错误窗口。
按证据层排障
权重正确,业务执行量偏高。 同时比较原始请求、upstream attempts、每版本应用执行数和幂等去重数。若 attempts 放大,定位 SDK、网关、outbound/inbound proxy 各层 retry;若请求数正常但成本偏高,检查版本延迟、请求大小和后台任务,不要只调权重。
客户端超时,应用仍在运行。 对齐 client deadline、proxy timeout、attempt cancel 和应用上下文取消日志。常见原因是应用忽略断连、协议无法传播取消或后台工作脱离请求生命周期。缩短 timeout 只能更快返回错误,不能自动释放后端资源。
503 但应用没有日志。 查看 proxy response flag、overflow、no healthy upstream 和 connect failure。连接池拒绝、空 subset、endpoint 摘除与网络连接失败都可能在应用前返回 503,修复动作完全不同。
摘除后延迟更高。 机制可能正常:健康 endpoint 承接了坏实例流量后过载。比较 ejection 数、剩余 endpoint CPU/并发和 overflow;降低重试、扩容或回退版本通常比继续提高摘除比例更有效。
配置只对部分调用方生效。 timeout/retry 通常在 outbound client proxy 执行,未纳管的 client 不会使用它。检查来源 proxy、route config 和请求实际 cluster;headless Service、Gateway API producer/consumer route 和不同数据面形态还有产品支持边界。
访问日志、Trace、配置转储和 request header 可能包含内部拓扑、身份、路由条件与业务数据。排障样本只使用 example.test 等虚构域名,删除 Authorization/Cookie 和请求体,限制原始转储访问;不要把真实 Token、证书或内网 endpoint 放进 Git 和公开工单。
在架构选型中比较执行点与失败语义
Istio 用 VirtualService/Gateway API route 和 DestinationRule 组合路由、LB、连接池与 outlier detection,能力丰富但对象交互和 sidecar/ambient 执行点需要治理。Linkerd 的 timeout/retry 在 outbound proxy 执行,Gateway API HTTPRoute 的 request 与 backendRequest 分别表达总请求和每次后端尝试;retry 是 opt-in,endpoint failure accrual 是客户端局部状态,可查 Linkerd retries 和 circuit breaking。
Cilium 的 east-west L7 可通过 GAMMA Service attachment 进入 per-node Envoy,但 producer/consumer route、timeout/retry GEP 和 stable conformance 必须绑定目标 Cilium/Gateway API 版本;低层 CiliumEnvoyConfig 的校验、冲突和状态反馈有限,不能作为可移植抽象。Consul 把 service-router、service-resolver、service-splitter 分别用于匹配、subset/failover 和权重,service-defaults.upstreamConfig 承载连接限制与被动健康检查。Kuma/Kong Mesh 分别提供 MeshHTTPRoute、MeshTimeout、MeshRetry、MeshCircuitBreaker,但新旧 policy 与 targetRef 在 sidecar/gateway 的支持矩阵要按版本核对。
选型问题最终落在几个可测差异:路由附着在生产者还是消费者;计时器在哪一侧启动和取消;是否同时表达总 deadline 与 per-attempt;重试能否限定条件和预算;LB/ejection 是每 proxy 局部还是集中;配置状态能否关联 observed generation;失败时是本地拒绝、reset 还是上游错误。产品有字段不等于团队已经建立了故障预算合同。
容量、成本与长期治理
sidecar 会把连接、内存和 CPU 分散到工作负载;节点代理/per-node Envoy 降低每 Pod 组件数,却扩大节点级共享故障域;waypoint 或集中代理便于执行 L7 策略,也会成为聚合容量点。容量模型同时覆盖原始 QPS、最坏 retry 乘积、长连接、HTTP/2 stream、endpoint 变化、配置推送和遥测写入。平均 CPU 不能解释证书轮换、批量发布和故障恢复时的 P99。
用阶梯压测分别测无策略、版本路由、重试开启、单 endpoint 故障和控制面断连。每阶段至少观察 proxy/app CPU 与内存、active/pending connection、upstream attempts、overflow/ejection、P95/P99 和日志/Trace 量。停止流量后,连接与 pending 应回到稳定基线,used memory 不应随轮次单调增长。生产阈值来自 SLO 和基线,不复制实验中的 20 个连接或 950ms。
成本还包括过量重试带来的应用、数据库和下游调用,细粒度访问日志与 Trace 的索引,独立 waypoint/网关副本,以及配置对象和升级兼容的人力。日志采样不能抹掉 error、retry、overflow 和 ejection 事件;正常成功流量可按风险采样。每条韧性配置必须有服务 owner,每个全局默认有平台 owner,每个幂等语义和补偿策略有业务 owner。
长期评审应删除无流量 subset、过期 header route 和从未命中的 retry condition,复核客户端 SDK 是否新增重试,检查 Gateway API/CRD 与数据面发行线兼容,定期演练错误 endpoint、全部异常和控制面断连。任何一层改变默认 timeout 或 retry 都算架构变更,因为它会改变最坏调用乘积和容量预算。
回滚与清理顺序
实验结束先把 route 恢复到基线 revision,确认 100% 流量进入 orders-v1 且所有 client proxy 收敛;再删除 orders-v2、DestinationRule/VirtualService 和故障注入对象;最后移除工作负载与控制面:
kubectl -n mesh-lab delete virtualservice --all
kubectl -n mesh-lab delete destinationrule --all
kubectl -n mesh-lab delete deployment --all
kubectl -n mesh-lab delete service --all
kubectl delete namespace mesh-lab
istioctl uninstall --purge -y
kubectl get mutatingwebhookconfigurations
kubectl get validatingwebhookconfigurations共享集群禁止直接执行 --purge。先按 revision、release 和资源标签确认控制面所有权,检查是否还有其他 namespace、webhook、CRD 或数据面依赖。完成判据包括:无测试 route/subset/fault、无测试 endpoint 与代理、namespace 正常终止、节点网络规则无残留、指标/日志/Trace 测试数据按保留策略核销。若只删除控制面,旧 sidecar、last-known route 和连接仍可能继续影响业务;安全退出必须和安装一样逐段验证。
