为每个容器分配独立的代理隧道 实现TCP+UDP透明转发
最近有个需求,需要给每个容器,一对一分配不同的网络出口IP地址,不过这些出口IP地址都分布在远端的多个机器上,需要通过多个不同代理来连接,而本地机器对外的网络访问只有一个统一的出口IP。
最简单的方法是给每个容器里的程序,都单独配置使用对应的代理。但这样除了太耦合不好管理之外,更重要的是,代理信息也会暴露在容器里了。我更希望实现透明代理,容器内的整个环境包括DNS和程序通过代理隧道,到达远端机器的出口IP来访问网络,容器内不知道代理的存在,以为自己的网络出口就是远端机器的出口IP。
搭建这套系统,使用了V2Fly工具的dokodemo-door协议,以及kernel netfilter的REDIRECT和TPROXY能力(这里使用V2Fly只作为代理隧道搭建和管理,不作为突破出海网络访问限制)。
按照V2Fly官方文档介绍,Dokodemo door(任意门)是一个入站数据协议,它可以监听一个本地端口,并把所有进入此端口的数据发送至指定代理服务器。配合V2Fly的路由能力,我可以配置策略指定不同的来源IP,路由到不同的代理隧道,这样只要我给不同的容器设置不同的IP,容器就可以自动走对应的代理隧道,可以实现一对一或者多对一关系。并且跑一个V2Fly实例就能搞定,不需要跑多个。
第一步:创建docker网络和启动容器
先创建一个bridge network给容器使用,bridge主网卡获分配10.8.0.1,然后我的容器从10.8.0.10开始分配,加了internal flag让他不要自动加Masquerade和相关的netfilter规则,后面我自己配置。另外也配置了容器使用google和cloudflare的公共DNS,否则预设会请求docker daemon本地自带的DNS服务器,然后再从宿主机配置的DNS服务器获取记录,这样就相当于访问了本地运营商的DNS,并且DNS流量也没有走代理隧道。指定DNS后,容器内就会直接请求指定的DNS服务器而不走docker的DNS服务器。
docker network create --internal --subnet=10.8.0.0/24 mynetwork
docker run -id --name mycontainer0 --network mynetwork --ip 10.8.0.10 --dns 8.8.8.8 --dns 1.1.1.1 alpine
docker run -id --name mycontainer1 --network mynetwork --ip 10.8.0.11 --dns 8.8.8.8 --dns 1.1.1.1 alpine
docker run -id --name mycontainer2 --network mynetwork --ip 10.8.0.12 --dns 8.8.8.8 --dns 1.1.1.1 alpine
第二步:配置V2Fly
在V2Fly inbounds配置dokodemo-door接收来自容器的流量,TCP和UDP都接收。outbounds配置多个代理隧道,另一端是到远端的多个机器。并且配置routing路由策略,指定每个容器走哪一个代理隧道。
"inbounds": [
{
"port": 10900,
"listen": "10.8.0.1",
"protocol": "dokodemo-door",
"settings": {
"network": "tcp,udp",
"followRedirect": true
}
}
],
"outbounds": [
// 连到远端的机器,hk0,hk1,hk2
],
"routing": {
"rules": [
{
"type": "field",
"outboundTag": "hk0",
"source": ["10.8.0.10"]
},
{
"type": "field",
"outboundTag": "hk1",
"source": ["10.8.0.11"]
},
{
"type": "field",
"outboundTag": "hk2",
"source": ["10.8.0.12"]
}
]
}
第三步:网络转发
这一步也是核心的部分了,需要让容器发出的包,转发到10.8.0.1的10900端口给V2Fly。
TCP
先处理TCP转发,在主网络namespace通过iptables新增转发规则。
iptables -t nat -N MYPROXY
iptables -t nat -A MYPROXY -d 0.0.0.0/8 -j RETURN
iptables -t nat -A MYPROXY -d 10.0.0.0/8 -j RETURN
iptables -t nat -A MYPROXY -d 127.0.0.0/8 -j RETURN
iptables -t nat -A MYPROXY -d 169.254.0.0/16 -j RETURN
iptables -t nat -A MYPROXY -d 172.16.0.0/12 -j RETURN
iptables -t nat -A MYPROXY -d 192.168.0.0/16 -j RETURN
iptables -t nat -A MYPROXY -d 224.0.0.0/4 -j RETURN
iptables -t nat -A MYPROXY -d 240.0.0.0/4 -j RETURN
iptables -t nat -A MYPROXY -p tcp -j REDIRECT --to-ports 10900
iptables -t nat -A PREROUTING -p tcp -s 10.8.0.0/24 -j MYPROXY
上面创建了一个MYPROXY规则链,除了目标IP为bogon IP之外的封包,全部都转发到10900端口,不指定IP的话预设会使用来源网卡的主IP,那就是10.8.0.1。然后把来源于10.8.0.0/24的封包都应用这个规则链。相当于所有来自容器的TCP流量都转发到10.8.0.1:10900给V2Fly。
详细的封包路径:
- 10.8.0.12容器内的程序发了一个TCP封包,假设目的地IP端口是1.2.3.4:12345,根据路由表
default via 10.8.0.1 dev eth0,通过ARP查询得到10.8.0.1对应的MAC地址,封包的目的地MAC就是bridge主网卡的MAC地址 - TCP包从容器namespace内的主网卡eth0发出,本质上是一对veth的其中一边,然后封包会在另一边(宿主机namespace的veth)接收到,由于宿主机端的veth加入了bridge,注册了bridge的rx_handler,因此veth接收到封包后会进入br_handle_frame(),函数查询发现封包目的地MAC就是bridge主网卡,于是进入网络协议栈处理。
- 进入网络协议栈后,进入netfilter处理,其中会经过nat表的PREROUTING链(路由前执行),这时候发现源IP匹配上了MYPROXY规则链(此时封包的来源IP是10.8.0.12,目标IP是1.2.3.4:12345),并且执行
-j REDIRECT --to-ports 10900,然后封包的目标IP改为了10.8.0.1:10900,并且把原信息记录到conntrack里。 - 进入路由环节,发现10.8.0.1是本地IP,然后找出监听此地址和端口的socket,那就是V2Fly的入站。
- 封包进入V2Fly处理,V2Fly通过调用getsockopt(…SO_ORIGINAL_DST…)找出此封包原始的目的地IP和端口1.2.3.4:12345
- V2Fly经过代理隧道请求目的地IP和端口
- 目的地服务器处理完后返回TCP响应封包,回到V2Fly,然后V2Fly再回复封包给10.8.0.12,POSTROUTING时经过SNAT把来源IP从10.8.0.1换回到目的地服务器的IP和端口1.2.3.4:12345
- 封包经过bridge和veth转发,回到容器内的程序socket,程序认为他是直接和目的地服务器通讯,实现透明代理
UDP
然后处理UDP转发,UDP是无状态的,跟踪起来比较麻烦,以及不能像TCP那样通过SO_ORIGINAL_DST来获取原始目的地信息,需要通过IP_RECVORIGDSTADDR。Conntrack对UDP长连线的稳定性也不高,由于udp预设的留存时间是30s(没收到回复)和120s(有受到过回复),长连线容易遇到超时。
参阅V2Fly官方文档
,redirect模式支持TCP和UDP,所以理论上UDP沿用我们上面设置的REDIRECT也可以(加一行iptables -t nat -A MYPROXY -p udp -j REDIRECT --to-ports 10900),但是dokodemo-door官方文档
给的示例,拆开了TCP使用REDIRECT,而UDP则使用TPROXY。因此我们也按照官方推荐,UDP使用TPROXY做转发,不折腾REDIRECT了。
在主网络namespace通过iptables新增转发规则,以及添加一个路由表。
ip route add local default dev lo table 10900
ip rule add fwmark 10900 lookup 10900
iptables -t mangle -A PREROUTING -s 10.8.0.0/24 -p udp -j TPROXY --on-port 10900 --on-ip 10.8.0.1 --tproxy-mark 10900/10900
上面在mangle表添加了一个TPROXY规则,把所有来自于10.8.0.0/24的UDP包(来自于容器的UDP流量),转发到10.8.0.1:10900上监听的socket,并且添加10900的标记,路由时input到本地,给V2Fly接收,netfilter对prerouting的执行顺序是raw表再到mangle表再到nat表,由于TPROXY需要前置于NAT,因此必须要mangle表配置而不能在nat表上添加。
详细的封包路径:
- 容器内发出一个UDP封包,目的地IP端口是1.2.3.4:12345,前两步骤和上面TCP一致,然后到magle表的PREROUTING链,源IP匹配上于是执行TPROXY规则,TPROXY会找出本地在监听10.8.0.1:10900并且开启了IP_TRANSPARENT的socket,然后会把这个socket赋值给这个封包的skb->sk(=配置上了V2Fly的入站监听socket),以及给这个包打上10900标记到skb->mark,这里和TCP处理的区别是,封包目的地IP和端口不变,还是1.2.3.4:12345。
- 进入路由环节,发现这个包有个10900标记,由于设置了ip rule,于是走table 10900,(tproxy给封包打的标记编号和route table编号是独立的,可以分开设置任意值,不需要一致,这里只是为了好看设置成一致)
- 封包的目的地IP是1.2.3.4,根据table 10900的路由规则,
local default dev lo,所有的IP都属于本地,所以进入本地传递处理 - 由于netfilter的TPROXY提前给这个封包设置了skb->sk的值是V2Fly的入站监听socket,本地传递处理时inet_steal_sock()会直接把封包交给这个socket(顺利传递到V2Fly),跳过查socket表来寻找监听此封包目的地IP+端口对应的socket。
- 封包进入V2Fly处理,此时封包IP头里的目的地IP和端口1.2.3.4:12345本身就是要访问的目的地,V2Fly经过代理隧道请求目的地
- 目的地服务器处理完后返回UDP响应封包,回到V2Fly,然后V2Fly再回复封包给容器。注意这里和TCP处理不一样,TCP是经过POSTROUTING的SNAT来把封包源IP换回远端服务器的IP和端口,而UDP的话,V2Fly直接就把封包的源IP和端口伪造成服务器的IP和端口(开启了IP_TRANSPARENT的socket支持伪造源IP),就不需要经过SNAT了。
第四步:实际测试和UDP包NoPorts问题解决
实际测试,容器内的TCP流量顺利跑通,但UDP一直发不出去,打开V2Fly的debug日志,发现V2Fly没收到容器发出去的UDP包。
经过几天的努力排查以及阅读kernel源码,发现是由于Bridge内部L2层自己先会调用netfilter处理Prerouting,具体是在br_nf_pre_routing()函数,过程中TPROXY设置封包的skb->sk。当bridge的prerouting流程结束后,会调用netif_receive_skb()重新进入IP层再跑。进入IP层的时候,ip_rcv_core()会调用skb_share_check(),检查发现skb的引用数大于1,然后触发skb_clone()克隆封包,而克隆过程会清除skb->sk(),导致TPROXY预先设置skb->sk的值丢失,具体在下面skbuff.c的1576行(基于kernel 6.12.49版本)。

在IP层处理过程中,由于bridge之前已经跑过prerouting,所以这次跑的时候被ip_sabotage_in()过滤掉不会再prerouting(=不会再跑TPROXY规则设置socket),导致封包在路由后本地传递处理时,由于没有预先设置所属socket,需要从本地socket表寻找监听此封包目的地IP+端口对应的socket,而本地显然不存在监听1.2.3.4:12345的socket(V2Fly监听的是10.8.0.1:10900),因此找不到对应的socket,最终把这个UDP包丢弃并产生NoPorts错误。
通过/proc/net/snmp可以看到网络数据统计,可以看到UDP NoPorts的计数一直在增长:

解决办法是,禁用Bridge自己的netfilter,让封包直接到达IP层处理,在IP层处理完整的prerouting流程,包括应用TPROXY规则:
sysctl -w net.bridge.bridge-nf-call-iptables=0
设置完成后,连续运作了几天测试,TCP和UDP都完美运行,成功实现了每个容器分配独立的透明代理隧道。
运行日志如下,见到容器10.8.0.12向x.x.201.166发送的UDP包,到达了V2Fly并且路由到hk2代理隧道:
