第二章 容器技术基础

172 阅读13分钟

目标

  1. 了解容器的实现原理,Namespace和Cgroups
  2. 了解容器和虚拟机的区别

从进程开始说起

容器就是一种特殊的进程而已,它被Namespace的“障眼法”困在“单独的世界里”

Namespace的障眼法

容器技术的核心功能就是通过约束和修改进程的动态表现,为其创造一个“边界”。

对于docker等大多数Linux容器来说,cgroups技术是用来制造约束的主要手段,而namespace技术是用来修改进程视图的主要方法。

我们先来看一个实例,来讲解namespace到底为容器作出了什么贡献。

假设你已经有一个在Linux操作系统上运行的docker项目,比如我的环境是Ubuntu 16.04和docker CE 18.05。接下来首先创建一个容器。

$ docker run -it busybox /bin/sh

这个命令对于docker用户已经不陌生了,它会帮我们启动一个容器,并且在容器里面执行/bin/sh,然后会给我们分配一个命令行终端来跟这个容器交互。

我们在终端里面查看进程:

/ # ps
PID USER TIME COMMAND
1   root 0:00 /bin/sh
2   root 0:00 ps

可以看到我们运行的/bin/sh,它的pid是容器内部的第一号进程。这也就意味着Docker将其隔离在了一个“新世界”。

而这就是Docker利用Namespace魔法给这个进程施展了“障眼法”,让这个进程永远关在一个虚拟的世界里面。而在真实的世界里,操作系统仍然会给它分配一个真实的PID。

Namespace机制是Linux的一种技术,它其实只是Linux创建新进程的一个可选参数。 在Linux系统中创建进程的系统调用函数时clone(),比如:

int pid = clone(main_function, stack_size, SIGCHLD, NULL);

这个系统调用会给我们创建一个新的进程,并且返回它的PID。

我们也可以指定CLONE_NEWPID参数,来达到上面“障眼法”的效果:

int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);

除了刚才的PID Namespace,Linux还提供了其他的Namespace. 比如Mount Namespace只让被隔离进程看到当前Namespace的挂载点信息,NetWork Namespace只让被隔离进程看到当前Namespace的网络设备信息,等。

可见,容器其实是一种特殊的进程而已。

隔离与限制

Namespace将进程隔离到了一个新的“世界”,但是这个“世界”运转时产生的影响也会影响到原本的“世界”

Cgroups可以将新的“世界”做一些限制,减少给原本的世界带来的影响

容器VS虚拟机

刚才我们讲到用户在容器里运行的应用进程跟宿主机上的其他进程一样,都由宿主机操作系统统一管理,只不过这些被隔离的进程拥有额外设置的Namespace参数。

我们回过头来再看虚拟机,它是在宿主机上创建了一个真实的虚拟机,并且它里面必须运行一个完整的客户操作系统,用来执行用户的应用进程。

我们知道操作系统本身就需要占用各种资源,更何况运行在虚拟机里面的应用在进行系统调用的时候,就不可避免的要经过虚拟化软件的拦截和处理,这本身又是一次性能损耗。

这也解释了docker项目比虚拟机更受欢迎的原因。所以“敏捷”和“高性能”是容器相较于虚拟机的最大优势。但是有利就有弊,容器的最主要的问题就是隔离的不彻底,其主要表现在以下几点。

  1. 容器需要共享宿主机内核,这也就意味着你无法在Windows宿主机上运行Linux容器。反之亦然。
  2. 在Linux内核中有很多资源和对象对Namespace的“障眼法”具备“免疫”效果。比如你在容器中使用了修改系统时间的系统调用,那么整个宿主机的时间都会被修改。这时候所谓的“隔离”就会失效。
  3. 容器向应用暴露的攻击面是相当大的。容器的安全性远低于虚拟机
  4. 而且容器作为宿主机的一个进程,那么它能够使用到的资源可以随时被宿主机上的其他进程占用。或者自己也可能用光宿主机上所有的资源,这显然是不合理的。

对于前面几点在后面的章节中会讲解基于虚拟化或者独立内核技术的容器实现,对于第4点,也就是我们本节的主角—Linux Cgroups

Cgroups

Cgroups就是Linux内核中用来为进程设置资源限制的一个重要功能。可以限制的资源比如CPU,内存,磁盘,网络带宽等等,不仅如此,Cgroups还能够对进程进行优先级设置审计,以及将进程挂起和恢复等操作。

Cgroups暴露了一组文件目录,它以文件和目录的方式组织在L路径/sys/fs/cgroup/下。

我们可以通过mount -t cgroup来将其显示:

xdxx@LAPTOP-KMEBIJ5Q:/$ mount -t cgroup
cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
cgroup on /sys/fs/cgroup/cpu type cgroup (rw,nosuid,nodev,noexec,relatime,cpu)
cgroup on /sys/fs/cgroup/cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct)
cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices)
cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer)
cgroup on /sys/fs/cgroup/net_cls type cgroup (rw,nosuid,nodev,noexec,relatime,net_cls)
cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event)
cgroup on /sys/fs/cgroup/net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,net_prio)
cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb)
cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids)
cgroup on /sys/fs/cgroup/rdma type cgroup (rw,nosuid,nodev,noexec,relatime,rdma)

我们看到在/sys/fs/cgroup/下面有诸如cpuset、cpu、blkio等子目录。这些子目录也叫子系统,这些都是我们这台机器当前可以被限制的资源种类。如下表,列出了各个子系统的含义:

名称含义
cpuset为进程分配单独的CPU核和对应的内存节点
cpu主要限制进程的 cpu 使用率。
cpuacct可以统计 cgroups 中的进程的 cpu 使用报告。
blkio可以限制进程的块设备 io,一般就是磁盘设备
memory可以限制进程的 内存使用量。
devices可以控制进程能够访问某些设备。
freezer可以挂起或者恢复 cgroups 中的进程。

对于每个子系统,都有其自己的配置文件,比如CPU子系统:

xdxx@LAPTOP-KMEBIJ5Q:/$ ls /sys/fs/cgroup/cpu
cgroup.clone_children  cgroup.sane_behavior  cpu.cfs_quota_us  cpu.rt_runtime_us  cpu.stat           release_agent
cgroup.procs           cpu.cfs_period_us     cpu.rt_period_us  cpu.shares         notify_on_release  tasks

其中cpu.cfs_period_uscpu.cfs_quota_us经常组合使用。可以用于限制进程在cfs_period_us的时间段内,只能被分配到cfs_quota_us时间的CPU资源

我们在后台执行这样一个脚本:

xdxx@LAPTOP-KMEBIJ5Q:/$ while : ; do : ; done &
[1] 306

这是一个死循环,我们来看cpu的资源占用情况。

PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
306 xdxx      20   0   13232   4960      0 R 100.0   0.1   0:45.90 bash

可以看到PID=306的进程 已经把CPU吃满了。

现在我们就开始让这个脚本在一个“容器”中运行,并且给这个容器做CPU限制。

我们在/sys/fs/cgroup/cpu这个目录下创建一个container文件夹

xdxx@LAPTOP-KMEBIJ5Q:/sys/fs/cgroup/cpu$ sudo mkdir container
xdxx@LAPTOP-KMEBIJ5Q:/sys/fs/cgroup/cpu$ cd container
xdxx@LAPTOP-KMEBIJ5Q:/sys/fs/cgroup/cpu/container$ ls
cgroup.clone_children  cpu.cfs_period_us  cpu.rt_period_us   cpu.shares  notify_on_release
cgroup.procs           cpu.cfs_quota_us   cpu.rt_runtime_us  cpu.stat    tasks

我们可以看到,系统已经自动在新建的container文件夹下面加上了cpu子系统的配置文件。

我们通过cat命令查看到cfs_period_us的值默认为100000,cfs_quota_us值默认为-1,-1的含义就是无限制.

我们通过echo 20000 > cfs_quota_us将cfs_quota_us的值改为20000,也就是说,container这个控制组的cpu限制为20%。

将运行的进程PID写入tasks文件echo 306 > /sys/fs/cgroup/cpu/container/tasks,然后再来看一下CPU使用情况

PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
306 xdxx      20   0   13232   4960      0 R  20.0   0.1   0:45.90 bash

可以看到,计算机的CPU使用率立刻降到了20%。

Groups的缺点

Group对资源的限制能力也有许多不完善之处,被提到最多的自然就是/proc文件系统的问题。

/proc文件系统是linux用来记录进程信息的,我们的top命令就是从该文件系统中读取数据。所以,当你在容器里执行top命令,就会发现它显示的信息居然是宿主机的CPU和内存信息,并非当前容器的数据。/proc文件系统不受cgroups的限制。这种“误导”性数据对于应用程序来说,无疑是灾难性的。

深入理解容器镜像

Docker镜像使用分这么一个创新的方式,发挥了其最大的特点:资源共享

大家都可以基于一个base镜像来做增量的镜像叠加。而且内存中只需要加载一次base镜像,就可以为所有的容器进行服务。

挂载文件系统

作者引用了“左耳朵耗子”的一段博客,其介绍了一个小程序。摘取了主要的几行代码(原代码可参考原文):

int container_main(void* arg) {
    ...
    execv("bin/bash", NULL);
    ...
    return 1;
}
​
int main(){
    ...
    int container_id = clone(container_main, stack, CLONE_NEWNS | SIGCHLD, NULL);
    waitpid(container_pid, NULL, 0);
    ...
}

main函数创建了一个新的子进程container_main,并且启用了mount namespace(通过CLONE_NEWNS标志)。

这个程序会创建一个容器,并在容器中执行了“bin/bash”,也就是一个shell。

我们在这个容器中执行ls /tmp,就会看到跟宿主机一模一样的目录结构。 其实mount namespace修改的是容器进程对文件系统“挂载点”的认知,所以我们还需要在创建了新进程后执行“挂载”这个操作,并且告诉容器哪些目录需要重新挂载。

我们在上面的代码基础上,增加一行执行“挂载”动作:

int container_main(void* arg) {
    ...
    // 执行“挂载”动作
    mount("none", "/tmp", "tmpfs", 0, "");
    execv("bin/bash", NULL);
    ...
    return 1;
}
​
int main(){
    ...
    int container_id = clone(container_main, stack, CLONE_NEWNS | SIGCHLD, NULL);
    waitpid(container_pid, NULL, 0);
    ...
}

这行代码告诉容器以tmpfs内存盘格式重新挂载/tmp目录。

启动后,我们在容器中执行ls /tmp,就会发现/tmp变成了一个空目录。

很显然,这种依靠代码去实现“挂载”的操作,显得十分“旁系”。那有没有“正统血脉”,可以更加方便的完成这一项工作呢?

chroot

chroot(change root file system),这个命令是用来改变进程的根目录到指定的位置。

用法:

假设我们有一个$HOME/test目录,你想把它作为/bin/bash进程的根目录

第一步,创建test目录和几个lib文件夹

mkdir -p $HOME/test
mkdir -p $HOME/test/{bin,lib64,lib}

第二步,把bash命令复制到test目录对应的bin路径下:

cp -v /bin/{bash, ls} $HOME/test/bin

第三步,把bash命令需要的所有so文件复制到test目录对应的lib路径下。执行下面这个脚本:

T=$HOME/test
list="${ldd /bin/ls | egrep -o '/lib.*.[0-9]'}"
for i in $list; do cp -v "$i" "${T}${i}"; done

第四步,执行chroot命令

chroot $HOME/test /bin/bash

执行完以后,我们再执行ls /就已经是$HOME/test/bin目录下的内容了。

而容器中为了让这个根目录看起来更“真实”,一般会在这个容器的根目录下挂载一个完整的操作系统的文件系统。

这个挂载在容器上的文件系统,就是所谓的“容器镜像”, 它还有一个更专业的名字:rootfs(根文件系统)

容器镜像的分层

有了容器镜像以后,我们就能够解决开发环境的不一致性问题。

因为我们完全可以将一个操作系统的文件系统打包到我们的容器镜像中,然后可以在任何地方使用这个容器镜像。这种下沉到操作系统级别的运行环境一致性填平了应用在本地开发和远端执行环境之间难以逾越的鸿沟。

但是一个镜像被打包以后,它并不是一成不变的,因为我们在应用的功能迭代中需要使用到的环境会越来越丰富,但是这种丰富它是“增量”的体现。

比如我将已经配置好JAVA环境的Ubuntu操作系统打包成了一个镜像,那么这个镜像就可以在任何地方去部署JAVA应用环境。

倘若有一天我需要在这个镜像中增加数据库环境,直接去修改这个镜像就会将原来的镜像破坏掉,也就是说原本那个基础的JAVA环境的镜像就不复存在了。这一段话有可能比较难以理解,但是你可以想一想代码中组件的复用。我们的一些功能可能是多个组件相互协作完成的。而不是去针对同一个组件进行功能的迭代,从而达到我们的目标。

Union FS(union file system)

Docker公司在镜像的设计中引入了层(layer) 的概念,也就是说,用户制作镜像的每一步操作都会生成一个层,也就是一个增量rootfs。

Union FS就是将每层联合挂载到一个相同的目录下。

比如有两个目录A和B,它们都拥有两个文件:

A
|--a
|--x
B
|--b
|--x

通过联合挂载的方式将这两个目录挂载到了一个公共目录C上:

mkdir C
mount -t aufs -o dirs=./A:./B none ./C

我们把./C叫做挂载点,执行后,C的目录内容如下:

C
|--a
|--b
|--x

需要注意,在联合挂载后,x文件只有一份。而且,你在C目录下对a和b做修改,那么这些修改也会在对应的目录A、B中生效。

原书中,介绍了AuFS(Advance UnionFS) 的镜像层存放目录。但是我发现我的Ubuntu20.04版本使用的Union FS是overlay2(目前docker默认的UnionFS).

所以接下来的部分内容跟原书不一致,其原理是一样的。

我们首先拉取一个Ubuntu的镜像并在docker容器中运行:

docker run -d ubuntu:latest sleep 3600

Docker镜像使用的rootfs往往由多个组成,运行上面命令后,会返回如下内容:

docker image inspect ubuntu:latest
...
        "RootFS": {
            "Type": "layers",
            "Layers": [
                "sha256:f454.....",
                "sha256:8dja.....",
                "sha256:9djw.....",
                "sha256:d08d.....",
                "sha256:338d.....",
            ]
        }

可以看到这个镜像实际上由5个层组成(但是我机器使用的不是AUFS,而是overlay2,所以我这里打印的Layers中只有一项数据,实际上含义是不一样的)。Docker镜像会把这些增量联合挂载在一个统一的挂载点上。

需要注意的是,不同版本的UnionFS,拉取到的Ubuntu的镜像分层数量就不一样

Overlay2存储驱动

原书介绍的是AuFS,这是Dokcer最早使用的存储驱动。而现在Docker默认使用overlay2作为存储驱动。

与AUFS相比,OverlayFS:

  • 设计更简单;
  • 被加入Linux3.18版本内核
  • 可能更快

OverlayFS将一个Linux主机中的两个目录组合起来,一个在上,一个在下,对外提供统一的视图。这两个目录就是层layer,将两个层组合在一起的技术被称为联合挂载union mount。在OverlayFS中,上层的目录被称作upperdir,下层的目录被称作lowerdir,对外提供的统一视图被称作merged。OverlayFS的结构如下图所示:

接下来我们实操介绍overlay2的文件目录结构

第一步,进入到overlay2的目录

/var/lib/docker/overlay2这个目录下的每个文件夹对应的就是一个镜像(除了l文件夹)

root@ubuntu:/var/lib/docker/overlay2# cd /var/lib/docker/overlay2
root@ubuntu:/var/lib/docker/overlay2# ls
39219beb434d44a8adb01df5954a871f08466add9409e61c86817c7c6f307016
80b9ae391f1db82ad986f78a9e0664df2ef47a76f0ba93997b639ae2dde1d556
80b9ae391f1db82ad986f78a9e0664df2ef47a76f0ba93997b639ae2dde1d556-init
9359f343a3d50da48304f6666f52d151356156bd34d24d7c190a0c929ea12ead
987a22ef63b4bcede71232f519772c045baae09759a1778572d2c9953c0d1137
987a22ef63b4bcede71232f519772c045baae09759a1778572d2c9953c0d1137-init
eb094ee20f52a0fb5f9d582cdcbb631f464413f2181d021d2d3a6d83f36da21e
eb094ee20f52a0fb5f9d582cdcbb631f464413f2181d021d2d3a6d83f36da21e-init
l

第二步,找出Ubuntu镜像所在的目录

我们先查看docker 容器进程,然后通过docker进程id来找到这个容器镜像的目录:

root@ubuntu:/var/lib/docker/overlay2/l# docker ps
CONTAINER ID   IMAGE           COMMAND        CREATED          STATUS          PORTS     NAMES
d78dc7c9000f   ubuntu:latest   "sleep 3600"   11 minutes ago   Up 11 minutes             keen_dirac
root@ubuntu:/var/lib/docker/overlay2/l# docker inspect d78dc7c9000f
{
    ...
    "GraphDriver": {
        "Data": {
            "LowerDir": "/var/lib/docker/overlay2/c73a6dc257d880f81d32befbf254c2dbbb7789fb5a1e4f77afcf2c9661031bd5-init/diff:/var/lib/docker/overlay2/39219beb434d44a8adb01df5954a871f08466add9409e61c86817c7c6f307016/diff",
            "MergedDir": "/var/lib/docker/overlay2/c73a6dc257d880f81d32befbf254c2dbbb7789fb5a1e4f77afcf2c9661031bd5/merged",
            "UpperDir": "/var/lib/docker/overlay2/c73a6dc257d880f81d32befbf254c2dbbb7789fb5a1e4f77afcf2c9661031bd5/diff",
            "WorkDir": "/var/lib/docker/overlay2/c73a6dc257d880f81d32befbf254c2dbbb7789fb5a1e4f77afcf2c9661031bd5/work"
        },
        "Name": "overlay2"
    }
    ...
}

可以看到,我们之前介绍过的几个目录已经在上面打印出来了。

我们进入到镜像id对应的这个目录,该目录下分别有五个文件(夹):

root@ubuntu:/var/lib/docker/overlay2/c73a6dc257d880f81d32befbf254c2dbbb7789fb5a1e4f77afcf2c9661031bd5# ls
diff  link  lower  merged  work

merged文件夹对外提供统一的视图,我们进入到merged文件夹,并展示其内容。

root@ubuntu:/var/lib/docker/overlay2/c73a6dc257d880f81d32befbf254c2dbbb7789fb5a1e4f77afcf2c9661031bd5/merged# ls
bin  boot  dev  etc  home  lib  lib32  lib64  libx32  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

可以看到,这就是一个完整的Ubuntu的根目录文件系统。

第三步,找出镜像分层

我们之前说过,容器镜像是分层的,而我们当前找到的目录就属于container mount,即容器挂载点,也就是上图中,最上层的部分。它并不属于容器镜像的任意”层“,上图的分层仅仅是Overlays的目录结构

通过docker inspect d78dc7c9000f命令返回的信息,可以知道还有另外两层的目录路径。

比如lowerDir,对应的是两个目录(层):

/var/lib/docker/overlay2/c73a6dc257d880f81d32befbf254c2dbbb7789fb5a1e4f77afcf2c9661031bd5-init/diff

/var/lib/docker/overlay2/39219beb434d44a8adb01df5954a871f08466add9409e61c86817c7c6f307016/dif

其中init为后缀的是容器启动时临时加的init层,另外一个则是最底层,就是Ubuntu镜像的只读层

而我们看到的UpperDir就是最高层—读写层

需要注意的是,最开始我们提到的Ubuntu分为5层,其实那5层都属于只读层。而在我这个版本的Overlays2中,Ubuntu的只读层就仅为一层,也就是对应的UpperDir的目录

init、只读层、读写层就是RootFS的三个组成部分

只读层:是这个容器的rootfs的最下面的层,对应的就是Ubuntu的镜像。如下所示:

root@ubuntu:/var/lib/docker/overlay2/39219beb434d44a8adb01df5954a871f08466add9409e61c86817c7c6f307016/diff# ls
bin  boot  dev  etc  home  lib  lib32  lib64  libx32  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

Init层:介于只读层和读写层的中间,是Docker项目单独生成的一个内部层,专门用来存放/etc/hosts、/etc/resolv.conf等信息。为什么需要这一层呢?如果我们在读写层去修改一些镜像的内容,那么就会对整个镜像生效。我们知道像Ubuntu这样的base镜像是用来共享的,你修改了之后其他容器拿到的就不是原本的这个镜像了。如果你修改init层,它只会针对当前的容器生效。

Init层的内容,如下所示

root@ubuntu:/var/lib/docker/overlay2/c73a6dc257d880f81d32befbf254c2dbbb7789fb5a1e4f77afcf2c9661031bd5-init/diff# ll
total 16
drwxr-xr-x 4 root root 4096 Jul 10 05:47 ./
drwx--x--- 4 root root 4096 Jul 10 05:47 ../
drwxr-xr-x 4 root root 4096 Jul 10 05:47 dev/
-rwxr-xr-x 1 root root    0 Jul 10 05:47 .dockerenv*
drwxr-xr-x 2 root root 4096 Jul 10 05:47 etc/

读写层:是这个容器的rootfs的最上一层,在写入文件之前,这个目录(对应upperDir的目录)会是空的,而一旦在容器中进行了写操作,那么修改产生的内容就会以增量的方式出现在该层中。

下面我们通过创建一个文件,来达到这样一个效果。

root@ubuntu:/# docker exec -it d78dc7c9000f touch /hello
root@ubuntu:/# cd /var/lib/docker/overlay2/c73a6dc257d880f81d32befbf254c2dbbb7789fb5a1e4f77afcf2c9661031bd5/diff
root@ubuntu:/var/lib/docker/overlay2/c73a6dc257d880f81d32befbf254c2dbbb7789fb5a1e4f77afcf2c9661031bd5/diff# ls
hello

我们在指定容器中创建了一个hello文件,然后对应的读写层就会增加一个hello文件。

我们再来看下merged文件夹的内容:

root@ubuntu:/var/lib/docker/overlay2/e8179e2cb53d10a67eaa52ac805bb31d077d9fc7aa3f80ea1e4c465cfefd038f/merged# ls
bin   dev  hello  lib    lib64   media  opt   root  sbin  sys  usr
boot  etc  home   lib32  libx32  mnt    proc  run   srv   tmp  var

发现多了一个hello文件

最后这三层都会被联合挂载到merged目录下,表现为一个完整的Ubuntu操作系统提供给容器使用