前言
内部有非 K8S 环境上需要类似 SVC 的负载实现,一开始是用 NGINX 做的,所有 SVC 域名都解析成一个 dummy IP ,然后 NGINX 根据 server_name
去 proxy 不同的 upstream 。 开始还是能用的,结果后面很多服务依赖 host
这个 header ,报错签名错误,而且毕竟这样是在用户态,效率不如内核态高。于是打算搞下之前的打算:把 IPVS 的 ClusterIP
的 SVC 扣到非 K8S 环境上使用。
kube-proxy 的 SVC 简单讲就是 node 上任何进程访问 SVC IP:SVC PORT
会被 dnat 成 endpoint
,是工作在内核态的四层负载,不会在机器上看到端口监听,而默认非集群的机器是无法访问 SVC IP 。在 K8S 里,endpoint 的 ip 无非就是 POD IP
,host IP
。前者就是 SVC 选中 POD ,后者例如 kubernetes
这个 SVC ,会 DNAT 成每个 kube-apiserver
的 host IP:6443
端口,也可能是 ExternalName
或者手动创建的 endpoint 。既然 kubernetes
这个 SVC 可以。那我的打算应该也是可以实现的。但是一开始实际按照思路试了下发现不行,网上的文章基本都是在单机 docker 或者现有的 K8S 环境上搞的,漏掉了很多精华和核心思想,这里记录下我的思路和实现过程。
环境信息
前面说的 SVC 现象是和 kube-proxy
的模式无关的。iptables
模式排查不直观,我更倾向于 IPVS
去搞,它更直观,而且支持更多的调度算法。管理 IPVS 规则的话我们需要安装 ipvsadm
,这里我是两台干净的 CentOS 7.8 来做环境。
IP |
---|
192.168.2.111 |
192.168.2.222 |
先安装下基础需要的:
1 | yum install -y ipvsadm curl wget tcpdump ipset conntrack-tools |
过程
先思考下 kube-proxy 的 IPVS ,因为 SVC 端口和 POD 的端口不一样,所以 kube-proxy 使用的 nat
模式。暂且打算添加一个下面类似的 SVC :
1 | IP: 169.254.11.2 |
准备工作
web 的话我是使用的 golang 的一个简单 web 二进制起的 ran :
1 | wget https://github.com/m3ng9i/ran/releases/download/v0.1.6/ran_linux_amd64.zip |
两个机器的这个 web 都起来后我们开个窗口去 192.168.2.111
上继续后面的操作。
lvs nat
kube-proxy 并没有像 lvs nat 那样有单独的机器做 NAT GW
,或者认为每个 node 都是自己的 NAT GW
。现在来添加 169.254.11.2:80
这个 SVC ,使用 ipvsadm 添加:
1 | ipvsadm --add-service --tcp-service 169.254.11.2:80 --scheduler rr |
先添加本地的 web 作为 real server ,下面含义是添加为一个 nat 类型的 real server :
1 | ipvsadm --add-server --tcp-service 169.254.11.2:80 \ |
查看下当前列表:
1 | $ ipvsadm -ln |
因为是自己的 NAT GW
,所以 VIP 配置在自己身上:
1 | ip addr add 169.254.11.2/32 dev eth0 |
测试下访问看看:
1 | $ curl 169.254.11.2/www/test |
添加上另一个节点的 8080:
1 | $ ipvsadm --add-server --tcp-service 169.254.11.2:80 \ |
测试下访问看看:
1 | $ curl 169.254.11.2/www/test |
发现 curl 在卡住和能访问返回 192.168.2.111
之间切换,没有返回 192.168.2.222
的。查看下 IPVS 的 connection ,发现调度到非本机才会卡住:
1 | $ ipvsadm -lnc |
在 192.168.2.222
上抓包看看:
1 | $ tcpdump -nn -i eth0 port 8080 |
从 Flags
看,就是 tcp 重传,并且 SRC IP
是 VIP 。节点 192.168.2.222.8080
给 169.254.11.2.50710
回包会走到网关上去。网关上抓包也看到确实如此:
1 | $ tcpdump -nn -i eth0 host 169.254.11.2 |
lvs 和 netfilter
在介绍 lvs 的实现之前,我们需要了解 netfilter ,Linux 的所有数据包都会经过它,而我们使用的 iptables 是用户态提供的操作工具之一。Linux 内核处理进出的数据包分为了 5 个阶段。netfilter 在这 5 个阶段提供了 hook 点,来让注册的 hook 函数来实现对包的过滤和修改。下图的 local process 就是上层的协议栈。
下面是 IPVS 在 netfilter 里的模型图,IPVS 也是基于 netfilter 框架的,但只工作在 INPUT
链上,通过注册 ip_vs_in
钩子函数来处理请求。因为 VIP 我们配置在机器上(常规的 lvs nat 的 VIP 是在 NAT GW 上,我们这里是自己),我们 curl 的时候就会进到 INPUT
链,ip_vs_in
会匹配然后直接跳转触发 POSTROUTING
链,跳过 iptables 规则。
所以请求流程是:
1 | # CIP: client IP # RIP: real server IP |
lvs 做了 DNAT 并没有做 SNAT ,所以我们利用 iptables 做 SNAT :
1 | $ iptables -t nat -A POSTROUTING -m ipvs --vaddr 169.254.11.2 --vport 80 -j MASQUERADE |
访问看看还是不通,抓包看还是没生效,nat 是依赖 conntrack
的,而 IPVS 默认不会记录 conntrack,我们需要开启 IPVS 的 conntrack 才可以让 MASQUERADE 生效。
1 | # 让 Netfilter 的 conntrack 状态管理功能也能应用于 IPVS 模块 |
现在实现了单个 SVC 的,但是仔细思考下还是有问题,如果后续增加另一个 SVC 又得增加一个 iptables 规则了,那就又回到 iptables 的匹配复杂度耗时长上去了。所以我们可以利用 iptables 的 mark 和 ipset 配合减少 iptables 规则。
利用 ipset 和 iptable 的 mark
iptables 的五链四表如上图所示,我们先删掉原有的规则:
1 | $ iptables -t nat -D POSTROUTING -m ipvs --vaddr 169.254.11.2 --vport 80 -j MASQUERADE |
平时自己家里使用了 openwrt ,之前看了下上面的 iptables 规则设计挺好的,特别是预留了很多链专门给用户在合适的位置插入规则,比如下面的 INPUT
规则:
1 | -A INPUT -i eth0 -m comment --comment "!fw3" -j zone_lan_input |
zone_lan_src_ACCEPT
是最后面,zone_lan_input
是最开始,那用户向 input_lan_rule
链里插入规则即可,利用多个链来设计也方便别人。
规则设计我们先逆着来思考下,最后肯定是 MASQUERADE
的,得在 nat 表的 POSTROUTING
链创建 MASQUERADE
的规则。
但是添加之前先思考下,lvs 做了 DNAT 后,最后包走向了 POSTROUTING
链,而且后面我们是有多个 SVC 的。此刻包的 SRC IP
会是 VIP
,见上面抓包的结果:
1 | # 假设没做 masq 的时候(刚好调度到非本地的 real server 上 |
而且后续可能是在 docker 环境上部署,可能默认桥接网络下的容器也会去访问 SVC
,此刻的 SRC IP
就不会是网卡上的 VIP
了,所以我们在 PREROUTING 阶段 dest IP,dest Port 是 svc 信息则做 masq snat。
可以在此刻利用一个 ipset 存储所有的 SVC_IP:SVC_PORT
匹配,然后打上 mark,然后在 POSTROUTING
链去根据 mark 去做 MASQUERADE
。
1 | # PREROUTING 阶段处理 |
然后添加下 SVC_IP:SVC_PORT
到我们的 ipset 里:
1 | ipset add ZGZ-CLUSTER-IP 169.254.11.2,tcp:80 -exist |
上面我们创建的 ipset 里 ip,port
和 iptables 里 --match-set
后面的 dst,dst
组合在一起就是 DEST IP
和 DEST PORT
同时匹配,下面是一些举例:
ipset type | iptables match-set | Packet fields |
---|---|---|
hash:net,port,net | src,dst,dst | src IP CIDR address, dst port, dst IP CIDR address |
hash:net,port,net | dst,src,src | dst IP CIDR address, src port, src IP CIDR address |
hash:ip,port,ip | src,dst,dst | src IP address, dst port, dst IP address |
hash:ip,port,ip | dst,src,src | dst IP address, src port, src ip address |
hash:mac | src | src mac address |
hash:mac | dst | dst mac address |
hash:ip,mac | src,src | src IP address, src mac address |
hash:ip,mac | dst,dst | dst IP address, dst mac address |
hash:ip,mac | dst,src | dst IP address, src mac address |
然后访问下还是不通,通过两台机器轮询负载均衡的 curl html 返回内容看到了访问不通的时候都是调度到非本机,也就是此刻的 curl 只经过 OUTPUT
链,过 POSTROUTING
的时候并没有 mark 也就不会做 masq , 调试了下发现确实会走 OUTPUT
链:
1 | $ echo 'kern.warning /var/log/iptables.log' >> /etc/rsyslog.conf |
需要添加下面规则,让它也进下 svc 判断和打 mark:
1 | iptables -t nat -A OUTPUT -m comment --comment "zgz service portals" -j ZGZ-SERVICES |
keepalived 的自动化实现
到目前为止都是手动挡,而且没健康检查,其实我们可以利用 keepalived 做个自动挡的。
安装 keepalived 2
CentOS7 自带的源里 keepalived
版本很低,我们安装下比自带新的版本:
1 | yum install -y http://www.nosuchhost.net/~cheese/fedora/packages/epel-7/x86_64/cheese-release-7-1.noarch.rpm |
配置 keepalived
我们需要配置下 keepalived ,修改之前先看下默认相关的:
1 | $ systemctl cat keepalived |
/etc/sysconfig/keepalived
里修改为下面:
1 | KEEPALIVED_OPTIONS="-D --log-console --log-detail --use-file=/etc/keepalived/keepalived.conf" |
我们选择在主配置文件里去 include 子配置文件,keepalivd 接收 kill -HUP
信号触发 reload ,后续自动化添加 SVC 的时候添加子配置文件后发送信号即可。
1 | cat > /etc/keepalived/keepalived.conf << EOF |
我们写一个脚本,一个是用来添加一个子配置文件里的相关信息到 ipset 里,另一方面也让它在重启或者启动 keepalived 的时候每次能初始化,先添加 systemd 的部分:
1 | mkdir -p /usr/lib/systemd/system/keepalived.service.d |
然后编写脚本 /etc/keepalived/ipvs.sh
:
1 |
|
脚本就如上面所示,读取 keepalived 的 lvs 文件,把 VIP:PORT
加到 ipset 里,VIP 加到 dummy
接口上,之前是加到 eth0 上,但是业务网卡可能会重启影响,dummy 接口和 loopback 类似,它总是 up 的,除非你 down 掉它,SVC 地址配置在它上面不会随着物理接口状态变化而受到影响。删除掉之前 eth0 上的 VIP ip addr del 169.254.11.2/32 dev eth0
,然后把前面的转成 keepalived 的配置文件测试下:
1 | chmod a+x /etc/keepalived/ipvs.sh |
测试下
1 | # 先清理掉之前手动添加的 |
停掉一个 web 后在我们配置的健康检查几秒也剔除了 rs :
1 | $ curl 169.254.11.2/www/test |
系统的相关配置
后面重启后发现不通,发现内核模块没加载,使用 systemd-modules-load
去开机加载:
1 | cat > /etc/modules-load.d/ipvs.conf << EOF |
一些说明
有人 https://github.com/kubernetes/kubernetes/issues/72236 发现 ipvs 下,访问 svcIP+宿主机的端口,例如22也能访问,这不安全,然后就有大佬 2022/09/02 合入的 pr 加了个链 KUBE-IPVS-FILTER
让 svcIP:非svcPort 无法访问,ping 也 ping 不通了
docker 运行的方案
docker-compose
文件如下,自己把脚本挂载进去即可:
1 | version: '3.5' |
容器运行方案注意一个国产化的问题,uos 的 iptables 是 nf_tables 模式,会没锁文件 /run/xtables.lock
,需要切成 iptables-legacy
1 | $ iptables -V |
根据节点 ip 扩容
后续我们有个需求,根据节点 IP 扩容,因为配置都是 ansible 渲染的,而不可能让 ansible 重新渲染所有的文件,也不想造轮子,突然想到用脚本解决得了:
1 | !/bin/bash |
上面脚本存在容器里的 /etc/tools/sacle.sh
,然后所有的节点 IP 存在 tools/node_list.txt
,让 sacle.sh
读取进来形成 array ,然后对每个配置文件执行上面脚本。