1. 分层的镜像

在我们启动一个容器之前,通常需要下载这个容器对应的镜像,以这个镜像为基础启动容器。镜像中包含了对应的程序的二进制文件与其所依赖的文件,程序在启动后看到的rootfs只是这个镜像中存在的文件。这样,我们就可以为容器中的进程提供一个干净的文件系统。

创建一个镜像(image)的最简单方法是使用Dockerfile。

FROM scratch
COPY hello /
CMD ["/hello"]

scratch是docker为我们提供的一个空镜像,我们可以在此基础上构建任何我们想要的镜像。

在书写Dockerfile时,想必你听说过这么一句话,不要在Dockerfile中创建太多层.

在Dockerfile中,每一个指令都会创建一个新的“层”,这里的层,指的是UnionFS中的一个文件目录。当我们创建了过多的层,会导致镜像体积变大,除此之外,Union FS 也会有最大层数限制。

因此对于如下的Dockerfile文件写法,应尽量避免:

FROM debian:stretch

RUN apt-get update
RUN apt-get install -y gcc libc6-dev make wget
RUN wget -O redis.tar.gz "http://download.redis.io/releases/redis-5.0.3.tar.gz"
RUN mkdir -p /usr/src/redis
RUN tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1
RUN make -C /usr/src/redis
RUN make -C /usr/src/redis install

可优化为如下写法

FROM debian:stretch

RUN set -x; buildDeps='gcc libc6-dev make wget' \
    && apt-get update \
    && apt-get install -y $buildDeps \
    && wget -O redis.tar.gz "http://download.redis.io/releases/redis-5.0.3.tar.gz" \
    && mkdir -p /usr/src/redis \
    && tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 \
    && make -C /usr/src/redis \
    && make -C /usr/src/redis install \
    && rm -rf /var/lib/apt/lists/* \
    && rm redis.tar.gz \
    && rm -r /usr/src/redis \
    && apt-get purge -y --auto-remove $buildDeps

这里的原理有点类似于Redis的AOF日志重写。

为什么要将镜像分为很多层,而不是像系统镜像那样打包为一个ISO文件。

不同的镜像,其所需的文件存在重复,如果对每个镜像都单独复制一份,会导致镜像过于臃肿,浪费磁盘空间,也会占用大量网络资源下载镜像文件。

例如我们有100个服务都需要依赖于ubuntu18.04环境,如果我们需要为这些服务制作镜像,显然不可能真的将ubuntu18.04的所有文件复制100份,最好的办法是机器上只存在一份这样的文件,每个容器都来复用它,这就需要使用UnionFS。

2. image分层的基础:UnionFS

UnionFS称:联合挂载。其最主要的功能是将多个目录挂载在同一个目录下。

前面说到,对于100个都需要Ubuntu18.04环境的服务,最好的办法是在机器上提供一份ubuntu18.04环境文件,所有的服务都来依赖这一份文件。

但这就产生了另一个问题:如果某一个服务需要对ubuntu18.04中的某个文件进行修改,或是在这之上产生新的文件,那么其他同样依赖于这个环境的服务可能会被影响。

这就需要用到UnionFS的特性。

目前docker使用的UnionFS实现是OverlayFS.下面通过一个小实验来掌握overlay的基本特性:

执行以下脚本

#!/bin/bash

umount ./merged
rm upper lower merged work -r

mkdir upper lower merged work
echo "I'm from lower!" > lower/in_lower.txt
echo "I'm from upper!" > upper/in_upper.txt
# `in_both` is in both directories
echo "I'm from lower!" > lower/in_both.txt
echo "I'm from upper!" > upper/in_both.txt

sudo mount -t overlay overlay \
 -o lowerdir=./lower,upperdir=./upper,workdir=./work \
 ./merged

20220710170306

OverlayFS 的一个 mount 命令牵涉到四类目录,分别是 lower,upper,merged 和 work,

  • “lower/",也就是被 mount 两层目录中底下的这层(lowerdir)。在 OverlayFS 中,最底下这一层里的文件是不会被修改的,你可以认为它是只读的。OverlayFS 支持多个 lowerdir。

  • “upper/",它是被 mount 两层目录中上面的这层 (upperdir)。在 OverlayFS 中,如果有文件的创建,修改,删除操作,那么都会在这一层反映出来,它是可读写的。

  • “merged” ,是挂载点(mount point)目录,也是用户看到的目录,用户的实际文件操作在这里进行。

  • “work/",是一个存放临时文件的目录,OverlayFS 中如果有文件修改,就会在中间过程中临时存放文件到这里。并未表现在图中。

如果lower和upper中存在同名文件,那么不会显示lower中的文件。

对于以下三种文件操作:

  1. 新建文件。这个文件将会出现在upper目录中。

  2. 删除文件。如果我们删除"in_upper.txt”,那么这个文件会在 upper/ 目录中消失。如果删除"in_lower.txt”, 在 lower/ 目录里的"in_lower.txt"文件不会有变化,只是在 upper/ 目录中增加了一个特殊文件来告诉 OverlayFS,“in_lower.txt’这个文件不能出现在 merged/ 里了,这就表示它已经被删除了。

  3. 修改文件。如果修改"in_lower.txt”,那么就会在 upper/ 目录中新建一个"in_lower.txt"文件,包含更新的内容,而在 lower/ 中的原来的实际文件"in_lower.txt"不会改变。

所有的更改都不会反映到lowerdir上,因此容器的镜像文件中各层正好作为 OverlayFS 的 lowerdir 的目录,然后加上一个空的 upperdir 一起挂载好后,就组成了容器的文件系统。

同时,overlayfs还支持挂载多个lower目录,这样就实现了镜像的多个层。

以下是一个正在运行的容器他的UnionFS挂载情况:

"GraphDriver": {
  "Data": {
    "LowerDir": "/var/lib/docker/overlay2/94809845e9decf34968235fe45bc9d66780a6c2f1929e6aeace43c62ae8a8473-init/diff:/var/lib/docker/overlay2/c95ef099ca18c9b33ff36907bb94c28f3c81a9758058b1698ae438fc88552ac7/diff:/var/lib/docker/overlay2/84cc0780b885789c335f30a784fde4355bfc11b4b9c538da0d6be665d0e87f74/diff:/var/lib/docker/overlay2/94ab5f531aa05c31918599de4a947b6af18488a5bd9f62186060aa32a0938677/diff:/var/lib/docker/overlay2/4154cfa0480d3be6d22ecd4b52225798d8c7d2f349602d36a821fd310318f19e/diff",
    "MergedDir": "/var/lib/docker/overlay2/94809845e9decf34968235fe45bc9d66780a6c2f1929e6aeace43c62ae8a8473/merged",
    "UpperDir": "/var/lib/docker/overlay2/94809845e9decf34968235fe45bc9d66780a6c2f1929e6aeace43c62ae8a8473/diff",
    "WorkDir": "/var/lib/docker/overlay2/94809845e9decf34968235fe45bc9d66780a6c2f1929e6aeace43c62ae8a8473/work"
    },
    "Name": "overlay2"
},

3. 镜像的组成

docker对镜像的管理是根据OCI标准来的,根据OCI标准,一个image可以分为以下四个部分:

  • Image Index: image index是个json文件,用于使image能支持多个平台和多tag。
  • Image Manifest: manifest是一个json文件,这个文件包含了对filesystem layers和image config的描述。
  • Image Config: image config就是一个json文件,这个json文件包含了对这个image的描述。
  • FileSystem Layer: Filesystem Layer包含了文件系统的信息,即该image包含了哪些文件/目录,以及它们的属性和数据。

它们之间的关系如下:

20220811093358

3.1 Media Type

Media Type, 媒体类型。

在OCI标准中,不同的文件对应不同的media type。其对应关系如下:

Media Type说明
application/vnd.oci.descriptor.v1+jsonContent Descriptor 内容描述文件
application/vnd.oci.layout.header.v1+jsonOCI Layout 布局描述文件
application/vnd.oci.image.index.v1+jsonImage Index 高层次的镜像元信息文件
application/vnd.oci.image.manifest.v1+jsonImage Manifest 镜像元信息文件
application/vnd.oci.image.config.v1+jsonImage Config 镜像配置文件
application/vnd.oci.image.layer.v1.tarImage Layer 镜像层文件
application/vnd.oci.image.layer.v1.tar+gzipImage Layer 镜像层文件gzip压缩
application/vnd.oci.image.layer.nondistributable.v1.tarImage Layer 非内容寻址管理
application/vnd.oci.image.layer.nondistributable.v1.tar+gzipImage Layer, gzip压缩 非内容寻址管理

3.2 FileSystem Layer

每个filesystem layer都包含了在上一个layer上的改动情况,主要包含三方面的内容:

  • 变化类型:是增加、修改还是删除了文件

  • 文件类型:每个变化发生在哪种文件类型上

  • 文件属性:文件的修改时间、用户ID、组ID、RWX权限等

根据我们上面所介绍的UnionFS,很容易理解。

3.3 Image Config

Config包含了对镜像文件的描述,它是一个json文件,示例如下:

{
    "created": "2015-10-31T22:22:56.015925234Z",
    "author": "Alyssa P. Hacker <alyspdev@example.com>",
    "architecture": "amd64",
    "os": "linux",
    "config": {
        "User": "alice",
        "ExposedPorts": {
            "8080/tcp": {}
        },
        "Env": [
            "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
            "FOO=oci_is_a",
            "BAR=well_written_spec"
        ],
        ...
    },
    "rootfs": {
      "diff_ids": [
        "sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1",
        "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"
      ],
      "type": "layers"
    },
    "history": [
        ...
    ]
}

其中大部分是可选参数,必须的参数有:

  • architecture: CPU架构
  • os: 镜像在什么操作系统上运行
  • rootfs:指定了image所包含的filesystem layers,type的值必须是layers,diff_ids包含了layer的列表(顺序排列),每一个sha256就是每层layer对应tar包的sha256码
    • type: type的值必须是layers
    • diff_ids: 每层layer包的哈希值的列表

其他都是可选参数,见名知意

3.4 Image Manifest

Image Manifest为特定架构和操作系统的单个容器镜像提供配置和一系列的layer。

Manifest 的三大主要目标:

  • 内容可寻址(通过 hash 算法为镜像和它的组件生成唯一ID)

  • 支持多种平台的架构镜像(由一个更上层的 manifest 说明包含的镜像 manifests 其具体平台的版本情况)

  • 能被解析成为 OCI 运行时规范

示例如下:

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "size": 7023,
    "digest": "sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7"
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "size": 32654,
      "digest": "sha256:9834876dcfb05cb167a5c24953eba58c4ac89b1adf57f28f2f9d09af107ee8f0"
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "size": 16724,
      "digest": "sha256:3c3a4604a545cdc127456d94e421cd355bca5b528f4a9c1905b15da2eb4a4c6b"
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "size": 73109,
      "digest": "sha256:ec4b8955958665577945c89419d1af06b5f7636b4ac3da7f12184802ad867736"
    }
  ],
  "annotations": {
    "com.example.key1": "value1",
    "com.example.key2": "value2"
  }
}

从上面的json文件中可以看出,manifest中包含对config的描述,包括其mediaType, size,digest等,这里的digest就是config文件的sha256值,可以看出manifest与config的一对一对应关系。而layer中则指明了这个manifest包含哪些层,manifest与layer是一对多关系。

3.5 Image Index

Image Index指向一个特定manifests的更高层manifest,用于支持多平台。index是可选的。

示例如下:

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 7143,
      "digest": "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f",
      "platform": {
        "architecture": "ppc64le",
        "os": "linux"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 7682,
      "digest": "sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270",
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      }
    }
  ],
  "annotations": {
    "com.example.key1": "value1",
    "com.example.key2": "value2"
  }
}

index文件包含了对image中所有manifest的描述,相当于一个manifest列表,包括每个manifest的media type,文件大小,sha256码,支持的平台以及平台特殊的配置。

比如ubuntu想让它的image支持amd64和arm64平台,于是它在两个平台上都编译好相应的包,然后将两个平台的layer都放到这个image的filesystem layers里面,然后写两个config文件和两个manifest文件,再加上这样一个描述不同平台manifest的index文件,就可以让这个image支持两个平台了,两个平台的用户可以使用同样的命令得到自己平台想要的那些layer。

3.6 镜像layout

镜像的布局包含以下内容:

  • blobs:内容寻址的块文件,目录必须存在,但是可以为空
  • oci-layout: 必须存在的json对象,必须包含imageLayoutVersion字段
  • index.json: 必须存在的JSON格式,文件中必须包含镜像Index的基本属性

可以查看hello-world镜像的内容:

$ yay -S skopeo # 安装skopeo

$ skopeo copy docker://hello-world oci:hello-world # 利用skopeo下载hello-world镜像

$ tree -L 3 hello-world
hello-world
├── blobs
│   └── sha256
│       ├── 2db29710123e3e53a794f2694094b9b4338aa9ee5c40b930cb8063a1be392c54
│       ├── 75ab15a4973c91d13d02b8346763142ad26095e155ca756c79ee3a4aa792991f
│       └── 811f3caa888b1ee5310e2135cfd3fe36b42e233fe0d76d9798ebd324621238b9
├── index.json
└── oci-layout

index.json

{
  "schemaVersion": 2,
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:75ab15a4973c91d13d02b8346763142ad26095e155ca756c79ee3a4aa792991f",
      "size": 402
    }
  ]
}

oci-layout

{
  "imageLayoutVersion": "1.0.0"
}

4. 从远程获取镜像的过程

  • docker发送image的名称+tag(或者digest)给registry服务器,服务器根据收到的image的名称+tag(或者digest),找到相应image的manifest,然后将manifest返回给docker

  • docker得到manifest后,读取里面image配置文件的digest(sha256),这个sha256码就是image的ID

  • 根据ID在本地找有没有存在同样ID的image,有的话就不用继续下载了

  • 如果没有,那么会给registry服务器发请求(里面包含配置文件的sha256和media type),拿到image的配置文件(Image Config)

  • 根据配置文件中的diff_ids(每个diffid对应一个layer tar包的sha256,tar包相当于layer的原始格式),在本地找对应的layer是否存在

  • 如果layer不存在,则根据manifest里面layer的sha256和media type去服务器拿相应的layer(相当去拿压缩格式的包)。

  • 拿到后进行解压,并检查解压后tar包的sha256能否和配置文件(Image Config)中的diff_id对的上,对不上说明有问题,下载失败

  • 根据docker所用的后台文件系统类型,解压tar包并放到指定的目录

  • 等所有的layer都下载完成后,整个image下载完成,就可以使用了

上面的过程涉及到两个接口:

GET /v2/<name>/manifests/<reference>

其中为镜像名称,可能包含tag或digest。

这个接口用于获取镜像的manifest。

GET /v2/<name>/blobs/<digest>

这个接口用于获取镜像的layer

5. 镜像在本地的存储

镜像默认在系统中的存储位置在/var/lib/docker

这里我们单独选择一个镜像golang:1.16.6来看

5.1 镜像的digest

镜像的digest即为镜像manifest文件的sha256值,当镜像内容发生变化,即其中的layer发生了变化,则其layer的sha256值必然发生变化,而相应的包含layer哈希值的manifest必然也会发生变化,这样就保证了digest能唯一对应一个镜像。

5.2 repositories.json

repositories.json中记录了和本地image相关的repository信息,主要是name和image id的对应关系,当image从registry上被pull下来后,就会更新该文件,其位置在/var/lib/docker/image/overlay2/repositories.json

$ cat repositories.json | jq
{
  "Repositories": {
    "golang": {
      "golang:1.16.6": "sha256:028d102f774acfd5d9a17d60dc321add40bea5cc6f06e0a84fd3aca1bb4c2b12",
      "golang@sha256:4544ae57fc735d7e415603d194d9fb09589b8ad7acd4d66e928eabfb1ed85ff1": "sha256:028d102f774acfd5d9a17d60dc321add40bea5cc6f06e0a84fd3aca1bb4c2b12",
    },
    ...
  },
}

记住golang:1.16.6的digest为028d102f774acfd5d9a17d60dc321add40bea5cc6f06e0a84fd3aca1bb4c2b12,记住前三个字母就可以了028

5.3 配置文件(image config)

在从服务器获取image时,会先获取manifest,然后从manifest中拿到config的hash,从而获取config,保存在/var/lib/docker/image/overlay2/imagedb/content/sha256,文件名就是image id

$ cat 028d102f774acfd5d9a17d60dc321add40bea5cc6f06e0a84fd3aca1bb4c2b12 | jq
{
  ...
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:afa3e488a0ee76983343f8aa759e4b7b898db65b715eb90abc81c181388374e3",
      "sha256:4b0edb23340c111e75557748161eed3ca159584871569ce7ec9b659e1db201b4",
      "sha256:5a9a65095453efb15a9b378a3c1e7699e8004744ecd6dd519bdbabd0ca9d2efc",
      "sha256:ad83f0aa5c0abe35b7711f226037a5557292d479c21635b3522e1e5a41e3ce23",
      "sha256:d1c59e37fbfc7294184d6fbe4ff8e1690d9119b6233f91af5ad0a4b36e45dff7",
      "sha256:e46b2fd4e4eadc0ee107417c66e968f417e0759fd7d625ab6f0537ba02c1c868",
      "sha256:9672a02ff8cffb302a4c5cef60f8b36cbbe9709a5ba78b7d0ce56db3219c5e51"
    ]
  }
}

这里要注意的时,如果image存在多个层,即diff_ids数组长度大于一,则diff_ids[0]是UnionFS最底层,diff_ids[-1]是UnionFS最高层。

5.4 layer的diff_id和digest的对应关系

layer的diff_id存在image的配置文件中,而layer的digest存在image的manifest中,他们的对应关系被存储在了/var/lib/docker/image/overlay2/distribution目录下:

.
├── diffid-by-digest
│   └── sha256
└── v2metadata-by-diffid
    └── sha256
  • diffid-by-digest: 存放digest到diffid的对应关系

  • v2metadata-by-diffid: 存放diffid到digest的对应关系

查看mysql最上层layer的digest:

$ cd /var/lib/docker/image/overlay2/distribution/v2metadata-by-diffid/sha256
$ cat 9672a02ff8cffb302a4c5cef60f8b36cbbe9709a5ba78b7d0ce56db3219c5e51 | jq # sha256:967...是golang:1.16.6的最上层layer
[
  {
    "Digest": "sha256:ff36ba4656980ea99a067c8a9b39f210a4da82badccaa2bae27317e711985668",
    "SourceRepository": "docker.io/library/golang",
    "HMAC": ""
  }
]

5.5 layer元数据

从image config中我们可以得到diff_ids,从而得到image各个层的sha256值,这时我们可以到/var/lib/docker/image/overlay2/layerdb/sha256去查看这些层,例如golang:1.16.6的最底层sha256为afa3e488a0ee76983343f8aa759e4b7b898db65b715eb90abc81c181388374e3

$ cat /var/lib/docker/image/overlay2/layerdb/sha256/afa3e488a0ee76983343f8aa759e4b7b898db65b715eb90abc81c181388374e3
$ tree
.
├── cache-id
├── diff
├── size
└── tar-split.json.gz

但在查看第二层的时候发现:

$ cd 4b0edb23340c111e75557748161eed3ca159584871569ce7ec9b659e1db201b4
bash: cd: 4b0edb23340c111e75557748161eed3ca159584871569ce7ec9b659e1db201b4: 没有那个文件或目录

这是因为docker使用了chainID的方式去保存这些layer,简单来说就是chainID=sha256sum(H(chainID), diffid),因此golang:1.16.6的第二层为:

$ echo -n "sha256:afa3e488a0ee76983343f8aa759e4b7b898db65b715eb90abc81c181388374e3 sha256:4b0edb23340c111e75557748161eed3ca159584871569ce7ec9b659e1db201b4" | sha256sum -

c21ff68b02e7caf277f5d356e8b323a95e8d3969dd1ab0d9f60e7c8b4a01c874  -

这时候查看c21ff68b02e7caf277f5d356e8b323a95e8d3969dd1ab0d9f60e7c8b4a01c874:

$ cd c21ff68b02e7caf277f5d356e8b323a95e8d3969dd1ab0d9f60e7c8b4a01c874
$ tree
.
├── cache-id
├── diff
├── parent
├── size
└── tar-split.json.gz

以此类推,我们可以找到任意一层。

在每一层中,一般包含5个文件:

  • cache-id是docker下载layer的时候在本地生成的一个随机uuid,指向真正存放layer文件的地方
  • diff文件存放layer的diffid
  • parent文件存放当前layer的父layer的diffid,而由于最底层layer没有parent,因此它没有这个文件
  • size当前layer的大小,单位是字节
  • tar-split.json.gz,layer压缩包的split文件,通过这个文件可以还原layer的tar包

打印出golang:1.16.6第二层的cache-id:

$ cat cache-id
40a11a2b4f594bcccbe2cbb6cd3e6fa4a3c2f8427817f43bbade4331173611d2

5.6 layer数据

layer数据在/var/lib/docker/overlay2/

通过我们刚才打印的golang:1.16.6第二层的cache-id,可以在这里找到golang:1.16.6第二层的文件:

$ cd /var/lib/docker/overlay2/40a11a2b4f594bcccbe2cbb6cd3e6fa4a3c2f8427817f43bbade4331173611d2
$ tree
.
├── committed
├── diff
├── link
├── lower
└── work

diff文件夹中就是这一层相对于下层修改的文件。

5.7 manifest文件

manifest里面包含的内容就是对config和layer的sha256 + media type描述,目的就是为了下载config和layer,等image下载完成后,manifest的使命就完成了,里面的信息对于image的本地管理来说没什么用,因此本地并没与单独对manifest进行存储。

References

https://cloud.tencent.com/developer/article/1769020

https://yeasy.gitbook.io/docker_practice/image/list

https://www.jianshu.com/p/3ba255463047

https://staight.github.io/2019/10/04/%E5%AE%B9%E5%99%A8%E5%AE%9E%E7%8E%B0-overlay2/

https://www.jianshu.com/p/3826859a6d6e

https://time.geekbang.org/column/article/318173

https://jvns.ca/blog/2019/11/18/how-containers-work--overlayfs/

https://vividcode.cc/oci-image-spec-introduction/

https://segmentfault.com/a/1190000009309276

https://github.com/opencontainers/image-spec

https://github.com/containers/skopeo

https://docs.docker.com/registry/spec/api/#pulling-an-image