深入理解Linux内核网络(九):容器网络虚拟化
本文讲解了容器网络虚拟化的三大基础,veth、网络命名空间和Bridge。veth模拟了现实物理网络中一对连接在一起可以相互通信的网卡。Bridge则模拟了交换机的角色,可以把Linux上的各种网卡设备连接在一起,让它们之间可以互相通信。网络命名空间则是将网络设备、进程、socket等隔离开,在一台机器上虚拟出多个逻辑上的网络栈。
部分内容来源于 《深入理解Linux网络》、《Linux内核源码分析TCP实现》
veth设备对
基础
在Docker等容器技术中,veth(Virtual Ethernet)是实现网络虚拟化的最基础技术之一。可以简单理解为,veth设备是一对成对出现的虚拟网络接口,类似于物理网络中的网线,将两个“虚拟网卡”互相连接在一起。
使用ip link命令可以创建一对veth设备。命令如下:
ip link add veth0 type veth peer name veth1
veth0和veth1是这对veth设备的名称,它们彼此互为对端。通过创建veth对,我们可以模拟两个设备之间的网络连接。
创建完成后,可以使用ip link show命令来查看所有的网络接口,包括物理接口和虚拟接口。输出中可以看到新创建的veth0和veth1,它们默认是处于DOWN状态的,需要进一步配置。
就像物理网卡一样,veth设备也需要配置IP地址才能进行通信。我们可以通过以下命令分别为veth0和veth1配置IP:
ip addr add 192.168.1.1/24 dev veth0
ip addr add 192.168.1.2/24 dev veth1
配置完IP地址后,需要将veth设备启动:
ip link set veth0 up
ip link set veth1 up
这样veth0和veth1的状态就会从DOWN变为UP,表示它们已经处于可以通信的状态。
为了使veth设备之间能够顺利通信,我们需要关闭反向路径过滤(rp_filter)并设置允许接收本机数据包。这是因为Linux内核默认会过滤掉不符合安全规则的网络包,为了让本地流量在这些虚拟接口之间通过,我们需要修改一些系统配置:
echo 0 > /proc/sys/net/ipv4/conf/all/rp_filter
echo 1 > /proc/sys/net/ipv4/conf/veth1/accept_local
echo 1 > /proc/sys/net/ipv4/conf/veth0/accept_local
以上命令分别禁用了所有接口的反向路径过滤,并为veth0和veth1打开了接受本地数据包的功能。
完成所有配置后,我们可以通过ping命令来测试veth0与veth1之间的通信:
ping 192.168.1.2 -I veth0
从veth0发出ping请求到veth1,如果配置正确,可以看到正常的ping响应,表明veth对之间已经能够顺畅通信。
底层创建
在底层实现中,veth设备的初始化是通过函数veth_init来进行的,代码如下:
static __init int veth_init(void)
{
return rtnl_link_register(&veth_link_ops);
}
这个函数的作用是将veth_link_ops
结构体注册到内核中。rtnl_link_register
是一个内核API,用于注册虚拟链路类型的设备操作,veth_link_ops
包含了veth设备的所有操作方法。
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, //用于创建新veth设备,具体通过veth_newlink函数实现。
.dellink = veth_dellink,
.policy = veth_policy,
.maxtype = VETH_INFO_MAX,
};
veth_newlink
是创建veth设备的关键函数,其代码如下:
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);
}
rtnl_create_link
用于创建一个peer设备。然后调用register_netdevice(peer)
和register_netdevice(dev)
分别将peer和dev设备注册到网络子系统中。
netdev_priv(dev)
用于获取设备的私有数据(即veth_priv
),它包含了指向对端设备的指针。通过rcu_assign_pointer(priv->peer, peer)
把peer设备与dev设备关联起来,反之亦然。
在创建veth设备时,使用了一个私有数据结构来管理veth设备之间的关联,这个数据结构定义如下:
struct veth_priv {
struct net_device __rcu *peer; //指向对端设备的指针
atomic64_t dropped; //记录丢包的计数
};
veth设备的初始化,veth_setup
,并通过veth_netdev_ops
结构定义了设备的基本操作,比如初始化、打开、发送数据包等。
static void veth_setup(struct net_device *dev)
{
dev->netdev_ops = &veth_netdev_ops;
dev->ethtool_ops = &veth_ethtool_ops;
}
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,
};
通信过程
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_xmit
。veth_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设备的这种通信机制模拟了物理网络设备的工作方式,使得两个容器或网络命名空间之间可以像通过物理网线连接一样进行通信。
网络命名空间
有了veth可以创建出许多的虚拟设备,默认它们都是在宿主机网络中的。接下来虚拟化中还有很重要的一步,那就是隔离。用Docker来举例,那就是不能让A容器用到B容器的设备。只有这样才能保证不同的容器之间复用硬件资源的同时,还不会影响其他容器的正常运行。
在Linux上实现隔离的技术手段就是命名空间(namespace)。通过命名空间可以隔离容器的进程PID、文件系统挂载点、主机名等多种资源。
此处要重点介绍的是网络命名空间(netnamespace,简称netns)。它可以为不同的命名空间从逻辑上提供独立的网络协议栈,具体包括网络设备、路由表、arp表、iptables以及套接字(socket)等。
使用方法
首先,使用以下命令创建一个新的网络命名空间net1:
ip netns add net1
在新创建的网络命名空间中,默认只存在一个本地回环接口(lo),并且状态是未启动的(DOWN),这可以通过以下命令来查看:
ip netns exec net1 ip link list
输出结果显示在net1命名空间中只有一个lo接口,状态为DOWN。
为了实现网络命名空间net1和宿主机之间的通信,需要创建一对veth设备,其中一端放入命名空间中,另一端保留在宿主机上。可以通过以下命令来实现:
ip link add veth1 type veth peer name veth1_p
ip link set veth1 netns net1
宿主机中只能看到veth1_p,但看不到veth1。在命名空间net1中可以看到veth1,而无法看到veth1_p。为了使宿主机和网络命名空间中的网络设备能够通信,需要分别给两个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
然后,需要将这两个设备分别设置为UP状态:
ip link set dev veth1_p up
ip netns exec net1 ip link set dev veth1 up
在命名空间中查看路由表、iptables等配置时,都是独立的,不会与宿主机或其他命名空间产生冲突。例如:
ip netns exec net1 route
ip netns exec net1 iptables -L
在新创建的命名空间中,路由表和iptables规则都是空的,表明每个命名空间都有自己独立的网络配置。宿主机只能看到veth1_p设备,而命名空间net1中只能看到veth1设备,这些设备在各自的网络命名空间中独立存在。
命名空间相关定义
在Linux内核中,命名空间(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);
...
}
}
通过调用 register_pernet_subsys()
函数,网络相关的子系统会被注册到 pernet_list 链表中。当网络命名空间被创建时,这些子系统会被逐一初始化。
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);
}
当设备(如 veth 对)被创建时,默认属于初始网络命名空间 init_net
,可以通过 dev_net_set()
函数将其移动到一个新的命名空间中。
void dev_net_set(struct net_device *dev, struct net *net) {
release_net(dev->nd_net);
dev->nd_net = hold_net(net);
}
每个 Socket 也属于某个特定的网络命名空间。这一归属关系是在创建 Socket 时通过进程的命名空间来决定的。
struct sock_common {
struct net *skc_net; // Socket 所属的网络命名空间
};
int sock_create(int family, int type, int protocol, struct socket **res) {
return __sock_create(current->nsproxy->net_ns, family, type, protocol, res);
}
网络收发如何利用命名空间
当一个网络包被发送时,内核需要找到其目的地址的合适路由。这种路由查找操作需要依赖当前网络命名空间,因为不同的网络命名空间有独立的路由表配置。图中展示了如何通过 sock_common
的 skc_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
,这使得不同的网络命名空间可以有独立的路由表配置。
虚拟交换机Bridge
当我们在一台物理机上部署多个容器时(例如十几个甚至几十个容器),仅通过 veth 对来直接连接容器之间就变得不实际了。这是因为每个容器都需要与其他容器互通,这样带来的网络连接量会非常复杂,而且效率低下。因此,需要一个更为灵活的方法来连接多个容器,这就是虚拟交换机(Bridge)。
创建 Bridge
veth1_p 和 veth2_p 作为这两个命名空间的外部接口,它们都被连接到一个名为 br0 的 Bridge 上。
brctl addbr br0
将 veth 接口连接到 Bridge,这里将两个 veth 接口(veth1_p 和 veth2_p)连接到 br0,使它们成为 br0 的端口。
ip link set dev veth1_p master br0
ip link set dev veth2_p master br0
给 br0 分配一个 IP 地址,这样整个虚拟交换机有了它的管理 IP。
ip addr add 192.168.0.100/24 dev br0
启动 veth 接口和 Bridge:
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
创建过程
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
...
}
调用传入的 setup()
函数来初始化设备。在这里,setup 实际指向的是 br_dev_setup
,它会进一步完成 Bridge 的初始化。
添加设备
当在用户空间中运行 brctl addif br0 veth0
命令时,实际上执行的过程就是将 veth0 设备添加到 br0 这个 Bridge 上。对应的内核代码实现由br_add_if
函数来完成。
// 文件: 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);
...
}
端口对象的创建:new_nbp 函数
// 文件: 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;
...
}
注册设备帧处理函数
// 文件: 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);
...
}
当该设备接收到数据包时,系统会调用这个处理函数对帧进行处理,这样 Bridge 就可以接收和转发来自各个端口的数据包。
数据包处理
当 veth 设备收到数据包时,会调用 __netif_receive_skb_core
这个函数进行处理:
// file: net/core/dev.c
static int __netif_receive_skb_core(struct sk_buff *skb, bool pfmemalloc)
{
// ...
// 捕获内核注册的接受事件的 hook
list_for_each_entry_rcu(...);
// 执行设备的 rx_handler(也就是 br_handle_frame)
rx_handler = rcu_dereference(skb->dev->rx_handler);
if (rx_handler) {
if (rx_handler(&skb)) {
switch (rx_handler(&skb)) {
case RX_HANDLER_CONSUMED:
ret = NET_RX_SUCCESS;
goto unlock;
// 其他处理分支
}
}
}
// 继续发送协议栈
// ...
return ret;
}
首先会通过 rx_handler
获取到当前设备的接收处理函数(这个 rx_handler
是在前面将 veth 设备连接到 Bridge 时设置为 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
中,数据包在做了一些前期的处理后,会调用 NF_HOOK,进入 br_handle_frame_finish
来完成数据包的转发。NF_HOOK 是内核中用于网络包处理的钩子机制,便于网络数据包在特定阶段进行用户自定义处理。
在 br_handle_frame_finish
中,数据包的转发路径和目标端口被确定:
// file: net/bridge/br_input.c
int br_handle_frame_finish(struct sk_buff *skb)
{
// 获取 veth1_p 设备所连接的 bridge 设备
struct net_bridge_port *p = br_port_get_rcu(skb->dev);
struct net_bridge *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);
}
数据包的实际转发 ,br_forward:
// file: net/bridge/br_forward.c
static void __br_forward(const struct net_bridge_port *to, struct sk_buff *skb)
{
// 将 skb 中的设备 dev 修改为目标设备
skb->dev = to->dev;
// 调用钩子进行转发
NF_HOOK(NFPROTO_BRIDGE, NF_BR_FORWARD, skb, indev, skb->dev,
br_forward_finish);
}
__br_forward
的主要作用是将数据包的目标设备从veth1_p
改为 veth2_p
,并通过调用 NF_HOOK 来进入 br_forward_finish
进行进一步的转发操作。
// 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);
}
static int br_dev_queue_push_xmit(struct sk_buff *skb)
{
dev_queue_xmit(skb);
}
dev_queue_xmit
是 Linux 网络子系统中用于执行实际数据包发送的函数。其会调用设备自身的发送函数,对于 veth 设备来说,这个函数就是 veth_xmit
:
// file: drivers/net/veth.c
static netdev_tx_t veth_xmit(struct sk_buff *skb, struct net_device *dev)
{
// 获取 veth 设备的对端
struct veth_priv *priv = netdev_priv(dev);
struct net_device *rcv = rcu_dereference(priv->peer);
// 调用 dev_forward_skb() 传递数据包
if (likely(dev_forward_skb(rcv, skb) == NET_RX_SUCCESS))
return NETDEV_TX_OK;
// 错误处理
}
经过上面步骤的处理后,Bridge把数据包发送到对应的目标veth设备(例如veth2),接下来veth2会进入它的接收数据包流程,开始新的数据包处理过程。
总览
Docker1 在 veth1 上发送数据
Docker1 容器通过其网络接口 veth1 发送数据包。在 Docker 容器内,veth1 看起来像是一个普通的网络接口,通过这个接口,数据包进入 Linux 内核进行传输。
veth1_p 接收到数据包
veth1_p 是与 veth1 配对的对端。它是一个虚拟设备,负责将 Docker1 发送的数据传递给下一个网络设备。veth1_p 收到数据包后,数据包从 Docker1 的用户空间进入 Linux 内核空间。
网桥(Bridge)将 veth1_p 上的数据包转发到 veth2_p 上
在数据包被 veth1_p 接收到之后,数据包被传递给与之连接的虚拟交换机(Bridge)。Bridge 会检查数据包的目标,找到与目标对应的出口接口。在这个例子中,Bridge 查找到 veth2_p 是目标接口,于是将数据包从 veth1_p 转发到 veth2_p。
Docker2 从 veth2 接收数据
最后,veth2 将数据包传递到 Docker2 容器中,这样 Docker2 就可以在用户空间接收到 Docker1 发送的数据包。
外部网络通信
解决容器外部通信需求需要使用到路由和NAT技术。
路由和NAT
在Linux中,当发送或者转发数据包时,涉及的一个重要过程就是路由选择。这包含本机发送(例如虚拟网络接口的数据发送)和转发到外部网络的情况。
路由表的配置主要包括两个表:
- local路由表:记录本地设备的路由规则,例如网卡设备的自身IP地址。使用
ip route list table local
可以查看本地的规则,包括127.0.0.1(loopback)等内容。 - main路由表:包含默认网关以及其他与外部通信相关的路由规则。通过
route -n
可以查看该路由表,其中包括不同网段的路由信息。
Linux会根据数据包的目标地址,通过路由表来选择适合的网卡发送出去。需要注意的是,如果发现目的地址并不是本地网络的范围,Linux默认是不进行转发的,需要通过配置来启用转发功能。
Linux内核网络栈在运行上基本属于纯内核态的东西,但为了迎合各种各样用户层不同的需求,内核开放了一些口子出来供用户层来干预。其中iptables就是一个非常常用的干预内核行为的工具,它在内核里埋下了五个钩子入口,这就是俗称的五链。
- PREROUTING:在数据包进入时处理。
- INPUT:在数据包到达本机时处理。
- FORWARD:数据包要转发到其他接口时处理。
- OUTPUT:在数据包从本机发出前处理。
- POSTROUTING:在数据包离开时处理。
数据包在不同阶段会触发不同的iptables链:
-
接收数据时:数据包进入Linux系统时,首先经过PREROUTING链进行初步的处理和地址转换,然后再判断是否是本机的,如果是,则进入INPUT链进行处理。
-
发送数据时:当主机自身产生数据包需要发送出去时,会经过OUTPUT链进行处理,之后再经过POSTROUTING链来完成最终的NAT和转发设置。
-
数据转发时:当数据包到达后发现目标地址不是本机时,系统会根据路由表选择合适的接口进行转发,这时会先经过FORWARD链,然后再进入POSTROUTING链处理,最终完成转发。
实现外部通信
# 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
# 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
前置准备做好,测试ping外部的ip
ip netns exec net1 ping 10.153.*.*
结果显示网络不可达。原因是 net1 只包含 192.168.0.0/24 的路由,而并没有可以访问 10.153.* .* 的出口路由。
添加默认的出口路由
ip netns exec net1 route add default gw 192.168.0.1 veth1
之后再次尝试ping时仍然不通,原因在于默认情况下Linux系统不允许包转发。打开包转发和配置iptables:
sysctl net.ipv4.conf.all.forwarding=1
iptables -P FORWARD ACCEPT
但是这时候外网还是无法识别来自 192.168.0.* 段的 IP,必须进行NAT(网络地址转换)。
iptables -t nat -A POSTROUTING -s 192.168.0.0/24 ! -o br0 -j MASQUERADE
在iptables中设置NAT转换,将来源地址是 192.168.0.0/24 的流量转换成宿主机 eth0 的外部地址,这样外部网络就能正确识别并响应这些请求。
开放容器端口
容器中的服务通常只对内网可见,但有时候需要将某些服务开放给外部网络访问。在当前的例子中,虚拟网络的IP是 192.168.0.2,外界并不能识别这个地址。所以需要使用DNAT(Destination Network Address Translation)将请求目标IP从外部的地址映射为内部网络的IP,以此来实现网络请求的重定向。
使用 iptables 的 NAT 规则来配置 DNAT,这样外部对宿主机某个特定端口的请求会被转发到虚拟网络内部的对应服务。例如:
iptables -t nat -A PREROUTING -i br0 -p tcp -m tcp --dport 8088 -j DNAT --to-destination 192.168.0.2:80
当外部访问宿主机的8088端口时,该请求将会被转发到虚拟网络中的 192.168.0.2:80 端口。
相关问题
容器中的eth0和母机上的eth0是同一个东西吗?
每个容器中的网络设备是独立的。在宿主机(物理Linux机器)中的eth0通常是一个实际的物理网卡,而容器中的eth0是一个虚拟的设备,由内核虚拟化技术通过veth对的形式创建。这些虚拟网卡的作用是使得容器和宿主机之间有一个通信渠道,同时它们之间可以实现隔离。在网络命名空间中每个网络设备的名称是可以自定义的,且彼此互不干扰。
veth设备是什么?它是如何工作的?
veth是一种虚拟以太网设备,它常常成对出现,类似于回环设备,但是veth对中的两个设备之间是相互连接的。比如在宿主机和容器之间,宿主机的一个端点(veth1)被放置到容器的网络命名空间中,作为容器的eth0接口。它的工作机制是:一个设备端发送数据,另一端就可以接收,形成桥接。它是实现容器与宿主机通信的重要媒介。
Linux是如何实现虚拟网络环境的?
Linux通过网络命名空间(network namespace)来实现虚拟网络环境。每个网络命名空间都有自己独立的路由表、iptables、防火墙规则、设备接口等,从而实现网络隔离。网络命名空间可以通过clone系统调用(指定CLONE_NEWNET标志位)来创建,也可以使用setns等命令加入现有的命名空间。
命名空间的本质就是使得每个进程有自己的网络环境,网络设备、路由表等在不同的命名空间中互不影响。通过这种方式,Linux可以实现在同一个物理主机上运行多个独立的虚拟网络。
Linux如何保证同宿主机上多个虚拟网络环境中的路由表等可以独立工作?
每个网络命名空间都有自己独立的struct net对象,它包含了路由表等网络配置信息。在数据包的处理流程中,Linux内核会根据socket找到它所属的网络命名空间,进而在对应命名空间的路由表中找到合适的规则来处理数据包。因此,路由表和其他网络配置在不同的命名空间中都是独立的,从而保证了它们的独立工作。
同一宿主机上多个容器之间是如何通信的?
同一个宿主机上的多个容器可以通过桥接(Bridge)设备来互相通信。Linux的Bridge类似于物理交换机,它将虚拟网络接口(例如veth)连接在一起,使得这些接口之间可以相互转发数据包。在内核网络栈中,Bridge工作在二层(数据链路层),实现对不同接口之间的广播和数据转发。
Linux上的容器如何和外部机器通信?
容器和外部通信通常通过以下三种技术来实现:
- veth设备:作为容器内网和宿主机外网之间的接口。
- Bridge设备:将多个veth设备连接起来,实现容器和宿主机的二层通信。
- 路由和NAT:容器私有的IP地址是外界无法直接识别的,为了实现外部访问,需要设置NAT规则,将容器内的私有地址映射为宿主机可识别的地址,通常使用iptables进行配置。这样一来,容器就可以通过宿主机的IP地址与外界通信。