每次看docker相关的资料,都是零零碎碎的,没有系统性的去把所有知识融汇起来。最近花时间好好学习了一下,基本算是能够把docker所用的linux相关的知识给串通起来;这里简单记录一下,希望便于后续进一步学习或者对后来人有一定的帮助。
核心技术
docker是一个牛X的产品,它所用到的核心技术,在linux中已经有较老的历史了,有很多人早已看到linux的这些特性,也不乏有类似docker这样使用这些技术的公司。但是Solomon Hykes和他年青的小伙伴儿们看到这这个商机,成立Dotcloud公司,把创建“大规模的创新工具”的想法付诸现实;最终发展成为被大众接纳,甚至渐渐爱不释手的docker。
那么,到底docker的幕后都用到了哪些更牛X的技术呢?我们就来聊下那些幕后英雄们!
- NameSpace
- Cgroup
- AUFS
NameSpace
Namespace为docker的进程(容器)作了逻辑隔离。
接触较多,也比较好理解的可能莫属linux network namespace了。我们可以在linux上手动执行命令ip net add net-1
, 就可以在创建一个名为net-1的网络命名空间,这个命名空间就相当于是位于linux系统中另一个逻辑网络区域,它和系统当前的命名空间相互隔离。
有了上面形象的理解后,我们再来看linux下docker使用到的其他命名空间(以下各个参数,都可以在/proc/{pid}/ns/*
目录下查询到):
UTS
UTS Namespace 主要用来隔离nodename
和domainname
两个系统标识。在UTSNamespace里, 每个Namespace都允许有自己的hostname,docker就是靠它来保障容器的hostname与宿主机不一样的。
PID
PID Namespace 用来隔离进程ID。同一个进程在不同的PID Namespace里可以拥有不同的PID,比如每一个container中的1号进程,其实在宿主机上都有自己的进程ID,我们如果要在外部操作它,就需要通过docker inspect
等命令来找到它在宿主机上的ID号,并对其进行操作。
在C语言中,创建新的PID命名空间,需要调用clone()系统函数,并且传入CLONE_NEWPID参数。指定该参数后,子进程无法获取到parent pid的信息,认为自己没有父进程(已经被隔离)。
IPC
IPC Namespace 用来隔离System V IPC和POSIX message queues。每一个IPC Namespace都有自己的System V IPC和POSIX message queue。这一点通过在容器内外分别执行ipcs -a
可以验证。
MOUNT
Mount Namespace 用来隔离各个进程看到的挂载点视图。在不同Namespace的进程中,看到的文件系统层次是不一样的。在Mount Namespace中调用 mount()和 umount()仅仅只影响当前Namespace的文件系统,而对全局的文件系统是没有影响的。
在C语言中,需要在clone()函数中传入CLONE_NEWNS参数。
NET
这块在开篇已经讲到,主要是将新启的进程放到自己的network namespace中,保障与外部网络的隔离性。
根命名空间和子命名空间中的通信,可以通过使用veth来实现,veth创建后默认在根命名空间中,只需要将一个veth的另一端放入对应的子NS中即可。
USER
用来隔离用户和用户组,一个进程的user id和group id在namespace内部与在宿主机上是不同的。比如宿主机上非root用户,在容器内部可以是root用户。
实践
在golang的实现中,我们应该熟悉以下代码:
1 | cmd := exec.Command(initCmd, "init") |
上面代码在创建子进程的时候,就分别指定了各个命名空间的参数,这样创建出的子进程就拥有对应的子命名空间。
Cgroup
如果说namespace主要为docker作了进程间的隔离,那Cgroup就真正为这层隔离加上物理的资源限制。它提供了对一组进程及将来子进程的资源限制、控制和统计的能力,这些资源包括CPU、内存、存储、网络等。
Cgroup主要包含三大组件:
- hierarchy
- 子系统
- 进程组
hierarchy
hierarchy 是把一组cgroup串成一个树状结构,一个这样的树便是一个hierarchy,通过这种树状结构,Cgroups可以做到继承。我们甚至可以将其理解为cgroup文件系统,在挂载了该文件系统的目录下,创建子目录就会有对应的cgroup相关文件生成;而这种目录结构是可以层层继承的,下面hierarchy对应子系统的能力也随着这样的继承关系而层层继承。
子系统
- cpu – 使用调度程序提供对 CPU 的 cgroup 任务访问。
- cpuacct – 自动生成 cgroup 中任务所使用的 CPU 报告。
- cpuset – 为 cgroup 中的任务分配独立 CPU(在多核系统)和内存节点。
- devices – 可允许或者拒绝 cgroup 中的任务访问设备。
- memory – 设定 cgroup 中任务使用的内存限制,并自动生成由那些任务使用的内存资源报告。
- blkio – 为块设备设定输入/输出限制,比如物理设备(磁盘,固态硬盘,USB 等等)。
- net_cls – 使用等级识别符(classid)标记网络数据包,可允许 Linux 流量控制程序(tc)识别从具体 cgroup 中生成的数据包。
- ns – 名称空间子系统。
- freezer – 挂起或者恢复 cgroup 中的任务。
在linux上,这些子系统都对应已经挂载到了对应的目录。假设docker要为某一个容器指定memory的限制,在ubuntu下,docker会直接在/sys/fs/cgrop/memory/docker/下创建目录(名称为容器ID),并在目录下创建对应的限制。
1 | root@vpn:/sys/fs/cgroup# ls /sys/fs/cgroup/ # <=这里是系统所有的子系统 |
进程组
cgroup文件系统中对应每一个子目录,都会自动创建tasks文件,而受该subsystem限制的进程号就逐一列到该task文件中,如下:
1 | root@vpn:/sys/fs/cgroup/cpu/docker/7bdb229c1974123cfa6f408a142bcf496c46c04381087f0f2878c5c61763fe44# ls |
AUFS
这里不讲AUFS的概念,直接讲这个流程:
- 首先,docker启动容器需要有一个镜像,但是这个镜像解压缩后不允许被修改,是readOnly的;
- 接下来,你启动容器后,要修改容器里面的文件,docker就为container创建了另外一个目录,你对镜像做的任何操作的内容都位于这个叠加的文件上;
- 对于container来讲,它看不到后面那些文件是readOnly,哪些是readWrite的,它就像用户一样,其实都在使用这个虚拟的联合文件系统,这就是AUFS。
为了提高效率,AUFS使用了写时复制技术:
写时复制( copy-on-write),也叫隐式共享, 是一种对可修改资源实现高效复制的资源管理技术。它的思想是,如果一个资源是重复的,但没任何修改,这时并不需要立即创建一个新的资源,这个资源可以被新旧实例共享。创建新资源发生在第一次写操作,也就是对资源进行修改的时候。通过这种资源共享的方式,可以显著地减少 未修改资源复制带来的消耗,但是也会在进行资源修改时增加小部分的开销。
具体到docker上,aufs的实现体现在 /var/lib/docker/aufs/
目录下的三个文件夹:1
2root@vpn:/var/lib/docker/aufs# ls
diff layers mnt
容器最终mount的是 mnt 目录下的文件,而readWrite的层都位于 diff 目录中。所有,我们要实现一个commit命令,本质就是将容器位于 mnt 中的文件打包出来。
实践
东拉西扯的讲了一堆核心原理,突然觉得其他也没啥好讲的了,就来看看docker的创建流程吧。
docker run
主进程(也就是runc)
- 这里的创建
父进程
,其实就是container进程,只是首先运行的命令是docker init(该进程启动之后会阻塞,等待从管道读取信息); - 然后docker run 在这个阶段需要为container的1号进程准备各种设置,也就是前面提到的各种namespace,cgroup、aufs等;
- 同样,网络和存储,涉及外部需要准备的资源都需要docker run进程来处理;
container进程(容器1号进程)
- 主要等待主进程ready后,从管道中读取各种配置信息,比如环境变量等;
- 设置根目录,挂载自己的 /proc 和 /tmpfs等,这样才能在内部查询到进程号;
- 执行启动容器中指定的entrypoint(虽然进程号一样,但是之前执行的是docker in
各种架构
DockerD - Containerd - OCI
其实就是我们有一个oci的config文件,然后结合runc就可以运行起来一个容器了。至于containd就是访问runc的一层rpc,它同时实现对image的一些管理功能(比如pull镜像)。
从下图中可以看到,dockerd 还管理了docker-proxy为容器配置网络;而containerd-shim就是runc的实现,每一个容器都有一个container-shim与之一一对应,这样就算dockerd挂了,也不影响container。
1 | [root@qcloud-vm system]# docker -v |
通过上面的关系实践,我们基本了解了整个docker中进程的关系:
- 系统启动的时候启动了docker daemon和containerd两个守护进程;
- 用户docker daemon响应docker client的请求,并下发请求到containerd;
- 如果container指定了端口映射,docker daemon还会启动docker-proxy来做端口映射;
- containerd是通过containerd-shim来运行runc,并真正创建容器的。
CRI-O
k8s代码对pod做了很多抽象,要将pod映射到container需要一层转换(比如现在是运行了dockerService,在里面实现containerManager,Network等的管理),而K8S试图取消中间经过containerd的管理,直接到达CRI,当前已有crictl之类的工具。