容器技术

从过去物理机和虚拟机为主体的开发运维环境,向以容器为核心的基础设施的转变过程,并不是一次温和的改革,而是涵盖了对网络、存储、调度、操作系统、分布式原理等各个方面的容器化理解和改造。

关于Linux内核、分布式系统、网络、存储等是真正掌握容器技术体系的精髓所在

“编排”容器都是容器云项目的灵魂所在,也是kubernetes社区持久生命力的源泉,从分布式系统设计的视角出发,抽象和归纳出这些特性中体现出来的普遍方法,“开源生态”永远都是容器技术和kubernetes项目成功的关键。

简介

PaaS

PaaS提供应用托管能力:租用一批AWS或OpenStack的虚拟机,然后就像管理物理服务器那样,用脚本或手工方式在这些机器上部署应用。当时的云计算项目,比的就是谁能更好地模拟本地服务器环境,能带来更好的上云体验。

PaaS开源项目的出现,就是解决这个问题的最佳方案,核心是一套应用的打包和分发机制

  1. 用户把应用的可执行文件和启动脚本打进一个压缩包,上传到云上存储

  2. 开源PaaS项目会通过调度器选择一个可以运行这个应用的虚拟机,然后通知机器上的Agent把应用的压缩包下载下来启动

需要在同一个虚拟机上启动很多个来自不同用户的应用,PaaS开源项目会调用操作系统的Cgroup和Namespace机制为每个应用单独创建一个沙盒的隔离环境,然后在沙盒中启动这些应用,实现了把多个用户的应用互不干涉地在虚拟机里批量地、自动地运行起来的目的。这正是PaaS项目最核心的能力,运行应用的隔离环境,或者说沙盒,就是所谓的容器。

Docker

Docker项目与大部分PaaS项目的功能和实现原理是一样的,剩下不同的一小部分使得Docker成为无敌的存在。

Docker Image

PaaS能够帮助用户大规模部署应用到集群里,是因为它提供了一套应用打包的功能,也是这个功能让PaaS凉了。用户需要为不同的语言、框架,甚至每个版本的应用都维护一个打好的包,并且打好的包在本地能运行,上了云可能要修改很多参数和配置。虽然PaaS项目能够一键部署,但是为每个应用打包的工作太累。

Docker镜像解决的就是打包问题

  • 所谓镜像就是一个压缩包,里面包含的内容比PaaS的应用可执行文件和启停脚本的组合要丰富

  • 大多数Docker镜像是直接由一个完整的操作系统的所有文件和目录构成,所以这个压缩包里的内容和本地的开发测试环境用的操作系统是完全一样的

这是Docker镜像最厉害的地方,只要有了这个压缩包,在使用某种技术创建一个沙盒,在沙盒中解压这个包,就可以运行本地开发的程序了。这个过程中完全不需要修改任何的配置,这个压缩包使得本地环境和云环境高度一致。

Docker Swarm

镜像解决了应用打包的难题,还需要有组件能够完成大规模应用部署的职责。Docker Swarm,原生的容器集群管理项目。

Docker Compose

由Fig项目改名而来。

编排指用户如何通过某些工具或者配置来完成一组虚拟机以及关联资源的定义、配置、创建、删除等工作,然后由云计算平台按照这些指定的逻辑来完成过程。

容器时代、编排就是对Docker容器的一系列定义、配置和创建动作的管理。

基于Kubernetes API 和扩展接口

  • 目前热度极高的微服务治理项目 Istio

  • 被广泛采用的有状态应用部署框架 Operator

  • 还有像 Rook 这样的开源创业项目,它通过kubernetes的可扩展接口,把ceph这样重量级产品封装成了简单易用的容器存储插件

Kubernetes社区以开发者为核心,构建一个相对民主和开放的容器生态。

隔离与限制

  • 容器技术的兴起源于PaaS技术的普及

  • Docker项目具有里程碑式的意义

  • Docker项目通过“容器镜像”,解决了应用打包这个根本性难题

容器本身没有价值,有价值的是“容器编排"。

进程

Docker 容器单进程是只有一个进程是可控的(回收与生命周期管理),不是只能运行一个进程,其他运行的进程是不受docker控制的。

数据 + 代码本身的二进制文件,放在磁盘上,就是我们平常说的一个“程序”,也叫可执行镜像。

计算机运行程序的过程:

  1. 操作系统从“程序”中发现输入数据保存在一个文件中,这些数据被加载到内存中待命

  2. 操作系统又读取到代码表示的计算指令,指示CPU完成相应操作

  3. CPU与内存协作进行运算,需要使用寄存器存放数值,内存堆栈保存执行的命令和变量

  4. 计算机中被打开的文件,各种各样的I/O设备在不断调用中修改自己的状态

“程序”被执行起来,从磁盘上的二进制文件,变成了计算机内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件,以及各种设备状态信息的一个集合。这样一个程序运行起来后的计算机执行环节的总和,就是进程。

进程

  • 静态表现:安安静静待在磁盘上的程序

  • 动态表现:计算机里数据和状态的总和

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

对于Docker等大多数Linux容器来说:

  • Cgroups技术是用来制作约束的主要手段

  • Namespace技术则是用来修改进程视图的主要方法

Namespace

Namespace其实只是Linux创建新进程的一个可选参数,在Linux系统中创建进程的系统调用是clone(),如下:

# 创建一个新的进程,并返回它的进程号pid
int pid = clone(main_function, stack_size, SIGCHLD, NULL);

当调用clone()系统调用创建一个新进程时,可以在参数中指定CLONE_NEWPID参数,比如:

# 创建的这个进程会“看到”一个全新的进程空间,这个进程空间里,它的PID是1。但是在宿主机真实的进程空间里,这个进程的PID还是真实的数值。
int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);

通过多次执行clone()调用,可以创建多个PID Namespace,每个Namespace里的进程,都认为自己是当前容器的第1号进程,它即看不到宿主机里面真真的进程空间,也看不到其他PID Namespace里的具体情况。除了PID Namespace,Linux操作系统还通过了Mount、UTS、IPC、Network和User这些Namespace,用来对各种进程上下文进行隔离操作

比如:

  • Mount Namespace:用来让被隔离进程只看到当前Namespace里的挂载点信息

  • Network Namespace:用来让被隔离的进程只看到当前Namespace里的网络设备和配置

这是Linux容器最基本的实现原理,容器其实是一种特殊的进程而已。

Docker容器实际上是在创建进程时,指定这个进程所需启用的一组Namespace参数,这样容器就只能看到当前Namespace所限定的资源、文件、设备、状态、配置。对于宿主机以及其他不相关的程序,它们完全看不到。

与真实存在的虚拟机不同,在使用Docker的时候,并没有一个真正的“Docker容器”运行在宿主机里面,Docker项目帮助用户启动的,还是原来的进程,只不过在创建这个进程时,Docker为它们加上了各种各样的Namespace参数。此时,这些进程会觉得自己是各自PID Namespace里的第1号进程,只能看到各自Mount Namespace里挂在的目录和文件,只能访问各自Network Namespace里的网络设备,就仿佛运行在一个个“容器”里面,与世隔绝。

Namespace技术实际上修改了应用进程看待整个计算机的“视图”,即它的“视线”被操作系统做了限制,只能看到某些指定的内容,但是对于宿主机来说,这些被隔离的进程和其他进程没有太大区别。

Docker Engine或者任何容器管理工具,并不对应用进程的隔离环境负责,也不会创建任何实体的“容器”,真正对隔离环境负责的是宿主机操作系统本身。

使用虚拟化技术作为应用沙盒,就必须要由Hypervisor来负责创建虚拟机,并且它里面必须运行一个完整的Guest OS才能执行用户的应用进程。这不可避免地带来了额外的资源消耗和占用。根据实验,一个运行着Centos的KVM虚拟机启动后,在不作优化的情况下,虚拟机自己需要占用200M左右内存。此外,用户应用运行在虚拟机里面,它对宿主机操作系统的调用就不可避免地要经过虚拟化软件的拦截和处理,这本身又是一层性能损耗,尤其对计算资源网络磁盘I/O的损耗非常大。

相比之下,容器化后的用户应用,却依然还是一个宿主机上的普通进程,这就意味着这些因为虚拟化而带来的性能损耗都是不存在的;另一方面,使用Namespace作为隔离手段的容器并不需要单独的Guest OS,这就使得容器额外的资源占用可以忽略不计。

这样的好处:敏捷和高性能是容器相较于虚拟机最大的优势,也是它能够在PaaS这种更细粒度的资源管理平台上大行其道的重要原因

这样的不足:基于Linux Namespace的隔离机制,隔离的不彻底。

  • 容器只是运行在宿主机上的特殊进程,多个容器之间使用的还是同一个宿主机的操作系统和内核。虽然在容器内通过Mount Namespace单独挂载不同版本的操作系统文件,但是不能改变共享宿主机内核的事实。如果要在Windows宿主机上运行Linux容器,如果要在低版本的Linux宿主机上运行高版本的Linux容器,都是行不通的

  • Linux内核中,有很多资源和对象是不能被Namespace化的,最典型的例子就是时间。相比于在虚拟机内可以随便折腾的自由度,在容器里部署应用的时候,“什么能做,什么不能做”,就是用户必须考虑的问题。如果容器中的程序使用settimeofday(2)系统调用修改了时间,整个宿主机的时间都会被随之修改。这显然不符合预期。

Cgroups(容器的限制)

  1. 共享内核的事实,使得容器给应用暴露出来的攻击面是相当大的,应用“越狱”的难度自然也比虚拟机低得多。

  2. 尽管在实践中可以使用Seccomp等技术,对容器内部发起的所有系统调用进行过滤和甄别来进行安全加固,但是这种方法因为多了一层对系统调用的过滤,一定会拖累容器的性能。默认情况下,不知道该开启哪些系统调用,禁止哪些系统调用。

基于虚拟化或者独立内核技术的容器实现,可以比较好地在隔离与性能之间作出平衡。

虽然容器内的进程被隔离在容器内部,但是在宿主机上依然和其他的进程之间是平等的竞争关系。虽然表面上被隔离,但是所能够使用的资源(cpu、内存),却是可以随时被宿主机上的其他进程(其他容器)占用的。也可能一个容器把所有资源吃光,这些情况显然都不是一个沙盒应该表现出的合理情况。

Linux Cgroups 主要作用:

  1. 限制一个进程能够使用的资源上限,包括CPU、内存、磁盘、网络带宽等

  2. 对进程设置优先级、审计、将进程挂起和恢复等

在Linux中,Cgroups给用户暴露出来的操作接口是文件系统,即它以文件系统和目录的方式组织在操作系统的/sys/fs/cgroup路径下。

使用mount命令展示出来如下:

[root@tdc-01 cgroup]# mount -t cgroup
# 输出的是一系列文件系统目录
cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd)
cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb)
cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct,cpu)
cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event)
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/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
cgroup on /sys/fs/cgroup/net_cls type cgroup (rw,nosuid,nodev,noexec,relatime,net_cls)

/sys/fs/cgroup下面有很多诸如cpuset、cpu、memory这样的子目录,也叫子系统。这些都是这台机器当前可以被Cgroups进行限制的资源种类。在子系统对应的资源种类下,就可以看到该类资具体可以被限制的方法。

如对CPU子系统来说,可以看到如下几个配置文件:

[root@tdc-01 cgroup]# ls /sys/fs/cgroup/cpu
cgroup.clone_children  cgroup.procs          cpuacct.stat   cpuacct.usage_percpu  cpu.cfs_quota_us  cpu.rt_runtime_us  cpu.stat  machine.slice      release_agent  tasks
cgroup.event_control   cgroup.sane_behavior  cpuacct.usage  cpu.cfs_period_us     cpu.rt_period_us  cpu.shares         docker    notify_on_release  system.slice   user.slice

在这些输出中,如cpu.cfs_period_uscpu.cfs_quota_us这样的关键词,这两个参数需要组合使用,可以用来限制进程在长度为cpu.cfs_period_us的一段时间内,只能被分配到总量为cpu.cfs_quota_us的CPU时间。

在对应的子系统下创建一个目录,这个目录成为一个“控制组”,系统会在新创建的目录下,自动生成该子系统对应的资源限制文件

root@ubuntu:/sys/fs/cgroup/cpu$ mkdir container
root@ubuntu:/sys/fs/cgroup/cpu$ ls container/
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

执行如下进程,是一个死循环,将CPU占用到100%:

while : ; do : ; done &

用top指令查看CPU使用:

# 在输出里可以看到目标进程4517,CPU 的使用率已经 100% 了(%Cpu:100.0us)
top - 14:33:24 up 28 days,  4:10,  1 user,  load average: 4.50, 3.57, 3.00
Tasks: 1248 total,   2 running, 1246 sleeping,   0 stopped,   0 zombie
%Cpu(s): 14.4 us,  2.5 sy,  0.0 ni, 82.3 id,  0.5 wa,  0.0 hi,  0.3 si,  0.0 st
KiB Mem : 13174787+total, 43071160 free, 30426240 used, 58250476 buff/cache
KiB Swap: 33554428 total, 33554428 free,        0 used. 99608064 avail Mem

PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
4517 root      20   0  116372   1556    332 R 100.0  0.0   7:54.13 bash

查看新创建的控制组中的配置文件,cpu quota没有任何限制(即-1),cpu period默认为100ms(100000us)。

cat /sys/fs/cgroup/cpu/container/cpu.cfs_period_us
100000
cat /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us
-1

通过修改这些文件的内容来设置限制。

# 向cfs_quota_us文件中写入20ms(20000us)
echo 20000 > /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us

表示在每个100ms的时间里,被改控制组限制的进程只能使用20ms的cpu时间,也就是说这个进程只能使用20%的cpu带宽。

# 把需要被限制的进程PID写入tasks文件中
echo 4517 > /sys/fs/cgroup/cpu/container/tasks

通过top命令再次查看。

# 从输出可以看到目标进程4517的cpu使用降到了20%
Tasks: 1245 total,   2 running, 1243 sleeping,   0 stopped,   0 zombie
%Cpu(s):  7.0 us,  1.4 sy,  0.0 ni, 91.2 id,  0.0 wa,  0.0 hi,  0.3 si,  0.0 st
KiB Mem : 13174787+total, 43206112 free, 30265896 used, 58275868 buff/cache
KiB Swap: 33554428 total, 33554428 free,        0 used. 99768544 avail Mem

PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
4517 root      20   0  116372   1556    332 R  19.7  0.0   9:05.19 bash

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

  • blkio:块设备I/O限制,一般用于磁盘等设备

  • cpuset:为进程分配单独的CPU核和对应的内存节点

  • memory:为进程设定内存使用的限制

Linux Cgroups是一个子系统目录加上一组资源限制文件的组合,对于Docker等Linux容器项目来说,需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后,把这个进程的PID填写到对应控制组的tasks文件中。

在控制组中的资源文件里面填写的值,就是执行docker run时设定的参数,比如:

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

在这个容器启动后,查看Cgroups文件系统下,CPU子系统中,docker这个控制组的资源限定文件的内容来确认:

# 每个容器在docker这个控制组下有对应的容器ID的子目录
cat /sys/fs/cgroup/cpu/docker/5d5c9f67d/cpu.cfs_period_us
100000
cat /sys/fs/cgroup/cpu/docker/5d5c9f67d/cpu.cfs_quota_us
20000

一个正在运行的Docker容器,其实就是一个启用了多个Linux Namespace的应用进程,这个进程能够使用的资源量,受Cgroups配置的限制。这也是容器技术中一个非常重要的概念:容器是一个“单进程”模型。

由于一个容器的本质就是一个进程,用户的应用进程实际上就是容器里PID=1的进程,也是其他后续创建的所有进程的父进程。这意味着,在一个容器中,没办法同时运行两个不同的应用,除非能事先找到一个公共的PID=1的程序充当两个不同应用的父进程,这也是为什么很多人会用systemd或者supervisord这样的软件来代替应用本身作为容器的启动进程

Cgroups对资源的限制能力也有很多不完善的地方,被提及最多的自然是/proc文件系统的问题。

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

如果在容器中执行top命令,查看到的是宿主机的cpu和内存数据,而不是当前容器的数据。造成这个问题的原因就是/proc文件系统并不知道用户通过Cgroups给这个容器做了什么样的资源限制,即/proc文件系统不了解Cgroups限制的存在。

在生产环境中,这个问题必须进行修正,否则应用程序在容器里读取的CPU核数、可用内存等信息都是宿主机上的数据,这会给应用的运行带来非常大的困惑和风险,可以利用lxcfs,提升容器资源可见性。

容器镜像

Linux容器最基础的两种技术:

  • Namespace:作用“隔离”,让应用进程只能看到该Namespace内的世界

  • Cgroups:作用“限制”,给上面的世界围上一圈看不见的墙

容器的本质是一种特殊的进程。 被以上两种技术装进了一个被隔离的空间中,这个空间就是PaaS项目赖以生存的应用“沙盒”。

在这个空间中,虽然四周有墙,但是如果容器进程低头一看地面,会是什么样的景象?换句话说,容器里的进程看到的文件系统又是什么样子的?

这是一个关于Mount Namespace的问题:容器里的应用进程,理应看到一份完全独立的文件系统。这样,它就可以在自己的容器目录(比如/tmp)下进行操作,而完全不会受到宿主机以及其他容器的影响。

以下程序作用,在创建子进程时开启指定的Namespace:

#define _GNU_SOURCE
#include <sys/mount.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>
#define STACK_SIZE (1024 * 1024)
static char container_stack[STACK_SIZE];
char* const container_args[] = {
    "/bin/bash",
    NULL
};

int container_main(void* arg){  
    printf("Container - inside the container!\n");
    execv(container_args[0], container_args);
    printf("Something's wrong!\n");
    return 1;
}

int main(){
    printf("Parent - start a container!\n");
    // 通过clone()系统调用创建了一个新的子进程container_main,并且声明要为它启用Mount Namespace(即:CLONE_NEWNS标志)
    int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWNS | SIGCHLD , NULL);
    waitpid(container_pid, NULL, 0);
    printf("Parent - container stopped!\n");
    return 0;
}

在子进程中执行的是/bin/bash程序(也就是一个shell),这个shell就运行在了Mount Namespace的隔离环境中;在子进程中执行ls命令,查看到的还是宿主机的文件,即:即使开启了Mount Namespace,容器进程看到的文件系统还是跟宿主机完全一样。因为,Mount Namespace修改的是容器进程对文件系统“挂载点”的认知。但是,这就意味着,只有在“挂载”这个操作发生之后,进程的视图才会被改变,而在此之前,新创建的容器会直接继承宿主机的各个挂载点。

因此,创建新进程时,除了声明要启用Mount Namespace之外,还要告诉容器进程,有哪些目录需要重新挂载,比如/tmp目录。在容器进程执行前可以添加一步重新挂载/tmp目录的操作:

int container_main(void* arg)
{
    printf("Container - inside the container!\n");
    // 如果你的机器的根目录的挂载类型是 shared,那必须先重新挂载根目录
    // mount("", "/", NULL, MS_PRIVATE, "");
    mount("none", "/tmp", "tmpfs", 0, "");
    execv(container_args[0], container_args);
    printf("Something's wrong!\n");
    return 1;
}

在修改的代码里,在容器进程启动之前,加上mount("none", "/tmp", "tmpfs", 0, "");就是告诉容器以tmpfs(内存盘)格式,重新挂载/tmp目录,此时重新运行程序后查看/tmp目录会发现变成了一个空目录,这就意味着挂载生效了。

因为创建的新进程启用了Mount Namespace,所以这次挂载操作,只在容器进程的Mount Namespace中有效,在宿主机的挂载中查看不到上述挂载点。Mount Namespace和其他Namespace的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载(mount)操作才能生效

在创建新的容器的时候,重新挂载根目录“/”,即可实现,容器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。在Linux系统中,chroot命令可以方便的完成上述工作,“change root file system”,即改变进程的根目录到指定的位置

现在有一个$home/test目录,想要把它作为一个/bin/bash进程的根目录:

# 创建test目录和相关的lib文件夹:
T=$HOME/test
mkdir -p $HOME/test
mkdir -p $HOME/test/{bin,lib64,lib}
cd $T

# 把bash命令拷贝到test目录对应的bin路径下:
cp -v /bin/{bash,ls} $HOME/test/bin

# 把 bash 命令需要的所有 so 文件,也拷贝到 test目录对应的lib路径下
# 找不到so文件可用ldd命令
list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')"
for i in $list; do cp -v "$i" "${T}${i}"; done

# 执行chroot命令,告诉操作系统,将$HOME/test目录作为/bin/bash进程的根目录
chroot $HOME/test /bin/bash

此时执行 ls / 返回的都是$HOME/test目录下的内容,而不是宿主机的内容。对于被chroot的进程来说,它不会感受到自己的根目录被修改了。

为了让容器中的根目录看起来更加的真实,一般会在容器的根目录下挂载一个完整操作系统的文件系统,比如Ubuntu 16.04的ISO。这样在容器启动后,在容器里通过执行ls / 查看根目录下的内容,就是ubuntu 16.04的所有目录和文件。

这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”,它有个更专业的名字,叫rootfs(根文件系统)。

# 一个最常见的rootfs会包含以下目录
ls /
bin dev etc home lib lib64 mnt opt proc root run sbin sys tmp usr var

进入容器之后,执行的/bin/bash 就是rootfs的/bin目录下的可执行文件,与宿主机的/bin/bash完全不同。

对于Docker项目来说,最核心的原理实际上就是为待创建的用户进程

  1. 启用Linux Namespace配置

  2. 设置指定的Cgroups参数

  3. 切换进程的根目录

这样,一个完整的容器就诞生了,不过Docker项目在最后一步会优先使用pivot_root系统调用,如果系统不支持,才使用chroot。注意:rootfs只是一个操作系统包含的文件、配置和目录,并不包括操作系统的内核。

在Linux系统中,操作系统内核和操作系统包含的文件、配置和目录是分开存放的,只有在开机启动的时候,操作系统才会加载指定版本的内核镜像。

rootfs只包含操作系统的躯壳,不包括操作系统的灵魂。同一台机器上的所有容器都共享宿主机操作系统的内核。这就意味着,如果应用程序需要配置内核参数,加载额外的内核模块,以及跟内核进行直接的交互,这些操作和依赖的对象都是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个“全局变量”,牵一发而动全身。这是容器相比虚拟机的主要缺陷之一。

由于云端和本地服务器环境不同,应用的打包过程,一直是使用PaaS时最痛苦的一个步骤。但有了容器镜像(即rootfs)之后,这个问题就被优雅的解决了。rootfs的存在,保证了容器的一致性

  • rootfs里打包的不只是应用,而是整个操作系统的文件和目录,即:应用以及它运行所需的依赖,都被封装在了一起

  • 容器镜像“打包操作系统”的能力,使得最基础的依赖环境也变成了应用沙盒的一部分,这就赋予了容器所谓的一致性

对应用依赖的理解,不能局限于编程语言层面,对于一个应用来说,操作系统本身才是它所需要的最完整的“依赖库”。

无论是在本地还是云端,只要解压打包好的容器镜像,那么这个应用运行所需的完整的执行环境就被重现出来了。

这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和云端执行之间的鸿沟

联合文件系统

思考另一个问题,是否在每次开发或者升级应用的时候,都要重复制作一次rootfs?

  • 既然这些修改都是基于一个旧的rootfs,以增量的方式去做修改

  • 所有人都只维护相对于base rootfs修改的增量内容,而不是每次修改都制造一个“fork”

Docker在镜像的设计中,引入了层(layer)的概念,用户在制作镜像的每一步操作,都会生成一个层,也就是增量的rootfs。实现这个想法,使用了联合文件系统(Union File System)的能力。

UnionFS最主要的功能是将多个不同位置的目录联合挂载到同一个目录下:

# 两个目录A和B
tree .
.
├── A
│  ├── a
│  └── x
└── B
  ├── b
  └── x

# 使用联合挂在的方式,将这两个目录挂载到一个公共的目录C
mkdir C
mount -t aufs -o dirs=./A:./B none ./C

# 查看目录C的内容
tree ./C
./C
├── a
├── b
└── x
# 在这个合并后的目录里,有a,b,x三个文件,并且x文件只有一份,这就是合并的含义
# 并且如果在目录C里对a,b,x文件做修改,这些修改也会在对应的目录A,B中生效

Ubuntu 16.04和Docker CE 18.05,这对组合默认使用AuFS,可以使用docker info命令查看到这些信息。

Docker 支持的UnionFS,包括但不限于以下这几种:aufs, device mapper, btrfs, overlayfs, vfs, zfs。

  • aufs是 ubuntu 常用的

  • device mapper 是 centos

  • btrfs 是 SUSE

  • overlayfs ubuntu 和 centos 都会使用,现在最新的 docker 版本中默认两个系统都是使用的 overlayfs

  • vfs 和 zfs 常用在 solaris 系统

AuFS

AuFS 名字的进化过程:AnotherUnionFS --> AlternativeUnionFS --> AdvanceUnionFS。

从名字可以发现:

  1. AuFS是对Linux原生UnionFS的重写和改进

  2. AuFS没有进入Linux内核的主干,只在Ubuntu和Debian这些发行版中使用

对于AuFS来说,最关键的目录结构在 /var/lib/docker 路径下的 diff 目录/var/lib/docker/aufs/diff/<layer_id>

# 启动容器
docker run -d ubuntu:latest sleep 3600

Docker会从Docker Hub上拉取一个Ubuntu镜像到本地。这里所谓的“镜像”,实际上就是Ubuntu操作系统的rootfs,它的内容是Ubuntu操作系统的所有文件和目录。不过与上文提到的rootfs的差别在于,Docker镜像使用的rootfs,往往由多个“层”组成。

docker image inspect ubuntu:latest
...
    "RootFS": {
    "Type": "layers",
    "Layers": [
        "sha256:f49017d4d5ce9c0f544c...",
        "sha256:8f2b771487e9d6354080...",
        "sha256:ccd4d61916aaa2159429...",
        "sha256:c01d74f99de40e097c73...",
        "sha256:268a067217b5fe78e000..."
        ]
    }

该Ubuntu镜像实际上由5层组成,这5层就是5个增量rootfs,每一层都是Ubuntu操作系统文件和目录的一部分。在使用镜像时,Docker会把这些增量联合挂载在一个统一的挂载点上(等价于上面例子中的目录c)。

这个挂载点就是 /var/lib/docker/aufs/mnt,比如:

/var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e

# 这个目录里面正是一个完整的Ubuntu操作系统
ls /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var

这5个镜像层,是如何被联合挂载成这样一个完整的Ubuntu文件系统呢

这些信息记录在AuFS的系统目录 /sys/fs/aufs 下面。通过查看AuFS的挂载信息,可以找到这个目录对应的AuFS的内部ID(也叫:si):

cat /proc/mounts| grep aufs
none /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fc... aufs rw,relatime,si=972c6d361e6b32ba,dio,dirperm1 0 0

即si=972c6d361e6b32ba,使用这个ID,就可以在 /sys/fs/aufs 目录下查看到被联合挂载在一起的各层的信息:

cat /sys/fs/aufs/si_972c6d361e6b32ba/br[0-9]*
/var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...=rw
/var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...-init=ro+wh
/var/lib/docker/aufs/diff/32e8e20064858c0f2...=ro+wh
/var/lib/docker/aufs/diff/2b8858809bce62e62...=ro+wh
/var/lib/docker/aufs/diff/20707dce8efc0d267...=ro+wh
/var/lib/docker/aufs/diff/72b0744e06247c7d0...=ro+wh
/var/lib/docker/aufs/diff/a524a729adadedb90...=ro+wh

从这些信息可以看出:

  • 镜像的层都是放置在 /var/lib/docker/aufs/diff 目录

  • 然后被联合挂载在 /var/lib/docker/aufs/mnt 里面

从这个结构可以看出,这个容器的rootfs由下图所示的三部分组成:

第一部分 只读层

它是这个容器的rootfs最下面的五层,对应的正是ubuntu:latest镜像的五层,它们的挂载方式都是只读的(ro+wh,即readonly+whiteout)。

# 查看每一层中的内容
ls /var/lib/docker/aufs/diff/72b0744e06247c7d0...
etc sbin usr var
ls /var/lib/docker/aufs/diff/32e8e20064858c0f2...
run
ls /var/lib/docker/aufs/diff/a524a729adadedb900...
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var

这些层都是以增量的方式包含Ubuntu操作系统的一部分。

第二部分 可读可写层

它是这个容器的rootfs最上面的一层(6e3be5d2ecccae7cc),它的挂载方式为rw(即read write)。在没有写入文件之前,这个目录是空的,而一旦在容器里做了写操作,修改产生的内容就会以增量的方式出现在这个层中

为了实现删除文件的操作,AuFS在可读写层创建一个whiteout文件,把只读层里的文件“遮挡”起来。

比如要删除只读层中的foo文件,那么这个删除操作实际上是在可读写层创建了一个名为.wh.foo的文件。这样,当这两个层被联合挂载之后,foo文件就会被.wh.foo文件“遮挡”起来。这个功能就是“ro+wh”的挂载方式,即只读+witheout。

所以,最上面的可读写层的作用就是专门用来存放修改rootfs后产生的增量,无论是增删改都发生在这里。当使用完了这个修改过的容器之后,可以使用docker commit 和docker push指令,保存这个被修改过的可读写层,并上传到Docker Hub上。与此同时,原先的只读层里的内容不会有任何变化。这就是增量rootfs的好处

上面的读写层通常也称为容器层,下面的只读层称为镜像层。所有的增删查改操作都只会作用在容器层,相同的文件上层会覆盖掉下层。镜像文件的修改,比如修改一个文件的时候,首先会从上到下查找有没有这个文件,找到就复制到容器层中进行修改,修改的结果就会作用到下层的文件,这种方式也被称为copy-on-write

第三部分 Init层

它是一个以“-init”结尾的层,夹在只读层和读写层之间。Init层是Docker项目单独生成的一个内部层,专门用来存放 /etc/hosts, /etc/resolv.conf 等信息。

需要这样一层的原因是,这些文件本来属于只读的Ubuntu镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值,比如hostname,所以需要在可读写层对它们进行修改。但是这些修改往往只能对当前容器生效,并不希望执行docker commit时,把这些信息连同可读写层一起提交。

所以Docker做法是,在修改了这些文件之后,以一个单独的层挂载出来,在用户执行docker commit时只会提交可读写层,所以是不包含这些信息的。最终,这7层被联合挂载到/var/lib/docker/aufs/mnt目录下,表现为一个完整的Ubuntu操作系统供容器使用。

  1. 通过使用Mount Namespace和rootfs,容器就能够为进程构建出一个完善的文件系统隔离环境。这个功能的实现必须感谢 chrootpivot_root 这两个系统调用切换进程根目录的能力

  2. 在rootfs的基础上,Docker公司创新性地提出了使用多个增量rootfs联合挂载一个完整rootfs的方案,这就是容器镜像中 的概念

  3. 容器镜像的发明,不仅打通了“开发--测试--部署”流程的每一个环节,更重要的是:容器镜像将会成为未来软件的主流发布方式

Docker容器本质

用docker部署一个Python编写的Web应用,代码如下:

# 这段代码中使用Flask框架启动了一个web服务器
# 唯一功能就是:如果当前环境中有“NAME”这个环境变量
# 就把它打印在“Hello”后,否则就打印“Hello world”
# 最后再打印出当前环境的hostname
from flask import Flask
import socket
import os

app = Flask(__name__)

@app.route('/')
def hello():
    html = "<h3>Hello {name}!</h3>" \
           "<b>Hostname:</b> {hostname}<br/>"
    return html.format(name=os.getenv("NAME", "world"), hostname=socket.gethostname())

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=80)

# 这个应用的依赖被定义在同目录下的requirements.txt文件中
cat requirements.txt
Flask

将这样一个应用容器化的第一步,就是制作容器镜像。有两种方式:

  1. 制作rootfs(比较麻烦)

  2. Dockerfile(很便捷)

# 使用官方提供的 Python 开发镜像作为基础镜像
FROM python:2.7-slim

# 将工作目录切换为 /app
WORKDIR /app

# 将当前目录下的所有内容复制到 /app 下
ADD . /app

# 使用 pip 命令安装这个应用所需要的依赖
RUN pip install --trusted-host pypi.python.org -r requirements.txt

# 允许外界访问容器的 80 端口
EXPOSE 80

# 设置环境变量
ENV NAME World

# 设置容器进程为:python app.py,即:这个 Python 应用的启动命令
CMD ["python", "app.py"]
# 查看当前目录下的文件
ls
Dockerfile  app.py   requirements.txt

# 在当前目录下,让Docker制作镜像
docker build -t helloworld .

# -t 参数为这个镜像加上一个Tag
# docker build会自动加载当前目录下的Dockerfile文件
# 然后按照顺序执行文件中的原语

这个过程可以等同于Docker使用基础镜像启动了一个容器,然后在容器中依次执行Dockerfile中的原语。Dockerfile中的每个原语执行后,都会生成一个对应的镜像层。即使原语本身并没有明显地修改文件的操作(比如,ENV原语),它对应的层也会存在。只不过在外界看来这个层是空的

# docker bulid 操作完成后,通过docker images 查看结果
docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
helloworld          latest              314b99082eb0        11 seconds ago      130MB
python              2.7-slim            804b0a01ea83        3 weeks ago         120MB

# 通过 docker run 启动容器
docker run -p 4000:80 helloword

# 因为在Dockerfile的CMD中指定了启动容器后运行的进程
# 因此在上面的命令后面可以不写需要启动的进程,否则需要使用如下的命令
docker run -p 4000:80 helloworld python app.py

# 容器启动之后,使用docker ps查看
docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS                  NAMES
e7501877191e        helloworld          "python app.py"     43 seconds ago      Up 42 seconds       0.0.0.0:4000->80/tcp   suspicious_lichterman

# 在启动容器的时候,使用-p参数将容器内的80端口映射到宿主机的4000端口
# 然后可以通过宿主机的4000端口访问容器中的进程
curl localhost:4000
<h3>Hello World!</h3><b>Hostname:</b> e7501877191e<br/>

如果在运行容器的时候没有暴露端口,那么需要通过docker inspect命令查看到当前运行着的容器的IP地址才能访问,而不能通过宿主机的IP地址+暴露的端口号来访问,如下:

docker inspect 47982cd180a1
        "NetworkSettings": {
            "Bridge": "",
            "SandboxID": "2d7fe94c1301614cc5b4bd076f6d9afae28183af92d881ddbb34d5fb704d8470",
            "HairpinMode": false,
            "LinkLocalIPv6Address": "",
            "LinkLocalIPv6PrefixLen": 0,
            "Ports": {
                "80/tcp": null
            },
            "SandboxKey": "/var/run/docker/netns/2d7fe94c1301",
            "SecondaryIPAddresses": null,
            "SecondaryIPv6Addresses": null,
            "EndpointID": "1ba15c1758f95b8e0527f5a6555c52189d5b443b41439c398e2623b52d958109",
            "Gateway": "172.17.0.1",
            "GlobalIPv6Address": "",
            "GlobalIPv6PrefixLen": 0,
            "IPAddress": "172.17.0.2",
            "IPPrefixLen": 16,
            "IPv6Gateway": "",
            "MacAddress": "02:42:ac:11:00:02",
            "Networks": {
                "bridge": {
                    "IPAMConfig": null,
                    "Links": null,
                    "Aliases": null,
                    "NetworkID": "a52595db8607583e18e115387c9758f21900dac948bd7e5c7c2ba8edf8077b56",
                    "EndpointID": "1ba15c1758f95b8e0527f5a6555c52189d5b443b41439c398e2623b52d958109",
                    "Gateway": "172.17.0.1",
                    "IPAddress": "172.17.0.2",
                    "IPPrefixLen": 16,
                    "IPv6Gateway": "",
                    "GlobalIPv6Address": "",
                    "GlobalIPv6PrefixLen": 0,
                    "MacAddress": "02:42:ac:11:00:02",
                    "DriverOpts": null
                }
            }
        }


curl 172.17.0.2:80
<h3>Hello World!</h3><b>Hostname:</b> 47982cd180a1<br/>

这个容器进程“python app.py”,运行在由Linux Namespace和Cgroups构成的隔离环境里,而它运行所需的各种文件,比如python app.py,以及整个操作系统文件,则由多个联合挂载在一起的rootfs层提供。

  1. 这些rootfs层的最下层,是来自Docker镜像的只读层

  2. 在只读层上,Docker自己添加的Init层,用来存放被临时修改过的/etc/hosts等文件

  3. rootfs的最上层是一个可读写层,以Copy-on-Write的方式存放任何对只读层的修改,容器声明的Volume的挂载点,也在这一层

docker exec

使用docker exec命令可以进入容器,那么它是如何做到的呢?Linux Namespace创建的隔离空间虽然看不见摸不着,但是一个进程的Namespace信息在宿主机上以文件形式存在。

# 通过以下命令查看docker 容器的进程号(PID)
docker inspect --format '{{ .State.Pid }}' 47982cd180a1
17932

# 通过查看宿主机的proc文件,可以看到容器进程的所有Namespace对应的文件
ls -l /proc/17932/ns
total 0
lrwxrwxrwx 1 root root 0 Nov  8 16:48 ipc -> ipc:[4026532537]
lrwxrwxrwx 1 root root 0 Nov  8 16:48 mnt -> mnt:[4026532535]
lrwxrwxrwx 1 root root 0 Nov  8 16:29 net -> net:[4026532540]
lrwxrwxrwx 1 root root 0 Nov  8 16:48 pid -> pid:[4026532538]
lrwxrwxrwx 1 root root 0 Nov  8 17:05 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Nov  8 16:48 uts -> uts:[4026532536]

可以看到,每个进程的每种Linux Namespace都在对应的/proc/[进程号]/ns下有一个对应的虚拟文件,并且链接到真实的Namespace文件上。有了这些Linux Namespace的文件后,就可以加入到一个已经存在的Namespace中,即一个进程,可以选择加入到某个进程已有的Namespace中,从而达到进入这个进程所在容器的目的。

这个操作依赖的是Linux的setns()系统调用,通过如下代码说明整个过程:

#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE);} while (0)

// 第一个参数,要在这个Namespace里运行的进程
// 第二个参数,当前argv[1]表示当前进程要加入的Namespace文件的路径
int main(int argc, char *argv[]) {
    int fd;
    // 通过open()系统调用打开了指定的Namespace文件
    fd = open(argv[1], O_RDONLY);
    // 把这个文件的描述符fd交给setns()使用
    // 在setns()执行后,当前进程就加入了这个文件对应的Linux Namespace中
    if (setns(fd, 0) == -1) {
        errExit("setns");
    }
    execvp(argv[2], &argv[2]);
    errExit("execvp");
}

docker commit

docker commit 实际上就是在容器运行起来后,把最上层的“可读写层”,加上原先容器镜像的只读层,打包组合成一个新的镜像,原先的只读层在宿主机上是共享的,不占用额外空间。因为使用的是联合文件系统,所以在容器中对镜像rootfs所做的任何修改,都会被操作系统先复制到这个可读写层,然后再修改(即 Copy-on-Write)。有了Init层的存在,就是为了避免在commit的时候,把容器自己对/etc/hosts等文件的修改也一起提交掉。

Volume(数据卷)

容器技术使用rootfs机制和Mount Namespace,构建出了与宿主机完全隔离开的文件系统环境。那么以下两个问题如何解决:

  1. 容器里进程新建的文件,怎么才能让宿主机获取到?

  2. 宿主机上的文件和目录,怎么才能让容器里的进程访问到?

Docker Volume解决以上问题,Volume机制,允许将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作。

# 在Docker项目里,支持两种Volume声明方式
# 以上两种方式的本质实际上是一样的,都是把一个宿主机的目录挂载进容器的/test目录

# 没有显示声明宿主机目录,所以Docker会默认在宿主机上创建一个临时目录/var/lib/docker/volumes/[VOLUME_ID]/_data
# 然后挂载到容器的/test目录上
docker run -v /test ...

# Docker会直接把宿主机的/home目录挂载到容器的/test目录上
docker run -v /home:/test ...

如何做到将宿主机的目录或文件挂载到容器中?在容器进程被创建之后,尽管开启了Mount Namespace,但是在它执行chrootpivot_root之前,容器进程一直可以看到宿主机的整个文件系统(包括要运行的容器镜像)。

容器镜像的各层被保存在/var/lib/docker/aufs/diff目录下,容器启动后,镜像各层被联合挂载/var/lib/docker/aufs/mnt/目录下,挂载完成后,容器的rootfs就准备好了。

因此,实现宿主机的目录挂载到容器中,只要在rootfs准备好之后,在执行chroot之前,把volume指定的宿主机目录(比如/home),挂载到指定的容器目录(比如/test)在宿主机上对应的目录(即/var/lib/docker/aufs/mnt/[可读写层 ID]/test)上,这个volume的挂载工作就完成。

/home --> /var/lib/docker/aufs/mnt/可读写层ID/test

因为,在执行挂载操作时,“容器进程”已经创建了,此时的Mount Namespace已经开启,所以这个挂载事件只在这个容器里可见。宿主机上是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被Volume打破。

这里提到的“容器进程”是Docker创建的一个容器初始化进程(dockerinit),而不是应用进程(ENTRYPOINT+CMD)。

dockerinit负责完成一系列需要在容器内进行的初始化操作:

  1. 根目录的准备

  2. 挂载设备和目录

  3. 配置hostname

完成以上操作后,它通过execv()系统调用,让应用进程取代自己,成为容器里的PID=1的进程。

这里使用的挂载技术是Linux的绑定挂载(bind mount)机制。它的主要作用就是允许你将一个目录或文件,而不是整个设备,挂载到一个指定的目录上。并且,这时在该挂载点上进行的任何操作,只是发生在被挂载点的目录或文件上,原挂载点的内容则会被隐藏起来且不受影响

绑定挂载实际上是一个inode替换的过程。在Linux操作系统中,inode可以理解为存放文件内容的“对象”,而dentry(目录项),就是访问这个inode所使用的“指针”。

如上图所示,mount --bind /home /test 命令会将/home目录挂载到/test上。其实相当于将/test的dentry重定向到了/home 的inode。这样当修改/test目录时,实际修改的是/home目录的inode。这也就是为何,一旦执行umount命令,/test目录原先的内容就会恢复:因为修改真正发生在的是/home目录里

在一个正确的时机,进行一次绑定挂载,Docker就成功地将一个宿主机上的目录或文件,不动声色地挂载到容器中。这样,进程在容器中对这个/test目录的所有操作,都实际发生在宿主机的对应目录(如/home/var/lib/docker/volumes/[VOLUME_ID]/_data),而不会影响镜像的内容。

这个/test目录的内容,被挂载在容器rootfs的可读写层,但不会被docker commit提交。因为,容器的镜像操作,比如docker commit 都是发生在宿主机空间的。而由于Mount Namespace的隔离作用,宿主机不知道这个绑定挂载的存在,所以在宿主机看来,容器中可读写层的/test目录(/var/lib/docker/aufs/mnt/[可读写层ID]/test)始终是空的。Docker在一开始会创建/test这个目录作为挂载点,所以执行了docker commit之后,在新的镜像中,会多出来一个空的/test目录。

最后更新于

这有帮助吗?