第一部分: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

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐