Docker -- Docker底层原理深度剖析

概论
谈到原理 , 我们先来三板斧 。
然后我们心中要明白一件事情:
容器是一种特殊的进程 。容器技术的核心功能就是通过约束和修改进程的动态表现,从而为其创造出一个边界 。
其中技术是用来修改进程视图的主要方法 。
技术是用来制造约束的主要手段 。
Linux 的命名空间机制提供了以下8种不同的命名空间,包括 :
系统调用接口
clone()
int clone(int (*fn)(void *), void *stack, int flags, void *arg, .../* pid_t *parent_tid, void *tls, pid_t *child_tid */ );
clone() 用于创建新进程 , 通过传入一个或多个系统调用参数( flags 参数)可以创建出不同类型的,并且子进程也将会成为这些的成员 。
setns()
int setns(int fd, int nstype);
setns() 用于将进程加入到一个现有的中 。其中 fd 为文件描述符 , 引用 /proc/[pid]/ns/ 目录里对应的文件 ,  代表类型 。
()
int unshare(int flags);
() 用于将进程移出原本的 , 并加入到新创建的中 。同样是通过传入一个或多个系统调用参数( flags 参数)来创建新的。
ioctl()
int ioctl(int fd, unsigned long request, ...);
ioctl() 用于发现有关的信息 。
上面的这些系统调用函数,我们可以直接用 C 语言调用,创建出各种类型的 , 对于 Go 语言,其内部已经帮我们封装好了这些函数操作,可以更方便地直接使用,降低心智负担 。
使用Go实现简易的隔离
package mainimport ("os""os/exec")func main() {switch os.Args[1] {case "run":run()default:panic("help")}}func run() {cmd := exec.Command(os.Args[2], os.Args[3:]...)cmd.Stdin = os.Stdincmd.Stdout = os.Stdoutcmd.Stderr = os.Stderrmust(cmd.Run())}func must(err error) {if err != nil {panic(err)}}
我们需要在Linux系统下进行测试 。
[root@localhost k8s]# go run main.go run echo hellohello
当我们执行 go run main.go run echo hello 时,会创建出 main 进程,main 进程内执行 echo hello 命令创建出一个新的 echo 进程,最后随着 echo 进程的执行完毕 , main 进程也随之结束并退出 。
启动bash进程
先查看当前进程ID
[root@localhost ~]# psPID TTYTIME CMD61779 pts/100:00:00 bash62358 pts/100:00:00 ps
当前bash的pid为61779.
[root@localhost k8s]# go run main.go run /bin/bash[root@localhost k8s]# psPID TTYTIME CMD12429 pts/100:00:00 go12447 pts/100:00:00 main12450 pts/100:00:00 bash13021 pts/100:00:00 ps61779 pts/100:00:00 bash[root@localhost k8s]# exitexit[root@localhost k8s]# psPID TTYTIME CMD13611 pts/100:00:00 ps61779 pts/100:00:00 bash
在执行 go run main.go run /bin/bash 后,我们的会话被切换到了 PID 12450的 bash 进程中,而 main 进程也还在运行着(当前所处的 bash 进程是 main 进程的子进程,main 进程必须存活着,才能维持 bash 进程的运行) 。当执行 exit 退出当前所处的 bash 进程后 , main 进程随之结束,并回到原始的 PID 61779的 bash 会话进程 。
容器的实质是进程,你现在可以把 main 进程当作是 “” 工具,把 main 进程启动的 bash 进程,当作一个 “容器”。这里的 “” 创建并启动了一个 “容器” 。
但是这个 bash 进程中 , 我们可以随意使用操作系统的资源,并没有做资源隔离 。
那么我们接下来随便挑选两个资源来做一些限制 。
UTS隔离
要想实现资源隔离,在 run() 函数增加配置,先从最简单的 UTS 隔离开始,传入对应的系统调用参数 , 并通过 . 设置主机名:
func run() {cmd := exec.Command(os.Args[2], os.Args[3:]...)cmd.Stdin = os.Stdincmd.Stdout = os.Stdoutcmd.Stderr = os.Stderrcmd.SysProcAttr = &syscall.SysProcAttr{Cloneflags: syscall.CLONE_NEWUTS,}must(syscall.Sethostname([]byte("mycontainer")))must(cmd.Run())}
然后我们运行:
go run main.go /bin/bash
进入到新的命令行,然后输入指令 。
发现我们的主机名被改成了,一切符合正常 。但是我们输入exit , 退出改命令行,再次查看主机名,却发现,我main进程所在的原来的主机的主机名被改成了 。
因此我们需要改变我们的代码,我们来认识一个机制 。
在 Linux 2.2 内核版本及其之后,/proc/[pid]/exe 是对应 pid 进程的二进制文件的符号链接 , 包含着被执行命令的实际路径名 。如果打开这个文件就相当于打开了对应的二进制文件,甚至可以通过重新输入 /proc/[pid]/exe 重新运行一个对应于 pid 的二进制文件的进程 。
对于 /proc/self,当进程访问这个神奇的符号链接时 , 可以解析到进程自己的 /proc/[pid] 目录 。
合起来就是,当进程访问 /proc/self/exe 时 , 可以运行一个对应进程自身的二进制文件 。
package mainimport ("os""os/exec""syscall")func main() {switch os.Args[1] {case "run":run()case "child":child()default:panic("help")}}func run() {cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)cmd.Stdin = os.Stdincmd.Stdout = os.Stdoutcmd.Stderr = os.Stderrcmd.SysProcAttr = &syscall.SysProcAttr{Cloneflags: syscall.CLONE_NEWUTS,}must(cmd.Run())}func child() {must(syscall.Sethostname([]byte("mycontainer")))cmd := exec.Command(os.Args[2], os.Args[3:]...)cmd.Stdin = os.Stdincmd.Stdout = os.Stdoutcmd.Stderr = os.Stderrmust(cmd.Run())}func must(err error) {if err != nil {panic(err)}}
在 run() 函数中,我们不再是直接运行用户所传递的命令行参数 , 而是运行 /proc/self/exe ,并传入 child 参数和用户传递的命令行参数 。
同样当执行 go run main.go run echo hello 时 , 会创建出 main 进程 ,  main 进程内执行 /proc/self/exe child echo hello 命令创建出一个新的 exe 进程,关键也就是这个 exe 进程 , 我们已经为其配置了系统调用参数进行 UTS 隔离 。也就是说 , exe 进程可以拥有和 main 进程不同的主机名 , 彼此互不干扰 。
进程访问 /proc/self/exe 代表着运行对应进程自身的二进制文件 。因此,按照 exe 进程的启动参数,会执行 child() 函数 , 而 child() 函数内首先调用 . 更改了主机名(此时是 exe 进程执行的,并不会影响到 main 进程), 再次使用 exec. 运行用户命令行传递的参数 。
总结一下就是, main 进程创建了 exe 进程(exe 进程已经进行 UTS 隔离,exe 进程更改主机名不会影响到 main 进程), 接着 exe 进程内执行 echo hello 命令创建出一个新的 echo 进程,最后随着 echo 进程的执行完毕,exe 进程随之结束,exe 进程结束后,main 进程再结束并退出 。
创建 exe 进程的同时我们传递了标识符创建了一个 UTS,Go 内部帮我们封装了系统调用函数 clone() 的调用,我们也说过,由 clone() 函数创建出的进程的子进程也将会成为这些的成员,所以默认情况下(创建新进程时无继续指定系统调用参数),由 exe 进程创建出的 echo 进程会继承 exe 进程的资源, echo 进程将拥有和 exe 进程相同的主机名,并且同样和 main 进程互不干扰 。
因此,借助中间商 exe 进程 ,echo 进程可以成功实现和宿主机( main 进程)资源隔离,拥有不同的主机名 。
现在再看 。
[root@localhost k8s]# go run main.go run /bin/bash[root@mycontainer k8s]# hostnamemycontainer[root@mycontainer k8s]# psPID TTYTIME CMD50603 pts/100:00:00 go50621 pts/100:00:00 main50624 pts/100:00:00 exe50627 pts/100:00:00 bash53616 pts/100:00:00 ps61779 pts/100:00:00 bash[root@mycontainer k8s]# exitexit[root@localhost k8s]# hostnamelocalhost.localdomain
完全成功了 。
PID隔离
package mainimport ("fmt""os""os/exec""syscall")func main() {switch os.Args[1] {case "run":run()case "child":child()default:panic("help")}}func run() {fmt.Println("[main]", "pid:", os.Getpid())cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)cmd.Stdin = os.Stdincmd.Stdout = os.Stdoutcmd.Stderr = os.Stderrcmd.SysProcAttr = &syscall.SysProcAttr{Cloneflags: syscall.CLONE_NEWUTS |syscall.CLONE_NEWPID |syscall.CLONE_NEWNS,Unshareflags: syscall.CLONE_NEWNS,}must(cmd.Run())}func child() {fmt.Println("[exe]", "pid:", os.Getpid())must(syscall.Sethostname([]byte("mycontainer")))must(os.Chdir("/"))must(syscall.Mount("proc", "proc", "proc", 0, ""))cmd := exec.Command(os.Args[2], os.Args[3:]...)cmd.Stdin = os.Stdincmd.Stdout = os.Stdoutcmd.Stderr = os.Stderrmust(cmd.Run())must(syscall.Unmount("proc", 0))}func must(err error) {if err != nil {panic(err)}}
参数新增了和分别隔离进程 pid 和文件目录挂载点视图,: . 则是用于禁用挂载传播 。
当我们创建 PID时 , exe 进程包括其创建出来的子进程的 pid 已经和 main 进程隔离了,这一点可以通过打印 os.() 结果或执行 echo $$ 命令得到验证 。但此时还不能使用 ps 命令查看,因为 ps 和 top 等命令会使用 /proc 的内容 , 所以我们才继续引入了 Mount,并在 exe 进程挂载 /proc 目录 。
其他的就不一一展示了,我们的目标不是写出,而是理解而已 。
我们之前讲解了,但是Linux 的隔离机制相比于虚拟化技术也有很多不足,其中最主要的问题就是:隔离的不彻底 。
既然容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个宿主机的操作系统内核 。如果你的Linux版本不一样,那么隔离仍然是不彻底的 。此外,容器的资源也没有隔离 , 因此我们引出了下一个概念: 。
主要功能:
限制资源使用,比如内存使用上限以及文件系统的缓存限制 。优先级控制 , CPU利用和磁盘IO吞吐 。一些审计或一些统计,主要目的是为了计费 。挂起进程,恢复执行进程 。
在Linux中,给用户暴露出来的操作接口是文件系统 , 即它以文件和目录的方式组织在操作系统的/sys/fs/路径下 。我们可以用命令把它们展示出来:
[root@master cgroup]# mount -t cgroupcgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd)cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer)cgroup on /sys/fs/cgroup/net_cls,net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,net_prio,net_cls)cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct,cpu)cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices)cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids)cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb)cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event)cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
可以看到在/sys/fs/下面有很多诸如,cpu , 这样的子目录,也叫子系统 。这些都是我这台机器当前可用被进行限制的资源种类 。而在子系统对应的资源种类下,你可用看到该类资源具体可用被限制的方法 。比如对CPU子系统来说,我们就可以看到如下几个配置文件,这个指令是:
[root@master cgroup]# ls /sys/fs/cgroup/cpucgroup.clone_childrencgroup.sane_behaviorcpuacct.usage_percpucpu.rt_period_uscpu.statnotify_on_releasetaskscgroup.event_controlcpuacct.statcpu.cfs_period_uscpu.rt_runtime_uscpu_trelease_agentuser.slicecgroup.procscpuacct.usagecpu.cfs_quota_uscpu.shareskubepods.slicesystem.slice
那么这个配置文件怎么使用呢?
你需要在对应的子系统下面创建一个目录 , 比如我们现在进入/sys/fs//cpu目录下:
[root@master cpu]# mkdir container[root@master cpu]# cd container/[root@master container]# lltotal 0-rw-r--r-- 1 root root 0 Feb 27 16:35 cgroup.clone_children--w--w--w- 1 root root 0 Feb 27 16:35 cgroup.event_control-rw-r--r-- 1 root root 0 Feb 27 16:35 cgroup.procs-r--r--r-- 1 root root 0 Feb 27 16:35 cpuacct.stat-rw-r--r-- 1 root root 0 Feb 27 16:35 cpuacct.usage-r--r--r-- 1 root root 0 Feb 27 16:35 cpuacct.usage_percpu-rw-r--r-- 1 root root 0 Feb 27 16:35 cpu.cfs_period_us-rw-r--r-- 1 root root 0 Feb 27 16:35 cpu.cfs_quota_us-rw-r--r-- 1 root root 0 Feb 27 16:35 cpu.rt_period_us-rw-r--r-- 1 root root 0 Feb 27 16:35 cpu.rt_runtime_us-rw-r--r-- 1 root root 0 Feb 27 16:35 cpu.shares-r--r--r-- 1 root root 0 Feb 27 16:35 cpu.stat-rw-r--r-- 1 root root 0 Feb 27 16:35 notify_on_release-rw-r--r-- 1 root root 0 Feb 27 16:35 tasks
这个目录就被成为一个控制组 。你会发现 , 操作系统会在你新创建的目录下,自动生成该子系统对应的资源限制文件 。
现在我们后台执行一条这样的脚本:
[root@node1 container]# while : ; do : ; done &[1] 58358
它执行了一个死循环,然后PID号是58358 。
我们使用top命令查看一下它的CPU使用情况 。
[root@node1 container]# toptop - 16:41:14 up 10:26,2 users,load average: 0.23, 0.07, 0.06Tasks: 131 total,2 running, 129 sleeping,0 stopped,0 zombie%Cpu(s): 51.2 us,0.4 sy,0.0 ni, 48.4 id,0.0 wa,0.0 hi,0.0 si,0.0 stKiB Mem :3861296 total,2650804 free,648408 used,562084 buff/cacheKiB Swap:0 total,0 free,0 used.2976548 avail Mem PID USERPRNIVIRTRESSHR S%CPU %MEMTIME+ COMMAND58358 root200115544576168 R 100.00.00:13.19 bash
可用看到CPU的使用率是100%.
此时我们可以通过查看目录下的文件,看到控制组里面CPU quota还没有做任何限制,CPU 则是默认是100ms 。
[root@node1 container]# cat /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us -1[root@node1 container]# cat /sys/fs/cgroup/cpu/container/cpu.cfs_period_us 100000
和两者需要搭配起来使用:可以用来限制进程在长度的一段时间内,只能被分配到总理为的CPU时间 。
因此我们做一个修改,添加一些限制 。
[root@node1 container]# echo 20000 >/sys/fs/cgroup/cpu/container/cpu.cfs_quota_us
那么我们怎么让这个限制生效呢?
[root@node1 container]# echo 58358 > /sys/fs/cgroup/cpu/container/tasks
我们把这个进程PID写入tasks里面 。然后使用top看生效了没有 。
[root@node1 container]# toptop - 16:42:35 up 10:28,2 users,load average: 0.78, 0.30, 0.14Tasks: 132 total,2 running, 130 sleeping,0 stopped,0 zombie%Cpu(s): 10.6 us,0.5 sy,0.0 ni, 88.8 id,0.0 wa,0.0 hi,0.2 si,0.0 stKiB Mem :3861296 total,2651128 free,648016 used,562152 buff/cacheKiB Swap:0 total,0 free,0 used.2976908 avail Mem PID USERPRNIVIRTRESSHR S%CPU %MEMTIME+ COMMAND58358 root200115544576168 R20.00.01:29.58 bash
可以看到资源确实被成功的限制了 。
相关概念解析
task: 在中 , 任务就是系统的一个进程 。我们刚才把进程PID加入到task里面去 。
group: 控制组 , 指明了资源的配额限制 。进程可以加入到某个控制组 , 也可以迁移到另一个控制组 。
: 层级结构,控制组有层级结构,子节点的控制组继承父节点控制组的属性(资源配额、限制等)
: 子系统,一个子系统其实就是一种资源的控制器,比如子系统可以控制进程内存的使用 。子系统需要加入到某个层级,然后该层级的所有控制组,均受到这个子系统的控制 。
drwxr-xr-x 5 root root0 Feb 26 13:28 blkiolrwxrwxrwx 1 root root 11 Feb 26 13:28 cpu -> cpu,cpuacctlrwxrwxrwx 1 root root 11 Feb 26 13:28 cpuacct -> cpu,cpuacctdrwxr-xr-x 6 root root0 Feb 26 13:28 cpu,cpuacctdrwxr-xr-x 3 root root0 Feb 26 13:28 cpusetdrwxr-xr-x 5 root root0 Feb 26 13:28 devicesdrwxr-xr-x 3 root root0 Feb 26 13:28 freezerdrwxr-xr-x 3 root root0 Feb 26 13:28 hugetlbdrwxr-xr-x 5 root root0 Feb 26 13:28 memorylrwxrwxrwx 1 root root 16 Feb 26 13:28 net_cls -> net_cls,net_priodrwxr-xr-x 3 root root0 Feb 26 13:28 net_cls,net_priolrwxrwxrwx 1 root root 16 Feb 26 13:28 net_prio -> net_cls,net_priodrwxr-xr-x 3 root root0 Feb 26 13:28 perf_eventdrwxr-xr-x 5 root root0 Feb 26 13:28 pidsdrwxr-xr-x 5 root root0 Feb 26 13:28 systemd[root@node1 cgroup]# pwd/sys/fs/cgroup
这个就是子系统 。进入了某一个子进行里面的那些指标就是控制组 。
内存限制Go实例
//go:build linux// +build linuxpackage mainimport ("fmt""io/ioutil""os""os/exec""path/filepath""strconv""syscall")func main() {switch os.Args[1] {case "run":run()case "child":child()default:panic("help")}}func run() {fmt.Println("[main]", "pid:", os.Getpid())cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)cmd.Stdin = os.Stdincmd.Stdout = os.Stdoutcmd.Stderr = os.Stderrcmd.SysProcAttr = &syscall.SysProcAttr{Cloneflags: syscall.CLONE_NEWUTS |syscall.CLONE_NEWPID |syscall.CLONE_NEWNS,Unshareflags: syscall.CLONE_NEWNS,}must(cmd.Run())}func child() {fmt.Println("[exe]", "pid:", os.Getpid())cg()must(syscall.Sethostname([]byte("mycontainer")))must(os.Chdir("/"))must(syscall.Mount("proc", "proc", "proc", 0, ""))cmd := exec.Command(os.Args[2], os.Args[3:]...)cmd.Stdin = os.Stdincmd.Stdout = os.Stdoutcmd.Stderr = os.Stderrmust(cmd.Run())must(syscall.Unmount("proc", 0))}func cg() {mycontainer_memory_cgroups := "/sys/fs/cgroup/memory/mycontainer"os.Mkdir(mycontainer_memory_cgroups, 0755)must(ioutil.WriteFile(filepath.Join(mycontainer_memory_cgroups, "memory.limit_in_bytes"), []byte("100M"), 0700))must(ioutil.WriteFile(filepath.Join(mycontainer_memory_cgroups, "notify_on_release"), []byte("1"), 0700))must(ioutil.WriteFile(filepath.Join(mycontainer_memory_cgroups, "tasks"), []byte(strconv.Itoa(os.Getpid())), 0700))}func must(err error) {if err != nil {panic(err)}}
我们使用C语言来编写一个简单的创建容器的程序 。
#define _GNU_SOURCE#include #include #include #include #include #include #include #define STACK_SIZE (1024 * 1024)static char container_stack[STACK_SIZE];char* const container_args[] = {"/bin/bash",NULL};int container_main(void* arg){printf("Container - inside the container!\n");execv(container_args[0], container_args);printf("Something's wrong!\n");return 1;}int main(){printf("Parent - start a container!\n");int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWNS | SIGCHLD, NULL);waitpid(container_pid, NULL, 0);printf("Parent - container stopped!\n");return 0;}
这个main程序的功能很简单,就是通过clone()系统调用创建了一个新的子进程,并且声明要为它启动Mount。
然后我们运行程序 。
[root@VM-24-15-centos LDocker]# ./docker Parent - start a container!Parent - container stopped!Container - inside the container!
我们此时就已经进入了一个容器内部了 。
然后我们查看一下ls /里面的内存,我们惊奇的发现,这个跟我宿主机里面的内容不是一样的吗?隔离了一个寂寞!
这是怎么回事儿呢?
Mount 修改的 , 是容器进程对文件系统挂载点的认知 。只有在挂载这个操作发生之后,进程的视图才会被改变 。在此之前,新创建的容器都会直接集成宿主机的各个挂载点 。
因此我们想到的方法是,在创建新进程的时候,同时告诉容器挂载的点 。于是我们修改一下这个代码:
int container_main(void* arg){printf("Container - inside the container!\n");mount("", "/LDocker", NULL, MS_PRIVATE, "");execv(container_args[0], container_args);printf("Something's wrong!\n");return 1;}
什么是挂载?
指的就是将设备文件中的顶级目录连接到 Linux 根目录下的某一目录(最好是空目录) , 访问此目录就等同于访问设备文件 。
可以发现代码修改之后,操作是成功的 。
这就是Mount 跟其他的使用策略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载操作才能生效 。
然而用户希望看到的是一个独立的隔离环境,是所有的文件系统都是隔离的 , 而不是只有一部分,因此我们想到的是挂载整个根目录 。
挂载在容器根目录上,用来为容器进程提供隔离后执行环境的文件系统,就是所谓的容器镜像 。它还有一个更专业的名字,叫做(根文件系统) 。
但是需要明确的是:
只是一个操作系统所包含的文件,配置和目录,并不包括操作系统的内核 。在Linux操作系统中,这两部分是分开存放的,操作系统只有在开机启动的时候才会加载指定版本的内核镜像 。
因此只包括了操作系统的躯壳 , 并没有包括操作系统的灵魂 。
那么对于容器来说,这个操作系统的灵魂在哪里呢?
实际上,同一台机器上的所有的容器都共享主机操作系统的内核 。
有了(容器镜像之后),容器便解决了一致性问题 , 那么什么是一致性问题呢?
一致性问题就是:云端和本地服务器环境不同,应用的打包过程不一致 。
由于里面打包的不仅仅是应用,而是整个操作系统的文件和目录,也就意味着应用以及它运行所需要的依赖 , 都被封装在了一起,这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和远端执行环境之间难以逾越的鸿沟 。
但是这个时候出现了一个非常麻烦的问题:难道我每次开发一个应用,或者升级一下现有的应用 , 都要重复只做一次吗?
比如,我现在用操作系统的 ISO 做了一个,然后又在里面安装了 Java 环境 , 用来部署我的 Java 应用 。那么,我的另一个同事在发布他的 Java 应用时,显然希望能够直接使用我安装过 Java 环境的  , 而不是重复这个流程 。
一种比较直观的解决办法是 , 我在制作的时候,每做一步“有意义”的操作,就保存一个出来,这样其他同事就可以按需求去用他需要的了 。
但是,这个解决办法并不具备推广性 。原因在于 , 一旦你的同事们修改了这个,新旧两个之间就没有任何关系了 。这样做的结果就是极度的碎片化 。
那么 , 既然这些修改都基于一个旧的  , 我们能不能以增量的方式去做这些修改呢?
这样做的好处是,所有人都只需要维护相对于 base修改的增量内容,而不是每次修改都制造个“fork” 。
答案当然是肯定的 。
这也正是为何,公司在实现镜像时并没有沿用以前制作的标准流程,而是做了一个小小的创新:
在镜像的设计中,引入了层(layer)的概念 。也就是说,用户制作镜像的每一步操作(),都会生成一个层,也就是一个增量。
当然,这个想法不是凭空臆造出来的,而是用到了一种叫作联合文件系统(Union )的能力 。
因此,我们现在绕了一大圈,终于引出了原理的最后一个点,联合文件系统 。
什么是联合文件系统呢?
[root@VM-24-15-centos union]# tree ..|-- A||-- a|`-- x`-- B|-- b`-- x6 directories, 0 files[root@VM-24-15-centos union]# mkdir C[root@VM-24-15-centos union]# mount -t aufs -o dirs=./A:./B none ./C./C|-- a|-- b`-- x3 directories, 0 files
联合文件系统在中的应用

Docker -- Docker底层原理深度剖析

文章插图
我们现在来启动一个容器,比如:
docker run -d ubuntu:latest sleep 3600
这个时候会从 Hub上面拉取一个镜像到本地 。
这个所谓的“镜像”,实际上就是一个操作系统的,它的内容是 操作系统的所有文件和目录 。不过,与之前我们讲述的稍微不同的是, 镜像使用的 ,往往由多个“层”组成:
$ docker image inspect ubuntu:latest..."RootFS": {"Type": "layers","Layers": ["sha256:f49017d4d5ce9c0f544c...","sha256:8f2b771487e9d6354080...","sha256:ccd4d61916aaa2159429...","sha256:c01d74f99de40e097c73...","sha256:268a067217b5fe78e000..."]}
可以看到,这个镜像,实际上由五个层组成 。这五个层就是五个增量,每一层都是操作系统文件与目录的一部分;而在使用镜像时,会把这些增量联合挂载在一个统一的挂载点上(等价于前面例子里的“/C”目录) 。
这个挂载点就是 /var/lib//aufs/mnt/,比如:
不出意外的 , 这个目录里面正是一个完整的操作系统:
1 $ ls /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce2 bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
那么,前面提到的五个镜像层,又是如何被联合挂载成这样一个完整的文件系统的呢?
这个信息记录在 AuFS 的系统目录 /sys/fs/aufs 下面
首先,通过查看 AuFS 的挂载信息,我们可以找到这个目录对应的 AuFS 的内部 ID(也叫:si):
1 $ cat /proc/mounts| grep aufs2 none /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fc... aufs rw,relatime,si=972c6d361e6b32ba
即,si= 。
然后使用这个 ID , 你就可以在 /sys/fs/aufs 下查看被联合挂载在一起的各个层的信息:
1 $ cat /sys/fs/aufs/si_972c6d361e6b32ba/br[0-9]*2 /var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...=rw3 /var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...-init=ro+wh4 /var/lib/docker/aufs/diff/32e8e20064858c0f2...=ro+wh5 /var/lib/docker/aufs/diff/2b8858809bce62e62...=ro+wh6 /var/lib/docker/aufs/diff/20707dce8efc0d267...=ro+wh7 /var/lib/docker/aufs/diff/72b0744e06247c7d0...=ro+wh8 /var/lib/docker/aufs/diff/a524a729adadedb90...=ro+wh
从这些信息里,我们可以看到,镜像的层都放置在 /var/lib//aufs/diff 目录下,然后被联合挂载在 /var/lib//aufs/mnt 里面 。
从这个接口可以看出容器的由如图的三个部分构成:
第一部分:只读层
它是这个容器的最下面的五层,对应的正是 : 镜像的五层 。可以看到,它们的挂载方式都是只读的(ro+wh,即 +,至于什么是,我下面马上会讲到)
我们可以看一下:
1 $ ls /var/lib/docker/aufs/diff/72b0744e06247c7d0...2 etc sbin usr var3 $ ls /var/lib/docker/aufs/diff/32e8e20064858c0f2...4 run5 $ ls /var/lib/docker/aufs/diff/a524a729adadedb900...6 bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
这些层都是以增量的方式分布包含了操作系统的一部分 。
第二部分:可读可写层
它是这个容器的最上面的一层() , 它的挂载方式为:rw,即 read write 。在没有写入文件之前 , 这个目录是空的 。而一旦在容器里做了写操作,你修改产生的内容就会以增量的方式出现在这个层中 。
可是,你有没有想到这样一个问题:如果我现在要做的 , 是删除只读层里的一个文件呢?
为了实现这样的删除操作,AuFS 会在可读写层创建一个文件,把只读层里的文件“遮挡”起来 。
比如,你要删除只读层里一个名叫 foo 的文件,那么这个删除操作实际上是在可读写层创建了一个名叫.wh.foo 的文件 。这样,当这两个层被联合挂载之后,foo 文件就会被.wh.foo 文件“遮挡”起来,“消失”了 。这个功能,就是“ro+wh”的挂载方式,即只读 + 的含义 。我喜欢把形象地翻译为:“白障” 。
所以,最上面这个可读写层的作用,就是专门用来存放你修改后产生的增量 , 无论是增、删、改,都发生在这里 。而当我们使用完了这个被修改过的容器之后,还可以使用和 push 指令 , 保存这个被修改过的可读写层,并上传到Hub上,供其他人使用;而与此同时,原先的只读层里的内容则不会有任何变化 。这 , 就是增量 的好处 。
第三部分:Init层
它是一个以“-init”结尾的层,夹在只读层和读写层之间 。Init 层是项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/.conf 等信息 。
需要这样一层的原因是,这些文件本来属于只读的镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值比如 ,所以就需要在可读写层对它们进行修改 。
可是,这些修改往往只对当前的容器有效,我们并不希望执行时,把这些信息连同可读写层一起提交掉 。所以 ,  做法是,在修改了这些文件之后,以一个单独的层挂载了出来 。而用户执行只会提交可读写层,所以是不包含这些内容的 。
最终,这 7 个层都被联合挂载到 /var/lib//aufs/mnt 目录下,表现为一个完整的 操作系统供容器使用 。
重新认识容器exec原理
我们知道有一个命令叫做 exec 。在了解了Linux 之后,你应该很自然的会想到一个问题: exec是怎么进入到容器内部的?
【Docker -- Docker底层原理深度剖析】实际上,创建的隔离空间虽然看不见摸不着,但是一个进程的信息在宿主机上是确确实实存在的 , 并且是以一个文件的方式 。
比如 , 通过如下指令,你可以看到当前正在运行的 容器的进程号(PID)是25686:
1 $ docker inspect --format '{{ .State.Pid }}' 4ddf4638572d2 25686
这时,你可以通过查看宿主机proc文件,看到这个25686进程的所有对应的文件 。
1 $ ls -l /proc/25686/ns2 total 03 lrwxrwxrwx 1 root root 0 Aug 13 14:05 cgroup -> cgroup:[4026531835]4 lrwxrwxrwx 1 root root 0 Aug 13 14:05 ipc -> ipc:[4026532278]5 lrwxrwxrwx 1 root root 0 Aug 13 14:05 mnt -> mnt:[4026532276]6 lrwxrwxrwx 1 root root 0 Aug 13 14:05 net -> net:[4026532281]7 lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid -> pid:[4026532279]8 lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid_for_children -> pid:[4026532279]9 lrwxrwxrwx 1 root root 0 Aug 13 14:05 user -> user:[4026531837]10 lrwxrwxrwx 1 root root 0 Aug 13 14:05 uts -> uts:[4026532277]
可以看到,一个进程的每种 Linux ,都在它对应的 /proc/[进程号]/ns 下有一个对应的虚拟文件,并且链接到一个真实的文件上 。
这也就意味着:一个进程 , 可以选择加入到某个进程已有的当中,从而达到“进入”这个进程所在容器的目的,这正是exec 的实现原理 。
这个操作所依赖的,就是一个叫做setns()的Linux系统调用 。
#define _GNU_SOURCE#include #include #include #include #include #include #include #define errExit(msg) do { perror(msg); exit(EXIT_FAILURE);} while (0)int main(int argc, char *argv[]){int fd;fd = open(argv[1], O_RDONLY);if (setns(fd, 0) == -1) {errExit("setns");}execvp(argv[2], &argv[2]);errExit(argv[2], &argv[2])return 0;}
这段代码功能非常简单:它一共接收两个参数,第一个参数是 argv[1],即当前进程要加入的文件的路径,比如 /proc/25686/ns/net;而第二个参数,则是你要在这个 里运行的进程,如/bin/bash 。
这段代码的的核心操作,则是通过 open() 系统调用打开了指定的文件,并把这个文件的描述符 fd 交给 setns() 使用 。在 setns() 执行后 , 当前进程就加入了这个文件对应的 Linux当中了 。
原理
 , 实际上就是在容器运行起来后,把最上层的“可读写层”,加上原先容器镜像的只读层,打包组成了一个新的镜像 。当然,下面这些只读层在宿主机上是共享的,不会占用额外的空间 。
而由于使用了联合文件系统 , 你在容器里对镜像所做的任何修改 , 都会被操作系统先复制到这个可读写层,然后再修改 。这就是所谓的:Copy-on-Write 。
而正如前所说,Init 层的存在,就是为了避免你执行时,把自己对 /etc/hosts 等文件做的修改,也一起提交掉 。
原理
前面我已经介绍过,容器技术使用了机制和 Mount ,构建出了一个同宿主机完全隔离开的文件系统环境 。这时候,我们就需要考虑这样两个问题:
这正是要解决的问题: 机制,允许你将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作 。
我们之前已经说过:当容器进程被创建之后,尽管开启了 Mount  , 但是在它执行 (或者 )之前,容器进程一直可以看到宿主机上的整个文件系统 。
而宿主机上的文件系统,也自然包括了我们要使用的容器镜像 。这个镜像的各个层 , 保在/var/lib//aufs/diff 目录下,在容器进程启动后,它们会被联合挂载在/var/lib//aufs/mnt/ 目录中 , 这样容器所需的就准备好了 。
所以,我们只需要在准备好之后,在执行之前,把指定的宿主机目录(比如 /home 目录) , 挂载到指定的容器目录(比如 /test 目录)在宿主机上对应的目录(即 /var/lib//aufs/mnt/[可读写层 ID]/test)上,这个的挂载工作就完成了.
更重要的是,由于执行这个挂载操作时,“容器进程”已经创建了,也就意味着此时Mount已经开启了 。所以 , 这个挂载事件只在这个容器里可见 。你在宿主机上,是看不见容器内部的这个挂载点的 。这就保证了容器的隔离性不会被打破 。
注意:这里提到的 " 容器进程 ",是创建的一个容器初始化进程(),而不是应用进程 ( + CMD) 。会负责完成根目录的准备、挂载设备和目录、配置等一系列需要在容器内进行的初始化操作 。最后 , 它通过 execv() 系统调用,让应用进程取代自己,成为容器里的 PID=1 的进程 。
Linux绑定挂载机制原理
我们之前提到了很多次挂载,这个挂载实际上就是Linux的绑定挂载机制 。它的主要作用就是允许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上,并且这时你在该挂在带你上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响 。
绑定挂载的本质是inode替换的过程 。在 Linux 操作系统中 , inode 可以理解为存放文件内容的“对象”,而 ,也叫目录项,就是访问这个 inode 所使用的“指针” 。
正如上图所示,mount --bind /home /test,会将 /home 挂载到 /test 上 。其实相当于将 /test 的,重定向到了 /home 的 inode 。这样当我们修改 /test 目录时,实际修改的是 /home 目录的 inode 。这也就是为何,一旦执行命令,/test 目录原先的内容就会恢复:因为修改真正发生在的 , 是 /home 目录里 。
这样,进程在容器里对这个 /test 目录进行的所有操作,都实际发生在宿主机的对应目录(比如,/home,或者 /var/lib///[]/_data)里,而不会影响容器镜像的内容 。
那么,这个 /test 目录里的内容,既然挂载在容器的可读写层 , 它会不会被提交掉呢?
不会 。
这个原因其实我们前面已经提到过 。容器的镜像操作,比如,都是发生在宿主机空间的 。而由于 Mount的隔离作用,宿主机并不知道这个绑定挂载的存在 。所以,在宿主机看来,容器中可读写层的 /test 目录(/var/lib//aufs/mnt/[可读写层 ID]/test),始终是空的 。
不过,由于一开始还是要创建 /test 这个目录作为挂载点 , 所以执行了之后,你会发现新产生的镜像里 , 会多出来一个空的 /test 目录 。毕竟,新建目录操作 , 又不是挂载操作,Mount对它可起不到“障眼法”的作用 。
总结