容器镜像是如何工作的

一、概述

容器技术近些年的发展非常迅速,其本身使用的技术并非多么高深,但是带来的生产力提升是业界公认的。容器技术的主要特点就是其资源限制和环境隔离,linux 容器主要使用 cgroup 和 namespace 技术,但如果要更深入的理解容器技术,还需要了解一下容器镜像是什么。

二、初探

docker 可以说是容器技术的代表了,下面会以 docker 为例。docker 支持 pull, build, push 镜像。

Pull 操作如下:

➜  docker pull ubuntu:20.04
20.04: Pulling from library/ubuntu
345e3491a907: Pull complete   # 镜像共有三层
57671312ef6f: Pull complete
5e9250ddb7d0: Pull complete
Digest: sha256:cf31af331f38d1d7158470e095b132acd126a7180a54f263d386da88eb681d93 # 镜像的摘要
Status: Downloaded newer image for ubuntu:20.04
docker.io/library/ubuntu:20.04 # 镜像地址

Build 镜像时,需要提供 Dockerfile,Dockerfile 如下:

FROM ubuntu:20.04
RUN touch hello
CMD ['sh', '-c', 'echo hello ubuntu']

然后开始构建我们自己的镜像

➜  docker build -t joyme/ubuntu-hello:0.1 .
Sending build context to Docker daemon  2.048kB
Step 1/3 : FROM ubuntu:20.04
 ---> 7e0aa2d69a15
Step 2/3 : RUN touch hello
 ---> Running in 5e2fcef8c28b
Removing intermediate container 5e2fcef8c28b
 ---> 4bacc0d61995
Step 3/3 : CMD ['sh', '-c', 'echo hello ubuntu']
 ---> Running in 802e795e28c4
Removing intermediate container 802e795e28c4
 ---> ff588b7779d8
Successfully built ff588b7779d8
Successfully tagged joyme/ubuntu-hello:0.1

push 我们自己的镜像:

➜  docker push joyme/ubuntu-hello:0.1
The push refers to repository [docker.io/joyme/ubuntu-hello]
59c67359ad17: Pushed
2f140462f3bc: Mounted from library/ubuntu # 下面三层都是来自于 ubuntu:20.04
63c99163f472: Mounted from library/ubuntu
ccdbb80308cc: Mounted from library/ubuntu
0.1: digest: sha256:0a1a5857cade488bfc60c7f5d2be2c7c5eee7f90edc1950c4c32214fada31a7d size: 1149

我们也可以把镜像保存成 tar 包,然后加载。比如:

➜  docker save -o ubuntu-hello.tar joyme/ubuntu-hello:0.1
➜  ls
Dockerfile  ubuntu-hello.tar
➜  tar xvf ubuntu-hello.tar
1392a7609ae8d845eba5fbe95e266a6b104d55b30262a284c960583f91307420/
1392a7609ae8d845eba5fbe95e266a6b104d55b30262a284c960583f91307420/VERSION
1392a7609ae8d845eba5fbe95e266a6b104d55b30262a284c960583f91307420/json
1392a7609ae8d845eba5fbe95e266a6b104d55b30262a284c960583f91307420/layer.tar
15cbe1c29902a1020a4a47c835a82f0416f1896f02fac942fdd35d326c63fa22/
15cbe1c29902a1020a4a47c835a82f0416f1896f02fac942fdd35d326c63fa22/VERSION
15cbe1c29902a1020a4a47c835a82f0416f1896f02fac942fdd35d326c63fa22/json
15cbe1c29902a1020a4a47c835a82f0416f1896f02fac942fdd35d326c63fa22/layer.tar
2a8749a3d9080a156a4381fea67ce47a478fb31d6acaa20e19fda1ba0c8f20e7/
2a8749a3d9080a156a4381fea67ce47a478fb31d6acaa20e19fda1ba0c8f20e7/VERSION
2a8749a3d9080a156a4381fea67ce47a478fb31d6acaa20e19fda1ba0c8f20e7/json
2a8749a3d9080a156a4381fea67ce47a478fb31d6acaa20e19fda1ba0c8f20e7/layer.tar
6d56becb66b184f78b25f61dc91f68fcfce4baeecb3a8dcb21ada2306091aab7/
6d56becb66b184f78b25f61dc91f68fcfce4baeecb3a8dcb21ada2306091aab7/VERSION
6d56becb66b184f78b25f61dc91f68fcfce4baeecb3a8dcb21ada2306091aab7/json
6d56becb66b184f78b25f61dc91f68fcfce4baeecb3a8dcb21ada2306091aab7/layer.tar
ff588b7779d8b10861c566538b885c379d633d059fc067c5ec6e1ab026427075.json
manifest.json
repositories

可以发现,镜像是分层的,比如上面的 ubuntu:20.04 镜像总共有三层,我们基于 ubuntu:20.04 制作的 ubuntu-hello:0.1 镜像是 4 层,层信息记录在 manifest.json 文件中。

➜  cat manifest.json | jq
[
  {
    "Config": "ff588b7779d8b10861c566538b885c379d633d059fc067c5ec6e1ab026427075.json",
    "RepoTags": [
      "joyme/ubuntu-hello:0.1"
    ],
    "Layers": [
      "15cbe1c29902a1020a4a47c835a82f0416f1896f02fac942fdd35d326c63fa22/layer.tar",
      "6d56becb66b184f78b25f61dc91f68fcfce4baeecb3a8dcb21ada2306091aab7/layer.tar",
      "1392a7609ae8d845eba5fbe95e266a6b104d55b30262a284c960583f91307420/layer.tar",
      "2a8749a3d9080a156a4381fea67ce47a478fb31d6acaa20e19fda1ba0c8f20e7/layer.tar"
    ]
  }
]

2a8749a3 是我们刚刚创建的最后一层,也就是 touch hello 生成的新文件。而最后一行 CMD 由于并不会改变文件信息,因此不会单独的存储成一层,而是记录在和 touch hello 同一层中的 json 配置文件中。

    "Cmd": [
      "/bin/sh",
      "-c",
      "#(nop) ",
      "CMD [\"/bin/sh\" \"-c\" \"['sh', '-c', 'echo hello ubuntu']\"]"
    ],

三、镜像的本地存储

当使用 docker pull 镜像到本地后,镜像的存储位置一般都在 /var/lib/docker/image/<storage_driver> 下。因为要考虑到镜像层的复用等等场景, pull 下来的镜像肯定不会是一个压缩包。镜像存储时基本要满足以下几个场景:

  • 存储当期机器上所有的镜像索引。
  • 存储镜像到层的映射,这样使用镜像时可以找到层的信息。
  • 按照层来存储,这样在 pull 镜像时,如果有重复的层,可以避免多次拉取。
  • 需要知道一个层被哪些镜像引用。这样在删除镜像时,可以确定这个镜像的某个层能不能被删除。

镜像存储的目录分布如下:

├── distribution
│   ├── diffid-by-digest
│   └── v2metadata-by-diffid
├── imagedb
│   ├── content
│   └── metadata
├── layerdb
│   ├── mounts
│   ├── sha256
│   └── tmp
└── repositories.json
  • repositories.json: 存了的镜像的 repo 信息以及镜像信息。
  • distribution 下存储了和镜像分发相关的信息。
    • diffid-by-digest:存储了 digest 到 diffid 的映射,digest 用来在拉取层的时候,对比远端的层本地是否有
    • v2metadata-by-diffid 存储了 diffid 到层的元数据信息。元数据中记录了层的 digest,repo 等
  • imagedb 下存储了和镜像相关的信息。
    • content 下存储的是镜像的配置信息。也是符合 OCI 的规范的。可以参考:https://github.com/opencontainers/image-spec/blob/master/config.md
    • metadata 存储了镜像之间的 parent 信息。
  • layerdb 下存储了和镜像层相关的信息
    • mounts: 存储了层的的挂载信息,包括 init-id, mount-id, parent
    • sha256: 存储了层的信息,根据层的 sha256 划分目录
    • tmp:

现在可以知道,镜像的 repo, name, tag, id 等信息,可以通过 repositories.json 知道,使用的镜像的时候,提供 image+tag 找到 id,或者直接提供 id 也可以。比如上面的 ubuntu-hello 的 id 为 ff588b7779d8...,然后通过这个 id,在 /var/lib/docker/image/overlay2/imagedb/content/sha256下就可以找到名为这个 id 的文件了,里面存储了镜像的具体信息。这个 id 就是这个文件的 sha256sum 后的值。比如 ubuntu-hello的 content 就存储了以下信息(有一定的精简):

{
  "architecture": "amd64",
  "config": {
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ],
    "Cmd": [
      "/bin/sh",
      "-c",
      "['sh', '-c', 'echo hello ubuntu']"
    ],
    "Image": "sha256:4bacc0d6199516a20039bc06ddaa7247a7553a39ae700ade279bd6ce78cd0a61"
  },
  "container": "802e795e28c4afced8fb6e6e703f0efeeaa6fa07a36c4400e242c4cd65e4bff5",
  "history": [
    {
      "created": "2021-05-15T10:32:23.665502338Z",
      "created_by": "/bin/sh -c touch hello"
    },
    {
      "created": "2021-05-15T10:32:23.925742465Z",
      "created_by": "/bin/sh -c #(nop)  CMD [\"/bin/sh\" \"-c\" \"['sh', '-c', 'echo hello ubuntu']\"]",
      "empty_layer": true
    }
  ],
  "os": "linux",
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:ccdbb80308cc5ef43b605ac28fac29c6a597f89f5a169bbedbb8dec29c987439",
      "sha256:63c99163f47292f80f9d24c5b475751dbad6dc795596e935c5c7f1c73dc08107",
      "sha256:2f140462f3bcf8cf3752461e27dfd4b3531f266fa10cda716166bd3a78a19103",
      "sha256:59c67359ad1702b424dcf3deefdf137e92ef13c13bec5b878b08fe66683a78f7"
    ]
  }
}

我们现在重点关注 rootfs 字段下的 diff_ids,这里存储了该镜像的每一层的 diff_id。接下来就可以通过这些 id 找到这些层的信息了。

层的信息存储在 /var/lib/docker/image/overlay2/layerdb/sha256 下,目录名是这一层的 chain_id。

chain_id 的计算中,用到了所有祖先 layer 的信息,从而能保证根据 chain_id 得到的 rootfs 是唯一的。比如我在debian和ubuntu的image基础上都添加了一个同样的文件,那么commit之后新增加的这两个layer具有相同的内容,相同的diff_id,但由于他们的父layer不一样,所以他们的chain_id会不一样,从而根据chainid能找到唯一的rootfs。

chain_id 的计算方式如下:

ChainID(L₀) =  DiffID(L₀)
ChainID(L₀|...|Lₙ₋₁|Lₙ) = Digest(ChainID(L₀|...|Lₙ₋₁) + " " + DiffID(Lₙ))

比如:第一层的 diff_id 是 ccdbb80308cc5ef43b605ac28fac29c6a597f89f5a169bbedbb8dec29c987439,第一层的 chain_id 是 ccdbb80308cc5ef43b605ac28fac29c6a597f89f5a169bbedbb8dec29c987439,第二层的 diff_id 是 63c99163f47292f80f9d24c5b475751dbad6dc795596e935c5c7f1c73dc08107,那么第二层的 chain_id 可以用下面的方法计算:

echo -n “sha256:ccdbb80308cc5ef43b605ac28fac29c6a597f89f5a169bbedbb8dec29c987439 sha256:63c99163f47292f80f9d24c5b475751dbad6dc795596e935c5c7f1c73dc08107” | sha256sum

也就是:8d8dceacec7085abcab1f93ac1128765bc6cf0caac334c821e01546bd96eb741

8d8dceacec7085abcab1f93ac1128765bc6cf0caac334c821e01546bd96eb741目录下的文件如下:

cache-id  diff  parent  size  tar-split.json.gz
  • cache-id 是该层存储目录的名字。比如该 cache-id 的内容为 52cb2ec8a0ac5a2418c568896fc079cc29a0da7d7b6b0c4b740d13241581d6e8,对应的位置是 /var/lib/docker/overlay2/52cb2ec8a0ac5a2418c568896fc079cc29a0da7d7b6b0c4b740d13241581d6e8,这里才是这一层文件的实际位置。cache-id 是随机生成的。
  • diff: 存储的是 diff_id,这里就是 ccdbb80308cc5ef43b605ac28fac29c6a597f89f5a169bbedbb8dec29c987439。diff_id 是 layer.tar 的 sha256sum 的值。
  • parent: 记录了上一层的 chain_id。
  • size: 这一层的大小。
  • tar-split.json.gz:vbatts/tar-split 这个库生成的文件,主要为了 image pull 之后,重新推送可能出现的 layer checksum 不一致的问题。具体可见:
    • https://github.com/moby/moby/pull/14067
    • https://github.com/moby/moby/issues/14018
    • https://github.com/distribution/distribution/issues/634

四、镜像的使用

根据上面的探索可以知道,提供 image+tag 或者 image_id,就可以找到关于这个镜像的所有信息。那么镜像提供的这些文件是如何被使用的呢?最简单的做法就是,将所有层的信息合并成 rootfs,提供给 runc 使用就可以了。

那么如何把这些层的信息高效的组合成 rootfs ?答案就是联合文件系统(UnionFS)。联合文件系统是一种分层、轻量级并且高性能的文件系统,它支持对文件系统的修改作为一次提交来一层层的叠加,同时可以将不同目录挂载到同一个虚拟文件系统下。

这里我们还是拿 docker 来说,docker 目前支持的联合文件系统有:overlay2,aufs,fuse-overlayfs,devicemapper,btrfs,zfs,vfs。目前推荐的是 overlay2 ,下面会使用 overlay2 来讲解容器是如何使用镜像的。

overlayfs

如上图所示,overlay2 主要有三个目录

  • lowerdir 对应了 image layers,图上只花了一层,但实际上是支持多层的,lowerdir 是只读的,也就是说 image layers 中所有的文件都不会被修改。
  • upperdir 对应了 container layer,这个是我们运行容器时创建出来的。upperdir 是支持读写的
  • merged 对应了 container mount,这一层是容器内的文件系统视图。也就是说,会把 image layer 和 container layer 在逻辑上合并起来。

overlay2 其实还有一个目录 workdir,workdir 是为了在某些场景下,为了保证操作的原子性而设计的。具体的可以参考:深入理解overlayfs(二):使用与原理分析

这里我们举几个例子帮助理解。我们通过 image 运行容器后,此时 image layer 如上图所示,但 container layer 是空的。

  • 在容器内删除 file2,只会在 container layer 中创建一个名为 file2 的 whiteout 文件,image layer 的 file2 不会被真正的删除。容器内 file2 不可见。
  • 在容器内重新创建 file2,只会在 container layer 创建 file2。
  • 在容器内修改 file1,会触发 copy-up,将 image layer 的 file1 拷贝到 container layer 中并修改。

可见,在 container layer 的文件操作比普通的文件系统操作消耗更大,特别是修改 image layer 的大文件会触发复制。

我们可以使用 docker inspect 来看一下:

"GraphDriver": {
  "Data": {
    "LowerDir": "/data00/docker/overlay2/379f2fd25bc3e317b5f767ea1bf061174013c0aa80a9454c6f01d47722dbd270-init/diff:/data00/docker/overlay2/a80f1234038ffa4720876215a77c16fd42d67fd0f110d9bd984dca73ea03b49b/diff:/data00/docker/overlay2/2aac2c4faa0be785d9d0faa5127f635bbc4dc65eb635bc442da4e03b46f05814/diff:/data00/docker/overlay2/afd88f520ed7fde849d8b7b136261348cd165bed4a6796382188529e08426685/diff:/data00/docker/overlay2/081774340b851380f09ad2d0286d8cfad388bf6738673ee089ed06efbed295c0/diff:/data00/docker/overlay2/651cf1972f63c01679beac3705e1d984d7dcae01ed4e6ae6c76bd7326b97d1c0/diff:/data00/docker/overlay2/2fc2f5bf53a5aaaec137666c6988573b3c3887a63eb5594ccbcd07995d0d3e5f/diff:/data00/docker/overlay2/0d323f1368ced5d62a56e7dd447db7cc4032b598a23473d31d5e812d2fed2376/diff:/data00/docker/overlay2/d07016559096eb4a59d4fe1e97ed7b26a492156f764542ff15ccbf53624ff7d2/diff:/data00/docker/overlay2/db070da7fe3eb36cd5a70fbcd5d72fed192d2d25d27cd5d9f7cfa26fb9de7bd0/diff:/data00/docker/overlay2/cabe60077f68da04a6d460cac3ef339bde6919fc267d808fd52950bbc5f4891d/diff:/data00/docker/overlay2/f1368e926209187e11312e4b805c9b1d912ac64776eada90e5115b245f5ab54a/diff",
    "MergedDir": "/data00/docker/overlay2/379f2fd25bc3e317b5f767ea1bf061174013c0aa80a9454c6f01d47722dbd270/merged",
    "UpperDir": "/data00/docker/overlay2/379f2fd25bc3e317b5f767ea1bf061174013c0aa80a9454c6f01d47722dbd270/diff",
    "WorkDir": "/data00/docker/overlay2/379f2fd25bc3e317b5f767ea1bf061174013c0aa80a9454c6f01d47722dbd270/work"
  },
  "Name": "overlay2"
}

了解了上述的知识后,我们就可以对 docker commit 的机制做一些猜想了:应该就是将 upperdir 作为新的 lowerdir 进行挂载。

参考:

深入理解overlayfs(二):使用与原理分析

Open Container Initiative Distribution Specification

《容器镜像是如何工作的》上有1条评论

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据