Kubernetes 拓扑感知调度与就近访问

topologySpreadConstraintstrafficDistribution 都在解决 Kubernetes 集群里的拓扑感知问题,但它们所在的层次不同:

  • topologySpreadConstraints 作用在调度阶段,决定 Pod 应该如何分散到不同的可用区(zone)、节点(node)或自定义拓扑域。
  • trafficDistribution 作用在 Service 转发阶段,决定客户端访问 Service 时是否优先选择同可用区或同节点的 Endpoint。

前者解决“后端是否按拓扑域铺开”,后者解决“流量是否优先走近端后端”。如果只配置就近访问,而没有保证各个拓扑域内都有足够 Endpoint,最终很容易把局部流量压到少量 Pod 上。

本文基于 Kubernetes v1.36 的文档和上游源码,先从使用方式讲起,再进入实现细节。

阅读说明

为了避免把不同版本和不同转发实现混在一起,本文先约定几个边界:

  • Kubernetes 版本以 v1.36.0 为准,源码片段也对应这个版本。其他小版本的字段状态、feature gate 和源码路径可能存在差异。
  • trafficDistribution 依赖 EndpointSlice hints,以及 kube-proxy 或兼容实现对这些 hints 的支持。若集群使用 eBPF dataplane、第三方 Service 实现或云厂商托管代理,需要以对应实现和实测结果为准。
  • 本文主要讨论 Kubernetes 原生的 Service 转发路径;Service Mesh 场景会单独说明,因为 mesh 数据面可能绕过 kube-proxy 自行选择 Endpoint。
  • 下文中的 API 字段和值保留英文,例如 topologySpreadConstraintsPreferSameZone;概念性表述优先使用中文,例如“可用区”“节点”“同可用区”“同节点”。第一次出现时会保留常见英文缩写,便于和官方文档对应。

1. 背景:可用区和跨区流量#

1.1 可用区是什么#

在云厂商的基础设施里,Region 通常表示一个地理区域,Availability Zone 表示 Region 内相互隔离的一组机房资源。不同可用区之间一般具备独立的供电、网络和故障边界,但仍然可以通过区域内网络互通。

Kubernetes 并不直接理解云厂商的可用区模型,它看到的是节点上的标签(label)。常见的拓扑标签包括:

  • topology.kubernetes.io/region:节点所在 Region。
  • topology.kubernetes.io/zone:节点所在可用区。
  • kubernetes.io/hostname:节点名称,通常用于表示节点级拓扑域。

这些标签可能由云控制器、集群安装器或运维系统写入。对 Kubernetes 来说,拓扑域就是一组拥有相同 topologyKey=value 的节点。例如 topology.kubernetes.io/zone=cn-hangzhou-a 就是一个可用区级拓扑域。

Kubernetes 可用区拓扑域示意图
可用区拓扑域

1.2 跨区流量会带来哪些问题#

假设一个业务部署在三个可用区中,客户端 Pod 在 zone-a,Service 后端 Pod 却主要集中在 zone-bzone-c。此时请求可以正常访问,但会引入几个隐性问题:

Kubernetes 跨可用区 Service 流量示意图
跨区流量问题
  1. 延迟。跨可用区网络通常比同可用区网络多一跳,单次调用可能只多几毫秒,但在微服务链路里会被多次放大。对网关、缓存、数据库代理、日志采集这类高频调用路径来说,差异会更明显。

  2. 流量成本。很多云厂商会对跨可用区流量收费,尤其是大流量服务、日志链路、对象存储代理、消息队列代理等场景,跨区流量可能成为持续性成本。

  3. 故障影响面。可用区的意义在于隔离故障,如果业务流量大量跨区,一旦某个可用区的网络链路抖动,受影响的就不只是这个可用区内的客户端,还包括依赖它作为后端的其他可用区。

  4. 负载不均。如果某个可用区内没有足够的后端,配置了就近访问也只能回退到其他可用区;如果某个可用区内后端太少,本地流量又可能集中到少量 Pod 上。

很多业务上线初期更关注功能可用性,跨可用区成本和故障边界通常不会马上暴露。随着流量增长,延迟、费用和故障影响面会逐渐变成需要治理的问题。此时可以通过拓扑分布约束和就近访问降低跨区调用比例;但能否完全消除跨区流量,还取决于副本分布、流量分布和故障回退策略。

1.3 Kubernetes 中的拓扑信息来自哪里#

Kubernetes 的拓扑感知能力主要依赖节点标签(Node label)和 EndpointSlice。

调度阶段,kube-scheduler 会读取节点标签,并根据 Pod 的 topologySpreadConstraints 计算某个 Pod 放到某个节点后,各个拓扑域之间是否仍然满足均衡约束。

转发阶段,EndpointSlice 控制器会把 Pod 所在节点、可用区等信息写入 EndpointSlice。Service 配置了 trafficDistribution 后,控制器还会在 EndpointSlice 的 Endpoint 上写入拓扑提示(hints),kube-proxy 再根据本节点的拓扑信息选择更合适的 Endpoint 集合。

可以把链路理解成:

Kubernetes 拓扑信息在调度器、EndpointSlice 控制器和 kube-proxy 之间的流转
拓扑信息流转

2. Kubernetes 中的解决方案#

Kubernetes 在这个问题上提供了两个互补能力:

  • topologySpreadConstraints:控制 Pod 调度结果,让副本尽量分散到不同拓扑域。
  • trafficDistribution:控制 Service 流量偏好,让客户端优先访问同可用区或同节点 Endpoint。

前者是工作负载层面的放置策略,后者是 Service 层面的转发策略。一个常见组合是:Deployment 使用 topologySpreadConstraints 保证每个可用区都有近似数量的 Pod,Service 使用 trafficDistribution: PreferSameZone 让请求优先在本可用区内闭环。

topologySpreadConstraints 和 trafficDistribution 的职责分工
调度与转发分工

2.1 topologySpreadConstraints#

topologySpreadConstraints 定义在 PodSpec 中,Deployment、StatefulSet、Job 等工作负载通常写在 spec.template.spec.topologySpreadConstraints 下。它的目标不是指定“必须调度到哪个节点”,而是告诉调度器:同一组 Pod 在不同拓扑域里的数量差应该控制在什么范围内。

一个常见配置如下:

spec:
  template:
    metadata:
      labels:
        app: checkout
    spec:
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: checkout
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: checkout
      containers:
        - name: app
          image: nginx:1.27

这段配置表达两层约束:在可用区级别尽量做到强均衡,不满足就不调度;在节点级别只作为倾向,资源紧张时允许调度器继续调度。

字段不需要死记硬背,可以先抓住下面几个关键点。完整说明建议以 官方文档kubectl explain pod.spec.topologySpreadConstraints 为准。

字段 作用 常见用法 注意事项
topologyKey 选择用哪个节点标签划分拓扑域 topology.kubernetes.io/zone 表示按可用区分散,kubernetes.io/hostname 表示按节点分散 节点缺少该标签时,调度结果可能和预期不一致
labelSelector 定义参与统计的是哪一组 Pod 通常和工作负载的 selector 保持一致,例如 app: checkout 如果不能匹配 Pod 模板自身,后续计数会失真
maxSkew 允许不同拓扑域之间最多相差多少个匹配 Pod 高可用服务常用 1,表示尽量平均分布 值越小,越容易因为容量不足导致新 Pod 无法调度
whenUnsatisfiable 约束无法满足时如何处理 可用区级别常用 DoNotSchedule,节点级别可用 ScheduleAnyway 前者是硬约束,后者只影响打分排序
minDomains 期望至少有多少个可用拓扑域 三可用区服务可设置 3,避免只在少数可用区内继续堆副本 通常和 DoNotSchedule 一起使用,需要确认可用区容量
matchLabelKeys 用 Pod 自身标签扩展 selector 滚动发布时可用 pod-template-hash 让新旧 ReplicaSet 分开统计 适合发布场景,使用前要确认标签是否稳定存在
nodeAffinityPolicy / nodeTaintsPolicy 计算 skew 时是否考虑 affinity 和 taint 过滤后的节点集合 使用专用节点池时通常让统计范围和实际可调度范围保持一致 设置不当会让统计范围大于或小于真实可调度范围

调度示例#

假设集群有三个可用区,每个可用区两个节点。现在创建一个 checkout Deployment,副本数为 6,并配置 topologyKey: topology.kubernetes.io/zonemaxSkew: 1whenUnsatisfiable: DoNotSchedule

topologySpreadConstraints 按可用区依次调度 Pod 的示意图
调度顺序

调度器会在每次调度前统计已有 app=checkout Pod 在各可用区的数量。在三个可用区都具备可调度节点、且 maxSkew: 1 能被满足的情况下,6 个副本通常会形成 2/2/2 的分布。如果同时配置节点级别的 ScheduleAnyway,调度器会在满足可用区硬约束的前提下尽量分散到不同节点,但不会因为节点维度不均衡而阻塞调度。

使用注意#

使用时有几个注意点:

  • 节点必须有完整、稳定的拓扑标签;缺少 topologyKey 的节点不会被正常纳入这个约束。
  • 多个 topologySpreadConstraints 是逻辑 AND 关系,Pod 必须同时满足所有约束。
  • DoNotSchedule + maxSkew: 1 + minDomains 很适合核心多可用区服务,但也更容易让 Pod 进入 Pending 状态。
  • labelSelector 要能匹配 Pod 模板自身的标签,否则这个 Pod 会遵守约束,却不一定参与后续计数。
  • 它只影响新 Pod 的调度结果,不会主动驱逐或重排已经运行的 Pod。

2.2 trafficDistribution#

trafficDistribution 定义在 ServiceSpec 中,用来表达 Service 转发偏好。它不负责创建 Endpoint,也不会改变 Pod 的调度位置,只是在 kube-proxy 选择 Endpoint 时优先使用“更近”的后端。

基础配置很简单:

apiVersion: v1
kind: Service
metadata:
  name: checkout
spec:
  selector:
    app: checkout
  trafficDistribution: PreferSameZone
  ports:
    - port: 80
      targetPort: 80

如果客户端 Pod 位于 zone-a,并且 zone-a 内有就绪 Endpoint,流量会优先转发到 zone-a;如果没有,再回退到集群范围。完整语义可以参考官方的 Traffic distribution control

当前常见取值如下:

取值 作用 适用场景
PreferSameZone 优先选择同可用区 Endpoint 多可用区在线服务,减少跨可用区延迟和费用
PreferSameNode 优先选择同节点 Endpoint,没有时回退到同可用区,再回退到全集群 DaemonSet、节点级代理、日志采集、缓存代理等节点本地场景
PreferClose PreferSameZone 的旧别名,语义不够直观 新配置不建议继续使用

转发示例#

下面用两个客户端和三个 Service 对比不同配置下的转发路径。C1 运行在 zone-a/node-a1C2 运行在 zone-b/node-b1S1 不设置 trafficDistribution,请求会在所有就绪 Endpoint 中按代理实现的负载均衡策略分发;S2 设置 PreferSameNodeC1 有同节点 Endpoint,C2 没有;S3 设置 PreferSameZoneC1 有同可用区 Endpoint,C2 所在可用区没有。

C1 和 C2 访问不同 trafficDistribution Service 时的 Endpoint 选择路径
trafficDistribution 在未设置、PreferSameNode 和 PreferSameZone 下的转发路径

图里的实线表示优先候选集,虚线表示回退候选集,或未设置时的全局候选集。trafficDistribution 解决的是“请求优先发给谁”,不是“后端应该部署在哪里”。如果某个可用区或节点没有可用 Endpoint,kube-proxy 会根据对应配置回退到更大的候选集;要减少跨区回退,仍然需要前面的 topologySpreadConstraints 先把 Endpoint 铺开。

使用注意#

它和 internalTrafficPolicy / externalTrafficPolicy 的核心区别是强度不同:trafficDistribution 是偏好,找不到同拓扑 Endpoint 时可以回退;internalTrafficPolicy: LocalexternalTrafficPolicy: Local 是更强的本地性要求,没有本节点 Endpoint 时不会继续转发对应流量。

Kubernetes 官方文档在 Interaction with Traffic Policies 中说明:当 externalTrafficPolicyinternalTrafficPolicy 设置为 Local 时,它会优先于 trafficDistribution,作用于对应的外部或内部流量;当这些字段为 Cluster 或未设置时,trafficDistribution 才指导对应流量类型的路由。

两类 Local 策略的差异可以简单对比为:

策略 流量来源 流量分发规则 典型用途
internalTrafficPolicy: Local 集群内部 Pod 只转发到本节点 Pod DaemonSet、本地代理、低延迟
externalTrafficPolicy: Local 外部客户端 只转发到本节点 Pod,保留源 IP NodePort/LoadBalancer 保留源 IP、边缘节点流量

使用时要注意:

  • trafficDistribution 依赖 EndpointSlice hints 和 kube-proxy 支持,集群版本与实现要匹配。
  • 它会把同可用区或同节点流量尽量导向本地 Endpoint,但不保证负载一定均匀。
  • 如果某个可用区内 Endpoint 太少,本地流量可能集中到少量 Pod,反而制造热点。
  • 不要把 service.kubernetes.io/topology-mode: Auto 和它混用;如果 annotation 设置为 Auto,官方文档说明 annotation 会优先生效。
  • 对无 selector 的 Service,需要确认外部维护的 EndpointSlice 是否带有可用于 hints 的拓扑信息。

因此更稳妥的实践是把两者组合起来:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: checkout
spec:
  replicas: 6
  selector:
    matchLabels:
      app: checkout
  template:
    metadata:
      labels:
        app: checkout
    spec:
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: checkout
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: checkout
      containers:
        - name: app
          image: nginx:1.27
---
apiVersion: v1
kind: Service
metadata:
  name: checkout
spec:
  selector:
    app: checkout
  trafficDistribution: PreferSameZone
  ports:
    - port: 80
      targetPort: 80

这段配置的重点不是 YAML 本身,而是分工:Deployment 保证各可用区都有后端,Service 保证流量优先走本可用区。

2.3 落地验证与常见误区#

配置生效后,建议从节点标签、Pod 分布和 EndpointSlice hints 三个层面验证,而不是只看 YAML 是否提交成功:

kubectl get nodes -L topology.kubernetes.io/zone
kubectl get pod -l app=checkout -o wide
kubectl get endpointslice -l kubernetes.io/service-name=checkout -o yaml

重点确认几件事:

  • 节点是否都带有稳定的 topology.kubernetes.io/zonekubernetes.io/hostname 标签。
  • Pod 是否真的分布到了预期的可用区和节点上。
  • EndpointSlice 中的 Endpoint 是否按配置带有 zonenodeNamehints
  • 业务指标中的跨可用区调用比例是否下降,而不只是配置字段看起来正确。

常见误区也值得提前避开:

误区 更准确的理解
配了 trafficDistribution 就不会跨可用区 它是偏好而不是强制;本可用区没有就绪 Endpoint 时会回退
配了 topologySpreadConstraints 就会自动重排旧 Pod 它只影响新 Pod 的调度,不会主动驱逐或迁移已运行 Pod
maxSkew: 1 总是更好 约束越严格,对可用容量和节点标签质量的要求越高,失败时更容易 Pending
同节点优先适合所有服务 PreferSameNode 更适合节点本地模型,普通 Deployment 可能因此形成热点
Service Mesh 内流量一定受 kube-proxy 控制 mesh 数据面可能自行选择 Endpoint,需要看实际流量路径

3. 原理剖析#

下面的源码路径和代码片段都基于 Kubernetes v1.36.0,对应源码提交 ecf6decece6a6de25a57aad9ba90b6ce580f6f78。如果看 master 或其他小版本,路径和 feature gate 状态可能会有差异。为了突出主流程,下面的代码片段会省略部分错误处理、日志和非关键参数。

3.1 topologySpreadConstraints 的实现#

调度框架中的 PodTopologySpread 插件#

topologySpreadConstraints 由 kube-scheduler 的 PodTopologySpread 插件实现。核心代码在 pkg/scheduler/framework/plugins/podtopologyspread/ 下,主要参与 PreFilterFilterPreScoreScoreNormalizeScore 这几个扩展点。

kube-scheduler 调度流程中 topologySpreadConstraints 在 PreFilter、Filter 和 Score 阶段的作用环节
topologySpreadConstraints 在 kube-scheduler 调度流程中的作用环节

调度一个新 Pod 时,可以重点看两个阶段分界:硬约束会在 Filter 阶段直接淘汰节点,软约束则只在 Score 阶段影响节点排序。

硬约束判断的关键公式可以简化为:

skew = 当前拓扑域已有匹配 Pod 数 + 新 Pod 是否匹配 selector - 全局最小匹配数

if skew > maxSkew:
    该节点不可调度

这里的“全局最小匹配数”不是简单地取所有可用区的最小值,而是会结合 minDomains、节点亲和性(node affinity)、taints、节点是否具备对应拓扑标签等条件。源码里的 preFilterState 会维护 TpValueToMatchNumCriticalPaths,前者记录每个拓扑值的 Pod 计数,后者记录最小计数路径,避免每次 Filter 都重新扫描整个集群。

// Kubernetes v1.36.0: pkg/scheduler/framework/plugins/podtopologyspread/filtering.go
// 下面中文注释为辅助理解,不是源码中的原始注释。
type preFilterState struct {
    // 当前 Pod 上解析出来的硬拓扑分布约束。
    // CriticalPaths 和 TpValueToMatchNum 都按 constraint 下标索引。
    Constraints []topologySpreadConstraint

    // CriticalPaths[i] 只记录第 i 个 constraint 的两条关键路径,
    // 不保存 TpValueToMatchNum[i] 的完整排序结果。
    //
    // CriticalPaths[i][0].MatchNum 一定是该 constraint 下的最小匹配数;
    // CriticalPaths[i][1].MatchNum 只保证 >= [0],不保证是第二小匹配数。
    CriticalPaths []*criticalPaths

    // TpValueToMatchNum[i] 表示第 i 个 constraint 下:
    // topology value -> 已有匹配 Pod 数。
    //
    // 例如 constraints[0].TopologyKey == "topology.kubernetes.io/zone":
    //   TpValueToMatchNum[0]["zone-a"] == 2
    //   TpValueToMatchNum[0]["zone-b"] == 1
    //   TpValueToMatchNum[0]["zone-c"] == 1
    //
    // 如果 constraints[1].TopologyKey == "kubernetes.io/hostname":
    //   TpValueToMatchNum[1]["worker-1"] == 1
    //   TpValueToMatchNum[1]["worker-3"] == 2
    //   TpValueToMatchNum[1]["worker-4"] == 0
    //
    // 这里统计的是能被 constraint.Selector 匹配到的已有 Pod 数,
    // 不是节点数量。map key 来自本轮约束计算中合格节点上的 topology label value。
    TpValueToMatchNum []map[string]int
}

软约束的实现思路则不同。ScheduleAnyway 不会淘汰节点,而是在 Score 阶段计算每个节点所在拓扑域的匹配 Pod 数,再通过 NormalizeScore 反转分数:Pod 越少的拓扑域,节点分数越高。这样它不会阻塞调度,但会影响最终选点。

PreFilter:统计拓扑域内已有 Pod#

PreFilter 阶段会解析 Pod 上的 constraints,并基于 labelSelector 找出要统计的 Pod 集合。随后调度器遍历已有 Pod 和节点,把匹配 Pod 按拓扑键值对(topology pair)聚合,例如:

topology.kubernetes.io/zone=zone-a -> 2
topology.kubernetes.io/zone=zone-b -> 1
topology.kubernetes.io/zone=zone-c -> 1

这一步的关键是把后续 Filter 需要的全局信息提前算好,避免每个候选节点都重复扫描全部 Pod。

Filter:根据 maxSkew 判断节点是否可调度#

Filter 阶段会对每个候选节点执行硬约束判断。它会检查节点是否具备 constraint 要求的 topologyKey,再计算新 Pod 放上去后的 skew。如果任何一个硬约束不满足,该节点就会被过滤。

// Kubernetes v1.36.0: pkg/scheduler/framework/plugins/podtopologyspread/filtering.go
// 下面中文注释为辅助理解,不是源码中的原始注释。
//
// tpVal 来自候选节点的 node.Labels[c.TopologyKey]。
// 如果 c.TopologyKey == "topology.kubernetes.io/zone",且候选节点在 zone-a,
// matchNum 就是 TpValueToMatchNum[i]["zone-a"]。
// 如果 c.TopologyKey == "kubernetes.io/hostname",且候选节点是 worker-4,
// matchNum 就是 TpValueToMatchNum[i]["worker-4"]。
matchNum := s.TpValueToMatchNum[i][tpVal]

// selfMatchNum 表示新 Pod 自己是否也会被该 constraint 的 selector 匹配到。
// 同一个 Deployment 创建出来的 Pod,通常这里是 1。
skew := matchNum + selfMatchNum - minMatchNum
if skew > int(c.MaxSkew) {
    return fwk.NewStatus(fwk.Unschedulable, ErrReasonConstraintsNotMatch)
}

多个 constraint 之间是逻辑 AND 关系,所以可用区和节点两层约束都配置为 DoNotSchedule 时,候选节点必须同时满足两个维度的均衡要求。

Score:为更均衡的节点加分#

ScheduleAnyway 对应的约束主要在打分阶段发挥作用。调度器会给更有助于降低 skew 的节点更高分,再和资源、亲和性等其他插件的分数一起归一化。最终选中的节点不一定是拓扑上最优的节点,而是所有调度插件综合打分后的结果。

// Kubernetes v1.36.0: pkg/scheduler/framework/plugins/podtopologyspread/scoring.go
// 下面中文注释为辅助理解,不是源码中的原始注释。
if tpVal, ok := node.Labels[c.TopologyKey]; ok {
    var cnt int64

    if c.TopologyKey == v1.LabelHostname {
        // 如果按 hostname 打散,拓扑域就是当前节点本身,
        // 所以直接统计当前节点上已有多少个 selector 匹配 Pod。
        cnt = int64(countPodsMatchSelector(nodeInfo.GetPods(), c.Selector, pod.Namespace))
    } else {
        // 如果按 zone、region 等更大的拓扑域打散,
        // PreScore 已经把匹配 Pod 数按 topology value 聚合到 TopologyValueToPodCounts。
        //
        // 例如 c.TopologyKey == "topology.kubernetes.io/zone",
        // 当前候选节点的 tpVal == "zone-a",
        // s.TopologyValueToPodCounts[i]["zone-a"] == 3,
        // 那么 cnt 就是 3。
        cnt = *s.TopologyValueToPodCounts[i][tpVal]
    }

    // scoreForCount 会把 cnt、maxSkew 和拓扑域权重合成一个原始分。
    // 这里 cnt 越大,原始分越高;后面的 NormalizeScore 会再把分数反转。
    score += scoreForCount(cnt, c.MaxSkew, s.TopologyNormalizingWeight[i])
}

// NormalizeScore 阶段会反转原始分:
// 原始分越低,说明该拓扑域里的匹配 Pod 越少,最终得分越高。
// 这也是 ScheduleAnyway 表达“倾向于更均衡”而不是“直接过滤节点”的位置。
s := scores[i].Score
scores[i].Score = fwk.MaxNodeScore * (maxScore + minScore - s) / maxScore

这也是为什么软约束适合表达倾向,而不适合承载强可用性目标。

3.2 trafficDistribution 的实现#

trafficDistribution 不是在请求到来时才临时计算,而是在 Service 或 EndpointSlice 变化后先写入 hints,再由每个节点上的 kube-proxy 同步成本地转发规则。

trafficDistribution 从 Service 到 EndpointSlice hints,再到 kube-proxy 生成转发规则并选择就近 Endpoint 的生效链路
trafficDistribution 生效时机与 kube-proxy 转发路径

EndpointSlice 控制器如何生成 hints#

EndpointSlice 控制器会在 reconcile EndpointSlice 时处理 hints。源码里的 staging/src/k8s.io/endpointslice/trafficdist/trafficdist.go 定义了 ReconcileHints。在 trafficDistribution 生效路径下,控制器会根据字段生成或清理 hints;如果设置了 PreferSameZonePreferClose,就给就绪 Endpoint 写入 ForZones;如果设置了 PreferSameNode,还会写入 ForNodes

引用说明:在 Kubernetes v1.36.0 中,PreferSameZonePreferSameNode 的 Service API 常量定义在 staging/src/k8s.io/api/core/v1/types.go。对应 feature gate 是 PreferSameTrafficDistribution,其生命周期状态表显示它从 1.35 开始是 GA、默认开启,并设置了 LockToDefault: true(见 pkg/features/kube_features.go)。

EndpointSlice 控制器初始化 reconciler 时会把 feature gate 状态传进去:

// Kubernetes v1.36.0: pkg/controller/endpointslice/endpointslice_controller.go
endpointslicerec.WithPreferSameTrafficDistributionEnabled(
    utilfeature.DefaultFeatureGate.Enabled(features.PreferSameTrafficDistribution),
)

reconciler 先判断当前 Service 的 trafficDistribution 是否可用;如果可用并且未使用旧的 topology hints annotation,就调用 trafficdist.ReconcileHints

// Kubernetes v1.36.0: staging/src/k8s.io/endpointslice/reconciler.go
canUseTrafficDistribution := r.validTrafficDistribution(service.Spec.TrafficDistribution) &&
    !hintsEnabled(service.Annotations)

if canUseTrafficDistribution {
    trafficdist.ReconcileHints(service.Spec.TrafficDistribution, slicesToCreate, slicesToUpdate, unchangedSlices(...))
}

ReconcileHints 本身只负责选择 heuristic,真正写 hints 时再看 Endpoint 是否有 zonenodeName

// Kubernetes v1.36.0: staging/src/k8s.io/endpointslice/trafficdist/trafficdist.go
if trafficDistribution != nil && closeTrafficDistribution.Has(*trafficDistribution) {
    h = &preferCloseHeuristic{*trafficDistribution == corev1.ServiceTrafficDistributionPreferSameNode}
}
// Kubernetes v1.36.0: staging/src/k8s.io/endpointslice/trafficdist/trafficdist.go
if endpoint.Zone != nil {
    forZones = []discoveryv1.ForZone{{Name: *endpoint.Zone}}
}
if endpoint.NodeName != nil && h.generateNodeHints {
    forNodes = []discoveryv1.ForNode{{Name: *endpoint.NodeName}}
}

EndpointSlice hints 的数据结构#

一个 EndpointSlice 中的 Endpoint 可能长这样:

endpoints:
  - addresses:
      - 10.244.1.23
    nodeName: node-a
    zone: cn-hangzhou-a
    hints:
      forZones:
        - name: cn-hangzhou-a
      forNodes:
        - name: node-a

这里的 zonenodeName 描述 Endpoint 自身位于哪里,hints 描述这个 Endpoint 更适合被哪些可用区或节点的客户端使用。

kube-proxy 如何按节点 / 可用区选择 Endpoint#

kube-proxy 侧会 watch EndpointSlice 和本节点标签。pkg/proxy/topology.go 中的 CategorizeEndpoints 会先根据 hints 判断当前 Service 应该使用哪种拓扑模式:

  1. 如果同节点 hints 可用,并且本节点命中至少一个就绪 Endpoint,就使用 PreferSameNode
  2. 否则,如果所有就绪 Endpoint 都有可用区 hints,并且本节点所在可用区命中至少一个 Endpoint,就使用 PreferSameZone
  3. 否则退回默认模式,使用集群内所有就绪 Endpoint。

真正过滤 Endpoint 时,逻辑很直接:PreferSameNode 要求 Endpoint 的 node hint 命中当前节点;PreferSameZone 要求 zone hint 命中当前节点所在可用区。回退发生在“选择拓扑模式”阶段,而不是对每个 Endpoint 单独兜底。

// Kubernetes v1.36.0: pkg/proxy/topology.go
topologyMode = topologyModeFromHints(svcInfo, endpoints, nodeName, zone)
if !availableForTopology(ep, topologyMode, nodeName, zone) {
    return false
}

topologyModeFromHints 的优先级是先看同节点 hints,再看同可用区 hints,最后退回默认模式:

// Kubernetes v1.36.0: pkg/proxy/topology.go
// 下面中文注释为辅助理解,日志分支有省略。
func topologyModeFromHints(svcInfo ServicePort, endpoints []Endpoint, nodeName, zone string) string {
    hasReadyEndpoints := false
    hasEndpointForNode := false
    allEndpointsHaveNodeHints := true
    hasEndpointForZone := false
    allEndpointsHaveZoneHints := true

    for _, endpoint := range endpoints {
        if !endpoint.IsReady() {
            continue
        }
        hasReadyEndpoints = true

        // 只有所有 ready Endpoint 都带 node hints,
        // 并且至少有一个 Endpoint 命中当前 nodeName,
        // 后面才可能选择 PreferSameNode。
        if endpoint.NodeHints().Len() == 0 {
            allEndpointsHaveNodeHints = false
        } else if endpoint.NodeHints().Has(nodeName) {
            hasEndpointForNode = true
        }

        // 同理,只有所有 ready Endpoint 都带 zone hints,
        // 并且至少有一个 Endpoint 命中当前节点所在 zone,
        // 后面才可能选择 PreferSameZone。
        if endpoint.ZoneHints().Len() == 0 {
            allEndpointsHaveZoneHints = false
        } else if endpoint.ZoneHints().Has(zone) {
            hasEndpointForZone = true
        }
    }

    if !hasReadyEndpoints {
        return ""
    }

    if utilfeature.DefaultFeatureGate.Enabled(features.PreferSameTrafficDistribution) {
        if allEndpointsHaveNodeHints && hasEndpointForNode {
            return v1.ServiceTrafficDistributionPreferSameNode
        }
    }
    if allEndpointsHaveZoneHints && hasEndpointForZone {
        return v1.ServiceTrafficDistributionPreferSameZone
    }

    // 空字符串表示不启用拓扑过滤,回到默认的集群范围转发。
    return ""
}

过滤函数也体现了“mode 选择阶段回退,执行阶段不再回退”的特点:

// Kubernetes v1.36.0: pkg/proxy/topology.go
// 下面中文注释为辅助理解。
func availableForTopology(endpoint Endpoint, topologyMode, nodeName, zone string) bool {
    switch topologyMode {
    case "":
        // topologyModeFromHints 已经决定不使用拓扑过滤,
        // 所以所有 ready Endpoint 都可以进入候选集。
        return true
    case v1.ServiceTrafficDistributionPreferSameNode:
        // mode 已经确定为 PreferSameNode 后,这里只检查 node hint。
        // 如果当前 Endpoint 不命中当前节点,不会在这里回退到 zone。
        return endpoint.NodeHints().Has(nodeName)
    case v1.ServiceTrafficDistributionPreferSameZone:
        // mode 已经确定为 PreferSameZone 后,这里只检查 zone hint。
        return endpoint.ZoneHints().Has(zone)
    default:
        return false
    }
}

回退行为:什么时候回退到全集群转发#

trafficDistribution 的回退行为是它和 Local traffic policy 的核心区别。

PreferSameZone 为例,如果当前节点所在可用区存在命中的就绪 Endpoint,kube-proxy 就只使用这些 Endpoint;如果没有命中,才回退到全集群就绪 Endpoint。

PreferSameNode 为例,优先级是:

同节点 Endpoint
    -> 同可用区 Endpoint
        -> 集群范围 Endpoint

这说明它是可用性优先的就近访问,而不是严格本地访问。

4. Service Mesh 场景下的边界#

如果集群里已经接入 Service Mesh,就近访问还要再看一层:这条请求最终是由 kube-proxy 选择 Endpoint,还是由 mesh 数据面选择 Endpoint。

原生 trafficDistribution 作用于 Kubernetes Service 转发层。它依赖 EndpointSlice hints,并由 kube-proxy 在节点上生成转发规则,适合不经过 mesh 的普通 Service 访问,或者作为集群的基础兜底能力。

Service Mesh 通常会在数据面代理里做自己的 Endpoint 负载均衡。以 Istio 为例:

  • Sidecar 模式下,Envoy 可以基于 locality 信息做本地优先、跨 locality failover 或权重分配;locality 来自 region、zone、sub-zone,其中 Kubernetes 的 topology.kubernetes.io/regiontopology.kubernetes.io/zone 是常用输入。
  • Ambient 模式下,Istio 提供了 networking.istio.io/traffic-distribution annotation,让 ztunnel 按 PreferSameZonePreferSameNode 分发流量;它还可以配置在 Namespace 或 ServiceEntry 上,用来覆盖一组服务或外部服务。
  • 如果同一个 Service 同时设置了 Kubernetes spec.trafficDistribution 和 Istio 的 traffic distribution annotation,Istio 文档说明 Service spec 字段的优先级更高。

引用说明:Istio 1.27.0 Change Notes 在 Traffic Management 中明确列出:开始支持 Kubernetes Service trafficDistribution 字段中的 PreferSameNodePreferSameZone(见 Istio 1.27.0 Change Notes)。

因此,mesh 环境里不要把 trafficDistribution 理解成“所有流量一定都会按 kube-proxy 规则走”。更稳妥的做法是先确认流量路径:

流量路径 推荐控制点
普通 Pod 访问 Kubernetes Service VIP spec.trafficDistribution + kube-proxy
Mesh 内部服务互访,代理负责 Endpoint 选择 Mesh 的 locality / traffic distribution 配置
Mesh 与非 Mesh 工作负载混合 两边都要配置一致的 locality 目标,并用指标确认实际跨区比例
外部服务或 ServiceEntry 使用 Mesh 的 locality 能力,Kubernetes Service 字段通常覆盖不到

不管由哪一层做就近转发,前提都一样:目标拓扑里必须有足够就绪 Endpoint。Service Mesh 可以提供更细的 L7 策略、健康检测、熔断、灰度和可观测性,但它不能替代 topologySpreadConstraints 把 Pod 铺到合适的可用区或节点上。

5. 总结#

这些能力可以按控制目标拆成两类:一类控制 Pod 分布,一类控制请求如何选择 Endpoint。实际落地时,通常先让后端分布足够健康,再叠加流量侧的就近偏好。

5.1 控制 Pod 分布#

topologySpreadConstraints 作用在调度阶段,适合解决“副本不要集中在同一个可用区、节点或机架”这类问题。它的收益是从源头让 Endpoint 分布更均衡,为多可用区高可用和后续就近访问打基础。代价是它只影响新创建或重新调度的 Pod;如果使用 DoNotSchedule 这类硬约束,还需要提前确认每个拓扑域容量足够,否则 Pod 可能进入 Pending 状态。配置时也要保证 topologyKey、Pod 标签和 labelSelector 能正确匹配。

5.2 控制请求转发#

trafficDistribution 作用在流量选择阶段,解决的是“请求应该优先打到哪个 Endpoint”。

trafficDistribution: PreferSameZone 适合多可用区服务,希望优先访问本可用区后端以降低延迟和跨区费用。它是偏好而不是强制,优点是没有本可用区 Endpoint 时会回退到全集群,优先保证可用性;代价是如果本可用区 Endpoint 太少,仍可能形成热点。

trafficDistribution: PreferSameNode 更适合 DaemonSet、节点级缓存、日志/监控 agent、node-local proxy 这类节点本地模型。它可以减少节点间跳转,又不会像 Local policy 那样在没有本节点 Endpoint 时直接失败。代价是它对后端分布要求更高,如果服务并不是节点本地模型,可能把流量压到少量同节点 Endpoint 上。

已经接入 Service Mesh 时,mesh 内部流量的 locality 通常应该放到 mesh 策略里统一管理,例如 Istio 的 locality load balancing 或 ambient traffic distribution。它适合需要 L7 灰度、mTLS、重试、熔断、外部服务和细粒度 failover 的场景;代价是引入额外数据面和控制面复杂度,并且要明确哪些流量经过 mesh,哪些仍然走 Kubernetes 原生 Service 路径。

参考资料#

访问量 访客数