参考资料:深入理解Linux网络
相关实际问题
1、容器中的eth0
和母机上的eth0
是一个东西吗?
2、veth
设备是什么,它是如何工作的?
3、Linux
是如何实现虚拟网络环境的?
4、Linux
如何保证同宿主机上多个虚拟网络环境中的路由表可以独立工作?
5、同一宿主机上多个容器之间是如何通信的?
6、Linux
上的容器如何和外部机器通信?
veth设备对
回想一下在物理机组成的网络世界里,最基础、最简单的网络连接方式是什么? ====》eth
(网卡)
那么在网络虚拟化实现的第一步,就是用软件来模拟物理世界中的网卡。===》veth
(虚拟网卡)
如图所示,ens33
就是eth
,lo
是本机网络中的回环设备loopback
。lo
与veth
一样都是用软件虚拟机出来的一个设备。
veth如何使用
在linux
中可以使用ip
命令创建一对veth
。命令如下:
ip link add veth0 type veth peer name veth1
其中link
表示link layer
的意思,表示数据链路层。这个命令可以用于管理和查看网络接口,包括物理接口、也包括虚拟接口。
使用show
命令进行查看
ip link show
可以看到lo
、ens33
中有UP
标记,表示这两个设备启动了。
和eth0
(ens33
)、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
一下
至此,一对虚拟设备已经建立起来了。不过若想它们之间可以通信,需要关闭反向过滤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
若执行上述命令报错
可以使用下面这种方式:
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'
现在在veth0
上ping
一下veth1
。
ping 192.168.1.2 -I veth0
在另外一个控制台上启动了tcpdump进行抓包,结果如下:
由于两个设备之间是首次通信,所以veth0
首先发出一个arp request
,veth1
收到后恢复一个arp reply
。然后就是正常的ping
包了。
veth底层创建过程
veth
的相关源码位于driver/net/veth.c
,其中初始化入口是veth_init
。
在veth_init
中注册了veth_link_ops
(veth
设备的操作方法),它包含了veth
设备的创建、启动和删除等回调函数。
在veth_newlink
中,通过register_netdevice
创建了peer
和dev
两个网络虚拟设备。然后通过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_ops
是veth
设备的操作函数,例如发送过程中调用的函数指针ndo_start_xmit
,对于veth
设备来说就会调用到veth_xmit
。
veth网络通信过程
网络设备层最后会通过ops->ndo_start_xmit
来调用驱动进行真正的发送。
veth
的网络与lo
设备不同的就是使用的驱动程序不一样。其中lo设备最后使用的loopback_xmit
,veth
设备使用的是veth_xmit
。
veth_xmit
函数大致流程如下:
- 获取
veth
设备的对端(priv->peer
) - 调用
dev_forward_skb
向对端发包(调用了eth_type_trans
将skb
的所属设备改为对端) - 与
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
)等。使得不同的网络空间都好像运行在独立的网络中一样。如图所示:
如何使用网络命名空间
先看一下网络命名空间如何使用。创建一个新的命名空间net1,再创建一对veth,将veth的一头放到net1中。分别查看母机和net1空间内的iptables、设备。最后让两个命名空间通信,要达成的效果如图:
详细过程:
# 创建网络命名空间
ip netns add net1
# 查看iptables、路由表以及网络设备
ip netns exec net1 route
ip netns exec net1 iptables -L
ip netns exec net1 ip link list
由于新创建的网络命名空间,所以iptable
、路由表都是空的,不过存在一个lo
本地回环设备,默认状态是DOWN
状态。
接下来创建一对veth
,并把veth
的另一头添加给它
ip link add veth1 type veth peer name veth1_p
ip link set veth1 netns net1
在母机上查看当前设备,发现已经看不到veth1
了,只能看到veth1_p
新设备已经到net1
这个空间了
ip netns exec net1 ip link list
把这对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
母机
net1
让它和母机通信试一下
ip netns exec net1 ping 192.168.0.100 -I veth1
在这个空间里面,网络设备、路由表、arp表、iptables都是独立的,不会和母机冲突,也不会其他空间里的产物产生干扰,而且还可以通过veth和其他空间下的网络进行通信。
命名空间相关的定义
在内核中,很多组件都是和namspace
有关系的。
关联命名空间
linux
中每个进程(线程)都是用task_struct
来表示的。每个task_struct
都要关联到一个命名空间对象nsproxy
,而nsproxy
又包含了网络命名空间(netns
)。对于网卡设备和socket
来说,通过自己的成员来直接表明自己的归属。如图所示。
拿网络设备来说,只有归属到当前网络命名空间下的时候才能通过ifconfig
看到。
所有的网络设备刚创建出来都是在宿主机默认网络空间下。可以通过ip link set 设备名 netns 网络空间名
将设备移动到另一个空间里去,这其实修改的就是net_device
下的struct_net
指针
网络命名空间定义
可见每个net
内核对象下都包含了自己的路由表、iptable
以及内核参数配置等。
每一个网络命名空间——netns
中都有一个loopback_dev
,这就是为什么在刚创建出来的空间就有一个lo
设备的底层原因。
在网络命名空间中最核心的数据结构是struct netns_ipv4 ipv4
,在这个结构里定义了每一个网络空间专属的路由表、ipfilter
以及各种内核参数。
网络命名空间的创建
进程与网络命名空间
Linux
上存在着一个默认的网络命名空间,Linux
中的1号进程初始使用该默认空间。其他所有进程都是由1号进程派生出来的,在派生clone
的时候如果没有特别指定,所有的进程都将共享这个默认的网络空间。
其实内核提供了三种操作命名空间的方式,分别是clone
、setns
和unshare
。本节只用clone
来举例。
先看一下默认的网络命名空间的初始化过程。
//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
时初始化的,包括路由表、tcp
的proc
伪文件系统、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_subsys
或register_pernet_device
将其初始化函数注册到网络命名空间系统的全局链表pernet_list
中。
拿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_operations
。register_pernet_operations
中会把传入的ops
链入pernet_list
中。并且会遍历所有的网络命名空间,然后在这个空间内执行了ops_init
初始化。这个初始化是网络子系统在注册的时候调用的。同样,当新的命名空间创建时,会遍历该全局变量pernet_list
,执行每个子模块注册的初始化函数。
我们拿路由表来举例,路由表子系统通过register_pernet_subsys
将fib_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
来决定的。
网络收发如何使用网络命名空间
socket
上记录了其归属的网络命名空间,需要在查找路由表之前先找到该命名空间,再找到网络命名空间中的路由表。
小结
通过为不同的网络命名空间创建不同的struct net
对象,从而每个struct net
中都有独立的路由表,iptables
等数据结构,每个设备、每个socket
上也有指针表明自己属于哪个网络命名空间,通过这种方法从逻辑上看起来好像真的有多个协议栈一样。
虚拟交换机Bridge
Linux
中的veth
是一对能够相互连接、相互通信的虚拟网卡。通过使用它,可以使Docker
容器和母机通信,或者在两个Docker
容器中进行交流。
不过在实际工作中,我们会想在一台物理机上虚拟出几个、几十个容器,以求充分压榨物理机的硬件资源。但这样带来的问题是大量容器之间的网络互联。上面很简单的veth互联方案是没有办法直接工作的,我们该怎么办?
回想一下物理机的网络环境,多台不同的物理机是怎么样连接在一起的呢。
在网络虚拟化环境里,和物理网络中的虚拟机一样,也需要这样一个软件来实现的设备。它需要很多虚拟网口,能把更多的虚拟机网卡连接在一起,通过自己的转发功能让这些虚拟网卡之间可以通信。在Linux
中的这个软件就叫做Bridge
(纯软件实现的)。
如何使用Bridge
在分析它的工作原理之前,先看一下是如何使用的
创建两个不同的网络
Bridge
是用来连接两个不同的虚拟网络的,所以在准备实验Bridge
之前需要先用ip net
命令构建出来两个不同的网络空间来
首先创建一个net1
:
sudo ip netns add net1
接下来创建一对veth
,设备名分别是veth1
和veth1_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
查看网络名称空间
ip netns list
以同样的方式创建一个新的网络名称空间
netns
:net2
veth pair
:veth2,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
执行之后如下图所示:
把两个网络连接到一起
我们刚刚创建了两个独立的网络环境,这个时候它们之间是不能互通的,需要创建一个虚拟交换机——Bridge
,来把这两个网络环境连接起来
创建过程如下。创建一个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的状态
网络连通测试
激动人心的时刻到了,我们在net1
(通过指定ip netns exec net1
以及 -I veth1
)里ping
一下net2
里的IP
(192.168.0.102),如图所示:
这样我们就在一台Linux
上虚拟除了net1
和net2
两个不同的网络环境的。我们还可以按照这种方式创建更多的网络,都可以通过一个Bridge
连接到一起,这就是Docker
中网络系统的工作原理。
Bridge是如何创建出来的
在内核中,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
。在这个函数调用里要注意两点
- 第一个参数传入了
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_device
和struct 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_device
和struct net_bridge
)的申请以及初始化。
添加设备
调用brctl addif bro veth0
给网桥添加设备的时候,会将veth
设备以虚拟的方式连到网桥上。当添加了若干个veth
以后,内核中对象的大概逻辑如图所示。
其中veth
由struct 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
这个结构体,它模拟的是物理交换机上的一个插口。它起到一个连接的作用,把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->portno=index;
......
}
find_portno
寻找可用端口,p->br = br
和bridge
设备关联了起来,p->dev = dev
和代表veth
设备的dev
对象也建立了联系。
在br_add_if
中还调用netdev_rx_handler_register
注册了设备接收函数,设置veth
上的rx_handler
为br_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);
}
数据包的处理过程
物理网卡的收包过程
网络协议栈的处理
数据包会被网卡先送到RingBuffer
中,然后依次经过硬中断、软中断处理。在软中断中再依次把包送到设备层、协议栈,最后唤醒应用程序。不过,拿veth设备来举例,如果它连接到Bridge
上,在设备层的_netif_receive_skbcore
函数中和上述过程有所不同。连在Bridge
上的veth
在收到数据包的时候,不会进入协议栈,而是会进入Bridge
处理。Bridge
找到合适的转发口(另一个veth
),通过这个veth
把数据转发出去。工作流程如图所示。
我们从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
然后调用br_forward_finish
进入发送流程。在br_forward_finish
里会依次调用br_devqueue_push_xmit
和dev_queue_xmit
。
至此,Bridge
上的转发流程就算完毕了。要注意的是,整个Bridge
的工作源码都是在net/core/dev.c
或net/bridge
目录下,都是在设备层工作的。这也就充分印证了我们经常说的Bridge
和物理交换机也一样是二层上的设备。
接下来,收到网桥发过来数据的vetn会把数据包发送给它的对端veth2
,veth2
再开始自己的数据包接收流程,如图所示
小结
所谓网络虚拟化,其实用一句话来概括就是用软件来模拟实现真实的物理网络连接。
Linux
内核中的Bridge
模拟实现了物理网络中的交换机的角色。和物理网络类似,可以将虚拟设备插入Bridge
。不过和物理网络有点不一样的是,一对veth
插入Bridge
的那端其实就不是设备了,可以理解为退化成了一个网线插头。当Bridge
接入了多对veth
以后,就可以通过自身实现的网络包转发的功能来让不同的veth
之间互相通信了。
回到Docker
的使用场景上来举例,完整的Docker1
和Docker2
通信的过程如图所示
我们再继续拉大视野,从两个Docker
的用户态来开始看一看
Docker1
在需要发送数据的时候,先通过send
系统调用发送,这个发送会执行到协议栈进行协议头的封装等处理。经由邻居子系统找到要使用的设备(veth1
)后,从这个设备将数据发送出去,veth1
的对端veth1_p
会收到数据包。
收到数据包的veth1_p
是一个连接在Bridge
上的设备,这时候Bridge
会接管该veth
的数据接收过程。从自己连接的所有设备中查找目的设备。找到veth2_p
以后,调用该设备的发送函数将数据发送出去。同样,veth2_p
的对端veth2
即将收到数据。
其中veth2
收到数据后,将和lo
、eth0
等设备一样,进入正常的数据接收处理过程。Docker2
中的用户态进程将能够收到Docker1
发送过来的数据了。
外部网络通信
通过
veth、网络命名空间和Bridge
在一台Linux
上就能虚拟多个网络环境出来。也还可以让新建网络环境之间、和宿主机之间都可以通信。这时还剩下一个问题没有解决,那就是虚拟网络环境和外部网络的通信,如图所示。
解决这个问题需要用到路由和NAT技术。
路由和NAT
路由
Linux
在发送数据包或者转发包的时候,会涉及路由过程。这个发送数据过程既包括本机的数据发送,也包括途经当前机器的数据包的转发。
所谓路由其实很简单,就是该选择哪张网卡(虚拟网卡设备也算)将数据写进去到底该选择哪张网卡呢,规则都是在路由表中指定的。Linux
中可以有多张路由表,最重要和常用的是local
和main
。
local
路由表统一记录本地,确切说是本地网络命名空间中的网卡设备IP
的路由规则。
ip route list table local
使用 ip route list table main
或者route -n
查看main
除了本机发送,转发也会涉及路由过程。如果Linux
收到数据包以后发现目的地址并不是本地地址的话,就可以选择把这个数据包从自己的某个网卡设备转发出去。这时和本机发送一样,也需要读取路由表。根据路由表的配置来选择从哪个设备将包转走。
不过值得注意的是,Linux
上转发功能默认是关闭的。也就是发现目的地址不是本机IP地址时默认将包直接丢弃。需要做一些简单的配置,Linux
才可以干像路由器一样的工作,实现数据包的转发。
iptables与NAT
Linux
内核网络栈在运行上基本属于纯内核态的东西,但为了迎合各种各样用户层不同的需求,内核开放了一些口子出来供用户层来干预。其中iptables
就是一个非常常用的干预内核行为的工具,它在内核里埋下了五个钩子入口,这就是俗称的五链。
Linux在接收数据的时候,在IP层进入ip_rcv
中处理。再执行路由判断,发现是本机的话就进入ip_local_deliver
进行本机接收,最后送往TCP
协议层。在这个过程中,埋了两个HOOK
,第一个是PRE_ROUTING
。这段代码会执行到iptables
中PREROUTING
里的各种表。发现是本地接收后接着又会执行到LOCAL_IN
,这会执行到iptables
中配置的INPUT
规则。
在发送数据的时候,查找路由表找到出口设备后,依次通过__ip_local_out
、ip_output
等函数将包送到设备层。在这两个函数中分别过了OUTPUT
和POSTROUTING
的各种规则。
在转发数据的时候,Linux
收到数据包发现不是本机的包可以通过查找自己的路由表找到合适的设备把它转发出去。那就先在ip_rcv
中将包送到ip_forward
函数中处理,最后在ip_output
函数中将包转发出去。在这个过程中分别过了PREROUTING
、FORWARD
和POSTROUTING
三个规则。
查看iptables
的规则:sudo iptables -L
常见的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里的五个链在内核网络模块中的位置就可以归纳成如图所示
数据接收过程走的是1和2,发送过程走的是4和5,转发过程是1、3、5。有了这张图,我们能更清楚地理解iptables和内核的关系。
在iptables
中,根据实现的功能的不同,又分成了四张表。分别是raw
、mangle
、nat
和filter
。其中nat
表实现我们常说的NAT (Network AddressTranslation)
功能。其中NAT
又分成SNAT ( Source NAT)
和DNAT ( Destination NAT )
两种。
SNAT
解决的是内网地址访问外部网络的问题。它是通过在POSTROUTING
里修改来源 IP
来实现的。DNAT
解决的是内网的服务要能够被外部访问到的问题。它是通过PREROUTING
修改目标IP
实现的。
实现外部网络通信
基于以上的基础知识,我们用纯手工的方式搭建一个可以和Docker类似的虚拟网络。
实验准备环境
先来创建一个虚拟的网络环境,如图所示
创建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
创建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
这样我们就在Linux上创建出了一个虚拟的网络。
请求外部资源
现在假设net1这个网络环境想访问外部网络资源。假设它要访问的另外一台机器的IP是10.153.*.*,这个10.153.*.*后面两段由于是内部网络,所以隐藏起来了。你在实验的过程中,用自己的IP代替即可。
直接来访问一下试试
ip netns exec net1 ping 10.153.*.*
提示网络不通,用这段报错关键字在内核源码里搜索一下。
//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
主要是执行路由选路。所以我们推断可能是路由出问题了,看一下这个网络命名空间的路由表。
怪不得,原来net1
这个网络命名空间下默认只有102.168.0.*这个网段的路由规则。我们ping
的IP
是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
其中UG
表示路由是活动的且路由需要通过网关进行转发。
再ping
一下试试
可以看到仍然不通。上面路由帮我们把数据包从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地址。这通常用于实现局域网内主机与公网之间的通信,使得内部主机能够通过本机进行网络访问。
再来试一试
可以看到通了,这时候可以开启tcpdump
抓包查看一下,在Bridge
上抓到的包我们能看到还是原始的源IP
和目的IP
。
再到eth0(ens33)
上查看,源IP
已经被替换成可和外网通信的eth0(ens33)
上的IP
了。
至此,容器就可以通过宿主机的网卡来访问外部网络上的资源了。我们来总结一下这个发送过程。
开放容器端口
我们再考虑另外一个需求,那就是把在这个网络命名空间内的服务提供给外部网络使用。和上面的问题一样,虚拟网络环境中192.168.0.2
这个IP
外界是不认识它的。只有这个宿主机知道它是谁,所以我们同样还需要NAT
功能。
这次我们是要实现外部网络访问内部地址,所以需要的是DNAT
配置。DNAT
和SNAT
配置中有一个不一样的地方就是需要明确指定容器中的端口在宿主机上对应哪个。比如在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协议的服务器,以便在该网络命名空间中接收来自其他主机的连接和请求。
两台虚拟机,与上面访问公网有所不同, 但本质一样。
结果如下,使用192.168.159.129的机器telnet192.168.159.128机器的8080端口,会把数据转发到net1所监听的80端口,有net1
的设备接收数据。
net1中的80端口
在192.168.159.129中进行tcpdump抓包,可以看到目标ip端口还是8088
但数据包到宿主机协议栈以后命中了我们配置的DNAT
规则,宿主机把它转发到了br0
上。发现在br0
上抓到的目的IP
和端口是已经替换过的了,换成了192.168.0.2:80,如图所示
Bridge
当然知道192.168.0.2是veth1
。于是,在veth1
上监听80的服务就能收到来自外界的请求了!我们来总结一下这个接收过程。
小结
现在业界已经有很多公司都迁移到容器上了。开发人员写出来的代码大概率是要运行在容器上的。因此深刻理解容器网络的工作原理非常重要。只有这样,将来遇到问题的时候才知道该如何下手处理。
veth
实现连接,Bridge
实现转发,网络命名空间实现隔离,路由表控制发送时的设备选择,iptables
实现nat
等功能。基于以上基础知识,我们采用纯手工的方式搭建了一个虚拟网络环境,如图所示。
这个虚拟网络可以访问外网资源,也可以提供端口服务供外网来调用。这就是Docker
容器网络工作的基本原理。
总结
事实上,当前大火的容器并不是新技术,而是基于Linux的一些基础组件诞生和演化出来的。
本文深度拆解了容器网络虚拟化的三大基础,veth
、网络命名空间和Bridge
。veth
模拟了现实物理网络中一对连接在一起可以相互通信的网卡。Bridge
则模拟了交换机的角色,可以把Linux
上的各种网卡设备连接在一起,让它们之间可以互相通信。网络命名空间则是将网络设备、进程、socket
等隔离开,在一台机器上虚拟出多个逻辑上的网络栈。理解了它们的工作原理之后再理解容器就容易得多了。
回到本章开篇提到的几个问题上。
- 容器中的
eth0
和母机上的eth0
是一个东西吗?
答案是不是,每个容器中的设备都是独立的。物理Linux
机上的eth0
一般来说是个真正的网卡,有网线接口。而容器中的eth0
只是一个虚拟设备 veth
设备对中的一头,它和lo
回环设备类似,是以纯软件方式工作的。设备的名字是可以随便修改的,其实想改成什么都可以。命名成eth0
这个名字是容器作者们为了让容器和物理机更像。
veth
设备是什么,它是如何工作的?
veth
设备和回环设备lo
非常像,唯一的区别就是veth
是为了虚拟化技术而生的,所以它多了个结对的概念。每一次创建veth
都会创建出来两个虚拟网络设备。这两个设备是连通着的,在veth
的一头发送数据,另一头就可以收到。它是容器和母机通信的基础。
- Linux是如何实现虚拟网络环境的?
默认情况下,其实就存在一个网络命名空间,在内核中它叫init_net
。网络命名空间的内核对象中,是包含自己的路由表、iptable
,甚至是内核参数的。创建网络命名空间的方法有多种,分别是clone
、setns
和unshare
,通过它们可以创建新的空间出来。拿clone
来举例,如果指定了CLONE_NEWNET
标记,内核就会创建一个新的网络命名空间。
每个进程内部都会有指针,通过它来表示自己的命名空间归属。veth
等虚拟网卡设备也归属在默认命名空间下,但可以通过命令将它修改到其他网络命名空间中。
通过上述的一系列操作,每个命名空间中都有了自己独立的进程、虚拟网卡设备、socket、路由表、iptables等元素,所以也就进而实现了网络的隔离。
- Linux如何保证同宿主机上多个虚拟网络环境中路由表等可以独立工作?
不管有没有新的网络命名空间,Linux
的网络包收发流程都是一样的。只不过涉及特定的网络命名空间相关的逻辑时需要先查找到表示命名空间的struct net
对象。拿路由步骤举例,内核先根据socket
找到其归属的网络命名空间,再找到命名空间里的路由表,然后再开始执行查找。
如果没有创建任何新namespace
,就执行的是默认命名空间inet_net
中的路由规则就是通过route
命令直接查看到的规则。如果是在新的namespace
中,那就是执行的这个空间下的路由配置。
- 同一宿主机上多个容器之间是如何通信的?
在物理机的网络环境中,多台不同的物理机之间通过以太网交换机连接在一起,进而实现通信。在Linux
下也是类似的,Bridge
是用软件模拟了交换机,它也有插口的概念,多个虚拟设备都是“连接”在Bridge
上的。Bridge
工作在内核网络栈的二层上,可以在不同的插口之间转发数据包。
- Linux上的容器如何和外部机器通信?
使用veth
、Bridge
、网络命名空间三个技术搭建起来的虚拟网络只能在宿主机内部进行通信,因为其私有IP
无法被外网认识。我们采用路由表控制以及NAT
功能可以使得虚拟网络通过母机的网卡和外部机器进行通信。
Kubernets
、lstio
等项目中用的网络方案看似复杂,但其实追根溯源也是对路由选择、iptables
等技术的不同应用方式罢了!
参考资料:深入理解Linux网络