上一篇笔记中,我们在介绍 Linux 网络设备的时候简单地看到了一种通过 TUN/TAP 设备来实现 VPN 的方式,但是并没有实践 TUN/TAP 虚拟网络设备在 Linux 中具体是怎么发挥功能的。这篇笔记我们就来看看在云计算领域中如何基于TUN设备实现 IPIP 隧道。
IPIP 隧道
我们在之前的笔记中也提到了,TUN 网络设备能将三层(IP 网络数据包)数据包封装在另外一个三层数据包之中,这样通过 TUN 设备发送出来的数据包会像下面这样:
MAC: xx:xx:xx:xx:xx:xx
IP Header: <new destination IP>
IP Body:
IP: <original destination IP>
TCP: stuff
HTTP: stuff
这就是典型的 IPIP 数据包的结构。Linux 原生支持好几种不同的 IPIP 隧道类型,但都依赖于 TUN 网络设备,我们可以通过命令 ip tunnel help
来查看 IPIP 隧道的相关类型以及支持的操作:
# ip tunnel help
Usage: ip tunnel { add | change | del | show | prl | 6rd } [ NAME ]
[ mode { ipip | gre | sit | isatap | vti } ] [ remote ADDR ] [ local ADDR ]
[ [i|o]seq ] [ [i|o]key KEY ] [ [i|o]csum ]
[ prl-default ADDR ] [ prl-nodefault ADDR ] [ prl-delete ADDR ]
[ 6rd-prefix ADDR ] [ 6rd-relay_prefix ADDR ] [ 6rd-reset ]
[ ttl TTL ] [ tos TOS ] [ [no]pmtudisc ] [ dev PHYS_DEV ]
Where: NAME := STRING
ADDR := { IP_ADDRESS | any }
TOS := { STRING | 00..ff | inherit | inherit/STRING | inherit/00..ff }
TTL := { 1..255 | inherit }
KEY := { DOTTED_QUAD | NUMBER }
其中 mode
代表不同的 IPIP 隧道类型,Linux 原生共支持5种 IPIP 隧道:
- ipip: 普通的 IPIP 隧道,就是在报文的基础上再封装成一个 IPv4 报文
- gre: 通用路由封装(Generic Routing Encapsulation),定义了在任意网络层协议上封装其他网络层协议的机制,所以对于 IPv4 和 IPv6 都适用
- sit: sit 模式主要用于 IPv4 报文封装 IPv6 报文,即 IPv6 over IPv4
- isatap: 站内自动隧道寻址协议(Intra-Site Automatic Tunnel Addressing Protocol),类似于 sit 也是用于 IPv6 的隧道封装
- vti: 即虚拟隧道接口(Virtual Tunnel Interface),是一种 IPsec 隧道技术
还有一些有用的参数:
- ttl N 设置进入隧道数据包的 TTL 为 N(N是一个1—255之间的数字,0是一个特殊的值,表示这个数据包的 TTL 值是继承(inherit)的),ttl 参数的缺省值是为 inherit
- tos T/dsfield T 设置进入隧道数据包的 TOS 域,缺省是 inherit
- [no]pmtudisc 在这个隧道上禁止或者打开路径最大传输单元发现(Path MTU Discovery),默认打开的
Note: nopmtudisc 选项和固定的 ttl 是不兼容的,如果使用了固定的 ttl 参数,系统会打开路径最大传输单元发现(Path MTU Discovery)功能。
one-to-one
我们首先以最基本的一对一隧道模式为例来介绍如何在 Linux 中搭建 IPIP 隧道来实现两个不同子网之间的通信。
开始之前需要注意的是,并不是所有的 Linux 发行版都会默认加载 ipip.ko 模块,可以通过 lsmod | grep ipip
查看内核是否加载该模块;若没有则用 modprobe ipip
命令先加载 ipip 模块;如果一切正常通过执行 lsmod | grep ipip
命令应该显示类似于下面的输出:
# lsmod | grep ipip
# modprobe ipip
# lsmod | grep ipip
ipip 20480 0
tunnel4 16384 1 ipip
ip_tunnel 24576 1 ipip
现在就可开始搭建 IPIP 隧道了,我们的目标网络拓扑如下图所示:
其中有处于在同一个网段172.16.0.0/16
的两台主机 A 和 B,因此可以直接联通。我们需要做的是分别在两台主机上创建两个不同的子网:
Note: 实际上,这两台主机 A 和 B 不必处于同一个子网,只要处于同一个三层可路由的网络之中,也就是说能通过三层网络路由得到就可以完成 IPIP 隧道的搭建。
A: 10.42.1.0/24
B: 10.42.2.0/24
为了简化,我们先在 A 节点上创建 bridge 网络设备 mybr0,并且设置 IP 地址为10.42.1.0/24
子网的网关地址,也就是10.42.1.1/24
,然后启用 mybr0 网桥设备:
# ip link add name mybr0 type bridge
# ip addr add 10.42.1.1/24 dev mybr0
# ip link set dev mybr0 up
类似地,然后在 B 节点上也执行类似的操作,但是子网地址为10.42.2.0/24
:
# ip link add name mybr0 type bridge
# ip addr add 10.42.2.1/24 dev mybr0
# ip link set dev mybr0 up
接下来,我们分别在 A 和 B 节点上
- 创建对应的 TUN 网络设备
- 设置对应的 local 和 remote 地址为节点的可路由地址
- 设置对应的网关地址分别为我们即将创建的 TUN 网络设备
- 启用 TUN 网络设备来创建 IPIP 隧道
Note: 步骤3是为了节省简化我们创建子网的步骤,直接设置网关地址就可以不用创建额外的网络设备。
A:
# modprobe ipip
# ip tunnel add tunl0 mode ipip remote 172.16.232.194 local 172.16.232.172
# ip addr add 10.42.1.1/24 dev tunl0
# ip link set tunl0 up
通过上面的命令我们创建了新的隧道设备 tunl0 并且设置了隧道的 remote 和 local 的IP地址,这是 IPIP 数据包的外层地址;对于内层地址,我们分别设置两个子网地址,这样,IPIP 数据包就会如下图所示:
B:
# modprobe ipip
# ip tunnel add tunl0 mode ipip remote 172.16.232.172 local 172.16.232.194
# ip addr add 10.42.2.1/24 dev tunl0
# ip link set tunl0 up
为了保证我们通过创建的 IPIP 隧道来访问两个不同主机上的子网,我们需要手动添加如下静态路由:
A:
# ip route add 10.42.2.0/24 dev tunl0
B:
# ip route add 10.42.1.0/24 dev tunl0
现在主机 AB 的路由表如下所示:
A:
# ip route show
default via 172.16.200.51 dev ens3
10.42.1.0/24 dev tunl0 proto kernel scope link src 10.42.1.1
10.42.2.0/24 dev tunl0 scope link
172.16.0.0/16 dev ens3 proto kernel scope link src 172.16.232.172
B:
# ip route show
default via 172.16.200.51 dev ens3
10.42.1.0/24 dev tunl0 scope link
10.42.2.0/24 dev tunl0 proto kernel scope link src 10.42.2.1
172.16.0.0/16 dev ens3 proto kernel scope link src 172.16.232.194
到此我们就可以开始验证 IPIP 隧道是否正常工作:
A:
# ping 10.42.2.1 -c 2
PING 10.42.2.1 (10.42.2.1) 56(84) bytes of data.
64 bytes from 10.42.2.1: icmp_seq=1 ttl=64 time=0.269 ms
64 bytes from 10.42.2.1: icmp_seq=2 ttl=64 time=0.303 ms
--- 10.42.2.1 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1013ms
rtt min/avg/max/mdev = 0.269/0.286/0.303/0.017 ms
B:
# ping 10.42.1.1 -c 2
PING 10.42.1.1 (10.42.1.1) 56(84) bytes of data.
64 bytes from 10.42.1.1: icmp_seq=1 ttl=64 time=0.214 ms
64 bytes from 10.42.1.1: icmp_seq=2 ttl=64 time=3.27 ms
--- 10.42.1.1 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1021ms
rtt min/avg/max/mdev = 0.214/1.745/3.277/1.532 ms
是的,可以 ping 通,我们通过 tcpdump 命令在 TUN 设备抓取数据:
# tcpdump -n -i tunl0
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on tunl0, link-type RAW (Raw IP), capture size 262144 bytes
01:32:05.486835 IP 10.42.1.1 > 10.42.2.1: ICMP echo request, id 3460, seq 1, length 64
01:32:05.486868 IP 10.42.2.1 > 10.42.1.1: ICMP echo reply, id 3460, seq 1, length 64
01:32:06.509617 IP 10.42.1.1 > 10.42.2.1: ICMP echo request, id 3460, seq 2, length 64
01:32:06.509668 IP 10.42.2.1 > 10.42.1.1: ICMP echo reply, id 3460, seq 2, length 64
到此为止,我们的实验是成功的。但是需要注意的是,如果我们使用的是 gre 模式,有可能需要设置防火墙才能让两个子网互通,这种情况在搭建 IPv6 隧道较为常见。
one-to-many
上一节中我们通过指定 TUN 设备的 local 地址和 remote 地址创建了一个一对一的 IPIP 隧道,实际上,在创建 IPIP 隧道的时候完全可以不指定 remote 地址,只要在 TUN 设备上增加对应的路由,IPIP 隧道就知道如何封装新的 IP 数据包并发送到路由指定的目标地址。
还是举个栗子来说明,假设我们现在有处于同一个三层网络的3个节点:
A: 172.16.165.33
B: 172.16.165.244
C: 172.16.168.113
同时在这三个节点上分别创建三个不同的子网:
A: 10.42.1.0/24
B: 10.42.2.0/24
C: 10.42.3.0/24
与上一小节不同的是,我们没有直接将子网的网关地址设置为 TUN 设备的 IP 地址,而是创建额外的 bridge 网络设备以模拟实际常用的容器网络模型。我们在 A 节点上创建 bridge 网络设备 mybr0,并且设置 IP 地址为 10.42.1.0/24 子网的网关地址,然后启用 mybr0:
# ip link add name mybr0 type bridge
# ip addr add 10.42.1.1/24 dev mybr0
# ip link set dev mybr0 up
然后在 B 和 C 节点上分别执行类似的操作:
B:
# ip link add name mybr0 type bridge
# ip addr add 10.42.2.1/24 dev mybr0
# ip link set dev mybr0 up
C:
# ip link add name mybr0 type bridge
# ip addr add 10.42.3.1/24 dev mybr0
# ip link set dev mybr0 up
我们的最终目标是在三个节点之间分别俩俩搭建 IPIP 隧道来保证这三个不同的子网直接能够互相通信,因此下一步是创建 TUN 网络设备并且设置路由信息,分别在A和B两台节点上:
- 创建对应的 TUN 网络设备并启用
- 设置 TUN 网络设备的 IP 地址
- 设置到不同子网的路由,指明下一跳的地址
对应的网关地址分别为我们即将创建的 TUN 网络设备
- 启用 TUN 网络设备来创建 IPIP 隧道
Note: TUN 网络设备的 IP 地址是对应节点的子网地址,但是子网掩码是32位的,例如 A 节点上子网地址是
10.42.1.0/24
,A 节点上的 TUN 网络设备的 IP 地址是10.42.1.0/32
。这样做的原因是有时候同一个子网(例如10.42.1.0/24
)的地址会分配相同的 MAC 地址,因此不能通过二层的链路层直接通信,而如果保证 TUN 网络设备的 IP 地址和任何地址都不在同一个子网,也就不存在二层的链路层直接通信了。关于这点请参考 Calico 的实现原理,每个容器会有相同的 MAC 地址,后面我们有机会在深入探究。
Note: 还有一点需要注意,给 TUN 网络设备设置路由的时候指定了
onlink
参数, 目的是保证下一跳是直接到该 TUN 网络设备,这样即使节点之间不在同一个子网中也可以搭建 IPIP 隧道。
A:
# modprobe ipip
# ip tunnel add tunl0 mode ipip
# ip link set tunl0 up
# ip addr add 10.42.1.0/32 dev tunl0
# ip route add 10.42.2.0/24 via 172.16.165.244 dev tunl0 onlink
# ip route add 10.42.3.0/24 via 172.16.168.113 dev tunl0 onlink
B:
# modprobe ipip
# ip tunnel add tunl0 mode ipip
# ip link set tunl0 up
# ip addr add 10.42.2.0/32 dev tunl0
# ip route add 10.42.1.0/24 via 172.16.165.33 dev tunl0 onlink
# ip route add 10.42.3.0/24 via 172.16.168.113 dev tunl0 onlink
C:
# modprobe ipip
# ip tunnel add tunl0 mode ipip
# ip link set tunl0 up
# ip addr add 10.42.3.0/32 dev tunl0
# ip route add 10.42.1.0/24 via 172.16.165.33 dev tunl0 onlink
# ip route add 10.42.2.0/24 via 172.16.165.244 dev tunl0 onlink
到此我们就可以开始验证我们搭建的 IPIP 隧道是否正常工作:
A:
# try to ping IP in 10.42.2.0/24 on Node B
# ping 10.42.2.1 -c 2
PING 10.42.2.1 (10.42.2.1) 56(84) bytes of data.
64 bytes from 10.42.2.1: icmp_seq=1 ttl=64 time=0.338 ms
64 bytes from 10.42.2.1: icmp_seq=2 ttl=64 time=0.302 ms
--- 10.42.2.1 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1028ms
rtt min/avg/max/mdev = 0.302/0.320/0.338/0.018 ms
...
# try to ping IP in 10.42.3.0/24 on Node C
# ping 10.42.3.1 -c 2
PING 10.42.3.1 (10.42.3.1) 56(84) bytes of data.
64 bytes from 10.42.3.1: icmp_seq=1 ttl=64 time=0.315 ms
64 bytes from 10.42.3.1: icmp_seq=2 ttl=64 time=0.381 ms
--- 10.42.3.1 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1029ms
rtt min/avg/max/mdev = 0.315/0.348/0.381/0.033 ms
看起来一切正常,如果反过来从 B 或 C 节点分别 ping 其他子网,也是可以通的。这就说明我们确实可以创建一对多的 IPIP 隧道,一对多的 IPIP 隧道模式在一些典型的多节点网络中创建 overlay 通信模型中非常有用。
under the hood
我们再通过 tcpdump 命令在分别在 B 和 C 的 TUN 设备抓取数据:
B:
# tcpdump -n -i tunl0
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on tunl0, link-type RAW (Raw IP), capture size 262144 bytes
22:38:28.268089 IP 10.42.1.0 > 10.42.2.1: ICMP echo request, id 6026, seq 1, length 64
22:38:28.268125 IP 10.42.2.1 > 10.42.1.0: ICMP echo reply, id 6026, seq 1, length 64
22:38:29.285595 IP 10.42.1.0 > 10.42.2.1: ICMP echo request, id 6026, seq 2, length 64
22:38:29.285629 IP 10.42.2.1 > 10.42.1.0: ICMP echo reply, id 6026, seq 2, length 64
C:
# tcpdump -n -i tunl0
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on tunl0, link-type RAW (Raw IP), capture size 262144 bytes
22:36:18.236446 IP 10.42.1.0 > 10.42.3.1: ICMP echo request, id 5894, seq 1, length 64
22:36:18.236499 IP 10.42.3.1 > 10.42.1.0: ICMP echo reply, id 5894, seq 1, length 64
22:36:19.265946 IP 10.42.1.0 > 10.42.3.1: ICMP echo request, id 5894, seq 2, length 64
22:36:19.265997 IP 10.42.3.1 > 10.42.1.0: ICMP echo reply, id 5894, seq 2, length 64
其实,从创建一对多的 IPIP 隧道的过程中我们就能大致猜到 Linux 的 ipip 模块基于路由信息获取 IPIP 包的内部 IP 然后再用外部 IP 封装成新的 IP 数据包。至于怎么解封 IPIP 数据包的呢?我们来看看 ipip 模块收数据包的过程:
void ip_protocol_deliver_rcu(struct net *net, struct sk_buff *skb, int protocol)
{
const struct net_protocol *ipprot;
int raw, ret;
resubmit:
raw = raw_local_deliver(skb, protocol);
ipprot = rcu_dereference(inet_protos[protocol]);
if (ipprot) {
if (!ipprot->no_policy) {
if (!xfrm4_policy_check(NULL, XFRM_POLICY_IN, skb)) {
kfree_skb(skb);
return;
}
nf_reset_ct(skb);
}
ret = INDIRECT_CALL_2(ipprot->handler, tcp_v4_rcv, udp_rcv,
skb);
if (ret < 0) {
protocol = -ret;
goto resubmit;
}
__IP_INC_STATS(net, IPSTATS_MIB_INDELIVERS);
} else {
if (!raw) {
if (xfrm4_policy_check(NULL, XFRM_POLICY_IN, skb)) {
__IP_INC_STATS(net, IPSTATS_MIB_INUNKNOWNPROTOS);
icmp_send(skb, ICMP_DEST_UNREACH,
ICMP_PROT_UNREACH, 0);
}
kfree_skb(skb);
} else {
__IP_INC_STATS(net, IPSTATS_MIB_INDELIVERS);
consume_skb(skb);
}
}
}
以上代码取自于:https://github.com/torvalds/linux/blob/master/net/ipv4/ip_input.c#L187-L224
可以看到,ipip 模块会根据数据包的协议类型去解封,然后将解封后的 skb 数据包再做一次解封。以上只是一些非常浅显的分析,如果大家感兴趣,推荐去多看看 ipip 模块的源代码实现。