Docker入门详解


Docker介绍

Docker的英文翻译是“搬运工”,他搬运的东西就是常说的集装箱Container,Container 里面装的是任意类型的 App,开发人员可以通过 Docker 将App 变成一种标准化的、可移植的、自管理的组件,可以在任何主流的操作系统中开发、调试和运行。

Docker其实是容器化技术的具体技术实现之一,采用go语言开发。很多人刚接触Docker时,认为它就是一种更轻量级的虚拟机,这种认识其实是错误的,Docker和虚拟机有本质的区别。容器本质上讲就是运行在操作系统上的一个进程,只不过加入了对资源的隔离和限制。而Docker是基于容器的这个设计思想,基于Linux Container技术实现的核心管理引擎。

image

Docker 组件

Docker 使用 C/S (客户端/服务器)体系的架构,Docker 客户端与 Docker 守护进程通信,Docker 守护进程负责构建,运行和分发 Docker 容器。

Docker 客户端和守护进程可以在同一个系统上运行,也可以将 Docker 客户端连接到远程 Docker 守护进程。Docker 客户端和守护进程使用 REST API 通过UNIX套接字或网络接口进行通信。

Docker Damon

docker daemon(dockerd),一般也会被称为 docker engine。,用来监听 Docker API 的请求和管理 Docker 对象,比如镜像、容器、网络和 Volume。

Docker Client

docker client 是 和 Docker 进行交互的最主要的方式方法,比如 可以通过 docker run 命令来运行一个容器,然后 的这个 client 会把命令发送给上面的 Dockerd,让他来做真正事情。

Containerd-shim

它是 containerd 的组件,是容器的运行时载体,主要是用于剥离 containerd 守护进程与容器进程,引入shim,允许runc 在创建和运行容器之后退出,并将 shim 作为容器的父进程,而不是 containerd 作为父进程,这样做的目的是当 containerd 进程挂掉,而 shim 还正常运行,可以保证容器不受影响。此外,shim 也可以收集和报告容器的退出状态,不需要 containerd 来 wait 容器进程。 在 docker 宿主机上看到的 shim 也正是代表着一个个通过调用 containerd 启动的 docker 容器。

该程序的安装路径为:/usr/bin/docker-containerd-shim

Containerd

在宿主机中管理完整的容器生命周期:容器镜像的传输和存储、容器的执行和管理、存储和网络等。

该程序的安装路径为:/usr/bin/docker-containerd

RunC

RunC 是一个轻量级的工具,用来运行容器,容器作为 runC 的子进程开启,在不需要运行一个 Docker daemon 的情况下可以嵌入到其他各种系统,也就是说可以不用通过 docker 引擎,直接运行容器。docker是通过Containerd调用 runC 运行容器的,该程序的安装路径为:/usr/bin/docker-runc

image-20210514103505139

Docker Registry

用来存储 Docker 镜像的仓库,Docker Hub 是 Docker 官方提供的一个公共仓库,而且 Docker 默认也是从 Docker Hub 上查找镜像的,当然 也可以很方便的运行一个私有仓库,当 使用 docker pull 或者 docker run 命令时,就会从 配置的 Docker 镜像仓库中去拉取镜像,使用 docker push 命令时,会将 构建的镜像推送到对应的镜像仓库中。

Images

镜像(Image)就是一堆只读层(read-only layer)的统一视角

左边有多个只读层,它们重叠在一起。除了最下面一层,其它层都会有一个指针指向下一层。这些层是Docker内部的实现细节,并且能够在主机(运行Docker的机器)的文件系统上访问到。

统一文件系统(union file system)技术能够将不同的层整合成一个文件系统,为这些层提供了一个统一的视角,这样就隐藏了多层的存在,在用户的角度看来,只存在一个文件系统。 可以在图片的右边看到这个视角的形式。

在主机文件系统上找到有关这些层的文件。需要注意的是,在一个运行中的容器内部,这些层是不可见的。存在于/var/lib/docker/aufs目录下。

Container

容器是一个镜像的可运行的实例,可以使用 Docker REST API 或者 CLI 来操作容器,容器的实质是进程,但与直接在宿主执行的进程不同,容器进程运行于属于自己的独立的命名空间。因此容器可以拥有自己的 root 文件系统、自己的网络配置、自己的进程空间,甚至自己的用户 ID 空间。容器内的进程是运行在一个隔离的环境里,使用起来,就好像是在一个独立于宿主的系统下操作一样。这种特性使得容器封装的应用比直接在宿主运行更加安全。

容器的定义和镜像几乎一模一样,也是一堆层的统一视角,唯一区别在于容器的最上面那一层是可读可写的。

底层技术支持

Namespaces(做隔离)、CGroups(做资源限制)、UnionFS(镜像和容器的分层) the-underlying-technology Docker 底层架构分析

容器与虚拟机技术的对比

重新分析下 docker 和 传统 VM 的区别:

img

迁移性和性能:

  • 传统 VM: 需要基于 Hypervisor 的硬件虚拟化技术,模拟出 CPU,内存等硬件。然后在其上搭建一套完整的操作系统,自然在性能上会有很大的损失。迁移自然更不用说,传统的 ova 导出后就是一个完整的操作系统。
  • Docker:Docker 将 Hypervisor 的位置换成自己的 Docekr Engine. 然后运行的容器仅仅是一个特殊的进程,自然性能不会有太大的损失。并且可以应用和其所需要的系统文件打包成镜像,无论在哪读可以正常运行,而且相对于 ova 来说体积也小了更多。(需要内核支持)

一般来说,运行着 CentOS 的 KVM,启动后,在不做优化的前提下,需要占用 100~200 M 内存。在加上用户对宿主机的调用,需要通过虚拟化软件拦截和处理,又是一层性能损耗,特别是对计算资源,网络和磁盘I/O等。

隔离性:

  • 传统 VM:由于是虚拟化出一套完整的操作系统,所以隔离性非常好。比如微软的 Azure 平台,就是在 Windows 服务器上,虚拟出大量的 Linux 虚拟机。
  • Docker:在隔离性上相差就很多了,因为本身上容器就是一种进程,而所有的进程都需要共享一个系统内核。
    • 这就意味着,在 Windows 上运行 Linux 容器,或者 Linux 宿主机运行高版本内核的容器就无法实现。
    • 在 Linux 内核中,有许多资源和对象不能 Namespace 化,如时间,比如通过 settimeofday(2) 系统调用 修改时间,整个宿主机的实际都会被修改。
    • 安全的问题,共享宿主机内核的事实,容器暴露出的攻击面更大。

资源的限制:

  • 传统 VM:非常便于管理,控制资源的使用,依赖于虚拟的操作系统。
  • Docker:由于 docker 内资源的限制通过 Cgroup 实现,而 Cgroup 有很多不完善的地方,比如
    • 对 /proc 的处理问题。进入容器后,执行 top 命令,看到的信息和宿主机是一样的,而不是配置后的容器的数据。(可以通过 lxcfs 修正)。
    • 在运行 java 程序时,给容器内设置的内存为 4g,使用默认的 jvm 配置。而默认的 jvm 读取的内存是宿主机(可能大于 4g),这样就会出现 OOM 的情况。

解决的问题

  1. 容器是如何进行隔离的?

    在创建新进程时,通过 Namespace 技术,如 PID namespaces 等,实现隔离性。让运行后的容器仅能看到本身的内容。

    比如,在容器运行时,会默认加上 PID, UTS, network, user, mount, IPC, cgroup 等 Namespace.

  2. 容器是如何进行资源限制的?

    通过 Linux Cgroup 技术,可为每个进程设定限制的 CPU,Memory 等资源,进而设置进程访问资源的上限。

  3. 简述下 docker 的文件系统?

    docker 的文件系统称为 rootfs,它的实现的想法来自与 Linux unionFS 。将不同的目录,挂载到一起,形成一个独立的视图。并且 docker 在此基础上引入了层的概念,解决了可重用性的问题。

    在具体实现上,rootfs 的存储区分根据 linux 内核和 docker 本身的版本,分为 overlay2 , overlay, aufs, devicemapper 等。rootfs(镜像)其实就是多个层的叠加,当多层存在相同的文件时,上层的文件会将下层的文件覆盖掉。

  4. 容器的启动过程?

    1. 指定 Linux Namespace 配置
    2. 设置指定的 Cgroups 参数
    3. 切换进程的根目录
  5. 容器内运行多个应用的问题?

    首先更正一个概念, 都说容器是一个单进程的应用,其实这里的单进程不是指在容器中只允许着一个进程,而是指只有一个进程时可控的。在容器内当然可以使用 ping,ssh 等进程,但这些进程时不受 docker 控制的。

    容器内的主进程,也就是 pid =1 的进程,一般是通过 DockerFile 中 ENTRYPOINT 或者 CMD 指定的。如果在一个容器内如果存在着多个服务(进程),就可能出现主进程正常运行,但是子进程退出挂掉的问题,而对于 docker 来说,仅仅控制主进程,无法对这种意外的情况作出处理,也就会出现,容器明明正常运行,但是服务已经挂掉的情况,这时编排系统就变得非常困难。而且多个服务,在也不容易进行排障和管理。

    所以如果真的想要在容器内运行多个服务,一般会通过带有 systemd 或者 supervisord 这类工具进行管理,或者通过 --init 方法。其实这些方法的本质就是让多个服务的进程拥有同一个父进程。

    但考虑到容器本身的设计,就是希望容器和服务能够同生命周期。所以这样做,有点背道而驰的意味。

docker目录结构

/var/lib/docker/
├── containers
├── image
│   └── overlay2
│       ├── distribution
│       ├── imagedb
│       │   ├── content
│       │   │   └── sha256
│       │   └── metadata
│       │       └── sha256
│       ├── layerdb
│       └── repositories.json
├── network
│   └── files
│       └── local-kv.db
├── overlay2
│   └── l
├── plugins
│   ├── storage
│   │   └── blobs
│   │       └── tmp
│   └── tmp
├── swarm
├── tmp
├── trust
└── volumes
    └── metadata.db

容器实现原理

从本质上,容器其实就是一种沙盒技术。就好像把应用隔离在一个盒子内,使其运行。因为有了盒子边界的存在,应用于应用之间不会相互干扰。并且像集装箱一样,拿来就走,随处运行。其实这就是 PaaS 的理想状态。

实现容器的核心,就是要生成限制应用运行时的边界。 编译后的可执行代码加上数据,叫做程序。而把程序运行起来后,就变成了进程,也就是所谓的应用。如果能在应用启动时,给其加上一个边界,这样不就能实现期待的沙盒吗?

在 Linux 中,实现容器的边界,主要有两种技术 CgroupsNamespace,Cgroups 用于对运行的容器进行资源的限制,Namespace 则会将容器隔离起来,实现边界。

这样看来,容器只是一种被限制的了特殊进程而已。

容器的隔离:Namespace

在介绍 Namespace 前,先看一个实验:

# 使用 python3.6.8 的官方镜像,建立了一个运行 django 的环境
# 进入该容器后,使用 ps 命令,查看运行的进程
root@8729260f784a:/src# ps -A
  PID TTY          TIME CMD
    1 ?        00:01:22 gunicorn
   22 ?        00:01:20 gunicorn
   23 ?        00:01:24 gunicorn
   25 ?        00:01:30 gunicorn
   27 ?        00:01:16 gunicorn
   41 pts/0    00:00:00 bash
   55 pts/0    00:00:00 ps

可以看到,容器内 PID =1 的进程,是 gunicorn 启动的 django 应用。PID =1 的进程是系统启动时的第一个进程,也称 init 进程。其他的进程,都是由它管理产生的。而此时,PID=1 确实是 django 进程。

接着,退出容器,在宿主机执行 ps 命令

[root@localhost ~]# ps -ef | grep gunicorn
root      9623  8409  0 21:29 pts/0    00:00:00 grep --color=auto gunicorn
root     30828 30804  0 May28 ?        00:01:22 /usr/local/bin/python /usr/local/bin/gunicorn -c gunicorn_config.py ctg.wsgi
root     31171 30828  0 May28 ?        00:01:20 /usr/local/bin/python /usr/local/bin/gunicorn -c gunicorn_config.py ctg.wsgi
root     31172 30828  0 May28 ?        00:01:24 /usr/local/bin/python /usr/local/bin/gunicorn -c gunicorn_config.py ctg.wsgi
root     31174 30828  0 May28 ?        00:01:30 /usr/local/bin/python /usr/local/bin/gunicorn -c gunicorn_config.py ctg.wsgi
root     31176 30828  0 May28 ?        00:01:16 /usr/local/bin/python /usr/local/bin/gunicorn -c gunicorn_config.py ctg.wsgi

如果以宿主机的视角,发现 django 进程 PID 变成了 30828。这也就不难证明,在容器中,确实做了一些处理。把明明是 30828 的进程,变成了容器内的第一号进程,同时在容器还看不到宿主机的其他进程。这也说明容器内的环境确实是被隔离了。

这种处理,其实就是 Linux 的 Namespace 机制。比如,上述将 PID 变成 1 的方法就是通过PID Namespace。在 Linux 中创建线程的方法是 clone, 在其中指定 CLONE_NEWPID 参数,这样新创建的进程,就会看到一个全新的进程空间。而此时这个新的进程,也就变成了 PID=1 的进程。

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

Namespace概念

namespace 是 Linux 内核用来隔离内核资源的方式。通过 namespace 可以让一些进程只能看到与自己相关的一部分资源,而另外一些进程也只能看到与它们自己相关的资源,这两拨进程根本就感觉不到对方的存在。具体的实现方式是把一个或多个进程的相关资源指定在同一个 namespace 中。

Linux namespaces 是对全局系统资源的一种封装隔离,使得处于不同 namespace 的进程拥有独立的全局系统资源,改变一个 namespace 中的系统资源只会影响当前 namespace 里的进程,对其他 namespace 中的进程没有影响。

namespace 的用途

Linux 内核实现 namespace 的一个主要目的就是实现轻量级虚拟化(容器)服务。在同一个 namespace 下的进程可以感知彼此的变化,而对外界的进程一无所知。这样就可以让容器中的进程产生错觉,认为自己置身于一个独立的系统中,从而达到隔离的目的。也就是说 linux 内核提供的 namespace 技术为 docker 等容器技术的出现和发展提供了基础条件。

我们可以从 docker 实现者的角度考虑该如何实现一个资源隔离的容器。比如是不是可以通过 chroot 命令切换根目录的挂载点,从而隔离文件系统。为了在分布式的环境下进行通信和定位,容器必须要有独立的 IP、端口和路由等,这就需要对网络进行隔离。同时容器还需要一个独立的主机名以便在网络中标识自己。接下来还需要进程间的通信、用户权限等的隔离。最后,运行在容器中的应用需要有进程号(PID),自然也需要与宿主机中的 PID 进行隔离。也就是说这六种隔离能力是实现一个容器的基础,让我们看看 linux 内核的 namespace 特性为我们提供了什么样的隔离能力:

名称 宏定义 隔离的资源
IPC CLONE_NEWIPC System V IPC(信号量、消息队列和共享内容)和POSIX message queuest
Network CLONE_NEWNET Network devices,stacks,ports,etc(网络设备、网络栈、端口等)。
Mount CLONE_NEWNS Mount points(文件系统挂载点)
PID CLONE_NEWPID Process IDS(进程编号)
User CLONE_NEWUSER User adn group IDs(用户和用户组)
UTS CLONE_NEWUTS Hostname and NIS domain name(主机名和NIS域名)
Cgroup CLONE_NEWCGROUP Cgroup root directory(cgroup的根目录)

namespace 的发展历史

Linux 在很早的版本中就实现了部分的 namespace,比如内核 2.4 就实现了 mount namespace。大多数的 namespace 支持是在内核 2.6 中完成的,比如 IPC、Network、PID、和 UTS。还有个别的 namespace 比较特殊,比如 User,从内核 2.6 就开始实现了,但在内核 3.8 中才宣布完成。同时,随着 Linux 自身的发展以及容器技术持续发展带来的需求,也会有新的 namespace 被支持,比如在内核 4.6 中就添加了 Cgroup namespace。

Linux 提供了多个 API 用来操作 namespace,它们是 clone()、setns() 和 unshare() 函数,为了确定隔离的到底是哪项 namespace,在使用这些 API 时,通常需要指定一些调用参数:CLONE_NEWIPC、CLONE_NEWNET、CLONE_NEWNS、CLONE_NEWPID、CLONE_NEWUSER、CLONE_NEWUTS 和 CLONE_NEWCGROUP。如果要同时隔离多个 namespace,可以使用 | (按位或)组合这些参数。同时我们还可以通过 /proc 下面的一些文件来操作 namespace。下面就让让我们看看这些接口的简要用法。

查看进程所属的 namespace

从版本号为 3.8 的内核开始,/proc/[pid]/ns 目录下会包含进程所属的 namespace 信息,使用下面的命令可以查看当前进程所属的 namespace 信息:

ll /proc/$$/ns

首先,这些 namespace 文件都是链接文件。链接文件的内容的格式为 xxx:[inode number]。其中的 xxx 为 namespace 的类型,inode number 则用来标识一个 namespace,我们也可以把它理解为 namespace 的 ID。如果两个进程的某个 namespace 文件指向同一个链接文件,说明其相关资源在同一个 namespace 中。
其次,在 /proc/[pid]/ns 里放置这些链接文件的另外一个作用是,一旦这些链接文件被打开,只要打开的文件描述符(fd)存在,那么就算该 namespace 下的所有进程都已结束,这个 namespace 也会一直存在,后续的进程还可以再加入进来。
除了打开文件的方式,我们还可以通过文件挂载的方式阻止 namespace 被删除。比如我们可以把当前进程中的 uts 挂载到 ~/uts 文件:

touch ~/uts
sudo mount --bind /proc/$$/ns/uts ~/uts

使用 stat 命令检查下结果

很神奇吧,~/uts 的 inode 和链接文件中的 inode number 是一样的,它们是同一个文件。

clone() 函数

我们可以通过 clone() 在创建新进程的同时创建 namespace。clone() 在 C 语言库中的声明如下:

/* Prototype for the glibc wrapper function */
#define _GNU_SOURCE
#include <sched.h>
int clone(int (*fn)(void *), void *child_stack, int flags, void *arg);

实际上,clone() 是在 C 语言库中定义的一个封装(wrapper)函数,它负责建立新进程的堆栈并且调用对编程者隐藏的 clone() 系统调用。Clone() 其实是 linux 系统调用 fork() 的一种更通用的实现方式,它可以通过 flags 来控制使用多少功能。一共有 20 多种 CLONE_ 开头的 falg(标志位) 参数用来控制 clone 进程的方方面面(比如是否与父进程共享虚拟内存等),下面我们只介绍与 namespace 相关的 4 个参数:

  • fn:指定一个由新进程执行的函数。当这个函数返回时,子进程终止。该函数返回一个整数,表示子进程的退出代码。
  • child_stack:传入子进程使用的栈空间,也就是把用户态堆栈指针赋给子进程的 esp 寄存器。调用进程(指调用 clone() 的进程)应该总是为子进程分配新的堆栈。
  • flags:表示使用哪些 CLONE_ 开头的标志位,与 namespace 相关的有CLONE_NEWIPC、CLONE_NEWNET、CLONE_NEWNS、CLONE_NEWPID、CLONE_NEWUSER、CLONE_NEWUTS 和 CLONE_NEWCGROUP。
  • arg:指向传递给 fn() 函数的参数。

在后续的文章中,我们主要通过 clone() 函数来创建并演示各种类型的 namespace。

setns() 函数

通过 setns() 函数可以将当前进程加入到已有的 namespace 中。setns() 在 C 语言库中的声明如下:

#define _GNU_SOURCE
#include <sched.h>
int setns(int fd, int nstype);

和 clone() 函数一样,C 语言库中的 setns() 函数也是对 setns() 系统调用的封装:

  • fd:表示要加入 namespace 的文件描述符。它是一个指向 /proc/[pid]/ns 目录中文件的文件描述符,可以通过直接打开该目录下的链接文件或者打开一个挂载了该目录下链接文件的文件得到。
  • nstype:参数 nstype 让调用者可以检查 fd 指向的 namespace 类型是否符合实际要求。若把该参数设置为 0 表示不检查。

前面我们提到:可以通过挂载的方式把 namespace 保留下来。保留 namespace 的目的是为以后把进程加入这个 namespace 做准备。在 docker 中,使用 docker exec 命令在已经运行着的容器中执行新的命令就需要用到 setns() 函数。为了把新加入的 namespace 利用起来,还需要引入 execve() 系列的函数,该函数可以执行用户的命令,比较常见的用法是调用 /bin/bash 并接受参数运行起一个 shell。

unshar() 函数 和 unshare 命令

通过 unshare 函数可以在原进程上进行 namespace 隔离。也就是创建并加入新的 namespace 。unshare() 在 C 语言库中的声明如下:

#define _GNU_SOURCE
#include <sched.h>
int unshare(int flags);

和前面两个函数一样,C 语言库中的 unshare() 函数也是对 unshare() 系统调用的封装。调用 unshare() 的主要作用就是:不启动新的进程就可以起到资源隔离的效果,相当于跳出原先的 namespace 进行操作。

系统还默认提供了一个叫 unshare 的命令,其实就是在调用 unshare() 系统调用。

容器的限制:Cgroups

通过 Namespace 技术,实现了容器和容器间,容器与宿主机之间的隔离。但这还不够,想象这样一种场景,宿主机上运行着两个容器。虽然在容器间相互隔离,但以宿主机的视角来看的话,其实两个容器就是两个特殊的进程,而进程之间自然存在着竞争关系,自然就可以将系统的资源吃光。当然, 不能允许这么做的。

Cgroups 就是 Linux 内核中用来为进程设置资源的一个技术。

Linux Cgroups 全称是 Linux Control Group,主要的作用就是限制进程组使用的资源上限,包括 CPU,内存,磁盘,网络带宽。

还可以对进程进行优先级设置,审计,挂起和恢复等操作。

在之前的版本中,可通过 libcgroup tools 来管理 cgroup, 在 RedHat7 后,已经改为通过 systemctl 来管理。

systemd 在 Linux 中的功能就是管理系统的资源。而为了管理的方便,衍生出了一个叫 Unit 的概念,比如一个 unit 可以有比较宽泛的定义,比如可以表示抽象的服务,网络的资源,设备,挂载的文件系统等。为了更好的区分,Linux 将 Unit 的类型主要分为 12 种。

类型 作用
.automount 用于自动挂载配置的挂载点
.swap 描述系统的交换区,反映了设备或文件的路径
.target 在系统启动或者改变状态时,为其他 unit 提供同步点
.path 定义的文件路径,用于激活。
.service 一个服务或者一个应用,具体定义在配置文件中。
.socket 一个网络或者 IPC socket,FIFO buffer.
.device 描述一个需要被 systemd udevsysfs 文件系统管理的设备
.mount 定义的挂载点
.timer 定时器
.snapshot systemctl snapshot 命令自动创建的单元
.slice 用于关联 Linux Control Group 节点,根据关联的 slice 来限制进程。一个管理单元的组。Slice 并不包含任何进程,仅仅管理由 service 和 scope 组成的层级结构。
.scope systemd 从 bus 接口收到消息后自动创建。Scope 封装了任意进程通过 fork() 函数开启或停止的进程,并且在 systemd 运行时注册。例如:用户 sessions,容器和虚拟机。

Cgroup 中,主要使用的是 slice,、scope、service 这三种类型。

如创建一个临时 cgroup,然后对其启动的进程进行资源限制:

 # 创建一个叫 toptest 的服务,在名为 test 的 slice 中运行
systemd-run --unit=toptest --slice=test top -b

Running as unit toptest.service.

现在 toptest 的服务已经运行在后台了

# 通过 systemd-cgls 来查看 Cgroup 的信息
[root@localhost ~]#  systemd-cgls
├─1 /usr/lib/systemd/systemd --switched-root --system --deserialize 22
├─test.slice
│ └─toptest.service
│   └─6490 /usr/bin/top -b

# 通过 systemctl status 查看服务的状态
[root@localhost ~]# systemctl status toptest

现在对运行的 toptest 服务进行资源的限制。

# 先看下,没有被限制前的 Cgroup 的信息, 6490 为进程 PID
[root@localhost ~]# cat /proc/6490/cgroup
11:pids:/test.slice
10:blkio:/test.slice
9:hugetlb:/
8:cpuset:/
7:memory:/test.slice
6:devices:/test.slice
5:net_prio,net_cls:/
4:perf_event:/
3:freezer:/
2:cpuacct,cpu:/test.slice
1:name=systemd:/test.slice/toptest.service

# 对其使用的 CPU 和 内存进行限制
systemctl set-property toptest.service CPUShares=600 MemoryLimit=500M

# 再次查看 Cgroup 的信息,发现在 cpu 和 memory 追加了一些内容。
[root@localhost ~]# cat /proc/6490/cgroup
11:pids:/test.slice
10:blkio:/test.slice
9:hugetlb:/
8:cpuset:/
7:memory:/test.slice/toptest.service
6:devices:/test.slice
5:net_prio,net_cls:/
4:perf_event:/
3:freezer:/
2:cpuacct,cpu:/test.slice/toptest.service
1:name=systemd:/test.slice/toptest.service

这时可以在 /sys/fs/cgroup/memory/test.slice/sys/fs/cgroup/cpu/test.slice 目录下,多出了一个叫 toptest.service 的目录。

在其目录下 cat toptest.service/cpu.shares 可以发现,里面的 CPU 被限制了 600.

回到 Docker,其实 docker 和 上面做的操作基本一致,具体需要限制哪些资源就是在 docker run 里指定:

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

关于 docker 具体的限制,可以在 sys/fs/cgroup/cpu/docekr/ 等文件夹来查看。

容器镜像 - rootfs

容器技术的核心是通过 Namespace 限制了容器看到的视野,通过 Cgroup限制了容器可访问的资源。 但关于 Mount Namespace 还有一些特殊的地方,需要着重关注下。

Mount Namespace 特殊之处在于,除了在修改时需要进程对文件系统挂载点的认证,还需要显式声明需要挂载那些目录。在 Linux 系统中,有一个叫 chroot 的命令,可以改变进程的根目录到指定的位置。而 Mount Namespace 正是基于 chroot 的基础上发展出来的。

在容器内,应该看到完全独立的文件系统,而且不会受到宿主机以及其他容器的影响。这个独立的文件系统,就叫做 容器镜像。它还有一个更专业的名字叫 rootfsrootfs 中包含了一个操作系统所需要的文件,配置和目录,但并不包含系统内核。 因为在 Linux 中,文件和内核是分开存放的,操作系统只有在开启启动时才会加载指定的内核。这也就意味着,所有的容器都会共享宿主机上操作系统的内核。

rootfs和layer的设计

任何程序运行时都会有依赖,无论是开发语言层的依赖库,还是各种系统lib、操作系统等,不同的系统上这些库可能是不一样的,或者有缺失的。为了让容器运行时一致,docker将依赖的操作系统、各种lib依赖整合打包在一起(即镜像),然后容器启动时,作为它的根目录(根文件系统rootfs),使得容器进程的各种依赖调用都在这个根目录里,这样就做到了环境的一致性。

不过,这时发现了另一个问题:难道每开发一个应用,都要重复制作一次rootfs?

比如, 现在用Debian操作系统的ISO做了一个rootfs,然后又在里面安装了Golang环境,用来部署 的应用A。那么另一个同事在发布他的Golang应用B时,希望能够直接使用 安装过Golang环境的rootfs,而不是重复这个流程,那么UnionFS就派上用场了。

Docker镜像的设计中,引入了层(layer)的概念,也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量rootfs(一个目录),这样应用A和应用B所在的容器共同引用相同的Debian操作系统层、Golang环境层(作为只读层),而各自有各自应用程序层,和可写层。当然,这个想法不是凭空臆造出来的,而是用到了一种叫作联合文件系统(Union File System)的能力。

Union File System 也叫 UnionFS,最主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。

再次强调: rootfs只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。这就意味着,如果应用程序需要配置内核参数、加载额外的内核模块,以及跟内核进行直接的交互, 就需要注意了:这些操作和依赖的对象,都是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个“全局变量”,牵一发而动全身。

镜像中的层都是读写的,那么运行着的容器的运行时数据是存储在哪里?

镜像和容器在存储上的主要差别就在于容器多了一个读写层。镜像由多个只读层组成,通过镜像启动的容器在镜像之上加了一个读写层。通过下图知道可以通过 docker commit 命令基于运行时的容器生成新的镜像,那么 commit 做的其中一个工作就是将读写层数据写入到新的镜像中:

img

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

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

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

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

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

img

UnionFS

联合文件系统(Union File System):它可以把多个目录(也叫分支)内容联合挂载到同一个目录下,而目录的物理位置是分开的。UnionFS允许只读和可读写目录并存,就是说可同时删除和增加内容。UnionFS应用的地方很多,比如在多个磁盘分区上合并不同文件系统的主目录,或把几张CD光盘合并成一个统一的光盘目录(归档)。另外,具有写时复制(copy-on-write)功能UnionFS可以把只读和可读写文件系统合并在一起,虚拟上允许只读文件系统的修改可以保存到可写文件系统当中。

Union 文件系统是Docker镜像的基础。镜像可以通过分层来进行继承,基于基础镜像(没有父镜像) ,可以制作各 种具体的应用镜像。

而bootfs (boot file system)主要包含 ”bootloader“ 和 ”kernel“ , bootloader主要是引导加载kernel, Linux刚启动时会加载bootfs文件系统, 在Docker镜像的最底层是bootfs。这一层与典型的Linux/Unix系统是一样的 ,包含boot加载器和内核。当boot加载完成之后整个内核就都在内存中了,此时内存的使用权已由bootfs转交给内核,此时系统也会卸载bootfs.

rootfs (root file system) , 在bootfs之上。包含的就是典型Linux系统中的/dev, /proc, /bin, /etc等标准目录和文件。rootfs就是各种不同的操作系统发行版,比如Ubuntu , Centos等等。

平时安装进虚拟机的 Centos 都是好几个 G,为什么 docker 才 200MB?

对于一个精简的 OS,rootfs 可以是很小,只需要包含最基本的命令,工具和程序库就可以了,因为底层直接用 Host 的 kernel,自己只需要提供 rootfs 就可以了,由此可见对于不同的 linux 发行版,bootfs 基本是一致的,rootfs 会有差别,因此不同的发行版可以公用 bootfs。

各Linux版本的UnionFS

由于各种原因, Linux各发行版实现的UnionFS各不相同,所以Docker在不同linux发行版中使用的也不同。可以通过docker info来查看docker使用的是哪种。

上层镜像层中的文件覆盖了底层镜像层中的文件。这样就使得文件的更新版本作为一个新镜像层添加到镜像当中。Docker通过存储引擎(新版本采用快照机制)的方式来实现镜像层堆栈,并保证多镜像层对外展示为统一的文件 系统。

UnionFS的其他实现分别有: aufs, device mapper, btrfs, overlayfs, vfs, zfs。aufs是ubuntu 常用的,device mapper 是 centos,btrfs 是 SUSE,overlayfs ubuntu 和 centos 都会使用,现在最新的 docker 版本中默认两个系统都是使用的 overlayfs,vfs 和 zfs 常用在 solaris 系统。

顾名思义, 每种存储引擎都基于Linux 中对应的 文件系统或者块设备技术, 并且每种存储引擎都有其独有的性能特点。

可以通过 docker info 来查询使用的存储驱动,这里的是 overlay2

[root@localhost ~]# docker infoClient: Debug Mode: falseServer: Containers: 4  Running: 4  Paused: 0  Stopped: 0 Images: 4 Server Version: 19.03.8 Storage Driver: overlay2

Overlay2

在 Linux 的主机上,OverlayFS 一般有两个目录,但在显示时具体会显示为一个目录。这两个目录被称为层,联合在一起的过程称为 union mount。 在其下层的目录称为 lowerdir, 上层的目录称为 upperdir。两者联合后,暴露出来的视图称为 view, 听起来有点抽象,先看下整体结构:

overlayfs lowerdir, upperdir, merged

可以看到,lowerdir 其实对应的就是镜像层,upperdir 对应的就是容器器。而 merged 对应的就是两者联合挂载之后的内容。而且,当镜像层和容器层拥有相同的文件时,会以容器层的文件为准(最上层的文件为准)。通常来说,overlay2 支持最多 128 lower 层。

下面实际看下容器层和镜像具体的体现, 这台 linux 主机上,运行着 4 个 container。

Docker 一般的存储位置在 /var/lib/docker,先看下里面的结构:

[root@localhost docker]# ls -l /var/lib/dockertotal 16drwx------.  2 root root   24 Mar  4 03:39 builderdrwx--x--x.  4 root root   92 Mar  4 03:39 buildkitdrwx------.  7 root root 4096 Jun  1 10:36 containersdrwx------.  3 root root   22 Mar  4 03:39 imagedrwxr-x---.  3 root root   19 Mar  4 03:39 networkdrwx------. 69 root root 8192 Jun  1 15:01 overlay2drwx------.  4 root root   32 Mar  4 03:39 pluginsdrwx------.  2 root root    6 Jun  1 15:00 runtimesdrwx------.  2 root root    6 Mar  4 03:39 swarmdrwx------.  2 root root    6 Jun  1 15:01 tmpdrwx------.  2 root root    6 Mar  4 03:39 trustdrwx------.  3 root root   45 May 18 10:28 volumes

需要着重关注的是 container, image, overlay2 这几个文件夹。

  • container:这个不用多说,正在运行或创建的容器会在这个目录下。
  • mage:对应记录的就是镜像。
  • overlay2:记录的是每个镜像下包含的 lowerrdir.

之前提到,unionfs 的实现可能有多种,比如 overlay2、aufs、devicemapper`等。那么自然在 image 文件夹下,就会存在多种驱动的文件夹,:

image/└── overlay2    ├── distribution    ├── imagedb    │   ├── content    │   └── metadata    ├── layerdb    │   ├── mounts    │   ├── sha256    │   └── tmp    └── repositories.json

这里的 imagedblayerdb, 就是存储元数据的地方。前面说过,容器的文件系统构成就是通过 image 层 和 container 层联合构成的,而每个 image 可能是由多个层构成。这就意味着,每个层可能会被多个 image 引用。那么之间是如何关联的呢?答案就在 imagedb 这个文件下。

这里 以 mysql 镜像为例:

# 查看 mysql 的镜像 id[root@localhost docker]# docker image lsREPOSITORY          TAG                 IMAGE ID            CREATED             SIZEctg/mysql           5.7.29              84164b03fa2e        3 months ago        456MB# 进入到 imagedb/content/sha256 目录, 可以找到对应的镜像 id[root@localhost docker]# ls -l image/overlay2/imagedb/content/sha256/...-rw-------. 1 root root  6995 Apr 27 02:45 84164b03fa2ecb33e8b4c1f2636ec3286e90786819faa4d1c103ae147824196a# 接着看下里面记录的内容, 这里截取有用的部分cat  image/overlay2/imagedb/content/sha256/84164b03fa2ecb33e8b4c1f2636ec3286e90786819faa4d1c103ae147824196a{.........  "os": "linux",  "rootfs": {    "type": "layers",    "diff_ids": [      "sha256:f2cb0ecef392f2a630fa1205b874ab2e2aedf96de04d0b8838e4e728e28142da",      "sha256:a9f6b7c7101b86ffaa53dc29638e577dabf5b24150577a59199d8554d7ce2921",      "sha256:0c615b40cc37ed667e9cbaf33b726fe986d23e5b2588b7acbd9288c92b8716b6",      "sha256:ad160f341db9317284bba805a3fe9112d868b272041933552df5ea14647ec54a",      "sha256:1ea6ef84dc3af6506c26753e9e2cf7c0d6c1c743102b85ebd3ee5e357d7e9bc4",      "sha256:6fce4d95d4af3777f3e3452e5d17612b7396a36bf0cb588ba2ae1b71d139bab9",      "sha256:6de3946ea0137e75dcc43a3a081d10dda2fec0d065627a03800a99e4abe2ede4",      "sha256:a35a4bacba4d5402b85ee6e898b95cc71462bc071078941cbe8c77a6ce2fca62",      "sha256:1ff9500bdff4455fa89a808685622b64790c321da101d27c17b710f7be2e0e7e",      "sha256:1cf663d0cb7a52a3a33a7c84ff5290b80966921ee8d3cb11592da332b4a9e016",      "sha256:bcb387cbc5bcbc8b5c33fbfadbce4287522719db43d3e3a286da74492b7d6eca"    ]  }}

可以看到 mysql 镜像由 11 层组成,其中 f2cb 是最低层,bcb3 是最上层。

接着, 看下 layerdb 的内容:

[root@localhost docker]# ls -l  image/overlay2/layerdb/total 8drwxr-xr-x.  6 root root 4096 May 13 13:38 mountsdrwxr-xr-x. 39 root root 4096 Apr 27 02:51 sha256drwxr-xr-x.  2 root root    6 Apr 27 02:51 tmp# 首先看下 sha256 目录下的内容[root@localhost docker]# ls -l  image/overlay2/layerdb/sha256/total 0....drwx------. 2 root root 71 Apr 27 02:45 bbb9cccab59a16cb6da78f8879e9d07a19e3a8d49010ab9c98a2c348fa116c87drwx------. 2 root root 71 Apr 27 02:45 f2cb0ecef392f2a630fa1205b874ab2e2aedf96de04d0b8838e4e728e28142da....

可以发现,在这里仅能找到最底层的层 ID,原因在于层之间的关联是通过 chainID 的方式保存的,简单来说就是通过 sha256 算法后能计算出一层的容器 id.

比如这里,最底层 id 是 f2cb0ecef392f2a630fa1205b874ab2e2aedf96de04d0b8838e4e728e28142da , 上一层 id 是 a9f6b7c7101b86ffaa53dc29638e577dabf5b24150577a59199d8554d7ce2921, 那么对应在 sha256 目录下的下一层 id 的计算方法就是:

[root@localhost docker]# echo -n "sha256:f2cb0ecef392f2a630fa1205b874ab2e2aedf96de04d0b8838e4e728e28142da sha256:a9f6b7c7101b86ffaa53dc29638e577dabf5b24150577a59199d8554d7ce2921" | sha256sumbbb9cccab59a16cb6da78f8879e9d07a19e3a8d49010ab9c98a2c348fa116c87  -

接着 可以在 sha256 目录下,找到 bbb9.. 这层的内容。

现在把镜像和层关联起来,但之前说过,image 目录下存的都是元数据。真实的 rootfs 其实在另一个地方 - /docker/overlay2 下。

# 通过查询 cache-id,得到就是真实的 rootfs 层[root@localhost docker]# cat  image/overlay2/layerdb/sha256/f2cb0ecef392f2a630fa1205b874ab2e2aedf96de04d0b8838e4e728e28142da/cache-id2996b24990e75cbd304093139e665a45d96df8d7e49334527827dcff820dbf16[

进入到 /docker/overlay2 下查看:

[root@localhost docker]# ls -l overlay2/total 4...drwx------. 3 root root   47 Apr 27 02:45 2996b24990e75cbd304093139e665a45d96df8d7e49334527827dcff820dbf16...drwx------. 2 root root 4096 May 13 13:38 l

这样真实的 rootfs 层也被找到了。

这里重新梳理下, 先是在 mage/overlay2/imagedb/content/sha256/ ,根据 image id 查看该 image 具有所有的层ID,然后根据最底层ID和上层ID通过 sha256 计算得到,引用的上一层 ID, 依次类推,关联所有的层。最后通过每一层的 cache-id,将元数据和真实的 rootfs 层数据对应起来了。

AUFS

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

AUFS 将镜像层(只读)组织成多个目录,在 AUFS 的术语中成为 branch。运行时容器文件会作为一层容器层(container lay,读写)覆盖在镜像层之上。最后通过联合挂载技术进行呈现。下图是 AUFS 的文章组织架构的示意图。由于 AUFS 可以算是一种过时的技术,所以这里就不在赘述了。

img

总结 - rootfs的构成

每个 rootfs 由镜像层(lowerdir)和 容器层(upperdir)构成,其中镜像层只能只读,而容器层则能读写。而且镜像层可有最多128层构成。

其实,rootfs 构成还有另外一层,但由于在进行提交或编译时,不会把这层加进去,所以就没把这层算在rootfs里面,但实际上存在的。

在之前查看 /var/lib/docker/overlay2/ 下镜像层,会看到好几个以 -init 结尾的目录,而且数量恰好等于容器的数量。这层夹在镜像层之上,容器层之下。是由 docker 内部单独生成的一层,专门用于存放 etc/hosts/etc/resolv.conf 等配置信息。存在的目的,是由于用户在容器启动时,需要配置一些特定的值,比如 hostname,dns 等,但这些配置仅对当前容器有效,放到其他环境下自然有别的配置,所以这层被单独拿出来,在提交镜像时,忽略这一层。

容器运行时

容器运行时顾名思义就是要掌控容器运行的整个生命周期,以 docker 为例,其作为一个整体的系统,主要提供的功能如下:

  • 制定容器镜像格式
  • 构建容器镜像 docker build
  • 管理容器镜像 docker images
  • 管理容器实例 docker ps
  • 运行容器 docker run
  • 实现容器镜像共享 docker pull/push

然而这些功能均可由小的组件单独实现,且没有相互依赖。而后 Docker 公司与 CoreOS 和 Google 共同创建了 OCI (Open Container Initial),并提供了两种规范:

filesystem bundle(文件系统束): 定义了一种将容器编码为文件系统束的格式,即以某种方式组织的一组文件,并包含所有符合要求的运行时对其执行所有标准操作的必要数据和元数据,即config.json 与 根文件系统。

而后,Docker、Google等开源了用于运行容器的工具和库 runc,作为 OCI 的一种实现参考。在此之后,各种运行时工具和库也慢慢出现,例如 rkt、containerd、cri-o 等,然而这些工具所拥有的功能却不尽相同,有的只有运行容器(runc、lxc),而有的除此之外也可以对镜像进行管理(containerd、cri-o)。目前较为流行的说法是将容器运行时分成了 low-level 和 high-level 两类。

low-level: 指的是仅关注运行容器的容器运行时,调用操作系统,使用 namespace 和 cgroup 实现资源隔离和限制。high-level: 指包含了更多上层功能,例如 grpc调用,镜像存储管理等。

不同工具的关系如下图:

img

low-level runtime

low-level runtime 关注如何与操作系统交互,创建并运行容器。目前常见的 low-level runtime有:

  • lmctfy -- 是Google的一个项目,它是Borg使用的容器运行时
  • runc -- 目前使用最广泛的容器运行时。它最初是作为Docker的一部分开发的,后来被提取出来作为一个单独的工具和库。其实现了 OCI 规范,包含config.json文件和容器的根文件系统。
  • rkt -- CoreOS开发的Docker/runc的一个流行替代方案,提供了其他 low-level runtimes (如runc)所提供的所有特性。

创建一个简单的 runtime。

以 busybox 镜像作为运行时的一个根文件系统,首先创建一个临时目录并将 busybox 中的所有文件解压缩到目录中

CID=$(docker create busybox)ROOTFS=$(mktemp -d)docker export $CID | tar -xf - -C $ROOTFS

限制需要创建 cgroup 对内存和cpu进行限制

UUID=$(uuidgen)cgcreate -g cpu,memory:$UUID# 内存限制设置为 100MBcgset -r memory.limit_in_bytes=100000000 $UUID# cpu 限制设置为 512mcgset -r cpu.shares=512 $UUID

上面 cpu.shares 是相对于同时运行的其他进程的CPU。单独运行的容器可以使用整个CPU,但是如果其他容器正在运行,它们会按照比例分配cpu资源。除此以外,还可以对cpu内核数量的使用进行限制:

# 设置检查CPU使用情况的频率,单位是微秒cgset -r cpu.cfs_period_us=1000000 $UUID# 设置任务在一个时间段内在一个核心上运行的时间量,单位是微秒cgset -r cpu.cfs_quota_us=2000000 $UUID

然后使用 unshare 命令在 cgroug 中执行命令,它可以实现 namespace 的隔离。

cgexec -g cpu,memory:$UUID \>     unshare -uinpUrf --mount-proc \>     sh -c "/bin/hostname $UUID && chroot $ROOTFS /bin/sh"/ echo "Hello from in a container"Hello from in a container

最后,在执行结束后,通过下面的指令清理环境

cgdelete -r -g cpu,memory:$UUIDrm -r $ROOTFS

high-level runtime

High-level runtimes相较于low-level runtimes位于堆栈的上层。low-level runtimes负责实际运行容器,而High-level runtimes负责传输和管理容器镜像,解压镜像,并传递给low-level runtimes来运行容器。目前主流的 high-level runtime 有:

  • docker
  • containerd
  • rkt

这里以 containerd 为例具体解析整个架构以及工作原理。

Containerd

containerd 的架构图如图

img

其中,grpc 模块向上层提供服务接口,metrics 则提供监控数据(cgroup 相关数据),两者均向上层提供服务。containerd 包含一个守护进程,该进程通过本地 UNIX 套接字暴露 grpc 接口。

storage 部分负责镜像的存储、管理、拉取等metadata 管理容器及镜像的元数据,通过bootio存储在磁盘上task -- 管理容器的逻辑结构,与 low-level 交互event -- 对容器操作的事件,上层通过订阅可以知道发生了什么事情Runtimes -- low-level runtime(对接 runc)

containerd 主要流程如下:

img

图中的 containerEngine 在 docker 中就是 docker-containerd 组件,创建容器记录的metadata,并请求 containerd 的 task 模块,task 模块会在 runtime 中创建 task 实例,分别会加入 task list, 监控 cgroup 等操作,每个 task 实例则调用 shim 去创建container。

containerd-shim

containerd-shim 是 containerd 的一个组件,主要是用于剥离 containerd 守护进程与容器进程。containerd 通过 shim 调用 runc 的包函数来启动容器。当执行 pstree 命令时,可以看到如下的进程关系:

img

引入shim,允许runc 在创建和运行容器之后退出,并将 shim 作为容器的父进程,而不是 containerd 作为父进程,这样做的目的是当 containerd 进程挂掉,由于 shim 还正常运行,因此可以保证容器不受影响。此外,shim 也可以收集和报告容器的退出状态,不需要 containerd 来 wait 容器进程。

当有需求去替换 runc 运行时工具库时,例如替换为安全容器 kata container 或 Google 研发的 gViser,则需要增加对应的shim(kata-shim等),以上两者均有自己实现的 shim。

容器创建过程

首先,用户通过Docker client输入docker run来创建被运行一个容器。Docker client主要的工作是通过解析用户所提供的一系列参数后,分别发送了这样两条请求:

"POST", "/containers/create?"+containerValues"POST", "/containers/"+createResponse.ID

这样client的工作也就完成了,很显然client做的事情很少,主要是负责给Docker daemon发送请求。不过,通过client所发送的两条请求, 可以很自然的把docker run的整个执行过程分成create与start两个阶段。

Create阶段

这阶段Docker daemon的主要工作是对client提交的POST表单进行分析整理,获得具有可移植性的配置参数结构体config和不可移植的配置结构体hostconfig。然后daemon会调用daemon.newContainer函数来创建一个基本的container对象,并将config和hostconfig中保存的信息填写到container对象中。当然此时的container对象并不是一个具体的物理容器,它其中保存着所有用户指定的参数和Docker生成的一些默认的配置信息。最后,Docker会将container对象进行JSON编码,然后保存到其对应的状态文件中。

上述过程完成后,一个容器的基本配置信息就已经完全具备,用户可以使用docker inspect来查看这个容器所对应的各种配置信息。

start阶段

完成了create阶段后,client紧接着会发送start请求来启动一个真正的物理容器。当Docker daemon接收到这个start请求后,会使用在create阶段配置好的container对象中的各种配置参数来完成volume挂点的注册,容器网络的创建和创建并启动物理容器等工作。下面先对容器的网络环境创建过程做一个介绍。

volume挂载点的注册

Docker daemon在将hostconfig配置到容器配置信息的过程中,会调用daemon.registerMountPoints函数对client提供的POST表单中的volume相关信息进行注册,并以mountpoint的形式存储在容器的配置信息中,在真正启动物理容器的时候才会进行挂载。

Volume的挂载点可以分为两类,一类为使用其他容器中的挂载点,另一类为用户指定的绑定挂载。下面 来看一下两种volume挂载点的注册流程。

  • 使用其他容器中的挂载点:在对这类挂载点进行注册时,首先会使用容器的id在Docker daemon中查找对应的结构体。然后遍历其中的所有挂载点,并且将其中的挂载点信息全部都注册到当前的容器结构体之中。
  • 用户指定的绑定挂载:用户指定的绑定挂载可以有Source Path:Destination Path的格式,也可以是Name:Destination Path的格式。如果用户输入的参数是Source Path:Destination Path的格式那么,daemon会解析其中的Source Path和Destination Path,并使用它们注册对应的挂载点。如果用户输入的参数是Name:Destination Path的格式,那么daemon会查找用户提供的Name是否已经对应了一个使用docker volume已经创建好了的挂载点信息,如果是的话,则会使用这个挂载点的信息和用户提供的Destination Path进行本容器的挂载点注册。如果这个Name在daemon中没有对应的挂载点的话,daemon则会在其默认文件夹下创建一个目录,作为挂载点中的Source Path,然后使用用户提供的Destination Path和自行创建的Source Path进行本容器的挂载点注册。

在完成了挂载点的注册之后,daemon会将所有的挂载点信息更新到容器的配置信息中,以备后续使用。

网络的创建

Docker daemon使用client提供的POST表单中网络相关的参数,通过调用daemon.initializeNetworking函数来完成容器网络栈的创建和配置。daemon.initializeNetworking函数则通过对Docker的网络依赖库(即libnetwork)的一系列调用,来完成容器的网络栈创建和配置等工作。

要理解Docker容器的网络部分的执行流程,那么首先要清楚libnetwork中的三个核心概念。

  • 沙盒(Sandbox):一个沙盒包含了一个容器网络栈的信息。沙盒可以对容器的接口,路由和DNS设置等进行管理。沙盒的实现可以是Linux Network Namespace, FreeBSD Jail或者类似的机制。一个沙盒可以有多个端点(Endpoint)和多个网络(Network)。
  • 端点(Endpoint):一个端点可以加入一个沙盒和一个网络。端点的实现可以是veth pair, Open vSwitch内部端口或者相似的设备。一个端点只可以属于一个网络并且只属于一个沙盒。
  • 网络(Network):一个网络是一组可以直接互相联通的端点。网络的实现可以是Linux bridge,VLAN等等。一个网络可以包含多个端点。

清楚了以上三个核心概念之后, 从Docker源码的角度并通过Docker中默认的网络模式(bridge模式)来看一下容器网络栈的创建过程。

  • 在Docker daemon启动之后,会创建一个默认的network,其本质工作就是创建了一个名为docker0的默认网桥。
  • 确定默认网桥之后,daemon会调用container.BuildCreateEndpointOptions来创建此容器中endpoint的配置信息。然后再调用Network.CreateEndpoint使用上面配置好的信息创建对应的endpoint。在bridge模式中,libnetwork创建的设备是veth pair。Libnetwork中调用netlink.LinkAdd(veth)进行了veth pair的创建,把其中的的一个veth设备是加入到docker0网桥中,另一个则是为了sandbox所准备的。
  • 接下来daemon会调用daemon.buildSandboxOptions来创建此容器的sandbox,然后调用Network.NewSandbox来创建属于此容器的新的sandbox。libnetwork在接收到创建sandbox的请求后,会使用系统调用为容器创建一个新的netns,并将这个netns的路径返写入到对应容器的配置信息中,以便后续的使用。
  • 最后,daemon会调用ep.Join(sb)将endpoint加入到容器对应的sandbox中。先将endpoint加入到容器对应的sandbox中,然后对endpoint的ip信息和gateway等信息进行配置,并将所有的信息更新到对应容器的配置信息中。

容器的创建和启动

在完成创建容器的各种准备工作之后,Docker daemon会通过对libcontainer的一系列调用来完成容器的创建和启动工作。Libcontainer是Docker的运行时库,它可以通过调用者提供的配置参数来创建并运行一个容器出来,下面 来看一Docker是如何使用之前配置的结构体中的各项参数,通过libcontainer创建并运行一个容器的。

一、创建逻辑容器Container与逻辑进程process

所谓的逻辑容器container和逻辑进程process并非时真正运行着的容器和进程,而是libcontainer中所定义的结构体。逻辑容器container中包含了namespace,cgroups,device和mountpoint等各种配置信息。逻辑进程process中则包含了容器中所要运行的指令以其参数和环境变量等。

Docker daemon会调用execdriver.Run来完成和libcontainer的一系列交互工作。首先将会将所有和新建容器相关的参数装入可以被libcontainer使用的结构体config中。然后使用config作为参数来调用libcontainer.New()生成用来产生container的工厂factory。再调用factory.Create(config),就会生成一个将config包含其中的逻辑容器container。接下来调用newProcess(config)来将config中关于容器内所要运行命令的相关信息填充到process结构体中,这个结构体即为逻辑进程process。使用container.Start(process)来启动逻辑容器。

二、启动逻辑容器container

Docker daemon会调用linuxContainer.Start来启动逻辑容器。这个函数的主要工作就是调用newParentProcess()来生成parentprocess实例(结构体)和用于runC与容器内init进程相互通信的管道。

在parentprocess实例中,除了有记录了将来与容器内进程进行通信的管道与各种基本配置等,还有一个极为重要的字段就是其中的cmd。

cmd字段是定义在os/exec包中的一个结构体。os/exec包主要用于创建一个新的进程,并在这个进程中执行指定的命令。开发者可以在工程中导入os/exec包,然后将cmd结构体进行填充,即将所需运行程序的路径和程序名,程序所需参数,环境变量,各种操作系统特有的属性和拓展的文件描述符等。

在Docker中程序将cmd的应用路径字段Path填充为/proc/self/exe(即为应用程序本身,Docker)。参数字段Args填充为init,表示对容器进行初始化。SysProcAttr字段中则填充了各种Docker所需启用的namespace(其中包括前面所讲到的netns路径)等属性。

然后调用parentprocess.cmd.Start()启动物理容器中的init进程。接下来将物理容器中init进程的进程号加入到Cgroup控制组中,对容器内的进程实施资源控制。再把配置参数通过管道传送给init进程。最后通过管道等待init进程根据上述配置完成所有的初始化工作,或者出错退出。

三、物理容器的配置和创建

容器中的init进程首先会调用StartInitialization()函数,通过管道从父进程接收各种配置参数。然后对容器进行如下配置:

  1. 将init进程加入其指定的namespace中,这里会将init进程加入到前面已经创建好的netns中,这样init进程就拥有了自己独立的网络栈,完成了网络创建和配置的最后一步。
  2. 设置进程的会话ID。
  3. 使用系统调用,将前面注册好的挂载点全部挂载到物理主机上,这样就完成了volume的创建。
  4. 对指定目录下的文件系统进行挂载,并切换根目录到新挂载的文件系统下。设置hostname,加载profile信息。
  5. 最后使用exec系统调用来执行用户所指定的在容器中运行的程序。

这样就完成了一个容器的创建和启动过程。

Docker 原生健康检查

自 1.12 版本之后,Docker 引入了原生的健康检查实现,可以在 Dockerfile 中声明应用自身的健康检测配置。健康检查 HEALTHCHECK 指令告诉 Docker 如何检查容器是否仍在工作。 它能够监测类似一个服务器虽然服务进程仍在运行, 但是陷入了死循环, 不能响应新的请求的情况。

当一个容器有指定健康检查时, 它除了普通状态之外, 还有健康状态 (health status) 。 健康状态的初始状态是正在启动 (starting) , 一旦通过了一个健康检查, 它将变成健康 (healthy) (不管之前的状态是什么), 经过一定数量的连续失败之后, 它将变成不健康 (unhealthy) 。

健康检查指令有两种形式:

  • HEALTHCHECK [OPTIONS] CMD command 通过运行容器内的一个指令来检查容器的健康情况;
  • HEALTHCHECK NONE 禁用任何(包括基层至父镜像)健康检查指令。

可以出现在 CMD 之前的选项有:

  • --interval=DURATION 间隔时间, 默认 30s (30秒);
  • --timeout=DURATION 超时时间, 默认 30s (30秒);
  • --start-period=DURATION 启动时间, 默认 0s, 如果指定这个参数, 则必须大于 0s ;
  • --retries=N 重试次数, 默认 3 ;

健康检查会在容器启动后的间隔时间内运行, 在上一次检查完成之后, 按照指定的间隔时间再次运行。

如果单次健康检查的时间超过了指定的超时时间,则认为是失败的。

如果连续失败次数超过了指定的重试次数, 则容器的健康状态将被视为不健康 (unhealthy) 。

start-period 为需要启动的容器提供了初始化的时间段, 在这个时间段内如果检查失败, 则不会记录失败次数。 如果在启动时间内成功执行了健康检查, 则容器将被视为已经启动, 如果在启动时间内再次出现检查失败, 则会记录失败次数。

一个 Dockerfile 中只能有一个健康检查指令生效, 如果出现了多个, 则只有最后一个有效。

CMD 关键字之后的 command 可以是一个 shell 命令(例如: HEALTHCHECK CMD /bin/check-running)或者一个 exec 数组(与其它 Dockerfile 命令相同, 参考 [ENTRYPOINT])。

该命令的返回值说明了容器的状态:

  • 0: healthy - 容器健康, 可以使用;
  • 1: unhealthy - 容器工作不正常, 需要诊断;
  • 2: reserved - 保留, 不要使用这个返回值;

例如, 每隔 5 分钟检查一个网络服务器能够在 3 秒内响应主页的请求:

HEALTHCHECK --interval=5m --timeout=3s \  CMD curl -f http://localhost/ || exit 1

为了帮助调试失败信息, 任何向 stdout 或者 stderr 的文本输出会被记录下来(使用 UTF-8 编码), 并保存在容器的健康状态中, 可以使用 docker inspect 命令查询。 健康健康检查的错误输出应该尽可能的简短, 目前只保存前面的 4k 字符。

当容器的健康状态发生变化时, 将会产生一个 health_status 事件, 这个时间将会携带新的状态。

指令使用

如果没有为容器指定健康检查指令, 则使用 docker ps 时, 返回列表如下:

CONTAINER ID        IMAGE                 COMMAND                  CREATED             STATUS              PORTS                    NAMES72d9db1c503d        beginor/jexus:5.8.3.0   "docker-entrypoint.s…"   9 days ago          Up 7 days           0.0.0.0:8088->80/tcp   jexus

在 status 那一列只能显示 Up 7 days , 表示 7 天前启动, 不能显示容器的健康状况。

如果指定了容器指定健康检查指令, 则输出为:

CONTAINER ID        IMAGE      COMMAND      CREATED        STATUS        PORTS       NAMES10ec32c21b2e        beginor/jexus:5.8.3.1   "docker-entrypoint.s…"   2 weeks ago         Up 1 days (healthy)   0.0.0.0:8088->80/tcp   jexus

可以看到, 在 status 那一列显示为 Up 1 days (healthy)

镜像创建过程

扩展

BuildKit

从版本18.09开始,Docker支持由moby / buildkit 项目提供的用于执行构建的新后端。与旧的实现相比,BuildKit后端提供了许多好处。例如,BuildKit可以:

  • 检测并跳过执行未使用的构建阶段
  • 并行构建独立的构建阶段
  • 两次构建之间仅增量传输构建上下文中的已更改文件
  • 在构建上下文中检测并跳过传输未使用的文件
  • 使用具有许多新功能的外部Dockerfile实现
  • 避免其他API(中间图像和容器)的副作用
  • 优先考虑构建缓存以进行自动修剪

要使用BuildKit后端,需要DOCKER_BUILDKIT=1在CLI上设置环境变量 ,然后再调用docker build

要了解基于BuildKit的构建可用的实验性Dockerfile语法,请参阅BuildKit存储库中的文档

Buildx

由于Docker守护进程中的某些限制,Docker并未充分发挥BuildKit的全部功能。因此,对Docker客户端CLI进行了扩展以提供插件框架,该框架允许使用插件来扩展可用的CLI功能。一个名为Buildx的实验性插件会绕过守护程序中的旧版构建函数,它使用BuildKit后端进行所有构建。该工具提供了所有熟悉的镜像构建命令和功能,但通过一些特定于BuildKit的附加功能对其进行了扩充。

buildx是Docker CLI插件,用于BuildKit扩展的构建功能:https://github.com/docker/buildx

主要特征:

  • 熟悉的用户界面来自 docker build
  • 带有容器驱动程序的完整BuildKit功能
  • 多个构建器实例支持
  • 用于跨平台图像的多节点构建
  • 撰写构建支持
  • 在制品:高级构建构造(bake
  • 容器内驱动程序支持(Docker和Kubernetes)

BuildKit(通过Buildx扩展)支持多个构建器实例。这是一项重要功能,其他镜像构建工具中没有类似功能。它实际上意味着可以出于构建目的共享一组构建器实例。也可能为一个项目分配了一组构建器实例,而另一个项目则分配了另一组。

$ docker buildx lsNAME/NODE DRIVER/ENDPOINT STATUS  PLATFORMSdefault * docker                  default default         running linux/amd64, linux/386

默认情况下,Buildx插件以Docker驱动程序为目标,该驱动程序使用Docker守护程序提供的BuildKit库,但存在其固有的局限性。另一个驱动程序是docker-container,它透明地在容器内启动BuildKit来执行构建。它可以提供BuildKit全部功能。第三个用于Kubernetes的驱动程序可以让BuildKit实例以Pod的方式在Kubernetes中运行。这特别有趣,因为它可以启动在Kubernetes中运行的BuildKit的构建——全部来自Docker CLI。这是否是理想的工作流程,这完全取决于个人或公司的选择。

参考

一篇文章带你吃透 Docker 原理

https://help.aliyun.com/document_detail/58588.html Docker 容器健康检查机制

https://beginor.github.io/2018/03/11/healthy-check-instruction-of-docker.html Docker 容器的健康检查

http://www.dockone.io/article/1239 Docker run执行流详解(以volume,network和libcontainer为线索)

https://www.jianshu.com/p/3ba255463047 Docker技术原理之Linux UnionFS(容器镜像)

https://haojianxun.github.io/2018/05/03/理解docker的rootfs和分层构建联合挂载的概念/

https://www.zhihu.com/column/c_1196004665702080512 深入理解container--容器运行时

https://learnku.com/articles/47844 Docker 镜像讲解

https://www.crblog.cc/docker/_book/chapter1/08docker镜像分层技术.html

Linux Namespace : 简介


免责声明!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系本站邮箱yoyou2525@163.com删除。



 
粤ICP备18138465号  © 2018-2025 CODEPRJ.COM