背景:一个 DNS 短视频把我带进了整条链路#

起因很小:我刷到 KodeKloud 的一个短视频《Pod DNS Not Working - Part 1》,讲的是 Pod 解析不了 DNS 时怎么一步步排查。视频本身不长,给的排查技巧也很实用,但真正勾住我的是它顺带提到的一句话——业务流量通常不会直接依赖 Pod 原始 IP,而是更常通过 Service DNS 名称访问,因为 Pod IP 会随着重建变化。

于是我冒出一个问题:CoreDNS 解析一个普通 Service 的 DNS 名称,返回给我的只是一个 ClusterIP,而这个 ClusterIP 是个虚拟地址,通常并不直接挂在任何节点的物理网卡上——那发往它的包,到底是怎么找到并送到某个真实 Pod 的?

这里说的 Service DNS 名称,既包括 my-svc.default.svc.cluster.local 这种完整域名,也包括同 namespace 里常写的 my-svc 这种短名;后者会被 Pod 里的 resolver 按 search domain 补成完整域名再查询。

顺着这个问题往下挖,我发现它其实串起了 K8s Service 网络的一整条链路。这篇就按这条链路走一遍:

  • Pod 怎么把域名变成 IP(DNS / CoreDNS)
  • ClusterIP 这个虚拟 IP 的包怎么到达 Pod(kube-proxy + 内核 DNAT)
  • kube-proxy 凭什么知道后端有哪些 Pod(EndpointSlice)
  • 负载均衡到底是谁在做
  • 为什么 2026 年这套 dataplane 正在从 iptables / IPVS 往 nftables 和 eBPF 迁移

我自己 homelab 那几个 K3s 集群已经换成了 Cilium,所以最后一节会和我之前那几篇实战文接上。

先把结论说清楚#

整条链路其实可以一句话概括:CoreDNS 负责「Service DNS 名称 → 虚拟 IP」的翻译,kube-proxy(配合内核 iptables / IPVS / nftables)负责「虚拟 IP → 真实 Pod IP」的路由和负载均衡,而后端 Pod 列表来自 EndpointSlice。 至少在这条链路里,三个角色的职责边界是比较清晰的。

flowchart LR P["客户端 Pod"] -->|"1. 查 my-svc / FQDN"| C["CoreDNS"] C -->|"2. 返回 ClusterIP 10.96.0.100"| P P -->|"3. 连 10.96.0.100"| K["宿主机内核
iptables / IPVS / nftables 规则"] K -->|"4. DNAT 换成某个 Pod IP"| T["目标 Pod 10.244.1.25"] E["EndpointSlice
(后端 Pod 列表)"] -.被 kube-proxy watch.-> K KP["kube-proxy
(规则配置员)"] -.写规则.-> K

下面把每一步拆开讲。

第一站:Pod 怎么把域名变成 IP#

当 Pod 发起一次域名解析,它先读 /etc/resolv.conf,这个文件指明该问哪个 DNS server——在 K8s 里通常指向 kube-dns 这个 Service(它背后是 CoreDNS 的 Pod)。如果集群开了 NodeLocal DNSCache,这里也可能先指向本地缓存地址。请求到了 CoreDNS 之后分两种情况:

  • 集群内部域名(如 kubernetes.default):CoreDNS 直接翻译成对应的 Service IP。
  • 集群外部域名(如 google.com):CoreDNS 把请求 forward 给上游的外部 DNS server。

排查技巧:一次测两个域名#

短视频里我觉得最实用的一招,是在出问题的 Pod 里同时解析两个域名:一个集群内的 kubernetes.default,一个集群外的 google.com。两者的组合结果能很快圈定故障范围:

内部 kubernetes.default 外部 google.com 大概率的问题
失败 失败 Pod 大概率已经用不了集群 DNS:CoreDNS 挂了,或网络/防火墙挡了流量
成功 失败 集群内 DNS 正常,外部 DNS 坏了:CoreDNS 上游 forward 配置有问题,或集群断了外网
失败 成功 Pod 能摸到某个 DNS,但不是集群的:Pod 里大概率配错了 DNS server
都成功但很慢 都成功但很慢 DNS 没坏,可能只是解析请求量过大、负载偏高

再叠加一层报错语义,定位会更准:

  • 返回 No such host:请求送到了,但这个 DNS 名称确实不存在。
  • 返回 Timeout:通常说明 Pod 自始至终都没摸到 DNS server。

这一段如果想看真实环境里 CoreDNS 出问题的连锁反应,可以翻我之前那篇 从 Cilium Gateway 到 CoreDNS 的跨层级故障排查;另外 DNS 解析链路本身(stub / recursive / authoritative)我在 CDN 与 DNS 的存活性 里也单独梳理过。

第二站:ClusterIP 只是个虚拟 IP#

拿到 ClusterIP(比如 10.96.0.100)之后,客户端 Pod 就直接往这个地址发连接了。问题在于:ClusterIP 是个虚拟 IP,它不属于任何一台机器的物理网卡。 所以这里发生的不是「CoreDNS 再帮你去找 Pod」,而是数据包在节点内核网络层被改写后转发。

幕后推手是每个节点上的 kube-proxy。但要强调一点:kube-proxy 自己并不处理数据包,它只是个「规则配置员」。 它从 API Server 拿到 Service 和后端列表,翻译成内核规则;真正执行转发的是 Linux 内核。流程大致是:

  1. 客户端 Pod 向 10.96.0.100 发起连接。
  2. 包离开 Pod 进入宿主机内核网络栈,被内核里的 iptables / IPVS / nftables 规则拦截——这些规则是 kube-proxy 提前写好的。
  3. 内核按规则做 DNAT(目的地址转换),把目标 IP 从 ClusterIP 换成某个后端 Pod IP(比如 10.244.1.25)。
  4. 包被正常路由到目标 Pod。

所以「虚拟 IP 怎么到 Pod」的答案是:内核在 L4 做了一次地址转换,不是有人拿着 ClusterIP 去网络里寻址,而是先做 DNAT 再继续转发。

后端列表从哪来:EndpointSlice#

下一个问题:kube-proxy 凭什么知道一个 Service 背后有哪些 Pod、它们的 IP 是多少?

答案是 EndpointSlice。CoreDNS 和 kube-proxy 之间其实不直接通信,它们都是 Kubernetes control plane 的消费者:

flowchart TD SVC["Service (带 selector)"] --> EPC["EndpointSlice Controller"] POD["匹配且 Ready 的 Pod"] --> EPC EPC -->|写入| EPS["EndpointSlice 对象
(Pod IP + 端口列表)"] EPS -->|watch| KP["每个节点上的 kube-proxy"] KP -->|刷新 iptables/IPVS/nftables| K["节点内核规则"]
  • 你创建带 selector 的 Service 时,EndpointSlice Controller 持续监听符合条件、且处于 Ready 状态的 Pod,把它们的 IP 和端口写进 EndpointSlice 对象。
  • 每个节点上的 kube-proxy 通过 API Server watch Service 和 EndpointSlice 的变化。
  • 一旦后端 Pod 扩容、缩容或挂掉,EndpointSlice 变化,kube-proxy 会尽快更新本节点内核里的规则,尽量让 ClusterIP 映射的 Pod IP 列表保持最新。

(早期用的是 Endpoints 对象,所有后端塞在一个对象里;EndpointSlice 是为了大规模场景把它切片,减少 watch 时的更新放大。)

负载均衡到底是谁做的#

既然一个 ClusterIP 后面可能挂着多个 Pod,那「这次请求落到哪个 Pod」就是负载均衡。至少对常规 ClusterIP 类型 Service 来说,负载均衡的策略通常由 kube-proxy 在每个节点上配置,再由内核执行。 不同代理模式策略差别很大:

  • iptables 模式:iptables 本身没有「轮询」这种应用级算法,kube-proxy 用 statistic 模块做基于概率的分发。比如 3 个后端,第一条规则 1/3 命中 Pod 1,没中进下一条;第二条在剩下流量里 1/2 命中 Pod 2;第三条 100% 命中 Pod 3。宏观上接近均匀,但它做不到真正的轮询,也感知不到后端连接数或负载。
  • IPVS 模式:直接用内核的 IPVS(L4 负载均衡器),支持多种调度算法,可通过 --ipvs-scheduler 配:rr(轮询,默认)、lc(最小连接)、dh(目的哈希)、sh(源哈希,常用于简单会话保持)、sed(最短预期延迟)等。

会话保持是 Service 层面的另一种策略,让同一客户端 IP 的请求总落到同一个 Pod:

spec:
  sessionAffinity: ClientIP
  sessionAffinityConfig:
    clientIP:
      timeoutSeconds: 10800 # 保持 3 小时

开启后 kube-proxy 会在当前 proxy mode(iptables / IPVS / nftables 等)里加上对应的会话保持逻辑。

哪些负载均衡不归 kube-proxy 管#

值得划清边界:kube-proxy 只管到 L4(IP + 端口)。 下面这些决策不由 kube-proxy 做;底层流量是否还经过 ClusterIP,取决于具体实现:

  • L7 路由 / 灰度:基于 HTTP header、cookie、URL path 转发,或蓝绿、金丝雀发布,是 Ingress Controller(Nginx Ingress、Traefik)或 Service Mesh(Istio、Linkerd)的活;它们可能转发到 Service,也可能直接使用后端 endpoints。
  • Headless Service:见下一节,负载均衡交给客户端。
  • eBPF 数据面(如开启 kube-proxy replacement 的 Cilium):直接在内核用 eBPF 完成 Service 转发,绕过 kube-proxy 的 ClusterIP 那套。

一个例外:Headless Service#

如果你把 Service 的 clusterIP 显式设成 None,它就变成 Headless Service,机制完全不同:

  • 不分配 ClusterIP,kube-proxy 也不为它建转发规则。
  • CoreDNS 不再返回一个虚拟 IP,而是直接返回该 Service 后端所有 Ready Pod 的 IP 列表(A / AAAA 记录)。
  • 客户端拿到这组 Pod IP 后,自己决定连哪个——负载均衡主要交给客户端代码。

这在部署有状态集群(Kafka、Elasticsearch、ZooKeeper 这类需要稳定网络标识、客户端自己做分片/选主的场景)时非常常见。

dataplane 在换代:iptables → IPVS → nftables → eBPF#

前面反复出现「iptables / IPVS / nftables」,因为这层 dataplane 正好在换代。我写这篇时(2026-05-29)查到的最新稳定版已经到了 K8s 1.36「Haru」;具体版本状态和发布日期还是建议以官方 release note 为准。先上一张对比表:

维度 iptables IPVS nftables eBPF (Cilium)
底层结构 线性链表 hash 表 verdict maps eBPF map
匹配复杂度 O(n) O(1) O(1) O(1)
大规模表现 相对差,全量 reload 时 CPU 易飙 好,更轻量 通常最好
定位 通用防火墙 纯 L4 负载均衡器 netfilter 新一代统一子系统 绕过 netfilter
K8s 社区状态 1.36 仍是默认 v1.35 已弃用 v1.33 GA CNI 自带,非 kube-proxy

几点展开:

  • iptables:规则更新是非增量的,每次增删(Pod 扩缩容、Service 变更)都要锁住整张表、全量倒出再 reload。大集群里这会带来明显延迟和 CPU 抖动;线性匹配在 Service 上千时也拖慢每个包。但出于兼容性,至少在 1.36 这一版里,它仍是 kube-proxy 的默认模式
  • IPVS:O(1) 解决了 iptables 的查找瓶颈,但它只是个纯 L4 负载均衡器,撑不住 NetworkPolicy、NodePort 伪装、拓扑感知路由这些 K8s 原生需求。结果是 kube-proxy 在 IPVS 模式下底层还得再建一部分 iptables 规则打补丁,「IPVS + iptables」双系统混合,维护代价很高。这也正是它被弃用的原因——IPVS 模式在 v1.35 被标记为 deprecated。
  • nftables:netfilter 的正统继任者,用 verdict maps 实现 O(1),且支持原子的增量更新,不再需要全量 reload,同时能在一套架构里统一处理负载均衡、NAT 和策略过滤。它 v1.33 GA(v1.29 引入 alpha,中间过了 beta),但有硬性前提:节点内核必须 ≥ 5.13,否则 kube-proxy 启动时会在版本检查阶段报错。
  • eBPF:Cilium 这类 CNI 用 socket-level 负载均衡,在 connect/sendmsg 等系统调用阶段就把 ClusterIP 换成 Pod IP,直接从 socket 层替掉 netfilter 那套。开启 kubeProxyReplacement=true 后,kube-proxy 可以直接从集群里删掉。

关于「1.36 已移除 IPVS」:不少二手文章写成「1.36 已彻底移除 IPVS」,但对照 kubernetes.io 文档,IPVS 在 v1.35 只是 deprecated(feature state 标的是 v1.35 [deprecated]),1.36 里仍然可用,只是会打印告警。按 K8s 的弃用惯例,GA 特性被弃用后还会再留数个版本才真正移除。

取舍:1.36 时代我会怎么选#

下面是结合我自己 homelab 经验的判断,至少在我这些场景下成立,不一定适配所有集群:

  • 中小规模、求稳(Service 数量不大):我会留在默认的 iptables。它服役十多年,兼容性最好、经验也最丰富;小规模下那点开销通常无感,未必值得为一点性能收益去承担网络重构的风险。
  • 原本就是 IPVS 的大规模 / 高并发集群:我会趁升级窗口评估迁到 nftables。先确认所有节点内核 ≥ 5.13,再改 kube-proxy 的 mode、滚动重启;这样能甩掉 IPVS 那套「双系统打补丁」的技术债,也避开它后续被移除的被动局面。
  • 已经或准备上 Cilium:那这次 IPVS 弃用对你可能反而是个「架构瘦身」契机。不用纠结 nftables 怎么配,直接开 kubeProxyReplacement=true,把 kube-proxy 连同 netfilter 那套一起请出集群,走纯 eBPF 转发。我 homelab 和 Oracle 的两个 K3s 集群就是这条路,迁移踩坑记在 K3s CNI 从 Flannel 到 Cilium,跨集群服务发现在 Cilium ClusterMesh 实战

一个容易踩的坑:如果用的是 Calico 的 eBPF 数据面,官方要求切换前先把 kube-proxy 设回 iptables 模式,再开 eBPF(Calico 需要基于 iptables 清理旧规则)。这点和 Cilium 不一样——Cilium 是直接不装 kube-proxy,别把两家的步骤记混了。

排查清单:下次 DNS / Service 出问题我按这个顺序看#

把上面整条链路压成一个排查顺序,方便下次照着走:

  1. 先看出问题 Pod 的 /etc/resolv.conf:确认 nameserver 指向的是不是你预期里的集群 DNS(很多集群是 kube-dns Service,也有一些会先走 NodeLocal DNSCache)。
  2. 再同时解析 kubernetes.defaultgoogle.com,用前面那张四象限表圈范围;顺手看报错是 No such host(DNS 名称不存在)还是 Timeout(通常是根本没摸到 DNS)。
  3. DNS 没问题但连不上 Service:检查 kubectl get endpointslices,看后端 Pod 是否真的进了列表(Pod 没 Ready 就不会被收进去)。
  4. EndpointSlice 有后端但流量不通:去看节点上的 kube-proxy——它的 mode、日志有没有报错、规则有没有刷上(iptables-save / ipvsadm -Ln / nft list ruleset,取决于模式)。
  5. 怀疑负载不均:先确认是不是 iptables 模式(它只是概率分发,不是真轮询),再看有没有意外开了 sessionAffinity: ClientIP
  6. 如果是 Cilium / eBPF 集群:上面 kube-proxy 那几步基本跳过,直接用 Cilium 自带的工具(cilium status、Hubble)看 Service 和转发。

说到底,K8s 网络这条链路并不神秘:CoreDNS 翻译 DNS 名称、kube-proxy 写规则、内核做转发、EndpointSlice 提供后端,dataplane 只是把「内核怎么做转发」这一段从 iptables 一路换到了 eBPF。把角色分清楚,排查时就知道该去链路的哪一层看。

参考#

一手资料:

二手报道(属于报道 / 解读,不是一手证据,引用时建议回查上面的一手文档):

本博客相关: