容器网络虚拟化

网络虚拟化技术的三大基础:veth、网络命名空间、Bridge

veth实现连接、Bridge实现转发、网络命名空间实现隔离、路由表控制发送时的设备选择、iptables实现nat等功能。

一、网络隔离—网络命名空间

​ 在Linux上实现隔离的技术手段就是命名空间(namespace)。通过命名空间可以隔离容器的进程PID(pid_namespace)、文件系统挂载点(mnt_namespace)、主机名(uts_namespace)等多种资源。本文重点学习的是网络命名空间(net_namespace,简称netns)。

​ 网络命名空间则是将**veth网络设备进程socket路由表iptables以及套接字(socket)等隔离开**,在一台机器上虚拟出多个逻辑上独立的网络栈。使得不同的网络空间都好像运行在独立的网络中一样。

在这里插入图片描述

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

命名空间相关定义

​ 命名空间(Namespace) 是用来实现资源隔离的一种机制,它可以把系统的某些资源虚拟化,从而使不同的进程或容器有自己独立的资源视图。

​ 每一个进程(线程)通过task_struct结构来表示。每一个task_struct都有一个指针指向命名空间代理对象 nsproxy,而nsproxy对象中则包含了各种类型的命名空间(包括网络命名空间)。

在这里插入图片描述

nsproxy是命名空间的核心结构,所有的命名空间类型(如网络、UTS、IPC、PID等)都通过nsproxy来进行关联。其定义如下:

struct nsproxy {
    struct uts_namespace *uts_ns;    // 主机名
    struct ipc_namespace *ipc_ns;    // IPC
    struct mnt_namespace *mnt_ns;    // 文件系统挂载点
    struct pid_namespace *pid_ns;    // 进程标号
    struct net *net_ns;              // 网络协议栈
};

网络设备在Linux内核中是通过struct net_device结构来表示的。该结构包含了网络设备的基本信息及其所属的网络命名空间指针:

struct net_device {
    char name[IFNAMSIZ];          // 设备名称
    struct net *nd_net;           // 所属网络命名空间
};

当我们通过命令 ip link set dev veth1 netns net1 将veth1移动到命名空间net1时,实际上修改的就是这个指针(nd_net),将其从宿主机网络命名空间指向net1。

在这里插入图片描述

struct net 是表示网络命名空间的核心数据结构,它定义了每个网络命名空间的所有网络设备、路由表、iptables等配置信息:

struct net {
    struct net_device *loopback_dev;  // 每个net中都有一个回环设备lo
    struct netns_ipv4 ipv4;           // IPv4相关的路由和iptables
    ...
};

struct netns_ipv4 是网络命名空间中用于表示IPv4协议相关配置的数据结构:

struct netns_ipv4 {
    struct fib_table *fib_local;  // 路由表(本地)
    struct fib_table *fib_main;   // 路由表(主表)
    struct fib_table *fib_default;// 路由表(默认)
    
    struct xt_table *iptables_filter; // iptables过滤表
    struct xt_table *iptables_raw;    // iptables原始表
    struct xt_table *arp_table;       // ARP表
    
    long sysctl_tcp_mem[3];           // 内核TCP内存参数
};

创建命名空间

在这里插入图片描述

进程与网络命名空间的关联:

struct task_struct init_task = INIT_TASK(init_task);
#define INIT_TASK(tsk) { \
    .nsproxy = &init_nsproxy, \
    ...
}

nsproxy 中的 net_ns 指向 init_net,这是 Linux 启动时创建的默认网络命名空间。所有从 init 派生的进程如果不特别指定,都会共享这个默认命名空间。

​ 当我们需要创建一个新的进程并使其拥有自己的网络命名空间时,就需要用到 clone() 系统调用,并使用标志位 CLONE_NEWNET。

struct net *copy_net_ns(unsigned long flags, struct user_namespace *user_ns, struct net *old_net) {
    struct net *net;

    if (!(flags & CLONE_NEWNET))
        return get_net(old_net); // 复用旧的网络命名空间

    // 分配新的网络命名空间
    net = net_alloc();
    rv = setup_net(net, user_ns);
    ...
}

​ 在新创建的网络命名空间中,需要对不同的子系统进行初始化,例如路由表、iptables、ARP 表等。这些子系统的初始化是通过setup_net() 函数来实现的。

static int __net_init 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);
        ...
    }
}

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

​ 当一个网络包被发送时,内核需要找到其目的地址的合适路由。这种路由查找操作需要依赖当前网络命名空间,因为不同的网络命名空间有独立的路由表配置。图中展示了如何通过 sock_commonskc_net 来关联 struct net,从而在该网络命名空间中查找路由表进行处理。

在这里插入图片描述

在 IP 层发送的过程中,函数 ip_queue_xmit() 负责处理数据包的发送:

int ip_queue_xmit(struct sk_buff *skb, struct flowi *fl)
{
    rt = ip_route_output_ports(sock_net(sk), fl4, sk, daddr, inet->inet_saddr, ...);
}

sock_net(sk):用于获取与这个 Socket 相关的网络命名空间(即 struct net)。

static inline struct net *sock_net(const struct sock *sk)
{
    return read_pnet(&sk->sk_net);
}

​ 这个步骤确定了数据包所属的网络命名空间,进而决定从哪个网络命名空间的路由表中进行路由查找。在调用 ip_route_output_ports() 后,最终会到达 fib_lookup(),这是用于路由查找的核心函数。

static inline int fib_lookup(struct net *net, ...)
{
    struct fib_table *table;
    table = fib_get_table(net, RT_TABLE_LOCAL);
    table = fib_get_table(net, RT_TABLE_MAIN);
    ...
}
static inline struct fib_table *fib_get_table(struct net *net, u32 id)
{
    ptr = id == RT_TABLE_LOCAL ?
          &net->ipv4.fib_table_hash[TABLE_LOCAL_INDEX] :
          &net->ipv4.fib_table_hash[TABLE_MAIN_INDEX];
    return hlist_entry(ptr->first, struct fib_table, tb_hlist);
}

​ 根据传入的网络命名空间 net 和路由表的 ID,从 net 的 ipv4 成员中找到对应的路由表。通过对比传入的 ID,确定是返回本地路由表还是主路由表。

每个命名空间都有自己的 fib_table_hash,这使得不同的网络命名空间可以有独立的路由表配置。

二、容器与宿主机的通信—veth设备对

​ 计算机之间通过eth0网卡进行通信,而Docker网络虚拟化则是通过veth设备对——软件虚拟出来的设备来模拟“网卡”。

veth是Docker网络虚拟化最基础的技术。它模拟了在物理世界里的两块连接在一起的网卡。veth总是成双成对的出现,所有我们通常称它为veth设备对

​ veth的网络通信过程与Linux网络包的收发过程类似,包括基于veth的网络设备的发送与接收。在这里的描述中主要以veth_xmit(发送数据包)为例,来探讨veth设备之间的通信过程。

​ 网络设备在进行数据传输时,最终会通过设备操作指针ops->ndo_start_xmit来调用驱动程序进行真正的发送。这个过程大致如下:

  • 通过dev->netdev_ops获取设备的操作集。
  • 通过操作集中的ndo_start_xmit来实际发送数据包。
int dev_hard_start_xmit(struct sk_buff *skb, struct net_device *dev,
                        struct netdev_queue *txq)
{
    const struct net_device_ops *ops = dev->netdev_ops;

    // 调用驱动的ndo_start_xmit进行发送
    rc = ops->ndo_start_xmit(skb, dev);
    ...
}

对于veth设备来说,这里的ndo_start_xmit指向了veth_xmitveth_xmit是veth设备发送数据包的具体实现:

static netdev_tx_t veth_xmit(struct sk_buff *skb, struct net_device *dev)
{
    struct veth_priv *priv = netdev_priv(dev);
    struct net_device *rcv;

    // 获取veth设备的对端
    rcv = rcu_dereference(priv->peer);

    // 调用dev_forward_skb向对端发送包
    if (likely(dev_forward_skb(rcv, skb) == NET_RX_SUCCESS)) {
        ...
    }
}

dev_forward_skb负责将数据包从一个设备转发到另一个设备:

int dev_forward_skb(struct net_device *dev, struct sk_buff *skb)
{
    skb->protocol = eth_type_trans(skb, dev);
    ...
    return netif_rx(skb);
}

​ 调用netif_rx(skb)将数据包交给网络子系统进行处理。netif_rx是Linux内核中用于将数据包交由网络栈处理的函数。

​ veth设备的接收过程与普通的网络设备类似,在数据包传递给netif_rx后,最终数据包会被放入内核的网络处理队列中并通过软中断机制进行处理。

数据包在进入netif_rx后,会经历以下步骤:

static int enqueue_to_backlog(struct sk_buff *skb, int cpu, ...)
{
    ...
    __skb_queue_tail(&sd->input_pkt_queue, skb); //进入软中断队列
    ...
    __napi_schedule(sd, &sd->backlog); //调用软中断
}

​ 软中断被触发后,数据包会被进一步处理,最终调用net_rx_action来处理这些数据包,并将它们递交给上层协议栈:

static int __init net_dev_init(void)
{
    for_each_possible_cpu(i) {
        sd->backlog.poll = process_backlog;
    }
}

process_backlog 是网络子系统用于处理接收队列中数据包的回调函数,最终将数据包交由协议栈处理。veth设备的这种通信机制模拟了物理网络设备的工作方式,使得两个容器或网络命名空间之间可以像通过物理网线连接一样进行通信。

三、容器与容器的通信—Bridge

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

Bridge是由软件实现交换机的技术,它模拟了计算机中交换机的角色,可以把Linux上【不同的网络空间下】各种网卡设备连接在一起,让它们之间可以通信

​ Bridge 是由两个内核对象相邻存储的,它们分别是 struct net_device 和 struct net_bridge,如图 所示。struct net_device 是 Linux 中用于表示网络接口的基本结构,而 struct net_bridge 则是专门用来表示桥接设备的数据结构。

在这里插入图片描述

为了创建一个新的 Bridge,在内核中使用了 br_add_bridge() 这个函数,它的作用就是完成 Bridge 的初始化。

// 文件: 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);

    // 注册网桥设备
    res = register_netdev(dev);
    if (res)
        free_netdev(dev);

    return res;
}

alloc_netdev() 函数是一个宏,实际上它会调用 alloc_netdev_mqs() 函数。

// 文件: net/core/dev.c
struct net_device *alloc_netdev_mqs(int sizeof_priv, ..., void (*setup)(struct net_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 = PTR_ALIGN(p, NETDEV_ALIGN);

    // 网桥设备初始化
    dev->... = ...;
    setup(dev); // setup 是一个函数指针,实际使用的是 br_dev_setup
    ...
}

在这里插入图片描述

Bridge工作过程汇总:
1、Docker1往veth1上发送数据。
2、由于veth1_p是veth1 的对端,所以这个虚拟设备上可以收到包。
3、veth收到包以后发现自己是连在Bridge 上的,于是进入bidge转发处理逻辑。在Bridge设备上寻找要转发到的端口,这时找到了veth2_p开始发送。bridge完成了自己的转发工作。
4、veth2作为veth2_p的对端,收到了数据包。
5、Docker2就可以从veth2设备上收到数据了。

在这里插入图片描述

从两个Docker的用户态来开始。Docker1向Docker2发送数据大致流程:

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

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

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

四、容器与外网的通信—路由与NAT技术

虚拟网络环境和外部网络是如何通信的—路由和NAT技术路由表控制以及iptables实现的NAT等功能可以使得虚拟网络通过宿主机的网卡和外部机器进行通信。

路由:就是通过路由表指定的路由规则选择哪张网卡(虚拟网卡设备也算)将数据写进去。Linux有多张路由表,最重要的也是最常用是localmain
local路由表:记录本网络命名空间中的网卡设备IP的路由规则。
main路由表:其它路由规则一般都记录在main路由表里。

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

​ 在iptables中,根据实现的功能不同,又分成了四张表:raw、mangle、nat、filter。其中nat表的实现就是我们常说的NAT(Network Address Translation)功能。其中NAT又分为SNAT(Source NAT)和DNAT(Destination NAT)两种。

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

在这里插入图片描述

解决容器外部通信需求需要使用到路由和NAT技术。

更多推荐