第十章 容器网络虚拟化

10.1 相关实际问题

时至今日,容器和云原生相关的技术大火。现在越来越多的公司在线上生产环境中不再是将服务部署到实体物理机或者是 KVM 虚拟机上,而是部署到基于 Docker 的容器云上。这就对我们开发等相关的技术同学提出了新的挑战,你需要理解你写出来的程序是如何在容器云上运行的。如果理解不到位,很有可能你将没有能力定位线上问题,也没有能力进行性能等方面的优化。

回到我们的网络上来,我们来思考这么几个问题。

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

大家在容器中执行 ifconfig 等命令的时候,和实体机一样也能看到一个 eth0。那么这个 eth0 和物理机上的 eth0 网卡设备是一个东西吗?

2)veth 设备是啥,它是如何工作的?

有一些容器使用基础的同学可能会知道容器是基于 Linux 上的 veth 设备工作的。那问题来了,veth 设备到底是个什么样的东西。和我们日常工作中熟悉的网卡、回环设备相比它有什么相同,又有什么不同的地方?使用 veth 设备在发送和接收数据包的时候,内核等底层又是如何工作的?

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

容器化中非常重要的一步就是隔离,不能让 A 容器用到 B 容器的设备,甚至连看一眼都不可以。Linux 上实现隔离的技术手段就是 namespace(命名空间)。对于网络模块来说,由 netns(net namespace)为容器隔离出一套逻辑上完全独立的网络空间。

NOTE

图10.1 最简单的物理网络(描述:两台电脑通过交叉网线直连)

图10.2 veth(描述:veth设备对,成对出现,模拟物理网线连接)

那你知道 namespace 是如何创建出来的吗?进程、网卡设备、socket 等又是如何加入某个网络 namespace 里的。在被 namespace 隔离的容器的网络收发过程和直接在物理机上的收发过程有啥不一样?

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

在被容器隔离的容器网络环境中,是可以配置自己独立的路由表、iptable 规则的。那么 Linux 是如何保证多个容器之间、容器和母机之间的路由表、iptable 规则都能独立工作而不冲突的?

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

在现实工作中,为了充分压榨机器的硬件资源,我们一般会在一台机器上虚拟出来几个,甚至几十个的容器。那么这些容器之间是如何互相实现网络互通而进行通信的呢?

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

除了内部互通,一般来说容器里运行的服务是需要访问外部的,例如访问数据库。另外就是可能需要暴露比如 80 端口对外提供服务。那么 Docker 是如何实现和外网互通的?

10.2 Veth 设备对

我们这一小节来看一下 Docker 网络虚拟化中最基础的技术 - veth。回想下在物理机组成的网络里,最基本,最简单的网络连接方式是什么?没错,那就是直接用一根交叉网线把两台电脑的网卡连起来,如图 10.1。这样,一台机器发送数据,另外一台就能收到了。

那么,网络虚拟化实现的第一步,就是用软件来模拟这个简单的网络连接实现过程。实现的技术就是我们的主角 veth,它模拟了在物理世界里的两块连接在一起的网卡,这两个“网卡”之间可以互相通信。平时工作中在 Docker 镜像里我们看到的 eth0 设备,其实就是 veth。

事实上,这种软件模拟硬件方式我们一点儿也不陌生,我们本机网络 IO 里的 lo 回环设备也是这样一个用软件虚拟出来的设备。Veth 和 lo 的一点区别就是 veth 总是成双成对地出现。我们来深入地看看 veth 这个东东是咋工作的。

10.2.1 veth 如何使用

不像回环设备,绝大多数同学在日常工作中可能都没接触过 veth。所以本文咱们专门拉一小节出来介绍 veth 是如何使用的。

在 Linux 下,我们可以通过使用 ip 命令创建一对儿 veth。其中 link 表示 link layer 的意思,即链路层。这个命令可以用于管理和查看网络接口,包括物理网络接口,也包括虚拟接口。

使用 ip link show 来进行查看。

# ip link add veth0 type veth peer name veth1
# ip link show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP mode DEFAULT qlen 1000
    link/ether 6c:0b:84:d5:88:d1 brd ff:ff:ff:ff:ff:ff
3: eth1: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT qlen 1000
    link/ether 6c:0b:84:d5:88:d2 brd ff:ff:ff:ff:ff:ff
4: veth1@veth0: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT qlen 1000
    link/ether 4e:ac:33:e5:eb:16 brd ff:ff:ff:ff:ff:ff
5: veth0@veth1: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT qlen 1000
    link/ether 2a:6d:65:74:30:fb brd ff:ff:ff:ff:ff:ff

和 eth0、lo 等网络设备一样,veth 也需要为其配置上 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 就可以查看到它们了。

# ifconfig
eth0: ......
lo: ......
veth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.1.1  netmask 255.255.255.0  broadcast 0.0.0.0
        ......
veth1: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.1.2  netmask 255.255.255.0  broadcast 0.0.0.0
        ......

现在,一对儿虚拟设备已经建立起来了。不过我们需要做一点准备工作,它们之间才可以进行互相通信。首先要关闭反向过滤 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

好了,我们在 veth0 上来 ping 一下 veth1。这两个 veth 之间可以通信了,欧耶!

# ping 192.168.1.2 -I veth0
PING 192.168.1.2 (192.168.1.2) from 192.168.1.1 veth0: 56(84) bytes of data.
64 bytes from 192.168.1.2: icmp_seq=1 ttl=64 time=0.019 ms
64 bytes from 192.168.1.2: icmp_seq=2 ttl=64 time=0.010 ms
64 bytes from 192.168.1.2: icmp_seq=3 ttl=64 time=0.010 ms
...

我在另外一个控制台上,还启动了 tcpdump 抓包,抓到的结果如下。

# tcpdump -i veth0
09:59:39.449247 ARP, Request who-has *** tell ***, length 28
09:59:39.449259 ARP, Reply *** is-at 4e:ac:33:e5:eb:16 (oui Unknown), length 28
09:59:39.449262 IP *** > ***: ICMP echo request, id 15841, seq 1, length 64
09:59:40.448689 IP *** > ***: ICMP echo request, id 15841, seq 2, length 64
......

由于两个设备之间是首次通信的,所以 veth0 首先先发出了一个 arp request,veth1 收到后回复了一个 arp reply。然后接下来就是正常的 ping 命令下的 IP 包。

10.2.2 veth 底层创建过程

在上一小节中,我们亲手创建了一对儿 veth 设备,并通过简单的配置就可以让他们之间互相进行通信了。那么在本小节中,我们看看在内核里,veth 到底是如何创建的。

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

//file: drivers/net/veth.c
static __init int veth_init(void)
{
  return rtnl_link_register(&veth_link_ops);
}

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

//file: drivers/net/veth.c
static struct rtnl_link_ops veth_link_ops = {
  .kind   = DRV_NAME,
  .priv_size  = sizeof(struct veth_priv),
  .setup    = veth_setup,
  .validate = veth_validate,
  .newlink  = veth_newlink,
  .dellink  = veth_dellink,
  .policy   = veth_policy,
  .maxtype  = VETH_INFO_MAX,
};

我们先来看下 veth 设备的创建函数 veth_newlink,这是理解 veth 的关键之处。

//file: drivers/net/veth.c
static int veth_newlink(struct net *src_net, struct net_device *dev,
       struct nlattr *tb[], struct nlattr *data[])
{
  ...
  //创建
  peer = rtnl_create_link(net, ifname, &veth_link_ops, tbp);
  //注册
  err = register_netdevice(peer);
  err = register_netdevice(dev);
  ...
  //把两个设备关联到一起
  priv = netdev_priv(dev); 
  rcu_assign_pointer(priv->peer, peer);
  priv = netdev_priv(peer); 
  rcu_assign_pointer(priv->peer, dev);
}

veth_newlink 中,我们看到它通过 register_netdevice 创建了 peer 和 dev 两个网络虚拟设备。接下来的 netdev_priv 函数返回的是网络设备的 private 数据,priv->peer 就是一个指针而已。

两个新创建出来的设备 dev 和 peer 通过 priv->peer 指针来完成结对。其中 dev 设备里的 priv->peer 指针指向 peer 设备,peer 设备里的 priv->peer 指向 dev。

//file: drivers/net/veth.c
struct veth_priv {
  struct net_device __rcu *peer;
  atomic64_t    dropped;
};

接着我们再看下 veth 设备的启动过程。

//file: drivers/net/veth.c
static void veth_setup(struct net_device *dev)
{
  //veth的操作列表,其中包括veth的发送函数veth_xmit
  dev->netdev_ops = &veth_netdev_ops;
  dev->ethtool_ops = &veth_ethtool_ops;
  ......
}

其中 dev->netdev_ops = &veth_netdev_ops 这行也比较关键。veth_netdev_ops 是 veth 设备的操作函数。

//file: drivers/net/veth.c
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,
  .ndo_get_stats64     = veth_get_stats64,
  .ndo_set_mac_address = eth_mac_addr,
};

例如发送过程中调用的函数指针 ndo_start_xmit,对于 veth 设备来说就会调用到 veth_xmit。这个在下一个小节里我们会用到。

10.2.3 veth 网络通信过程

本书在第二章和第四章系统介绍了 Linux 网络包的收发过程。在第五章我们又详细讨论了基于回环设备 lo 的本机网络 IO 过程。我们回顾一下第五章中基于回环设备 lo 的本机网络过程。在发送阶段里,流程分别是:send 系统调用 协议栈 邻居子系统 网络设备层 驱动。在接收阶段里,流程分别是:软中断 驱动 网络设备层 协议栈 系统调用返回。过程图示如图 10.3。

NOTE

图10.3 本机网络通信过程(描述:发送路径:send系统调用 → 协议栈 → 邻居子系统 → 网络设备层 → 驱动;接收路径:软中断 → 驱动 → 网络设备层 → 协议栈 → 系统调用返回)

基于 veth 的网络 IO 过程和上面这个过程图几乎完全一样。和 lo 设备所不同的就是使用的驱动程序不一样,马上我们就能看到。

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

//file: net/core/dev.c
int dev_hard_start_xmit(struct sk_buff *skb, struct net_device *dev,
   struct netdev_queue *txq)
{
  //获取设备驱动的回调函数集合 ops
  const struct net_device_ops *ops = dev->netdev_ops;
  //调用驱动的 ndo_start_xmit 来进行发送
  rc = ops->ndo_start_xmit(skb, dev);
  ...
}

在第五章中,我们提到过对于回环设备 lo 来说 netdev_opsloopback_ops。那么上面发送过程中调用的 ops->ndo_start_xmit 对应的就是 loopback_xmit

//file:drivers/net/loopback.c
static const struct net_device_ops loopback_ops = {
  .ndo_init      = loopback_dev_init,
  .ndo_start_xmit= loopback_xmit,
  .ndo_get_stats64 = loopback_get_stats64,
};

回顾本文上一小节中,对于 veth 设备来说,它在启动的时候将 netdev_ops 设置成了 veth_netdev_ops。那 ops->ndo_start_xmit 对应的具体发送函数就是 veth_xmit。这就是在 veth 发送的过程中,唯一和 lo 设备不同的地方所在。我们来简单看一下这个发送函数的代码。

//file: drivers/net/veth.c
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)) {
  }

veth_xmit 中主要就是获取一下当前 veth 设备,然后向对端把数据发送过去就行了。发送到对端设备的工作是由 dev_forward_skb 函数来处理的。

//file: net/core/dev.c
int dev_forward_skb(struct net_device *dev, struct sk_buff *skb)
{
  skb->protocol = eth_type_trans(skb, dev);
  ...
  return netif_rx(skb);
}

先调用了 eth_type_trans 将 skb 的所属设备改为了刚刚取到的 veth 的对端设备 rcv

//file: net/ethernet/eth.c
__be16 eth_type_trans(struct sk_buff *skb, struct net_device *dev)
{
  skb->dev = dev;
  ...
}

接着调用 netif_rx,这块儿又和 lo 设备的操作一样了。在该方法中最终会执行到 enqueue_to_backlog 中(netif_rx -> netif_rx_internal -> enqueue_to_backlog)。在这里将要发送的 skb 插入 softnet_data->input_pkt_queue 队列中并调用 napi_schedule 来触发软中断,见下面的代码。

//file: net/core/dev.c
static int enqueue_to_backlog(struct sk_buff *skb, int cpu, ...)
{
  sd = &per_cpu(softnet_data, cpu);
  __skb_queue_tail(&sd->input_pkt_queue, skb);
  ...
  ____napi```c
  ____napi_schedule(sd, &sd->backlog);
}
 
//file:net/core/dev.c
static inline void ____napi_schedule(struct softnet_data *sd, ...)
{
  list_add_tail(&napi->poll_list, &sd->poll_list);
  __raise_softirq_irqoff(NET_RX_SOFTIRQ);
}

当数据发送完唤起软中断后,veth 对端的设备开始接收.和发送过程不同的是,所有的虚拟设备的收包 poll 函数都是一样的,都是在设备层被初始化成了 process_backlog.

//file:net/core/dev.c
static int __init net_dev_init(void)
{
  for_each_possible_cpu(i) {
    sd->backlog.poll = process_backlog;
  }
}

所以 veth 设备的接收过程和 lo 设备完全一样.想再看看这块过程的同学就请参考第五章的第四节.大致流程是 net_rx_action 执行到 deliver_skb,然后送到协议栈中.

|--->net_rx_action()
    |--->process_backlog()
        |--->__netif_receive_skb()
            |--->__netif_receive_skb_core()
            |---> deliver_skb

10.2.4 总结

由于大部分的同学在日常工作中一般不会接触到 veth,所以在看到 Docker 相关的技术文中提到这个技术时总会以为它是多么的高深.

其实从实现上来看,虚拟设备 veth 和我们日常接触的 lo 设备非常非常的像.连基于 veth 的本机网络 IO 通信图其实都是我直接从第五章里扣过来的.只要看完了本书第五章,理解起来 veth 简直不要太容易.

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

10.3 网络命名空间

上一节中介绍了 veth,有了 veth 我们可以创建出许多的虚拟设备,默认它们都是在宿主机网络中的.接下来虚拟化中还有很重要的一步,那就是隔离.用 Docker 来举例,那就是不能让 A 容器用到 B 容器的设备,甚至连看一眼都不可以.只有这样才能保证不同的容器之间复用硬件资源的同时,还不会影响其它容器的正常运行.

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

NOTE

图10.4 虚拟网络环境(描述:多个 netns,每个拥有独立的网络协议栈:网络设备、路由表、arp表、iptables、socket等)

你是不是和飞哥一样,也很好奇 Linux 底层到底是如何实现网络隔离的?我们今天来好好挖一挖 netns 的内部实现.

10.3.1 如何使用 netns

我们先来看一下网络空间是如何使用的吧.接下来我们来创建一个新的命名空间 net1.再创建一对儿 veth,将 veth 的一头放到 net1 中.分别查看一下母机和 net1 空间内的 iptable、设备等.最后让两个命名空间之间进行通信,要达成的效果如图 10.5.

NOTE

图10.5 实验目的(描述:母机与 net1 命名空间通过 veth 设备对连接,各自拥有独立的网络栈,实现跨命名空间通信)

下面是详细的创建过程.首先我们先来创建一个新的网络命名空间 - net1.

# ip netns add net1

来查看一下它的 iptable、路由表、以及网络设备

# ip netns exec net1 route
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
# ip netns exec net1 iptables -L
Chain INPUT (policy ACCEPT)
target     prot opt source               destination
......
# ip netns exec net1 ip link list
lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT qlen 1
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

由于是新创建的 netns,所以上述的输出中路由表、iptable 规则都是空的.不过这个命名空间中初始的情况下就存在一个 lo 本地环回设备,只不过默认是 DOWN(未启动)状态.

接下来我们创建一对儿 veth,并把 veth 的一头添加给它.

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

在母机上查看一下当前的设备,发现已经看不到 veth1 这个网卡设备了,只能看到 veth1_p.

# ip link list
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 ...
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 ...
3: eth1: <BROADCAST,MULTICAST> mtu 1500 ...
45: veth1_p@if46: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT qlen 1000
    link/ether 0e:13:18:0a:98:9c brd ff:ff:ff:ff:ff:ff link-netnsid 0

这个新设备已经跑到 net1 这个网络空间里了.

# ip netns exec net1 ip link list
1: lo: <LOOPBACK> mtu 65536 ...
46: veth1@if45: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT qlen 1000
    link/ether 7e:cd:ec:1c:5d:7a brd ff:ff:ff:ff:ff:ff link-netnsid 0

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

# ip addr add 192.168.1.1/24 dev veth1_p
# ip link set veth1_p up
# ip netns exec net1 ip addr add 192.168.1.2/24 dev veth1
# ip netns exec net1 ip link set veth1 up

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

# ifconfig
...
veth1_p: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.1.1  netmask 255.255.255.0  broadcast 0.0.0.0
        ......
# ip netns exec net1 ifconfig
...
veth1: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.1.2  netmask 255.255.255.0  broadcast 0.0.0.0
        ......

我们来让它和母机通信一下试试.

# ping 192.168.1.2 -I veth1_p
PING 192.168.1.2 (192.168.1.2) from 192.168.1.1 veth1_p: 56(84) bytes of data.
64 bytes from 192.168.1.2: icmp_seq=1 ttl=64 time=0.012 ms
64 bytes from 192.168.1.2: icmp_seq=2 ttl=64 time=0.008 ms
...

11. 第十章 容器网络虚拟化

10.3 网络命名空间 (Network Namespace)

# 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
# ifconfig
eth0: ...
lo: ...
veth1_p: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.0.100  netmask 255.255.255.0  broadcast 0.0.0.0
        ...
# ip netns exec net1 ifconfig
veth1: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.0.101  netmask 255.255.255.0  broadcast 0.0.0.0
        ...       
# ip netns exec net1 ping 192.168.0.100 -I veth1
PING 192.168.0.100 (192.168.0.100) from 192.168.0.101 veth1: 56(84) bytes of data.
64 bytes from 192.168.0.100: icmp_seq=1 ttl=64 time=0.027 ms
64 bytes from 192.168.0.100: icmp_seq=2 ttl=64 time=0.010 ms

图10.6 内核 namespace 相关数据结构

好了,现在一个新的网络命名空间创建实验就结束了.在这个空间里,网络设备、路由表、ARP表、iptables都是独立的,不会和母机上的冲突,也不会和其它空间里的产生干扰.而且还可以通过 veth 来和其它空间下的网络进行通信.想实际动手做这个实验的同学在公众号“开发内功修炼”后台回复“配套源码”,来获取本实验要使用的测试 makefile 文件.

10.3.2 Namespace 相关的定义

在内核中,很多组件都是和 namespace 有关系的,我们先来看看这个关联关系是如何定义的.后面我们再看下 namespace 本身的详细结构.

归属到 Namespace 的东西

在 Linux 中,很多我们平常熟悉的概念都是归属到某一个特定的网络 namespace 中的,比如进程、网卡设备、socket 等等.

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

拿网络设备来举例,只有归属到当前 netns 下的时候才能够通过 ifconfig 看到,否则是不可见的.我们详细来看看这几个数据结构的定义,先来看进程.

命名空间的核心数据结构是上面的这个 struct nsproxy.所有类型的 namespace(包括 pid、文件系统挂载点、网络栈等等)都是在这里定义的.

其中 struct net *net_ns 就是今天我们要讨论的网络命名空间.它的详细定义我们待会再说.我们接着再看表示网络设备的 struct net_device,它也是要归属到某一个网络空间下的.

所有的网络设备刚创建出来都是在宿主机默认网络空间下的.可以通过 ip link set 设备名 netns 网络空间名 将设备移动到另外一个空间里去,这时其实修改的就是 net_device 下的 struct net* 指针.所以在前面的实验里,当 veth1 移动到 net1 下的时候,该设备在宿主机下“消失”了,在 net1 下就能看到了.

还有我们经常用的 socket,也是归属在某个网络命名空间下的.

//file:include/linux/sched.h
struct task_struct {
  /* namespaces */
  struct nsproxy *nsproxy;
  ......
}
 
//file: include/linux/nsproxy.h
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; // 网络协议栈
};
 
//file: include/linux/netdevice.h
struct net_device{
  //设备名
  char      name[IFNAMSIZ];
  //网络命名空间
  struct net    *nd_net;
  ...
}
 
//file:
struct sock_common {
  struct net    *skc_net;
}

图10.7 网络命名空间数据结构

网络 Namespace 定义

本小节中,我们来看网络 namespace 的主要数据结构 struct net 的定义.

可见每个 net 下都包含了自己的路由表、iptable 以及内核参数配置等等.我们来看具体的代码.

由上述定义可见,每一个 netns 中都有一个 loopback_dev,这就是为什么我们在第一节中看到刚创建出来的空间里就能看到一个 lo 设备的底层原因.

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

//file:include/net/net_namespace.h
struct net {
  //每个 net 中都有一个回环设备
  struct net_device       *loopback_dev;          /* The loopback */
  //路由表、netfilter都在这里
  struct netns_ipv4 ipv4;
  ......
}
 
//file: include/net/netns/ipv4.h
struct netns_ipv4 {
  //路由表 
  struct fib_table  *fib_local;
  struct fib_table  *fib_main;
  struct fib_table  *fib_default;
  //ip表
  struct xt_table   *iptable_filter;
  struct xt_table   *iptable_raw;
  struct xt_table   *arptable_filter;
  //内核参数
  long sysctl_tcp_mem[3];
  ...

图10.8 默认命名空间

10.3.3 网络 namespace 的创建

进程与网络命名空间

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

在 clone 里可以指定创建新进程时的 flag,都是 CLONE_ 开头的. 和 namespace 有的的标志位有 CLONE_NEWIPCCLONE_NEWNETCLONE_NEWNSCLONE_NEWPID 等等.如果在创建进程时指定了 CLONE_NEWNET 标记位,那么该进程将会创建并使用新的 netns.

其实内核提供了三种操作命名空间的方式,分别是 clonesetnsunshare.本文中我们只用 clone 来举例,它的工作结果如图 10.9.

使用 strace 跟踪可以确认 ip netns add 命令内部是使用了 unshare.unshare 的工作原理和 clone 类似.

图10.9 创建新命名空间

我们先来看下默认的网络命名空间的初始化过程.

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

//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_nsproxy 里将多个命名空间都进行了初始化,其中我们关注的网络命名空间,用的是默认网络空间 init_net.它是系统初始化的时候就创建好的.

上面的 setup_net 方法中对这个默认网络命名空间进行初始化.

看到这里我们清楚了 1 号进程的命名空间初始化过程.Linux 中所有的进程都是由这个 1 号进程创建的.如果创建子进程过程中没有指定 CLONE_NEWNET 这个 flag 的话,就直接还使用这个默认的网络空间.

如果创建进程过程中指定了 CLONE_NEWNET,那么就会重新申请一个网络命名空间出来.见如下的关键函数 copy_net_ns(它的调用链是 do_fork => copy_process => copy_namespaces => create_new_namespaces => copy_net_ns).

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

图10.10 网络子系统链

记住 setup_net 是初始化网络命名空间的,这个函数接下来我们还会提到.

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

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

由于内核网络模块的复杂性,在内核中将网络模块划分成了各个子系统.每个子系统都定义了一个 struct pernet_operations.

各个子系统通过调用 register_pernet_subsysregister_pernet_device 将其初始化函数注册到网络命名空间系统的全局链表 pernet_list 中,如图 10.10.你在源码目录下用这两个函数搜索的话,会看到各个子系统的注册过程.

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

  //申请新网络命名空间并初始化
  net = net_alloc();
  rv = setup_net(net, user_ns);
  ...
}
 
//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_operations 又会调用 __register_pernet_operations.

在上面 list_add_tail 这一行,完成了将子系统传入的 struct pernet_operations *ops 链入到 pernet_list 中.并注意一下,for_each_net 是遍历了所有的网络命名空间,然后在这个空间内执行了 ops_init 初始化.

这个初始化是网络子系统在注册的时候调用的.同样当新的命名空间创建时,会遍历该全局变量 pernet_list,执行每个子模块注册上来的初始化函数.再回到我们 3.1.1 里提到的 setup_net 函数.

//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);
  ...
}
 
//file: include/net/net_namespace.h
#define for_each_net(VAR)       \
  list_for_each_entry(VAR, &net_namespace_list, list)
 
//file: net/core/net_namespace.c
static int __register_pernet_operations(struct list_head *list,
          struct pernet_operations *ops)
{
  struct net *net;
  list_add_tail(&ops->list, list);
  if (ops->init || (ops->id && ops->size)) {
    for_each_net(net) {
      error = ops_init(ops, 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);
  ...
}
 
//file: net/core/net_namespace.c
static int ops_init(const struct pernet_operations *ops, struct net *net)
{
  if (ops->init)
    err = ops->init(net);
}

在创建新命名空间调用到 setup_net 时,会通过 pernet_list 找到所有的网络子系统,把它们都 init 一遍.

我们拿路由表来举例,路由表子系统通过 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 来创建一套独立的路由规则.

再比如拿 iptable 中的 nat 表来说,也是一样.每当创建新命名空间的时候,就会调用 iptable_nat_net_init 创建一套新的表.

//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,
};
static int __init iptable_nat_init(void)
{
  err = register_pernet_subsys(&iptable_nat_net_ops);
  ...

图10.11 修改设备命名空间

添加设备

在一个设备刚刚创建出来的时候,它是属于默认网络命名空间 init_net 的,包括 veth 设备.不过可以在创建完后修改设备到新的网络命名空间.

拿 veth 设备来举例,它是在创建时的源码 alloc_netdev_mqs 中设置到 init_net 上的.(执行代码路径:veth_newlink => rtnl_create_link => alloc_netdev_mqs)

//file: core/dev.c
struct net_device *alloc_netdev_mqs(...)
{
  dev_net_set(dev, &init_net);
}
 
//file: include/linux/netdevice.h
void dev_net_set(struct net_device *dev,struct net *net)
{
  release_net(dev->nd_net);
  dev->nd_net = hold_net(net);
}

在执行修改设备所属的 namespace 的时候,会将 dev->nd_net 再指向新的 netns.

//file: core/dev.c
int dev_change_net_namespace(struct net_device *dev, struct net *net, ...)
{
  ...
  dev_net_set(dev, net)
}

对于 veth 来说,它包含了两个设备.这两个设备可以放在不同的 namespace 中.这就是 Docker 容器和其母机或者其它容器通信的基础.

图10.12 socket 命名空间来自于其所属进程

socket 与网络命名空间

其实每个 socket 都是归属于某一个网络命名空间的.这是由创建这个 socket 的进程所属的 netns 来决定的.当在某个进程里创建 socket 的时候,内核就会把当前进程的 nsproxy->net_ns 找出来,并把它赋值给 socket 上的网络命名空间成员 skc_net,如图 10.12.

我们来展开看下 socket 是如何被放到某个网络命名空间中的.在 socket 中,用来保存和网络命名空间归属关系的变量是 skc_net,如下.

//file: include/net/sock.h
struct sock_common {
  ...
  struct net    *skc_net;
}

接下来就是 socket 创建的时候,内核中可以通过 current->nsproxy->net_ns 把当前进程所属的 netns 找出来,最终把 socket 中的 sk_net 成员和该命名空间建立好了联系.

socket_create 中,看到 current->nsproxy->net_ns 了吧,它获取到了进程的 netns.再依次经过 __sock_create => inet_create => sk_alloc,调用到 sock_net_set 的时候,成功设置了新 socket 和 netns 的关联关系.

//file: net/socket.c
int sock_create(int family, int type, int protocol, struct socket **res)
{
  return __sock_create(current->nsproxy->net_ns, family, type, protocol, res, 0);
}
//file: include/net/sock.h
static inline
void sock_net_set(struct sock *sk, struct net *net)
{
  write_pnet(&sk->sk_net, net);
}

在知识星球中我们会进行内核等底层技术的视频讲解,能让你的底层学起来更快,事半功倍.还会进行线上问题排查以及性能优化等方面案例分享和交流.对大家技术深度和广度的积累很有好处.有想继续加入知识星球的同学微信扫描下面的二维码即可加入.另外在公众号后台发送「星球优惠券」可以获取开发内功修炼读者的专属优惠券.

10.3.4 网络收发如何使用 namespace

我们就以网络包发送过程中的路由功能为例,来看一下网络在传输的时候是如何使用到 netns 的.大致的原理是 socket 上记录了其归属的网络命名空间.需要查找路由表之前先找到该命名空间,再找到命名空间里的路由表,然后再开始执行查找,如图 10.13.

图10.13 网络发送路由表查找

我们来看详细的路由查找过程.在第四章中我们提到过在发送过程中在 IP 层的发送函数 ip_queue_xmit 中调用 ip_route_output_ports 来查找路由项.

//file: net/ipv4/ip_output.c
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 *sk_net 给找了出来.

//file: include/net/sock.h
static inline struct net *sock_net(const struct sock *sk)
{
  return read_pnet(&sk->sk_net);
}

在第五章简单介绍过路由查找的过程,路由查找最后会执行到 fib_lookup,我们来看下这个函数的源码.

路由查找的调用链条有点长,是 ip_route_output_ports => ->ip_route_output_flow => __ip_route_output_key() => ip_route_output_key_hash => ip_route_output_key_hash_rcu

//file: include/net/ip_fib.h
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);
}

图10.14 网络命名空间内核结构

由上述代码可见,在路由过程中是根据前面步骤中确定好的命名空间 struct net *net 来查找路由项的.不同的命名空间有不同的 net 变量,所以不同的 netns 中自然也就可以配置不同的路由表了.

10.3.5 结论

很多人说 Linux 的网络 namespace 实现了多个独立协议栈.这个说法其实不是很准确,内核网络代码只有一套,并没有隔离.只是通过为不同空间创建不同的 struct net 对象.每个 struct net 中都有独立的路由表、iptable 等数据结构.每个设备、每个 socket 上也都有指针指明自己归属那个 netns,如图 10.14.通过这种方法从逻辑上看起来好像是真的有多个协议栈一样.

这样,就为一台物理上创建出多个逻辑上的协议栈,为 Docker 容器的诞生提供了可能.如图 10.4 中,Docker1 和 Docker2 都可以分别拥有自己独立的网卡设备,配置自己的路由规则、iptable.从而使得他们的网络功能不会相互影响.怎么样,今天是不是对网络 namespace 理解更深了呢.

图10.15 veth 对儿通信

图10.16 物理局域网连接

10.4 虚拟交换机 Bridge

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

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

回头想一下,在物理机的网络环境中,多台不同的物理机之间是如何连接一起互相通信的呢?没错,那就是以太网交换机.同一网络内的多台物理机通过交换机连在一起,然后它们就可以相互通信了,如图 10.16.

在我们的网络虚拟化环境里,和物理网络中的交换机一样,也需要这样的一个软件实现的设备.它需要有很多个虚拟端口,能把更多的虚拟网卡连接在一起,通过自己的转发功能让这些虚拟网卡之间可以通信.在 Linux 下这个软件实现交换机的技术就叫做 bridge(再强调下,这是纯软件实现的).

图 10.16 物理局域网连接

物理网络环境示意图:多台物理机通过交换机连接,实现相互通信.

图 10.17 bridge 工作原理

bridge 作为软件实现的交换机,连接多个虚拟网卡,负责在不同“端口”之间转发数据包.

各个 Docker 容器都通过 veth 连接到 bridge 上,bridge 负责在不同的“端口”之间转发数据包.这样各个 Docker 之间就可以互相通信了!这一节我们来展开聊聊 bridge 的详细工作过程.

10.4.1 如何使用 bridge

在分析它的工作原理之前,很有必要先来看一看网桥是如何使用的.为了方便大家理解,接下来我们通过动手实践的方式,在一台 Linux 上创建一个小型的虚拟网络出来,并让它们之间互相通信.

创建两个不同的网络

Bridge 是用来连接两个不同的虚拟网络的,所以在准备实验 bridge 之前我们得先需要用 net namespace 构建出两个不同的网络空间来.如图 10.18.

图 10.18 创建两个虚拟网络

使用 net namespace 创建两个独立的网络环境 net1 和 net2.

具体的创建过程如下.我们通过 ip netns 命令创建 net namespace.首先创建一个 net1:

# ip netns add net1

接下来创建一对儿 veth 出来,设备名分别是 veth1 和 veth1_p.并把其中的一头 veth1 放到这个新的 netns 中.

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

因为我们打算是用这个 veth1 来通信,所以需要为其配置上 ip,并把它启动起来.

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

查看一下,上述的配置是否成功.

# ip netns exec net1 ip link list
# ip netns exec net1 ifconfig

重复上述步骤,再创建一个新的 netns 出来,命名分别为:

  • netns: net2
  • veth pair: veth2, veth2_p
  • ip: 192.168.0.102

好了,这样我们就在一台 Linux 就创建出来了两个虚拟的网络环境.

把两个网络连接到一起

在上一个步骤中,我们只是创建出来了两个独立的网络环境而已.这个时候这两个环境之间还不能互相通信.我们需要创建一个虚拟交换机 - bridge,来把这两个网络环境连起来,如图 10.19.

图 10.19 使用 bridge 连接两个网络

创建 bridge 设备,将 veth1_p 和 veth2_p 插入到 bridge 上,实现 net1 和 net2 的连通.

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

# brctl addbr br0
# ip link set dev veth1_p master br0
# ip link set dev veth2_p master br0

再为 bridge 配置上 IP,并把 bridge 以及插在其上的 veth 启动起来.

# ip addr add 192.168.0.100/24 dev br0
# ip link set veth1_p up
# ip link set veth2_p up
# ip link set br0 up

查看一下当前 bridge 的状态,确认刚刚的操作是成功了的.

# brctl show
bridge name     bridge id               STP enabled     interfaces
br0             8000.4e931ecf02b1       no              veth1_p
                                                        veth2_p

网络连通测试

激动人心的时刻就要到了,我们在 net1 里(通过指定 ip netns exec net1 以及 -I veth1),ping 一下 net2 里的 IP(192.168.0.102)试试.

# ip netns exec net1 ping 192.168.0.102 -I veth1
PING 192.168.0.102 (192.168.0.102) from 192.168.0.101 veth1: 56(84) bytes of data.
64 bytes from 192.168.0.102: icmp_seq=1 ttl=64 time=0.037 ms
64 bytes from 192.168.0.102: icmp_seq=2 ttl=64 time=0.008 ms
64 bytes from 192.168.0.102: icmp_seq=3 ttl=64 time=0.005 ms

图 10.20 网络连通测试

从 net1(192.168.0.101)ping net2(192.168.0.102)成功,延迟低至 0.005ms.

哇塞,通了通了!!这样,我们就在一台 Linux 上虚拟出了 net1 和 net2 两个不同的网络环境.我们还可以按照这种方式创建更多的网络,都可以通过一个 bridge 连接到一起.这就是 Docker 中网络系统工作的基本原理.

10.4.2 Bridge 是如何创建出来的

在内核中,bridge 是由两个相邻存储的内核对象来表示的,如图 10.21.

图 10.21 bridge 内核结构

Bridge 内核对象由 struct net_device 和 struct net_bridge 两个相邻存储的结构体组成.

我们先看下它是如何被创建出来的.内核中创建 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.在这个函数调用里要注意两点:

  1. 第一个参数传入了 struct net_bridge 的大小
  2. 第三个参数传入的 br_dev_setup 是一个函数.

带着这两点注意事项,我们进入到 alloc_netdev 的实现中.

好吧,竟然是个宏.那就得看 alloc_netdev_mqs 了.

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

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

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

//file: 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)的申请以及初始化.

10.4.3 添加设备

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

图 10.22 给 bridge 添加设备过程

veth 由 struct net_device 表示,bridge 的虚拟插口由 struct net_bridge_port 表示,两者通过 net_bridge_port 连接.

其中 veth 是由 struct net_device 来表示,bridge 的虚拟插口是由 struct net_bridge_port 来表示.我们接下来看看源码,是如何达成上述的逻辑结果的.

添加设备会调用到 net/bridge/br_if.c 下面的 br_add_if.

//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 这个结构体,它模拟的是物理交换机上的一个插口.它起到一个连接的作用,把 veth 和 bridge 给连接了起来.见 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->port_no = index;
  ...
}

new_nbp 中,先是申请了代表插口的内核对象.find_portno 是在当前 bridge 下寻找一个可用的端口号.接下来插口对象通过 p->br = br 和 bridge 设备关联了起来,通过 p->dev = dev 和代表 veth 设备的 dev 对象也建立了联系.

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

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

10.4.4 数据包处理过程

在第二章我们讲到过接收包的完整流程.数据包会被网卡先送到 RingBuffer 中,然后依次经过硬中断、软中断处理.在软中断中再依次把包送到设备层、协议栈,最后唤醒应用程序.

不过,拿 veth 设备来举例,如果它连接到了网桥上的话,在设备层的 __netif_receive_skb_core 函数中和上述过程有所不同.连在 bridge 上的 veth 在收到数据包的时候,不会进入协议栈,而是会进入网桥处理.网桥找到合适的转发口(另一个 veth),通过这个 veth 把数据转发出去.工作流程如图 10.23.

图 10.23 bridge 上的数据转发过程

数据包从 veth1_p 接收后,经网桥处理,转发到 veth2_p,再通过 veth2 到达目标.

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

//file: net/core/dev.c
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 = NET_RX_SUCCESS;
      goto unlock;
    }
  }
  
  // 送往协议栈
  //...
unlock:
  rcu_read_unlock();
out:
  return ret;
}

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

接着来看下网桥是咋工作的吧,进入到 br_handle_frame 中来搜寻.

//file: net/bridge/br_input.c
rx_handler_result_t br_handle_frame(struct sk_buff **pskb)
{
  ...
forward:
  NF_HOOK(NFPROTO_BRIDGE, NF_BR_PRE_ROUTING, skb, skb->dev, NULL,
      br_handle_frame_finish);
}

上面我对 br_handle_frame 的逻辑进行了充分的简化,简化后它的核心就是调用 br_handle_frame_finish.同样 br_handle_frame_finish 也有点小复杂.本文中,我们主要想了解的 Docker 场景下 bridge 上的 veth 设备转发.所以根据这个场景,我又对该函数进行了充分的简化.

//file: net/bridge/br_input.c
int br_handle_frame_finish(struct sk_buff *skb)
{   
  // 获取 veth 所连接的网桥端口、以及网桥设备
  struct net_bridge_port *p = br_port_get_rcu(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_forward 进入真正的转发流程.

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

图 10.24 修改 skb 归属设备

在 __br_forward 中,将 skb 上的设备 dev 改为了新的目的 dev.

__br_forward 中,将 skb 上的设备 dev 改为了新的目的 dev.

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

//file: net/bridge/br_forward.c
int br_forward_finish(struct sk_buff *skb)
{
  return NF_HOOK(NFPROTO_BRIDGE, NF_BR_POST_ROUT```c
//file: net/bridge/br_forward.c
int br_forward_finish(struct sk_buff *skb)
{
  return NF_HOOK(NFPROTO_BRIDGE, NF_BR_POST_ROUTING, skb, NULL, skb->dev,
                 br_dev_queue_push_xmit);
}
 
int br_dev_queue_push_xmit(struct sk_buff *skb)
{
  dev_queue_xmit(skb);
  ...
}

dev_queue_xmit 就是发送函数,在 10.2 节我们介绍过,后续的发送过程就是 dev_queue_xmit => dev_hard_start_xmit => veth_xmit。在 veth_xmit 中会获取到当前 veth 的对端,然后把数据给它发送过去。

图 10.25 转发给 veth

数据从 veth1_p 经网桥转发到 veth2_p,再由 veth2_p 发送到其对端 veth2。

图 10.26 目的设备接收处理

veth2 收到数据后,进入正常的数据接收流程,将数据送入协议栈。

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

接下来,收到网桥发过来数据的 veth 会把数据包发送给它的对端 veth2,veth2 再开始自己的数据包接收流程。

10.4.5 总结

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

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

回到 Docker 的使用场景上来举例,完整的 Docker 1 和 Docker 2 通信的过程如下图 10.27。

图 10.27 bridge 工作过程汇总

完整展示 Docker1 和 Docker2 通过 bridge 通信的 5 个步骤。

大致步骤是:

  1. Docker1 往 veth1 上发送数据
  2. 由于 veth1_p 是 veth1 的 pair,所以这个虚拟设备上可以收到包
  3. veth 收到包以后发现自己是在连在网桥上的,于是乎进入网桥处理。在网桥设备上寻找要转发到的端口,这时找到了 veth2_p 开始发送。网桥完成了自己的转发工作
  4. veth2 作为 veth2_p 的对端,收到了数据包
  5. Docker2 里的就可以从 veth2 设备上收到数据了

觉得这个流程图还不过瘾?那我们再继续拉大视野,从两个 Docker 的用户态来开始看一看,见图 10.28。

图 10.28 基于 bridge 的本机网络发送和接收

完整展示从 Docker1 用户态 send 系统调用,到协议栈、邻居子系统、veth、bridge 转发,再到 Docker2 接收的全流程。

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

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

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

10.5 外部网络通信

学习完了前面几节,我们通过 veth、namespace 和 bridge 在一台 Linux 上就能虚拟多个网络环境出来。也还可以让新建网络环境之间、和宿主机之间都可以通信。这时我们还剩下一个问题没有解决,那就是虚拟网络环境和外部网络的通信,如图 10.29。还拿 Docker 容器来举例,你启动的容器里的服务肯定是需要访问外部的数据库的。还有就是可能需要暴露比如 80 端口对外提供服务。例如在 Docker 中我们通过下面的命令将容器的 80 端口上的 web 服务要能被外网访问的到。

图 10.29 容器外部通信需求

容器需要访问外部数据库,以及对外暴露 80 端口提供 web 服务。

图 10.30 main 路由表查看

通过 ip route list table mainroute -n 查看 main 路由表。

我们今天的文章主要就是解决这个问题的。解决它还需要用到路由和 NAT 技术。

10.5.1 路由和 NAT

路由

Linux 在发送数据包或者转发包的时候,会涉及路由过程。这个发送数据过程既包括本机的数据发送,也包括途径当前机器的数据包的转发。其中本机发送在第三章我们讨论过。

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

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

其它的路由规则,一般都是在 main 路由表中记录着的。可以用 ip route list table local 查看,也可以用更简短的 route -n

#ip route list table local
local 10.143.x.y dev eth0 proto kernel scope host src 10.143.x.y
local 127.0.0.1 dev lo proto kernel scope host src 127.0.0.1

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

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

iptables 与 NAT

10.5.2 实现外部网络通信

基于以上的基础知识,我们用纯手工的方式搭建一个可以和 Docker 类似的虚拟网络。而且要实现和外网通信的功能。在公众号“开发内功修炼”后台回复“配套源码”,来获取本实验要使用的测试 makefile 文件。

实验环境准备

我们先来创建一个虚拟的网络环境出来,其命名空间为 net1。宿主机的 IP 是 10.162 的网段,可以访问外部机器。虚拟网络为其分配 192.168.0 的网段,这个网段是私有的,外部机器无法识别。

这个虚拟网络的搭建过程如下:

  1. 先创建一个 netns 出来,命名为 net1
  2. 创建一个 veth 对儿(veth1 - veth1_p),把其中的一头 veth1 放在 net1 中,给它配置上 IP,并把它启动起来。
  3. 创建一个 bridge,给它也设置上 IP。
  4. 接下来把 veth 的另外一端 veth1_p 插到 bridge 上面。
  5. 最后把网桥和 veth1_p 都启动起来。
# ip netns add net1
# ip link add veth1 type veth peer name veth1_p
# ip link set veth1 netns net1
# ip netns exec net1 ip addr add 192.168.0.2/24 dev veth1  
# ip netns exec net1 ip link set veth1 up

![图10.33 请求外部资源](Image 1210 on Page 292)

这样我们就在 Linux 上创建出了一个虚拟的网络。这个准备过程和 10.4 小节中一样,只不过今天为了省事,只创建了一个网络出来,上一节中创建出来了两个。

请求外部资源

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

我们直接来访问一下试试:

# brctl addbr br0
# ip addr add 192.168.0.1/24 dev br0
# ip link set dev veth1_p master br0
# ip link set veth1_p up
# ip link set br0 up
 
# ip netns exec net1 ping 10.153.*.*
connect: Network is unreachable

提示网络不通,这是怎么回事?用这段报错关键字在内核源码里搜索一下:

//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, &fl4, 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 主要是执行路由选路。所以我们推断可能是路由出问题了,看一下这个命名空间的路由表。

# ip netns exec net1 route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
192.168.0.0     0.0.0.0         255.255.255.0   U     0      0        0 veth1

怪不得,原来 net1 这个 namespace 下默认只有 192.168.0.* 这个网段的路由规则。我们 ping 的 IP 是 10.153.*.* ,根据这个路由表里找不到出口。自然就发送失败了。

我们来给 net 添加上默认路由规则,只要匹配不到其它规则就默认送到 veth1 上,同时指定下一跳是它所连接的 bridge(192.168.0.1)。

# ip netns exec net1 route add default gw 192.168.0.1 veth1 

再 ping 一下试试。

# ip netns exec net1 ping 10.153.*.* -c 2
PING 10.153.*.* (10.153.*.*) 56(84) bytes of data.
--- 10.153.*.* ping statistics ---
2 packets transmitted, 0 received, 100% packet loss, time 999ms

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

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

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

没错,那就是我们上面说的 NAT 技术。

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

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

![图10.34 bridge 上抓到的源 IP](Image 1221 on Page 295) ![图10.35 eth0 上抓到的源 IP](Image 1226 on Page 296)

来再 ping 一下试试,欧耶,通了!

# ip netns exec net1 ping 10.153.*.*
PING 10.153.*.* (10.153.*.*) 56(84) bytes of data.
64 bytes from 10.153.*.*: icmp_seq=1 ttl=57 time=1.70 ms
64 bytes from 10.153.*.*: icmp_seq=2 ttl=57 time=1.68 ms

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

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

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

![图10.36 访问外部资源过程](Image 1235 on Page 298)

开放容器端口

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

这次我们是要实现外部网络访问内部地址,所以需要的是 DNAT 配置。

DNAT 和 SNAT 配置中有一个不一样的地方就是需要明确指定容器中的端口在宿主机上是对应哪个。比如在 docker 的使用中,是通过 -p 来指定端口的对应关系:

# docker run -p 8000:80 ...

![图10.37 eth0 上抓到的目的 IP](Image 1243 on Page 300) ![图10.38 bridge 上抓到的目的 IP](Image 1248 on Page 301)

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

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

这里表示的是宿主机在路由之前判断一下如果流量不是来自 br0,并且是访问 TCP 的 8088 的话,那就转发到 192.168.0.2:80

net1 环境中启动一个 Server:

# ip netns exec net1 nc -lp 80

在外部用 telnet 连一下试试,通了!

# telnet 10.162.*.* 8088
Trying 10.162.*.*...
Connected to 10.162.*.*.
Escape character is '^]'.

开启抓包,# tcpdump -i eth0 host 10.153.*.*。可见在 eth0 上的时候,网络包目的是宿主机的 IP 的端口。

但数据包到宿主机协议栈以后命中了我们配置的 DNAT 规则,宿主机把它转发到了 br0 上。在 bridge 上抓包看看,由于没有那么多的网络流量包,所以不用过滤直接抓包就行,# tcpdump -i br0。发现在 br0 上抓到的目的 IP 和端口是已经替换过的了,换成了 192.168.0.2:80

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

![图10.39 响应外部请求过程](Image 1253 on Page 302)

![图10.40 容器与外部的通信过程](Image 1261 on Page 304)

欢迎加入我的知识星球,也欢迎加入我的技术交流群

Github:https://github.com/yanfeizhang/coder-kung-fu

10.5.3 总结

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

Veth 实现连接,bridge 实现转发,namespace 实现隔离,路由表控制发送时的设备选择,iptables 实现 NAT 等功能。基于以上基础知识,我们采用纯手工的方式搭建了一个虚拟网络环境。

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


10.6 本章总结

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

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

回到我们开篇提到的几个问题上:

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

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

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