当前位置: 首页 > news >正文

Linux 网络虚拟化深度解析:从 veth 设备对到容器网络实战

第一部分:veth 设备对 —— 虚拟世界的 "网线"

1.1 什么是 veth 设备对?

veth(Virtual Ethernet)设备对,可以理解为软件模拟的一对 "虚拟网卡",它们总是成对出现,就像用一根虚拟的 "网线" 把两个网络接口连在一起。

物理世界类比:想象两台电脑,用一根网线直连它们的网卡,数据就能互相传输。

虚拟世界实现:veth pair 就是软件实现的这种 "直连网线",一端叫 veth0,另一端叫 veth1,数据从 veth0 发出,必然从 veth1 收到,反之亦然。

它和本机的 lo(回环设备)不同,lo 是 "自己发给自己",而 veth 是 "一端发给另一端",是跨命名空间或跨容器通信的基础。

1.2 如何创建和配置 veth 设备对?

在 Linux 系统中,你可以通过 ip 命令来创建和管理 veth 设备对。

创建 veth 对
ip link add veth0 type veth peer name veth1

这条命令会创建一对虚拟设备:veth0 和 veth1。它们是 "对等" 的,任何一端发出的数据包都会被另一端接收。

查看设备
ip link show

你会看到类似这样的输出:

5: veth0@veth1: ... 6: veth1@veth0: ...

这里的 @符号表示它们是配对的。

配置 IP 地址

veth 设备需要配置 IP 才能通信:

ip addr add 192.168.1.1/24 dev veth0 ip addr add 192.168.1.2/24 dev veth1
启动设备
ip link set veth0 up ip link set veth1 up

启动后,你可以用 ifconfig 或 ip addr show 查看设备状态,确认它们处于 UP 和 RUNNING 状态。

1.3 如何让 veth 对之间通信?

即使配置了 IP 并启动了设备,它们之间可能还无法通信,因为 Linux 内核默认启用了反向路径过滤(rp_filter),它会检查数据包的源 IP 是否 "合理",如果不合理就丢弃。

关闭 rp_filter
echo 0 > /proc/sys/net/ipv4/conf/all/rp_filter echo 0 > /proc/sys/net/ipv4/conf/veth0/rp_filter echo 0 > /proc/sys/net/ipv4/conf/veth1/rp_filter
开启 accept_local

为了让设备能接收发往本机 IP 的数据包,还需要开启 accept_local:

echo 1 > /proc/sys/net/ipv4/conf/veth0/accept_local echo 1 > /proc/sys/net/ipv4/conf/veth1/accept_local

完成以上配置后,你就可以在 veth0 上 ping veth1 了:

ping 192.168.1.2 -I veth0

你会看到成功的 ping 响应,证明 veth 对之间已经可以正常通信。

1.4 veth 设备的底层创建过程

veth 设备的创建和管理是由 Linux 内核的网络子系统负责的,其核心代码位于drivers/net/veth.c

初始化

内核模块加载时,会调用 veth_init () 函数,注册 veth 设备的操作接口:

static __init int veth_init(void) { return rtnl_link_register(&veth_link_ops); }
创建设备对

当你执行ip link add ... type veth ...时,内核会调用 veth_newlink () 函数:

  1. 创建对端设备:通过rtnl_create_link()创建 peer 设备(比如 veth1)。
  2. 注册设备:调用register_netdevice()将 veth0 和 veth1 注册到内核网络设备列表中。
  3. 建立关联:通过netdev_priv()获取设备的私有数据结构 veth_priv,并用rcu_assign_pointer()将两个设备的 peer 指针互相指向对方,形成 "对"。
struct veth_priv { struct net_device __rcu *peer; atomic64_t dropped; };

这样,veth0 的 peer 指向 veth1,veth1 的 peer 指向 veth0,数据包就能在它们之间 "穿越"。

veth 设备的操作函数

veth 设备的行为由其操作函数集 veth_netdev_ops 定义,其中最关键的是发送函数 ndo_start_xmit,它被设置为 veth_xmit:

static const struct net_device_ops veth_netdev_ops = { .ndo_init = veth_dev_init, .ndo_open = veth_open, .ndo_stop = veth_close, .ndo_start_xmit = veth_xmit, // 数据包发送函数 .ndo_change_mtu = veth_change_mtu, ... };

当数据从 veth0 发出时,内核会调用 veth_xmit (),这个函数会查找 veth0 的 peer(即 veth1),然后将数据包 "转发" 给 veth1 的接收队列,完成 "虚拟网线" 的数据传递。

1.5 veth 设备的数据传输原理

veth 其实是一个 "管道"。它和日常接触的 lo(回环)设备非常像,只不过 veth 多了个结对的概念。

  • lo 设备:自己发给自己,数据包在内核里转一圈回到自己。
  • veth 设备:A 发给 B。A 是 veth0,B 是 veth1。

在代码层面,veth 的发送函数 veth_xmit 做的事情非常简单:它根本不走物理网线,也不走复杂的协议栈处理,而是直接把数据包 "扔" 给它的 "兄弟"(peer)。

发送过程详解:veth_xmit

当你在 veth0 上发送数据(比如 ping 包)时,内核网络栈会调用 veth 设备的发送函数:veth_xmit。

第一步:找到 "兄弟"

// 获取 veth 设备的对端 struct veth_priv *priv = netdev_priv(dev); struct net_device *rcv; rcv = rcu_dereference(priv->peer);
  • dev 是当前发送数据的设备(比如 veth0)。
  • priv->peer 就是 veth0 的 "另一半"(veth1)。

第二步:把包扔过去

if (likely(dev_forward_skb(rcv, skb) == NET_RX_SUCCESS)) { // 发送成功 }

这里调用了 dev_forward_skb。这个函数的作用就是把数据包(skb)转发给接收端设备(rcv,即 veth1)。注意,这里并没有真正把数据发到物理网卡上,而是在内存中把数据包 "移交" 了。

移交过程详解:dev_forward_skb

dev_forward_skb 做了两件事:

  1. 修改归属:重新设置 skb 的协议类型和所属设备(skb->dev 变成了接收端 veth1)。
  2. 触发接收
return netif_rx(skb);

这是最关键的一步。netif_rx 是 Linux 网络设备层接收数据包的标准入口。

  • 对于物理网卡,数据是硬件中断来了之后调用这个函数。
  • 对于 veth,它是直接在软件里调用这个函数,假装是 "硬件收到了数据"。
接收过程详解:软中断与队列

既然调用了 netif_rx,接下来的流程就和物理网卡收到数据一模一样了。

入队

enqueue_to_backlog

数据包(skb)被放入了 CPU 的 "输入队列"(input_pkt_queue)。这就好比把信件扔进了 veth1 的 "信箱" 里。

触发软中断

__raise_softirq_irqoff(NET_RX_SOFTIRQ);

系统触发了一个软中断(SoftIRQ),告诉内核:"嘿,veth1 收到数据了,快来处理!"

注意:这里是软中断,不是硬件中断。因为全是软件模拟的,效率非常高。

处理接收(Poll)

net_rx_action() |--> process_backlog() |--> __netif_receive_skb() |--> deliver_skb (送到协议栈,比如 IP 层)
  • 内核的软中断处理函数 net_rx_action 会被调度执行。
  • 它会从队列里把刚才放进去的包拿出来。
  • 然后一层层往上送,经过 IP 层、TCP/UDP 层,最终到达应用程序(或者如果是 ping 包,就由 ICMP 协议处理并回包)。

1.6 为什么 veth 对如此重要?

veth pair 是 Docker、Kubernetes 等容器技术实现网络隔离和通信的核心机制:

  • 容器网络:每个容器都有自己的网络命名空间,容器内的 eth0 实际上就是 veth pair 的一端,另一端在宿主机上,连接到网桥(bridge)或路由表,从而实现容器与宿主机、容器与外部网络的通信。
  • 网络命名空间通信:不同 network namespace 之间无法直接通信,veth pair 就是连接它们的 "桥梁"。

第二部分:网络命名空间 —— 隔离的基石

2.1 什么是网络命名空间?

默认情况下,所有的进程(包括 Docker 容器里的进程)都在一个叫 host net 的默认命名空间里。大家共用一张路由表、共用所有的网卡(eth0, lo 等)、共用 iptables。

当你创建一个新的网络命名空间(比如叫 net1),你就相当于凭空变出了一套全新的、独立的网络协议栈:

  • 独立的网卡:在这个空间里,你看不到宿主机的 eth0,除非特意把它放进去。
  • 独立的 IP:你可以给这个空间配一个和宿主机完全不同的 IP 段。
  • 独立的规则:这个空间里的 iptables 规则和宿主机互不干扰。

2.2 内核实现原理

数据结构关联

每个进程(task_struct)都有一个指针指向它的命名空间(nsproxy)。nsproxy 里有一个指针指向 struct net。

关键点:struct net 这个结构体里,包含了该空间独享的路由表、iptables、甚至独享的回环设备(loopback_dev)。这就是为什么你在容器里执行 ifconfig 也能看到 lo 设备的原因 —— 那是它自己独有的 lo,不是宿主机的。

默认归属

所有进程的 task_struct 结构体中,都有一个成员叫 nsproxy(命名空间代理)。默认情况下,大家都指向同一个全局变量:init_net(初始网络命名空间)。这意味着:大家共用一套路由表、iptables、网卡设备。

隔离状态

当进程调用 clone 系统调用并带上 CLONE_NEWNET 标志位时:

  1. 内核会为进程分配一个新的 struct net 对象。
  2. 进程的 nsproxy 指针指向这个新对象。
  3. 结果:进程拥有了独立的网络设备、路由表和 iptables,与其他进程彻底隔离。

2.3 创建命名空间的内核流程

系统的起点:init 进程与 init_net

Linux 系统的 0 号 / 1 号进程(init 进程)的初始化代码:

// file: init/init_task.c struct task_struct init_task = INIT_TASK(init_task); // file: include/linux/init_task.h #define INIT_TASK(tsk) \ { \ ... .nsproxy = &init_nsproxy, \ ... }

这行代码硬编码了 init 进程使用初始的命名空间代理。

// file: kernel/nsproxy.c struct nsproxy init_nsproxy = { ... .net_ns = &init_net, };

init_nsproxy 结构体里,.net_ns 指针指向了 init_net。

// file: net/core/net_namespace.c struct net init_net = { ... }; // 定义了初始网络命名空间

init_net 是全局变量,代表宿主机原本的那个网络环境。

创建新命名空间:copy_net_ns

当我们在用户态执行ip netns add xxx或者 Docker 启动时,底层会调用 clone 系统调用,最终进入内核的 copy_net_ns 函数:

// file: net/core/net_namespace.c struct net *copy_net_ns(unsigned long flags, struct user_namespace *user_ns, struct net *old_net) { struct net *net; // 1. 检查标志位 if (!(flags & CLONE_NEWNET)) return get_net(old_net); // 如果没带 CLONE_NEWNET 标志,直接增加引用计数 // 2. 申请新空间 net = net_alloc(); // 分配一个新的 struct net 内存 // 3. 初始化新空间 rv = setup_net(net, user_ns); ... }

解析:

  1. 判断标志位:如果创建进程时没说要隔离网络(CLONE_NEWNET),那就直接复用老的(old_net)。
  2. net_alloc():给新容器申请了一个 "空房间"(内存空间)。
  3. setup_net():最关键的一步,相当于给这个 "空房间" 进行 "装修",配置家具(路由表、iptables 等)。
插件化机制:pernet_operations

内核网络功能非常复杂,不可能把所有初始化代码都写在 setup_net 里。Linux 采用了 "注册回调" 的设计模式。

// file: include/net/net_namespace.h struct pernet_operations { struct list_head list; // 链表节点 int (*init)(struct net *net); // 初始化函数指针 void (*exit)(struct net *net); // 退出函数指针 ... };

定义了一个标准接口。每个网络子系统(如路由、iptables、网设备)都要遵循这个接口。

// file: net/core/net_namespace.c static struct list_head *first_device = &pernet_list; int register_pernet_subsys(struct pernet_operations *ops) { error = register_pernet_operations(first_device, ops); ... }

这是一个注册函数。比如路由模块启动时,会调用这个函数,把自己的初始化函数(init)注册到全局链表 pernet_list 上。

触发初始化:setup_net 遍历链表

回到创建命名空间时的 setup_net 函数:

// file: net/core/net_namespace.c static __net_init int setup_net(struct net *net, struct user_namespace *user_ns) { const struct pernet_operations *ops; list_for_each_entry(ops, &pernet_list, list) { error = ops_init(ops, net); } }
  • list_for_each_entry:这是一个宏,用来遍历 pernet_list 链表。
  • ops_init(ops, net):遍历到每一个子系统时,调用它的 init 函数,并把刚才申请的新 net 结构体传进去。
实例:路由表与 iptables 的初始化

案例 A:路由表 (FIB)

// file: net/ipv4/fib_frontend.c static struct pernet_operations fib_net_ops = { .init = fib_net_init, .exit = fib_net_exit, }; void __init ip_fib_init(void) { register_pernet_subsys(&fib_net_ops); }

逻辑:

  1. 系统启动时,ip_fib_init 被调用,把 fib_net_ops 注册到全局链表。
  2. 当创建新命名空间时,setup_net 遍历链表找到了 fib_net_ops。
  3. 调用 fib_net_init (net)。
  4. 结果:新的命名空间里生成了一套独立的路由表

案例 B:iptables NAT 表

// file: net/ipv4/netfilter/iptable_nat.c static struct pernet_operations iptable_nat_net_ops = { .init = iptable_nat_net_init, .exit = iptable_nat_net_exit, };

同理,当创建新命名空间时,iptable_nat_net_init 被调用,为新空间分配独立的 NAT 规则表。

2.4 网卡的归属与迁移

默认归属

当一个网卡设备(比如 veth)刚被创建出来时,它默认是属于默认网络命名空间(即 init_net,也就是宿主机)的。

//file: core/dev.c struct net_device *alloc_netdev_mqs(...) { // 关键行:创建时,默认把设备的 nd_net 指针指向 init_net dev_net_set(dev, &init_net); }

struct net_device 是内核描述网卡的结构体。它里面有一个成员 nd_net,用来记录这个网卡属于哪个命名空间。刚出生时,它就被强制指向了全局的 init_net。

动态迁移

既然默认在宿主机,那怎么给容器用呢?答案是 "搬家"。

//file: include/linux/netdevice.h void dev_net_set(struct net_device *dev, struct net *net) { release_net(dev->nd_net); // 1. 减少旧命名空间的引用计数 dev->nd_net = hold_net(net); // 2. 把指针指向新的命名空间,并增加新空间的引用计数 }

这就是 Docker 的核心操作:

  1. 在宿主机创建 veth 对(都在宿主机)。
  2. 把其中一端(如 veth1)通过 dev_net_set 操作,"扔" 进容器的命名空间。
  3. 从此,宿主机看不到 veth1,只有容器能看到。

2.5 Socket 的归属

当你在容器里运行 Nginx 监听 80 端口时,内核怎么知道这是容器里的 80,而不是宿主机的 80?

核心原理:Socket 继承自创建它的进程。

进程(task_struct)手里拿着命名空间的门票(nsproxy)。当进程创建 Socket 时,内核会顺手把这张门票复印一份,贴在 Socket 上。

// 进程创建 socket 的核心函数 int sock_create(...) { // 关键行:获取当前进程的命名空间 current->nsproxy->net_ns // 并传给底层创建函数 return __sock_create(current->nsproxy->net_ns, family, type, protocol, res, 0); } // 底层赋值函数 static inline void sock_net_set(struct sock *sk, struct net *net) { write_pnet(&sk->sk_net, net); // 把命名空间指针写入 socket 结构体 }
  • sk 是内核中描述 socket 的结构体。
  • sk->sk_net 是 socket 里的一个指针。
  • 这一连串调用确保了:谁生的孩子像谁。宿主机进程生的 Socket 归宿主机管,容器进程生的 Socket 归容器管。

2.6 路由查找的真相

当数据包发出去时,内核是怎么查到容器自己的路由表,而不是宿主机的路由表?

核心逻辑:以前(没有命名空间时),路由查找函数是全局的。现在,路由查找函数多了一个参数:struct net *net。

代码追踪

  1. 发送数据:调用 ip_queue_xmit。
  2. 获取上下文
// 从 socket 中取出当初贴上去的命名空间标签 sock_net(sk)
  1. 查找路由
// 带着标签去查路由 rt = ip_route_output_ports(sock_net(sk), ...);
  1. 最终落地
static inline struct fib_table *fib_get_table(struct net *net, u32 id) { // 关键点:net->ipv4.fib_table_hash // 不是查全局变量,而是查 net 结构体里的成员变量! ptr = id == RT_TABLE_LOCAL ? &net->ipv4.fib_table_hash[...] : &net->ipv4.fib_table_hash[...]; ... }

这就好比查字典:

  • 旧模式:全办公室只有一本字典(全局路由表),大家抢着用。
  • 新模式:每个人桌子上都有一本字典(struct net 里的路由表)。查字典时,先看你是哪个部门的(sock_net (sk)),然后直接拿你桌子上的那本查。

2.7 所谓的 "虚拟化" 到底是什么?

"Linux 的网络命名空间实现了多个独立协议栈" 这个说法其实不是很准确。

真实情况

  1. 代码只有一套:内核里的网络代码(TCP/IP 协议栈的实现逻辑)只有一份,没有复制。
  2. 数据被隔离了:所谓的 "隔离",仅仅是把全局变量(如路由表、iptables 规则、设备列表)打包进一个结构体 struct net。
  3. 指针的魔法
    • 每个进程指向一个 struct net。
    • 每个网卡指向一个 struct net。
    • 每个 Socket 指向一个 struct net。

一句话总结:网络命名空间不是 "克隆了多套内核网络功能",而是通过 struct net 结构体做了一层逻辑隔离,让不同的进程以为自己独享了整套网络环境。


第三部分:Bridge 网桥 —— 虚拟交换机

3.1 为什么需要 Bridge?

前面的章节讲了 "隔离"(Namespace),但隔离之后,容器就成了一个个孤岛,无法互相通信。Bridge(网桥)就是为了解决这个问题而生的。

物理世界类比:在机房里,如果要把几十台服务器连起来,我们不会把它们用网线两两互联,而是把它们都插在一台交换机上。

虚拟世界实现:Linux Bridge 就是一个软件交换机,它有很多 "插口"(端口),可以把多根 veth 网线插进来。

3.2 搭建 Bridge 的基本步骤

创建交换机
brctl addbr br0

这行命令在宿主机上虚拟出了一台交换机,名字叫 br0。此时它还是悬空的,没连任何设备。

插网线
ip link set dev veth1_p master br0 ip link set dev veth2_p master br0

这两行命令把原本孤立的 veth1_p 和 veth2_p 都 "挂载" 到了 br0 上。这就相当于把两根网线插进了交换机的插口。

配置网关 IP
ip addr add 192.168.0.100/24 dev br0

给交换机配置一个 IP。这个 IP 通常作为容器的网关。

激活设备
ip link set br0 up ip link set veth1_p up ip link set veth2_p up

把网卡和交换机都启动(UP 状态),电路才算真正接通。

3.3 Bridge 的内核 "真身":1+1 结构

在用户态看,Bridge 就是一个叫 br0 的设备。但在内核态,一个 Bridge 其实是由两个相邻存储的内核对象组成的:

  • struct net_device:这是 "面子"。因为 Bridge 在 Linux 眼里首先得是一个网络设备,它得有名字、MAC 地址、状态(UP/DOWN),能被 ifconfig 看到。
  • struct net_bridge:这是 "里子"。这是专门给 Bridge 用的控制结构,里面存着转发表(MAC 地址表)、端口列表等交换机特有的数据。

代码解析(br_add_bridge)

alloc_netdev(sizeof(struct net_bridge), ...)

这个调用非常精妙。它一次性申请了一块大内存,前半部分放 net_device,后半部分紧挨着放 net_bridge。这样设计是为了内存访问的局部性,提高效率。

3.4 Bridge 的诞生:从申请到注册

当你执行brctl addbr br0时,内核走了这几步:

  1. 申请内存:调用 alloc_netdev_mqs。注意这里传入了 br_dev_setup 函数指针。
  2. 初始化:alloc_netdev 内部会调用 br_dev_setup。这个函数会初始化刚才申请的 net_device(设置名字、MTU)和 net_bridge(初始化自旋锁、端口列表)。
  3. 注册:调用 register_netdev (dev)。这一步把 Bridge 正式注册到内核网络子系统中,这时候你在系统里就能看到 br0 了。

3.5 核心机制:Hook 机制(拦截数据包)

这是理解 Bridge 工作原理最关键的一点。

当执行brctl addif br0 veth1_p时,不仅仅是把 veth 挂到了 Bridge 的列表里,更重要的是修改了 veth 的行为。

netdev_rx_handler_register(dev, br_handle_frame, p);

这行代码给 veth1_p 安装了一个 "拦截器"。

  • 正常情况:网卡收到包 -> 交给协议栈(IP 层 / TCP 层) -> 给应用程序。
  • 加入 Bridge 后:网卡收到包 -> 被 br_handle_frame 拦截 -> 交给 Bridge 处理(转发) -> 不再往上传给协议栈(除非是发给 Bridge 自身 IP 的包)。

结论:加入 Bridge 的网卡,实际上 "退化" 成了一个纯粹的交换机端口,它不再处理 IP 层逻辑,只负责收发数据帧。

3.6 数据包转发全流程

一个数据包从 Docker1 到 Docker2 的完整旅程:

┌─────────────────────────────────────────────────────────────────┐ │ 数据包转发流程 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────┐ │ │ │ Docker1 │ │ │ │ veth1 │ ────┐ │ │ │192.168. │ │ │ │ │ 0.101 │ │ Step 1: 发包 │ │ └─────────┘ │ Docker1里的进程发送数据 │ │ │ 数据包通过容器内的veth1发出 │ │ ▼ │ │ ┌───────────┐ │ │ │ veth1_p │ │ │ │ (宿主机端) │ ────┐ │ │ └───────────┘ │ │ │ │ Step 2: 对端接收与拦截 │ │ │ 宿主机的veth1_p收到数据 │ │ │ 因为注册了rx_handler │ │ │ 内核调用br_handle_frame │ │ ▼ │ │ ┌───────────┐ │ │ │ br0 │ │ │ │ (Bridge) │ │ │ └───────────┘ │ │ │ │ │ │ Step 3: Bridge查表转发 │ │ │ 学习: MAC_A在veth1_p端口 │ │ │ 查找: 目标MAC_B在veth2_p端口 │ │ │ 改写: skb->dev改为veth2_p │ │ ▼ │ │ ┌───────────┐ │ │ │ veth2_p │ │ │ │ (宿主机端) │ ────┐ │ │ └───────────┘ │ │ │ │ Step 4: 发往下一个端口 │ │ │ 调用dev_queue_xmit │ │ │ 把包发给veth2_p │ │ ▼ │ │ ┌─────────┐ │ │ │ Docker2 │ │ │ │ veth2 │ │ │ │192.168. │ │ Step 5: 进入目标容器 │ │ │ 0.102 │ │ veth2_p发送的数据瞬间出现在veth2上 │ │ └─────────┘ │ Docker2的veth2收到包 │ │ │ 上传给协议栈,最终被应用程序接收 │ │ ▼ │ │ [完成] │ │ │ └─────────────────────────────────────────────────────────────────┘

详细步骤解析

步骤 1:发包

  • Docker1 里的进程发送数据。
  • 数据包通过容器内的 veth1 发出。

步骤 2:对端接收与拦截

  • 宿主机的 veth1_p 收到数据。
  • 关键转折:因为 veth1_p 之前注册了 rx_handler,内核发现它属于某个 Bridge,于是调用 br_handle_frame。

步骤 3:Bridge 查表转发

  • br_handle_frame -> br_handle_frame_finish。
  • 学习:Bridge 记录 "哦,MAC_A 在 veth1_p 这个端口"。
  • 查找:Bridge 查表发现目标 MAC_B 在 veth2_p 端口。
  • 改写:修改 skb->dev,把目标设备从 veth1_p 改为 veth2_p。

步骤 4:发往下一个端口

  • 调用 dev_queue_xmit 把包发给 veth2_p。
  • veth2_p 发送数据。

步骤 5:进入目标容器

  • 因为 veth 是成对的,veth2_p 发送的数据会瞬间出现在 veth2 上。
  • Docker2 里的 veth2 收到包,上传给协议栈,最终被 Docker2 的应用程序接收。

3.7 Bridge 的核心价值

  • 结构上:Bridge = 通用网卡设备 + 专用网桥控制块。
  • 机制上:Bridge 不是主动去拉数据,而是通过 Hook(钩子)机制,在网卡收到数据的第一时间进行拦截。
  • 流程上:数据包在宿主机内部走的是 veth -> Bridge Hook -> veth 的路径,完全在内核态完成,不经过物理网卡,也不经过复杂的 IP 路由,所以效率非常高。

这就是为什么 Docker 容器间通信速度极快的原因。


第四部分:容器网络实战 —— 从孤岛到互联

4.1 实战目标

通过一个 "纯手工打造 Docker 网络" 的实战案例,把 Network Namespace、veth pair、Bridge、路由、NAT、iptables 这些核心概念串联起来。

核心目标:理解容器(Container)是如何实现网络隔离,又是如何与外部世界通信的。

4.2 第一阶段:搭建 "集装箱"—— 网络隔离与连接

这一阶段的目标是创建一个隔离的网络环境,就像给应用造了一个独立的房间。

1. 创建 Network Namespace
ip netns add net1

创建一个隔离的网络空间 net1。在这个空间里,有自己的网卡、路由表,别人看不到它,它也看不到外面。这模拟了 Docker 容器的隔离性。

2. 创建 veth pair
ip link add veth1 type veth peer name veth1_p

创建一对虚拟网线。veth1 插在 net1 房间里,veth1_p 留在宿主机的大厅里。数据可以通过这根网线在 "房间" 和 "大厅" 之间传输。

3. 将 veth1 移动到命名空间
ip link set veth1 netns net1

把 veth1 这头 "拔" 下来,插到了 net1 这个命名空间里。宿主机上只能看到 veth1_p 了,veth1"消失" 了(其实是搬家了)。

4. 创建 Bridge
brctl addbr br0

创建一个虚拟交换机 br0。把 veth1_p 插在交换机上。

ip link set dev veth1_p master br0
5. 配置 IP
# 进入命名空间配置IP ip netns exec net1 ip addr add 192.168.0.2/24 dev veth1 ip netns exec net1 ip link set veth1 up # 给br0配置网关IP ip addr add 192.168.0.1/24 dev br0 ip link set br0 up ip link set veth1_p up

现状:此时,net1 里是个孤岛。虽然物理连接都通了,但它不知道怎么去外面的世界。

4.3 第二阶段:走出孤岛 —— 路由与转发

这一阶段解决 "容器访问外网" 的问题。

遇到的第一个坑:路由缺失

现象:在 net1 里 ping 外部 IP,提示 Network is unreachable。

原因:net1 的路由表里只有 "去 192.168.0.x 网段走 veth1" 的规则,它不知道去其他网段该走哪里。

解决:添加默认路由

ip netns exec net1 ip route add default gw 192.168.0.1 veth1

告诉 net1:"所有不知道去哪的包,都扔给网关 192.168.0.1(也就是宿主机的 br0)"。

遇到的第二个坑:转发未开启

现象:加了路由还是不通。

原因:宿主机默认不开启 IP 转发功能,它收到包后不知道要转发出去,而是直接丢弃。

解决:开启 IP 转发

sysctl net.ipv4.conf.all.forwarding=1

打开宿主机的 "路由器模式"。

遇到的第三个坑:NAT(SNAT/MASQUERADE)

现象:包发出去了,但外网机器不回消息。

原因:外网机器收到包,发现源 IP 是 192.168.0.2(私有 IP),它根本不认识这个网段,不知道怎么回包,或者路由器直接就把私有 IP 的包过滤了。

终极解决:SNAT(源地址转换)

iptables -t nat -A POSTROUTING -s 192.168.0.0/24 -o eth0 -j MASQUERADE

原理

  1. 当包从 br0 走向 eth0(物理网卡)准备发往外网时,iptables 把包的源 IP 从 192.168.0.2 改写成宿主机的 IP(比如 10.162.x.x)。
  2. 外网机器收到包,以为是宿主机发的,回包给宿主机。
  3. 宿主机收到回包后,再把 IP 改回 192.168.0.2 发给容器。

这就通了!

4.4 第三阶段:请君入瓮 —— 端口映射

这一阶段解决 "外网访问容器" 的问题(比如访问容器里的 Web 服务)。

需求

外网想访问容器 net1 里的 80 端口。

难点

外网只知道宿主机的 IP,不知道怎么找到容器。

解决方案:DNAT(目的地址转换)
iptables -t nat -A PREROUTING -p tcp --dport 8088 -j DNAT --to-destination 192.168.0.2:80

原理

  1. 外网访问 宿主机 IP:8088。
  2. 数据包刚到宿主机 eth0,在路由判断之前(PREROUTING 链),iptables 拦截了它。
  3. iptables 把包的目的 IP 从 "宿主机 IP:8088" 修改为 "192.168.0.2:80"。
  4. 宿主机根据路由表,把这个包转发给 br0,进而通过 veth 传给容器。

效果:这就实现了 Docker 的-p 8088:80端口映射功能。

4.5 完整的网络拓扑图

┌─────────────────────────────────────────────────────────────────────────┐ │ 容器网络完整拓扑 │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────────┐ ┌──────────────────┐ │ │ │ 外部网络 │ │ 外部网络 │ │ │ │ (互联网) │ │ (互联网) │ │ │ └────────┬─────────┘ └────────┬─────────┘ │ │ │ │ │ │ │ 访问宿主机:8088 │ 回包给宿主机IP │ │ ▼ ▼ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ 宿主机 │ │ │ │ │ │ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ │ │ eth0 │ │ iptables│ │ br0 │ │ │ │ │ │物理网卡 │◄──────►│ NAT规则 │◄──────►│ 网桥 │ │ │ │ │ │10.162...│ │ SNAT │ │192.168 │ │ │ │ │ └─────────┘ │ DNAT │ │ .0.1 │ │ │ │ │ └─────────┘ └────┬────┘ │ │ │ │ │ │ │ │ │ ┌─────────────────────┼───────────┐│ │ │ │ │ │ ││ │ │ │ ▼ ▼ ││ │ │ │ ┌──────────┐ ┌──────────┐ ││ │ │ │ │ veth1_p │ │ veth2_p │ ││ │ │ │ │ │ │ │ ││ │ │ │ └────┬─────┘ └────┬─────┘ ││ │ │ └────────────────────┼─────────────────────┼─────────────┘│ │ │ │ │ │ │ │ ┌────────────┼─────────────────────┼────────────┐ │ │ │ │ │ │ │ │ │ │ ▼ │ ▼ │ │ │ │ ┌───────────────┐ │ ┌───────────────┐ │ │ │ │ │ 容器 net1 │ │ │ 容器 net2 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ┌─────────┐ │ │ │ ┌─────────┐ │ │ │ │ │ │ │ veth1 │ │ │ │ │ veth2 │ │ │ │ │ │ │ │192.168 │ │ │ │ │192.168 │ │ │ │ │ │ │ │ .0.2 │ │ │ │ │ .0.3 │ │ │ │ │ │ │ └─────────┘ │ │ │ └─────────┘ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ 路由表: │ │ │ 路由表: │ │ │ │ │ │ default gw │ │ │ default gw │ │ │ │ │ │ 192.168.0.1 │ │ │ 192.168.0.1 │ │ │ │ │ └───────────────┘ │ └───────────────┘ │ │ │ │ │ │ │ │ │ veth pair │ veth pair │ │ │ │ 虚拟网线 │ 虚拟网线 │ │ │ │ │ │ │ │ └─────────────────────────┼──────────────────────────────────┼─┘ │ │ │ │ └──────────────────────────────────┘ │ │ │ 数据流: │ │ 1. 容器发出数据 → veth → br0 → SNAT改源IP → eth0 → 外网 │ │ 2. 外网回包 → eth0 → DNAT改目的IP → br0 → veth → 容器 │ │ │ └─────────────────────────────────────────────────────────────────────────┘

4.6 排错实战:常见问题与解决

问题 1:Network is unreachable

原因:容器内没有默认路由。

解决

ip netns exec net1 ip route add default gw 192.168.0.1
问题 2:能发出包但收不到回包

原因:宿主机未开启 IP 转发。

解决

sysctl -w net.ipv4.ip_forward=1
问题 3:外网无法访问容器服务

原因:未配置 DNAT 规则。

解决

iptables -t nat -A PREROUTING -p tcp --dport <宿主机端口> -j DNAT --to-destination <容器IP>:<容器端口>
问题 4:容器无法访问外网

原因:未配置 SNAT 规则。

解决

iptables -t nat -A POSTROUTING -s 192.168.0.0/24 -o eth0 -j MASQUERADE

4.7 一键获取完整项目代码

# 创建命名空间 ip netns add net1 # 创建veth对 ip link add veth1 type veth peer name veth1_p # 将veth1移入命名空间 ip link set veth1 netns net1 # 创建并配置Bridge brctl addbr br0 ip link set dev veth1_p master br0 ip addr add 192.168.0.1/24 dev br0 # 配置容器IP ip netns exec net1 ip addr add 192.168.0.2/24 dev veth1 ip netns exec net1 ip link set veth1 up ip netns exec net1 ip link set lo up # 启动所有设备 ip link set br0 up ip link set veth1_p up # 添加默认路由 ip netns exec net1 ip route add default gw 192.168.0.1 # 开启IP转发 sysctl -w net.ipv4.ip_forward=1 # 配置NAT iptables -t nat -A POSTROUTING -s 192.168.0.0/24 -o eth0 -j MASQUERADE # 端口映射(示例:将宿主机8088端口映射到容器80端口) iptables -t nat -A PREROUTING -p tcp --dport 8088 -j DNAT --to-destination 192.168.0.2:80

0voice · GitHub

http://www.jsqmd.com/news/820108/

相关文章:

  • 降低维普AI率有3个常见坑!90%同学都踩过这个软件最稳!
  • Windows Cleaner:免费开源的系统优化工具,彻底解决C盘空间不足问题
  • 微光成炬,防——养同行,旭明康泽:寻找健康守护人
  • 90%的AI从业者都在反复看的人工智能底层知识清单
  • 用代码管理技能:构建结构化个人技能库的工程实践
  • 从混沌到清晰:markdownReader如何让Chrome成为你的终极Markdown阅读器
  • 程序员如何构建“职业生涯投资组合”?别把所有筹码押在一门语言上
  • 无人机图像拼接:算法原理详解与OpenCV实现
  • Final Cut Pro用户紧急注意:Sora 2 v2.1已悄然开放本地渲染通道——错过这波整合红利,下一次API开放至少延迟117天
  • 设计模式实战指南:从理论到工程落地的技能库构建
  • 深度学习模型边缘部署技术与优化实践
  • AI智能体技能管理:构建语义化技能发现与调用系统
  • 滴滴开源企业级问卷系统架构解析:高并发、数据安全与微服务实践
  • 基于MCP协议构建AI代理长期记忆系统:mnemo-mcp部署与应用指南
  • 同一条链接,不同时段点击,呈现不同落地页,如何实现?
  • FPGA调试技术:ILA与VIO核心实战指南
  • 技能驱动开源赏金平台:从能力证明到任务匹配的技术实践
  • 为AI编程助手注入超级上下文:基于MCP协议构建项目级智能伙伴
  • 香港科技大学与MetaX联手:让AI回答问题的速度快13%秘诀
  • 助睿实验作业1:订单利润分流数据加工(零代码 ETL 完整流程)
  • ITO靶材制备工艺水平排名:相对密度与绑定率定性对比
  • shein 请求头加密算法逆向分析
  • Mac系统安装Claude
  • 10分钟精通rpatool:掌握Ren‘Py游戏资源管理的核心技术
  • 工作空间管理器:提升开发效率的环境切换与自动化工具
  • GelSight 视触觉3D显微系统 4.4 软件版本上线,粗糙度测量维度全面拓展
  • PROFINET工业以太网:实时通信与设备互操作性解析
  • UVa 220 Othello
  • 挑选工作效率提升工具,必这4个核心筛选标准
  • ROPfuscator:基于ROP链的代码混淆技术原理与实践