Cilium 容器数据路径

Cilium

Cilium 提供了一套基于 eBPF 的容器网络方案,在网络可观测性和隔离策略方面提供了非常多强大的特性。

我们今天主要关注 Cilium 容器数据面路径这个话题,即一个使用 Cilium 做为 CNI 的 Pod 的数据是如何流动的。

这里我们以在 Pod 中向 1.1.1.1:80发起 TCP 连接(nc -v 1.1.1.1 80)的流量为例进行分析,演示的 Cilium 集群使用 veth pair 打通容器和宿主机网络命名空间,同时使用 vxlan 模式来进行跨主机间的 Pod 通信。

这里我们使用 Pod 10.1.128.86 进行演示,其所在 Node IP 为 10.11.224.56,Cilium 集群信息如下:

KVStore:                Ok   Disabled
Kubernetes:             Ok   1.20 (v1.20.6) [linux/amd64]
KubeProxyReplacement:   Partial   [bond0 10.11.224.56 (Direct Routing)]
Host firewall:          Disabled
Cilium:                 Ok   1.11.1 (v1.11.1-76d34db)
IPAM:                   IPv4: 8/62 allocated from 10.1.128.64/26, 
BandwidthManager:       Disabled
Host Routing:           Legacy
Masquerading:           IPTables [IPv4: Enabled, IPv6: Disabled]
Controller Status:      44/44 healthy
Proxy Status:           OK, ip 10.1.128.88, 0 redirects active on ports 10000-20000
Hubble:                 Ok   Current/Max Flows: 4095/4095 (100.00%), Flows/s: 1.59   Metrics: Ok
Encryption:             Disabled

数据面路径

开始

我们在容器中敲下如下命令,尝试用 nc 工具(用 telnet 甚至是 curl 也都是可以的)发起 TCP 连接:

# -v verbose 模式,输出更多日志
nc -v 1.1.1.1 80

第一步容器网络空间拿到包之后会进行路由判断,我们可以进入到容器网络空间,查看容器路由配置,虽然我们可以通过 kubectl/docker exec 打开 shell 进入容器,但是容器 rootfs 上配套的相应工具通常不是那么的齐全,因此我们可以在宿主机上切换到容器网络空间来操作:

# 获取容器 pid ,注意替换容器名称

docker inspect --format "{{.State.Pid}}" $(docker ps |grep ${POD_NAME} | grep -v pause | awk '{print $1}')

# 拿到容器 pid 后即可通过 nsenter 命令在该空间下创建进程执行命令
# -t 指定要进去哪个 pid 对应进程的命名空间
# -n 进入网络命令空间
# -u 进入主机名命名空间(让 shell 提示符更直观的跟宿主机的区分开来)

nsenter -t $NAMESPACE_ID -n -u bash

容器路由

nsenter 进入容器网络空间之后,我们就可以直接查看容器配置了。

我们先查看一下路由策略:

[root@test-demo-64b675f4c5-2njgn ~]# ip rule list
0:	from all lookup local
32766:	from all lookup main
32767:	from all lookup default

只有三张默认的路由表策略在,并没有额外配置,默认会使用 main 这张路由表,我们打开看看:

[root@test-demo-64b675f4c5-2njgn ~]# ip route show table main
default via 10.1.128.88 dev eth0 mtu 1450
10.1.128.88 dev eth0 scope link
[root@test-demo-64b675f4c5-2njgn ~]#

可以看到路由表配置非常简单,该容器下所有的包通通都会经由 eth0 设备发往 IP 10.1.128.88

ARP

知道了网络层下一步的目的地是10.1.128.88 之后,操作系统还需要知道数据链路层这个包需要发往哪里,也就是 ARP 地址解析过程,我们可以使用 tcpdump 观察具体的报文:

# 先清空一下容器内的 ARP 缓存
[root@test-demo-64b675f4c5-2njgn ~]# ip -s -s neigh flush all
# 抓包,开始抓包之后另起一个 shell 进行请求:nc -v 1.1.1.1 80
[root@test-demo-64b675f4c5-2njgn ~]# tcpdump -n -vvv -i eth0
tcpdump: listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
17:30:45.022443 ARP, Ethernet (len 6), IPv4 (len 4), Request who-has 10.1.128.88 tell 10.1.128.86, length 28
17:30:45.022475 ARP, Ethernet (len 6), IPv4 (len 4), Reply 10.1.128.88 is-at 4e:ef:52:ad:3a:e4, length 28

可以观察到容器端收到了 ARP 响应:IP 10.1.128.88 对应的 MAC 地址为 4e:ef:52:ad:3a:e4,那么这个 ARP 响应又是哪儿来的呢?

veth pair

ARP包会广播发送给所有的设备,我们查看一下设备列表:

# -c 彩色输出
[root@test-demo-64b675f4c5-2njgn ~]# ip -c link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: tunl0@NONE: <NOARP> mtu 1480 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ipip 0.0.0.0 brd 0.0.0.0
134: eth0@if135: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
    link/ether 0a:58:d3:2e:06:2a brd ff:ff:ff:ff:ff:ff link-netnsid 0

ip link 命令会输出该网络空间下所有的网络设备,每个设备的开头是设备的 ID,专业名词叫:ifindex,然后是设备名紧接着一个@后面跟着的是 veth pair 的对端 ifindex,如果不是 veth 类型就不会有。

veth 类型的设备是两个一组,像一条隧道一样,一个设备收到的所有网络包都会立即被转发给另一个设备。

可以看到这里有一个特殊的设备 eth0,它对端设备的 ifindex 是 135,很明显这就是宿主机和容器网络联通的桥梁,我们可以切换到宿主机空间下看一下这个对端设备:

[root@host-machine ~]# ip -c link |grep -A 1 135
135: lxce37d234f154c@if134: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
    link/ether 4e:ef:52:ad:3a:e4 brd ff:ff:ff:ff:ff:ff link-netnsid 6

容器中的 eth0 设备收到 ARP 广播包之后就会转发给宿主机上的设备 lxce37d234f154c

根据 Cilium 的源码我们可以知道, lxce37d234f154c 这个设备的 tc ingress点上有一段 eBPF 逻辑,会处理 ARP 的包响应。

eBPF ARP 应答

我们可以通过 tc 命令查看上面提到的 eBPF 挂载逻辑:

# tc egress 上没有挂载
[root@host-machine ~]# tc filter show dev lxce37d234f154c egress
[root@host-machine ~]# tc filter show dev lxce37d234f154c ingress
filter protocol all pref 1 bpf chain 0
filter protocol all pref 1 bpf chain 0 handle 0x1 bpf_lxc.o:[from-container] direct-action not_in_hw tag 22adca600f7d1c7c
[root@host-machine ~]#

可以看到 egress 点上并没有挂载代码,而 ingress 上则挂载了一段逻辑,section 为from-container,源码位于 bpf_lxc.c 文件中:源码地址,其中跟 ARP 应答相关的部分逻辑如下:

__section("from-container")
int handle_xgress(struct __ctx_buff *ctx)
{
	switch (proto) {
	// 判断协议为 ARP
  case bpf_htons(ETH_P_ARP):
		union macaddr mac = NODE_MAC;
	  union macaddr smac;
	  __be32 sip;
	  __be32 tip;
    // 校验并填充 smac 为当前设备的物理地址
	  if (!arp_validate(ctx, &mac, &smac, &sip, &tip))
		  return CTX_ACT_OK;

	  if (tip == LXC_IPV4)
		  return CTX_ACT_OK;

     int ret = arp_prepare_response(ctx, smac, sip, dmac, tip);
     if (unlikely(ret != 0))
		   goto error;
	    
	   ctx_redirect(ctx, ctx_get_ifindex(ctx), direction);
     ret = DROP_MISSED_TAIL_CALL;
	}
}

判断出这是一个来自容器的 ARP 协议包之后,Cilium 会在代码中构建出一个 ARP 响应,将当前设备的 MAC 地址设置为结果,然后通过内核提供的 eBPF 辅助函数 bpf_redirect跳过后续的路径。

eBPF 网络策略

接收到 ARP 响应之后,容器端发出来的包就能正常被宿主机中的网卡 lxce37d234f154c 正确接收了。

然后容器端就可以正常构造 TCP 包发出来了,TCP 包会根据路由走到容器 eth0 设备,然后出现在 veth pair 在宿主机的那一端 lxce37d234f154c,此时还是会经过其 tc ingress 的 eBPF 挂载点,除了我们上文中提到的 ARP 响应的逻辑,这里还有一段比较重要的逻辑:网络策略,如果我们配置了可能会限制该 Pod 对于 1.1.1.1:80的访问的话,那么发出的网络包就会在这段逻辑中被丢弃。

NAT

通过网络策略之后,网络包就会按普通的流程流经内核网络栈,经过各种 hook 然后来到路由决策阶段,可以看到我们这个发往公网的包会经过机器初始化时配置的默认网关经由 bond0 网卡发出:

[root@host-machine ~]# ip -c route show table main
# 默认网关
default via 10.11.224.254 dev bond0
# vxlan模式不能把包直接发到默认网卡,所以需要单独路由处理
10.1.128.0/26 via 10.1.128.88 dev cilium_host src 10.1.128.88 mtu 1450
10.1.128.64/26 via 10.1.128.88 dev cilium_host src 10.1.128.88
10.1.128.88 dev cilium_host scope link
10.1.128.128/26 via 10.1.128.88 dev cilium_host src 10.1.128.88 mtu 1450
10.1.128.192/26 via 10.1.128.88 dev cilium_host src 10.1.128.88 mtu 1450
10.1.129.0/26 via 10.1.128.88 dev cilium_host src 10.1.128.88 mtu 1450
10.1.129.64/26 via 10.1.128.88 dev cilium_host src 10.1.128.88 mtu 1450
10.11.224.0/24 dev bond0 proto kernel scope link src 10.11.224.56
169.254.0.0/16 dev bond0 scope link metric 1004

物理网卡上也挂载了一些 tc eBPF 代码,不过主要是为 NodePort、HostFirewall 等其他功能服务,和我们的当前的包场景关系不大。

另外这里由于我们采用的是 overlay 网络模式,所以容器的 IP 地址外界是不认的,要经过 NAT 转换才行。

Cilium 支持基于 eBPF 实现的 NAT 功能,但是要求内核版本 5.10 及以上,我们当前系统不满足,所以还是基于 iptables 来实现的。

我们可以通过iptables-save命令查看当前所有的 iptables 规则,然后 grep 当前节点的 CIDR:

[root@host-machine ~]# iptables-save |grep -i masquerade |grep '10.1.128.64/26'
-A CILIUM_POST_nat -s 10.1.128.64/26 ! -d 10.1.128.64/26 ! -o cilium_+ -m comment --comment "cilium masquerade non-cluster" -j MASQUERADE
-A CILIUM_POST_nat ! -s 10.1.128.64/26 ! -d 10.1.128.64/26 -o cilium_host -m comment --comment "cilium host->cluster masquerade" -j SNAT --to-source 10.1.128.88

可以看到有这么两条规则会对从容器出来的源 IP 为 10.1.128.64/26 网段的包进行MASQUERADE即 NAT。

由于这些包是发往公网,而不是发往其他网段,所以也不涉及 vxlan 隧道的逻辑,接下来这些网络包就会继续走到物理网卡出主机,然后一路经过各个路由器最终到达 1.1.1.1 的服务器了。