IPVS模式下ClusterIP泄露宿主机端口的问题
在一个启用了IPVS模式kube-proxy的K8S集群中,运行着一个Docker Registry服务。我们尝试通过docker manifest命令(带上--insecure参数)来推送manifest时,出现TLS timeout错误。
这个Registry通过ClusterIP类型的Service暴露访问端点,且仅仅配置了HTTP/80端口。docker manifest命令的--insecure参数的含义是,在Registry不支持HTTPS的情况下,允许使用不安全的HTTP协议通信。从报错上来看,很明显docker manifest认为Registry支持HTTPS协议。
在宿主机上尝试 telnet RegistryClusterIP 443,居然可以连通。检查后发现节点上使用443端口的,只有Ingress Controller的NodePort类型的Service,它在0.0.0.0上监听。删除此NodePort服务后,RegistryClusterIP:443就不通了,docker manifest命令恢复正常。
如果kube-proxy启用了IPVS模式,并且宿主机在0.0.0.0:NonServicePort上监听,那么可以在宿主机上、或者Pod内,通过任意ClusterIP:NonServicePort访问到宿主机的NonServicePort。
这一行为显然不符合预期,我们期望仅仅在Service对象中声明的端口,才可能通过Cluster连通。如果ClusterIP上的未知端口,内核应该丢弃报文或者返回适当的ICMP。
如果kube-proxy使用iptables模式,不会出现这种异常行为。
启用IPVS的情况下,所有ClusterIP都会绑定在kube-ipvs0这个虚拟的网络接口上。例如对于kube-dns服务的ClusterIP 10.96.0.10(ServicePort为TCP 53 / TCP 9153):
1 2 3 4 |
5: kube-ipvs0: <BROADCAST,NOARP> mtu 1500 qdisc noop state DOWN group default link/ether fa:d9:9e:37:12:68 brd ff:ff:ff:ff:ff:ff inet 10.96.0.10/32 brd 10.96.0.10 scope global kube-ipvs0 valid_lft forever preferred_lft forever |
这种绑定是必须的,因为IPVS的工作原理是,在netfilter挂载点LOCAL_IN上注册钩子ip_vs_in,拦截目的地是VIP(ClusterIP)的封包。而要使得封包进入到LOCAL_IN,它的目的地址必须是本机地址。
每当为网络接口添加一个IP地址,内核都会自动在local路由表中增加一条规则,对于上面的10.96.0.10,会增加:
1 2 |
# 对于目的地址是10.96.0.10的封包,从kube-ipvs0发出,如果没有指定源IP,使用10.96.0.10 local 10.96.0.10 dev kube-ipvs0 proto kernel scope host src 10.96.0.10 |
上述自动添加路由的一个副作用是,对于任意一个端口Port,如果不存在匹配ClusterIP:Port的IPVS规则,同时宿主机上某个应用在0.0.0.0:Port上监听,封包就会交由此应用处理。
在宿主机上执行 telnet 10.96.0.10 22,会发生以下事件序列:
- 出站选路,根据local表路由规则,从kube-ipvs0接口发出封包
- 由于kube-ipvs0是dummy的,封包立刻从kube-ipvs0的出站队列移动到入站队列
- 目的地址是本地地址,因此进入LOCAL_IN挂载点
- 由于22不是ServicePort,封包被转发给本地进程处理,即监听了22的那个进程
如果删除内核自动在local表中添加的路由:
1 |
ip route del table local local 10.96.0.10 dev kube-ipvs0 proto kernel scope host src 10.96.0.10 |
则会出现以下现象:
- 无法访问10.96.0.10:22。这是我们期望的,因为10.96.0.10这个服务没有暴露22端口,此端口理当不通
- 无法ping 10.96.0.10。这不是我们期望的,但是一般情况下不会有什么问题。iptables模式下ClusterIP就是无法ping的,IPVS模式下可以在本机ping仅仅是绑定ClusterIP到kube-ipvs0的一个副作用。通常应用程序不应该对ClusterIP做ICMP检测,来判断服务是否可用,因为这依赖了kube-proxy的特定工作模式
- 在宿主机上,可以访问10.96.0.10:53。这是我们期望的,宿主机上可以访问ClusterIP
- 在某个容器的网络命名空间下,无法访问10.96.0.10:53。这不是我们期望的,相当于Pod无法访问ClusterIP了
以上4条,惟独3难以理解。为什么路由没了,宿主机仍然能访问ClusterIP:ServicePort?这个我们还没有从源码级别深究,但是很明显和IPVS有关。IPVS在LOCAL_OUT上挂有钩子,它可能在此钩子中检测到来自本机(主网络命名空间)的、访问ClusterIP+ServicePort(即IPVS虚拟服务)的封包,并进行了某种“魔法”处理,从而避开了没有路由的问题。
下面我们进一步验证上述“魔法”处理的可能性。使用 tcpdump -i any host 10.96.0.10来捕获流量,从容器命名空间访问ClusterIP:ServicePort时,可以看到:
1 2 |
# 容器IP 11:32:00.448470 IP 172.27.0.24.56378 > 10.96.0.10.53: Flags [S], seq 2946888109, win 28200, options... |
但是从宿主机访问ClusterIP:ServicePort时,则捕获不到任何流量。但是,通过iptables logging,我们可以确定,内核的确以ClusterIP为源地址和目的地址,发起了封包:
1 2 3 4 5 |
iptables -t mangle -I OUTPUT 1 -p tcp --dport 53 -j LOG --log-prefix 'out-d53: ' # dmesg -w # 源地址 目的地址 # [3374381.426541] out-d53: IN= OUT=lo SRC=10.96.0.100 DST=10.96.0.100 LEN=52 TOS=0x10 PREC=0x00 TTL=64 ID=18885 DF PROTO=TCP SPT=42442 DPT=53 WINDOW=86 RES=0x00 ACK URGP=0 |
回顾一下数据报出站、入站的处理过程:
- 出站,依次经过 netfilter/iptables ⇨ tcpdump ⇨ 网络接口 ⇨网线
- 入站,依次经过 网线 ⇨ 网络接口 ⇨ tcpdump ⇨ netfilter/iptables
只有当IPVS在宿主机请求10.96.0.10的封包出站时,在netfilter中对匹配IPVS虚拟服务的封包进行如下处理,才能解释iptables中能看到10.96.0.10,而紧随其后的tcpdump中却又看不到的现象:
- 修改目的地址为Service的Endpoint地址,这就是NAT模式的IPVS(即kube-proxy使用NAT模式)应有的行为
- 修改了源地址为当前宿主机的地址,不这样做,回程报文就无法路由回来
另外注意一下,如果从宿主机访问ClusterIP:NonServicePort,则tcpdump能捕获到源或目的地址为ClusterIP的流量。这是因为IPVS发现它不匹配任何虚拟服务,会直接返回NF_ACCEPT,然后封包就按照常规流程处理了。
如果宿主机上有一个在0.0.0.0上监听的、存在安全漏洞的服务,则可能被恶意的工作负载利用。
少部分的应用程序,例如docker manifest,其行为取决于端口探测的结果,会无法正常工作。
可能的解决方案有:
- 在iptables中匹配哪些针对ClusterIP:NonServicePort的流量,Drop或Reject掉
- 使用基于fwmark的IPVS虚拟服务,这需要在iptables中对针对ClusterIP:ServicePort的流量打fwmark,而且每个ClusterIP都需要占用独立的fwmark,难以管理
对于解决方案1,可以使用如下iptables规则:
1 2 |
# 如果目的地址是ClusterIP 但是目的端口不是ServicePort 则拒绝 iptables -A INPUT -d 10.96.0.0/12 -m set ! --match-set KUBE-CLUSTER-IP dst,dst -j REJECT |
这个规则能够为容器解决宿主机端口泄露的问题,但是会导致宿主机上无法访问ClusterIP。
引起此问题的原因是,在宿主机访问ClusterIP时,会同时使用ClusterIP作为源地址/目的地址。这样,来自Endpoint的回程报文,unNATed后的目的地址,就会匹配到上面的iptables规则,从而导致封包被Reject掉。
要解决此问题,我们可以修改内核自动添加的路由,提示使用其它地址作为源地址:
1 2 |
# 这条路由给出src提示,当访问10.96.0.10时,选取192.168.104.82(节点IP)作为源地址 ip route replace table local local 10.96.0.10 dev kube-ipvs0 proto kernel scope host src 192.168.104.82 |
上文我们提到了一个“魔法”处理的猜想,这里我们对IPVS的实现细节进行深入学习,证实此猜想。
本节牵涉到的内核源码均来自linux-3.10.y分支。
这是从2.4.x引入内核的一个框架,用于实现防火墙、NAT、封包修改、记录封包日志、用户空间封包排队之类的功能。
netfilter运行在内核中,允许内核模块在Linux网络栈的不同位置注册钩子(回调函数),当每个封包穿过网络栈时,这些钩子函数会被调用。
iptables是经典的,基于netfilter的用户空间工具。它的继任者是nftables,它更加灵活、可扩容、性能好。
netfilter提供了5套钩子(的挂载点):
挂载点 | 说明 | ||
NF_IP_PER_ROUTING |
当封包进入网络栈时调用。封包的目的地可能是本机,或者需要转发 ip_rcv / ipv6_rcv是内核接受并处理IP数据报的入口,此函数会调用这类钩子:
|
||
NF_IP_LOCAL_IN |
当路由判断封包应该由本机处理时(目的地址是本机地址)调用 ip_local_deliver / ip6_input负责将IP数据报向上层传递,此函数会调用这类钩子 |
||
NF_IP_FORWARD |
当路由判断封包应该被转发给其它机器(或者网络命名空间)时调用 ip_forward / ip6_forward负责封包转发,此函数会调用这类钩子 |
||
NF_IP_POST_ROUTING |
在封包即将离开网络栈(进入网线)时调用,不管是转发的、还是本机发出的,都需要经过此挂载点 ip_output / ip6_finish_output2会调用这类钩子 |
||
NF_IP_LOCAL_OUT |
当封包由本机产生,需要往外发送时调用 __ip_local_out / __ip6_local_out会调用这类钩子 |
这些挂载点,和iptables的各链是对应的。
要在内核中使用netfilter的钩子,你需要调用函数:
1 2 3 4 |
// 注册钩子 int nf_register_hook(struct nf_hook_ops *reg){} // 反注册钩子 void nf_unregister_hook(struct nf_hook_ops *reg){} |
入参nf_hook_ops是一个结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
struct nf_hook_ops { // 钩子的函数指针,依据内核的版本不同此函数的签名有所差异 nf_hookfn *hook; struct net_device *dev; void *priv; // 钩子针对的协议族,PF_INET表示IPv4 u_int8_t pf; // 钩子类型代码,参考上面的表格 unsigned int hooknum; // 每种类型的钩子,都可以有多个,此数字决定执行优先级 int priority; }; // 钩子函数的签名 typedef unsigned int nf_hookfn(unsigned int hooknum, struct sk_buff *skb, // 正被处理的数据报 const struct net_device *in, // 输入设备 const struct net_device *out, // 是出设备 int (*okfn)(struct sk_buff *)); // 如果通过钩子检查,则调用此函数,通常用不到 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
/* Responses from hook functions. */ // 丢弃该报文,不再继续传输或处理 #define NF_DROP 0 // 继续正常传输报文,如果后面由低优先级的钩子,仍然会调用它们 #define NF_ACCEPT 1 // 告知netfilter,报文被别人偷走处理了,不需要再对它做任何处理 // 下文的分析中,我们有个例子。一个netfilter钩子在内部触发了对netfilter钩子的调用 // 外层钩子返回的就是NF_STOLEN,相当于将封包的控制器转交给内层钩子了 #define NF_STOLEN 2 // 对该数据报进行排队,通常用于将数据报提交给用户空间进程处理 #define NF_QUEUE 3 // 再次调用该钩子函数 #define NF_REPEAT 4 // 继续正常传输报文,不会调用此挂载点的后续钩子 #define NF_STOP 5 #define NF_MAX_VERDICT NF_STOP |
优先级通常以下面的枚举为基准+/-:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
enum nf_ip_hook_priorities { // 数值越小,优先级越高,越先执行 NF_IP_PRI_FIRST = INT_MIN, NF_IP_PRI_CONNTRACK_DEFRAG = -400, // 可以看到iptables各表注册的钩子的优先级 NF_IP_PRI_RAW = -300, NF_IP_PRI_SELINUX_FIRST = -225, NF_IP_PRI_CONNTRACK = -200, NF_IP_PRI_MANGLE = -150, NF_IP_PRI_NAT_DST = -100, NF_IP_PRI_FILTER = 0, NF_IP_PRI_SECURITY = 50, NF_IP_PRI_NAT_SRC = 100, NF_IP_PRI_SELINUX_LAST = 225, NF_IP_PRI_CONNTRACK_HELPER = 300, NF_IP_PRI_CONNTRACK_CONFIRM = INT_MAX, NF_IP_PRI_LAST = INT_MAX, }; |
ip_vs模块初始化时,会通过ip_vs_init函数,调用nf_register_hook,注册以下netfilter钩子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
static struct nf_hook_ops ip_vs_ops[] __read_mostly = { // 注册到LOCAL_IN,这两个钩子处理外部客户端的报文 // 转而调用ip_vs_out,用于NAT模式下,处理LVS回复外部客户端的报文,例如修改IP地址 { .hook = ip_vs_reply4, .owner = THIS_MODULE, .pf = NFPROTO_IPV4, .hooknum = NF_INET_LOCAL_IN, .priority = NF_IP_PRI_NAT_SRC - 2, }, // 转而调用ip_vs_in,用于处理外部客户端进入IPVS的请求报文 // 如果没有对应请求报文的连接,则使用调度函数创建连接结构,这其中牵涉选择RS负载均衡算法 { .hook = ip_vs_remote_request4, .owner = THIS_MODULE, .pf = NFPROTO_IPV4, .hooknum = NF_INET_LOCAL_IN, .priority = NF_IP_PRI_NAT_SRC - 1, }, // 注册到LOCAL_OUT,这两个钩子处理LVS本机的报文 // 转而调用ip_vs_out,用于NAT模式下,处理LVS回复客户端的报文 { .hook = ip_vs_local_reply4, .owner = THIS_MODULE, .pf = NFPROTO_IPV4, .hooknum = NF_INET_LOCAL_OUT, .priority = NF_IP_PRI_NAT_DST + 1, }, // 转而调用ip_vs_in,调度并转发(给RS)本机的请求 { .hook = ip_vs_local_request4, .owner = THIS_MODULE, .pf = NFPROTO_IPV4, .hooknum = NF_INET_LOCAL_OUT, .priority = NF_IP_PRI_NAT_DST + 2, }, // 这两个函数注册到FORWARD // 转而调用ip_vs_in_icmp,用于处理外部客户端发到IPVS的ICMP报文,并转发到RS { .hook = ip_vs_forward_icmp, .owner = THIS_MODULE, .pf = NFPROTO_IPV4, .hooknum = NF_INET_FORWARD, .priority = 99, }, // 转而调用ip_vs_out,用于NAT模式下,修改RS给的应答报文的源地址为IPVS虚拟地址 { .hook = ip_vs_reply4, .owner = THIS_MODULE, .pf = NFPROTO_IPV4, .hooknum = NF_INET_FORWARD, .priority = 100, } }; |
从上面的钩子我们可以看到:
- 针对外部发起的、本机发起的,对IPVS的请求(目的是VIP的SYN),钩子的位置是不一样的:
- 对于外部的请求,在LOCAL_IN中处理,钩子函数为ip_vs_remote_request4
- 对于本机的请求,在LOCAL_OUT中处理,钩子函数为ip_vs_local_request4
- 尽管钩子的位置不同,但是函数ip_vs_remote_request4、ip_vs_local_request4都是调用ip_vs_in。实际上,这两个函数的逻辑完全一样:
123456789101112131415161718192021222324/** AF_INET handler in NF_INET_LOCAL_IN chain* Schedule and forward packets from remote clients*/static unsigned intip_vs_remote_request4(unsigned int hooknum, struct sk_buff *skb,const struct net_device *in,const struct net_device *out,int (*okfn)(struct sk_buff *)){return ip_vs_in(hooknum, skb, AF_INET);}/** AF_INET handler in NF_INET_LOCAL_OUT chain* Schedule and forward packets from local clients*/static unsigned intip_vs_local_request4(unsigned int hooknum, struct sk_buff *skb,const struct net_device *in, const struct net_device *out,int (*okfn)(struct sk_buff *)){return ip_vs_in(hooknum, skb, AF_INET);}
回顾一下上文我们关于“魔法”处理的疑惑。对于从宿主机发起对10.96.0.10:53的请求,我们通过iptables logging证实了使用的源IP地址是10.96.0.10:
- 这个请求为什么tcpdump捕获不到?
- 为什么删除路由不影响宿主机对ClusterIP的请求(却又导致容器无法请求ClusterIP)?
这两个问题的答案,很可能就隐藏在ip_vs_in函数中,因为它是处理进入IPVS的数据报的统一入口。如果该函数同时修改了原始封包的源/目的地址,就解释了问题1;如果该函数在内部进行了选路操作,则解释了问题2。
下面分析一下ip_vs_in的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
static unsigned int ip_vs_in(unsigned int hooknum, struct sk_buff *skb, int af) { // 网络命名空间 struct net *net; // IPVS的IP头,其中存有3层头len、协议、标记、源/目的地址 struct ip_vs_iphdr iph; // 持有协议(TCP/UDP/SCTP/AH/ESP)信息,更重要的是带着很多函数指针。这些指针负责针对特定协议的IPVS逻辑 struct ip_vs_protocol *pp; // 每个命名空间一个此对象,包含统计计数器、超时表 struct ip_vs_proto_data *pd; // 当前封包所属的IPVS连接对象,此对象最重要的是packet_xmit函数指针。它负责将封包发走 struct ip_vs_conn *cp; int ret, pkts; // 描述当前网络命名空间的IPVS状态 struct netns_ipvs *ipvs; // 如果封包已经被标记为IPVS请求/应答,不做处理,继续netfilter常规流程 // 后续ip_vs_nat_xmit会让封包“重入”netfilter,那时封包已经打上IPVS标记 // 这里的判断确保重入的封包走netfilter常规流程,而不是进入死循环 if (skb->ipvs_property) return NF_ACCEPT; // 如果封包目的地不是本机且当前不在LOCAL_OUT // 或者封包的dst_entry不存在,不做处理,继续netfilter常规流程 if (unlikely((skb->pkt_type != PACKET_HOST && hooknum != NF_INET_LOCAL_OUT) || !skb_dst(skb))) { ip_vs_fill_iph_skb(af, skb, &iph); IP_VS_DBG_BUF(12, "packet type=%d proto=%d daddr=%s" " ignored in hook %u\n", skb->pkt_type, iph.protocol, IP_VS_DBG_ADDR(af, &iph.daddr), hooknum); return NF_ACCEPT; } // 如果当前IPVS主机是backup,或者当前命名空间没有启用IPVS,不做处理,继续netfilter常规流程 net = skb_net(skb); ipvs = net_ipvs(net); if (unlikely(sysctl_backup_only(ipvs) || !ipvs->enable)) return NF_ACCEPT; // 使用封包的IP头填充IPVS的IP头 ip_vs_fill_iph_skb(af, skb, &iph); // 如果是RAW套接字,不做处理,继续netfilter常规流程 if (unlikely(skb->sk != NULL && hooknum == NF_INET_LOCAL_OUT && af == AF_INET)) { struct sock *sk = skb->sk; struct inet_sock *inet = inet_sk(skb->sk); if (inet && sk->sk_family == PF_INET && inet->nodefrag) return NF_ACCEPT; } // 处理ICMP报文,和我们的场景无关 if (unlikely(iph.protocol == IPPROTO_ICMP)) { int related; int verdict = ip_vs_in_icmp(skb, &related, hooknum); if (related) return verdict; } // 如果协议不受IPVS支持,不做处理,继续netfilter常规流程 pd = ip_vs_proto_data_get(net, iph.protocol); if (unlikely(!pd)) return NF_ACCEPT; // 协议被支持,得到pp pp = pd->pp; // 尝试获取封包所属的IPVS连接对象 cp = pp->conn_in_get(af, skb, &iph, 0); // 如果封包属于既有IPVS连接,且此连接的RS(dest)已经设置,且RS的权重为0 // 认为是无效连接,设为过期 if (unlikely(sysctl_expire_nodest_conn(ipvs)) && cp && cp->dest && unlikely(!atomic_read(&cp->dest->weight)) && !iph.fragoffs && is_new_conn(skb, &iph)) { ip_vs_conn_expire_now(cp); __ip_vs_conn_put(cp); cp = NULL; } // 调度一个新的IPVS连接,这里牵涉到RS的LB算法 if (unlikely(!cp) && !iph.fragoffs) { int v; if (!pp->conn_schedule(af, skb, pd, &v, &cp, &iph)) // 如果返回0,通常v是NF_DROP,这以为这调度失败,封包丢弃 return v; } if (unlikely(!cp)) { IP_VS_DBG_PKT(12, af, pp, skb, 0, "ip_vs_in: packet continues traversal as normal"); if (iph.fragoffs) { IP_VS_DBG_RL("Unhandled frag, load nf_defrag_ipv6\n"); IP_VS_DBG_PKT(7, af, pp, skb, 0, "unhandled fragment"); } return NF_ACCEPT; } // 入站封包 —— 在我们的场景中,这是本地客户端入了IPVS系统的封包 // 从网络栈的角度来说,我们正在处理的是出站封包... IP_VS_DBG_PKT(11, af, pp, skb, 0, "Incoming packet"); // IPVS连接的RS不可用 if (cp->dest && !(cp->dest->flags & IP_VS_DEST_F_AVAILABLE)) { // 立即将连接设为过期 if (sysctl_expire_nodest_conn(ipvs)) { ip_vs_conn_expire_now(cp); } // 丢弃封包 __ip_vs_conn_put(cp); return NF_DROP; } // 更新计数器 ip_vs_in_stats(cp, skb); // 更新IPVS连接状态机,做的事情包括 // 根据数据包 tcp 标记字段来更新当前状态机 // 更新连接对应的统计数据,包括:活跃连接和非活跃连接 // 根据连接状态,设置超时时间 ip_vs_set_state(cp, IP_VS_DIR_INPUT, skb, pd); if (cp->packet_xmit) // 调用packet_xmit将封包发走,实际上是重入netfilter的LOCAL_OUT,封包控制权转移走,后续不该再操控skb ret = cp->packet_xmit(skb, cp, pp, &iph); else { IP_VS_DBG_RL("warning: packet_xmit is null"); ret = NF_ACCEPT; } if (cp->flags & IP_VS_CONN_F_ONE_PACKET) pkts = sysctl_sync_threshold(ipvs); else pkts = atomic_add_return(1, &cp->in_pkts); if (ipvs->sync_state & IP_VS_STATE_MASTER) ip_vs_sync_conn(net, cp, pkts); // 放回连接对象,重置连接定时器 ip_vs_conn_put(cp); return ret; } |
上面这段代码中,“魔法”处理最可能发生在:
- conn_schedule:在这里需要进行IPVS连接的调度
- packet_xmit:在这里发送经过IPVS处理的封包
二者都是函数指针,在TCP协议下,conn_schedule指向tcp_conn_schedule。在NAT模式下,packet_xmit指向ip_vs_nat_xmit。packet_xmit指针是在conn_schedule过程中初始化的。
我们看一下TCP协议下IPVS连接的调度过程。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
static int tcp_conn_schedule(int af, struct sk_buff *skb, struct ip_vs_proto_data *pd, int *verdict, struct ip_vs_conn **cpp, struct ip_vs_iphdr *iph) { // 网络命名空间 struct net *net; // IPVS虚拟服务对象 struct ip_vs_service *svc; struct tcphdr _tcph, *th; // 解析L4头,如果失败,提示ip_vs_in丢弃封包 th = skb_header_pointer(skb, iph->len, sizeof(_tcph), &_tcph); if (th == NULL) { *verdict = NF_DROP; return 0; } net = skb_net(skb); rcu_read_lock(); if (th->syn && // 根据封包特征,去查找匹配的虚拟服务 (svc = ip_vs_service_find(net, af, skb->mark, iph->protocol, &iph->daddr, th->dest))) { int ignored; // 如果当前网络命名空间“过载”了,丢弃封包 if (ip_vs_todrop(net_ipvs(net))) { rcu_read_unlock(); *verdict = NF_DROP; return 0; } // 选择一个RS,建立IPVS连接 // 如果找不到RS,或者发生致命错误,则ignore为0或-1,这种情况下 // IPVS连接没有成功创建,提示ip_vs_in丢弃封包,可能附带回复ICMP *cpp = ip_vs_schedule(svc, skb, pd, &ignored, iph); if (!*cpp && ignored <= 0) { if (!ignored) // ignored=0,找不到RS *verdict = ip_vs_leave(svc, skb, pd, iph); else *verdict = NF_DROP; rcu_read_unlock(); return 0; } } rcu_read_unlock(); // 如果调度成功,IPVS连接对象不为空,返回1 /* NF_ACCEPT */ return 1; } |
到这里我们还没有看到IPVS对封包地址进行更改,需要进一步阅读ip_vs_schedule。
这是IPVS调度的核心函数,它支持TCP/UDP,它为虚拟服务选择一个RS,创建IPVS连接对象。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
struct ip_vs_conn * ip_vs_schedule(struct ip_vs_service *svc, struct sk_buff *skb, struct ip_vs_proto_data *pd, int *ignored, struct ip_vs_iphdr *iph) { struct ip_vs_protocol *pp = pd->pp; // IPVS连接对象(connection entry) struct ip_vs_conn *cp = NULL; struct ip_vs_scheduler *sched; struct ip_vs_dest *dest; __be16 _ports[2], *pptr; unsigned int flags; // ... *ignored = 0; /* * Non-persistent service */ // 调度工作委托给虚拟服务的scheduler sched = rcu_dereference(svc->scheduler); // 调度器就是选择一个RS(ip_vs_dest) dest = sched->schedule(svc, skb); if (dest == NULL) { IP_VS_DBG(1, "Schedule: no dest found.\n"); return NULL; } flags = (svc->flags & IP_VS_SVC_F_ONEPACKET && iph->protocol == IPPROTO_UDP) ? IP_VS_CONN_F_ONE_PACKET : 0; // 初始化IPVS连接对象 ip_vs_conn { struct ip_vs_conn_param p; ip_vs_conn_fill_param(svc->net, svc->af, iph->protocol, &iph->saddr, pptr[0], &iph->daddr, pptr[1], &p); // 操控ip_vs_conn的逻辑包括: // 初始化定时器 // 设置网络命名空间 // 设置地址、fwmark、端口 // 根据IP版本、IPVS模式(NAT/DR/TUN)为连接设置一个packet_xmit cp = ip_vs_conn_new(&p, &dest->addr, dest->port ? dest->port : pptr[1], flags, dest, skb->mark); if (!cp) { *ignored = -1; return NULL; } } // ... return cp; } |
到这里我们可以看到, conn_schedule仍然没有对封包做任何修改。看来关键在packet_xmit函数中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
int ip_vs_nat_xmit(struct sk_buff *skb, struct ip_vs_conn *cp, struct ip_vs_protocol *pp, struct ip_vs_iphdr *ipvsh) { // 路由表项 struct rtable *rt; /* Route to the other host */ // 是否本机 是否输入路由 int local, rc, was_input; EnterFunction(10); rcu_read_lock(); // 是否尚未设置客户端端口 if (unlikely(cp->flags & IP_VS_CONN_F_NO_CPORT)) { __be16 _pt, *p; p = skb_header_pointer(skb, ipvsh->len, sizeof(_pt), &_pt); if (p == NULL) goto tx_error; // 设置IPVS连接对象的cport // caddr cport 客户端地址 // vaddr vport 虚拟服务地址 // daddr dport RS地址 ip_vs_conn_fill_cport(cp, *p); IP_VS_DBG(10, "filled cport=%d\n", ntohs(*p)); } was_input = rt_is_input_route(skb_rtable(skb)); // 出口路由查找,依据是封包、RS的地址、以及若干标识位 // 返回值提示路由目的地是否是本机 local = __ip_vs_get_out_rt(skb, cp->dest, cp->daddr.ip, IP_VS_RT_MODE_LOCAL | IP_VS_RT_MODE_NON_LOCAL | IP_VS_RT_MODE_RDR, NULL); if (local < 0) goto tx_error; rt = skb_rtable(skb); // 如果目的地是本机,RS地址是环回地址,是输入 if (local && ipv4_is_loopback(cp->daddr.ip) && was_input) { IP_VS_DBG_RL_PKT(1, AF_INET, pp, skb, 0, "ip_vs_nat_xmit(): " "stopping DNAT to loopback address"); goto tx_error; } // 封包将被修改,执行copy-on-write if (!skb_make_writable(skb, sizeof(struct iphdr))) goto tx_error; if (skb_cow(skb, rt->dst.dev->hard_header_len)) goto tx_error; // 修改封包,dnat_handler指向tcp_dnat_handler if (pp->dnat_handler && !pp->dnat_handler(skb, pp, cp, ipvsh)) goto tx_error; // 更改目的地址 ip_hdr(skb)->daddr = cp->daddr.ip; // 为出站封包生成chksum ip_send_check(ip_hdr(skb)); IP_VS_DBG_PKT(10, AF_INET, pp, skb, 0, "After DNAT"); skb->local_df = 1; // 发送封包: // 如果发送出去了,返回 NF_STOLEN // 如果没有发送(local=1,目的地是本机),返回NF_ACCEPT rc = ip_vs_nat_send_or_cont(NFPROTO_IPV4, skb, cp, local); rcu_read_unlock(); LeaveFunction(10); return rc; tx_error: kfree_skb(skb); rcu_read_unlock(); LeaveFunction(10); return NF_STOLEN; } static inline int ip_vs_nat_send_or_cont(int pf, struct sk_buff *skb, struct ip_vs_conn *cp, int local) { // 注意这个NF_STOLEN的含义,参考上文 int ret = NF_STOLEN; // 给封包设置IPVS标记,NF_HOOK会导致当前封包重入netfilter,此标记会让重入后的封包立即NF_ACCEPT、 // 重入让修改后的封包有机会被ipables处理 skb->ipvs_property = 1; if (likely(!(cp->flags & IP_VS_CONN_F_NFCT))) ip_vs_notrack(skb); else ip_vs_update_conntrack(skb, cp, 1); // 如果目的地不是本机 if (!local) { skb_forward_csum(skb); // 调用LOCAL_OUT挂载点 NF_HOOK(pf, NF_INET_LOCAL_OUT, skb, NULL, skb_dst(skb)->dev, dst_output); } else ret = NF_ACCEPT; return ret; } |
在ip_vs_nat_xmit中,我们可以了解到,对于宿主机发起的针对ClusterIP:ServicePort的请求
- 封包的目的地址被修改为Endpoint(通常是Pod,IPVS中的RS)的地址
- 修改后的封包,重新被塞入netfilter(内层),注意当前就正在netfilter(外层)中
- 外层钩子的返回值是NF_STOLEN:封包处理权转移给内层钩子,停止后续netfilter流程
- 内层钩子的返回值是NF_ACCEPT:不做IPVS相关处理,继续后续netfilter流程。IPVS前、后的LOCAL_OUT、POSTROUTING钩子都会正常执行。也就是说,对于修改后的封包,内核会进行完整、常规的netfilter处理,就像没有IPVS存在一样
到这里,我们确定了,IPVS会在LOCAL_OUT中进行DNAT。但是只有同时进行SNAT,才能解释上文的中的疑惑。
花费了不少时间在IPVS上探究后,我们意识到走错了方向。我们忘记了SANT是kube-proxy会去做的事情。查看一下iptables规则就一目了然了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
# iptables -t nat -L -n -v Chain OUTPUT (policy ACCEPT 2 packets, 150 bytes) pkts bytes target prot opt in out source destination # 所有出站流量都要经过自定义的 KUBE-SERVICES 链 21M 3825M KUBE-SERVICES all -- * * 0.0.0.0/0 0.0.0.0/0 /* kubernetes service portals */ Chain KUBE-SERVICES (2 references) pkts bytes target prot opt in out source destination # 如果目的IP:PORT属于K8S服务,则调用KUBE-MARK-MASQ链 0 0 KUBE-MARK-MASQ all -- * * !172.27.0.0/16 0.0.0.0/0 match-set KUBE-CLUSTER-IP dst,dst # 给封包打上标记 0x4000 Chain KUBE-MARK-MASQ (5 references) pkts bytes target prot opt in out source destination 98 5880 MARK all -- * * 0.0.0.0/0 0.0.0.0/0 MARK or 0x4000 Chain POSTROUTING (policy ACCEPT 2 packets, 150 bytes) pkts bytes target prot opt in out source destination 44M 5256M KUBE-POSTROUTING all -- * * 0.0.0.0/0 0.0.0.0/0 /* kubernetes postrouting rules */ Chain KUBE-POSTROUTING (1 references) pkts bytes target prot opt in out source destination # 仅仅处理 0x4000标记的封包 1781 166K RETURN all -- * * 0.0.0.0/0 0.0.0.0/0 mark match ! 0x4000/0x4000 # 执行SNAT 97 5820 MARK all -- * * 0.0.0.0/0 0.0.0.0/0 MARK xor 0x4000 97 5820 MASQUERADE all -- * * 0.0.0.0/0 0.0.0.0/0 /* kubernetes service traffic requiring SNAT */ |
由于ip_vs_local_request4挂钩在LOCAL_OUT,优先级为NF_IP_PRI_NAT_DST+2 ,因此它是发生在上面nat表OUTPUT链中MARK之后的。也就是说在IPVS处理之前,kube-proxy已经给原始的封包打上标记。
重入的、DNAT后的封包进入LOCAL_OUT,随后进入POSTROUTING。由于标记的缘故,封包被kube-proxy的规则SNAT。
经过POSTROUTING的封包,经过tcpdump,但是由于源、目的IP地址,以及目的端口都改变了,因而我们看到tcpdump没有任何输出。
这里做一下小结。
为什么IPVS模式下,能够ping通ClusterIP? 这是因为IPVS模式下,ClusterIP被配置为宿主机上一张虚拟网卡kube-ipvs0的IP地址。
为什么IPVS模式下,宿主机端口被ClusterIP泄漏?每当添加一个ClusterIP给网络接口后,内核自动在local表中增加一条路由,此路由保证了针对ClusterIP的访问,在没有IPVS干涉的情况下,路由到本机处理。这样,在0.0.0.0上监听的进程,就接收到报文并处理。
为什么删除内核添加的路由后:
- 宿主机上访问ClusterIP:NonServicePort不通了?因为没有路由了
- 没有路由了,为什么宿主机上访问ClusterIP:ServicePort仍然畅通?如上文分析,IPVS在ip_vs_nat_xmit中仍然会进行选路操作
- 那为什么从容器网络命名空间访问ClusterIP:ServicePort不通呢?IPVS处理本地、远程客户端的代码路径不一样。容器网络命名空间是远程客户端,需要首先进入PER_ROUTING,然后选路,路由目的地是本机,才会进入LOCAL_IN,IPVS才有介入的时机。由于路由被删掉了,选路那一步就会出问题
为什么通过--match-set KUBE-CLUSTER-IP匹配目的地址,如果封包目的端口是NonServicePort则Reject:
- 这种方案对容器命名空间有效?容器请求的源地址不会是ClusterIP,因此回程报文的目的地址不会因为匹配规则而Reject
- 这种方案导致宿主机无法访问ClusterIP?宿主机发起请求时用的是ClusterIP,请求端口是随机的。这种请求的回程报文必然匹配规则导致Reject
Leave a Reply