ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Kubernetes Service到Pod流量路径:iptables、IPVS与conntrack全解析

Kubernetes Service到Pod流量路径:iptables、IPVS与conntrack全解析 搞明白 Service 到 Pod 的流量路径是每一个被kubectl get svc 能看到 ClusterIPPod 也全是 Running却依然访问不通的人迟早要过的坎。我处理线上问题这些年Service 相关的坑见得太多了明明 endpoints 是满的curl 就是超时明明有两个副本流量却全压在同一个 Pod 上。这些问题的根源几乎都落在同一条链路上——用户请求到达 Service 的虚拟 IP 之后内核里到底发生了什么数据包才精准落进那一个 Pod。这篇文章不聊抽象架构图我直接把 iptables 的规则现场、IPVS 的调度表、以及数据包在节点网卡和 Pod 网卡之间的走向全部摊开来讲。适合刚学完 k8s 基础、对本机网络细节还犯迷糊的人也适合经常被 Service 访问故障折磨的运维和开发。你不需要提前把整个 CNI 源码读完只需要带着一个疑问进来用户访问 k8s 的 svc 后流量是怎么打到具体的某一个 pod 上的1. 先搭好实验场景再拆流量链路1.1 一个最小场景Deployment 两个副本 ClusterIP Service先把场景固定下来后面所有规则和抓包才有参照物。假设我用一份很普通的 Deployment 起了两个 Nginx 副本再创建一个 ClusterIP 类型的 Serviceyaml 大概长这样apiVersion: apps/v1 kind: Deployment metadata: name: nginx-demo spec: replicas: 2 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:1.27 --- apiVersion: v1 kind: Service metadata: name: nginx-svc spec: selector: app: nginx ports: - port: 80 targetPort: 80部署完之后kubectl get svc nginx-svc会看到类似10.96.154.25:80的 ClusterIPkubectl get endpoints nginx-svc会看到两个 Pod IP比如10.244.1.5:80和10.244.1.6:80。到这里一切都很正常但正常只是表象真正的问题在于ClusterIP 这个地址你在任何一台节点上用ip addr都找不到它对应的网卡它只是写在 etcd 里的一条资源记录。那用户访问它的时候操作系统凭什么知道要把包往哪里送答案的起点是三个角色EndpointSlice 控制器负责维护 Pod 名单kube-proxy 负责把名单翻译成内核规则内核的 netfilter/IPVS 负责真正干活。这三者是一条流水线任何一个环节卡住Service 就会看起来正常用起来难受。1.2 三种常见的访问入口路径有什么不同Service 的流量入口大致分三种集群内部的 ClusterIP、对外的 NodePort、云厂商或自建负载均衡器的 LoadBalancer。很多人把这三种当成三套完全不同的机制其实它们的后半段是共用的区别只在数据包到达节点的过程中怎么被接住。入口类型数据包最开始去哪Service 参与方式典型场景ClusterIP集群内 Pod 或节点直接访问虚拟 IP节点上的 NAT 规则直接处理服务间调用、测试验证NodePort任意节点的 30000-32767 端口节点收到后转到 ClusterIP 规则无 LB 环境的外网访问LoadBalancer外部 LB 先把流量转发到某节点 NodePort最终仍走 NodePort 后半段云上生产环境对外服务也就是说NodePort 和 LoadBalancer 本质上都是多一层入口包装真正的分发逻辑还是 ClusterIP 那套。这也是为什么排查问题时不管用户从哪进来的最后都要回到ClusterIP - Endpoints - Pod这条主线上去看。搞清楚这条主线剩下的都是旁支。2. Service 是虚拟的承诺kube-proxy 才是执行者2.1 Service 与 Endpoints一份被持续维护的花名册Service 本身不存任何 Pod 信息它只有两样东西一个不变的虚拟 IP以及一个 selector 标签选择器。真正把 Service 和 Pod 绑在一起的是 Endpoints 这个资源以及它背后的 EndpointSlice。在比较新的 Kubernetes 版本里kube-controller-manager 里的 EndpointSlice 控制器会一直盯着符合条件的 Pod一旦 Pod 的 IP 变化、标签变化或者 Ready 状态变化它就会立刻增删 EndpointSlice 里的地址条目。用一句话概括Service 是招聘启事EndpointSlice 是入职名单。招聘启事上写的是要求selector入职名单上写的是现有员工Pod IP。kube-proxy 不会自己去问 Pod 在不在它只看名单。所以当你发现 Service 访问不通时第一步永远是kubectl get endpointslices -l kubernetes.io/service-namenginx-svc看看名单是不是空的、IP 是不是落后于实际 Pod IP。这一步能过滤掉至少三成问题。2.2 kube-proxy 的本职工作把清单翻译成内核规则kube-proxy 是运行在每个节点上的 DaemonSet它不是数据面的转发器而是一个规则翻译器。它通过 API Server 监听 Service 和 EndpointSlice 的变化然后把变化翻译成节点上实际生效的转发规则。翻译成的规则有两种主流形态iptables 规则nat 表或者 IPVS 虚拟服务器。这里要澄清一个常见误解数据包不是流经 kube-proxy 的kube-proxy 甚至可以停掉已经写好的内核规则照样会继续转发流量。真正让数据包改道的是内核里的 netfilter 钩子kube-proxy 只是往这些钩子上挂规则。理解这一点特别重要——排错的时候你去看 kube-proxy 日志往往什么发现都没有因为问题根本不在它身上而在它写出来的规则和内核行为上。2.3 为什么 ClusterIP 不是绑在某块网卡上的地址我刚开始学 k8s 的时候干过一件傻事去节点上ip addr找 ClusterIP想着找到那块虚拟网卡就能理解它了结果自然是什么都没有。ClusterIP 从诞生起就不属于任何网卡它只存在于内核的 NAT 规则里。数据包到达节点之后会在 PREROUTING 或 OUTPUT 链里被匹配到这个虚拟 IP然后直接被改写目的地压根走不到路由到某张网卡这一步。这就像公司前台有一个总机号码拨进去之后前台把电话转接给具体员工总机号码本身不连接任何一根真实的电话线。这个设定有一个让人意外的副作用你在节点上ping ClusterIP通常是通的内核会响应但telnet ClusterIP 80才有意义因为只有匹配到端口和协议规则才会触发。3. 负载均衡发生在哪iptables 与 IPVS 的两种答案3.1 iptables 模式下的抽签逻辑kube-proxy 默认的代理模式是 iptables部分发行版和云厂商会改成 ipvs在这种模式下负载均衡完全靠 nat 表里一串链的跳转来完成。用优雅一点的说法叫概率抽签说难听点就是掷骰子。我先用命令把现场抓出来你感受一下iptables -t nat -L KUBE-SERVICES -n --line-numbers输出里能看到一条匹配 ClusterIP 的规则大意是目的地址是10.96.154.25且目的端口是 80 的包跳到 KUBE-SVC 开头的一条链。接着把这条 SVC 链打出来iptables -t nat -L KUBE-SVC-XXXX -n -v假设有两个后端 Pod你会看到类似这样的两条规则Chain KUBE-SVC-XXXX (1 references) target prot opt source destination KUBE-SEP-A 0.0.0.0/0 0.0.0.0/0 statistic mode random probability 0.50000000000 KUBE-SEP-B 0.0.0.0/0 0.0.0.0/0第一条规则带着statistic mode random probability 0.5意思是一半概率跳到后端 A另一半概率落到第二条而第二条无条件跳到后端 B。如果后端有三个概率会变成 1/3 和 1/2保证最终每个后端分到约 1/3 的流量。再往下看 KUBE-SEP 链它才是真正动手的地方Chain KUBE-SEP-A (1 references) target prot opt source destination DNAT tcp 0.0.0.0/0 10.96.154.25 tcp dpt:80 to:10.244.1.5:80这行 DNAT 规则把目的 IP 从 ClusterIP 改写成了 Pod IP。到这里负载均衡其实已经结束了——后面的路由转发是内核自己的事。值得留意的是iptables 模式的随机是每个新连接独立抽签的。它不会记住某个客户端上次分到了哪个 Pod所以同一客户端的两个不同连接可能被分到不同后端。如果你需要会话保持就得给 Service 配sessionAffinity: ClientIPkube-proxy 会额外生成一条按源 IP 哈希的规则。3.2 IPVS 模式把负载均衡交给内核模块iptables 模式在规则数量很大的时候性能会明显下降因为链表是线性匹配的。所以生产环境里我更喜欢把 kube-proxy 切到 IPVS 模式在 kube-proxy 的 ConfigMap 里把mode: ipvs配上就行。IPVS 是内核里专门的 L4 负载均衡器它维护一张哈希表直接用内核线程做调度规则再多也不会像 iptables 那样一条条遍历。接线完成之后在节点上执行ipvsadm -Ln会看到这样一张表IP Virtual Server version 1.2.1 Prot LocalAddress:Port Scheduler Flags - RemoteAddress:Port Forward Weight ActiveConn InActConn TCP 10.96.154.25:80 rr - 10.244.1.5:80 Masq 1 0 0 - 10.244.1.6:80 Masq 1 0 0LocalAddress 是 Service 的 ClusterIPRemoteAddress 就是两个 Pod IP调度算法默认是 rr轮询。每来一个新连接IPVS 内核模块按照调度算法直接选一个 RealServer然后用Masq方式做 DNAT把包转到 Pod。整个过程不经过用户态也没有链表遍历性能比 iptables 模式高一个量级。iptables 和 IPVS 的选择说白了就是灵活但线性和高效但简单之间的权衡。iptables 模式胜在匹配条件全、规则透明适合小集群和排查学习IPVS 模式胜在性能和调度算法丰富适合后端数量多、连接密度高的生产集群。我个人的经验是先在小集群里把 iptables 模式玩明白再切 IPVS因为你只有看得懂规则才能在出问题时快速定位而不是瞎猜。3.3 DNAT、SNAT、MASQUERADE数据包的改头换面术前面已经提到 DNAT 把目的地址改成了 Pod IP但流量能回来靠的不只是 DNAT还有 SNAT 和 conntrack。这里用生活化的方式解释一下这三兄弟的分工。DNAT 是进入时的改名换姓包到达节点时目的地址还是 ClusterIPDNAT 把它改写为 Pod IP相当于快递到了前台前台把收件人改成具体员工的名字再往后送。SNAT/MASQUERADE 是出门时的伪装当数据包来自集群外部比如通过 NodePort 进来的如果不伪装源地址Pod 回包时会把包发给原始客户端而不是经过节点中转这样客户端收到的源 IP 是 Pod IP而不是它访问的节点 IP连接直接断掉。所以 MASQUERADE 会把源 IP 改成节点网卡 IP回包先回到节点节点再根据 conntrack 记录把包转回客户端。conntrack 是这个链路里的记事本。DNAT 发生的时候内核会在连接跟踪表里记一笔这条连接原始目的是10.96.154.25:80被改写成了10.244.1.5:80。回包到达节点时conntrack 看到这是刚才那条连接的回复就反向做一次 UN-DNAT把源地址再改回 ClusterIP这样客户端浑然不知自己其实跟 Pod 直接对话过。这里有一个我在实战中踩过很多次的坑conntrack 表是有上限的默认值在某些内核和环境下并不高。如果集群里短连接特别多conntrack 表被撑满新连接就会随机丢包表现是Service 时通时不通。排查时可以用conntrack -S看 insert_failed 计数如果一直在涨就该考虑调大nf_conntrack_max或者从应用层改造连接模式了。4. 最后一跃从节点网卡到 Pod 内网卡4.1 节点路由表和 CNI 插件的分工数据包被 DNAT 成10.244.1.5:80之后内核需要重新做一次路由决策这个目的 IP 到底从哪张网卡出去这一步不再由 Service 管而是由 CNI 网络插件负责。不同的 CNI 方案在这层的表现完全不同我以最常见的 Flannel 和 Calico 为例。Flannel 的 VXLAN 模式下每个节点上有一个叫cni0的桥接网卡所有本节点的 Pod 都通过 veth 虚拟网线挂在这座桥上。节点路由表里会有这样的条目10.244.1.0/24 dev cni0表示目的网段是本节点的直接桥接10.244.2.0/24 via 10.244.2.1 dev flannel.1表示目的网段在别的节点走 VXLAN 隧道。Calico 则更直接它通过 BGP 在各节点间宣告 Pod 网段路由数据包直接走节点物理网卡转发没有隧道封装性能更好但依赖底层网络的连通性。不管哪种方案数据包最终都要通过 veth 对进入 Pod 的网络命名空间。veth 就像一根虚拟网线一头插在宿主的 cni0 或 cali 接口上另一头插在 Pod 的 eth0 上。包走到宿主这一头就等同于一脚迈进了 Pod 的门剩下的就是 Pod 里协议栈的正常处理了。4.2 回包是另一条路但 conntrack 让它看起来像同一跳很多人以为数据包从哪来就沿原路回去其实完全不是。Pod 收到请求后Nginx 回包的源地址是10.244.1.5:80目的地址是客户端 IP如果经过了 MASQUERADE这个客户端 IP其实是发出请求的节点 IP。回包走的路径是Pod 的 eth0 - veth - 节点路由表 - 按源目地址查 conntrack发现这条连接是之前 DNAT 过的于是做反向转换把源地址改回 ClusterIP如果之前有 SNAT再把目的地址改回客户端原始 IP。这一套转折全部发生在内核里对应用层完全透明。理解了回包路径就理解了为什么kubectl logs里看到的客户端 IP 有时候是节点 IP 而不是真实用户 IP。默认 externalTrafficPolicy 是 Cluster 模式NodePort 流量经过节点时会做 SNAT真实源 IP 被抹掉了。想要保留真实源 IP得把 Service 的externalTrafficPolicy改成 Local但这又要求流量只转发到本节点的 Pod跨节点转发会被放弃流量分布不均匀。这是一个取舍问题没有两头都占的方案。4.3 完整生命周期一个 HTTP 请求的 7 个阶段把上面的知识串起来一次完整的请求大概是这样的。我拿集群外用户通过 NodePort 访问来举例因为它覆盖了最全的路径用户请求到达某个节点的 NodePort 端口比如10.0.0.5:30080。节点上的 iptables 或 IPVS 规则匹配到 NodePort把它交给对应的 ClusterIP Service 规则继续处理。在 KUBE-SVC 链或 IPVS 调度表中按概率或轮询选中一个后端 Pod比如10.244.1.5。做 DNAT目的地址从 ClusterIP 或 NodePort 对应的虚拟地址改成10.244.1.5:80同时 MASQUERADE 把源地址改成节点 IP。内核重新路由根据 CNI 的路由表把包从正确网卡发出经过 veth 进入 Pod。Nginx 处理完请求回包到节点。conntrack 识别出这是已记录的连接做反向 NAT。节点把回包交给用户用户看到的仍然是他访问的那个地址整个过程无感知。这 7 个阶段里最容易出问题的集中在 2、3、5。前两个属于 kube-proxy 规则层的范畴最后一个属于 CNI 路由层的范畴排查时先把这两大块分开别在 Pod 里面瞎抓包。5. 排查实录从不通到通了的常见套路5.1 访问 ClusterIP 超时但 Pod 明明健康现象是 Pod 状态都是 Runningkubectl get svc也有 ClusterIP但进 Pod 里curl http://10.96.154.25就是卡住或拒绝。这种问题八成出在 EndpointSlice 和 kube-proxy 规则之间的同步上按下面的顺序查能省掉很多冤枉路。先看名单有没有更新kubectl get endpointslices -l kubernetes.io/service-namenginx-svc -o yaml确认地址是实际 Pod IP。再看 kube-proxy 有没有把规则写进去节点上执行iptables -t nat -L KUBE-SERVICES -n | grep 10.96.154.25如果在 IPVS 模式下就ipvsadm -Ln | grep 10.96.154.25。规则如果不存在要么是 EndpointSlice 没更新要么是 kube-proxy 没监听到后者去查 kube-proxy 日志里有没有连不上 API Server 的记录。我遇到过一个特别隐蔽的情况Pod 是新版但 Elastic IP 老挂在旧 Pod 上EndpointSlice 里显示的 IP 在节点上根本不存在。原因是有个 StatefulSet 的 headless Service 配合 network 相关资源时EndpointSlice 控制器更新延迟而 kube-proxy 又还没来得及删旧规则。这种问题用kubectl get endpointslices一眼就能看见地址列表里有幽灵 IP。5.2 流量总是打到同一个 Pod 上另一个高频问题是两个副本但压测时其中一个 Pod 的流量占比接近 90%。很多人第一时间怀疑负载均衡算法坏了其实大概率是 conntrack 在帮忙。iptables 的概率抽签是针对新连接的但同一个 TCP 连接里的所有包conntrack 都会命中同一条记录一直送到同一个 Pod。如果你的压测脚本用了长连接复用或者数据库连接池一直保持着连接那流量天然就固定在一个后端上。这不算 bug而是很多中间件连接的正常表现。要验证这一点用短连接方式压测比如每个请求新建一个 TCP 连接再看分布通常会恢复均衡。另一个原因是 Service 配置了sessionAffinity: ClientIP这会让同一个源 IP 的所有连接都打到一个 Pod 上。排查时kubectl get svc nginx-svc -o yaml看一眼 clientIP 字段就知道。生产环境如果需要均匀分布确认 sessionAffinity 不是 ClientIP或者根据业务需要把 hash 键改成sourceIP,destinationIP这样的组合。5.3 排查命令速查表最后把这些年积累下来最常用的排查命令整理成一个速查表按从上到下的顺序执行基本能把 Service 流量问题定位到具体环节。排查目的命令关键看点查看服务与端口kubectl get svc nginx-svc -o yamlClusterIP、port、sessionAffinity查看后端名单kubectl get endpoints nginx-svcAddresses 是否与实际 Pod IP 一致查看新版名单kubectl get endpointslices -l kubernetes.io/service-namenginx-svcready 地址数量看 kube-proxy 模式kubectl get cm -n kube-system kube-proxy -o yamlconfig.conf 里的 mode 字段iptables 规则总入口iptables -t nat -L KUBE-SERVICES -n -vClusterIP 是否出现在链里定位 SVC 链iptables -t nat -L KUBE-SVC-XXXX -n -v概率规则和跳转目标看最终 DNATiptables -t nat -L KUBE-SEP-XXXX -n -vto: 后面的 Pod IP 是否正确IPVS 调度表ipvsadm -Ln --statsVS/RS 对应关系与连接数看 conntrack 是否丢表conntrack -Sinsert_failed 是否持续增长看节点路由ip route show table mainPod 网段走哪个接口/下一跳进 Pod 网络空间抓包nsenter -t pid -n tcpdump -i eth0 port 80数据包是否真的进入 Pod这套命令走下来绝大多数流量打不到 Pod 的问题都能定位到名单没更新、规则没写入、路由不通这三层里的某一层。剩下的少数疑难杂症就要靠抓包和对 CNI 插件的深入理解了那是另一个话题。我个人在实际操作中的体会是Service 到 Pod 的流量链路并不复杂复杂的是它跨越了多个层次控制面有 kube-controller-manager 和 kube-proxy数据面有 netfilter、路由表和 CNI。平时写 yaml 的时候永远记得一句话规则是 kube-proxy 写的路由是 CNI 管的名单是控制器维护的三者各司其职互不越界。把这条边界焊死在脑子里排障速度至少能快一倍。最后再分享一个小技巧每次改完 Service 或者扩缩容之后顺手在节点上把iptables -t nat -L KUBE-SERVICES -n -v和ipvsadm -Ln翻一遍你见规则见得越多对这条链路就越有手感哪天它真的出问题的时候你闭着眼都能猜到它断在哪一截。
返回列表