Docker Bridge网络实现大概分为两步,第一步是在宿主机上创建网桥,然后创建一个Veth Pair,一端连接到Bridge上;第二步是创建一个新的Network Namespace中,然后把Veth Pair另一端移动到新的NetNS中。

runC中有一个parent process,在init的时候会创建一个init process。
(如果是我自己来在runC里)实现容器网络的话,是先启动init process(并且带上CLONE_NEWNET,CLONE_NEWPID…,这样新的process就会在一个新的NS里),然后拿到child pid,之后进入已有的child net ns,做上面的第二步。

但是Docker不是这样做的,实际上runC中没有任何网络操作。
Docker调用runC的run时,是一个阻塞操作,它没有办法在启动init process后去加入自己的逻辑代码。

实际上是这样实现容器网络的:Docker是在调用runC的run之前,先创建好一个新的Net NS(同样是CLONE_NEWNET),然后把/proc/$pid/ns/net 给mount到一个之前建的目录(对应CNM中的Sandbox)/var/run/docker/netns/$sandbox_id(似乎这样做可以在新的进程死掉后,net ns仍然保留)。然后在调用runC的run时,传入一个runC的config.json,跟网络有关系的就是下面这些:

每一个进程有一个/proc/PID/ns目录,该目录下每一个命名空间对应一个文件。从3.8版本起,每一个这类文件都是一个特殊的符号链接。该符号链接提供在其命名空间上执行某个操作的某种方法。

$ ls -l/proc/

is replaced byshell’s PID total 0 lrwxrwxrwx. 1 mtk mtk 0Jan 8 04:12 ipc -> ipc:[4026531839] lrwxrwxrwx. 1 mtk mtk 0Jan 8 04:12 mnt -> mnt:[4026531840] lrwxrwxrwx. 1 mtk mtk 0Jan 8 04:12 net -> net:[4026531956] lrwxrwxrwx. 1 mtk mtk 0Jan 8 04:12 pid -> pid:[4026531836] lrwxrwxrwx. 1 mtk mtk 0Jan 8 04:12 user -> user:[4026531837] lrwxrwxrwx. 1 mtk mtk 0Jan 8 04:12 uts -> uts:[4026531838] 这些符号链接可以用来判断两个命名空间是否在同一个命名空间。如果两个进程在同一个命名空间,内核会保证由/proc/PID/ns导出的inode号将会是一样的。inode号可以通过stat()系统调用获得。 然而,内核同样为每个 /proc/PID/ns构建了符号链接,以使其指向包含标识命名空间类型字符串(字符串以inode号结尾)的名字。我们可以通过ls -l或者readlink命令查看名字。 让我们回到上面 demo_uts_namespaces运行的shell会话,通过查看父子进程的/proc/PID/ns符号链接信息可以知道它们是否位于同一个命名空间。 ^Z # Stop parent and child [1]+ Stopped ./demo_uts_namespaces bizarro

  1. # jobs-l # Show PID of parent process
  2. [1]+ 27513Stopped ./demo_uts_namespacesbizarro
  3. # readlink/proc/27513/ns/uts # Show parent UTS namespace
  4. uts:[4026531838]
  5. # readlink/proc/27514/ns/uts # Show child UTS namespace
  6. uts:[4026532338]

正如我们看到的, /proc/PID/ns/uts 符号链接并不一样,表明它们位于不同的命名空间中。 /proc/PID/ns同样服务于其它目的,如果我们随便打开一个文件,那么只要文件描述打开状态,那么命名空间将会保持存在而不论命名空间中的进程是否全部退出,相同的效果可以通过绑定其中一个符号链接到文件系统的其它地方获得。

touch~/uts # Create mount point

  1. # mount --bind/proc/27514/ns/uts ~/uts

在3.8之前, /proc/PID/ns 下的文件是硬链接,并且只有ipc、net和uts文件是存在的。

  1. "linux": {
  2. "namespaces": [
  3. {
  4. "type": "pid"
  5. },
  6. {
  7. "type": "uts"
  8. },
  9. {
  10. "type": "ipc"
  11. },
  12. {
  13. "type": "network",
  14. "path": "/var/run/docker/netns/$sanbox_id"
  15. },
  16. {
  17. "type": "mount"
  18. }
  19. ]
  20. },

然后在runC,我认为会有一种操作就是已经有path的NS,就加入;没有path的NS,就新建。
但是实际上我读runC代码时,发现它nsenter模块(init process启动的地方)没有新建NS的操作,只有join已有NS的操作。
在parent process 执行bootstrapData的时候,甚至只考虑了传了Path的NS。

nsenter实际是比较复杂,它有三个进程init-1,init-2,init-3。
init-1创建init-2(child),init-2中会加入所有的NS(setns),然后创建init-3,最终只有init-3会在执行完C代码后进入Go runtime。

  1. //libcontainer/container_linux.go
  2. nsMaps := make(map[configs.NamespaceType]string)
  3. for _, ns := range c.config.Namespaces {
  4. if ns.Path != "" {
  5. nsMaps[ns.Type] = ns.Path
  6. }
  7. }
  8. _, sharePidns := nsMaps[configs.NEWPID]
  9. data, err := c.bootstrapData(c.config.Namespaces.CloneFlags(), nsMaps)

我的疑惑是:config.json中没有带Path的NS,也应该是需要新建的,但是为什么runC中没有任何新建NS的代码,init-1->2->3之间是用clone系统调用新建的,但是它带的CLONEFLAGS中却没有CLONE_NEWNET之类的,而是CLONE_PARENT(_CLONE_PARENT 创建的子进程的父进程是调用者的父进程,新进程与创建它的进程成了“兄弟”而不是“父子”)。
它实现了init(新建或使用已有NS)和exec(全部使用已有NS)两套逻辑用同一套代码实现。
按照我的粗浅想法,应该是init和exec分开的,init的话如果传了Path,那么就setns,如果没传,就在clone init-2的时候带需要新建的NS的CLONE_FLAGS;exec的话就全部setns。

  1. libcontainer/nsenter/nsexec.c
  2. static int clone_parent(jmp_buf *env, int jmpval)
  3. {
  4. struct clone_t ca = {
  5. .env = env,
  6. .jmpval = jmpval,
  7. };
  8. return clone(child_func, ca.stack_ptr, CLONE_PARENT | SIGCHLD, &ca);
  9. }