学习docker底层原理:资源隔离、资源限制、镜像分层相关技术、容器进程本质、为什么说容器是单进程模型、docker网络工作原理。

Docker架构

从 1.11 版本开始, dockerd 已经成了独立的二进制,此时的容器也不是直接由 dockerd 来启 动了,而是集成了 containerd、runC 等多个组件

runC 是 Docker 官方按照 OCI 容器运行时标准的一个实现。通俗地讲,runC 是一个用来运行容 器的轻量级工具,是真正用来运行容器的。

containerd 是 Docker 服务端的一个核心组件,它是从 dockerd 中剥离出来的 ,它的诞生完全 遵循 OCI 标准,是容器标准化后的产物。 containerd 通过 containerd-shim 启动并管理 runC, 可以说 containerd 真正管理了容器的生命周期。

docker相关二进制

1containerd
2containerd-shim
3ctr
4docker
5docker-init
6docker-proxy
7dockerd
8runc

实验docker 各组件的关系

1docker run -d busybox sleep 3600
2
3ps aux |grep dockerd
4
5sudo pstree -l -a -A  {dockerd进程id}
1dockerd 
2    |-containerd --config /var/run/docker/containerd/containerd.toml --log-level info
3    | |-containerd-shim -namespace moby -workdir /var/lib/docker/containerd/daemon/io.containerd.runtime.v1.linux/moby/d14d205070 73e5743e607efd616571c834f1a914f903db6279b8de4b5ba3a45a -address /var/run/docker/containerd/containerd.sock -containerd-binary /usr/bin/containerd -runtime-root /var/run/docker/runtime-runc 
4    | | |-sleep 3600

Docker隔离:NameSpace

容器技术的一个核心优势就是资源隔离性,也就是 namespace 技术。

1. namespace 简介

namespace 的中文一般翻译成命名空间,

linux 的 namespace 理解成一系列的资源的抽象的集合。每个进程都有一个 namespace 属性,进程的 namespace 可以相同。

对于同属于一个 namespace 中进程,可以感知到彼此的存在和变化,而对外界的进程一无所知.

具体参考:namespace man-page

2. namespace 种类

Linux 内核中提供了 6 中隔离支持,分别是:IPC 隔离、网络隔离、挂载点隔离、进程编号隔离、用户和用户组隔离、主机名和域名隔离。

Namespace flag 隔离内容
IPC CLONE_NEWIPC IPC(信号量、消息队列和共享内存等)隔离
Network CLONE_NEWNET 网络隔离(网络栈、端口等)
Mount CLONE_NEWNS 挂载点(文件系统)
PID CLONE_NEWPID 进程编号
User CLONE_NEWUSER 用户和用户组
UTS CLONE_NEWUTS 主机名和域名

每个进程都有一个 namespace,在 /proc/<pid>/ns 下面,下面是一个示例:

 1[root@xxx ns]# ls -al
 2total 0
 3dr-x--x--x 2 root root 0 Nov  3 16:16 .
 4dr-xr-xr-x 9 root root 0 Nov  3 15:50 ..
 5lrwxrwxrwx 1 root root 0 Nov  3 16:16 ipc -> ipc:[4026531839]
 6lrwxrwxrwx 1 root root 0 Nov  3 16:16 mnt -> mnt:[4026531840]
 7lrwxrwxrwx 1 root root 0 Nov  3 16:16 net -> net:[4026531956]
 8lrwxrwxrwx 1 root root 0 Nov  3 16:16 pid -> pid:[4026531836]
 9lrwxrwxrwx 1 root root 0 Nov  3 16:16 user -> user:[4026531837]
10lrwxrwxrwx 1 root root 0 Nov  3 16:16 uts -> uts:[4026531838]

上图 ipc, mnt, net, pid, user, uts,分别对应6 中隔离技术。

对于我们直接运行宿主机上并且没有做资源隔离的进程,这 6 个 link 文件指向的目标文件也都是一致的。

而对于 docker 进程,ns 目录下的 link 文件和宿主机上的 link 文件是不一样的,也就是说他们属于不同的 namespace 空间。

3. namespace api

Linux 系统提供的系统调用来管中窥豹看一下 namespace 技术的使用细节。系统调用包括:

clone

clone 会创建一个新的进程,函数原型如下:

1#define _GNU_SOURCE
2#include <sched.h>
3
4int clone(int (*fn)(void *), void *child_stack,
5          int flags, void *arg, ...
6          /* pid_t *ptid, void *newtls, pid_t *ctid */ );

几个形参的意思分别是:

  • fn:新的进程执行的函数;
  • child_stack:新的进程的栈空间;
  • flags:表示使用哪些 CLONE_* 标志位,与 namespace 相关的参数主要包括 CLONE_NEWIPC、CLONE_NEWNS、CLONE_NEWNET、CLONE_NEWPID、CLONE_NEWUSERS和CLONE_NEWUTS,分别对应不同的 namespace。

setns

setns() 的函数原型如下:

1#define _GNU_SOURCE             /* See feature_test_macros(7) */
2#include <sched.h>
3
4int setns(int fd, int nstype);

我们可以通过系统调用 setns() 加入到一个已经存成在 namespace 中。这个 api 的一个实际使用例子就是我们执行 docker exec 命令进入到容器内部:在终端执行命令 docker exec 相当于 fork 一个子进程,然后将该进程加入到我们参数指定 docker 进程中,这样我们就得到了和 docker 进程内部一样的隔离视图了。

unshare

unshare() 的函数原型如下:

1#define _GNU_SOURCE
2#include <sched.h>
3
4int unshare(int flags);

unshare 相当于对当前进程进行隔离,我们不需要启动一个新的进程就可以启动隔离的效果。Linux 的 unshare 命令就是基于这个 api 来实现的。

ioctl

ioctl() 的函数原型如下:

1#include <sys/ioctl.h>
2
3int ioctl(int fd, unsigned long request, ...);

其中 fd 是文件描述符,当 fd 指向 ns 文件的时候,我们就可以通过 ioctl 去获取一些 namespace 信息。这个系统调用 Docker 中也没有使用,所以这里限于篇幅,不再展开。感兴趣的同学可以参考这条 manpage:ioctl_ns

4. namespace 代码示例

通过几个代码 demo,来更深入地理解一下 namespace 技术。

首先我们通过 clone 系统调用来创建一个进程隔离的子进程。

 1// 子进程的函数主题
 2int child_fn() {
 3  	// system 函数可以让我们的程序执行 shell 命令
 4    system("mount -t proc proc /proc");
 5    system("ps aux");
 6    printf("child pid: %d\n", getpid());
 7    return 0;
 8}
 9
10int main() {
11  	// 子进程的栈空间大小
12    int CHILD_STACK_SIZE = 1024 * 1024;
13  	
14  	//子进程的栈空间
15    char child_stack[CHILD_STACK_SIZE];
16  
17  	// 创建子进程:
18  	// 1. child_fn 表示子进程的主题是函数 child_fn()
19  	// 2. child_stack + CHILD_STACK_SIZE 表示子进程的栈空间,其实就是局部变量 child_stack
20  	// 3. CLONE_NEWPID 表示子进程使用新的 PID namespace;SIGCHLD 表示接收信号
21    int child_pid = clone(child_fn, child_stack + CHILD_STACK_SIZE, CLONE_NEWPID | SIGCHLD, NULL);
22  
23  	// waitpid 表示父进程等待子进程退出。如果不加这行代码,父进程将会直接退出,子进程就变成了孤儿进程
24    waitpid(child_pid, NULL, 0);
25    return 0;
26}

上面程序中 child_fn 就是我们子进程运行的函数实体。在子进程中我们先执行了 /proc 挂载,这么做的原因是 ps 命令是查看的 /proc 目录,如果我们创建了子进程之后而没有挂载 /proc ,那么看到的还是原来的进程列表。这里我们先进行 /proc 目录挂载,然后执行 ps ,执行结果如下:

1[root@xxx ~]# gcc clone_pid.c
2[root@xxx ~]# ./a.out
3USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
4root         1  0.0  0.0   5068    92 pts/2    S+   17:13   0:00 ./a.out
5root         3  0.0  0.0 151064  1792 pts/2    R+   17:13   0:00 ps aux
6child pid: 1

我们可以看到在进行了进程隔离的子进程空间中一号进程就是我们的子进程,并且看不到其他进程

基于 Linux Namespace 的隔离机制相比于虚拟化技术也有很多不足之处,其中最主要的问题就是:隔离得不彻底。

首先,既然容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个宿主机的操作系统内核。

其次,在 Linux 内核中,有很多资源和对象是不能被 Namespace 化的,最典型的例子就是:时间。

Docker资源限制:cgroup

Docker 中的资源限制技术:CGroups。

Linux Cgroups 的全称是 Linux Control Group,简单来说,CGroups 的作用就是限制一个进程组能够使用的资源上限,CPU,内存、磁盘、网络带宽等等。

核心概念

CGroups 中有几个重要概念:

  • cgroup:通过 CGroups 系统进行限制的一组进程。CGroups 中的资源限制都是以进程组为单位实现的,一个进程可以加入到某个进程组,从而受到相同的资源限制。
  • task:在 CGroups 中,task 可以理解为一个进程。
  • hierarchy:可以理解成层级关系,CGroups 的组织关系就是层级的形式,每个节点都是一个 cgroup。cgroup 可以有多个子节点,子节点默认继承父节点的属性。
  • subsystem:更准确的表述应该是 resource controllers,也就是资源控制器,比如 cpu 子系统负责控制 cpu 时间的分配。子系统必须应用(attach)到一个 hierarchy 上才能起作用。

其中最核心的是 subsystem,CGroups 目前支持的 subsystem 包括:

  • cpu:限制进程的 cpu 使用率;
  • cpuacct:统计 CGroups 中的进程的 cpu 使用情况;
  • cpuset:为 CGroups 中的进程分配单独的 cpu 节点或者内存节点;
  • memory:限制进程的内存使用;
  • devices:可以控制进程能够访问哪些设备;
  • blkio:限制进程的块设备 IO;
  • freezer:挂起或者恢复 CGroups 中的进程;
  • net_cls:标记进程的网络数据包,然后可以使用防火墙或者 tc 模块(traffic controller)控制该数据包。这个控制器只适用从该 cgroup 离开的网络包,不适用到达该 cgroup 的网络包;
  • ns:将不同 CGroups 下面的进程应用不同的 namespace;
  • perf_event:监控 CGroups 中的进程的 perf 事件(注:perf 是 Linux 系统中的性能调优工具);
  • pids:限制一个 cgroup 以及它的子节点中可以创建的进程数目;
  • rdma:限制 cgroup 中可以使用的 RDMA 资源。

通过上面列举出来的 subsystem,我们可以简单的了解到,通过 Linux CGroups 我们可以控制的资源包括:CPU、内存、网络、IO、文件设备等。

使用演示

CGroups 在使用之前需要挂载一下,正常我们使用的系统都应该挂载了,我们可以通过下面的命令查看一下:

 1~]# mount -t cgroup
 2cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd)
 3cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices)
 4cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
 5cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer)
 6cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct,cpu)
 7cgroup on /sys/fs/cgroup/net_cls type cgroup (rw,nosuid,nodev,noexec,relatime,net_cls)
 8cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
 9cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb)
10cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event)
11cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)

我们可以看到 CGroups 是以文件系统的形式组织起来的,为了文件系统目录 /sys/fs/cgroup/ 目录下,其中每个子目录对应一个 subsystem ,或者说资源控制器。我们看一下 cpu 和 memory 子目录中的数据。

1[vagrant@maozhongyu cgroup]$ls /sys/fs/cgroup/cpu
2cgroup.clone_children  cpuacct.usage_percpu  cpu.stat
3cgroup.event_control   cpu.cfs_period_us     notify_on_release
4cgroup.procs           cpu.cfs_quota_us      release_agent
5cgroup.sane_behavior   cpu.rt_period_us      tasks
6cpuacct.stat           cpu.rt_runtime_us
7cpuacct.usage          cpu.shares
 1[vagrant@maozhongyu cgroup]$ls /sys/fs/cgroup/memory
 2cgroup.clone_children               memory.memsw.failcnt
 3cgroup.event_control                memory.memsw.limit_in_bytes
 4cgroup.procs                        memory.memsw.max_usage_in_bytes
 5cgroup.sane_behavior                memory.memsw.usage_in_bytes
 6memory.failcnt                      memory.move_charge_at_immigrate
 7memory.force_empty                  memory.numa_stat
 8memory.kmem.failcnt                 memory.oom_control
 9memory.kmem.limit_in_bytes          memory.pressure_level
10memory.kmem.max_usage_in_bytes      memory.soft_limit_in_bytes
11memory.kmem.slabinfo                memory.stat
12memory.kmem.tcp.failcnt             memory.swappiness
13memory.kmem.tcp.limit_in_bytes      memory.usage_in_bytes
14memory.kmem.tcp.max_usage_in_bytes  memory.use_hierarchy
15memory.kmem.tcp.usage_in_bytes      notify_on_release
16memory.kmem.usage_in_bytes          release_agent
17memory.limit_in_bytes               tasks
18memory.max_usage_in_bytes

除了一些和 cpu 和 memory 特有的文件,这两个 subsystem 有一些共同的文件,比如 tasks 就表示这个 subsystem 控制的进程 id 列表。下面我们以 cpu subsystem 为例来演示一下。

 1[vagrant@maozhongyu cpu]$sudo mkdir hello
 2[vagrant@maozhongyu cpu]$cd hello/
 3[vagrant@maozhongyu hello]$ls
 4cgroup.clone_children  cpuacct.usage_percpu  cpu.shares
 5cgroup.event_control   cpu.cfs_period_us     cpu.stat
 6cgroup.procs           cpu.cfs_quota_us      notify_on_release
 7cpuacct.stat           cpu.rt_period_us      tasks
 8cpuacct.usage          cpu.rt_runtime_us
 9[vagrant@maozhongyu hello]$pwd
10/sys/fs/cgroup/cpu/hello

从上面的截图我们可以发现,创建完 hello 文件夹之后,系统为我们自动创建了一些 cgroup 相关的文件,比如 cpu.cfs_period_uscpu.cfs_quota_us 表示进程在长度为 cfs_period 的一段时间内只能被分配到总量为 cfs_quota 的 CPU 时间。cpu.cfs_period_us 默认值为 100000,也就是 100000 us;

1[vagrant@maozhongyu hello]$cat cpu.cfs_period_us
2100000
3[vagrant@maozhongyu hello]$cat cpu.cfs_quota_us
4-1

这个时候我们启动 for 循环的脚本把 cpu 打满。

1[vagrant@maozhongyu hello]$while : ; do : ; done &
2[1] 5310
3[vagrant@maozhongyu hello]$top -p 5310

然后我们通过命令 top -p 5277 查看这个进程的资源使用情况,CPU 确实是被打满了。

下面我们将该进程加入到我们之前建的 hello 那个 cpu cgroup 里面。我们首先将 hello cpu cgroup 的 cpu.cfs_quota_us 改完 50000,相当于 cpu.cfs_period_us 的一半,这样理论上就可以将 cpu 的使用率限制到 50% 了。我们试试。其中第二行将进程 id 写入到 cgroup 的 tasks 文件中。

1[root@maozhongyu hello]#echo 50000 > cpu.cfs_quota_us
2[root@maozhongyu hello]# echo 5310 > tasks

下面我再使用 top -p 5310 查看进程 5310 的资源使用情况如下,我们可以看到 CPU 使用率在 50%左右,基本等于一半,符合预期。

1PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
2 5310 root      20   0  115440    596    168 R  48.5  0.1   0:44.69 bash

除 CPU 子系统外,Cgroups 的每一项子系统都有其独有的资源限制能力,比如:

  • blkio,为块设备设定I/O 限制,一般用于磁盘等设备;
  • cpuset,为进程分配单独的 CPU 核和对应的内存节点;
  • memory,为进程设定内存使用的限制。

Docker 使用 CGroup

我们可以在 docker run 命令启动容器的时候指定 cgroup

1[root@docker ~]# docker run --help | grep cpu
2      --cpu-period int                 Limit CPU CFS (Completely Fair Scheduler) period
3      --cpu-quota int                  Limit CPU CFS (Completely Fair Scheduler) quota
4      --cpu-rt-period int              Limit CPU real-time period in microseconds
5      --cpu-rt-runtime int             Limit CPU real-time runtime in microseconds
6  -c, --cpu-shares int                 CPU shares (relative weight)
7      --cpus decimal                   Number of CPUs
8      --cpuset-cpus string             CPUs in which to allow execution (0-3, 0,1)
9      --cpuset-mems string             MEMs in which to allow execution (0-3, 0,1)

支持 memory 限制如下。

1[root@docker ~]# docker run --help | grep memory
2      --kernel-memory bytes            Kernel memory limit
3  -m, --memory bytes                   Memory limit
4      --memory-reservation bytes       Memory soft limit
5      --memory-swap bytes              Swap limit equal to memory plus swap: '-1' to enable unlimited swap
6      --memory-swappiness int          Tune container memory swappiness (0 to 100) (default -1)

其实装了docker的机器,在每个 subsystem 下面都有一个 docker 目录,没错,docker 目录下面就是我们机器上面运行的 docker 进程。

其中的那一串字符对应就是 container id,我们可以通过 docker ps 查看。

我们进入到其中一个子目录。

还记得我们前面说的 tasks 文件是该 cgroup 包含的进程吧,我们查看一下。

1 docker top f2bd424

1~]#  ps -ef | grep 1743  

显然 tasks 中的进程 ID 就是 docker 进程对应到宿主机上面的进程 ID。

Linux Cgroups 的设计还是比较易用的,简单粗暴地理解呢,它就是一个子系统目录加上一组资源限制文件的组合。而对于 Docker 等 Linux 容器项目来说,它们只需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后,把这个进程的 PID 填写到对应控制组的 tasks 文件中就可以了。

而至于在这些控制组下面的资源文件里填上什么值,就靠用户执行 docker run 时的参数指定了,比如这样一条命令:

1$ docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash

在启动这个容器后,我们可以通过查看 Cgroups 文件系统下,CPU 子系统中,“docker”这个控制组里的资源限制文件的内容来确认:

1$ cat /sys/fs/cgroup/cpu/docker/5d5c9f67d/cpu.cfs_period_us 
2100000
3$ cat /sys/fs/cgroup/cpu/docker/5d5c9f67d/cpu.cfs_quota_us 
420000

这就意味着这个 Docker 容器,只能使用到 20% 的 CPU 带宽。

Linux 下的 /proc 目录存储的是记录当前内核运行状态的一系列特殊文件,用户可以通过访问这些文件,查看系统以及当前正在运行的进程的信息,比如 CPU 使用情况、内存占用率等,这些文件也是 top 指令查看系统信息的主要数据来源。

但是,你如果在容器里执行 top 指令,就会发现,它显示的信息居然是宿主机的 CPU 和内存数据,而不是当前容器的数据。

造成这个问题的原因就是,/proc 文件系统并不知道用户通过 Cgroups 给这个容器做了什么样的资源限制,即:/proc 文件系统不了解 Cgroups 限制的存在。

镜像分层技术

分层结构

1[root@docker ~]# docker image inspect busybox:latest
2...
3"RootFS": {
4     "Type": "layers",
5     "Layers": [
6          "sha256:195be5f8be1df6709dafbba7ce48f2eee785ab7775b88e0c115d8205407265c5"
7      ]
8 },

RootFS 就是镜像 busybox:latest 的镜像层,只有一层

上面命令还会输出 GraphDriver

1"GraphDriver": {
2            "Data": {
3                "LowerDir": "/var/lib/docker/overlay2/cd7a.../diff",
4                "MergedDir": "/var/lib/docker/overlay2/da4c.../merged",
5                "UpperDir": "/var/lib/docker/overlay2/da4c../diff",
6                "WorkDir": "/var/lib/docker/overlay2/da4c.../work"
7            },
8            "Name": "overlay2"
9        },

GraphDriver 负责镜像本地的管理和存储以及运行中的容器生成镜像等工作,可以将 GraphDriver 理解成镜像管理引擎,我们这里的例子对应的引擎名字是 overlay2(overlay 的优化版本)。除了 overlay 之外,Docker 的 GraphDriver 还支持 btrfsaufsdevicemappervfs 等。

正常情况下很多镜像都是由多层组成的。

镜像和容器在存储上的主要差别就在于容器多了一个读写层。镜像由多个只读层组成,通过镜像启动的容器在镜像之上加了一个读写层。

docker commit 命令基于运行时的容器生成新的镜像,那么 commit 做的其中一个工作就是将读写层数据写入到新的镜像中

Container最上面是一个可写的容器层,以及若干只读的镜像层组成,Container的数据就存放在这些层中,这样的分层结构最大的特性是Copy-On-Write(写时复制):

1、新数据会直接存放在最上面的Container层。

2、修改现有的数据会先从Image层将数据复制到容器层,修改后的数据直接保存在Container层,Image层保持不变。

由此可以看出,每个步骤都将创建一个imgid, 一直追溯到我们的base镜像的id 。关于的部分,则不在本机上。 最后一列是每一层的大小。最后一层只是启动bash,所以没有文件变更,大小是0 。我们创建的镜像是在base镜像之上的,并不是完全复制一份base,然后修改,而是共享base的内容。这时候,如果我们新建一个新的镜像,同样也是共享base镜像。 那修改了base镜像,会不会导致我们创建的镜像也被修改呢? 不会!因为不允许修改历史镜像,只允许修改容器,而容器只可以在最上面的容器层进行 写和变更

所有写入或者修改运行时容器的数据都会存储在读写层,当容器停止运行的时候,读写层的数据也会被同时删除掉。因为镜像层的数据是只读的,所有如果我们运行同一个镜像的多个容器副本,那么多个容器则可以共享同一份镜像存储层 (要保留数据,那要用数据卷)

UnionFS

Docker 的存储驱动的实现是基于 Union File System,简称 UnionFS,中文可以叫做联合文件系统。UnionFS 设计将其他文件系统联合到一个联合挂载点的文件系统服务。

所谓联合挂载技术,是指在同一个挂载点同时挂载多个文件系统,将挂载点的源目录与被挂载内容进行整合,使得最终可见的文件系统将会包含整合之后的各层的文件和目录

举个例子:比如我们运行一个 ubuntu 的容器。由于初始挂载时读写层为空,所以从用户的角度来看:该容器的文件系统与底层的 rootfs 没有区别;然而从内核角度来看,则是显式区分的两个层

当需要修改镜像中的文件时,只对处于最上方的读写层进行改动,不会覆盖只读层文件系统的内容,只读层的原始文件内容依然存在,但是在容器内部会被读写层中的新版本文件内容隐藏。当 docker commit 时,读写层的内容则会被保存。

写时复制(Copy On Write)

Linux 系统内核启动时首先挂载的 rootfs 是只读的,在系统正式工作之后,再将其切换为读写模式。Docker 容器启动时文件挂载类似 Linux 内核启动的方式,将 rootfs 设置为只读模式。不同之处在于:在挂载完成之后,利用上面提到的联合挂载技术在已有的只读 rootfs 上再挂载一个读写层

读写层位于 Docker 容器文件系统的最上层,其下可能联合挂载多个只读层,只有在 Docker 容器运行过程中文件系统发生变化时,才会把变化的文件内容写到可读写层,并隐藏只读层的老版本文件,这就叫做 写实复制,简称 CoW

AUFS

AUFS 是 UnionFS 的一种实现,全称为 Advanced Multi-Layered Unification Filesystem,是早期 Docker 版本默认的存储驱动,最新的 Docker 版本默认使用 OverlayFS。

AUFS 将镜像层(只读)组织成多个目录,在 AUFS 的术语中成为 branch。运行时容器文件会作为一层容器层(container lay,读写)覆盖在镜像层之上。最后通过联合挂载技术进行呈现。

OverlayFS

OverlayFS 是类似 AUFS 的联合文件系统的一种实现,相比 AUFS 性能更好,实现更加简单。Docker 针对 OverlayFS 提供了两种存储驱动:overlayoverlay2

overlay2 在 inode 使用率上更加高效,所以一般也是推荐 overlay2,Linux 内核版本要求是 4.0 或者更高版本

OverlayFS 将镜像层(只读)称为 lowerdir,将容器层(读写)称为 upperdir,最后联合挂载呈现出来的为 mergedir。文件层次结构可以用下图表示

busybox 容器的 docker inspect 的结果。

1"GraphDriver": {
2            "Data": {
3                "LowerDir": "/var/lib/docker/overlay2/cd7a.../diff",
4                "MergedDir": "/var/lib/docker/overlay2/da4c.../merged",
5                "UpperDir": "/var/lib/docker/overlay2/da4c../diff",
6                "WorkDir": "/var/lib/docker/overlay2/da4c.../work"
7            },
8            "Name": "overlay2"
9        },

我们在容器中做的改动,都会在 upperdirmergeddir 中体现。比如我们在容器中的 /tmp 目录下新建一个文件,那么在 upperdirmergeddir 中就能够看到该文件。

容器本质是进程

Docker的本质是进程

理解容器的本质最简单的方式就是类比。

  • 进程是程序的运行实体;
  • 容器是镜像的运行实体。

镜像和程序的角色是一样的,只不过镜像要比程序更加的丰富。程序只是按简单的格式存储在文件系统中,而镜像是按层,以联合文件系统的方式存储

容器和进程的角色也是类似的,只不过容器相比于普通进程多了更多地附加属性。

既然容器也是进程,那么它一定也有进程号。

docker top <container-id> 可以看到容器的进程号

1[root@xxx ~]# docker top d3973eb73bec
2UID                 PID                 PPID                C                   STIME               TTY                 TIME                CMD
3root                25533               25514               0                   Jun25               ?                   00:00:00            /http-server
4[root@xxx ~]# ps aux | grep 25533
5root      7008  0.0  0.0 112716   964 pts/0    S+   20:26   0:00 grep --color=auto 25533
6root     25533  0.0  0.0 707104  2564 ?        Ssl  Jun25   0:00 /http-server

http-server 容器对应的操作系统进程号就为 25533 号进程。为了更加直接的感受一下容器是一种进程,我们可以看一下 /proc/<process-id> 这个目录

 1[root@xxx proc]# ls
 2....
 3[root@emr-header-1 proc]# cd 25533
 4[root@emr-header-1 25533]# ls
 5attr       clear_refs       cpuset   fd       limits     mem         net        oom_score      personality  schedstat  stack   syscall  wchan
 6autogroup  cmdline          cwd      fdinfo   loginuid   mountinfo   ns         oom_score_adj  projid_map   sessionid  stat    task
 7auxv       comm             environ  gid_map  map_files  mounts      numa_maps  pagemap        root         setgroups  statm   timers
 8cgroup     coredump_filter  exe      io       maps       mountstats  oom_adj    patch_state    sched        smaps      status  uid_map
 9[root@xxx 25533]# ls -al ns
10total 0
11dr-x--x--x 2 root root 0 Jun 25 09:40 .
12dr-xr-xr-x 9 root root 0 Jun 25 09:40 ..
13lrwxrwxrwx 1 root root 0 Jun 26 20:29 ipc -> ipc:[4026532462]
14lrwxrwxrwx 1 root root 0 Jun 26 20:29 mnt -> mnt:[4026532460]
15lrwxrwxrwx 1 root root 0 Jun 25 09:40 net -> net:[4026532524]
16lrwxrwxrwx 1 root root 0 Jun 26 20:29 pid -> pid:[4026532463]
17lrwxrwxrwx 1 root root 0 Jun 26 20:29 user -> user:[4026531837]
18lrwxrwxrwx 1 root root 0 Jun 26 20:29 uts -> uts:[4026532461]
19[root@xxx 25533]# cat cgroup
2011:cpuset:/docker/d3973eb73bec5e62bf47710d8607a87ce27973c3dcd653b39eae41da25564d4d
2110:hugetlb:/docker/d3973eb73bec5e62bf47710d8607a87ce27973c3dcd653b39eae41da25564d4d
229:perf_event:/docker/d3973eb73bec5e62bf47710d8607a87ce27973c3dcd653b39eae41da25564d4d
238:pids:/docker/d3973eb73bec5e62bf47710d8607a87ce27973c3dcd653b39eae41da25564d4d
247:freezer:/docker/d3973eb73bec5e62bf47710d8607a87ce27973c3dcd653b39eae41da25564d4d
256:memory:/docker/d3973eb73bec5e62bf47710d8607a87ce27973c3dcd653b39eae41da25564d4d
265:net_prio,net_cls:/docker/d3973eb73bec5e62bf47710d8607a87ce27973c3dcd653b39eae41da25564d4d
274:devices:/docker/d3973eb73bec5e62bf47710d8607a87ce27973c3dcd653b39eae41da25564d4d
283:blkio:/docker/d3973eb73bec5e62bf47710d8607a87ce27973c3dcd653b39eae41da25564d4d
292:cpuacct,cpu:/docker/d3973eb73bec5e62bf47710d8607a87ce27973c3dcd653b39eae41da25564d4d
301:name=systemd:/docker/d3973eb73bec5e62bf47710d8607a87ce27973c3dcd653b39eae41da25564d4d

http-server 这个容器作为操作系统的进程的一些基本信息,比如 ns 目录就对应 6 个不同的 namespace,而 cgroup 则对应 11 种不同的 cgroup。

容器是个单进程模型

一号进程,虚拟机中是 systemd 进程,容器中是 entrypoint 启动进程,然后所有的其他线程都是一号进程的子进程,或者子进程的子进程,递归下去。这里的主要差异就体现在 systemd 进程对僵尸进程回收的能力。

僵尸进程

1号进程 systemd进程

 1[root@emr-header-1 ~]# ps aux
 2USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
 3root         1  0.1  0.0 190992  3568 ?        Ss   Mar16 289:04 /usr/lib/systemd/systemd --switched-root --system --de
 4root         2  0.0  0.0      0     0 ?        S    Mar16   0:05 [kthreadd]
 5root         3  0.0  0.0      0     0 ?        S    Mar16  13:01 [ksoftirqd/0]
 6root         5  0.0  0.0      0     0 ?        S<   Mar16   0:00 [kworker/0:0H]
 7root         7  0.0  0.0      0     0 ?        S    Mar16  14:41 [migration/0]
 8root         8  0.0  0.0      0     0 ?        S    Mar16   0:00 [rcu_bh]
 9root         9  0.0  0.0      0     0 ?        S    Mar16 243:19 [rcu_sched]
10root        10  0.0  0.0      0     0 ?        S    Mar16   0:50 [watchdog/0]
11root        11  0.0  0.0      0     0 ?        S    Mar16   0:39 [watchdog/1]
12root        12  0.0  0.0      0     0 ?        S    Mar16  23:51 [migration/1]
13root        13  0.0  0.0      0     0 ?        S    Mar16  15:44 [ksoftirqd/1]
14root        15  0.0  0.0      0     0 ?        S<   Mar16   0:00 [kworker/1:0H]

我们可以看到排在第一位的就是前面说到的 1 号进程 systemd。其中的 STAT 那一列就是进程状态,这里的状态都是和 S 有关的,但是正常还有 R、D、Z 等状态。各个状态的含义简单描述如下:

  • S : Interruptible Sleep,中文可以叫做可中断的睡眠状态,表示进程因为等待某个资源或者事件就绪而被系统暂时挂起。当资源或者事件 Ready 的时候,进程轮转到 R 状态;
  • R : 也就是 Running,有时候也可以指代 Runnable,表示进程正在运行或者等待运行;
  • Z : Zombie,也就是僵尸进程。我们知道每个进程都是会占用一定的资源的,比如 pid 等,如果进程结束,资源没有被回收就会变成僵尸进程;
  • D : Disk Sleep,也就是 Uninterruptible Sleep,不可中断的睡眠状态,一般是进程在等待 IO 等资源,并且不可中断。D 状态相信很多人在实践中第一次接触就是 ps 卡住。D 状态一般在 IO 等资源就绪之后就会轮转到 R 状态,如果进程处于 D 状态比较久,这个时候往往是 IO 出现问题,解决办法大部分情况是重启机器;
  • I : Idle,也就是空闲状态,不可中断的睡眠的内核线程。和 D 状态进程的主要区别是可能实际上不会造成负载升高。

对于正常的使用情况,子进程的创建一般需要父进程通过系统调用 wait() 或者 waitpid() 来等待子进程结束,从而回收子进程的资源。除了这种方式外,还可以通过异步的方式来进行回收,这种方式的基础是子进程结束之后会向父进程发送 SIGCHLD 信号,基于此父进程注册一个 SIGCHLD 信号的处理函数来进行子进程的资源回收就可以了。

僵尸进程的最大危害是对资源的一种永久性占用,比如进程号,系统会有一个最大的进程数 n 的限制,也就意味一旦 1 到 n 进程号都被占用,系统将不能创建任何进程和线程(进程和线程对于 OS 而言,使用同一种数据结构来表示,task_struct)。这个时候对于用户的一个直观感受就是 shell 无法执行任何命令,这个原因是 shell 执行命令的本质是 fork。

 1[root@emr-header-1 ~]# ulimit -a
 2core file size          (blocks, -c) 0
 3data seg size           (kbytes, -d) unlimited
 4scheduling priority             (-e) 0
 5file size               (blocks, -f) unlimited
 6pending signals                 (-i) 63471
 7max locked memory       (kbytes, -l) 64
 8max memory size         (kbytes, -m) unlimited
 9open files                      (-n) 131070
10pipe size            (512 bytes, -p) 8
11POSIX message queues     (bytes, -q) 819200
12real-time priority              (-r) 0
13stack size              (kbytes, -s) 8192
14cpu time               (seconds, -t) unlimited
15max user processes              (-u) 63471
16virtual memory          (kbytes, -v) unlimited
17file locks                      (-x) unlimited

孤儿进程

前面说到如果子进程先于父进程退出,并且父进程没有对子进程残留的资源进行回收的话将会产生僵尸进程。

另外一种情况,父进程先于子进程退出的话,那么子进程的资源谁来回收呢 ?

父进程先于子进程退出,这个时候我们一般将还在运行的子进程称为孤儿进程,但是实际上孤儿进程并没有一个明确的定义,他的状态还是处于上面讨论的几种进程状态中。那么孤儿进程的资源谁来回收呢?类 Unix 系统针对这种情况会将这些孤儿进程的父进程置为 1 号进程也就是 systemd 进程,然后由 systemd 来对孤儿进程的资源进行回收。

单进程模型的本质

上面讲的是 虚拟机或者一个完整的 OS 是如何避免僵尸进程的。

但是,在容器中,1 号进程一般是 entry point 进程,针对上面这种 将孤儿进程的父进程置为 1 号进程进而避免僵尸进程 处理方式,容器是处理不了的。进而就会导致容器中在孤儿进程这种异常场景下僵尸进程无法彻底处理的窘境。

所以说,容器的单进程模型的本质其实是容器中的 1 号进程并不具有管理多进程、多线程等复杂场景下的能力。如果一定在容器中处理这些复杂情况的,那么需要开发者对 entry point 进程赋予这种能力。

k8s是如何避免僵尸进程

开发者自己赋予 entrypoint 进程管理多进程的能力,这里我更推荐借助Kubernetes来做这件事情。

k8s 中可以将多个容器编排到一个 pod 里面,共享同一个 Linux NameSpace。这项技术的本质是使用 k8s 提供一个 pause 镜像,展开来说就是先用 pause 镜像实例化出 NameSpace,然后其他容器加入这个 NameSpace 从而实现 NameSpace 共享

pause 是 k8s 在 1.10 版本引入的技术,要使用 pause,我们只需要在 pod 创建的 yaml 中指定 shareProcessNamespace 参数为 true,如下。

 1apiVersion: v1
 2kind: Pod
 3metadata:
 4  name: nginx
 5spec:
 6  shareProcessNamespace: true
 7  containers:
 8  - name: nginx
 9    image: nginx
10  - name: shell
11    image: busybox
12    securityContext:
13      capabilities:
14        add:
15        - SYS_PTRACE
16    stdin: true
17    tty: true

创建 pod

1kubectl apply -f share-process-namespace.yaml

attach 到 pod 中,ps 查看进程列表。

1/ # ps ax
2PID   USER     TIME  COMMAND
3    1 root      0:00 /pause
4    8 root      0:00 nginx: master process nginx -g daemon off;
5   14 101       0:00 nginx: worker process
6   15 root      0:00 sh
7   21 root      0:00 ps ax

我们可以看到 pod 中的 1 号进程变成了 /pause,其他容器的 entrypoint 进程都变成了 1 号进程的子进程。这个时候开始逐渐逼近事情的本质了:/pause 进程是如何处理 将孤儿进程的父进程置为 1 号进程进而避免僵尸进程 的呢?

 1#define STRINGIFY(x) #x
 2#define VERSION_STRING(x) STRINGIFY(x)
 3
 4#ifndef VERSION
 5#define VERSION HEAD
 6#endif
 7
 8static void sigdown(int signo) {
 9  psignal(signo, "Shutting down, got signal");
10  exit(0);
11}
12
13static void sigreap(int signo) {
14  while (waitpid(-1, NULL, WNOHANG) > 0)
15    ;
16}
17
18int main(int argc, char **argv) {
19  int i;
20  for (i = 1; i < argc; ++i) {
21    if (!strcasecmp(argv[i], "-v")) {
22      printf("pause.c %s\n", VERSION_STRING(VERSION));
23      return 0;
24    }
25  }
26
27  if (getpid() != 1)
28    /* Not an error because pause sees use outside of infra containers. */
29    fprintf(stderr, "Warning: pause should be the first process\n");
30
31  if (sigaction(SIGINT, &(struct sigaction){.sa_handler = sigdown}, NULL) < 0)
32    return 1;
33  if (sigaction(SIGTERM, &(struct sigaction){.sa_handler = sigdown}, NULL) < 0)
34    return 2;
35  if (sigaction(SIGCHLD, &(struct sigaction){.sa_handler = sigreap,
36                                             .sa_flags = SA_NOCLDSTOP},
37                NULL) < 0)
38    return 3;
39
40  for (;;)
41    pause();
42  fprintf(stderr, "Error: infinite loop terminated\n");
43  return 42;
44}

这种方式的基础是子进程结束之后会向父进程发送 SIGCHLD 信号,基于此父进程注册一个 SIGCHLD 信号的处理函数来进行子进程的资源回收就可以了

SIGCHLD 信号的处理函数核心就是这一行 while (waitpid(-1, NULL, WNOHANG) > 0) ,其中 WNOHANG 参数是为了让父进程直接返回不阻塞。

除了这种方式外,还可以通过异步的方式来进行回收

docker网络工作原理

docker0网桥(Bridge)

我们在 Linux 宿主机上面启动了 Docker Daemon 进程之后,通过 ifconfig 查看,会发现多了一个叫 docker0 的网卡,这个就是 docker0 网桥。

1[root@docker ~]# ifconfig
2docker0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
3        inet 172.17.0.1  netmask 255.255.0.0  broadcast 172.17.255.255
4        ether 02:42:3e:ce:27:81  txqueuelen 0  (Ethernet)
5        RX packets 3643  bytes 311618 (304.3 KiB)
6        RX errors 0  dropped 0  overruns 0  frame 0
7        TX packets 3017  bytes 3388653 (3.2 MiB)
8        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

网桥就是早期的两端口二层网络设备,用来连接不同的局域网,对数据包进行存储、转发操作

docker0 网桥连接的就是容器网段和宿主机网段

docker0 网桥是在 Docker Daemon 启动的时候自动创建的,从我们上面的结果 (inet 和 netmask) 可以看出来 docker0 的 IP 为 172.17.0.1/16。之后使用 bridge 模式(默认)创建出来的 Docker 容器都将在 docker0 子网的范围内选取一个未被占用的 IP 使用,并连接到 docker0 网桥上。

docker0 网桥的 IP 地址和子网范围是可以通过参数修改的,使用 CIDR 的格式,--bip=CIDR

在 Linux 系统中,我们可以通过 brctl 命令来查看网桥的信息(如果提示找不到命令,需要先安装 bridge-utils 软件包)

网桥上面连接了很多了 veth 设备,同时 veth 设备总是成对出现的,那么也就意味着 veth 的另一端连接的是容器的 eth0

iptables

iptables 可以简单理解为是一个命令行防火墙(firewall)工具,我们可以设置一些 iptables 规则来达到流量控制。Docker 会在宿主机系统上增加一些 iptables 规则,以用来管理 Docker 容器和容器之间以及和外界的通信。

iptables-save 命令来查看一下我的这台虚拟机(运行着多个 Docker 容器)上面的 iptable 规则情况

 1# iptables-save 
 2[root@docker ~]# iptables-save
 3# Generated by iptables-save v1.4.21 on Sun Mar 29 14:28:38 2020
 4*nat
 5:PREROUTING ACCEPT [904001:54226848]
 6:INPUT ACCEPT [904000:54226788]
 7:OUTPUT ACCEPT [60846644:3691707360]
 8:POSTROUTING ACCEPT [60846645:3691707420]
 9:DOCKER - [0:0]
10-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
11-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
12-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
13-A POSTROUTING -s 172.17.0.4/32 -d 172.17.0.4/32 -p tcp -m tcp --dport 80 -j MASQUERADE
14-A POSTROUTING -s 172.17.0.5/32 -d 172.17.0.5/32 -p tcp -m tcp --dport 6379 -j MASQUERADE
15-A POSTROUTING -s 172.17.0.6/32 -d 172.17.0.6/32 -p tcp -m tcp --dport 5000 -j MASQUERADE
16-A DOCKER -i docker0 -j RETURN
17-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.4:80
18-A DOCKER ! -i docker0 -p tcp -m tcp --dport 6379 -j DNAT --to-destination 172.17.0.5:6379
19-A DOCKER ! -i docker0 -p tcp -m tcp --dport 5000 -j DNAT --to-destination 172.17.0.6:5000
20COMMIT
21# Completed on Sun Mar 29 14:28:38 2020
22# Generated by iptables-save v1.4.21 on Sun Mar 29 14:28:38 2020
23*filter
24:INPUT ACCEPT [450195298:73092369567]
25:FORWARD DROP [0:0]
26:OUTPUT ACCEPT [802081724:168977653504]
27:DOCKER - [0:0]
28:DOCKER-ISOLATION-STAGE-1 - [0:0]
29:DOCKER-ISOLATION-STAGE-2 - [0:0]
30:DOCKER-USER - [0:0]
31-A FORWARD -j DOCKER-USER
32-A FORWARD -j DOCKER-ISOLATION-STAGE-1
33-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
34-A FORWARD -o docker0 -j DOCKER
35-A FORWARD -i docker0 ! -o docker0 -j ACCEPT
36-A FORWARD -i docker0 -o docker0 -j ACCEPT
37-A DOCKER -d 172.17.0.4/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 80 -j ACCEPT
38-A DOCKER -d 172.17.0.5/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 6379 -j ACCEPT
39-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 5000 -j ACCEPT
40-A DOCKER-ISOLATION-STAGE-1 -i docker0 ! -o docker0 -j DOCKER-ISOLATION-STAGE-2
41-A DOCKER-ISOLATION-STAGE-1 -j RETURN
42-A DOCKER-ISOLATION-STAGE-2 -o docker0 -j DROP
43-A DOCKER-ISOLATION-STAGE-2 -j RETURN
44-A DOCKER-USER -j RETURN
45COMMIT
46# Completed on Sun Mar 29 14:28:38 2020
47[root@docker ~]#

iptables 默认有 4 个表:

  • nat:地址转换表;
  • filter:数据过滤表;
  • raw:状态跟踪表;
  • mangle:包标记表。

这里的输出只有 nat 表和 filter 表。其中 nat 表中有一条规则如下:

1-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE

这条规则的含义定义了 Docker 容器和外界的通信,含义是将源地址为 172.17.0.0/16 (docker0 网桥的子网,也就是 Docker 容器发出的数据) 的数据包,当不是从 docker0 网卡发出时做 SNAT 转换。

SNAT 的意思是源地址转换,将 IP 包的源地址转换为相应网卡的地址。这条规则的作用是当我们从 Docker 容器访问外网时,在外边看来就是从宿主机上发出的,外部对于 Docker 容器无感知。

上面这条规则定义 Docker 容器访问外部,那么从外部访问 Docker 容器服务时,是怎么处理的呢?

1*nat
2...
3-A DOCKER ! -i docker0 -p tcp -m tcp --dport 6379 -j DNAT --to-destination 172.17.0.5:6379
4...
5*filter
6...
7-A DOCKER -d 172.17.0.5/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 6379 -j ACCEPT
8...

其中 nat 表中的规则含义是将访问宿主机的 6379 端口流量转发到 172.17.0.5 的 6379 端口上。(redis容器做了端口映射到宿主机到6379端口上)

外界访问 Docker 容器是通过 iptables 做 DNAT 实现的。DNAT 将 SNAT 中的 Source 换成 Destination,表示目的地址转换。

filter 表中的规则用来对流量做限制,这里的这条规则表示允许所有的外部 IP 访问容器,可以通过在 filter 的 Docker 链上添加规则来对外部的 IP 访问做出限制

不光是与外界通信,Docker 容器之间通信也受到 iptables 规则限制

默认Docker容器都位于 docker0 网桥的子网内。同时我们从 iptables 中的输出看到一条 filter 规则。

1-A FORWARD -i docker0 -o docker0 -j ACCEPT

这条规则保证容器之间可以互相通信,如果将 Docker Server 启动参数 --icc 设置为 false,则这条规则会被设置为 DROP,容器之间的相互通信就会被禁止。

IP-Forward

在 Docker 容器网络通信的过程中,还涉及到数据包在多个网卡间的转发,这需要将内核参数 ip-forward 打开,参数位于 /proc/sys/net/ipv4/ip_forward。

1[root@docker ~]# cat /proc/sys/net/ipv4/ip_forward
21

Docker server 启动的时候默认会将 ip-forward 设置为 1。(通常这一步不需要我们手动来设置)

DNS 和主机名

容器的主机名以及 DNS 是设置在文件 /etc/hostname、/etc/hosts、/etc/resolv.conf 中的,对于容器来说,在容器启动后会覆盖这些文件从而达到修改属性的目的。

1[root@docker ~]# docker exec -ti 4be4cca01392 sh
2/ # mount
3...
4/dev/vda1 on /etc/hostname type ext4 (rw,relatime,data=ordered)
5/dev/vda1 on /etc/hosts type ext4 (rw,relatime,data=ordered)
6/dev/vda1 on /etc/resolv.conf type ext4 (rw,relatime,data=ordered)
7...

也可以通过参数 -h HOSTNAME--dns=IP_ADDRESS… 来对 hostname 和 DNS 进行设置

docker学习之namespace

深入剖析 Kubernetes

跟 BAT 技术专家学 Docker + K8S