Namespace

我们首先创建一个容器来试试。

  1. [root@tengxunyun1412 ~]# docker images
  2. REPOSITORY TAG IMAGE ID CREATED SIZE
  3. docker-sb-ec latest 9ac4e7222986 13 days ago 672MB
  4. mycentos v1.0 047f4d09c048 13 days ago 231MB
  5. tomcat 9.0.54-jre8-openjdk 07ec6fa0983a 2 weeks ago 294MB
  6. tomcat latest 4ce9babdd885 2 weeks ago 680MB
  7. nginx latest 87a94228f133 2 weeks ago 133MB
  8. centos latest 5d0da3dc9764 6 weeks ago 231MB
  9. java 8 d23bdf5b1b1b 4 years ago 643MB
  10. [root@tengxunyun1412 ~]# docker run -d docker-sb-ec
  11. 9ea1cf2d86df6e076e0aab99ebc41ff8ff7c0fc089f633f6a41afef0cd91af62
  12. [root@tengxunyun1412 ~]# docker ps -a
  13. CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
  14. 9ea1cf2d86df docker-sb-ec "java -jar app.jar -…" 4 seconds ago Up 2 seconds flamboyant_kalam
  15. [root@tengxunyun1412 ~]# docker exec -it 9ea1cf2d86df /bin/bash
  16. root@9ea1cf2d86df:/apps/deploy# ps -ef
  17. UID PID PPID C STIME TTY TIME CMD
  18. root 1 0 13 13:20 ? 00:00:09 java -jar app.jar --server.port=8082
  19. root 36 0 0 13:20 pts/0 00:00:00 /bin/bash
  20. root 51 36 0 13:21 pts/0 00:00:00 ps -ef
  21. root@9ea1cf2d86df:/apps/deploy#

可以看到,我们在 Docker 里最开始执行的java -jar app.jar,就是这个容器内部的第 1 号进程 (PID=1),而这个容器里一共只有三个进程在运行。这就意味着,这三个进程已经被Docker隔离在了一个跟宿主机完全不同的世界当中。这究竟是怎么做到呢?本来,每当我们在宿主机上运行了一个java -jar app.jar程序,操作系统都会给它分配一个进程编号,比如 PID=100。这个编号是进程的唯一标识,就像员工的工牌一样。而现在,我们要通过Docker把这个java -jar app.jar程序运行在一个容器当中。这时候,Docker就会在这个第100号员工入职时给他施一个“障眼法”,让他永远看不到前面的其他99个员工,他就会错误地以为自己就是公司里的第1号员工。这种机制,其实就是对被隔离应用的进程空间做了手脚,使得这些进程只能看到重新计算过的进程编号。可实际上,他们在宿主机的操作系统里,还是原来的第100号进程。这种技术,就是 Linux 里面的 Namespace 机制。
除了我们刚刚用到的 PID Namespace,Linux操作系统还提供了 Mount、UTS、 IPC、Network 和 User 这些 Namespace,用来对各种不同的进程上下文进行“障眼 法”操作。 比如,Mount Namespace,用于让被隔离进程只看到当前 Namespace 里的挂载点信息;Network Namespace,用于让被隔离进程看到当前 Namespace 里的网络设备和配置。
所以说,容器里的进程实际上是一种特殊的进程而已。 Docker Engine或者任何容器管理工具都不会对应用进程的隔离环境负责,也不会创建任何实体的“容器”,真正对隔离环境负责的是宿主机操作系统本身。
image.png
上面这张图很好的显示了,虚拟机和容器的区别,对于虚拟机来说,应用直接运行在的是虚拟机虚拟出来的操作系统,而对于容器来说,应用依然是直接运行在宿主机上的操作系统。只不过是应用使用的进程被容器加上了限制。

Cgroups

Linux Cgroups 的全称是 Linux Control Group。它最主要的作用,就是限制一个进程 组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。
在 Linux 中,Cgroups 给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式 组织在操作系统的 /sys/fs/cgroup 路径下。在Centos机器里,我可以用 mount 指令把它们展示出来:

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

它的输出结果,是一系列文件系统目录。可以看到,在 /sys/fs/cgroup 下面有很多诸如 cpuset、cpu、 memory 这样的子目录, 也叫子系统。这些都是我这台机器当前可以被 Cgroups 进行限制的资源种类。而在子系统 对应的资源种类下,你就可以看到该类资源具体可以被限制的方法。比如,对 CPU 子系统 来说,我们就可以看到如下几个配置文件:

  1. [root@tengxunyun1412 ~]# ls /sys/fs/cgroup/cpu
  2. cgroup.clone_children cpuacct.usage cpuacct.usage_percpu_user cpu.cfs_quota_us cpu.stat release_agent
  3. cgroup.procs cpuacct.usage_all cpuacct.usage_sys cpu.rt_period_us docker system.slice
  4. cgroup.sane_behavior cpuacct.usage_percpu cpuacct.usage_user cpu.rt_runtime_us notify_on_release tasks
  5. cpuacct.stat cpuacct.usage_percpu_sys cpu.cfs_period_us cpu.shares onion

你就会在它的输出里注意到 cfs_period 和 cfs_quota 这 样的关键词。这两个参数需要组合使用,可以用来限制进程在长度为 cfs_period 的一段时 间内,只能被分配到总量为 cfs_quota 的 CPU 时间。 而这样的配置文件又如何使用呢? 你需要在对应的子系统下面创建一个目录,比如,我们现在进入 /sys/fs/cgroup/cpu 目录下:

  1. [root@tengxunyun1412 ~]# cd /sys/fs/cgroup/cpu
  2. [root@tengxunyun1412 cpu]# mkdir container
  3. [root@tengxunyun1412 cpu]# ls container/
  4. cgroup.clone_children cpuacct.usage cpuacct.usage_percpu_sys cpuacct.usage_user cpu.rt_period_us cpu.stat
  5. cgroup.procs cpuacct.usage_all cpuacct.usage_percpu_user cpu.cfs_period_us cpu.rt_runtime_us notify_on_release
  6. cpuacct.stat cpuacct.usage_percpu cpuacct.usage_sys cpu.cfs_quota_us cpu.shares tasks

这个目录就称为一个“控制组”。你会发现,操作系统会在你新创建的 container 目录 下,自动生成该子系统对应的资源限制文件。 现在,我们在后台执行这样一条脚本:

  1. [root@tengxunyun1412 cpu]# while : ; do : ; done &
  2. [1] 3197530

显然,它执行了一个死循环,可以把计算机的 CPU 吃到 100%,根据它的输出,我们可以看到这个脚本在后台运行的进程号(PID)是 3197530。 这样,我们可以用 top 指令来确认一下 CPU 有没有被打满:

  1. PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
  2. 3197530 root 20 0 28716 2036 0 R 100.0 0.1 0:41.70 bash
  3. 3179644 root 20 0 3462164 306184 16896 S 0.3 8.0 0:11.82 java
  4. 1 root 20 0 184952 11336 8688 S 0.0 0.3 5:44.32 systemd
  5. 2 root 20 0 0 0 0 S 0.0 0.0 0:00.14 kthreadd
  6. 3 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 rcu_gp
  7. ...

在输出里可以看到,CPU 的使用率已经 100% 了。 而此时,我们可以通过查看 container 目录下的文件,看到 container 控制组里的 CPU quota 还没有任何限制(即:-1),CPU period 则是默认的 100 ms(100000 us):

  1. [root@tengxunyun1412 cpu]# cat /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us
  2. -1
  3. [root@tengxunyun1412 cpu]# cat /sys/fs/cgroup/cpu/container/cpu.cfs_period_us
  4. 100000

接下来,我们可以通过修改这些文件的内容来设置限制:向 container 组里的 cfs_quota 文件写入 20 ms(20000 us),它意味着在每 100 ms 的时间里,被该控制组限制的进程只能使用 20ms 的CPU时间,也就是说这个进程只能使用到 20% 的CPU带宽。接下来,我们把被限制的进程的 PID 写入 container 组里的 tasks 文件,上面的设置就会对该进程生效了:

  1. [root@tengxunyun1412 cpu]# echo 3197530 > /sys/fs/cgroup/cpu/container/tasks
  2. [root@tengxunyun1412 cpu]# top
  3. PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
  4. 3197530 root 20 0 28716 2036 0 R 19.7 0.1 2:49.30 bash
  5. 3179644 root 20 0 3462164 306184 16896 S 0.3 8.0 0:11.91 java
  6. 1 root 20 0 184952 11336 8688 S 0.0 0.3 5:44.33 systemd
  7. 2 root 20 0 0 0 0 S 0.0 0.0 0:00.14 kthreadd
  8. 3 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 rcu_gp

可以看到,计算机的 CPU 使用率立刻降到了 20%。 除 CPU 子系统外,Cgroups 的每一项子系统都有其独有的资源限制能力,比如:

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

Linux Cgroups 的设计还是比较易用的,简单粗暴地理解呢,它就是一个子系统目录加上 一组资源限制文件的组合。而对于 Docker 等 Linux 容器项目来说,它们只需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后, 把这个进程的 PID 填写到对应控制组的 tasks 文件中就可以了。 而至于在这些控制组下面的资源文件里填上什么值,就靠用户执行 docker run 时的参数指 定了,比如这样一条命令:

  1. [root@tengxunyun1412 ~]# docker run -d --cpu-period=100000 --cpu-quota=20000 nginx
  2. 3adf63a9733c02fd91003b75ee0918d10cd26a3ad204953fda2f0efdd0f17f3f

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

  1. [root@tengxunyun1412 ~]# cat /sys/fs/cgroup/cpu/docker/3adf63a9733c02fd91003b75ee0918d10cd26a3ad204953fda2f0efdd0f17f3f/cpu.cfs_period_us
  2. 100000
  3. [root@tengxunyun1412 ~]# cat /sys/fs/cgroup/cpu/docker/3adf63a9733c02fd91003b75ee0918d10cd26a3ad204953fda2f0efdd0f17f3f/cpu.cfs_quota_us
  4. 20000

容器的限制

唯一状态的副作用

由于容器和宿主机公用的都是是宿主机上的操作系统。所以对于操作系统的唯一状态来说,就会产生混用的情况。比如时间:

  1. [root@tengxunyun1412 ~]# docker exec -it 3adf63a9733c /bin/bash
  2. root@3adf63a9733c:/# date
  3. Sun Oct 31 02:18:12 UTC 2021
  4. root@3adf63a9733c:/# exit
  5. exit
  6. [root@tengxunyun1412 ~]# date -s 20100405
  7. Mon Apr 5 00:00:00 CST 2010
  8. [root@tengxunyun1412 ~]# date
  9. Mon Apr 5 00:00:05 CST 2010
  10. [root@tengxunyun1412 ~]# docker exec -it 3adf63a9733c /bin/bash
  11. root@3adf63a9733c:/# date
  12. Sun Apr 4 16:00:11 UTC 2010
  13. root@3adf63a9733c:/# date -s 20100405
  14. date: cannot set date: Operation not permitted
  15. Mon Apr 5 00:00:00 UTC 2010

我们可以看到,修改宿主机操作系统会影响到容器内部的时间。但是容器内部对 date 这个命令做了限制,不允许它修改时间。

单进程模型

容器和虚拟机对进程的管理能力是有着巨大差异的。不管在容器中还是虚拟机中都有一个1号进程,虚拟机中是 systemd 进程,容器中是 entrypoint 启动进程,然后其他进程都是1号进程的子进程,或者子进程的子进程,递归下去。这里的主要差异就体现在 systemd 进程对僵尸进程回收的能力。
这里简单介绍一下 Linux 系统中的进程状态,我们可以通过 pstree 或者 top 或者 ps 等命令查看系统中的进程:

  1. [root@tengxunyun1412 ~]# ps aux
  2. USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
  3. root 1 0.0 0.2 184952 11336 ? Ss Mar25 6:01 /usr/lib/systemd/systemd --switched-root --system --d
  4. root 2 0.0 0.0 0 0 ? S Mar25 0:00 [kthreadd]
  5. root 3 0.0 0.0 0 0 ? I< Mar25 0:00 [rcu_gp]
  6. root 4 0.0 0.0 0 0 ? I< Mar25 0:00 [rcu_par_gp]
  7. root 6 0.0 0.0 0 0 ? I< Mar25 0:00 [kworker/0:0H-events_highpri]
  8. root 9 0.0 0.0 0 0 ? I< Mar25 0:00 [mm_percpu_wq]
  9. root 10 0.0 0.0 0 0 ? S Mar25 0:20 [ksoftirqd/0]
  10. root 11 0.0 0.0 0 0 ? I Mar25 1:37 [rcu_sched]
  11. root 12 0.0 0.0 0 0 ? S Mar25 0:00 [migration/0]
  12. root 13 0.0 0.0 0 0 ? S Mar25 0:00 [watchdog/0]
  13. root 14 0.0 0.0 0 0 ? S Mar25 0:00 [cpuhp/0]
  14. root 15 0.0 0.0 0 0 ? S Mar25 0:00 [cpuhp/1]
  15. root 16 0.0 0.0 0 0 ? S Mar25 0:00 [watchdog/1]
  16. root 17 0.0 0.0 0 0 ? S Mar25 0:01 [migration/1]

排在第一位的就是前面说到的 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 状态一般在 IO 等资源就绪之后就会轮转到 R 状态,如果进程处于 D 状态比较久,这个时候往往是 IO 出现问题,解决办法大部分情况是重启机器。
  • I: Idle,也就是空闲状态,不可中断的睡眠的内核线程。和 D 状态进程的主要区别是可能实际上不会造成负载升高。

    僵尸进程

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

    A child that terminates, but has not been waited for becomes a “zombie”. The kernel maintains a minimal set of information about the zombie process (PID, termination status, resource usage information) in order to allow the parent to later perform a wait to obtain information about the child. As long as a zombie is not removed from the system via a wait, it will consume a slot in the kernel process table, and if this table fills, it will not be possible to create further processes.

子进程终止后,其父进程没有对其资源进行回收,于是该子进程就变成了”僵尸进程“。在内核中,维护了一个僵尸进程的信息集合(包括PID, termination status, resource usage information)。只要僵尸进程未被移除(即通过系统调用wait()),那么一个僵尸进程就会占据内核进程表中的一个条目,一旦这张表被填满了,就不能再创建新的进程了。这也就是僵尸进程的危害。
僵尸进程的最大危害是对资源的一种永久性占用,比如进程号,系统会有一个最大的进程数 n 的限制,也就意味一旦 1 到 n 进程号都被占用,系统将不能创建任何进程和线程(进程和线程对于 OS 而言,使用同一种数据结构来表示,task_struct)。这个时候对于用户的一个直观感受就是 shell 无法执行任何命令,这个原因是 shell 执行命令的本质是 fork。

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

孤儿进程

前面讲到,如果子进程退出后,父进程没有对子进程残留的资源进行回收,就会产生僵尸进程。那么如果父进程先于子进程退出的话,子进程的资源该由谁来回收呢?
父进程先于子进程退出,我们一般将还在运行的子进程称为孤儿进程,那么孤儿进程的资源谁来回收呢?类 Unix 系统针对这种情况会将这些孤儿进程的父进程置为 1 号进程(也就是 systemd 进程),然后由 systemd 来对孤儿进程的资源进行回收。

容器单进程模型的本质

通过上面的回顾,基本了解了在操作系统中是如何避免僵尸进程的,但是在容器中,1 号进程一般是 entrypoint 进程,针对上面这种 将孤儿进程的父进程置为 1 号进程进而避免僵尸进程 处理方式,容器是处理不了的。进而就会导致容器中在孤儿进程这种异常场景下僵尸进程无法彻底处理的窘境。
所以说,容器的单进程模型的本质其实是容器中的 1 号进程并不具有管理多进程、多线程等复杂场景下的能力。如果一定在容器中处理这些复杂情况,那么需要开发者对 entrypoint 进程赋予这种能力。这无疑是加重了开发者的心智负担,这是任何一项大众技术或者平台框架都不愿看到的尴尬之地。

如何避免

除了「开发者自己赋予 entrypoint 进程管理多进程的能力」这一思路,目前的做法是,通过 Kubernetes 来管理容器。k8s 可以将多个容器编排到一个 pod 里面,共享同一个 Linux Namespace。这项技术的本质是使用 k8s 提供一个 pause 镜像,也就是说先启动一个 pause 容器,相当于实例化出 Namespace,然后其他容器加入这个 Namespace 从而实现 Namespace 的共享。