容器网络虚拟化

参考资料:深入理解Linux网络

相关实际问题

1、容器中的eth0和母机上的eth0是一个东西吗?

2、veth设备是什么,它是如何工作的?

3、Linux是如何实现虚拟网络环境的?

4、Linux如何保证同宿主机上多个虚拟网络环境中的路由表可以独立工作?

5、同一宿主机上多个容器之间是如何通信的?

6、Linux上的容器如何和外部机器通信?

veth设备对

回想一下在物理机组成的网络世界里,最基础、最简单的网络连接方式是什么? ====》eth(网卡)

那么在网络虚拟化实现的第一步,就是用软件来模拟物理世界中的网卡。===》veth(虚拟网卡)

image-20230604154208082

如图所示,ens33就是ethlo是本机网络中的回环设备loopbackloveth一样都是用软件虚拟机出来的一个设备。

veth如何使用

linux中可以使用ip命令创建一对veth。命令如下:

ip link add veth0 type veth peer name veth1

其中link表示link layer的意思,表示数据链路层。这个命令可以用于管理和查看网络接口,包括物理接口、也包括虚拟接口。

使用show命令进行查看

ip link show

image-20230604155425209

可以看到loens33 中有UP标记,表示这两个设备启动了。

eth0ens33)、lo等设备一样,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一下

image-20230604160742424

至此,一对虚拟设备已经建立起来了。不过若想它们之间可以通信,需要关闭反向过滤rp_filter,该模块会检查IP包是否符合要求,然后打开accept_local,接收本机IP数据包。命令如下:

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
echo 1 > /proc/sys/net/ipv4/conf/veth1/accept_local
echo 1 > /proc/sys/net/ipv4/conf/veth0/accept_local

若执行上述命令报错

image-20230604162826905

可以使用下面这种方式:

sudo bash -c 'echo 0 > /proc/sys/net/ipv4/conf/all/rp_filter'
sudo bash -c 'echo 0 > /proc/sys/net/ipv4/conf/veth0/rp_filter'
sudo bash -c 'echo 0 > /proc/sys/net/ipv4/conf/veth1/rp_filter'
sudo bash -c 'echo 1 > /proc/sys/net/ipv4/conf/veth1/accept_local'
sudo bash -c 'echo 1 > /proc/sys/net/ipv4/conf/veth0/accept_local'

现在在veth0ping一下veth1

ping 192.168.1.2 -I veth0

image-20230604163352358

在另外一个控制台上启动了tcpdump进行抓包,结果如下:

image-20230604163609426

由于两个设备之间是首次通信,所以veth0首先发出一个arp requestveth1收到后恢复一个arp reply。然后就是正常的ping包了。

veth底层创建过程

veth的相关源码位于driver/net/veth.c,其中初始化入口是veth_init

veth_init中注册了veth_link_opsveth设备的操作方法),它包含了veth设备的创建、启动和删除等回调函数。

image-20230604175035515

veth_newlink中,通过register_netdevice创建了peerdev两个网络虚拟设备。然后通过priv->peer指针来完成结对,dev中的priv->peer指向peer设备,peer设备中的priv->peer指向dev

接着是veth设备的启动过程

····
dev->netdev_ops = &veth_netdev_ops;
dev->ethtool_ops = &veth_ethtool_ops;
····

veth_netdev_opsveth设备的操作函数,例如发送过程中调用的函数指针ndo_start_xmit,对于veth设备来说就会调用到veth_xmit

veth网络通信过程

image-20230611160641220

网络设备层最后会通过ops->ndo_start_xmit来调用驱动进行真正的发送。

veth的网络与lo设备不同的就是使用的驱动程序不一样。其中lo设备最后使用的loopback_xmitveth设备使用的是veth_xmit

veth_xmit函数大致流程如下:

  1. 获取veth设备的对端(priv->peer
  2. 调用dev_forward_skb向对端发包(调用了eth_type_transskb的所属设备改为对端)
  3. lo设备一样最终会调用enqueue_to_backlog,将要发送的skb插入soft_data->input_pkt_queue队列并调用napi_schedule来触发软中断

小结

lo设备相比,veth是为了虚拟化技术而生的,它多了个结对的概念。在创建函数veth_newlink中,一次性创建了两个网络设备,并把对方的peer设置成了各自的peer,在发送数据的过程中,找到发送设备peer,然后发起软中断让对方接收就完事了。

网络命名空间

前面介绍了veth,有了veth可以创建出许多的虚拟设备,默认它们都是在宿主机网络中的。虚拟化中还有很重要的一步,那就是隔离。用docker举例,就是不能让A容器用到B容器的设备。

Linux上实现隔离的技术手段就是命名空间(namespace)。通过命名空间可以隔离容器的进程PID、文件系统挂载点、主机名等多种资源。不过此处要重点介绍的是网络命名空间(netnamespace,简称netns)。它可以为不同的命名空间从逻辑上提供独立的网络协议栈,具体包括网络设备、路由表、arp表、iptables以及套接字(socket)等。使得不同的网络空间都好像运行在独立的网络中一样。如图所示:

image-20230618165203775

如何使用网络命名空间

先看一下网络命名空间如何使用。创建一个新的命名空间net1,再创建一对veth,将veth的一头放到net1中。分别查看母机和net1空间内的iptables、设备。最后让两个命名空间通信,要达成的效果如图:

image-20230618165903697

详细过程:

# 创建网络命名空间
ip netns add net1
# 查看iptables、路由表以及网络设备
ip netns exec net1 route

image-20230618170528698

ip netns exec net1 iptables -L

image-20230618170627929

ip netns exec net1 ip link list

image-20230618170811134

由于新创建的网络命名空间,所以iptable、路由表都是空的,不过存在一个lo本地回环设备,默认状态是DOWN状态。

接下来创建一对veth,并把veth的另一头添加给它

ip link add veth1 type veth peer name veth1_p
ip link set veth1 netns net1

在母机上查看当前设备,发现已经看不到veth1了,只能看到veth1_p

image-20230806223752928

新设备已经到net1这个空间了

ip netns exec net1 ip link list

image-20230806224026163

把这对veth分别配置上IP,并把它们启动起来

ip addr add 192.168.0.100/24 dev veth1_p
ip netns exec net1 ip addr add 192.168.0.101/24 dev veth1
ip link set dev veth1_p up 
ip netns exec net1 ip link set dev veth1 up 

在母机和eth1中分别执行ifconfig查看当前启动的网络设备

ifconfig
ip netns exec net1 ifconfig

母机

image-20230806225114246

net1

image-20230806225228965

让它和母机通信试一下

ip netns exec net1 ping 192.168.0.100 -I veth1

image-20230806225415673

在这个空间里面,网络设备、路由表、arp表、iptables都是独立的,不会和母机冲突,也不会其他空间里的产物产生干扰,而且还可以通过veth和其他空间下的网络进行通信。

命名空间相关的定义

在内核中,很多组件都是和namspace有关系的。

关联命名空间

linux中每个进程(线程)都是用task_struct来表示的。每个task_struct都要关联到一个命名空间对象nsproxy,而nsproxy又包含了网络命名空间(netns)。对于网卡设备和socket来说,通过自己的成员来直接表明自己的归属。如图所示。

image-20230812145914675

拿网络设备来说,只有归属到当前网络命名空间下的时候才能通过ifconfig看到。

所有的网络设备刚创建出来都是在宿主机默认网络空间下。可以通过ip link set 设备名 netns 网络空间名将设备移动到另一个空间里去,这其实修改的就是net_device下的struct_net指针

网络命名空间定义

image-20230813124439879

可见每个net内核对象下都包含了自己的路由表、iptable以及内核参数配置等。

每一个网络命名空间——netns中都有一个loopback_dev,这就是为什么在刚创建出来的空间就有一个lo设备的底层原因。

在网络命名空间中最核心的数据结构是struct netns_ipv4 ipv4,在这个结构里定义了每一个网络空间专属的路由表、ipfilter以及各种内核参数。

网络命名空间的创建

进程与网络命名空间

Linux上存在着一个默认的网络命名空间,Linux中的1号进程初始使用该默认空间。其他所有进程都是由1号进程派生出来的,在派生clone的时候如果没有特别指定,所有的进程都将共享这个默认的网络空间。

其实内核提供了三种操作命名空间的方式,分别是clonesetnsunshare。本节只用clone来举例。

image-20230813125537285

先看一下默认的网络命名空间的初始化过程。

//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, \
}

上面的代码是在初始化第1号进程。可见nsproxy是已经创建好的init_nsproxy。再看init_nsproxy是如何创建的。

//file:kernel/nsproxy.c 
struct nsproxy init_nsproxy = { 
    .uts_ns=&init_uts_ns, 
    .ipc_ns=&init_ipc_ns, 
    .mnt_ns=NULL,
        .pid_ns=&init_pid_ns, 
    .net_ns=&init_net, 
};

初始的init_nsproxy里将多个命名空间都进行了初始化,其中我们关注的网络命名空间,用的是默认网络空间init_net。它是系统初始化的时候就创建好的。

//file:net/core/net_namespace.c 
struct net init_net ={
    .dev_base_head = LIST_HEAD_INIT(init_net.dev_base_head), 
};

EXPORT_SYMBOL(init_net);

//file: net/core/net_namespace.c 
static int _init net_ns_init(void) {
    ······
    setup_net(&init_net, &init_user_ns); 
    ······
    register_pernet_subsys(&net_ns_ops); 
    return 0;

}

上面的setup_net方法对这个默认网络命名空间进行初始化。如果创建子进程的过程中没有指定CLONE_NEWNET这个标志位,就直接还使用默认的网络空间。

如果创建进程过程中指定了CLONE_NEWNET标志位,那么就会重新申请一个网络命名空间出来。(它的调用链是do_fork=>copy_process =>copy_namespaces=> create_new_namespaces =>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; 
    // 重要!!!
    // 不指定CLONE_NEWNET就不会创建新的网络命名空间
    if(!(flags & CLONE_NEWNET))
        return get_net(old_net); 
    //申请新网络命名空间并初始化
    net=net_alloc();
    rv= setup_net(net,user_ns); 
}

网络命名空间内的子系统初始化

命名空间内的各个子系统都是在调用setup_net时初始化的,包括路由表、tcpproc伪文件系统、iptable规则读取,等等,所以这个小节也是蛮重要的。

由于内核网络模块的复杂性,在内核中将网络模块划分成了各个子系统。每个子系统都定义了一个初始化函数和一个退出函数。

//file: include/net/net_namespace.h
struct pernet_operations {
    // 链表指针
    struct list head list;
    // 子系统的初始化函数
    int (*init)(struct net *net);
    // 网络命名空间每个子系统的退出函数
    void (*exit)(struct net *net);
    void (*exit_batch)(struct list_head *net_exit_list);
    int *id;
    size_t size;
}

各个子系统通过调用register_pernet_subsysregister_pernet_device将其初始化函数注册到网络命名空间系统的全局链表pernet_list中。

image-20230913211210508

register_pernet_subsys来举例,简单看下它是如何将子系统都注册到pernet_list中的。

//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);
    .....
}

register_pernet_operations又会调用 register_pernet_operationsregister_pernet_operations中会把传入的ops链入pernet_list中。并且会遍历所有的网络命名空间,然后在这个空间内执行了ops_init初始化。这个初始化是网络子系统在注册的时候调用的。同样,当新的命名空间创建时,会遍历该全局变量pernet_list,执行每个子模块注册的初始化函数。

我们拿路由表来举例,路由表子系统通过register_pernet_subsysfib_net_ops注册进来。

//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);
    ......
}

这样每当创建一个新的网络命名空间时,就会调用fib_net_init来创建一套独立的路由规则。

添加设备

在一个设备刚创建出来的时候,他是属于默认网络命名空间init_net的,包括veth设备。不过可以在创建后进行修改,将设备添加到新的网络命名空间。

在执行修改设备所属的网络命名空间时没将dev->nd_net再指向新的netns,对于veth来说,它包含了两个设备。这两个设备可以放在不同的网络命名空间中。这就是Docker容器和其母机或者其他容器通信的基础。

socket与网络命名空间

其实每个socket都是归属于某个网络命名空间,由创建这个socket的进程所属的netns来决定的。

image-20230824223659435

网络收发如何使用网络命名空间

socket上记录了其归属的网络命名空间,需要在查找路由表之前先找到该命名空间,再找到网络命名空间中的路由表。

image-20230824223722489

小结

通过为不同的网络命名空间创建不同的struct net对象,从而每个struct net中都有独立的路由表,iptables等数据结构,每个设备、每个socket上也有指针表明自己属于哪个网络命名空间,通过这种方法从逻辑上看起来好像真的有多个协议栈一样。

虚拟交换机Bridge

Linux中的veth是一对能够相互连接、相互通信的虚拟网卡。通过使用它,可以使Docker容器和母机通信,或者在两个Docker容器中进行交流。

不过在实际工作中,我们会想在一台物理机上虚拟出几个、几十个容器,以求充分压榨物理机的硬件资源。但这样带来的问题是大量容器之间的网络互联。上面很简单的veth互联方案是没有办法直接工作的,我们该怎么办?

回想一下物理机的网络环境,多台不同的物理机是怎么样连接在一起的呢。

image-20230824224623115

在网络虚拟化环境里,和物理网络中的虚拟机一样,也需要这样一个软件来实现的设备。它需要很多虚拟网口,能把更多的虚拟机网卡连接在一起,通过自己的转发功能让这些虚拟网卡之间可以通信。在Linux中的这个软件就叫做Bridge(纯软件实现的)。

image-20230824224642392

如何使用Bridge

在分析它的工作原理之前,先看一下是如何使用的

创建两个不同的网络

Bridge是用来连接两个不同的虚拟网络的,所以在准备实验Bridge之前需要先用ip net命令构建出来两个不同的网络空间来

image-20230824225405330

首先创建一个net1

sudo ip netns add net1

接下来创建一对veth,设备名分别是veth1veth1_p,并把其中的一头veth1放到这个新的网络命名空间中。

sudo ip link add veth1 type veth peer name veth1_p
sudo ip link set veth1 netns net1

因为我们打算用这个veht1来通信,所以需要为其配置上IP,并启动它。

sudo ip netns exec net1 ip addr add 192.168.0.101/24 dev veth1
sudo ip netns exec net1 ip link set veth1 up

查看上述配置是否成功。

sudo ip netns exec net1 ip link list
sudo ip netns exec net1 ifconfig

image-20230826201818222

查看网络名称空间

ip netns list

image-20230826202011639

以同样的方式创建一个新的网络名称空间

netnsnet2

veth pairveth2,veth2_p

ip: 192.168.0.102

sudo ip netns add net2
sudo ip link add veth2 type veth peer name veth2_p
sudo ip link set veth2 netns net2
sudo ip netns exec net2 ip addr add 192.168.0.102/24 dev veth2
sudo ip netns exec net2 ip link set veth2 up
sudo ip netns exec net2 ip link list
sudo ip netns exec net2 ifconfig
sudo ip netns list

执行之后如下图所示:

image-20230826202624213

把两个网络连接到一起

我们刚刚创建了两个独立的网络环境,这个时候它们之间是不能互通的,需要创建一个虚拟交换机——Bridge,来把这两个网络环境连接起来

image-20230826202952108

创建过程如下。创建一个Bridge设备,把刚刚创建的两对veth中剩下的两头“插”到Bridge上来。

注意:执行brctl若报错brctl: command not found,则需要先安装响应的包,ubuntu中使用sudo apt-get install bridge-utils进行安装。

sudo brctl addbr br0
sudo ip addr add 192.168.0.100/24 dev br0
sudo ip link set dev veth1_p master br0
sudo ip link set dev veth2_p master br0

把Bridge以及插在其上的veth设备启动。

sudo ip link set veth1_p up
sudo ip link set veth2_p up
sudo ip link set br0 up
sudo brctl show

使用brctl show查看当前Bridge的状态

image-20230826204118331

网络连通测试

激动人心的时刻到了,我们在net1(通过指定ip netns exec net1 以及 -I veth1)里ping一下net2里的IP(192.168.0.102),如图所示:

image-20230826204727161

这样我们就在一台Linux上虚拟除了net1net2两个不同的网络环境的。我们还可以按照这种方式创建更多的网络,都可以通过一个Bridge连接到一起,这就是Docker中网络系统的工作原理。

Bridge是如何创建出来的

在内核中,Bridge是由两个相邻存储的内核对象来表示的

image-20230826205218784

内核中创建Bridge的关键代码在br_add_bridge这个函数里。

//file:net/bridge/br_if.c
int br_add_bridge(struct net *net, const char *name)
{
    //申请网桥设备,并用br_dev_setup来启动它
    dev = alloc_netdev(sizeof(struct net_bridge),name,br_dev_setup);
        dev_net_set(dev, net);
    dev->rtnl_link_ops = &br_link_ops;
    //注册网桥设备
    res = register_netdev(dev);
    if(res)
        free_netdev(dev);
    return res;
}

注册网桥的关键代码是alloc_netdev这一行。在这个函数里,将申请网桥的内核对象net_device。在这个函数调用里要注意两点

  • 第一个参数传入了struct_net_bridge的大小
  • 第三个参数传入的br_dev_setup是一个函数

带着这两点注意事项,进入alloc_netdev的实现中,发现是一个宏。

//file: include/linux/netdevice.h
#define alloc_netdev(sizeof priv,name, setup) alloc_netdev_mqs(sizeof_priv, name, setup,1,1)
//file: net/core/dev.c
struct net_device *alloc_netdev_mqs(int sizeof_priv, ..., void (*setup)(structnet device*))
{
    //申请网桥设备
    alloc_size = sizeof(struct net_device);
    if (sizeof_priv){
                alloc_size = ALIGN(alloc_size,NETDEV_ALIGN);
        alloc_size += sizeof_priv;
    }
        p= kzalloc(alloc_size,GFP_KERNEL);
    dev = PTRALIGN(P,NETDEV ALIGN);
    //网桥设备初始化
    dev->...= ...;
    //setup是一个函数指针,实际使用的是br_dev_setup
    setup(dev); 
    .....
}

在上述代码中,kzalloc是用来在内核态申请内核内存的。需要注意的是,申请的内存大小是一个struct net_device再加上一个struct net_bridge( 第一个参数传进来的)。一次性就申请了两个内核对象,这说明Bridge在内核中是由两个内核数据结构来表示的,分别是struct net_devicestruct net_bridge

申请完了一家紧接着调用setup,这实际是外部传入的br_dev_setup函数。在这个函数内部进行进一步的初始化。

//fle: net/bridge/br_device.c
void br_dev_setup(struct net_device *dev)
{
    struct net_bridge *br = netdev_priv(dev);
    dev->... = ...;
    br->... = ...;
    ......
}

总之,brctl addbr br0命令主要就是完成了Bridge内核对象 (struct net_devicestruct net_bridge)的申请以及初始化。

添加设备

调用brctl addif bro veth0给网桥添加设备的时候,会将veth设备以虚拟的方式连到网桥上。当添加了若干个veth以后,内核中对象的大概逻辑如图所示。

image-20230905220802370

其中vethstruct net_device来表示,Bridge的虚拟插口由struct net_bridge_port来表示。

//file: net/bridge/br_if.c
int br_add_if(struct net_bridge *br, struct net_device *dev)
{
    // 申请一个net_bridge_port
    struct net_bridge_port *p;
    p = new_nbp(br,dev);
    //注册设备接收函数
    err = netdev_rx_handler_register(dev, br_handle_frame, p);
        // 添加到bridge的已用端口列表里
    list_add_rcu(&p->list,&br->port list);
        ......
}

这个函数中的第二个参数dev传入的是要添加的设备。可以认为是veth的其中一头。比较关键的是net_bridge_port这个结构体,它模拟的是物理交换机上的一个插口。它起到一个连接的作用,把vethBridge连接了起来。new_nbp的源码如下:

//file: net/bridge/br_if.c
static struct net_bridge_port *new_nbp(struct net_bridge *br,struct net_device *dev)
{
    //申请插口对象
        struct net_bridge_port *p;
    p = kzalloc(sizeof(*p),GFP_KERNEL);
    //初始化插口
    index=find_portno(br);//寻找可用的端口
    p->br=br;
    p->dev= dev;
    p->portno=index;
    ......
}

find_portno寻找可用端口,p->br = brbridge设备关联了起来,p->dev = dev和代表veth设备的dev对象也建立了联系。

br_add_if中还调用netdev_rx_handler_register 注册了设备接收函数,设置veth上的rx_handlerbr_handle_frame后面在接收包的时候会回调到它

//file: net/core/dev.c
int netdev_rx_handler_register(struct net_device *dev,rx_handler_func_t *rx_handler,void *rx_handler_data)
{
    ......
        rcu_assign_pointer(dev->rx_handler_data,rx_handler_data);
    rcu_assign_pointer(dev->rx_handler,rx_handler);
}

数据包的处理过程

物理网卡的收包过程

image-20230904222454157

网络协议栈的处理

image-20230904222545948

数据包会被网卡先送到RingBuffer中,然后依次经过硬中断、软中断处理。在软中断中再依次把包送到设备层、协议栈,最后唤醒应用程序。不过,拿veth设备来举例,如果它连接到Bridge上,在设备层的_netif_receive_skbcore函数中和上述过程有所不同。连在Bridge上的veth在收到数据包的时候,不会进入协议栈,而是会进入Bridge处理。Bridge找到合适的转发口(另一个veth ),通过这个veth把数据转发出去。工作流程如图所示。

image-20230904222822156

我们从veth1p设备的接收看起,所有设备的接收都一样,都会进入_netif_receiveskb_core设备层的关键函数。

static int_netif_receive_skb_core(struct sk_buff *skb, bool pfmemalloc)
{
    ....
    // tcpdump抓包点
    list_for_each_entry_rcu(...);
    //执行设备的rx handler (也就是br handle frame)
    rx_handler = rcu_dereference(skb->dev->rx_handler);
    if (rx_handler){
        switch (rx_handler(&skb)) {
          case RX_HANDLER_CONSUMED:
                ret=NETRX_SUCCESS;
                goto unlock;
        }
    }
    // 送往协议栈// ......
    unlock:
            rcu_read_unlock();
    out:
        return ret;
}

_netif_receive_skb_core中先是过了tcpdump的抓包点,然后查找和执行了rx_handler。在上面小节中我们看到,把veth连接到Bridge上的时候,veth对应的内核对象dev中的rx_handler被设置成了br_handle_frame所以连接到Bridge上的veth在收到包的时候,会将帧送入Bridge处理函数br_handle_frame。另外要注意的是,Bridge函数处理完的话,一般来说就执行goto_unlock退出了。和普通的网卡数据包接收相比,并不会往下再送到协议栈。

br_handle_frame最终会执行br_handle_frame_finish

//file: net/bridge/br input.c
int br_handle_frame_finish(struct sk_buff *skb)
{
  // 获取veth所连接的网桥端口及Bridge设备
  struct net bridge_port *p = br_port_getrcu(skb->dev);
  br=p->br;
    // 更新和查找转发表
    struct net bridge_fdb_entry *dst;
    br_fdb_update(br,p,eth_hdr(skb)->h source,vid);
  dst =_br_fdb_get(br,dest,vid)
    // 转发
  if (dst) {
        br_forward(dst->dst,skb,skb2);
  }
}

在硬件中,交换机和集线器的主要区别就是它会智能地把数据送到正确的端口上去,而不会像集线器那样给所有的端口群发一遍。所以在上面的函数中,我们看到了更新和查找转发表的逻辑。这就是网桥在学习,它会根据自学习结果来工作。

在找到要送往的端口后,下一步就是调用br_forward => br_foward进入真正的转发流程。

//file: net/bridge/br_forward.c
static void _br_forward(const struct net_bridge_port *to, struct sk_buff *skb)
{
    // 将skb中的dev改成新的目的dev
        skb->dev = to->dev;
    NF_HOOK(NFPROTO_BRIDGE,NF_BR_FORWARD,skb,indev,skb->dev,br_forward_finish);
}

br_forward中,将skb上的设备dev改为了新的的dev

image-20230904224615642

然后调用br_forward_finish进入发送流程。在br_forward_finish里会依次调用br_devqueue_push_xmitdev_queue_xmit

image-20230904224842421

至此,Bridge上的转发流程就算完毕了。要注意的是,整个Bridge的工作源码都是在net/core/dev.cnet/bridge目录下,都是在设备层工作的。这也就充分印证了我们经常说的Bridge和物理交换机也一样是二层上的设备。

接下来,收到网桥发过来数据的vetn会把数据包发送给它的对端veth2veth2再开始自己的数据包接收流程,如图所示

image-20230904225133216

小结

所谓网络虚拟化,其实用一句话来概括就是用软件来模拟实现真实的物理网络连接

Linux内核中的Bridge模拟实现了物理网络中的交换机的角色。和物理网络类似,可以将虚拟设备插入Bridge。不过和物理网络有点不一样的是,一对veth插入Bridge的那端其实就不是设备了,可以理解为退化成了一个网线插头。当Bridge接入了多对veth以后,就可以通过自身实现的网络包转发的功能来让不同的veth之间互相通信了。

回到Docker的使用场景上来举例,完整的Docker1Docker2通信的过程如图所示

image-20230904225422184

我们再继续拉大视野,从两个Docker的用户态来开始看一看

Docker1在需要发送数据的时候,先通过send系统调用发送,这个发送会执行到协议栈进行协议头的封装等处理。经由邻居子系统找到要使用的设备(veth1)后,从这个设备将数据发送出去,veth1的对端veth1_p 会收到数据包。

收到数据包的veth1_p是一个连接在Bridge上的设备,这时候Bridge会接管该veth的数据接收过程。从自己连接的所有设备中查找目的设备。找到veth2_p以后,调用该设备的发送函数将数据发送出去。同样,veth2_p的对端veth2即将收到数据。

其中veth2收到数据后,将和loeth0等设备一样,进入正常的数据接收处理过程。Docker2中的用户态进程将能够收到Docker1发送过来的数据了。

image-20230904225453915

外部网络通信

通过veth、网络命名空间和Bridge在一台Linux上就能虚拟多个网络环境出来。也还可以让新建网络环境之间、和宿主机之间都可以通信。这时还剩下一个问题没有解决,那就是虚拟网络环境和外部网络的通信,如图所示。

image-20230907222929890

解决这个问题需要用到路由NAT技术。

路由和NAT

路由

Linux在发送数据包或者转发包的时候,会涉及路由过程。这个发送数据过程既包括本机的数据发送,也包括途经当前机器的数据包的转发

所谓路由其实很简单,就是该选择哪张网卡(虚拟网卡设备也算)将数据写进去到底该选择哪张网卡呢,规则都是在路由表中指定的。Linux中可以有多张路由表,最重要和常用的是localmain

local路由表统一记录本地,确切说是本地网络命名空间中的网卡设备IP的路由规则。

ip route list table local

image-20230907223818162

使用 ip route list table main 或者route -n 查看main

image-20230907224508494

image-20230907224226790

除了本机发送,转发也会涉及路由过程。如果Linux收到数据包以后发现目的地址并不是本地地址的话,就可以选择把这个数据包从自己的某个网卡设备转发出去。这时和本机发送一样,也需要读取路由表。根据路由表的配置来选择从哪个设备将包转走。

不过值得注意的是,Linux上转发功能默认是关闭的。也就是发现目的地址不是本机IP地址时默认将包直接丢弃。需要做一些简单的配置,Linux才可以干像路由器一样的工作,实现数据包的转发。

iptables与NAT

Linux内核网络栈在运行上基本属于纯内核态的东西,但为了迎合各种各样用户层不同的需求,内核开放了一些口子出来供用户层来干预。其中iptables就是一个非常常用的干预内核行为的工具,它在内核里埋下了五个钩子入口,这就是俗称的五链

Linux在接收数据的时候,在IP层进入ip_rcv中处理。再执行路由判断,发现是本机的话就进入ip_local_deliver进行本机接收,最后送往TCP协议层。在这个过程中,埋了两个HOOK,第一个是PRE_ROUTING。这段代码会执行到iptablesPREROUTING里的各种表。发现是本地接收后接着又会执行到LOCAL_IN,这会执行到iptables中配置的INPUT规则。

在发送数据的时候,查找路由表找到出口设备后,依次通过__ip_local_outip_output等函数将包送到设备层。在这两个函数中分别过了OUTPUTPOSTROUTING的各种规则。

在转发数据的时候Linux收到数据包发现不是本机的包可以通过查找自己的路由表找到合适的设备把它转发出去。那就先在ip_rcv中将包送到ip_forward函数中处理,最后在ip_output函数中将包转发出去。在这个过程中分别过了PREROUTINGFORWARDPOSTROUTING三个规则。

查看iptables 的规则:sudo iptables -L

image-20230907230318216

image-20230907230529260

常见的iptables target包括:

  • ACCEPT:接受数据包,允许其通过防火墙。
  • DROP:丢弃数据包,不给予任何响应,使其被防火墙丢弃。
  • REJECT:拒绝数据包,发送一个错误响应给发送者,通知其被防火墙拒绝。
  • LOG:记录数据包的相关信息到系统日志中,但不对数据包做任何处理。
  • DNAT:目标网络地址转换,用于修改数据包的目标IP地址和端口。
  • SNAT:源网络地址转换,用于修改数据包的源IP地址和端口。
  • MASQUERADE:源地址伪装,用于将数据包的源IP地址修改为防火墙的出口IP地址。
  • REDIRECT:重定向数据包,将其发送到指定的端口或地址。

iptables prot 为协议(protocol),opt为选项(options),协议一般指TCP、UDP、ICMP等协议。

opt用于指定规则的行为,一般有以下选项

  • "–dport":指定目标端口(Destination Port),用于匹配目标端口号。
  • "–sport":指定源端口(Source Port),用于匹配源端口号。
  • "–source":指定源IP地址或地址段,用于匹配源IP
  • "–destination":指定目标IP地址或地址段,用于匹配目标IP
  • "–state":指定连接状态,如"RELATED"、"ESTABLISHED"等。
  • "–icmp-type":指定ICMP类型,用于匹配特定类型的ICMP报文。

综上所述,iptables里的五个链在内核网络模块中的位置就可以归纳成如图所示

image-20230907231220615

数据接收过程走的是1和2,发送过程走的是4和5,转发过程是1、3、5。有了这张图,我们能更清楚地理解iptables和内核的关系。

iptables中,根据实现的功能的不同,又分成了四张表。分别是rawmanglenatfilter。其中nat表实现我们常说的NAT (Network AddressTranslation)功能。其中NAT又分成SNAT ( Source NAT)DNAT ( Destination NAT ) 两种。

SNAT 解决的是内网地址访问外部网络的问题。它是通过在POSTROUTING 里修改来源 IP来实现的。DNAT解决的是内网的服务要能够被外部访问到的问题。它是通过PREROUTING修改目标IP实现的。

实现外部网络通信

基于以上的基础知识,我们用纯手工的方式搭建一个可以和Docker类似的虚拟网络。

实验准备环境

先来创建一个虚拟的网络环境,如图所示

image-20230910102347851

创建net1脚本如下

# 创建网络命名空间
ip netns add net1
# 创建虚拟网卡对
ip link add veth1 type veth peer name veth1_p
# 把veth1放在net1中
ip link set veth1 netns net1
# 给veth1分配ip地址
ip netns exec net1 ip addr add 192.168.0.2/24 dev veth1  # IP
# 启动veth1网卡
ip netns exec net1 ip link set veth1 up
ip netns exec net1 ip link list
ip netns exec net1 ifconfig

image-20230910111119804

image-20230910103523028

创建bridge脚本如下

# 添加br0
brctl addbr br0
# 设置ip
ip addr add 192.168.0.1/24 dev br0
# 关联veth1_p
ip link set dev veth1_p master br0
# 启动veth1_p
ip link set veth1_p up
# 启动br0
ip link set br0 up
brctl show

image-20230910104219771

这样我们就在Linux上创建出了一个虚拟的网络。

请求外部资源

现在假设net1这个网络环境想访问外部网络资源。假设它要访问的另外一台机器的IP是10.153.*.*,这个10.153.*.*后面两段由于是内部网络,所以隐藏起来了。你在实验的过程中,用自己的IP代替即可。

image-20230910104637884

直接来访问一下试试

ip netns exec net1 ping 10.153.*.*

image-20230910110945302

提示网络不通,用这段报错关键字在内核源码里搜索一下。

//file: arch/parisc/include/uapi/asm/errno.h
#define ENETUNREACH 229 /* Network is unreachable */
//file: net/ipv4/ping.c
static int ping_sendmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,size_t len)
{
    ......
    rt = ip_route_output_flow(net, &f14, sk);
    if (IS_ERR(rt)){
        err= PTR_ERR(rt);
        rt = NULL;
        if(err== -ENETUNREACH)
            IP_INC_STATS_BH(net,IPSTATS_MIB_OUTNOROUTES);
        goto out;
    }
    ......
out:
    return err;
}

ip_route_output_flow这里,判断返回值如果是ENETUNREACH就退出了。从这个宏定义注释上来看报错的信息就是“Network is unreachable”。这个ip_route_output_flow主要是执行路由选路。所以我们推断可能是路由出问题了,看一下这个网络命名空间的路由表。

image-20230910111156258

怪不得,原来net1这个网络命名空间下默认只有102.168.0.*这个网段的路由规则。我们pingIP是101.35.*.*,根据这个路由表找不到出口,自然就发送失败了。我们来给net添加上默认路由规则,只要匹配不到其他规则就默认送到veth1上,同时指定下一条是它所连接的Bridge (192.168.0.1)。

ip netns exec net1 route add default gw 192.168.0.1 veth1 # 默认网关
ip netns exec net1 route -n

image-20230910111559615

其中UG表示路由是活动的且路由需要通过网关进行转发。

ping一下试试

image-20230910112609558

可以看到仍然不通。上面路由帮我们把数据包从veth正确送到了Bridge这个网桥。接下来网桥还需要Bridge转发到eth0网卡上。所以我们得打开下面这两个转发相关的配置。

sudo sysctl net.ipv4.conf.all.forwarding=1
sudo iptables -P FORWARD ACCEPT

不过这个时候,还存在一个问题。那就是外部的机器并不认识192.168.0.*这个网段的IP。它们之间都是通过101.*进行通信的。回想下我们工作中的电脑上没有外网IP的时候是如何正常上网的呢?外部的网络只认识外网IP。没错,那就是我们上面说的NAT技术。

这次的需求是实现内部虚拟网络访问外网,所以需要使用的是SNAT。它将namespace请求中的IP (192.168.0.2)换成外部网络认识的101.35.*.*,进而达到正常访问外部网络的效果。

sudo iptables -t nat -A POSTROUTING -s 192.168.0.0/24 ! -o br0 -j MASQUERADE

命令解析 by chatGPT

这个命令的作用是在nat表的POSTROUTING链中添加一条规则。该规则的作用是对源地址为192.168.0.0/24的数据包进行源地址转换(SNAT),使其通过网络接口(除了名称为"br"的接口)出去时使用源地址转换为本机的IP地址。

具体解析该命令的参数和选项:

-t nat:指定操作的iptables表为nat表。
-A POSTROUTING:将规则添加到POSTROUTING链。POSTROUTING链用于对数据包进行路由后的处理,通常用于进行网络地址转换。
-s 192.168.0.0/24:指定源地址为192.168.0.0/24,即匹配以192.168.0.开头的IP地址段。
! -o br:使用逻辑非操作符!,表示除了输出接口名称为"br"的接口之外的所有接口。
-j MASQUERADE:指定要执行的动作为MASQUERADE,即进行源地址转换(SNAT)。MASQUERADE动作会将源IP地址转换为本机的IP地址,使得数据包能够正确地通过网络接口出去。
综上所述,该命令的作用是对源地址为192.168.0.0/24的数据包进行源地址转换,使其通过除了"br"接口之外的其他接口出去时,源IP地址被转换为本机的IP地址。这通常用于实现局域网内主机与公网之间的通信,使得内部主机能够通过本机进行网络访问。

再来试一试

image-20230910113834694

可以看到通了,这时候可以开启tcpdump抓包查看一下,在Bridge上抓到的包我们能看到还是原始的源IP和目的IP

image-20230910114505707

再到eth0(ens33)上查看,源IP已经被替换成可和外网通信的eth0(ens33)上的IP了。

image-20230910115159739

至此,容器就可以通过宿主机的网卡来访问外部网络上的资源了。我们来总结一下这个发送过程。

image-20230910114959588

开放容器端口

我们再考虑另外一个需求,那就是把在这个网络命名空间内的服务提供给外部网络使用。和上面的问题一样,虚拟网络环境中192.168.0.2这个IP外界是不认识它的。只有这个宿主机知道它是谁,所以我们同样还需要NAT功能。

这次我们是要实现外部网络访问内部地址,所以需要的是DNAT配置。DNATSNAT配置中有一个不一样的地方就是需要明确指定容器中的端口在宿主机上对应哪个。比如在docker命令的使用中,是通过-p来指定端口的对应关系的。

 docker run -p 8000:80 ...

我们通过如下这个命令来配置DNAT规则。

 sudo iptables -t nat -A PREROUTING ! -i br0 -p tcp -m tcp --dport 8088 -j DNAT --to-destination 192.168.0.2:80

命令解析 by chatGPT

这个命令的作用是在nat表的PREROUTING链中添加一条规则。该规则的作用是将通过除了输入接口名称为"br0"的接口进入的TCP协议、目标端口为8088的数据包,进行目标地址转换(DNAT),将目标IP地址和端口转换为192.168.0.2:80。

具体解析该命令的参数和选项:

-t nat:指定操作的iptables表为nat表。
-A PREROUTING:将规则添加到PREROUTING链。PREROUTING链用于对数据包进行路由前的处理,通常用于进行目标地址转换。
! -i br0:使用逻辑非操作符!,表示除了输入接口名称为"br0"的接口之外的所有接口。
-p tcp:指定匹配的协议为TCP。
-m tcp --dport 8088:使用-m选项加载tcp模块,并指定目标端口为8088,即匹配目标端口为8088的TCP数据包。
-j DNAT --to-destination 192.168.0.2:80:指定要执行的动作为DNAT,即进行目标地址转换。--to-destination选项指定将目标IP地址和端口转换为192.168.0.2:80。
综上所述,该命令的作用是对通过除了"br0"接口之外的其他接口进入的TCP协议、目标端口为8088的数据包,进行目标地址转换,将目标IP地址和端口转换为192.168.0.2:80。这通常用于实现端口转发,将外部请求转发到内部服务器的指定IP地址和端口上。

在net1环境中启动一个服务器。

 sudo ip netns exec net1 nc -lp 80

命令解析 by chatGPT

这个命令的作用是在名为"net1"的网络命名空间中启动一个监听本地端口80的nc(netcat)服务器。

具体解析该命令的参数和选项:

ip netns exec net1:使用ip命令以网络命名空间的方式执行后续的命令。net1是一个网络命名空间的名称,通过这个命令可以在指定的网络命名空间中执行后续的命令。
nc:nc命令是netcat的简写,它是一个用于网络通信的工具,可以用作网络客户端或服务器。
-lp 80:-l选项表示nc命令作为服务器端进行监听,-p 80表示监听端口号为80。
综上所述,该命令的作用是在名为"net1"的网络命名空间中启动一个nc服务器,监听本地的80端口。这通常用于创建一个简单的Web服务器或其他基于TCP协议的服务器,以便在该网络命名空间中接收来自其他主机的连接和请求。

两台虚拟机,与上面访问公网有所不同, 但本质一样。

image-20230910122138691

结果如下,使用192.168.159.129的机器telnet192.168.159.128机器的8080端口,会把数据转发到net1所监听的80端口,有net1的设备接收数据。

image-20230910122347691

net1中的80端口

image-20230910122356726

在192.168.159.129中进行tcpdump抓包,可以看到目标ip端口还是8088

image-20230910124348789

但数据包到宿主机协议栈以后命中了我们配置的DNAT规则,宿主机把它转发到了br0上。发现在br0上抓到的目的IP和端口是已经替换过的了,换成了192.168.0.2:80,如图所示

image-20230910135830212

Bridge当然知道192.168.0.2是veth1。于是,在veth1上监听80的服务就能收到来自外界的请求了!我们来总结一下这个接收过程。

image-20230910135925011

小结

现在业界已经有很多公司都迁移到容器上了。开发人员写出来的代码大概率是要运行在容器上的。因此深刻理解容器网络的工作原理非常重要。只有这样,将来遇到问题的时候才知道该如何下手处理。

veth实现连接,Bridge实现转发,网络命名空间实现隔离,路由表控制发送时的设备选择,iptables实现nat等功能。基于以上基础知识,我们采用纯手工的方式搭建了一个虚拟网络环境,如图所示。

image-20230910140118353

这个虚拟网络可以访问外网资源,也可以提供端口服务供外网来调用。这就是Docker容器网络工作的基本原理。

总结

事实上,当前大火的容器并不是新技术,而是基于Linux的一些基础组件诞生和演化出来的。

本文深度拆解了容器网络虚拟化的三大基础,veth、网络命名空间和Bridgeveth模拟了现实物理网络中一对连接在一起可以相互通信的网卡。Bridge则模拟了交换机的角色,可以把Linux上的各种网卡设备连接在一起,让它们之间可以互相通信。网络命名空间则是将网络设备、进程、socket等隔离开,在一台机器上虚拟出多个逻辑上的网络栈。理解了它们的工作原理之后再理解容器就容易得多了。

回到本章开篇提到的几个问题上。

  1. 容器中的eth0和母机上的eth0是一个东西吗?

答案是不是,每个容器中的设备都是独立的。物理Linux机上的eth0一般来说是个真正的网卡,有网线接口。而容器中的eth0只是一个虚拟设备 veth设备对中的一头,它和lo回环设备类似,是以纯软件方式工作的。设备的名字是可以随便修改的,其实想改成什么都可以。命名成eth0这个名字是容器作者们为了让容器和物理机更像。

  1. veth设备是什么,它是如何工作的?

veth设备和回环设备lo非常像,唯一的区别就是veth是为了虚拟化技术而生的,所以它多了个结对的概念。每一次创建veth都会创建出来两个虚拟网络设备。这两个设备是连通着的,在veth的一头发送数据,另一头就可以收到。它是容器和母机通信的基础。

  1. Linux是如何实现虚拟网络环境的?

默认情况下,其实就存在一个网络命名空间,在内核中它叫init_net。网络命名空间的内核对象中,是包含自己的路由表、iptable,甚至是内核参数的。创建网络命名空间的方法有多种,分别是clonesetnsunshare,通过它们可以创建新的空间出来。拿clone来举例,如果指定了CLONE_NEWNET标记,内核就会创建一个新的网络命名空间。

每个进程内部都会有指针,通过它来表示自己的命名空间归属。veth等虚拟网卡设备也归属在默认命名空间下,但可以通过命令将它修改到其他网络命名空间中。

通过上述的一系列操作,每个命名空间中都有了自己独立的进程、虚拟网卡设备、socket、路由表、iptables等元素,所以也就进而实现了网络的隔离。

  1. Linux如何保证同宿主机上多个虚拟网络环境中路由表等可以独立工作?

不管有没有新的网络命名空间,Linux的网络包收发流程都是一样的。只不过涉及特定的网络命名空间相关的逻辑时需要先查找到表示命名空间的struct net对象。拿路由步骤举例,内核先根据socket找到其归属的网络命名空间,再找到命名空间里的路由表,然后再开始执行查找。

如果没有创建任何新namespace,就执行的是默认命名空间inet_net中的路由规则就是通过route命令直接查看到的规则。如果是在新的namespace中,那就是执行的这个空间下的路由配置。

  1. 同一宿主机上多个容器之间是如何通信的?

在物理机的网络环境中,多台不同的物理机之间通过以太网交换机连接在一起,进而实现通信。在Linux下也是类似的,Bridge是用软件模拟了交换机,它也有插口的概念,多个虚拟设备都是“连接”在Bridge上的。Bridge工作在内核网络栈的二层上,可以在不同的插口之间转发数据包。

  1. Linux上的容器如何和外部机器通信?

使用vethBridge、网络命名空间三个技术搭建起来的虚拟网络只能在宿主机内部进行通信,因为其私有IP无法被外网认识。我们采用路由表控制以及NAT功能可以使得虚拟网络通过母机的网卡和外部机器进行通信。

Kubernetslstio等项目中用的网络方案看似复杂,但其实追根溯源也是对路由选择、iptables等技术的不同应用方式罢了!

参考资料:深入理解Linux网络

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇