动手实现一个docker引擎-1-从内核到docker的三驾马车

学习自《自己动手写Docker》

作者:陈显鹭(花名:遥鹭)-阿里云高级研发工程师

京东购买链接:https://item.jd.com/10033552355433.html

其他资料:

  • https://www.cnblogs.com/heyanan/p/7800284.html
  • https://www.cnblogs.com/Philip-Tell-Truth/p/6284475.html
  • https://zhuanlan.zhihu.com/p/166393945
  • 《由浅入深吃透 Docker》郭少

我的主机环境:

  • 内核版本:Linux version 4.15.0-48-generic (buildd@lgw01-amd64-036)
  • Ubuntu 18.04.2 LTS

docker已经成为了容器化技术的代表名词,即使是k8s大行其道的今天,docker也是k8s的基石。所以打算跟着书一起用一个系列从零开始学习编写docker引擎。

前提:学习过docker基本操作使用、golang的基本使用

记录重点与实际遇到的难点、bug

一、容器与开发语言

​ docker最直观的理解:一个隔离的虚拟环境,封装了所有应用程序需要的一切在其中(代码、工具、系统依赖等),这样就可以称为“容器”,并且这样的容器是可以复制的(都是一个模子里刻出来的),而生成容器的模版就是“镜像”,镜像可以将所有系统级别依赖打包成为一个文件,所有镜像共享一个Kernel(在同一个宿主机下)。

扫描二维码关注公众号,回复: 13214223 查看本文章

什么是Kernel?

计算机系统的结构:

  1. **硬件:**物理机(这是系统的底层结构或基础)是由内存(RAM)、处理器(或 CPU)以及输入/输出(I/O)设备(例如存储网络和图形)组成的。其中,CPU 负责执行计算和内存的读写操作。
  2. Linux 内核:操作系统的核心。(明白了吗?内核正居于核心位置。)它是驻留在内存中的软件,用于告诉 CPU 要执行哪些操作。
  3. **用户进程:**这些是内核所管理的运行程序。用户进程共同构成了用户空间。用户进程有时也简称为进程。内核还允许这些进程和服务器彼此进行通信(称为进程间通信或 IPC)。

docker容器特点:

  1. 轻量级: 占用内存少、磁盘使用率高、启动快 (相比于虚拟机)
  2. 开放:基于开放标准,可运行在主流linux、windows操作系统
  3. 安全:隔离保护

容器与虚拟机

虚拟机(也被称为guest os)是一种模拟系统,即在软件层面上通过模拟硬件的输入和输出,让虚拟机的操作系统得以运行在没有物理硬件的环境中(也就是宿主机的操作系统上),其中能够模拟出硬件输入输出,让虚拟机的操作系统可以启动起来的程序,被叫做hypervisor, hypervisor能够创建出虚拟硬件。

虚拟机的缺点所在:

  • 每个虚拟机一般都有自己的kernel,并且开启之前需要先做开机自检,启动kernel,启动用户进程等一系列行为,启动很慢
  • 虚拟机还需要模拟硬件的输入输出,效率很差
  • 虚拟机需要包含用户的程序、函数库、整个客户操作系统,占用空间大

docked的改进:

  • docker所有容器(包括宿主机)之间共享内核kernel,各个容器在宿主机上相互隔离在用户态(cpu低级访问权限,一般程序的权限)下运行。docker的kernel version由宿主机决定

    因为共享kernel,所以不需要费大精力模拟硬件的输入输出,只需要模拟kernel的输入输出(因为共享),所以这种虚拟化也叫做操作系统层虚拟化 Operating-system-level virtualization

  • docker通过隔离容器不让容器使用宿主机的文件、进程、内存等系统实现封闭的环境(具体操作会在后面详述),让用户感受到容器有自己的文件、进程等系统(类似于虚拟机)

  • 容器不与任何基础设施绑定,移植性好

虚拟机与docker架构对比:

虚拟机:

image-20211028205122292

docker容器:

image-20211028205147983

正因为这些特点,docker能够加速开发效率,隔离移植、使用环境,容器的合作分享可以使用Docker Hub(类似于github,能够管理、更新docker镜像的仓库)

二、基础技术

2.1 容器的启动过程

linux进程实现的步骤:

  1. 在内存中将主进程复制一份得到子进程,此时主、子进程上下文完全一致
  2. 设置子进程的pid、parent_pid等其他与主进程不一样的内容(所以子进程大部分资源还是与主进程一致的)

docker需要制造一个虚拟的进程,所以进程的实现需要多做几步:

  1. 自定义rootfs(根文件系统),将宿主机的一个文件目录设置为虚拟机的根目录,例如rootfs=/root/ubuntu,在容器中其就是/
  2. 将自身的pid映射为0,并让其看不到其他任何进程pid,让其在容器中唯一
  3. 用户名隔离,可以把用户名变为root
  4. hostname隔离,可以领取一个hostname
  5. IPC隔离,隔离进程之间的相互通信
  6. 网络隔离,隔离进程与主机之间的网络

这些隔离方式都是调用linux系统内置(kernel)的隔离方法:clone(2) - Linux manual page

**docker是内核的搬运工:**docker实现这些隔离就是调用内核kernel支持的已有的内置隔离资源的方法,当然在其上也有一些拓展,例如资源隔离等

所以出现了docker两个特性:

  1. 启动速度快:因为本质上容器中的进程与宿主机进程没有很大区别本质上docker启动的就是一个被隔离的进程,共享了很多资源(虽然多了很多步骤)
  2. docker对linux内核版本有需求(版本号大于3.10),因为需要内核支持一些隔离特性的方法

对于容器启动后再新创建的进程,因为在创建容器时已经实现了与宿主机的资源隔离,所以在容器中新创建的进程天然就与宿主机实现了资源隔离!所以只需要刚开始的一次隔离(上面的6步)后面的进程就不需要再做了。

=> 找到启动快的原因:

linux启动流程如下图:

8geyMe

问:启动容器需要执行以上几步? 答:0步

2.2 Linux namespace 资源隔离

1. 基础介绍

Linux实现隔离的方式: namespaces(7)

命名空间将全局系统资源包装在一个抽象中 使名称空间内的进程看起来拥有自己的全局资源的独立实例。全局资源的修改对同一个命名空间下的其他进程是可见的,对于其他命名空间是不可见的。命名空间的一个用途是实现容器。

Linux Namespace各种命名空间:

ItNpxb

最新的 Linux 5.6 内核中提供了 8 种类型的 Namespace:

Namespace 名称 作用 内核版本
Mount(mnt) 隔离挂载点 2.4.19
Process ID (pid) 隔离进程 ID 2.6.24
Network (net) 隔离网络设备,端口号等 2.6.29
Interprocess Communication (ipc) 隔离 System V IPC 和 POSIX message queues 2.6.19
UTS Namespace(uts) 隔离主机名和域名 2.6.19
User Namespace (user) 隔离用户和用户组 3.8
Control group (cgroup) Namespace 隔离 Cgroups 根目录 4.6
Time Namespace 隔离系统时间 5.6

虽然 Linux 内核提供了8种 Namespace,但是最新版本的 Docker 只使用了其中的前6 种,分别为Mount Namespace、PID Namespace、Net Namespace、IPC Namespace、UTS Namespace、User Namespace。

第二列显示了用于在各种api中指定名称空间类型的标志值(系统调用参数),第三列标识了手册页中关于命名空间的详细信息,最后一列标识命名空间对应隔离的资源

Namespace能够实现在同一台主机下UID级别的隔离,给每一个UID的用户虚拟化出一个Namespace,这样多个用户之间可以访问系统资源的同时互相之间还实现了隔离。

从每个用户的角度看,每个命名空间都像一台单独的linux一样,有自己的init进程,并且pid不断递增

image-20211028230603400

例如上图:

用户A在命名空间A中认为自己的1号进程就是init进程,同理B如此。但是实际上都是分别映射到主进程3,4进程,从host的角度看只是3、4号进程虚拟化出来的一个空间而已。

查看进程所有命名空间

查看当前进程的所有命名空间:ls -l /proc/$$/ns | awk '{print $1, $9, $10, $11}'

total   
lrwxrwxrwx cgroup -> cgroup:[4026531835]
lrwxrwxrwx ipc -> ipc:[4026531839]
lrwxrwxrwx mnt -> mnt:[4026531840]
lrwxrwxrwx net -> net:[4026531993]
lrwxrwxrwx pid -> pid:[4026531836]
lrwxrwxrwx pid_for_children -> pid:[4026531836]
lrwxrwxrwx user -> user:[4026531837]
lrwxrwxrwx uts -> uts:[4026532271]

单独查看命名空间

例如查看UTS: readlink /proc/$$/ns/uts

# readlink /proc/$$/ns/uts
uts:[4026532271]

2. 命名空间API

目前Namespace的API的系统调用:

API 官方解释 简单解释
clone(2) 系统调用创建一个新进程。如果调用的flags参数指定了上面列出的一个或多个CLONE_NEW*标志,则新的命名空间是为每个标志创建,并且子进程被创建为 这些命名空间的成员。 创建一个新进程,可以通过CLONE_NEW*参数指定哪些命名空间被创建,并且他们的子进程也会被包含在这些Namespace中
setns(2) 系统调用允许调用进程加入现有的命名空间。 将一个进程加入现有命名空间
unshare(2) 系统调用的调用进程移动到 新的命名空间。如果调用的标志参数 指定列出的一个或多个CLONE_NEW*标志上面,然后为每个标志创建新的命名空间,并且调用进程成为这些命名空间的成员。(这个系统调用还实现了许多功能与命名空间无关。) 调用进程移动到新的命名空间
ioctl(2) 发现有关命名空间的信息。 输出命名空间的信息

3. UTS Namespace

主要用于隔离Hostname、Domainname系统标识(主机与域名)

go实现UTS命名空间的调用

// +build linux
package main

import (
	"log"
	"os"
	"os/exec"
	"syscall"
)

func main() {
    
    
	cmd := exec.Command("sh")		// 被复制出来的新进程的初始命令,我们使用sh执行
	cmd.SysProcAttr = &syscall.SysProcAttr{
    
    
		Cloneflags: syscall.CLONE_NEWUTS,			// 使用CLONE标志创建一个UTS Namespace
	}
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	if err := cmd.Run(); err != nil {
    
    
		log.Fatal(err)
	}
}

这段代码执行后我们会进入一个sh运行环境中,在这个环境中我们就实现了一个新的UTS Namespace

验证效果:

  • 查看当前宿主机进程之间的关系:pstree -pl (我的运行文件为test)

    image-20211029165014390

  • 查看当前sh的pid: echo $$

    $ echo $$  
    6372
    
  • 验证父进程与子进程是否在同一个UTS Namespace: /proc/xxx/ns/utsxxx是进程pid)

    $ readlink /proc/6369/ns/uts				// 父进程就是go可执行文件test
    uts:[4026531838]
    $ readlink /proc/6372/ns/uts                    
    uts:[4026532271]
    
  • 测试修改Hostname(正常情况下修改此环境内的hostname是不会影响外部主机的)

    $ hostname -b bird				// 修改主机名为bird
    $ hostname
    bird
    

    在另一个终端中查看宿主机的hostname是否改变

    $ hostname
    wenjie
    

    并没有因此改变,所以实现了主机名隔离

4. IPC Namespace

IPC Namespace用来隔离System V IPCPOSIX message queues,每一个IPC Namespace都拥有自己唯一的System V IPCPOSIX message queues

1. 进程间通讯IPC基本概念

IPC(Inter-Process Communication)进程间通信有三种信息共享方式:1.随文件系统 2.随kernel内核 3.随共享内存

相对的IPC的持续性(Persistence of IPC Object)也有三种:

  1. 随进程持续的(Process-Persistent IPC)

    IPC对象一直存在,直到最后拥有他的进程被关闭为止,典型的IPC有pipes(管道)和FIFOs(先进先出对象)

  2. 随内核持续的(Kernel-persistent IPC)

    IPC对象一直存在直到内核被重启或者对象被显式关闭为止,在Unix中这种对象有System v 消息队列,信号量,共享内存(注意***Posix消息队列,信号量和共享内存***被要求为至少是内核持续的,但是也有可能是文件持续的,这样看系统的具体实现)。

  3. 随文件系统持续的(FileSystem-persistent IPC)

    除非IPC对象被显式删除,否则IPC对象会一直保持(即使内核才重启了也是会留着的)。如果Posix消息队列,信号量,和共享内存都是用内存映射文件的方法,那么这些IPC都有着这样的属性。

不同的Unix IPC的持续性:

  1. 随进程

    Pipe, FIFO, Posix的mutex(互斥锁), condition variable(条件变量), read-write lock(读写锁),memory-based semaphore(基于内存的信号量) 以及 fcntl record lock,TCP和UDP套接字,Unix domain socket

  2. 随内核

    Posix的message queue(消息队列), named semaphore(命名信号量), System V Message queue, semaphore, shared memory。

要注意的是,虽然上面所列的IPC并没有随文件系统的,但是我们就像我们刚才所说的那样,Posix IPC可能会跟着系统具体实现而不同(具有不同的持续性),举个例子,写入文件肯定是一个文件系统持续性的操作,但是通常来说IPC不会这样实现。很少有IPC会实现文件系统持续,因为这会降低性能,不符合IPC的设计初衷。

System V IPC与Posix IPC是两种IPC的标准,后者在前者之上进行了改进,但是基本的概念都是差不多

具体的差别可见:系统V IPC与POSIX IPC

System V IPC是UNIX系统上广泛使用的三种进程间通信机制的名称:消息队列、信号量和共享内存。是随内核持续的,直到内核被重启或者对象被显性关闭为止。

  1. System V 消息队列:

    System V 消息队列允许数据以称为消息的单位交换,每个消息都可以有一个关联的优先级。POSIX消息队列提供了实现相同结果的替代API

  2. System V 信号量:

    System V信号量允许进程同步它们的动作。系统V的信号量被分配到称为集合的组中;集合中的每个信号量都是一个计数信号量。POSIX信号量提供了实现相同结果的替代API。

  3. System V 共享内存:

    系统V共享内存允许进程共享一个区域一个内存(一个scegment)。POSIX共享内存是实现相同结果的另一种API

2. 实践

实践其实很简单,在刚刚的程序上增加一个flag即可:

Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC,

再次编译启动:

查看现有的ipc消息队列: ipcs -q

$ ipcs -q
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages    

创建一个消息队列

$ ipcmk -Q							// 创建一个消息队列
Message queue id: 0
$ ipcs -q								// 查看

------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages    
0xcc4f9f77 0          root       644        0            0         

使用另一个终端查看消息队列:

$ ipcs -q

------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages    

无法查看到,说明实现了消息队列的隔离

5. PID Namespace

pid Namespace就是用于隔离进程ID的,同样一个进程在不同的PID Namespace可以拥有不同的进程ID

同样的修改一下代码中的flag:

Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID,

编译启动后,首先查看自己真实的PID

GHqSyk

当前的Pid是10166

然后查看自己当前隔离之后的ID:

$ echo $$
1

(注意,这里不能使用ps、top等命令查看,因为会调用/proc内容,后面会解决这个问题)

6. Mount Namespace

mount_namespaces(7)

负责隔离各个进程看到的挂载点视图,让每一个进程看到的文件系统层次是不同的。这也是Linux第一个实现的Namespace类型,所以注意他的flag是CLONE_NEWNS(new Namespace的缩写)

1. Mount命令

在挂载了/proc的进程下可以使用Mount命令

mount命令主要用于挂载linux中的文件系统等操作

关于此命令的详细使用可以查看:mount(8) — Linux manual page

这里列出常用的几个并解释:

  • mount : 输出当前所有的设备、挂载目录以及类型

  • mount -t type device dir
    device:指定要挂载的设备,比如磁盘、光驱等。
    dir:指定把文件系统挂载到哪个目录。
    type:指定挂载的文件系统类型,一般不用指定,mount 命令能够自行判断。
    options:指定挂载参数,比如 ro 表示以只读方式挂载文件系统。

    告诉内核将在device上(类型为type)上找到的文件系统附加到目录dir。选项-t type是可选的, 不写的话通常会自动检测。

  • mount --bind olddir newdir

    将文件系统层次结构的一部分重新挂载到其他地方,调用之后相同的内容可以在两个地方访问。重要的是要理解“bind”不会在内核VFS中创建任何二类或特殊的节点。“绑定”只是附加文件系统的另一个操作。

shared subtree

Mount有一个重要的地方,就是它的shared subtree机制:

该机制的出现主要是为了解决Mount Namespace操作不方便的问题:当宿主机有新的磁盘挂载后,我们希望能够通知所有的其他Namespace挂载这个磁盘,但是如果是完全隔离的状态,那么是需要每个都手动操作,非常麻烦。所以就出现了shared subtree机制,这个机制主要有两个关键点:

  1. peer group

    表示一个或者多个挂载点的集合,有下面两种情况会分到一个集合(group):

    • 通过--bind操作挂载的源挂载点(必须是一个挂载点)与目的挂载点
    • 新生成mount ns复制过去的挂载点在同一个集合
  2. propagate type 传播属性

    上述的问题依靠这个属性解决。传播属性重新定义了挂载对象/点之间的关系,定义的关系包括:

    • 共享关系: 一个挂载对象的挂载事件会传播到另一个挂载对象,反之亦然 (双向)
    • 从属关系:一个挂载对象的挂载事件会传播给另一个,反之不会 (单向)

    一个挂载的状态可以有以下几种:

    8T4RPY

    image-20211030233301325

    ​ 默认情况下,所有挂载状态都是私有的,改变状态的方法如下所示:

mount --make-shared mountpoint					// 共享
mount --make-slave mountpoint						// 从属
mount --make-private mountpoint					// 私有
mount --make-unbindable mountpoint			// 设置不可被绑定
// 有r前缀的表示递归的修改挂载点以及其子目录
mount --make-rshared mountpoint
mount --make-rslave mountpoint
mount --make-rprivate mountpoint
mount --make-runbindable mountpoint

2. 实践

修改代码:

Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,

编译启动:

查看一下/proc的文件内容

/proc是一个文件系统,可以通过特殊的机制将内核和内核信息发送给进程

$ ls /proc
1      19510  21767  21800  21833  21866  21900  21942  21975  22006  22039  22072  22105  22138  22172  22206  24     5299       driver        softirqs
10     19570  21768  21801  21834  21867  21901  21943  21976  22007  22040  22073  22106  22139  22173  22207  24819  5300       execdomains   stat
10143  19615  21769  21802  21835  21868  21902  21944  21977  22008  22041  22074  22107  22140  22174  22208  249    584        fb            swaps
11     2      21770  21803  21836  21869  21903  21945  21978  22009  22042  22075  22108  22141  22175  22209  25     589        filesystems   sys
115    20     21771  21804  21837  21870  21904  21946  21979  22010  22043  22076  22109  22142  22176  22210  25538  6          fs            sysrq-trigger
12     20444  21772  21805  21838  21871  21905  21947  21980  22011  22044  22077  22110  22143  22177  22211  258    6021       interrupts    sysvipc
13     20474  21773  21806  21839  21872  21906  21948  21981  22012  22045  22078  22111  22144  22178  22212  26     672        iomem         thread-self
....

这是宿主机的/proc,我们还需要手动的将/proc mount(挂载)到我们自己的Namespace下

使用命令mount -t proc proc /proc将宿主机的proc文件系统挂载到自己的Namespace下的/proc目录上

再次查看/proc文件系统:

$ ls /proc
1          cgroups   devices      fb           ioports   key-users    loadavg  modules       partitions   slabinfo  sysrq-trigger  uptime             zoneinfo
5          cmdline   diskstats    filesystems  irq       kmsg         locks    mounts        sched_debug  softirqs  sysvipc        version
acpi       consoles  dma          fs           kallsyms  kpagecgroup  mdstat   mtrr          schedstat    stat      thread-self    version_signature
buddyinfo  cpuinfo   driver       interrupts   kcore     kpagecount   meminfo  net           scsi         swaps     timer_list     vmallocinfo
bus        crypto    execdomains  iomem        keys      kpageflags   misc     pagetypeinfo  self         sys       tty            vmstat

相比宿主机的/proc已经少了很多内容

使用ps -ef查看进程:

$ ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 22:21 pts/1    00:00:00 sh
root         8     1  0 22:34 pts/1    00:00:00 ps -ef

可以看到这时候就只能看到自己Namespace下的进程了

我们回到宿主机,发现宿主机的/proc不能使用了,这就是因为宿主机的Namespace文件中本来对挂载点/proc设置的就是共享挂载,所以我们使用clone复制的时候,新的Mount Namespace也是共享挂载,两者是共享和传播的方式。

回到宿主机通过查看cat /proc/self/mountinfo当前宿主机NS的挂载点信息:

image-20211031092033846

/proc等系统的挂载点都是shared的状态

为了让新建的Mount Namespace不影响其他的Mount Namespace,所以我们需要设置为私有挂载模式:

一样的编译启动新的Mount Namespace,但是这次挂载的操作不同:

# 将/proc目录设置为私有挂载
$ mount --make-rprivate /proc
# 挂载/proc
$ mount -t proc proc /proc
# 查看
$ ls /proc

u6ByLy

通过运行可以看到在新的NS中我们实现了隔离

现在返回原来的NS,直接查看/proc看看是否受到影响

x0UWFs

可以看到并没有收到影响。我们不需要再重新挂载了,实现了隔离。

mount实现了和外部空间的隔离,在本Namespace下挂载的文件系统不回影响外部,所以这也是docker数据卷的特性原因之一

7. User Namespace

User Namespace主要隔离用户的用户组ID。比较常见的做法是将宿主机上的一个非root用户在新建的User Namespace中映射成一个root用户,这意味着这个进程在User Namespace内部是有root权限的。

在Linux Kernel 3.8开始非root进程也可以创建User Namespace了,并且实现了在新创的User Namespace中拥有root权限

修改代码如下:

func main() {
    
    
	cmd := exec.Command("sh")
	cmd.SysProcAttr = &syscall.SysProcAttr{
    
    
		Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWUSER,
	}
	// 设置凭证
	cmd.SysProcAttr.Credential = &syscall.Credential{
    
    
		Uid: uint32(1),
		Gid: uint32(1),
	}
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	if err := cmd.Run(); err != nil {
    
    
		log.Fatal(err)
	}
}

首先查看当前宿主机的用户与用户组: id

$ id
uid=0(root) gid=0(root) groups=0(root)

接下来运行程序再同样执行:

报错:fork/exec /bin/sh: operation not permitted

原因:https://github.com/xianlubird/mydocker/issues/3

Linux kernel 在 3.19 以上的版本中对 user namespace做了些修改

解决:删除掉cmd.SysProcAttr.Credential

注意:centos不支持user namspace需要额外的设置,见原因链接

再次运行:

$ id
uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)

可以看到UID已经不同了,因此User Namespace生效了

8. Network Namespace

用于隔离网络设备、IP地址端口等网络栈的Namespace。可以让每个容器拥有自己独立的(虚拟的)网络设备,并且容器可以绑定到自己端口,每个Namespace内的端口都不会互相冲突。

在宿主机上搭建网桥后就可以很方便的实现容器之间的通信,并且不同的容器可以使用相同的端口!

同样的添加flag:

Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWUSER | syscall.CLONE_NEWNET,

首先查看宿主机的网路设备情况:

$ ifconfig
docker0: flags=4099<UP,BROADCAST,MULTICAST>  mtu 1500
        inet 172.18.0.1  netmask 255.255.0.0  broadcast 172.18.255.255
        ether 02:42:df:81:27:f6  txqueuelen 0  (Ethernet)
        RX packets 585  bytes 140849 (140.8 KB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 611  bytes 987099 (987.0 KB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 172.17.59.2  netmask 255.255.192.0  broadcast 172.17.63.255
        ether 00:16:3e:0e:2d:b8  txqueuelen 1000  (Ethernet)
        RX packets 1168249932  bytes 1131213773283 (1.1 TB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 683891978  bytes 296842597629 (296.8 GB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
        inet 127.0.0.1  netmask 255.0.0.0
        loop  txqueuelen 1000  (Local Loopback)
        RX packets 67191390  bytes 8889749352 (8.8 GB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 67191390  bytes 8889749352 (8.8 GB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

错误:宿主机的/proc不见了 /proc is empty (not mounted ?)

解决:在宿舍机上重新挂载mount -t proc proc /proc

运行程序,在新的Network Namespace中查看网络:

运行的结果是:啥也没有。所以已经处于隔离状态了。

2.3 Linux Cgroups 资源限制

在Namespace中,我们只实现了隔离,但是怎样限制每个容器空间的大小呢,这就要使用到Cgroups

Linux Cgroups(Control Groups)提供了对一组进程以及将来子进程的资源限制,包括CPU、内存、存储与网络等

使用Cgroups可以方便的限制某个资源的占用并且可以实时的监控和统计信息

Control Groups,通常被称为cgroups,是Linux内核的一个特性,它允许进程被组织成分层的组,这些组可以被限制和监视各种类型的资源的使用。内核的cgroup接口是通过一个名为cgroupfs的伪文件系统提供的。分组是在核心cgroup内核代码中实现的,而资源跟踪和限制是在一组每个资源类型的子系统(内存、CPU等)中实现的。

最初发布的cgroups是在linux 2.6.24中运行的,但是随后的不协调的更新导致了cgroups分为了两个版本维护:v1和v2

v2发布在linux 3.10之后,v1成为了老版本,因为兼容性没有被移除

cgroups 主要提供了如下功能:

  • 资源限制: 限制资源的使用量,例如我们可以通过限制某个业务的内存上限,从而保护主机其他业务的安全运行。
  • 优先级控制:不同的组可以有不同的资源( CPU 、磁盘 IO 等)使用优先级。
  • 审计:计算控制组的资源使用情况。
  • 控制:控制进程的挂起或恢复。

详细内容见:cgroups(7) — Linux manual page

1. 三大组件

Cgroups构成主要是三个组件:Cgroup、Subsystem、Hierarchy

控制组Cgroup

cgroup用于对进程进行分组管理的机制,一个cgroup包含一组进程,然后可以以cgroup为单位来进行子系统subsystem的各种参数配置。表示一组进程和一组带有参数的子系统的关联关系。例如,一个进程使用了 CPU 子系统来限制 CPU 的使用时间,则这个进程和 CPU 子系统的关联关系称为控制组。

查看一下当前系统已经挂载的cgroups信息:

$ mount -t cgroup
cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,name=systemd)
cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
cgroup on /sys/fs/cgroup/net_cls,net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,net_cls,net_prio)
cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event)
cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb)
cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
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,cpu,cpuacct)
cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids)
cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer)
cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices)
cgroup on /sys/fs/cgroup/rdma type cgroup (rw,nosuid,nodev,noexec,relatime,rdma)
cgroup-test on /root/projects/golang_project/src/myDocker/cgroup-test type cgroup (rw,relatime,name=cgroup-test)

可以看到当前系统已经挂载了我们常用的cgroups子系统

子系统Subsystem

subsystem是一组资源控制的模块,是一个内核的组件,一个子系统代表一类资源调度控制器。包含如下几项:

image-20211101193901382

每个subsystem关联到相应的cgroup上并对其中的进程进行资源的限制与控制。这些subsystem都是逐步合并到内核中的。

查看自己linux内核支持的subsystem:

# 安装cgroup命令工具
$ apt-get install cgroup-bin
# 查看
$ lssubsys

mJmFWQ

层级树Hierarchy

层级树是由一系列的控制组按照树状结构排列组成的。

cgroup是一组进程和其子系统关联的集合, hierarchy是一系列cgroup的集合

通过这样的树结构,cgroups可以实现继承

例如:将多个进程设置为一组cgroup,并在其中设置了限制cpu的使用率。但是其中一个特殊的进程活动需要限制磁盘的I/O,如果单独设置就可能会影响其他同cgroup的进程。此时就可以将这个进程单独化为一个新的cgroup2,让其继承于cgroup1,这样单独对cgroup2设置限制就不会影响到cgroup1的其他进程。

三者关系

  • 系统创建hierarchy之后,默认创建一个cgroup根节点,系统会将所有进程加入到此cgroup中
  • 一个subsystem只能附加到一个hierarchy上(一对一的关系)
  • 一个hierarchy可以附加多个subsystem(一对多的关系)
  • 一个进程可以作为多个cgroup的成员,但是这些cgroup不能在一个hierarchy上(hierarchy与进程一对一)
  • 一个进程fork出的子进程与父进程在同一个cgroup上,当然也可以通过操作移动

2. 配置与操作Cgroups

kernel为了让cgroups的配置更加直观,通过一个虚拟的树状文件系统配置Cgroups,通过层级的目录虚拟出Cgroup树(hierarchy)。

配置案例:

1. 初始化

$ mkdir cgroup-test		# 创建挂载点
# 挂载一个hierarchy, -o表示添加可选项
$ sudo mount -t cgroup -o none,name=cgroup-test cgroup-test ./cgroup-test
# 查看挂载点信息
$ cat /proc/self/mountinfo

ocpDDK

可以看到刚刚设置的挂载

# 挂载后可以在目录下看到生成了许多默认文件
$ tree ./cgroup-test

默认文件以及其解释如下:

Jng8oK

2. 创建子cgroup

创建两个子cgroup

$ sudo mkdir cgroup-1
$ sudo mkdir cgroup-2
$ tree

P7lzMt

可以看到在一个cgroup的目录下创建文件夹时,内核会把这个文件夹标记为子cgroup创建必要文件并继承父cgroup的属性

3. 移动进程

一个进程在一个hierarchy上只能存在于一个cgroup,移动进程cgroup修改task文件即可

$ echo $$
# 将我的终端进程移动到cgroup-1中
$ cd cgroup-1
$ sudo sh -c "echo $$ >> tasks"
# 查看
$ cat /proc/17795/cgroup 

aJnSgh

4. subsystem限制进程资源

上面在创建层级树的之后并没有指定关联到任何的子系统,但是系统默认给每个子系统创建了一个默认的层级树(见控制组部分,使用mount -t cgroup可以查看到),例如memory的层级树,在子系统的根目录下的memory目录,这是一个层级树

# 查看
$ mount | grep memory
$ ls /sys/fs/cgroup/

下面就通过在这个层级树中创建cgroup限制如下进程占用的内存资源

$ cd /sys/fs/cgroup/memory/
# 在没有限制的情况下,启动一个占用内存的stress进程
$ apt install stress
$ stress --vm-bytes 200m --vm-keep -m 1
# 创建一个cgroup
$ sudo mkdir limit-memory-test && cd limit-memory-test
# 设置最大占用内存为100MB
$ sudo sh -c "echo  "100m" > memory.limit_in_bytes"
$ cat memory.limit_in_bytes
# 将当前进程移动到此group
$ sudo sh -c "echo $$ > tasks"
# 再次运行占用内存200MB的stress进程
$ stress --vm-bytes 200m --vm-keep -m 1

发现200m的压力测试启动失败,只有小于这个限制的stress才可以跑起来, 因为做了限制,当stress使用的内存大于限制后,cgroup就会将其进程杀死。

stress: info: [17728] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd
stress: FAIL: [17728] (415) <-- worker 17729 got signal 9
stress: WARN: [17728] (417) now reaping child worker processes
stress: FAIL: [17728] (451) failed run completed in 0s
# 删除创建的测试文件
$ rmdir limit-memory-test/

3. Docker如何使用Cgroups

通过实例查看一个docker容器怎样配置Cgroups

# 设置内存限制启动容器
$ sudo docker run -itd -m 128m ubuntu

docker会为每个容器在系统的hierarchy中创建cgroup

$ cd /sys/fs/cgroup/memory/docker/b496830a00213173ec4d43aee2ae01be6c524cda8e6f71e66e1defb90e10c32e
# 查看cgroup中进程使用的内存大小
$ cat memory.usage_in_bytes 

docker为每个容器创建cgroup并通过cgroup配置资源限制与监控

4. Go实现通过cgroup限制容器资源

package main

import (
	"fmt"
	"io/ioutil"
	"os"
	"os/exec"
	"path"
	"strconv"
	"syscall"
)

// 挂载了memory subsystem的hierarchy的根目录位置
const cgroupMemoryHierarchyMount = "/sys/fs/cgroup/memory"

func main() {
    
    
	fmt.Printf("os.Args[0] is %s\n", os.Args[0])
	// /proc/self/exe 它代表当前程序
	if os.Args[0] == "/proc/self/exe" {
    
    
		// 容器进程执行函数
		fmt.Printf("current pid %d\n", syscall.Getegid())	// 获得自己Namespace下的pid
		cmd := exec.Command("sh", "-c", `stress --vm-bytes 60m --vm-keep -m 1`)
		cmd.SysProcAttr=&syscall.SysProcAttr{
    
    }
		cmd.Stdin = os.Stdin
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
		if err := cmd.Run(); err != nil {
    
    
			fmt.Errorf("run err : %s \n", err.Error())
			os.Exit(1)
		}
	}
	// 执行当前程序
	cmd := exec.Command("/proc/self/exe")
	// fork一个子进程并设置Namespace
	cmd.SysProcAttr = &syscall.SysProcAttr{
    
    
		Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
	}
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	// start启动一个命令,但是不会等待其运行结束
	if err := cmd.Start(); err != nil {
    
    
		fmt.Errorf("start err : %s\n", err.Error())
		os.Exit(1)
	}
	// 得到fork出来的进程在外部命名空间的pid
	fmt.Printf("cmd pid is %v\n", cmd.Process.Pid)
	// 在系统默认创建挂载了memory subsystem的Hierarchy上创建新的cgroup
	os.Mkdir(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit"), 0755)
	// 将容器进程(cmd进程)放到这个cgroup中(在task文件中写入进程id)
	ioutil.WriteFile(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit", "tasks"), []byte(strconv.Itoa(cmd.Process.Pid)), 0644)
	// 限制cgroup进程使用
	ioutil.WriteFile(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit", "memory.limit_in_bytes"), []byte("50m"), 0644)
	// 等待进程结束
	cmd.Process.Wait()
}

运行结果:

cOIHnY

/proc/self/exe它代表当前执行的程序,是一个软链接. os.Args[0]就是当前执行文件路径

运行go run 2-cgroups.go

首先,当前执行路径是go编译器在tmp里面临时编译的可执行文件路径(见输出第一行),所以if os.Args[0] == "/proc/self/exe" {...}不会进入

而是使用cmd创建隔离Namespace环境再次执行当前程序(创建一个新的进程),因为采用的是cmd.Start()并不会等待此次执行结束(第二次执行当前程序),所以先输出了底下的cmd pid is 31669,然后一系列操作将这个新的进程设置了cgroup的内存限制为50m

随后已经第二次执行也同步开始,符合if条件进入if体内,输出当前进程的pid0(因为PID Namespace的隔离),值得注意的是这个进程与上面输出的31669进程是同一个进程,只是在外部Namespace中看此进程是31669,而在新的Namespace中其被隔离为0. 所以上面的进程限制就是设置在此进程。

随后在子进程中启动stress压力测试程序,内存占用要求为60m,因为收到了限制,所以无法启动,发生了报错。

可以尝试修改启动的限制,当在限制下启动是没有问题的。所以说明我们的内存限制起了作用。

2.4 Union File System 联合文件系统

1. 什么是Union File System

Union File System简称UnionFS, 是docker存储驱动功能的技术实现,是一种为Linux、FreeBSD和NetBSD操作系统设计的,把其他文件系统联合到一个联合挂载点的文件系统服务

使用不同的分支(branch)把不同文件系统的文件和目录“透明的”覆盖,形成一个单一的文件系统。这些branch都是只读(read-only)或者读写(read-write)的。

Union FileSystem的核心逻辑是Union Mount,**它支持把一个目录A叠加到另一个目录B之上;用户对目录B的读取就是A加上B的内容,而对B目录里文件写入和改写则会保存在目录A上,因为A在上一层。**这个类似差分VHD的效果,但是是以文件为单位的。因为是Union FS主要负责叠加访问的逻辑,因此对叠加的目录的原始文件系统适应性比较好。

例如:Dockerfile:

FROM ubuntu:14.04
ADD run.sh /
VOLUME /data
CMD ["./run.sh"]

kQZvVw

联合文件系统是docker镜像和容器的基础,它可以使Docker把镜像做成分层的结构,使镜像的每一层可以被共享。例如两个业务镜像都是基于 CentOS 7 镜像构建的,那么这两个业务镜像在物理机上只需要存储一次 CentOS 7 这个基础镜像即可,从而节省大量存储空间。

1. 写时复制

虚拟后的联合文件系统采用一项资源管理技术来防止改变原来的文件—写时复制(copy-on-write)/隐式共享

简单来说就是一个资源被重复使用时,没有任何修改就不会复制一个新的文件,这个资源可以被新旧实例共享。

但是在一个实例第一次对其执行写操作时,就会复制一个文件

简单的来说,就是在第一次需要改动的时候创建一个副本,这样就不会影响之前的文件

不同版本的UnionFS的版本不同,可以通过本机的docker info命令查看

image-20211102220030177

2. UnionFS实现分类

UnionFS 常见的实现有:

  • UnionFS,很早就开始的实现,看名字就很霸道,目前使用较少。
  • AUFS,全称是Advanced Multi Layered Unification Filesystem。创建于2006年,是对UnionFS的重构,因此初期也叫Another UnionFS。在 Docker 早期,OverlayFS 和 Devicemapper 相对不够成熟,AUFS 是最早也是最稳定的文件系统驱动。AuFS被众多Linux发行版所使用(多用于 Ubuntu 和 Debian 系统中),主要的场景就是LiveCD。目前最新的版本是AUFS4。
  • Devicemapper:应用于Red Hat 或 CentOS 系统中,Devicemapper 一直作为 Docker 默认的联合文件系统驱动,为 Docker 在 Red Hat 或 CentOS 稳定运行提供强有力的保障。
  • OverlayFS,近几年的Ubuntu发行版就使用的这个实现。OverlayFS的最大优势是在Linux 3.18时合并到内核,成为了Linux内建支持的文件系统了。

Docker 中最常用的联合文件系统有三种:AUFS、 Devicemapper和 OverlayFS。

2. AUFS

全称:Advanced Multi-Layered Unification Filesystem, 其完全重写了早期的UnionFS 1.x,主要的原因还是提高可靠性和性能,并引入一些新的功能,例如分支的负载均衡等。AUFS的一些实现已经被纳入UnionFS 2.x版本。

AUFS 是 Docker 最早使用的文件系统驱动,多用于 Ubuntu 和 Debian 系统中。在 Docker 早期,OverlayFS 和 Devicemapper 相对不够成熟,AUFS 是最早也是最稳定的文件系统驱动。

AUFS 目前并未被合并到 Linux 内核主线,因此只有 Ubuntu 和 Debian 等少数操作系统支持 AUFS。你可以使用以下命令查看你的系统是否支持 AUFS:

$ grep aufs /proc/filesystems
nodev   aufs

执行以上命令后,如果输出结果包含aufs,则代表当前操作系统支持 AUFS。AUFS 推荐在 Ubuntu 或 Debian 操作系统下使用,如果你想要在 CentOS 等操作系统下使用 AUFS,需要单独安装 AUFS 模块(生产环境不推荐在 CentOS 下使用 AUFS,如果你想在 CentOS 下安装 AUFS 用于研究和测试,可以参考这个链接),安装完成后使用上述命令输出结果中有aufs即可。 当确认完操作系统支持 AUFS 后,你就可以配置 Docker 的启动参数了。

1. Docker配置AUFS

更改Docker存储驱动为AUFS

如果你的系统支持AUFS,那么可以通过以下方式实现修改Docker的启动参数配置

先在 /etc/docker下新建 daemon.json 文件,并写入以下内容:

{
    
    
  "storage-driver": "aufs"
}

然后重启docker

$ sudo systemctl restart docker

重启成功后查看docker的文件系统是否改变:docker info

docker信息

2. AUFS原理

AUFS 是联合文件系统,意味着它在主机上使用多层目录存储,每一个目录在 AUFS 中都叫作分支,而在 Docker 中则称之为层(layer),但最终呈现给用户的则是一个普通单层的文件系统,我们把多层以单一层的方式呈现出来的过程叫作联合挂载。

rhWyCa

NO0FRW

如图 1 所示,每一个镜像层和容器层都是/var/lib/docker 下的一个子目录,镜像层和容器层都在 aufs/diff目录下,每一层的目录名称是镜像或容器的ID值(需要说明的是自从docker 1.10之后使用基于内容的寻址,diff目录下存储镜像layer文件夹不在与ID相同)),联合挂载点在aufs/mnt目录下,mnt目录是真正的容器工作目录。

当一个镜像未生成容器时,AUFS 的存储结构如下。

  • diff 文件夹:存储镜像内容,每一层都存储在以镜像层 ID 命名(不一定了)的子文件夹中。
  • layers 文件夹:存储镜像层关系的元数据,在 diif 文件夹下的每个镜像层在这里都会有一个文件,文件的内容为该层镜像的父级镜像的 ID。
  • mnt 文件夹:联合挂载点目录,未生成容器时,该目录为空。

当一个镜像已经生成容器时,AUFS 存储结构会发生如下变化。

  • diff 文件夹:当容器运行时,会在 diff 目录下生成容器层。
  • layers 文件夹:增加容器层相关的元数据。
  • mnt 文件夹:容器的联合挂载点,这和容器中看到的文件内容一致。

读取文件(容器优先策略)

当我们在容器中读取文件时,可能会有以下场景。

  • 文件在容器层中存在时:当文件存在于容器层时,直接从容器层读取。
  • 当文件在容器层中不存在时:当容器运行时需要读取某个文件,如果容器层中不存在时,则从镜像层查找该文件,然后读取文件内容。
  • 文件既存在于镜像层,又存在于容器层:当我们读取的文件既存在于镜像层,又存在于容器层时,将会从容器层读取该文件。

修改文件或目录:

AUFS 对文件的修改采用的是写时复制的工作机制,这种工作机制可以最大程度节省存储空间。

具体的文件操作机制如下。

  • 第一次修改文件:当我们第一次在容器中修改某个文件时,AUFS 会触发写时复制操作,AUFS 首先从镜像层复制文件到容器层,然后再执行对应的修改操作。

AUFS 写时复制的操作将会复制整个文件,如果文件过大,将会大大降低文件系统的性能,因此当我们有大量文件需要被修改时,AUFS 可能会出现明显的延迟。好在,写时复制操作只在第一次修改文件时触发,对日常使用没有太大影响。

  • 删除文件或目录:当文件或目录被删除时,AUFS 并不会真正从镜像中删除它,因为镜像层是只读的,AUFS 会创建一个特殊的文件或文件夹,这种特殊的文件或文件夹会阻止容器的访问。

3. AUFS实践-镜像层

在清空了所有的镜像与容器后:

tree /var/lib/docker/aufs/

2yVRwz

可以发现三个文件夹,但是没有任何的内容。

拉取一个镜像:ubuntu:15.04

docker pull ubuntu:15.04

ENA0g5

Docker pull 显示的结果中有四个Layer

查看:

tree /var/lib/docker/aufs/ -L 2

DOoP9g

在执行完命令后发现结果中也对应了四个存储文件夹,layers下面是对应的四个文件

查看layers下的元数据

cat /var/lib/docker/aufs/layers/2bb1a15acde9ea1f49312678e3fa587dfa35d4bdbd91dc33851002c904739875

Qb1yOs

显示结果为其父级的ID

所以,最终的层级结构如图:

L9Nk1l

接下来我们使用Dockerfile来实现以ubuntu:15.04为基础镜像创建一个changed-ubuntu的镜像,唯一的区别是在/tmp文件夹下创建了一个hello world的文件.Dockerfile文件内容如下:

Dockerfile 是一个用来构建镜像的文本文件,文本内容包含了一条条构建镜像所需的指令和说明。From表示要使用的基础镜像,每个RUN命令后面都会跟着具体的操作并且会在基础镜像之上创建新的一层

FROM ubuntu:15.04
RUN echo "hello world" > /tmp/newfile

运行:

# 编译并重命名为changed-ubuntu
$ docker build -t changed-ubuntu .

n7okb9

# 查看当前镜像:
$ docker images

REPOSITORY       TAG       IMAGE ID       CREATED         SIZE
changed-ubuntu   latest    5eec16ceae30   3 minutes ago   131MB
ubuntu           15.04     d1b55fd07600   5 years ago     131MB
# 查看changed-ubuntu使用了哪些image layer
$ docker history changed-ubuntu

IMAGE          CREATED         CREATED BY                                      SIZE      COMMENT
5eec16ceae30   5 minutes ago   /bin/sh -c echo "hello world" > /tmp/newfile    12B       
d1b55fd07600   5 years ago     /bin/sh -c #(nop) CMD ["/bin/bash"]             0B        
<missing>      5 years ago     /bin/sh -c sed -i 's/^#\s*\(deb.*universe\)$…   1.88kB    
<missing>      5 years ago     /bin/sh -c echo '#!/bin/sh' > /usr/sbin/poli…   701B      
<missing>      5 years ago     /bin/sh -c #(nop) ADD file:3f4708cf445dc1b53…   131MB  

可以看到5eec16ceae30镜像位于最上层,只有12B的大小,同时也可以观察到5eec16ceae30只用了12B的空间大小!这也证明了AUFS高效的使用了磁盘空间。下面的四层则是共享地构成ubuntu:15.04镜像的4个image layer

missing标记的layer是自docker 1.10之后,一个镜像的image layer的镜像历史数据都存储在一个文件中导致的,并不是什么异常

再次查看layer的存储信息:

QYHK0Y

可以观察到,每个目录下都多出了2b8a9...这样的文件/文件夹

# 查看changed-ubuntu中创建的文件
$ cat diff/2b8a9b74f3c04b46702d2e2b3e3ecf558b51e042efb2e37d6d043b653fd68b0e/tmp/newfile

hello world

# 查看元数据文件
$ cat layers/2b8a9b74f3c04b46702d2e2b3e3ecf558b51e042efb2e37d6d043b653fd68b0e 

ee5a42077cd3580a57a2fb6b8e9d8e43ea0c6c8b10fa41911c881972ef45ca0a
eaa5375a0e40af681796c3aa48d822e99b89f48d4dd2484aa012b2b760a5456b
2bb1a15acde9ea1f49312678e3fa587dfa35d4bdbd91dc33851002c904739875
7cb95dc4362ff5d14e931b542153a93ed708c37ce5b9d34e5547766638dd1d14

元数据文件的输出也可以看出层级就是在之前的四层上加了一层

4. AUFS实践-容器层

docker使用AUFS的写时复制(CoW)技术来实现image layer共享和减少磁盘的空间占用。CoW机制意味着一旦某一个文件只有很小部分的改动,AUFS也需要复制整个文件,这样的设计会对容器的性能产生一定影响,尤其是在文件比较大的时候或者位于很多image layer的下方又或者AUFS需要深度搜索目录结构树的时候。不过,这样的复制也只是一次,后续就不再需要了。

启动一个容器的时候,Docker会为其创建一个read-only的init layer,用于存储与这个容器内环境相关的内容;同时Docker还会创建一个read-write的layer来执行所有写操作。

存放位置:

  • 容器的mount挂载目录是/var/lib/docker/aufs/mnt
  • 元数据和配置文件都在/var/lib/docker/containers/<container-id>
  • read-write层存储在/var/lib/docker/aufs/diff

即使容器停止后,读写层仍然存在,所以容器的重启不会丢失数据。只有当一个容器被删除的时候,这个读写层才会被删除

接下来,通过实验验证这些结论:

首先,清空所有容器

# 查看所有容器,空
$ docker ps -aq
# 查看/var/lib/docker/containers/文件夹,空
$ ls /var/lib/docker/containers/
# 查看系统的aufs mount情况, 只有一个config文件夹
$ ls /sys/fs/aufs/

config

启动一个changed-ubuntu容器

$ docker run -dit changed-ubuntu bash
9eb5eb21b523982b561ae6fe1c67aaed2289bc5ec5801de8eea7428c1779988e
$ docker ps -a
CONTAINER ID   IMAGE            COMMAND   CREATED          STATUS          PORTS     NAMES
9eb5eb21b523   changed-ubuntu   "bash"    35 seconds ago   Up 34 seconds             gracious_sutherland

查看目录:

ls /var/lib/docker/aufs/diff/

ZL64KQ

多出了两个文件夹:带有init的是init层只读文件夹,而另一个没有的是read-write层文件夹

同理: ls /var/lib/docker/aufs/mnt/

Xb2UG5

ls /var/lib/docker/aufs/layers/

dgOkJK

# 查看文件内容:layer依赖
$ cat /var/lib/docker/aufs/layers/e26931d94a12e4b8bc0b66942d4525ff5aa6974d14ca9ebe847290533f6f0121

e26931d94a12e4b8bc0b66942d4525ff5aa6974d14ca9ebe847290533f6f0121-init
2b8a9b74f3c04b46702d2e2b3e3ecf558b51e042efb2e37d6d043b653fd68b0e
ee5a42077cd3580a57a2fb6b8e9d8e43ea0c6c8b10fa41911c881972ef45ca0a
eaa5375a0e40af681796c3aa48d822e99b89f48d4dd2484aa012b2b760a5456b
2bb1a15acde9ea1f49312678e3fa587dfa35d4bdbd91dc33851002c904739875
7cb95dc4362ff5d14e931b542153a93ed708c37ce5b9d34e5547766638dd1d14

$ cat /var/lib/docker/aufs/layers/e26931d94a12e4b8bc0b66942d4525ff5aa6974d14ca9ebe847290533f6f0121-init 

2b8a9b74f3c04b46702d2e2b3e3ecf558b51e042efb2e37d6d043b653fd68b0e
ee5a42077cd3580a57a2fb6b8e9d8e43ea0c6c8b10fa41911c881972ef45ca0a
eaa5375a0e40af681796c3aa48d822e99b89f48d4dd2484aa012b2b760a5456b
2bb1a15acde9ea1f49312678e3fa587dfa35d4bdbd91dc33851002c904739875
7cb95dc4362ff5d14e931b542153a93ed708c37ce5b9d34e5547766638dd1d14

查看container文件夹: ls

$ tree /var/lib/docker/containers/

/var/lib/docker/containers/
└── 9eb5eb21b523982b561ae6fe1c67aaed2289bc5ec5801de8eea7428c1779988e
    ├── 9eb5eb21b523982b561ae6fe1c67aaed2289bc5ec5801de8eea7428c1779988e-json.log
    ├── checkpoints
    ├── config.v2.json
    ├── hostconfig.json
    ├── hostname
    ├── hosts
    ├── mounts
    ├── resolv.conf
    └── resolv.conf.hash

新创建了一个与容器ID相同名称的文件夹存放着容器的metadata元数据与config文件

接下来从系统AUFS来看mount的情况:在/sys/fs/aufs/下多了一个文件夹:si_1b321958ae8fb1bb

$ cat /sys/fs/aufs/si_1b321958ae8fb1bb/*

/var/lib/docker/aufs/diff/e26931d94a12e4b8bc0b66942d4525ff5aa6974d14ca9ebe847290533f6f0121=rw
/var/lib/docker/aufs/diff/e26931d94a12e4b8bc0b66942d4525ff5aa6974d14ca9ebe847290533f6f0121-init=ro+wh
/var/lib/docker/aufs/diff/2b8a9b74f3c04b46702d2e2b3e3ecf558b51e042efb2e37d6d043b653fd68b0e=ro+wh
/var/lib/docker/aufs/diff/ee5a42077cd3580a57a2fb6b8e9d8e43ea0c6c8b10fa41911c881972ef45ca0a=ro+wh
/var/lib/docker/aufs/diff/eaa5375a0e40af681796c3aa48d822e99b89f48d4dd2484aa012b2b760a5456b=ro+wh
/var/lib/docker/aufs/diff/2bb1a15acde9ea1f49312678e3fa587dfa35d4bdbd91dc33851002c904739875=ro+wh
/var/lib/docker/aufs/diff/7cb95dc4362ff5d14e931b542153a93ed708c37ce5b9d34e5547766638dd1d14=ro+wh
64
65
66
67
68
69
70
/dev/shm/aufs.xino

在这里的输出可以看出diff文件夹下的目录的访问权限情况:

只有最上面的e26931d94a12e4b8bc0b66942d4525ff5aa6974d14ca9ebe847290533f6f0121有read-write权限,其他都是只读

删除一个文件:

想要在容器中删除一个文件(file1),AUFS会在容器的读写层中生成一个.wh.file1的文件来隐藏所有只读层的file1文件

下面进行测试:

# 进入容器删除之前的hello world文件然后退出
$ docker exec -it 9eb5eb21b523 /bin/bash
$ rm -rf /tmp/newfile
$ exit
# 在宿主机上检查
$ ls -a /var/lib/docker/aufs/diff/e26931d94a12e4b8bc0b66942d4525ff5aa6974d14ca9ebe847290533f6f0121/tmp/

# 输出结果
.  ..  .wh.newfile

# 删除掉这个 .wh.newfile, 再次进入容器
$ docker exec -it 9eb5eb21b523 /bin/bash
$ cat /tmp/newfile 

hello world

5. 自定义AUFS文件系统

通过简单的命令创建一个AUFS文件系统,感受如何使用AUFS和CoW实现文件管理

实验环境的创建如下:

(创建对应的文件夹/文件,文件的内容如红字所示)

jyt6iP

接下来把这些文件夹都用AUFS的方式挂载到mnt目录下,需要注意的是,在mount aufs命令中没有特定的指定权限的命令,默认的行为是dirs最左边的第一个目录是read-write,其他都为read-only

$ mount -t aufs -o dirs=./container-layer/:./image-layer1/:image-layer2/:image-layer3/:image-layer4/ none ./mnt
$ tree mnt

mnt/
├── container-layer.txt
├── image-layer1.txt
├── image-layer2.txt
├── image-layer3.txt
└── image-layer4.txt

查看文件的读写权限:在/sys/fs/aufs文件夹下会默认创建一个si_开头的文件夹,我们查看即可

cat /sys/fs/aufs/si_1b321958e543c9bb/* (具体文件夹名称因人而异)

/root/projects/golang_project/src/myDocker/AUFS-test/container-layer=rw
/root/projects/golang_project/src/myDocker/AUFS-test/image-layer1=ro
/root/projects/golang_project/src/myDocker/AUFS-test/image-layer2=ro
/root/projects/golang_project/src/myDocker/AUFS-test/image-layer3=ro
/root/projects/golang_project/src/myDocker/AUFS-test/image-layer4=ro
64
65
66
67
68
/root/projects/golang_project/src/myDocker/AUFS-test/container-layer/.aufs.xino

可以看到,满足了我们的需求

接下来向mnt/image-layer4.txt文件中添加一些文字:

$ echo -e "\nwrite to mnt's image-layer1.txt" >> ./mnt/image-layer4.txt
$ cat mnt/image-layer4.txt

# 输出
I am image layer-4

write to mnt's image-layer1.txt

这时mnt只是一个虚拟挂载点,并不能真实的反应文件的存储,所以我们需要寻找文件修改到底在什么位置

查看镜像层的文件:

$ cat image-layer4/image-layer4.txt 
I am image layer-4

并未修改,但是当我们查看container-layer目录的时候,发现多了一个image-layer4.txt的文件夹, 查看:

$ cat container-layer/image-layer4.txt

# 输出
I am image layer-4

write to mnt's image-layer1.txt

发现,文件的修改是在container-layer下实现的。此时就体现了写时复制CoW的过程:

当在mnt容器的虚拟挂载目录下第一次修改文件(image-layer4.txt)时

  • mnt目录下查找image-layer4.txt文件,将其拷贝到读写层的container-layer目录中
  • 然后,修改是在container-layer下的复制的image-layer4.txt文件中进行
  • 最后在虚拟挂载点mnt目录下查看确实修改了文件

总结:

  • 容器的mount挂载目录是/var/lib/docker/aufs/mnt, 这个目录是虚拟的,是多个diff目录叠加的效果
  • 使用AUFS挂载的目录都会在系统/sys/fs/aufs/下创建一个描述文件夹,其中描述了各个层的读写权限
  • 元数据和配置文件(主机配置、域名解析配置等)都在/var/lib/docker/containers/<container-id>
  • 容器的创建会创建两个层只读init层与读写层,都存放在/var/lib/docker/aufs/diff
  • 镜像层的文件也存储在/var/lib/docker/aufs/diff,容器的文件改变不会影响镜像层文件
  • 通过Dockerfile新打包的镜像会在基础镜像的基础上增加层(一个RUN一个层)

3. Devicemapper

我们知道 AUFS 并不在 Linux 内核主干中,所以如果你的操作系统是 CentOS,就不推荐使用 AUFS 作为 Docker 的联合文件系统了。通常使用 Devicemapper 作为 Docker 的联合文件系统。

1. 什么是Devicemapper

Devicemapper 是 Linux 内核提供的框架,从 Linux 内核 2.6.9 版本开始引入,Devicemapper 与 AUFS 不同,AUFS 是一种文件系统,而Devicemapper 是一种映射块设备的技术框架。

Devicemapper 提供了一种将物理块设备映射到虚拟块设备的机制,目前 Linux 下比较流行的 LVM (Logical Volume Manager 是 Linux 下对磁盘分区进行管理的一种机制)和软件磁盘阵列(将多个较小的磁盘整合成为一个较大的磁盘设备用于扩大磁盘存储和提供数据可用性)都是基于 Devicemapper 机制实现的。

2. 关键技术

Devicemapper 将主要的工作部分分为用户空间和内核空间。

  • 用户空间负责配置具体的设备映射策略与相关的内核空间控制逻辑,例如逻辑设备 dm-a 如何与物理设备 sda 相关联,怎么建立逻辑设备和物理设备的映射关系等。
  • 内核空间则负责用户空间配置的关联关系实现,例如当 IO 请求到达虚拟设备 dm-a 时,内核空间负责接管 IO 请求,然后处理和过滤这些 IO 请求并转发到具体的物理设备 sda 上。

这个架构类似于 C/S (客户端/服务区)架构的工作模式,客户端负责具体的规则定义和配置下发,服务端根据客户端配置的规则来执行具体的处理任务。

Devicemapper 的工作机制主要围绕三个核心概念。

  • 映射设备(mapped device):即对外提供的逻辑设备,它是由 Devicemapper 模拟的一个虚拟设备,并不是真正存在于宿主机上的物理设备。
  • 目标设备(target device):目标设备是映射设备对应的物理设备或者物理设备的某一个逻辑分段,是真正存在于物理机上的设备。
  • 映射表(map table):映射表记录了映射设备到目标设备的映射关系,它记录了映射设备在目标设备的起始地址、范围和目标设备的类型等变量
UPeWpH

Devicemapper 三个核心概念之间的关系如图 1,映射设备通过映射表关联到具体的物理目标设备。事实上,映射设备不仅可以通过映射表关联到物理目标设备,也可以关联到虚拟目标设备,然后虚拟目标设备再通过映射表关联到物理目标设备(多层映射)

Devicemapper 在内核中通过很多模块化的映射驱动(target driver)插件实现了对真正 IO 请求的拦截、过滤和转发工作,比如 Raid、软件加密、瘦供给(Thin Provisioning)等。其中**瘦供给模块是 Docker 使用 Devicemapper 技术框架中非常重要的模块,**下面我们来详细了解下瘦供给(Thin Provisioning)。

瘦供给(Thin Provisioning)

瘦供给的意思是动态分配,这跟传统的固定分配不一样。传统的固定分配是无论我们用多少都一次性分配一个较大的空间,这样可能导致空间浪费。而瘦供给是我们需要多少磁盘空间,存储驱动就帮我们分配多少磁盘空间。

这种分配机制就好比我们一群人围着一个大锅吃饭,负责分配食物的人每次都给你一点分量,当你感觉食物不够时再去申请食物,而当你吃饱了就不需要再去申请食物了,从而避免了食物的浪费,节约的食物可以分配给更多需要的人。

那么,你知道 Docker 是如何使用瘦供给来做到像 AUFS 那样分层存储文件的吗?答案就是: Docker 使用了瘦供给的快照(snapshot)技术。

什么是快照(snapshot)技术?这是全球网络存储工业协会 SNIA(StorageNetworking Industry Association)对快照(Snapshot)的定义:

关于指定数据集合的一个完全可用拷贝,该拷贝包括相应数据在某个时间点(拷贝开始的时间点)的映像。快照可以是其所表示的数据的一个副本,也可以是数据的一个复制品。

简单来说,快照是数据在某一个时间点的存储状态。快照的主要作用是对数据进行备份,当存储设备发生故障时,可以使用已经备份的快照将数据恢复到某一个时间点,而 Docker 中的数据分层存储也是基于快照实现的。

以上便是实现 Devicemapper 的关键技术,那 Docker 究竟是如何使用 Devicemapper 实现存储数据和镜像分层共享的呢?

3. Devicemapper 数据存储

当 Docker 使用 Devicemapper 作为文件存储驱动时,Docker 将镜像和容器的文件存储在瘦供给池(thinpool)中,并将这些内容挂载在 /var/lib/docker/devicemapper/ 目录下。

这些目录储存 Docker 的容器和镜像相关数据,目录的数据内容和功能说明如下。

  • devicemapper 目录(/var/lib/docker/devicemapper/devicemapper/):存储镜像和容器实际内容,该目录由一个或多个块设备构成。
  • metadata 目录(/var/lib/docker/devicemapper/metadata/): 包含 Devicemapper 本身配置的元数据信息, 以 json 的形式配置,这些元数据记录了镜像层和容器层之间的关联信息
  • mnt 目录( /var/lib/docker/devicemapper/mnt/):是容器的联合挂载点目录,未生成容器时,该目录为空,而容器存在时,该目录下的内容跟容器中一致。

4. Devicemapper 镜像分层与共享

Devicemapper 使用专用的块设备实现镜像的存储,并且像 AUFS 一样使用了写时复制的技术来保障最大程度节省存储空间,所以 Devicemapper 的镜像分层也是依赖快照来是实现的。

Devicemapper 的每一镜像层都是其下一层的快照,最底层的镜像层是我们的瘦供给池,通过这种方式实现镜像分层有以下优点。

  • 相同的镜像层,仅在磁盘上存储一次。例如,我有 10 个运行中的 busybox 容器,底层都使用了 busybox 镜像,那么 busybox 镜像只需要在磁盘上存储一次即可。
  • 快照是写时复制策略的实现,也就是说,当我们需要对文件进行修改时,文件才会被复制到读写层。
  • 相比对文件系统加锁的机制,Devicemapper 工作在块级别,因此可以实现同时修改和读写层中的多个块设备,比文件系统效率更高。

当我们需要读取数据时,如果数据存在底层快照中,则向底层快照查询数据并读取。

当我们需要写数据时,则向瘦供给池动态申请存储空间生成读写层,然后把数据复制到读写层进行修改

Devicemapper 默认每次申请的大小是 64K 或者 64K 的倍数,因此每次新生成的读写层的大小都是 64K 或者 64K 的倍数。

lDzftz

这个 Ubuntu 镜像一共有四层,每一层镜像都是下一层的快照,镜像的最底层是基础设备的快照。当容器运行时,容器是基于镜像的快照。综上,Devicemapper 实现镜像分层的根本原理就是快照。

5. Docker配置Devicemapper

(因为本机没有是Unbuntu系统,所以此部分并没有实验,仅记录)

Docker 的 Devicemapper 模式有两种:第一种是loop-lvm模式,该模式主要用来开发和测试使用;第二种是 direct-lvm模式,该模式推荐在生产环境中使用。

loop-lvm

使用以下命令停止已经运行的 Docker:

$ sudo systemctl stop docker

编辑/etc/docker/daemon.json文件,如果该文件不存在,则创建该文件,并添加以下配置:

{
    
    
  "storage-driver": "devicemapper"
}

启动 Docker:

$ sudo systemctl start docker

验证 Docker 的文件驱动模式:

$ docker info

# 输出
...
Storage Driver: devicemapper
  Pool Name: docker-253:1-423624832-pool
  Pool Blocksize: 65.54kB
  Base Device Size: 10.74GB
  Backing Filesystem: xfs
  Udev Sync Supported: true
  Data file: /dev/loop0
  Metadata file: /dev/loop1
  Data loop file: /var/lib/docker/devicemapper/devicemapper/data
  Metadata loop file: /var/lib/docker/devicemapper/devicemapper/metadata
...

可以看到 Storage Driver 为 devicemapper,这表示 Docker 已经被配置为 Devicemapper 模式。

但是这里输出的 Data file 为 /dev/loop0,这表示我们目前在使用的模式为 loop-lvm。但是由于 loop-lvm 性能比较差,因此不推荐在生产环境中使用 loop-lvm 模式。下面我们看下生产环境中应该如何配置 Devicemapper 的 direct-lvm 模式。

direct-lvm

同样的做法,只是/etc/docker/daemon.json文件修改为:

{
    
    
  "storage-driver": "devicemapper",
  "storage-opts": [
    "dm.directlvm_device=/dev/xdf",
    "dm.thinp_percent=95",
    "dm.thinp_metapercent=1",
    "dm.thinp_autoextend_threshold=80",
    "dm.thinp_autoextend_percent=20",
    "dm.directlvm_device_force=false"
  ]
}

其中 directlvm_device指定需要用作 Docker 存储的磁盘路径,Docker 会动态为我们创建对应的存储池。例如这里我想把 /dev/xdf设备作为我的 Docker 存储盘,directlvm_device则配置为 /dev/xdf。

$ docker info

...
Storage Driver: devicemapper
  Pool Name: docker-thinpool
  Pool Blocksize: 65.54kB
  Base Device Size: 10.74GB
  Backing Filesystem: xfs
  Udev Sync Supported: true
  Data file:
  Metadata file:
  Data loop file: /var/lib/docker/devicemapper/devicemapper/data
  Metadata loop file: /var/lib/docker/devicemapper/devicemapper/metadata
...

当我们看到 Storage Driver 为 devicemapper,并且 Pool Name 为 docker-thinpool时,这表示 Devicemapper 的 direct-lvm 模式已经配置成功

总结:

Devicemapper 使用块设备来存储文件,运行速度会比直接操作文件系统更快,因此很长一段时间内在 Red Hat 或 CentOS 系统中,Devicemapper 一直作为 Docker 默认的联合文件系统驱动,为 Docker 在 Red Hat 或 CentOS 稳定运行提供强有力的保障。

4. OverlayFS

OverlayFS 的发展分为两个阶段。2014 年,OverlayFS 第一个版本被合并到 Linux 内核 3.18 版本中,此时的 OverlayFS 在 Docker 中被称为overlay文件驱动。

overlay文件驱动模型:

9mxQXM

由于第一版的overlay文件系统存在很多弊端(例如运行一段时间后Docker 会报 “too many links problem” 的错误), **Linux 内核在 4.0 版本对overlay做了很多必要的改进,此时的 OverlayFS 被称之为overlay2。**目前较新版本的docker都是默认使用overlay2

因此,在 Docker 中 OverlayFS 文件驱动被分为了两种,一种是早期的overlay,不推荐在生产环境中使用,另一种是更新和更稳定的overlay2,推荐在生产环境中使用。下面的内容我们主要围绕overlay2展开。

1. 使用 overlay2 的先决条件

(因为本机没有采用xfs的文件系统,所以此部分并没有实验,仅记录)

overlay2虽然很好,但是它的使用是有一定条件限制的。

  • 要想使用overlay2Docker 版本必须高于 17.06.02。
  • 如果你的操作系统是 RHEL 或 CentOS,Linux 内核版本必须使用 3.10.0-514 或者更高版本,其他 Linux 发行版的内核版本必须高于 4.0(例如 Ubuntu 或 Debian),你可以使用uname -a查看当前系统的内核版本。
  • overlay2最好搭配 xfs 文件系统使用,并且使用 xfs 作为底层文件系统时,d_type必须开启,可以使用以下命令验证 d_type 是否开启:
$ apt install xfsprogs
$ xfs_info /var/lib/docker | grep ftype
naming   =version 2              bsize=4096   ascii-ci=0 ftype=1

当输出结果中有 ftype=1 时,表示 d_type 已经开启。如果你的输出结果为 ftype=0,则需要重新格式化磁盘目录,命令如下:

$ sudo mkfs.xfs -f -n ftype=1 /path/to/disk

另外,在生产环境中,推荐挂载 /var/lib/docker目录到单独的磁盘或者磁盘分区,这样可以避免该目录写满影响主机的文件写入,并且把挂载信息写入到 /etc/fstab,防止机器重启后挂载信息丢失。

挂载配置中推荐开启pquota,这样可以防止某个容器写文件溢出导致整个容器目录空间被占满。写入到 /etc/fstab 中的内容如下:

$UUID /var/lib/docker xfs defaults,pquota 0 0

其中 UUID 为 /var/lib/docker所在磁盘或者分区的 UUID 或者磁盘路径。 如果你的操作系统无法满足上面的任何一个条件,那我推荐你使用 AUFS 或者 Devicemapper 作为你的 Docker 文件系统驱动。

通常情况下, overlay2 会比 AUFS 和 Devicemapper 性能更好,而且更加稳定,因为 overlay2 在 inode 优化上更加高效。因此在生产环境中推荐使用 overlay2 作为 Docker 的文件驱动。

下面通过实例来教你如何初始化/var/lib/docker目录,为后面配置 Docker 的overlay2文件驱动做准备。

准备 /var/lib/docker目录

1.使用 lsblk(Linux 查看磁盘和块设备信息命令)命令查看本机磁盘信息:

$ lsblk

NAME   MAJ:MIN RM  SIZE RO TYPE MOUNTPOINT
vda    253:0    0  500G  0 disk
`-vda1 253:1    0  500G  0 part /
vdb    253:16   0  500G  0 disk
`-vdb1 253:17   0    8G  0 part

可以看到,我的机器有两块磁盘,一块是 vda,一块是 vdb。其中 vda 已经被用来挂载系统根目录,这里我想把 /var/lib/docker 挂载到 vdb1 分区上。

2.使用 mkfs 命令格式化磁盘 vdb1:

$ sudo mkfs.xfs -f -n ftype=1 /dev/vdb1

3.将挂载信息写入到 /etc/fstab,保证机器重启挂载目录不丢失:

$ sudo echo "/dev/vdb1 /var/lib/docker xfs defaults,pquota 0 0" >> /etc/fstab

4.使用 mount 命令使得挂载目录生效:

$ sudo mount -a

5.查看挂载信息:

$ lsblk

NAME   MAJ:MIN RM  SIZE RO TYPE MOUNTPOINT
vda    253:0    0  500G  0 disk
`-vda1 253:1    0  500G  0 part /
vdb    253:16   0  500G  0 disk
`-vdb1 253:17   0    8G  0 part /var/lib/docker

可以看到此时 /var/lib/docker 目录已经被挂载到了 vdb1 这个磁盘分区上。我们使用 xfs_info 命令验证下 d_type 是否已经成功开启:

$ xfs_info /var/lib/docker | grep ftype

naming   =version 2              bsize=4096   ascii-ci=0 ftype=1

可以看到输出结果为 ftype=1,证明 d_type 已经被成功开启。

准备好 /var/lib/docker 目录后,我们就可以配置 Docker 的文件驱动为 overlay2,并且启动 Docker 了。

2. Docker配置Overlay2

同样的修改/etc/docker/daemon.json文件

{
    
    
  "storage-driver": "overlay2",
  "storage-opts": [
    "overlay2.size=20G",							// 按需修改
    "overlay2.override_kernel_check=true"
  ]
}

其中 storage-driver 参数指定使用 overlay2 文件驱动,overlay2.size 参数表示限制每个容器根目录大小为 20G。限制每个容器的磁盘空间大小是通过 xfs 的 pquota 特性实现,overlay2.size 可以根据不同的生产环境来设置这个值的大小。我推荐你在生产环境中开启此参数,防止某个容器写入文件过大,导致整个 Docker 目录空间溢出。

重启sudo systemctl restart docker再查看docker info

MFU3kK

3. Overlay2工作原理

文件存储/挂载结构

overlay2 和 AUFS 类似,它将所有目录称之为层(layer),overlay2 的目录是镜像和容器分层的基础,而把这些层统一展现到同一的目录下的过程称为联合挂载(union mount)overlay2把目录的下一层叫作lowerdir,上一层叫作upperdir,联合挂载后的结果叫作merged

overlay支持两层结构overlay2 文件系统最多支持 128 个层数叠加,也就是说你的 Dockerfile 最多只能写 128 行,不过这在日常使用中足够了。

下面我们通过拉取一个 Ubuntu 操作系统的镜像来看下 overlay2 是如何存放镜像文件的。

首先,我们通过以下命令拉取 Ubuntu 镜像:

$ docker pull ubuntu:16.04

16.04: Pulling from library/ubuntu
58690f9b18fc: Pull complete 
b51569e7c507: Pull complete 
da8ef40b9eca: Pull complete 
fb15d46c38dc: Pull complete 
Digest: sha256:0f71fa8d4d2d4292c3c617fda2b36f6dabe5c8b6e34c3dc5b0d17d4e704bd39c
Status: Downloaded newer image for ubuntu:16.04
docker.io/library/ubuntu:16.04

可以看到有四层的镜像层被提取、构建

可以看到镜像一共被分为四层拉取,拉取完镜像后我们查看一下 overlay2 的目录:

$ sudo ls -l /var/lib/docker/overlay2/

total 20
drwx--x--- 4 root root 4096 Nov  8 14:41 0585e2ae16f2898a091715bb2422a307a4b1df8739e1144f6513697689533ce1
drwx--x--- 4 root root 4096 Nov  8 14:41 4af68b84dfe0d439f31d66fe8246f851ff87d72b09a531fd5517688ceaf876ca
drwx--x--- 3 root root 4096 Nov  8 14:41 6d04c5b6ac6ac44571f3644b89ddfadae8d91bb2b6ddf57fa9165ff3979f6b4a
drwx--x--- 4 root root 4096 Nov  8 14:41 d3ab29df8d4c66ba86e7aab3114ce7be6d76d1a17d73371fc62c35123679701b
drwx------ 2 root root 4096 Nov  8 14:41 l

可以看到 overlay2 目录下出现了四个镜像层目录和一个l目录,我们首先来查看一下l目录的内容:

$ sudo ls -l /var/lib/docker/overlay2/l

lrwxrwxrwx 1 root root 72 Nov  8 14:41 3WWCAJ23M5IBV6SNVFEG3S447Q -> ../6d04c5b6ac6ac44571f3644b89ddfadae8d91bb2b6ddf57fa9165ff3979f6b4a/diff
lrwxrwxrwx 1 root root 72 Nov  8 14:41 AVVSOD5YLZOM447U2GRZRSA6ZY -> ../d3ab29df8d4c66ba86e7aab3114ce7be6d76d1a17d73371fc62c35123679701b/diff
lrwxrwxrwx 1 root root 72 Nov  8 14:41 D3BPPGSXXULN4ZVYG4CHMCDF37 -> ../0585e2ae16f2898a091715bb2422a307a4b1df8739e1144f6513697689533ce1/diff
lrwxrwxrwx 1 root root 72 Nov  8 14:41 ZBKV2DMDHFHGOLRZYHCL4VCJ4R -> ../4af68b84dfe0d439f31d66fe8246f851ff87d72b09a531fd5517688ceaf876ca/diff

可以看到l目录是一堆软连接把一些较短的随机串软连到镜像层的 diff 文件夹下,这样做是为了避免达到mount命令参数的长度限制 下面我们查看任意一个镜像层下的文件内容:

$ sudo ls -l /var/lib/docker/overlay2/0585e2ae16f2898a091715bb2422a307a4b1df8739e1144f6513697689533ce1/

total 16
-rw------- 1 root root    0 Nov  8 14:41 committed
drwxr-xr-x 6 root root 4096 Nov  8 14:41 diff
-rw-r--r-- 1 root root   26 Nov  8 14:41 link
-rw-r--r-- 1 root root   28 Nov  8 14:41 lower
drwx------ 2 root root 4096 Nov  8 14:41 work

镜像层的 link 文件内容为该镜像层的短 ID,diff 文件夹为该镜像层的改动内容,lower 文件为该层的所有父层镜像的短 ID。 我们可以通过docker image inspect命令来查看某个镜像的层级关系,例如我想查看刚刚下载的 Ubuntu 镜像之间的层级关系,可以使用以下命令:

$ docker image inspect ubuntu:16.04

....
"GraphDriver": {
    
        
            "Data": {
    
                                                                    
                "LowerDir": "/var/lib/docker/overlay2/d3ab29df8d4c66ba86e7aab3114ce7be6d76d1a17d73371fc62c35123679701b/diff:/var/lib/docker/overlay2/0585e2ae16f2898a091715bb2422a307a4b1df8739e1144f6513697689533ce1/diff:/var/lib/docker/overlay2/6d04c5b6ac6ac44571f3644b89ddfadae8d91bb2b6ddf57fa9165ff3979f6b4a/diff", 
                "MergedDir": "/var/lib/docker/overlay2/4af68b84dfe0d439f31d66fe8246f851ff87d72b09a531fd5517688ceaf876ca/merged",                     
                "UpperDir": "/var/lib/docker/overlay2/4af68b84dfe0d439f31d66fe8246f851ff87d72b09a531fd5517688ceaf876ca/diff",             
                "WorkDir": "/var/lib/docker/overlay2/4af68b84dfe0d439f31d66fe8246f851ff87d72b09a531fd5517688ceaf876ca/work"                    
            },    
...

其中 MergedDir 代表当前镜像层在 overlay2 存储下的目录,LowerDir 代表当前镜像的父层关系,使用冒号分隔,冒号最后代表该镜像的最底层。

下面我们将镜像运行起来成为容器:docker run --name=ubuntu -d ubuntu:16.04 sleep 3600

我们使用docker inspect命令来查看一下容器的工作目录:

...
"GraphDriver": {
    
                        
            "Data": {
    
                            
                "LowerDir": "/var/lib/docker/overlay2/fc45c1a77027a24a029361bd29bc701ce9e00c45b4cba3b949d89fb2a26f
e36f-init/diff:/var/lib/docker/overlay2/4af68b84dfe0d439f31d66fe8246f851ff87d72b09a531fd5517688ceaf876ca/diff:/var
/lib/docker/overlay2/d3ab29df8d4c66ba86e7aab3114ce7be6d76d1a17d73371fc62c35123679701b/diff:/var/lib/docker/overlay
2/0585e2ae16f2898a091715bb2422a307a4b1df8739e1144f6513697689533ce1/diff:/var/lib/docker/overlay2/6d04c5b6ac6ac4457
1f3644b89ddfadae8d91bb2b6ddf57fa9165ff3979f6b4a/diff",
                "MergedDir": "/var/lib/docker/overlay2/fc45c1a77027a24a029361bd29bc701ce9e00c45b4cba3b949d89fb2a26
fe36f/merged",                                                                              
                "UpperDir": "/var/lib/docker/overlay2/fc45c1a77027a24a029361bd29bc701ce9e00c45b4cba3b949d89fb2a26f
e36f/diff",
                "WorkDir": "/var/lib/docker/overlay2/fc45c1a77027a24a029361bd29bc701ce9e00c45b4cba3b949d89fb2a26fe
36f/work"
            },
...

MergedDir 后面的内容即为容器层的工作目录,LowerDir 为容器所依赖的镜像层目录。 然后我们查看下 overlay2 目录下的内容:

$ sudo ls /var/lib/docker/overlay2/

0585e2ae16f2898a091715bb2422a307a4b1df8739e1144f6513697689533ce1
4af68b84dfe0d439f31d66fe8246f851ff87d72b09a531fd5517688ceaf876ca
6d04c5b6ac6ac44571f3644b89ddfadae8d91bb2b6ddf57fa9165ff3979f6b4a
d3ab29df8d4c66ba86e7aab3114ce7be6d76d1a17d73371fc62c35123679701b
fc45c1a77027a24a029361bd29bc701ce9e00c45b4cba3b949d89fb2a26fe36f
fc45c1a77027a24a029361bd29bc701ce9e00c45b4cba3b949d89fb2a26fe36f-init
l

可以看到 overlay2 目录下增加了容器层相关的目录,我们再来查看一下容器层下的内容:

$ sudo tree /var/lib/docker/overlay2/fc45c1a77027a24a029361bd29bc701ce9e00c45b4cba3b949d89fb2a26fe36f -L 2

/var/lib/docker/overlay2/fc45c1a77027a24a029361bd29bc701ce9e00c45b4cba3b949d89fb2a26fe36f
├── diff
├── link
├── lower
├── merged
│   ├── bin
│   ├── boot
│   ├── dev
│   ....
└── work
    └── work

link 和 lower 文件与镜像层的功能一致,link 文件内容为该容器层的短 ID,lower 文件为该层的所有父层镜像的短 ID 。diff 目录为容器的读写层,容器内修改的文件都会在 diff 中出现,merged 目录为分层文件联合挂载后的结果,也是容器内的工作目录。

总体来说,overlay2 是这样储存文件的:overlay2将镜像层和容器层都放在单独的目录,并且有唯一 ID,每一层仅存储发生变化的文件,最终使用联合挂载技术将容器层和镜像层的所有文件统一挂载到容器中,使得容器中看到完整的系统文件。

overlayer文件存储结构总结:

  • 不同于AUFS集中放置,以镜像、容器单独目录为主,每一个镜像或者容器都是单独的目录,其中都具有diff等文件夹

  • /var/lib/docker/overlay2/下有一个l目录,是一堆软连接**,**把一些较短的随机串软连到镜像层的 diff 文件夹下,这样做是为了避免达到mount命令参数的长度限制

  • 镜像文件夹:

    • link:文件中记录了该镜像的短ID
    • diff:该镜像层的改动内容, 按照层级关系不断向上叠加, 每一层都在下一层的基础上只保存新的文件
    • lower:所有父层的短ID (最底层镜像没有lower),可以索引出整个层次结构
    • work:用来完成如copy-on-write(CoW写时复制)的操作
  • 容器文件夹:

    • 容器文件夹与AUFS一样有两个,一个init、一个读写
    • link:同镜像
    • diff:同镜像,容器的读写层,改动都会放在里面
    • lower:同镜像
    • merge:文件的联合挂载点。容器最终的运行环境(虚拟的)(init层没有,只在读写层)
读取、修改文件

overlay2 的工作过程中对文件的操作分为读取文件和修改文件。

读取文件(容器层优先)

容器内进程读取文件分为以下三种情况。

  • 文件在容器层中存在:当文件存在于容器层并且不存在于镜像层时,直接从容器层读取文件;
  • 当文件在容器层中不存在:当容器中的进程需要读取某个文件时,如果容器层中不存在该文件,则从镜像层查找该文件,然后读取文件内容;
  • 文件既存在于镜像层,又存在于容器层:当我们读取的文件既存在于镜像层,又存在于容器层时,将会从容器层读取该文件。

修改文件或目录(与AUFS类似)

overlay2 对文件的修改采用的是写时复制的工作机制,这种工作机制可以最大程度节省存储空间。具体的文件操作机制如下。

  • 第一次修改文件:当我们第一次在容器中修改某个文件时,overlay2 会触发写时复制操作,overlay2 首先从镜像层复制文件到容器层,然后在容器层执行对应的文件修改操作。

  • 删除文件或目录:当文件或目录被删除时,overlay2 并不会真正从镜像中删除它,因为镜像层是只读的,overlay2 会创建一个特殊的文件或目录,这种特殊的文件或目录会阻止容器的访问。

4. 总结

overlay2 目前已经是 Docker 官方推荐的文件系统了,也是目前安装 Docker 时默认的文件系统,因为 overlay2 在生产环境中不仅有着较高的性能,它的稳定性也极其突出。但是 overlay2 的使用还是有一些限制条件的,例如要求 Docker 版本必须高于 17.06.02,内核版本必须高于 4.0 等。因此,在生产环境中,如果你的环境满足使用 overlay2 的条件,请尽量使用 overlay2 作为 Docker 的联合文件系统。

5. 各个UnionFS对比与总结

适用系统 是否合并内核 版本要求 优点/特点 缺点
AUFS 多用于 Ubuntu 和 Debian 系统中 >=3.1, 内核>=4.0请使用Overlay2 Docker 最早使用的文件系统驱动,稳定 代码可读性差(被拒绝合并到内核的原因)
Devicemapper Red Hat 或 CentOS Linux 内核 >=2.6.9 使用映射块设备的技术框架, 比文件系统效率高,稳定,docker默认的联合文件系统 只用于CentOS或Red hat系列
Overlay Ubuntu等等大多数linux Linux内核>=3.18 AUFS的继承,合并到了Linux内核 只工作在两层,并且运行时可能有一些意外的bug
Overlay2 Ubuntu等等大多数linux Docker >=17.06.02, Red Hat 或 CentOS 内核>=3.10.0-514 其他linux发行版内核>=4.0 overlay2在inode优化上更加高效,最高128层,已合并到内核,速度更快,主流 仍然年轻,不稳定,生产环境使用要慎重

觉得不错的话,请点赞关注呦~~你的关注就是博主的动力
关注公众号,查看更多go开发、密码学和区块链科研内容:
2DrbmZ

猜你喜欢

转载自blog.csdn.net/weixin_43988498/article/details/121044780