Envoy Composite Cluster 原理、配置要点与避坑指南
【免费下载链接】envoyCloud-native high-performance edge/middle/service proxy项目地址: https://gitcode.com/GitHub_Trending/en/envoy
同一个请求,重试一次,上游供应商就换了。这是 Envoy Composite Cluster(envoy.clusters.composite)的核心行为:它不看子集群健康状态,而是按**重试尝试次数(retry attempt count)**决定请求落到哪个子集群。首次请求、第一次重试、第二次重试,分别命中配置列表里的第 1、2、3 个集群,全部由一个下标换算公式驱动。
为什么按健康状态选集群不够用
Aggregate Cluster(把多个子集群聚合成一个逻辑集群的扩展)回答的问题是"此刻哪个子集群更可用",它的流量分配随健康状态波动:某子集群被 outlier detection 逐走一批主机,权重随之下降。这种"按可用比例分流"在故障切换场景是优点,但在需要按次序降级的场景是缺陷——你无法保证"第一次重试一定打到备用供应商",也无法让不同尝试承担不同成本档位,因为每次选谁都由当时的健康分布决定,路径不可预测。
Composite Cluster 把决策输入从健康状态换成了尝试序号。两者差异的根源只有一条:
| Aggregate Cluster | Composite Cluster | |
|---|---|---|
| 决策输入 | 各子集群当前的健康状态(权重随健康波动) | 当前请求是第几次尝试(固定序号,不随健康变化) |
换句话说,aggregate 的决策是"概率性"的:同样的重试,今天打到 A 明天可能打到 B;composite 的决策是查表:同样的第 N 次尝试,永远映射到列表里第 N 个位置,前提是那个子集群能提供主机。对 AI Gateway 多提供商切换、"贵服务优先、廉价服务兜底"这类场景,后者才是能写进 SLO 的行为。
三分钟看懂它的运行方式
最小可用配置如下。子集群必须在此处之外的别处独立定义,Composite Cluster 本身不承载任何 endpoint、负载均衡算法或健康检查:
name: composite_cluster connect_timeout: 0.25s lb_policy: CLUSTER_PROVIDED cluster_type: name: envoy.clusters.composite typed_config: "@type": type.googleapis.com/envoy.extensions.clusters.composite.v3.ClusterConfig clusters: - name: primary_cluster - name: secondary_cluster - name: fallback_clusterclusters是唯一的配置字段,列表顺序就是尝试次序。cluster.proto 的 validate 规则要求列表非空且集群名非空串,配置加载阶段就能拦住笔误。
给定上述配置与路由上num_retries: 2的重试策略,一次请求的生命周期是:
- 尝试 1(初始请求)→
primary_cluster - 尝试 2(第一次重试)→
secondary_cluster - 尝试 3(第二次重试)→
fallback_cluster - 尝试 4 及以后 → 无可用主机,请求失败
核心换算关系只有一行:
cluster_index = attempt_count - 1用大白话说:Envoy 的路由器把请求记为"第几次发出去",从 1 开始数;集群列表是 C++ 数组,从 0 开始数。减掉 1,第几次尝试就落在第几个集群上,仅此而已。
源码中的三次决策
系统如何知道当前是第几次尝试
CompositeClusterLoadBalancer::getAttemptCount()从负载均衡上下文的requestStreamInfo()里读出attemptCount()(见 cluster.cc#L45-L58)。上下文为空或 StreamInfo 里没有尝试计数时,一律按 0 处理——这个值随后会被映射函数当作异常拒绝,而不是默默命中第一个集群。
次数如何变成集群下标,越界怎么办
映射函数对 1 基数做减法,并对两种异常直接返回nullopt:
if (attempt_count == 0) { ENVOY_LOG(warn, "invalid attempt count 0 ..."); return std::nullopt; } const size_t cluster_index = attempt_count - 1; if (cluster_index < clusters_->size()) { return cluster_index; } // Attempts exceed available clusters - fail the request. return std::nullopt;这段逻辑把"重试比集群多"变成一次显式失败:nullopt最终表现为 no host available,而不是悄悄回落到最后一个集群。边界上,attempt_count == 0只打 warn 日志不报错,而越界只打 debug 日志——两个分支的日志等级不对称,排查时以请求结果为准。
下标确定后,主机由谁最终选择
子集群只负责"选哪个集群"这一层;集群内部选哪台主机,完全委托给该子集群自己的负载均衡器(round robin、maglev 等),Composite Cluster 不介入。同一尝试内向后 failover 的循环如下:
for (size_t cluster_index = start_index; cluster_index < clusters_->size(); ++cluster_index) { auto* cluster = getClusterByIndex(cluster_index); if (cluster != nullptr) { response = cluster->loadBalancer().chooseHost(&composite_context); if (response.host != nullptr || response.cancelable != nullptr) { return response; // 选到主机或异步选择已在途,立即返回 } } if (!skip_clusters_without_hosts) { break; // 关闭 failover 时,第一个无主机的集群即终止 } }这个循环从映射出的下标出发,逐个向后问子集群"你有主机吗"。委托用的CompositeLoadBalancerContext(lb_context.h)透传原始上下文的全部方法并额外记录selected_cluster_index,方便调试时知道请求最终落在哪个位置;peekAnotherHost与selectExistingConnection走的是同一条"先映射下标、再委托"的路径。
边界行为与容易踩的坑
同一尝试内向后 failover
- 现象:attempt 1 配置的
primary_cluster没打到,实际落在secondary_cluster,且envoy.request.attempt仍是 1。 - 触发条件:映射到的子集群没有任何可用主机——DNS 解析失败导致 endpoint 列表为空,或 outlier detection 把全部主机逐出。
- 后果:该尝试在列表内继续向后找,直到有集群能给出主机;全部无主机才以
no_healthy_upstream失败。注意 failover 不移动后续尝试的映射:primary 为空时,attempt 2 依然映射到secondary,而不是fallback。
- 现象:attempt 1 配置的
运行时开关
envoy.reloadable_features.composite_cluster_skip_clusters_without_hosts- 现象:默认开启时发生上述向后 failover;设为
false后,映射到的子集群无可用主机则立即失败。 - 触发条件:显式修改该 runtime 开关。
- 后果:行为回退到旧版本语义——无 failover,直接 503。变更说明见 changelogs/current/bug_fixes/composite_cluster__skip-clusters-without-hosts.rst,修复的正是"子集群无主机时误报 503 no_healthy_upstream"。
- 现象:默认开启时发生上述向后 failover;设为
异步主机选择不被打断
- 现象:某个子集群正在做异步选择时,后面的集群一律不会被尝试。
- 触发条件:子集群
chooseHost()返回的cancelable非空(异步选择已在途)。 - 后果:循环立即返回该响应,由异步流程拥有后续全部选择;这是有意设计,避免同一请求被两个子集群各选出一台主机。
尝试次数超出集群数量
- 现象:请求直接失败,返回 503 no host available。
- 触发条件:路由
num_retries过大,使总尝试次数超过clusters列表长度。 - 后果:映射函数返回
nullopt,无 failover 可言。子集群数量应与重试策略的总尝试次数(1 + num_retries)对齐,而不是只与num_retries对齐。
怎么确认它在按预期工作
路由上打开两个开关(virtual_host级别),让每次尝试的序号可被外部观测:
include_request_attempt_count: true include_attempt_count_in_response: true开启后请求与响应头会携带尝试计数,对照envoy.upstream.cluster访问日志字段即可确认"第 N 次尝试打到了哪个集群"。仓库内的验证入口有两处:
- 单元测试 test/extensions/clusters/composite/cluster_test.cc:覆盖集群创建、attempt count 提取(含空上下文、无 StreamInfo、越界值)、下标映射与 failover,并包含关闭 runtime 开关后回退旧行为的用例。
- 集成测试 test/extensions/clusters/composite/cluster_integration_test.cc:3 个静态子集群 + 1 个 composite 集群,路由
retry_on: "5xx";其中BasicRetryProgression用例让三个 fake upstream 依次返回 503、503、200,断言cluster.cluster_N.upstream_rq_total各恰好为 1,并可用addClusterWithoutEndpoints构造"endpoint 列表为空"的场景端到端验证同一尝试内 failover。
上线前自查 3 条
- 集群的
lb_policy取值为CLUSTER_PROVIDED——composite 自己不做负载均衡,该取值表示选择完全委托子集群。 - 重试总尝试次数(1 +
num_retries)与clusters列表长度相等:多一次则越界失败,少一次则尾部集群永远命中不到。 - 接受 failover 不移动后续映射的事实:primary 长期为空时,attempt 1 和 attempt 2 会连续命中 secondary,重试预算没有花在降级路径上,需据此规划重试规模。
一句话界定边界:它适合"重试路径必须可预测"的降级与切换(多 AI 提供商、成本分级路由),不适合"按健康比例分摊流量"的负载聚合——后者请继续用 Aggregate Cluster。
【免费下载链接】envoyCloud-native high-performance edge/middle/service proxy项目地址: https://gitcode.com/GitHub_Trending/en/envoy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考