
一条数据包从虚拟机里发出来经过一个外观上和 eth0 没有区别、但实际由用户态程序在背后模拟出来的网卡再进入另一台机器的协议栈——这套机制落到内核里主角就是 TUN/TAP。做容器网络、虚拟化、SDN 的开发者迟早都要和这两个名字打交道。这篇文章就把它们讲透为什么需要虚拟网络设备、TUN 和 TAP 到底差在哪、有哪些绕不开的应用场景以及我从零创建并打通一条 TUN 链路的完整实操记录。新手看完能直接动手老手也可以当作速查手册来用。1. 为什么需要虚拟网络设备物理网卡的三个边界问题1.1 物理网卡的硬限制先想一个问题一台服务器上物理网卡就那么几块PCIe 插槽、板载端口都是定死的。可现代业务根本不是这么玩的——一台物理机要跑几十个虚拟机每个虚拟机都得有一块自己的 eth0同一个机器上可能要同时做内网互通、流量采集、链路限速甚至模拟一条断掉的线路。物理网卡数量不够用转发规则写在硬件里改不动这是第一个瓶颈。第二个瓶颈更致命真实网卡的收发路径被内核协议栈牢牢握住。应用层程序想“看一眼”经过网卡的原始数据包要么用 AF_PACKET 原始套接字要么挂 netfilter 钩子要么改驱动。这些手段要么性能开销大要么移植性差要么需要动内核模块。想做点灵活的、应用层可控的网络逻辑在物理网卡上非常别扭。第三个瓶颈是隔离。虚拟化场景里每个虚拟机都想拥有独立网络栈、独立 MAC、独立路由表但物理网卡只有一个。要把一块物理网卡切出几十个虚拟接口靠硬件本身做不到。哪怕用 VLAN 也只能做简单的隔离想模拟“一个完全独立的路由器”依然没戏。这三个问题指向同一个答案把网卡本身变成软件用代码模拟一块能配 IP、能收发报文、能参与路由的虚拟网卡。这就是虚拟网络设备存在的根本理由。1.2 软件定义网络的破局点把网卡变成代码虚拟网络设备的本质是让你在网络协议栈的“设备层”插入一个由用户态程序控制的入口。它对外表现得和真实网卡一模一样可以ip addr add配地址可以进路由表可以设置 MTU可以 up/down。但真正收发数据时数据不是通过网线出去而是通过文件描述符交给了用户态进程。这个机制的意义非常深远。你可以把它想象成邮政系统真实网卡是一条固定的邮路信件交给邮局后怎么分拣、怎么运输完全不归你管而虚拟网卡相当于你在自己家开了一个收发室所有信件到你手里之后你想拆开看、想重新打包、想贴上别的标签转投他处都自己说了算。在用户态程序拿到数据包之后你可以做的事情包括原样转发给另一台机器封装成任何你能想到的格式解析、过滤、统计把流量当成数据来审计修改报文内容再写回网络栈做网关类的透明拦截干脆丢到某个加密通道里实现跨公网的内网互通。这就是 SDN、叠加网络、容器网络这些概念的技术地基。TUN/TAP 正是把“网卡变成代码”这件事落到内核里的关键设备所以它在虚拟化网络里是个绕不开的底层角色。1.3 TUN/TAP 在虚拟网卡家族里的定位Linux 内核里提供的虚拟设备其实不少常被拿出来说就有 Veth、Bridge、macvlan、TUN、TAP。很多新手一上来容易搞混。先给一张定位表后面再展开设备类型工作层次数据路径典型用途TUN三层IP层内核协议栈 ↔ 用户态程序隧道封装、路由实验、自定义网络协议TAP二层链路层内核协议栈 ↔ 用户态程序桥接、虚拟交换机、流量镜像、捕获分析Veth二层成对出现一端写入另一端收到容器网络命名空间之间的虚拟接线Bridge二层连接多个虚拟/物理网卡虚拟交换机让多个网卡共享一个二层域Veth 和 Bridge 都是“内核内部的虚拟接线工具”数据不离开内核而 TUN/TAP 是唯一一类直接把报文交到用户态的虚拟设备。弄清楚这个区别之后很多架构问题就迎刃而解了——容器网络里用 Veth 就够了因为容器和宿主机在一个内核里而跨主机的网络隧道要走用户态那就得上 TUN/TAP。2. TUN 和 TAP 到底差在哪一个路由层一个链路层2.1 打开 /dev/net/tun 的秘密TUN 和 TAP 在 Linux 里不是按两个独立模块实现的而是共用同一个杂项设备/dev/net/tun主设备号 10次设备号 200。用户态程序第一次接触 TUN/TAP通常就是open(/dev/net/tun, O_RDWR)。但打开这个文件的时候你并没有拿到一个具体的网卡。你只是拿到了一个“建卡接口”真正确定自己要 TUN 还是 TAP、要叫什么名字靠的是下一步的 ioctl#include fcntl.h #include sys/ioctl.h #include linux/if_tun.h #include string.h int tun_alloc(char *dev, int flags) { struct ifreq ifr; int fd open(/dev/net/tun, O_RDWR); if (fd 0) return -1; memset(ifr, 0, sizeof(ifr)); ifr.ifr_flags flags; // IFF_TUN 或 IFF_TAP if (*dev) strncpy(ifr.ifr_name, dev, IFNAMSIZ); if (ioctl(fd, TUNSETIFF, ifr) 0) { close(fd); return -1; } strcpy(dev, ifr.ifr_name); return fd; }这个函数我每次写网络工具都要用一遍。调用ioctl(fd, TUNSETIFF, ifr)之后内核会新建一个虚拟网卡并把“这张网卡的收发端”绑定到你手里这个 fd 上。之后你对这个 fd 做read()就能拿到从网卡方向进来的数据包做write()就相当于从网卡发出了一个数据包。需要注意的是ioctl 里通常会带上IFF_NO_PI标志。加了它读写时就是纯粹的 IP 包或以太网帧不加的话每个包前面还会多出 4 字节的 packet info 头新手容易在这上面多吃几个小时的苦头。我建议除非你要用 packet info 里的标志位否则一律加IFF_NO_PI。2.2 TUN 把 IP 包交给你TUN 设备工作在第三层也就是网络层。你从 fd 里读到的是内核已经处理好协议栈之后、即将“发到网线上”的完整 IP 包你往 fd 里写一个完整的 IP 包内核会当作“从这块网卡收到了一个包”然后交给本机 IP 协议栈处理。举个例子假设我创建了一个叫tun0的 TUN 设备配好地址10.0.0.1/24并启用。这时我执行ping 10.0.0.2数据包在内核里走的是ping 程序构造 ICMP 报文交给 TCP/IP 协议栈路由表匹配到出接口是tun0协议栈把 IP 包“发送”到这个虚拟网卡内核发现 tun0 属于 TUN 类型于是把这个 IP 包通过 fd 交给用户态程序。如果用户态程序不读这个包会被丢弃如果用户态程序读到了再原样写回同样的 fd内核就会觉得 tun0 又收到一个包继续往上送。所以 TUN 设备本质上是在“路由决策之后”和“链路层之前”开了一道用户态的天窗。2.3 TAP 把以太网帧交给你TAP 设备工作在第二层也就是链路层。差别在于TUN 给你 IP 包TAP 给你完整的以太网帧——包含源 MAC、目的 MAC、还可能带 VLAN 标签那种。TAP 的强大之处在于它让你在用户态模拟一整个二层网络。你可以在上面跑 ARP、跑 STP、跑任何依赖 MAC 地址的协议。最经典的用法是把一个 TAP 设备和一个真实网卡同时加进 Linux Bridge数据包到达真实网卡后会被桥接逻辑交给 TAP用户态程序就能“偷窥”到整个二层流量。相比之下TUN 设备不关心 MAC 地址天然不需要处理 ARP。因为 ARP 是链路层协议三层隧道根本不需要它。这也是为什么做“点对点 IP 隧道”时大家都选 TUN而做“虚拟交换机”“流量镜像”时必须上 TAP。2.4 一张表看懂差异对比维度TUNTAP工作层次三层Network二层Link读写内容IP 包以太网帧是否处理 ARP不需要需要与 Bridge 搭配通常不直接搭非常常见典型场景隧道、路由、自定义协议虚拟交换、流量捕获、桥接数据包体积相对较小多了 14 字节以太网头用户态处理负担低略高需要处理二层逻辑有两个选择经验可以分享跨主机连通性业务优先考虑 TUN因为它只关心 IP不需要处理 MAC 层的广播、ARP如果你要做的是“把一台物理机变成一台虚拟交换机”那就用 TAP因为二层帧才能和 Bridge、VLAN 无缝配合。3. TUN/TAP 到底被用在哪几个绕不开的实际场景3.1 虚拟化和云平台里的虚拟网卡用过 KVM/QEMU 的朋友应该见过类似启动参数-netdev tap,idnet0,ifnametap0,scriptno,downscriptno这里用的就是 TAP 设备。QEMU 创建了一个 TAP 网卡然后把它桥接到宿主机上的虚拟交换机里虚拟机里的 eth0 发出的 ARP 请求、IP 包全部都从 TAP fd 走到了宿主机用户态程序再被注入到 Bridge。整个过程对虚拟机里的系统来说就是一块正常的网卡。虚拟化平台之所以选 TAP 而不是 TUN是因为虚拟机需要完整的二层语义——虚拟机里要跑 DHCP、要解析 ARP、要和其他虚拟机处于同一个广播域。这一层语义 TUN 给不了只有 TAP 能做到。3.2 容器网络那边是怎么处理的容器网络常被拿来和虚拟机网络对比但这里有个容易搞错的地方Docker 默认的 Bridge 网络用的不是 TUN/TAP而是 Veth。Veth 是一对虚拟网线一端放在容器命名空间里一端放在宿主机网桥上。因为容器和宿主机共享同一个内核数据在 Veth 对之间直接转发根本不需要经过用户态。那 TUN/TAP 在容器世界里还有位置吗有而且很重要。一旦涉及“跨主机”的容器网络比如自研 CNI 插件、构建 Overlay 网络你就需要把报文从宿主机 A 搬到宿主机 B。Veth 只能在单机内核里搬运数据跨主机就要靠用户态封装这时 TUN/TAP 就是最直接的抓手。一句话总结Veth 负责“同一台机器里把容器接上线”TUN/TAP 负责“把网络包送到用户态做跨机器搬运”。各有各的活儿。3.3 网络模拟、丢包注入与流量审计我在本地搭实验环境的时候经常需要模拟“一条时好时坏的链路上跑业务”的场景。真实物理链路很难精准控制丢包和延迟但配合 TUN 设备加tc netem就很容易ip tuntap add dev tun0 mode tun ip addr add 192.0.2.1/30 dev tun0 ip link set tun0 up tc qdisc add dev tun0 root netem loss 10% delay 50ms所有经过 tun0 的包都会在用户态被调校一遍丢包率、延迟、乱序都能精确模拟。这种可控干扰对测试拥塞控制算法、验证应用超时重试逻辑非常有用。流量审计和入侵检测场景则更偏爱 TAP。需要“看”网络里的所有二层帧时把一个 TAP 设备接进旁路镜像口用户态程序读到的就是最原始的以太网帧。相比给真实网卡挂tcpdumpTAP 的方式更容易灵活处理报文也方便做多路分析管道。3.4 隧道与叠加网络的底层地基TUN/TAP 最广为人知的应用大概是各种隧道工具的内核地基。这个技术方向本身是中性的把本机的 IP 包封装进 UDP、TCP 或者其他自定义协议送到对端主机后再解封装注入回对端内核网络栈。我举一个完全在自有主机之间进行组网的例子公司有两台分别部署在两个机房里的 Linux 服务器你想让它们像连在同一台交换机上一样互通。可以在 A 机和 B 机各自创建一个 TUN 设备各写一个小用户态程序从本地 TUN fd 读到 IP 包封装成 UDP 报文发到对端收到对端的 UDP 报文解封装成 IP 包写进本地 TUN fd。这样两端的 TUN 设备就组成了一条三层链路原本互相隔离的两个机房内网就此打通。如果再在封装里加上加密和认证那就是一套功能完整的加密通道——很多开源隧道工具的核心思路都是这个。生产项目不会自己去把加密、重传、密钥交换全部重造一遍但理解了 TUN 在这里的位置你再看任何隧道工具的架构文档都会顺畅很多。4. 动手实操从零创建并打通一条 TUN 链路4.1 先检查环境和权限TUN/TAP 是内核模块大多数 Linux 发行版默认编译为模块或者直接编译进内核。动手前先确认ls -l /dev/net/tun正常情况下能看到一个字符设备/dev/net/tun主设备号 10次设备号 200。如果不存在手动创建一下sudo mkdir -p /dev/net sudo mknod /dev/net/tun c 10 200 sudo modprobe tun权限是另一个大坑。创建 TUN/TAP 设备需要CAP_NET_ADMIN能力。在普通 Linux 主机上最简单的方式是sudo执行如果是在 Docker 容器里则必须加上相应 capabilitydocker run --cap-add NET_ADMIN --device /dev/net/tun ...我在容器里调试 TUN 设备时经常一上来就遇到Operation not permitted十次里有八次是忘了带这两个参数。4.2 用一条命令创建 TUN 设备现代 Linux 用ip命令就能直接管理 TUN/TAP不必再装老旧的tunctlsudo ip tuntap add dev tun0 mode tun sudo ip addr add 10.0.0.1/24 dev tun0 sudo ip link set tun0 up三条命令之后tun0就是一个正经的、可用的三层网卡。你可以执行ip addr show tun0看到它也可以ping 10.0.0.2但此时不会有任何回包——因为没有用户态程序在另一端处理数据。你 ping 出去的包会被内核想办法交到用户态但没人接收直接丢掉。删除设备用sudo ip tuntap del dev tun0 mode tun4.3 最小读写程序亲眼看看数据进 TUN下面这个 Python 脚本是我每次验证环境都会跑的“最小抓包器”。它打开 tun0把里面流过的 IP 包解析出协议号、源地址、目的地址打印出来import os import fcntl import struct import socket import select TUNSETIFF 0x400454ca IFF_TUN 0x0001 IFF_NO_PI 0x1000 def open_tun(name): fd os.open(/dev/net/tun, os.O_RDWR) ifr struct.pack(16sH, name.encode()[:15], IFF_TUN | IFF_NO_PI) fcntl.ioctl(fd, TUNSETIFF, ifr) return fd def parse_ip(pkt): if len(pkt) 20: return None ihl (pkt[0] 0x0f) * 4 proto pkt[9] src socket.inet_ntoa(pkt[12:16]) dst socket.inet_ntoa(pkt[16:20]) return proto, src, dst def main(): fd open_tun(tun0) print(tun0 ready, fd , fd) while True: ready, _, _ select.select([fd], [], [], 1.0) if not ready: continue pkt os.read(fd, 2048) info parse_ip(pkt) if info: print(len%d proto%d %s - %s % (len(pkt), info[0], info[1], info[2])) if __name__ __main__: main()用 root 权限跑起来之后另开一个终端ping -c 3 10.0.0.2脚本里就会打出len84 proto1 10.0.0.1 - 10.0.0.2。proto1 就是 ICMP也就是 ping 的包。看到这行输出TUN 设备的用户态链路就算通了。4.4 两台主机之间的封装转发 Demo单机验证完我们来点真正有价值的东西用 UDP 封装把一台主机的 TUN 流量转发到另一台主机。两台主机各自运行同一份 Python 脚本假设 A 机 IP 是 192.0.2.10B 机 IP 是 192.0.2.20两边各建 tun0 并配好 10.0.0.1/24 和 10.0.0.2/24import os import fcntl import struct import socket import select TUNSETIFF 0x400454ca IFF_TUN 0x0001 IFF_NO_PI 0x1000 def open_tun(name): fd os.open(/dev/net/tun, os.O_RDWR) ifr struct.pack(16sH, name.encode()[:15], IFF_TUN | IFF_NO_PI) fcntl.ioctl(fd, TUNSETIFF, ifr) return fd def main(tun_name, local_udp_port, peer_ip, peer_udp_port): tun_fd open_tun(tun_name) udp_fd socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp_fd.bind((0.0.0.0, local_udp_port)) print(relay ready, tun%s udp%d - %s:%d % (tun_name, local_udp_port, peer_ip, peer_udp_port)) while True: ready, _, _ select.select([tun_fd, udp_fd], [], [], 1.0) if tun_fd in ready: pkt os.read(tun_fd, 2048) udp_fd.sendto(pkt, (peer_ip, peer_udp_port)) print(tun - udp: %d bytes % len(pkt)) if udp_fd in ready: pkt, addr udp_fd.recvfrom(2048) os.write(tun_fd, pkt) print(udp - tun: %d bytes % len(pkt)) if __name__ __main__: import sys main(sys.argv[1], int(sys.argv[2]), sys.argv[3], int(sys.argv[4]))A 机上执行python3 relay.py tun0 7000 192.0.2.20 7000B 机上执行python3 relay.py tun0 7000 192.0.2.10 7000然后在 A 机ping 10.0.0.2B 机的 tun0 就会收到这个 ICMP 包并交给协议栈协议栈发现目标地址是自身于是产生回包。一个最小封装的跨主机三层链路就通了。这段代码刻意去掉了加密、认证、粘包处理真实项目不要去生产环境这么裸跑但作为理解 TUN 转发原理的 Demo 非常合适。4.5 抓包验证和 MTU 调整链路跑通之后用抓包工具验证是最直观的sudo tcpdump -i tun0 -nn你会看到10.0.0.1 10.0.0.2: ICMP echo request这就证明数据确实从 tun0 走了。如果换成抓外层物理网卡还会看到这些 ICMP 被包进了 UDP 报文里这正是“封装”二字的直观含义。这里有个非常关键的参数调整经验TUN 设备默认 MTU 是 1500但我用 UDP 封装后外层又多了一层 IP 头20 字节和 UDP 头8 字节。如果内层包刚好是 1500 字节加上外层 28 字节就超过了物理链路 MTU会导致分片分片在某些网络环境里会被丢弃表现出来就是“小包通、大包不通”。解决办法是把 TUN 设备的 MTU 调到 1400 左右sudo ip link set dev tun0 mtu 1400很多隧道工具让你手动调 MTU 不是没事找事就是在治这个病。容器网络里 VXLAN 要把 MTU 设成 1450也是同一个道理。5. 常见问题与排查技巧实录5.1 /dev/net/tun 打不开的三种原因现象原因解决方法No such file or directorytun 模块未加载或设备节点未创建modprobe tunmknod /dev/net/tun c 10 200Operation not permitted当前用户缺少CAP_NET_ADMIN能力加sudo容器里加--cap-add NET_ADMIN虚拟机里无法使用宿主超管理器未开启嵌套虚拟化检查虚拟化设置或在物理机/有权容器里测试这个设备节点是全局共享的只创建一次就行。真正创建具体 TUN/TAP 接口靠的是 ioctl而不是重复 mknod。5.2 用户态程序一退出设备就不见了TUN/TAP 有两种创建方式很多人在这里被绕晕用ioctl(TUNSETIFF)让用户态程序创建设备如果没设置IFF_PERSIST标志程序结束时设备会被内核回收用ip tuntap add预先创建设备设备本身和用户态程序没有生命周期绑定程序退出后接口还在但数据没人读自然就只是“挂着”。我调试时更习惯先用ip tuntap add把设备建好再跑用户态程序这样程序崩溃了设备不会跟着消失方便排查。5.3 tun 通了但上层服务不通怎么排查如果ping能通但 TCP 服务不通按这个顺序排查ip addr show tun0确认 IP、掩码没问题ip route get 对端IP确认路由指向 tun0而不是被默认路由截走tcpdump -i tun0 -nn看 TCP 握手包是否真的进出 tun0检查 MTU大包场景直接调低到 1400 再试如果用了 TAP 还要重点查 ARP 表TAP 要能正常收发 ARP 请求与响应。我遇到最多的情况不是封装逻辑写错而是路由被系统默认路由抢先了。记住一句话TUN 设备只处理内核明确路由给它的包不是所有包都会自动走它。5.4 写回环路新手最隐蔽的 Bug有个行为特别坑用户态程序从 TUN fd 读到一个包如果没想清楚就原样write回同一个 fd内核会认为“从这块网卡收到了一个新包”再次交给用户态程序。于是程序又读到一个包又写回无限循环CPU 直接飙满。这不是写回操作本身非法而是“读出来再写回同一个设备”构成了环路。如果你想要的是转发应该写到别的 fd比如 UDP socket、另一个 TUN fd、真实网卡 fd而不是原路返回。如果只是做流量分析读完就丢绝不写回。这个教训我是在一次调试流量分析工具时踩进去的日志刷屏刷了几万行才反应过来。5.5 性能、多队列与后续扩展单队列 TUN 设备在大量小包场景下很容易成为瓶颈因为所有包都挤在一个 fd 上用户态只有一个线程在read。内核支持为 TUN/TAP 开启多队列ip tuntap add dev tun0 mode tun multi_queue开启后可以用多条 fd 分别绑定到不同队列用户态多线程并行读取吞吐量会好不少。但多队列也意味着用户态要处理多路并发、包乱序复杂度直线上升。如果只是轻度实验单队列完全够用。要是想在高性能场景里玩出花可以关注一下 eBPF/XDP 方向——在那套架构里数据包可以在内核里直接编写程序处理TUN/TAP 负责的是把包交到用户态的“最后一段路”。两者不是替代关系而是互补关系。我个人在实际操作中的体会是TUN/TAP 的上手门槛其实不高核心就是“一个文件描述符”和“两个方向”但真正理解它需要亲手跑一遍完整链路——创建、读写、封装、对端注入、抓包验证。走完这一套你对 Linux 网络栈的掌控感会提升一个台阶。后续如果再写进阶内容我打算从“基于 TUN 自建可控三层通道的完整工程实现”讲起把加密、重连、路由策略一个个加进去那才是真正把 TUN/TAP 的威力完全用起来的样子。