在之前的文章中,我们介绍了通过 TUN/TAP 设备实现 VPN 的基本概念。本文将深入实践,探讨如何在基于 Linux 的云计算环境中,利用 TUN 设备来搭建 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 命令查看支持的模式和操作。
# 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 ]
其中,mode 参数定义了不同的隧道封装类型:
- ipip:最基础的 IPIP 隧道,在 IPv4 报文之上再封装一层 IPv4 报文。
- gre:通用路由封装,定义了在任何网络层协议上封装其他网络层协议的机制,支持 IPv4 和 IPv6。
- sit:此模式主要用于 IPv6-in-IPv4 隧道,即用 IPv4 报文封装 IPv6 报文。
- isatap:站内自动隧道寻址协议,与 sit 类似,也用于 IPv6 隧道。
- vti:虚拟隧道接口,一种 IPsec 隧道技术。
常用参数说明:
ttl N:设置进入隧道的数据包的生存时间,N 为 1-255 的数字,默认为 inherit(继承)。
tos T / dsfield T:设置进入隧道的数据包的服务类型字段,默认为 inherit。
[no]pmtudisc:启用或禁用此隧道的路径 MTU 发现功能,默认启用。
注意:nopmtudisc 选项与固定的 ttl 值不兼容。如果设置了固定 ttl,路径 MTU 发现功能将自动启用。
点对点 (Point-to-Point) 模式
我们从基础的一对一隧道模式开始,演示如何在两台 Linux 主机间建立 IPIP 隧道,实现两个独立子网的互联。
开始前,请确保系统已加载 ipip 内核模块。可通过 lsmod | grep ipip 检查,若无输出,则使用 modprobe ipip 命令加载。
我们的目标拓扑如下图所示。主机 A 和 B 位于同一三层网络 172.16.0.0/16 中。我们将在两台主机上分别创建子网 10.42.1.0/24 和 10.42.2.0/24,并通过隧道连接它们。

首先,在节点 A 上创建网桥并设置 IP:
# 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
接下来,分别在两台主机上创建并配置 TUN 隧道设备。
- 节点 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
- 节点 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
上述命令创建了隧道设备 tunl0,并设置了隧道外层(公网)的本地与对端 IP 地址。内层地址则配置为各自的子网地址,数据包封装形式如下图所示。

为使子网互通,需要手动添加路由:
- 在节点 A 上:
# ip route add 10.42.2.0/24 dev tunl0
- 在节点 B 上:
# ip route add 10.42.1.0/24 dev tunl0
配置完成后,可以在节点 A 上 ping 节点 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.269 ms
64 bytes from 10.42.2.1: icmp_seq=2 ttl=64 time=0.303 ms
使用 tcpdump 可以在 tunl0 接口上捕获到明文的内层 ICMP 报文:
# tcpdump -n -i tunl0
22:38:28.268089 IP 10.42.1.1 > 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.1: ICMP echo reply, id 6026, seq 1, length 64
至此,点对点 IPIP 隧道搭建成功。若使用 GRE 等模式,可能需额外配置防火墙规则以允许协议通信,这在搭建 IPv6 隧道时更为常见。
一对多 (Point-to-Multipoint) 模式
上一节通过指定 remote 和 local 地址创建了点对点隧道。实际上,我们可以创建不指定对端地址的隧道,然后通过添加路由条目,让隧道根据路由目标自动完成封装和转发。这种模式在构建多节点覆盖网络时非常有用。
假设有三个节点 A、B、C 位于同一三层网络,IP 分别为:
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
与上例不同,我们创建网桥 mybr0 作为子网网关,模拟更接近实际 容器网络 的场景。以节点 A 为例:
# 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 上执行类似操作,分别使用 10.42.2.1/24 和 10.42.3.1/24。
接下来,在每个节点上创建 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
配置要点解析:
- 隧道设备 IP 使用 /32 掩码:将
tunl0 的地址设置为如 10.42.1.0/32,是为了避免二层 ARP 问题。这确保了该地址不属于任何广播域,内核不会为其发起或响应 ARP 请求,从而防止了多个节点的隧道接口因共享同一子网IP但MAC不同而导致的二层通信混乱。一些容器网络方案(如 Calico 的某些模式)也采用此思路。
- 使用
onlink 路由参数:onlink 参数告知内核,即使下一跳地址(如 172.16.165.244)不在隧道接口的直接连接网络中,也认为它是“在链路上”的,这对于在复杂路由环境中建立覆盖隧道至关重要。
配置完成后,即可在任一节点上测试与其他子网的连通性,例如从节点 A ping 节点 B 和 C 的网关:
# ping 10.42.2.1 -c 2
# ping 10.42.3.1 -c 2
测试成功表明一对多的 IPIP 覆盖网络已正常工作。
工作原理浅析
通过 tcpdump 在节点 B 或 C 的 tunl0 接口抓包,可以看到明文的 ICMP 请求/应答,其源地址是节点 A 隧道接口的 /32 地址(如 10.42.1.0)。
从一对多隧道的配置过程可以推断,Linux 的 ipip 模块会根据路由表信息,将去往特定目的网段(内层IP)的数据包,自动封装到一个新的 IP 数据包中,其外层目的 IP 就是路由中指定的 via 地址。
那么封装好的数据包在对端如何被解封装呢?关键在于内核协议栈的处理。当网卡收到一个协议号为 IPPROTO_IPIP (4) 的数据包时,内核会将其递交给 ipip 模块的接收处理函数。该函数会剥离外层 IP 头,将内层的原始 IP 数据包重新“注入”到协议栈的输入流程中,进行第二次路由决策并最终送达目标应用。这个过程类似于一个“虚拟的网卡”收到了一个数据包。
以上仅为原理性描述,详细实现可参考 Linux 内核源码中 net/ipv4/ipip.c 等相关文件。