第1章 夯实基础:Linux网络虚拟化
1.1 网络虚拟化基石:network namespace
在本书的开篇就介绍network namespace是因为它足够重要,毫不夸张地说,它是整个Linux网络虚拟化技术的基石。因此,我们将花较多篇幅介绍。
顾名思义,Linux的namespace(名字空间)的作用就是“隔离内核资源”。在Linux的世界里,文件系统挂载点、主机名、POSIX进程间通信消息队列、进程PID数字空间、IP地址、user ID数字空间等全局系统资源被namespace分割,装到一个个抽象的独立空间里。而隔离上述系统资源的namespace分别是Mount namespace、UTS namespace、IPC namespace、PID namespace、network namespace和user namespace。对进程来说,要想使用namespace里面的资源,首先要“进入”(具体操作方法,下文会介绍)到这个namespace,而且还无法跨namespace访问资源。Linux的namespace给里面的进程造成了两个错觉:
(1)它是系统里唯一的进程。
(2)它独享系统的所有资源。
默认情况下,Linux进程处在和宿主机相同的namespace,即初始的根namespace里,默认享有全局系统资源。
Linux内核自2.4.19版本接纳第一个namespace:Mount namespace(用于隔离文件系统挂载点)起,到3.8版本的user namespace(用于隔离用户权限),总共实现了上文提到的6种不同类型的namespace。尽管Linux的namespace隔离技术很早便存在于内核中,而且它就是为Linux的容器技术而设计的,但它一直鲜为人知。直到Docker引领的容器技术革命爆发,它才进入普罗大众的视线——Docker容器作为一项轻量级的虚拟化技术,它的隔离能力来自Linux内核的namespace技术。
说到network namespace,它在Linux内核2.6版本引入,作用是隔离Linux系统的设备,以及IP地址、端口、路由表、防火墙规则等网络资源。因此,每个网络namespace里都有自己的网络设备(如IP地址、路由表、端口范围、/proc/net目录等)。从网络的角度看,network namespace使得容器非常有用,一个直观的例子就是:由于每个容器都有自己的(虚拟)网络设备,并且容器里的进程可以放心地绑定在端口上而不必担心冲突,这就使得在一个主机上同时运行多个监听80端口的Web服务器变为可能,如图1-1所示。
图1-1 network namespace示意图
1.1.1 初识network namespace
和其他namespace一样,network namespace可以通过系统调用来创建,我们可以调用Linux的clone()(其实是UNIX系统调用fork()的延伸)API创建一个通用的namespace,然后传入CLONE_NEWNET参数表面创建一个network namespace。高阶读者可以参考下文的C代码创建一个network namespace。与其他namespace需要读者自己写C语言代码调用系统API才能创建不同,network namespace的增删改查功能已经集成到Linux的ip工具的netns子命令中,因此大大降低了初学者的体验门槛。下面先介绍几条简单的网络namespace管理的命令。
创建一个名为netns1的network namespace可以使用以下命令:
当ip命令创建了一个network namespace时,系统会在/var/run/netns路径下面生成一个挂载点。挂载点的作用一方面是方便对namespace的管理,另一方面是使namespace即使没有进程运行也能继续存在。
一个network namespace被创建出来后,可以使用ip netns exec命令进入,做一些网络查询/配置的工作。
如上所示,就是进入netns1这个network namespace查询网卡信息的命令。目前,我们没有任何配置,因此只有一块系统默认的本地回环设备lo。
想查看系统中有哪些network namespace,可以使用以下命令:
想删除network namespace,可以通过以下命令实现:
注意,上面这条命令实际上并没有删除netns1这个network namespace,它只是移除了这个network namespace对应的挂载点(下文会解释)。只要里面还有进程运行着,network namespace便会一直存在。
1.1.2 配置network namespace
当namespace里面的进程涉及网络通信时,namespace里面的(虚拟)网络设备就必不可少了。通过上文的阅读我们已经知道,一个全新的network namespace会附带创建一个本地回环地址。除此之外,没有任何其他的网络设备。而且,细心的读者应该已经发现,network namespace自带的lo设备状态还是DOWN的,因此,当尝试访问本地回环地址时,网络也是不通的。下面的小测试就说明了这一点。
在我们的例子中,如果想访问本地回环地址,首先需要进入netns1这个network namespace,把设备状态设置成UP。
然后,尝试ping 127.0.0.1,发现能够ping通。
但是,仅有一个本地回环设备是没法与外界通信的。如果我们想与外界(比如主机上的网卡)进行通信,就需要在namespace里再创建一对虚拟的以太网卡,即所谓的veth pair。顾名思义,veth pair总是成对出现且相互连接,它就像Linux的双向管道(pipe),报文从veth pair一端进去就会由另一端收到。关于veth pair更详细的介绍,参见1.2节,本节不再赘述。
下面的命令将创建一对虚拟以太网卡,然后把veth pair的一端放到netns1 network namespace。
如上所示,我们创建了veth0和veth1这么一对虚拟以太网卡。在默认情况下,它们都在主机的根network namespce中,将其中一块虚拟网卡veth1通过ip link set命令移动到netns1 network namespace。那么,veth0和veth1之间能直接通信吗?还不能,因为这两块网卡刚创建出来还都是DOWN状态,需要手动把状态设置成UP。这个步骤的操作和上文对lo网卡的操作类似,只是多了一步绑定IP地址,如下所示:
上面两条命令首先进入netns1这个network namespace,为veth1绑定IP地址10.1.1.1/24,并把网卡的状态设置成UP,而仍在主机根network namespace中的网卡veth0被我们绑定了IP地址10.1.1.2/24。这样一来,我们就可以ping通veth pair的任意一头了。例如,在主机上ping 10.1.1.1(netns1 network namespace里的网卡),如下所示:
同理,我们可以进入netns1 network namespace去ping主机上的虚拟网卡,如下所示:
另外,不同network namespace之间的路由表和防火墙规则等也是隔离的,因此我们刚刚创建的netns1 network namespace没法和主机共享路由表和防火墙,这一点通过下面的测试就能说明。
如上所示,我们进入netns1 network namespace,分别输入route和iptables-L命令,期望查询路由表和iptables规则,却发现空空如也。这意味着从netns1 network namespace发包到因特网也是徒劳的,因为网络还不通!不信读者可以自行尝试。想连接因特网,有若干解决方法。例如,可以在主机的根network namespace创建一个Linux网桥并绑定veth pair的一端到网桥上;也可以通过适当的NAT(网络地址转换)规则并辅以Linux的IP转发功能(配置net.ipv4.ip_forward=1)。关于Linux网桥和NAT,下文会有详细介绍,这里不再赘述。
需要注意的是,用户可以随意将虚拟网络设备分配到自定义的network namespace里,而连接真实硬件的物理设备则只能放在系统的根network namesapce中。并且,任何一个网络设备最多只能存在于一个network namespace中。
进程可以通过Linux系统调用clone()、unshare()和setns进入network namespace,下面会有代码示例。非root进程被分配到network namespace后只能访问和配置已经存在于该network namespace的设备。当然,root进程可以在network namespace里创建新的网络设备。除此之外,network namespace里的root进程还能把本network namespace的虚拟网络设备分配到其他network namespace——这个操作路径可以从主机的根network namespace到用户自定义network namespace,反之亦可。请看下面这条命令:
该怎么理解上面这条看似有点复杂的命令呢?分解成两部分:
(1)ip netns exec netns1进入netns1 network namespace。
(2)ip link set veth1 netns 1把netns1 network namespace下的veth1网卡挪到PID为1的进程(即init进程)所在的network namespace。
通常,init进程都在主机的根network namespace下运行,因此上面这条命令其实就是把veth1从netns1 network namespace移动到系统根network namespace。有两种途径索引network namespace:名字(例如netns1)或者属于该namespace的进程PID,上文中用的就是后者。
对namespace的root用户而言,他们都可以把其namespace里的虚拟网络设备移动到其他network namespace,甚至包括主机根network namespace!这就带来了潜在的安全风险。如果用户希望屏蔽这一行为,则需要结合PID namespace和Mount namespace的隔离特性做到network namespace之间的完全不可达,感兴趣的读者可以自行查阅相关资料。
1.1.3 network namespace API的使用
前文已经提到network namespace作为Linux六大namespace之一,其API涉及三个Linux系统调用:clone、unshare和setns,以及一些系统/proc目录下的文件。本节我们将通过几个C语言程序的例子介绍network namespace API的使用方法。clone()、unshare()和setns()系统调用会使用CLONE_NEW*常量来区别要操作的namespace类型。CLONE_NEW*常量一共有6个:CLONE_NEWIPC、CLONE_NEWNS、CLONE_NEWNET、CLONE_NEWPID、CLONE_NEW USER和CLONE_NEWUTS,分别代表6个不同的namespace类型。细心的读者通过名字应该能观察出来,CLONE_NEWNS指代的是network namespace。
1. 创建namespace的黑科技:clone系统调用
用户可以使用clone()系统调用创建一个namespace。但当知道clone()系统调用是用来创建一个新的进程时,请不要感到惊讶,让我们看下面的例子。
clone()的调用方式如上所示,它其实就是我们熟悉的UNIX/Linux系统调用fork()的延伸,我们可以通过其flags参数(标志位)控制特定的功能。clone()总共有二十多种CLONE_*标志位用来控制clone(克隆)进程时的行为。例如,是否与父进程共享虚拟内存、打开的文件描述符、信号处理等。
只要在clone()设置了其中一个标志位CLONE_NEW,系统就会创建一个新的对应类型的namespace及一个新的进程,并且会把这个进程放到这个新创建的namespace中。通过|(位或)操作,我们可以实现clone()同时指定多个CLONE_NEW标志位。看到这里,读者是否已经恍然大悟了?原来clone()创建namespace是这个道理啊。
再来解释clone()的几个参数的含义,从左到右分别是:
·函数指针child_func,指定一个由新进程执行的函数。当这个函数返回时,子进程终止。该函数返回一个整数,表示子进程的退出代码;
·指针child_stack传入子进程使用的栈空间,也就是把用户态堆栈指针赋给子进程的esp寄存器。调用clone()的进程应该总是为子进程分配新的堆栈;
·int类型的flags参数表示CLONE_*标志位(可以多个);
·args表示用户的自定义参数。
最后提一下权限/安全问题,大部分Linux namespace(user namespace除外)的创建都需要系统特权(capability),不一定是完整的root权限,但需要拥有CAP_SYS_ADMIN权限集来执行必要的系统调用。
注:Linux的特权是将root的权限划分为各个小部分,使得一个进程只需要被授予刚刚好的权限来执行特定的任务。如果这些特权足够小且选择得恰到好处,那么即使一个特权进程受损(比如缓冲区溢出),它所造成的危害也会受限于它所拥有的特权。例如,CAP_KILL允许进程向任意的进程发送信号,而CAP_SYS_TIME允许进程设置系统的时钟。
2. 维持namespace存在:/proc/PID/ns目录的奥秘
每个Linux进程都拥有一个属于自己的/proc/PID/ns,这个目录下的每个文件都代表一个类型的namespace。
在Linux内核3.8版本以前,/proc/PID/ns目录下的文件都是硬链接(hard link),而且只有ipc、net和uts这三个文件。从Linux内核3.8版本开始,每个文件都是一个特殊的符号链接文件,如上文提到的那样,这些文件提供了操作进程关联namespace的一种方式。先来看一眼这些符号链接文件都长什么样。
我们看到的符号链接的其中一个用途是确定某两个进程是否属于同一个namespace。如果两个进程在同一个namespace中,那么这两个进程/proc/PID/ns目录下对应符号链接文件的inode数字(即上文例子中[]内的数字,例如4026531839,也可以通过stat()系统调用获取返回结构体的st_ino字段)会是一样的。
除此之外,/proc/PID/ns目录下的文件还有一个作用——当我们打开这些文件时,只要文件描述符保持open状态,对应的namespace就会一直存在,哪怕这个namespace里的所有进程都终止运行了。听起来有点拗口,有什么意义呢?之前版本的Linux内核,要想保持namespace存在,需要在namespace里放一个进程(当然,不一定是运行中的),这种做法在一些场景下有些笨重(虽然Kubernetes就是这么做的)。因此,Linux内核提供的黑科技允许:只要打开文件描述符,不需要进程存在也能保持namespace存在!怎么操作?请看下面的命令:
如上所示,把/proc/PID/ns目录下的文件挂载起来就能起到打开文件描述符的作用,而且这个network namespace会一直存在,直到/proc/self/ns/net被卸载。
3. 往namespace里添加进程:setns系统调用
我们已经使用一些黑科技使得namespace即使没有进程在其中也能保持开放。接下来,我们就要往这个namespace里“扔”进程,Linux系统调用setns()就是用来做这个工作的,其主要功能就是把一个进程加入一个已经存在的namespace中。setns()的定义如下所示:
其中:
·参数fd表示进程待加入的namespace对应的文件描述符。我们已经知道,它是一个指向/proc/PID/ns目录下符号链接的文件描述符;
·int类型参数nstype的作用是让调用者检查第一个参数fd指向的namespace类型是否符合我们的实际要求,0表示不检查。
还记得前文我们用的ip netns exec子命令吗?我们用这条子命令轻松进入一个network namespace,然后执行一些操作。我们通过setns()和execve()系列函数就能组合出一个更加通用的小工具:进入一个指定namespace,然后在里面执行一条shell命令。
注:execve()系列函数可以用来执行用户自定义的命令,一个常用的方法是调用/bin/sh运行一个shell。
一个简单的C语言样例如下所示:
让我们编译上面的代码段,假设编译后的二进制文件是enterns,那么执行以下命令:
/my/net是上文我们新建的network namespace符号链接的挂载文件。以上命令执行完后,我们就进入一个新的network namespace,并且可以在里面执行shell命令。这种方式广泛应用于Docker和Kubernetes中。
注:在Linux内核3.8版本之前,setns()还不能用于加入Mount namespace、PID namespace和user namespace,但从3.8版本以后,支持所有类型的namespace。
4. 帮助进程逃离namespace:unshare系统调用
与namespace相关的最后一个系统调用是unshare(),用于帮助进程“逃离”namespace。unshare()系统调用的函数声明如下:
细心的读者想必已经发现,unshare()也有一个int类型的flags参数,这个flags的用法和clone()的flags是否一样呢?答案是肯定的。
unshare()系统调用的工作机制是:先通过指定的flags(即CLONE_NEW*bit位的组合)创建相应的namespace,再把这个进程挪到这些新创建的namespace中,于是也就完成了进程从原先namespace的撤离。unshare()提供的功能其实很像clone(),区别在于unshare()作用在一个已存在的进程上,而clone()会创建一个新的进程。
那么,unshare()系统调用具体有什么应用场景呢?大部分Linux发行版自带的unshare命令就是基于unshare()系统调用的,它的作用就是在当前shell所在的namespace外执行一条命令。unshare命令的用法如下所示:
Linux会为需要执行的命令(在上面的例子中,即program)启动一个新进程,然后在另外一个namespace中执行操作,这样就可以起到执行结果和原(父)进程隔离的效果。
5. 一个完整的例子
一个使用Linux network namespace相关系统调用的程序样例如下。由于代码比较简单,而且几个关键点都做了注释,笔者就不逐行解释了。
以上程序的大意是,在一个新的network namespace中初始化一个网络设备veth1,绑定IP地址169.254.1.1。当我们使用如下命令编译并运行以上程序时,会得到如下输出:
意思是父、子进程分别在两个不同的namespace中,其中父进程的PID是1831,子进程的PID是1(新建namespace中的第一个进程)。请注意上文输出的最后一行光标的停留位置,root@In Namespace~意味着我们已经进入新创建的namespace中。
进入这个新的namespace中,使用nc-l 1234命令监听0.0.0.0:1234。然后打开另一个终端,输入以下命令:
意思是向新建network namespace的169.254.1.2:1234发送一个网络报文Hi。切回原先的终端,会发现顺利地收到了一个Hi字符串。
1.1.4 小结
我们知道通过Linux的network namespace技术可以自定义一个独立的网络栈,简单到只有loopback设备,复杂到具备系统完整的网络能力,这就使得network namespace成为Linux网络虚拟化技术的基石——不论是虚拟机还是容器时代。network namespace的另一个隔离功能在于,系统管理员一旦禁用namespace中的网络设备,即使里面的进程拿到了一些系统特权,也无法和外界通信。最后,网络对安全较为敏感,即使network namespace能够提供网络资源隔离的机制,用户还是会结合其他类型的namespace一起使用,以提供更好的安全隔离能力。
关于namespace的介绍就告一段落,如果想探究更多网络虚拟化的奥秘,就要理解更多Linux内核提供的veth、VLAN、VXLAN、Macvlan等知识,我们在后面的章节会一一介绍。