写点什么

500 行代码手写 docker- 以新命名空间运行程序

  • 2023-05-25
    广东
  • 本文字数:6582 字

    阅读完需:约 22 分钟

500行代码手写docker-以新命名空间运行程序

(2)500 行代码手写 docker-以新命名空间运行程序

本系列教程主要是为了弄清楚容器化的原理,纸上得来终觉浅,绝知此事要躬行,理论始终不及动手实践来的深刻,所以这个系列会用 go 语言实现一个类似 docker 的容器化功能,最终能够容器化的运行一个进程。


本章的源码已经上传到 github,地址如下:


https://github.com/HobbyBear/tinydocker/tree/chapter2
复制代码


本章要完成的任务则是 golang 启动一个 sh 的进程,并且 sh 的进程将在新的命名空间中运行。


届时运行效果如下:



在正式开始编写代码前,先来看看 linux namespace 涉及的一些原理。

linux namespace 原理

命名空间是 linux 为了隔离各种资源而形成的一个概念,不同类型的命名空间能够对不同类型的资源进行隔离。


目前 linux 支持的命名空间有:



对主机名的隔离 和 ipc 进程消息通信的隔离 比较好理解,不同 uts 命名空间和 ipc 命名空间,其主机名和各自在 ipc 命名空间内部创建的 ipc 组件对彼此都不可见。


着重来看下剩下的 3 种类型的命名空间。

syscall.CLONE_NEWPID

内核在为新进程分配 pid 时对不同 pid 的 namespace 是进行了隔离的,同一个 pid 的 namespace 下的 pid 是不会重复的,但不同 pid 的 namespace 下的 pid 可以重复。


当执行 mount 命名挂载 procfs 时,也是先从当前进程 获取到 与进程相同的 pid namespace,然后进行挂载的。所以后续你会看到当一个处于新 pid namespace 里的程序,执行 mount 重新挂载 proc 文件系统后,proc 文件系统中进程号为 1 的进程就变了。

syscall.CLONE_NEWNS

再来看看 mnt namespace, 当执行完一个 mount 命名后,再访问挂载的目标目录,你会发现目标目录的内容已经变成了你指定的挂载目录,mnt namespace 就是为了让你的挂载目录对于其他的 mnt namespace 变成不可见的,其他 mnt namespace 该目录下依然是原先的目录结构。


📢📢❗️不过注意下,当用 systemd 作为 init 进程启动时,mount 默认的挂载方式是共享模式,这意味着你在一个 mnt namespace 下执行 mount 命令后的挂载对其他 mnt 的 namespace 是可见的。

比如我在新 mnt namespace 下挂载 procfs,这将会导致主机上的 procfs 失效,然后你访问主机的/proc 目录将会发现主机/proc 目录下的内容和新 mnt namespace /proc 目录下的内容是一样的。所以当你回到主机的 mnt namespace 去执行 top 命令时,将会提示你需要将 procfs 重新进程挂载

解决这个问题的办法则是将新 mnt namespace 设置为私有模式,后续我会在代码里体现这一部分。

syscall.CLONE_NEWNET

最后我们来看看 network namespace 起的作用,网络传输过程涉及到网络设备,域名解析,路由表,防火墙等等网络配置,network namespace 的出现就是为了将这些各种各样的网络配置进行隔离。所以你会发现,当你最开始进入一个新 network namespace 时,你用 ping 命名是 ping 不通任何地址的,因为你并没有为新的 network namespace 配置任何网络配置信息,比如路由表。


在大致了解了各种命名空间之后,那么究竟该如何在创建一个进程时指定新命名空间呢,让我们来看看用 go 如何实现。

golang 如何以新命名空间启动程序

cmd := exec.Command(initCmd, os.Args[1:]...)    cmd.SysProcAttr = &syscall.SysProcAttr{      Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS |        syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,    }    cmd.Env = os.Environ()    cmd.Stdin = os.Stdin    cmd.Stdout = os.Stdout    cmd.Stderr = os.Stderr    err = cmd.Run()
复制代码


golang 可以通过 exec 包下的 Command 方法构建一个 Command 结构体,调用其 Run 方法将会启动一个新的进程,其本质也是先 clone 系统调用 然后再 进行 exec 调用,可以看到在构建 Command 时指定了 clone 所需要的 flag 参数 。


💡💡❗️clone 系统调用其实和 fork 系统调用类似,不过 clone 系统调用可以指定在创建子进程时对哪些资源进行复制,比如上述例子中我们指定了各种命名空间的 flag,这代表新启动的子进程将会在新的命名空间下运行。


Command 启动结构体其实有两种方式,一种是 Start 方式,一种就是像代码里的 Run 方法启动。


两种方法区别在于调用 Start 不会等待子进程结束,而 Run 方法将会等待子进程结束。而这里为什么要调用 Run 方法呢,因为这里需要用到标准输入输出流,可以看到,我将控制台输入输出流传递给了 Command 的 Stddin,Stdout 参数,如果父进程在调用 Start 后关闭了进程,进程关闭将导致自身的文件描述符也关闭,所以标准输入输出也会关闭,那么子进程将不不能从标准输入中获取到信息了。


其父子进行通信的原理是通过建立一个管道,通过管道将标准输入的消息传递给了子进程,子进程也通道管道将自身的输出 输出到 标准输出。


总之,到这里算是明白了如何用 golang 启动一个新进程,并且新进程将拥有自己的命名空间。


现在让我们看下完整的这段代码


func main() {  switch os.Args[1] {  case "run":    initCmd, err := os.Readlink("/proc/self/exe")    if err != nil {      fmt.Println("get init process error ", err)      return    }    os.Args[1] = "init"    cmd := exec.Command(initCmd, os.Args[1:]...)    cmd.SysProcAttr = &syscall.SysProcAttr{      Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS |        syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,    }    cmd.Env = os.Environ()    cmd.Stdin = os.Stdin    cmd.Stdout = os.Stdout    cmd.Stderr = os.Stderr    err = cmd.Run()    if err != nil {      fmt.Println(err)    }    fmt.Println("init proc end", initCmd)    return  case "init":    cmd := os.Args[2]    err := syscall.Exec(cmd, os.Args[2:], os.Environ())    if err != nil {      fmt.Println("exec proc fail ", err)      return    }    fmt.Println("forever exec it ")    return  default:    fmt.Println("not valid cmd")  }}
复制代码


来简单分析下这段代码,如果传递给程序的参数是 run 那么将会在一个新的命名空间内 启动一个子进程,子进程运行的代码也是当前可执行程序的代码。


💡💡👨‍🦰 /proc/self/exe 是一个软链接,程序内部读取到的链接是自身可执行文件的路径。比如执行

root@ecs-295280:~/projects/tinydocker# ls -l  /proc/self/exe
lrwxrwxrwx 1 root root 0 May 11 17:32 /proc/self/exe -> /usr/bin/ls

ls 执行后返回/usr/bin/ls


接着传递给子进程 init 参数 ,子进程接手到 init 参数后将会用 后续的 可执行文件 程序 调用 exec 覆盖当前进程。所以可以看到 用 init 参数启动的进程,是新的命名空间内的第一个进程,后续用 exec 系统调用,将覆盖这个进程的堆栈,内存空间等信息,从而让 init 后面的可执行文件变成命名空间内的第一个进程。


我们运行这段程序时便可以这样运行。


root@ecs-295280:~/projects/tinydocker# ./tinydocker run /bin/lsgo.mod  main.go  ReadMe.md  tinydockerinit proc end /root/projects/tinydocker/tinydockerroot@ecs-295280:~/projects/tinydocker# ./tinydocker run /bin/sh#####
复制代码


可以看到 run 后面接着 执行/bin/ls 成功输出了当前目录下的文件,执行 /bin/sh 后启动了一个 sh 进程以便于我们同控制台进行交互,这得益于我们 在 cmd.Run 启动新进程前 将标准输入输出赋值给了 cmd 的标准输入输出参数。


不过可以看到 输出的目录还是主机上的目录,并没有达到隔离的效果,这是因为即使声明了创建新进程时在新的命名空间内部,但是因为没有重新挂载相关目录,新的 mnt namespace 依然是继承自主机的 mnt namespace,所以在没有重新挂载的前提下,新的 mnt namespace 下看到的目录和主机的是一样的。


所以现在让我们来重新挂载下目录。

为程序重新挂载根文件系统

首先要明白寻找文件系统地址的原理,当程序在寻找地址时,会首先判断地址时相对地址还是绝对地址,如果是相对地址则会从进程当前的地址开始寻找,如果是以‘/’ 开头,则说明是绝对地址,那么将会从根路径开始寻找,根路径涉及到两个点,一个是 mnt namespace 的根路径,一个是进程自身的根路径,比如进程将自身根路径设置为/home 那么进程自身在寻找/lanpangzi 时,实际是从 /home/lanpangzi 开始寻找。


现在来看看替换进程能够看到的文件范围时涉及的两种方式,这两种方式也是和刚才提到的根路径涉及的两个点有关。

chroot 替换方式

首先是 chroot 的方式,使用 chroot 可以替换进程自身的根目录,这样进程自身能够寻找到的范围就变到了设置的根目录下。


不过在看具体的代码前,我们还得有这么一个目录,到时候让进程把这个目录设置为其根目录,这个目录下的下文件通常会用 linux 的根目录文件系统(rootfs)填充,可以从这个网址上下载


之后我们便可以用 chroot 替换程序的根目录了。


syscall.Chroot("./ubuntu-base-16.04.6-base-amd64")syscall.Chdir("/")
复制代码


我的程序目录如下


(base) ➜  tinydocker git:(main) ✗ tree -L 1.├── ReadMe.md├── go.mod├── main.go├── ubuntu-base-16.04.6-base-amd64└── ubuntu-base-16.04.6-base-amd64.tar.gz
复制代码


注意为什么在更换了进程根目录后为啥还有个 syscall.Chdir 切换进程当前工作目录的系统调用,因为即使切换了进程的根目录,进程还是在 tinydocker 下,由于 相对地址的寻址不会寻找根目录,所以如果此时进程用相对地址'./go.mod'去访问 go mod 文件还是能访问到,当使用 syscall.Chdir 后,将会更新进程的当前目录到 ubuntu-base-16.04.6-base-amd64 下,这样后续进程对文件的访问范围将限制在 ubuntu-base-16.04.6-base-amd64 目录下。


不过 chroot 切换 文件系统根目录的方式只能改变该进程能看到的文件范围,并不能改变 mnt namespace 的根目录,所以替换的并不彻底。


可以用 nsenter 命令进入 mnt namespace 去看下 mnt namespace 的目录有哪些验证这一点。


首先是我在新进程下,查看根目录下的目录


root@ecs-295280:~/projects/tinydocker# ./tinydocker run /bin/sh# ls /bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
复制代码


运行的 sh 进程 pid 是 184295 ,然后我再在主机上用 nsenter 命令 进入该进程的命名空间 查看该 mnt namespace 根目录下有哪些文件


root@ecs-295280:~# nsenter -t 184295 -m ls /bin   CloudResetPwdUpdateAgent  conf  etc   lib    lib64   log         media  opt   root  sbin  src  swapfile  tmp  varboot  CloudrResetPwdAgent  dev   home  lib32  libx32  lost+found  mnt    proc  run   snap  srv  sys       usrroot@ecs-295280:~#
复制代码


可以看到两个目录下的文件是不一样的,而 nsenter 进入 mnt namespace 下查看的根目录 文件 则是我主机的 mnt namespace 上的根目录文件。


关于 nsenter 使用的一些参数设置如下:


nsenter [options] [program [arguments]]

options:
-t, --target pid:指定被进入命名空间的目标进程的pid
-m, --mount[=file]:进入mount命令空间。如果指定了file,则进入file的命令空间
-u, --uts[=file]:进入uts命令空间。如果指定了file,则进入file的命令空间
-i, --ipc[=file]:进入ipc命令空间。如果指定了file,则进入file的命令空间
-n, --net[=file]:进入net命令空间。如果指定了file,则进入file的命令空间
-p, --pid[=file]:进入pid命令空间。如果指定了file,则进入file的命令空间
-U, --user[=file]:进入user命令空间。如果指定了file,则进入file的命令空间
-G, --setgid gid:设置运行程序的gid
-S, --setuid uid:设置运行程序的uid
-r, --root[=directory]:设置根目录
-w, --wd[=directory]:设置工作目录

如果没有给出program,则默认执行$SHELL。

pivot_root 替换方式

接着来看看 pivot root 的方式,使用 pivot root 的方式替换挂载目录,可以把 mnt 命名空间的根目录也替换掉。


func PivotRoot(newroot string, putold string) (err error) 
复制代码


pivot root 系统调用有些限制,它要求传入两个目录,首先 newroot 和 putold 目录是要求在同一个挂载命名空间中,putold 会存放之前旧的命名空间的文件,并且 newroot 和 putold 处于的挂载命名空间和旧的挂载命名空间不能是同一个。


由于子进程默认会继承父进程的命名空间,所以需要对新命名空间的目录进行重新挂载一下。


syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, "")syscall.Mount(newroot, newroot, "bind", syscall.MS_BIND|syscall.MS_REC, "")
复制代码


注意在使用 mount bind 重新挂载 newroot 之前,我先通过一条 mount 语句声明了挂载命名空间从根目录开始以及其子目录都是私有模式挂载,因为我的主机是 ubuntu,默认的 init 进程已经由 systemd 进程代替,systemd 进程的默认挂载方式是共享挂载,后续会导致 pivot_root 的调用失败,声明为私有模式挂载后,再调用 mount bind 对 newroot 目录重新挂载,那么 newroot 目录就会被单独挂载到新命名空间了。


此时我再在主机上把程序启动起来,然后运行 nsenter 命令进入进程的 mnt namepspace 去查看根目录就发现它和主机上面的根目录不一样了。以下是相关命令。


将程序启动起来


root@ecs-295280:~# cd projects/tinydocker/root@ecs-295280:~/projects/tinydocker# ./tinydocker run /bin/sh#
复制代码


查看程序的进程号


root@ecs-295280:~# ps -ef | grep /bin/shroot      186426  186410  0 10:40 pts/0    00:00:00 ./tinydocker run /bin/shroot      186430  186426  0 10:40 pts/0    00:00:00 /bin/shroot      186534  186506  0 10:45 pts/1    00:00:00 grep --color=auto /bin/sh
复制代码


其中 186430 是 我启动的进程,进入该进程的 mnt namespace 去执行 ls 命令查看根目录


root@ecs-295280:~# nsenter -t 186430 -m /bin/ls /bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  varroot@ecs-295280:~#
复制代码


发现 mnt namepsace 的根目录已经被新的 newroot 下的 rootfs 替换掉了,已经和主机的根目录不同了。

为程序挂载 proc 文件系统

看到这里,还没有结束,我们刚刚仅仅把系统的根文件系统替换掉了,不过这个时候,如果你执行 top 命令会发现它提示你错误。


# topError, do this: mount -t proc proc /proc
复制代码


这是由于 top 命令默认会从/proc 路径去读取内核的进程信息,而替换了根文件系统后,/proc 下还没有挂载 procfs,所以需要重新挂载下。


🧚🏻🧚🏻🧚‍♀️ procfs 是一个内存文件系统,当用 mount 挂载 proc 类型的文件系统时,内核会从当前进程的 pid namespace 下找到该 pid namespace 下的所有进程,然后将进程的各种信息通过访问 /proc 目录的方式暴露出来。这样再访问/proc 目录时,就能访问到该 pid namespace 下的所有进程信息了。


添加上挂载 proc 文件系统的代码。


defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEVsyscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")
复制代码


再执行 top 命令.


top - 03:04:51 up 14 days, 10:23,  0 users,  load average: 0.00, 0.00, 0.00Tasks:   2 total,   1 running,   1 sleeping,   0 stopped,   0 zombie%Cpu(s):  0.0 us,  0.0 sy,  0.0 ni, 99.7 id,  0.3 wa,  0.0 hi,  0.0 si,  0.0 stKiB Mem :  2030524 total,   193608 free,   159664 used,  1677252 buff/cacheKiB Swap:        0 total,        0 free,        0 used.  1623628 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 1 root 20 0 4500 752 684 S 0.0 0.0 0:00.00 sh 4 root 20 0 36528 3044 2640 R 0.0 0.1 0:00.00 top
复制代码


可以看到当前我的 pid namespace 下有两个进程,一个是 sh 进程,一个是 top 进程,而 sh 由于是该命名空间下运行的第一个进程,所以进程号为 1。


这样便完成了在新命名空间内部运行一个进程,并且将进程的文件系统和主机的文件系统进行了隔离。不过隔离仅仅做到这一步还不算完,回忆下,当我们用 docker 启动一个进程时,是不是可以用同一份镜像启动多个容器,类比下现在的实现,你会发现,如果用一份 rootfs 来启动多个进程,那么多个进程最后改变的将会是同一个 rootfs 下的文件,这样将达不到将文件系统隔离的目的。


所以在下面一讲,我将演示下如何用内核联合文件系统的特质,达到一份镜像多次运行的效果。

发布于: 2023-05-25阅读数: 25
用户头像

还未添加个人签名 2020-09-17 加入

还未添加个人简介

评论

发布
暂无评论
500行代码手写docker-以新命名空间运行程序_Docker_蓝胖子的编程梦_InfoQ写作社区