剖析 kube-proxy IPVS 模式下的服务流量调度
在 Kubernetes 平台中,Service 抽象是微服务架构的核心,它为一组具有相同标签的 Pod 提供了稳定的网络端点和负载均衡能力。而 kube-proxy 则是实现这一 Service 机制的关键组件。深入理解 kube-proxy 的运作方式,对于掌握集群内部服务通信流程以及排查网络故障至关重要。
kube-proxy 提供了多种工作模式,包括 userspace、iptables 和 IPVS。其中,userspace 模式因性能问题已较少使用。传统的 iptables 模式在服务数量庞大时,会生成大量 iptables 规则链,这不仅导致规则更新时延增加,还会在大规模集群中引发显著的性能瓶颈。为了解决 iptables 模式的性能局限性,Kubernetes 从 v1.8 版本开始引入了 IPVS (IP Virtual Server) 模式的测试支持,并于 v1.11 版本达到通用可用 (GA) 状态。
IPVS 模式利用 Linux 内核中更高效的负载均衡技术,实现了规则的增量式更新,并且能够在服务更新过程中保持现有连接不中断,从而显著提升了在大规模集群中的性能和稳定性。
当前关于 kube-proxy 工作原理的文献,多以 iptables 模式为例,对 IPVS 模式的详细解析相对较少。本文将专注于揭示 kube-proxy IPVS 模式的内部机制。为了更彻底地理解其运作,我们将避免直接使用 Docker 或 Kubernetes 环境,而是通过 Linux 底层的网络工具进行模拟和演示。
Kubernetes 为每个 Pod 分配一个独立的网络命名空间 (Network Namespace)。在接下来的实验中,我们将手动创建网络命名空间,并启动简单的 HTTP 服务来模拟 Kubernetes 中的 Pod 实例。
本文的目标是模拟以下 Kubernetes Service 定义,并通过 IPVS 和 ipset 工具探索其流量转发原理:
apiVersion: v1
kind: Service
metadata:
name: app-svc-test
spec:
clusterIP: 192.168.10.100
selector:
app: webserver
ports:
- protocol: TCP
port: 8080
targetPort: 8080
按照本文的步骤,最终您将能够通过执行 curl 192.168.10.100:8080 命令,访问到我们手动创建的网络命名空间中的 HTTP 服务。
实验环境准备
请确保您的 Linux 系统(本文基于 Ubuntu 20.04 进行测试)已开启 IP 转发功能:
$ sysctl -w net.ipv4.ip_forward=1
接下来,我们将配置一个模拟 Kubernetes Pod 网络的实验环境:
- 创建一个名为
k8s-bridge的虚拟网桥。 - 创建两个网络命名空间
ns-app-a和ns-app-b,它们将模拟 Kubernetes 中的两个 Pod。 - 为每个网络命名空间配置 DNS 解析。
- 创建两个 veth pair (虚拟以太网对),其一端连接到宿主机上的
k8s-bridge,另一端连接到各自的网络命名空间。 - 为
ns-app-a中的 veth 设备分配 IP 地址172.18.0.101。 - 为
ns-app-b中的 veth 设备分配 IP 地址172.18.0.102。 - 为每个网络命名空间设置默认路由。
- 添加 iptables 规则,允许流量通过
k8s-bridge接口转发。 - 添加 iptables 规则,对来自
172.18.0.0/24网段的流量进行 IP 伪装 (MASQUERADE)。
$ ip link add dev k8s-bridge type bridge
$ ip address add 172.18.0.1/24 dev k8s-bridge
$ ip netns add ns-app-a
$ mkdir -p /etc/netns/ns-app-a
$ echo "nameserver 8.8.8.8" | tee -a /etc/netns/ns-app-a/resolv.conf
$ ip netns exec ns-app-a ip link set dev lo up
$ ip link add dev veth-host-a type veth peer name veth-ns-a
$ ip link set dev veth-host-a master k8s-bridge
$ ip link set dev veth-host-a up
$ ip link set dev veth-ns-a netns ns-app-a
$ ip netns exec ns-app-a ip link set dev veth-ns-a up
$ ip netns exec ns-app-a ip address add 172.18.0.101/24 dev veth-ns-a
$ ip netns add ns-app-b
$ mkdir -p /etc/netns/ns-app-b
$ echo "nameserver 8.8.8.8" | tee -a /etc/netns/ns-app-b/resolv.conf
$ ip netns exec ns-app-b ip link set dev lo up
$ ip link add dev veth-host-b type veth peer name veth-ns-b
$ ip link set dev veth-host-b master k8s-bridge
$ ip link set dev veth-host-b up
$ ip link set dev veth-ns-b netns ns-app-b
$ ip netns exec ns-app-b ip link set dev veth-ns-b up
$ ip netns exec ns-app-b ip address add 172.18.0.102/24 dev veth-ns-b
$ ip link set k8s-bridge up
$ ip netns exec ns-app-a ip route add default via 172.18.0.1
$ ip netns exec ns-app-b ip route add default via 172.18.0.1
$ iptables -t filter -A FORWARD -i k8s-bridge -j ACCEPT
$ iptables -t filter -A FORWARD -o k8s-bridge -j ACCEPT
$ iptables -t nat -A POSTROUTING -s 172.18.0.0/24 -j MASQUERADE
现在,我们将在网络命名空间中启动简单的 HTTP 服务器。请在不同的终端窗口中执行以下命令:
在第一个终端中,为 ns-app-a 启动 HTTP 服务:
$ ip netns exec ns-app-a python3 -m http.server 8080 --bind 172.18.0.101
在第二个终端中,为 ns-app-b 启动 HTTP 服务:
$ ip netns exec ns-app-b python3 -m http.server 8080 --bind 172.18.0.102
测试各个网络命名空间之间是否能正常通信:
$ curl 172.18.0.101:8080
$ curl 172.18.0.102:8080
$ ip netns exec ns-app-a curl 172.18.0.102:8080
$ ip netns exec ns-app-b curl 172.18.0.101:8080
上述测试均应成功返回 HTTP 服务的内容。至此,我们的模拟网络环境已准备就绪。
安装必要工具
为了便于调试和管理 IPVS 规则与 IP 集合,我们需要安装 ipset 和 ipvsadm 工具:
$ apt install ipset ipvsadm --yes
通过 IPVS 模拟 Service
现在,我们将使用 ipvsadm 工具创建一个虚拟服务 (Virtual Service) 来模拟 Kubernetes 中的 Service:
$ ipvsadm -A -t 192.168.10.100:8080 -s rr
-A或--add-service:添加一个虚拟服务。-t 192.168.10.100:8080或--tcp-service 192.168.10.100:8080:指定 TCP 协议的虚拟服务 IP 和端口。-s rr或--scheduler rr:选择轮询 (Round-Robin) 调度算法。这是 IPVS 相较于 iptables 的一个优势,能够灵活选择多种调度算法。
创建虚拟服务后,还需要为它指定一个后端真实服务器 (Real Server),即 ns-app-a 中的 HTTP 服务:
$ ipvsadm -a -t 192.168.10.100:8080 -r 172.18.0.101:8080 -m
-a或--add-server:为指定的虚拟服务添加一个真实服务器。-r 172.18.0.101:8080或--real-server 172.18.0.101:8080:指定真实服务器的 IP 地址和端口。-m或--masquerading:启用 IP 伪装模式。这意味着当数据包从真实服务器返回客户端时,源 IP 将被修改为虚拟服务的 IP。如果禁用此选项,IPVS 会尝试使用路由表进行转发,但通常在 Docker/Kubernetes 等场景下,这无法正常工作,因为回程流量需要进行 NAT 反转。由于 IPVS 本身没有 POST_ROUTING 钩子点,它需要依赖 iptables 来完成 IP 伪装等功能。
现在,从宿主机上测试虚拟服务是否能正常工作:
$ curl 192.168.10.100:8080
如果一切顺利,您应该能收到 ns-app-a HTTP 服务返回的内容。
在网络命名空间中访问虚拟服务
刚才的测试是在宿主机网络命名空间中进行的。现在,我们进入 ns-app-b 网络命名空间中进行测试:
$ ip netns exec ns-app-b curl 192.168.10.100:8080
您会发现访问失败了!这是因为 192.168.10.100 这个虚拟 IP 尚未被宿主机的任何网络接口所拥有,内核无法将其识别为本地地址。当数据包到达宿主机时,Linux 内核的 Netfilter 框架会对其进行处理。IPVS 在 Netfilter 中的实现主要依赖于 LOCAL_IN、LOCAL_OUT、FORWARD 这些钩子点,但没有 PRE_ROUTING 钩子。这意味着数据包在进行路由判断之前,IPVS 无法对其目标 IP 进行转换。如果数据包的目标 IP (即 192.168.10.100) 不属于本地接口,在经过路由判断后,它将不会被送往 LOCAL_IN 钩子,而是被认为是需要转发到其他机器,或者直接被丢弃。因此,我们需要将这个虚拟 IP 分配给一个本地的网络接口,使内核认为这个 IP 地址是属于本机的,从而让 IPVS 在 LOCAL_IN 钩子中能够处理它。
使用 Dummy 接口
在 Kubernetes 中,为了实现 Service ClusterIP,通常会创建一个 dummy 虚拟网络接口。dummy 接口类似于 loopback 接口,但您可以创建多个,它提供了一种将 IP 地址"虚拟化"绑定到主机的方式,但实际上并不进行数据转发。它有两个主要用途:
- 用于主机内部程序间的通信。
- 由于
dummy接口总是处于"up"状态,将其绑定 Service ClusterIP 可以确保 Service 地址的可用性不受物理网络接口状态的影响。
因此,我们创建一个 dummy 接口,并将其命名为 k8s-dummy0:
$ ip link add name k8s-dummy0 type dummy
然后,将虚拟 IP 192.168.10.100 分配给 k8s-dummy0 接口,并启用它:
$ ip addr add 192.168.10.100/32 dev k8s-dummy0
$ ip link set dev k8s-dummy0 up
此时再次尝试从 ns-app-b 访问 HTTP 服务:
$ ip netns exec ns-app-b curl 192.168.10.100:8080
您可能会发现仍然无法成功。为了解决这个问题,我们还需要启用一个重要的内核参数:net.bridge.bridge-nf-call-iptables。
bridge-nf-call-iptables 参数详解
Kubernetes 集群网络通常基于 Linux 网桥实现。每个 Pod 的虚拟网卡 (veth 设备) 都连接到宿主机上的网桥。同一节点上的 Pod 之间通信通常通过网桥进行二层转发,而跨节点通信则需要经过宿主机的物理网卡。
无论是 iptables 还是 IPVS 模式,Kubernetes Service 都会进行 DNAT (目的网络地址转换),将访问 ClusterIP:Port 的数据包转换成 Service 的某个后端 PodIP:Port。内核会将连接信息记录到 conntrack 表中。当后端 Pod 回复数据时,内核会根据 conntrack 表进行反向 NAT,确保数据包返回到正确的客户端。
然而,当 Pod 访问位于同一节点的 Service 时,可能出现问题:
- 客户端 Pod 发送数据包到 Service ClusterIP。
- 数据包经过 DNAT,目标 IP 被修改为同节点上的后端 Pod IP。
- 后端 Pod 收到数据包并回复。此时,由于客户端 Pod 和后端 Pod 都连接到同一个网桥,回复数据包可能直接通过二层转发返回给客户端 Pod,而跳过了 Netfilter 的三层处理 (包括
conntrack表的反向查询)。 - 由于回复数据包没有经过
conntrack,客户端 Pod 接收到的数据包的源 IP 仍是后端 Pod IP,而不是 Service ClusterIP。这导致客户端认为这不是它发起的连接的回复,从而无法正常通信。
为了解决这种"同节点服务访问"问题,net.bridge.bridge-nf-call-iptables 参数必须设置为 1。当此参数启用时,Linux 网桥在进行二层转发时,也会调用 Netfilter/iptables 的三层规则,包括 conntrack 模块。这样就能确保即使是同网桥内的流量,也能被 iptables 规则和 conntrack 跟踪并正确处理。
因此,我们需要加载 br_netfilter 模块并启用此内核参数:
$ modprobe br_netfilter
$ sysctl -w net.bridge.bridge-nf-call-iptables=1
现在,再次测试从 ns-app-b 访问虚拟服务:
$ ip netns exec ns-app-b curl 192.168.10.100:8080
这次,您应该能成功收到 ns-app-a HTTP 服务返回的内容了!
开启 Hairpin(发夹)模式
虽然现在 ns-app-b 可以通过虚拟服务访问 ns-app-a,但我们还需要测试一个特殊的场景:当一个 Pod 通过 Service IP 访问它自己时(即发夹模式,Hairpin Mode)。让我们从 ns-app-a 中尝试通过虚拟服务访问 192.168.10.100:8080:
$ ip netns exec ns-app-a curl 192.168.10.100:8080
不出所料,访问再次失败。在这种情况下,数据包从 ns-app-a 发出,经过 DNAT 再次指向 ns-app-a 本身。为了让这种"自环"流量能够正常工作,我们需要为连接 Pod 的 veth 接口开启"发夹"模式。发夹模式允许数据包从一个端口进入网桥后,在不离开网桥的情况下,再次从同一个端口流出。
我们可以通过 brctl 命令为 veth-host-a 接口开启发夹模式:
$ brctl hairpin k8s-bridge veth-host-a on
再次进行测试:
$ ip netns exec ns-app-a curl 192.168.10.100:8080
仍然失败了!除了启用发夹模式,对于 IPVS 模式下的自环流量,我们还需要确保连接跟踪(conntrack)能与 IPVS 协同工作。这需要设置另一个内核参数:
$ sysctl -w net.ipv4.vs.conntrack=1
这个参数强制 IPVS 配合连接跟踪表进行数据包处理。IPVS 的负载均衡决策只针对连接的第一个数据包,后续属于同一连接的数据包必须由 conntrack 表优先处理和反转,以维持连接的完整性。在发夹模式下,如果没有 conntrack 的协同,IPVS 对回来的数据包无法正确处理,导致通信中断。
最后,再次测试:
$ ip netns exec ns-app-a curl 192.168.10.100:8080
这次,ns-app-a 成功通过虚拟服务访问了它自己的 HTTP 服务!
开启网桥混杂模式
如果集群中有大量的 Pod,并且所有 Pod 都需要支持发夹模式(即能通过 Service IP 访问自己),为每个连接到网桥的 veth 接口单独开启 hairpin 模式会非常繁琐。有一种更简便的方法:直接开启网桥的混杂模式。
在普通模式下,网卡只接收发送给本机 MAC 地址的数据包(包括广播包)。而混杂模式下,网卡会接收所有经过它的数据包,无论其目标 MAC 地址是否是本机。如果一个网桥开启了混杂模式,它会自动为所有连接到它的端口启用 hairpin 模式。这样,您只需一条命令即可实现所有 Pod 的发夹功能:
$ ip link set k8s-bridge promisc on
现在,即使您尝试关闭之前为 veth-host-a 接口手动开启的发夹模式,测试仍然能够通过:
$ brctl hairpin k8s-bridge veth-host-a off
$ ip netns exec ns-app-a curl 192.168.10.100:8080
使用 ipset 优化 MASQUERADE
在实验环境准备阶段,我们添加了一条 iptables 规则,对所有来自 172.18.0.0/24 网段的流量进行伪装:
$ iptables -t nat -A POSTROUTING -s 172.18.0.0/24 -j MASQUERADE
然而,Kubernetes 通常为了性能考虑,只会对来自特定 IP 的流量进行伪装,而不是整个子网。如果每个 Pod 都对应一条单独的 iptables 规则,在大规模集群中仍然会导致规则数量庞大,抵消 IPVS 的部分性能优势。为了更完美地模拟 Kubernetes 并解决这一问题,我们将引入 ipset 工具。
首先,删除之前宽泛的 MASQUERADE 规则:
$ iptables -t nat -D POSTROUTING -s 172.18.0.0/24 -j MASQUERADE
现在,我们使用 ipset 创建一个名为 K8S-IPVS-LOOPBACK 的集合:
$ ipset create K8S-IPVS-LOOPBACK hash:ip,port,ip
这条命令创建了一个哈希集合,其成员由三个字段组成:目的 IP、目的端口、源 IP。这三个字段对应了 hairpin 模式下需要进行 IP 伪装的流量特征。
接着,向集合中添加一个条目,表示当流量的目的 IP 是 172.18.0.101,端口是 8080,并且源 IP 也是 172.18.0.101 时(即自环流量,经过 DNAT 后回到自身),需要进行伪装:
$ ipset add K8S-IPVS-LOOPBACK 172.18.0.101,tcp:8080,172.18.0.101
现在,无论有多少个 Pod,我们都只需要一条 iptables 规则来引用这个 ipset 集合:
$ iptables -t nat -A POSTROUTING -m set --match-set K8S-IPVS-LOOPBACK dst,dst,src -j MASQUERADE
-m set:启用set匹配模块。--match-set K8S-IPVS-LOOPBACK dst,dst,src:匹配K8S-IPVS-LOOPBACK集合中的条目。这里的dst,dst,src指示 iptables 依次将数据包的"目的 IP"、"目的端口"、"源 IP"与 ipset 集合中的三元组进行匹配。
再次进行网络连通性测试,包括宿主机、ns-app-b 和 ns-app-a 内部的访问,确保一切正常:
$ curl 192.168.10.100:8080
$ ip netns exec ns-app-b curl 192.168.10.100:8080
$ ip netns exec ns-app-a curl 192.168.10.100:8080
所有测试应该都能成功通过。
新增虚拟服务的后端
最后,我们将 ns-app-b 中的 HTTP 服务也添加为虚拟服务的后端:
$ ipvsadm -a -t 192.168.10.100:8080 -r 172.18.0.102:8080 -m
同时,向 K8S-IPVS-LOOPBACK 集合中添加 ns-app-b 的自环流量条目,以确保其发夹模式下的 MASQUERADE 也能生效:
$ ipset add K8S-IPVS-LOOPBACK 172.18.0.102,tcp:8080,172.18.0.102
现在,执行多次以下测试命令:
$ curl 192.168.10.100:8080
您会发现虚拟服务根据轮询调度算法,将请求交替转发到 ns-app-a 和 ns-app-b 的 HTTP 服务。这表明 IPVS 的负载均衡功能已正常运作。