第一部分: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 () 函数:
- 创建对端设备:通过
rtnl_create_link()创建 peer 设备(比如 veth1)。 - 注册设备:调用
register_netdevice()将 veth0 和 veth1 注册到内核网络设备列表中。 - 建立关联:通过
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 做了两件事:
- 修改归属:重新设置 skb 的协议类型和所属设备(skb->dev 变成了接收端 veth1)。
- 触发接收:
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 标志位时:
- 内核会为进程分配一个新的 struct net 对象。
- 进程的 nsproxy 指针指向这个新对象。
- 结果:进程拥有了独立的网络设备、路由表和 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); ... }解析:
- 判断标志位:如果创建进程时没说要隔离网络(CLONE_NEWNET),那就直接复用老的(old_net)。
- net_alloc():给新容器申请了一个 "空房间"(内存空间)。
- 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); }逻辑:
- 系统启动时,ip_fib_init 被调用,把 fib_net_ops 注册到全局链表。
- 当创建新命名空间时,setup_net 遍历链表找到了 fib_net_ops。
- 调用 fib_net_init (net)。
- 结果:新的命名空间里生成了一套独立的路由表
案例 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 的核心操作:
- 在宿主机创建 veth 对(都在宿主机)。
- 把其中一端(如 veth1)通过 dev_net_set 操作,"扔" 进容器的命名空间。
- 从此,宿主机看不到 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。
代码追踪
- 发送数据:调用 ip_queue_xmit。
- 获取上下文:
// 从 socket 中取出当初贴上去的命名空间标签 sock_net(sk)- 查找路由:
// 带着标签去查路由 rt = ip_route_output_ports(sock_net(sk), ...);- 最终落地:
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 的网络命名空间实现了多个独立协议栈" 这个说法其实不是很准确。
真实情况:
- 代码只有一套:内核里的网络代码(TCP/IP 协议栈的实现逻辑)只有一份,没有复制。
- 数据被隔离了:所谓的 "隔离",仅仅是把全局变量(如路由表、iptables 规则、设备列表)打包进一个结构体 struct net。
- 指针的魔法:
- 每个进程指向一个 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时,内核走了这几步:
- 申请内存:调用 alloc_netdev_mqs。注意这里传入了 br_dev_setup 函数指针。
- 初始化:alloc_netdev 内部会调用 br_dev_setup。这个函数会初始化刚才申请的 net_device(设置名字、MTU)和 net_bridge(初始化自旋锁、端口列表)。
- 注册:调用 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 br05. 配置 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原理:
- 当包从 br0 走向 eth0(物理网卡)准备发往外网时,iptables 把包的源 IP 从 192.168.0.2 改写成宿主机的 IP(比如 10.162.x.x)。
- 外网机器收到包,以为是宿主机发的,回包给宿主机。
- 宿主机收到回包后,再把 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原理:
- 外网访问 宿主机 IP:8088。
- 数据包刚到宿主机 eth0,在路由判断之前(PREROUTING 链),iptables 拦截了它。
- iptables 把包的目的 IP 从 "宿主机 IP:8088" 修改为 "192.168.0.2:80"。
- 宿主机根据路由表,把这个包转发给 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 MASQUERADE4.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:800voice · GitHub