【万字长文】Docker 容器镜像是怎样炼成的?

上周我看了很多关于容器镜像相关的博客,从大佬们那里偷偷学了不少知识,对容器镜像也有了一点点深入的了解。趁着这周末宅在家有空,我把最近所学的知识整理成文,供大家一起食用。文章内容比较长,不过如果读者能有耐心看完,相信还是能收获一些知识。

 

镜像是怎样炼成的

所谓炼成像就是构建镜像。提到容器镜像就不得不提一下 OCI ,即 Open Container Initiative,旨在围绕容器格式和运行时制定一个开放的工业化标准。目前 OCI 主要有三个规范:运行时规范 runtime-spec ,镜像规范 image-spec 以及不常见的镜像仓库规范 distribution-spec 。关于 OCI 这些规范的作用,这里就引用容器开放接口规范(CRI OCI)中的内容:

制定容器格式标准的宗旨概括来说就是不受上层结构的绑定,如特定的客户端、编排栈等,同时也不受特定的供应商或项目的绑定,即不限于某种特定操作系统、硬件、CPU 架构、公有云等。

这两个协议通过 OCI runtime filesytem bundle 的标准格式连接在一起,OCI 镜像可以通过工具转换成 bundle,然后 OCI 容器引擎能够识别这个 bundle 来运行容器。

  • 操作标准化:容器的标准化操作包括使用标准容器创建、启动、停止容器,使用标准文件系统工具复制和创建容器快照,使用标准化网络工具进行下载和上传。

  • 内容无关:内容无关指不管针对的具体容器内容是什么,容器标准操作执行后都能产生同样的效果。如容器可以用同样的方式上传、启动,不管是 PHP 应用还是 MySQL 数据库服务。

  • 基础设施无关:无论是个人的笔记本电脑、AWS S3、OpenStack,又或者其它基础设施,都应该支持容器的各项操作。

  • 为自动化量身定制:制定容器统一标准,使得操作内容无关化、平台无关化的根本目的之一,就是为了可以使容器操作全平台自动化。

  • 工业级交付:制定容器标准一大目标,就是使软件分发可以达到工业级交付

其实 OCI 规范内容很容易理解,不像 RFC 和 ISO 那么高深莫测,所以大家想对容器镜像有更深入的了解还是非常推荐大家去阅读有关文件😂。OCI 规范是免费的,不像大多数 ISO 规范需要付费才能阅读。

OCI image-spec

OCI 规范中的镜像规范 image-spec 决定了我们的镜像按照什么标准来构建,以及构建完镜像之后如何存放。下文提到的 Dockerfile 则决定了镜像的 layer 内容以及镜像的一些元数据信息。一个镜像规范 image-spec 和一个 Dockerfile 就指导着我们构建一个镜像。那么接下来我们就简单了解一下镜像规范,看看镜像是长什么样子的,对镜像有个大体的主观认识。

根据官方文档的描述,OCI 镜像规范的主要由以下几个 markdown 文件组成:

  • Image Manifest

  • Image Index 

  • Filesystem Layer

  • Image Configuration 

  • Conversion 

  • Descriptor 

图片

总结以上几个 markdown 文件, OCI 容器镜像规范主要包括以下几块内容:

layer

文件系统:以 layer (镜像层)保存的文件系统,每个 layer 保存了和上层之间变化的部分,layer 应该保存哪些文件,怎么表示增加、修改和删除的文件等。

image config

image config 文件:保存了文件系统的层级信息(每个层级的 hash 值,以及历史信息),以及容器运行时需要的一些信息(比如环境变量、工作目录、命令参数、mount 列表),指定了镜像在某个特定平台和系统的配置,比较接近我们使用 docker inspect <image_id> 看到的内容。

  • example

图片

manifest

manifest 文件 :镜像的 config 文件索引。manifest 文件中保存了很多和当前平台有关的信息,例如有哪些 layer 以及额外的 annotation 信息。另外 manifest 中的 layer 和 config 中的 layer 表达的虽然都是镜像的 layer ,但二者代表的意义不太一样,下面会讲到。manifest 文件存放在 registry 中,当我们拉取镜像的时候,会根据该文件拉取相应的 layer 。

另外 manifest 也分好几个版本,目前主流的版本是 Manifest Version 2, Schema 2 ,可以参考 Docker 的官方文档 Image Manifest Version 2, Schema 2 。registry 中有个 Manifest List 文件,该文件是为不同处理器体系架构而设计的,通过该文件指向与该处理器体系架构相对应的 Image Manifest ,这一点不要搞混。

  • Example Manifest List

图片

  • Image Manifest

图片

这里再补充一段大佬的解释:

Manifest 是一个 JSON 文件,其定义包括两个部分,分别是 Config 和 Layers。Config 是一个 JSON 对象,Layers 是一个由 JSON 对象组成的数组。可以看到,Config 与 Layers 中的每一个对象的结构相同,都包括三个字段,分别是 digest、mediaType 和 size。其中 digest 可以理解为是这一对象的 ID。mediaType 表明了这一内容的类型。size 是这一内容的大小。 

容器镜像的 Config 有着固定的 mediaType application/vnd.oci.image.config.v1+json。一个 Config 的示例配置如下,它记录了关于容器镜像的配置,可以理解为是镜像的元数据。通常它会被镜像仓库用来在 UI 中展示信息,以及区分不同操作系统的构建等。 

而容器镜像的 Layers 是由多层 mediaType 为 application/vnd.oci.image.layer.v1.*(其中最常见的是 application/vnd.oci.image.layer.v1.tar+gzip) 的内容组成的。众所周知,容器镜像是分层构建的,每一层就对应着 Layers 中的一个对象。

容器镜像的 Config,和 Layers 中的每一层,都是以 Blob 的方式存储在镜像仓库中的,它们的 digest 作为 Key 存在。因此,在请求到镜像的 Manifest 后,Docker 会利用 digest 并行下载所有的 Blobs,其中就包括 Config 和所有的 Layers。

image manifest index

index 文件 :其实就是我们上面提到的 Manifest List。在 Docker 的 distribution 中称之为 Manifest List ,而在 OCI 中就叫 OCI Image Index Specification 。其实两者指的是同一个文件,甚至两者 GitHub 上文档给的 example 都一模一样🤣,应该是 OCI 复制粘贴 Docker 的文档😂。index 文件是个可选的文件,包含着一个列表为同一个镜像不同的处理器 arch 指向不同平台的 manifest 文件,这个文件能保证一个镜像可以跨平台使用,每个处理器 arch 平台拥有不同的 manifest 文件,使用 index 作为索引。当我们使用 arm 架构的处理器时要额外注意,在拉取镜像的时候要拉取 arm 架构的镜像,一般处理器的架构都接在镜像的 tag 后面,默认 latest tag 的镜像是 x86 的,在 arm 处理器的机器这些镜像上是跑不起来的。

Dockerfile

众所周知 Docker 镜像需要一个 Dockerfile 来构建而成,当我们对 OCI 镜像规范有了个大致的了解之后,我们接下来就拿着 Dockerfile 这个 ”图纸“ 去一步步构建镜像。本文不再细讲 Dockerfile 的详细书写和技巧,网上有很多关于写好 Dockerfile 的技巧。

下面就是 webp server go Dockerfile 的例子:

图片

需要注意的是,在 RUN 指令的每行结尾我使用的是 ;\ 来接下一行 shell ,另一种写法是 && 。二者有本质的区别,比如 COMMAND 1;COMMAND 2 ,当 COMMAND 1 运行失败时会继续运行 COMMAND2 ,并不会退出。而 COMMAND 1&& COMMAND 2,时 COMMAND 1 运行成功时才接着运行 COMMAND 2 ,COMMAND 1 运行失败会退出。如果没有十足的把握保证每一行 shell 都能每次运行成功建议用 && ,这样失败了就退出构建镜像,不然构建出来的镜像会有问题。

镜像工厂

Docker 是一个典型的 C/S 架构的应用,分为 Docker 客户端(即平时敲的 docker 命令) Docker 服务端(dockerd 守护进程)。

Docker 客户端通过 REST API 和服务端进行交互,docker 客户端每发送一条指令,底层都会转化成 REST API 调用的形式发送给服务端,服务端处理客户端发送的请求并给出响应。 

Docker 镜像的构建、容器创建、容器运行等工作都是 Docker 服务端来完成的,Docker 客户端只是承担发送指令的角色。

Docker 客户端和服务端可以在同一个宿主机,也可以在不同的宿主机,如果在同一个宿主机的话,Docker 客户端默认通过 UNIX 套接字(/var/run/docker.sock)和服务端通信。

类比于钢铁是怎样炼成的,如果说炼制镜像也需要个工厂的话,那么 Dockerd 这个守护进程就是生产镜像的工厂。能生产镜像的不止 Docker 一家,红帽子家的 buildah 也能生产镜像,不过用的人并不多。二者的最大区别在于 buildah 可以不用 root 权限来构建镜像,而使用 Docker 构建镜像时需要用到 root 权限,没有 root 权限的用户构建镜像会当场翻车。

图片

不过 buildah 构建出来的镜像有一堆堆的兼容性问题,所以我们还是使用 Docker 来构建镜像。当我们使用 docker build 命令构建一个镜像的时候第一行日志就是 Sending build context to Docker daemon xx MB。这一步是 docker cli 这个命令行客户端将我们当前目录(即构建上下文) build context 打包发送 Docker daemon 守护进程 (即 dockerd)的过程。

图片

docker build 构建镜像的流程大概就是:

  • 执行 docker build -t imageName:Tag ,可以使用 -f 参数来指定 Dockerfile 文件

  • Docker 客户端会将构建命令后面指定的路径(.)下的所有文件打包成一个 tar 包,发送给 Docker 服务端

  • Docker 服务端收到客户端发送的 tar 包,然后解压,接下来根据 Dockerfile 里面的指令进行镜像的分层构建

  • Docker 下载 FROM 语句中指定的基础镜像,然后将基础镜像的 layer 联合挂载为一层,并在上面创建一个空目录

  • 接着启动一个临时的容器并在 chroot 中启动一个 bash,运行 RUN 语句中的命令:RUN: chroot . /bin/bash -c "apt get update……"

  • 一条 RUN 命令结束后,会把上层目录压缩,形成新镜像中的新的一层

  • 如果 Dockerfile 中包含其它命令,就以之前构建的层次为基础,从第二步开始重复创建新层,直到完成所有语句后退出

  • 构建完成之后为该镜像打上 tag

以上就是构建镜像的大致流程,我们也可以通过 docker history imageName:Tag 命令来逆向推算出 docker build 的过程。

图片

base image

当我们在写 Dockerfile 的时候都需要用 FROM 语句来指定一个基础镜像,这些基础镜像并不是无中生有,也需要一个 Dockerfile 来构建成镜像。下面我们用 debian:buster 这个基础镜像的 Dockerfile 来看一下基础镜像是如何炼成的。

图片

一个基础镜像的 Dockerfile 一般仅有三行。第一行 FROM scratch 中的 scratch 这个镜像并不真实的存在。当你使用 docker pull scratch 命令来拉取这个镜像的时候会翻车哦,提示 Error response from daemon: 'scratch' is a reserved name。这是因为自从 Docker 1.5 版本开始,在 Dockerfile 中 FROM scratch 指令并不进行任何操作,也就是不会创建一个镜像层。接着第二行的 ADD rootfs.tar.xz / 会自动把 rootfs.tar.xz 解压到 / 目录下,由此产生的一层镜像就是最终构建的镜像真实的 layer 内容。第三行 CMD ["bash"] 指定这镜像在启动容器的时候执行的应用程序,一般基础镜像的 CMD 默认为 bash 或者 sh 。

As of Docker 1.5.0 (specifically, docker/docker#8827), FROM scratch is a no-op in the Dockerfile , and will not create an extra layer in your image (so a previously 2-layer image will be a 1-layer image instead).

ADD rootfs.tar.xz / 中,这个 rootfs.tar.xz 就是我们经过一系列操作(一般是发行版源码编译)构建出来的根文件系统。这里就不细谈构建过程了。如果大家对源码构建 rootfs.tar.xz 这个过程感兴趣可以去看一下构建 debian 基础镜像的 Jenkins 流水线任务 debuerreotype,上面有构建这个 rootfs.tar.xz 完整过程,或者参考 Debian 官方的 docker-debian-artifacts 这个 repo 里的 shell 脚本。

这里需要注意一点,在这里往镜像里添加 rootfs.tar.xz 时使用的是 ADD 而不是 COPY ,因为在 Dockerfile 中的 ADD 指令 src 文件如果是个 tar 包,在构建的时候 Docker 会帮我们把 tar 包解开到指定目录,使用 copy 指令则不会解开 tar 包。另外一点区别就是 ADD 指令是添加一个文件,这个文件可以是构建上下文环境中的文件,也可以是个 URL,而 COPY 则只能添加构建上下文中的文件,所谓的构建上下文就是我们构建镜像的时候最后一个参数啦。

构建 rootfs.tar.xz 不同的发行版方法可能不太一样,Debian 发行版的 rootfs.tar.xz 可以在 docker-debian-artifacts 这个 repo 上找到,根据不同处理器 arch 选择相应的 branch ,然后这个 branch 下的目录就对应着该发行版的不同的版本的代号。意外发现 Debian 官方是将所有 arch 和所有版本的 rootfs.tar.xz 都放在这个 repo 里的,以至于这个 repo 的大小接近 2.88 GiB 😨。

图片

我们把这个 rootfs.tar.xz 解开就可以看到,这就是一个 Linux 的根文件系统,不同于我们使用 ISO 安装系统的那个根文件系统,这个根文件系统是经过一系列的裁剪,去掉了一些在容器运行中不必要的文件,使之更加轻量适用于容器运行的场景,整个根文件系统的大小为 125M,如果使用 slim 的rootfs.tar.xz 会更小一些,仅仅 76M。当然相比于仅仅几 M 的 alpine ,这算是够大的了。

图片

想要自己构建一个 debian:buster 基础镜像其实很简单:

图片

下面就是构建 Debian 基础镜像的过程,正如 Dockerfile 中的那样,最终只产生了一层镜像。

图片

 

 

镜像是怎样存放的(一)本地存储

当我们构建完一个镜像之后,镜像就存储在了我们 Docker 本地存储目录,默认情况下为 /var/lib/docker ,下面就探寻一下镜像是以什么样的目录结构存放的。在开始 hack 之前我们先统一一下环境信息,我使用的机器是 Ubuntu 1804,docker info 信息如下:

图片

为了方便分析,我将其他的 docker image 全部清空掉,只保留 debian:v1 和 debian:v2 这两个镜像,这两个镜像足够帮助我们理解容器镜像是如何存放的,镜像多了多话分析下面存储目录的时候可能不太方便(>﹏<),这两个镜像是我们之前使用 Debian 的 rootfs.tar.xz 构建出来的基础镜像。

图片

docker (/var/lib/docker)

图片

根据目录的名字我们可以大致推断出关于容器镜像的存储,我们只关心 image 和 overlay2 这两个文件夹即可,容器的元数据存放在 image 目录下,容器的 layer 数据则存放在 overlay2 目录下。

/var/lib/docker/image 目录结构

overlay2 代表着本地 Docker 存储使用的是 overlay2 该存储驱动,目前最新版本的 Docker 默认优先采用 overlay2 作为存储驱动,对于已支持该驱动的 Linux 发行版,不需要任何进行任何额外的配置,可使用 lsmod 命令查看当前系统内核是否支持 overlay2 。

另外值得一提的是 devicemapper 存储驱动已经在 Docker 18.09 版本中被废弃,Docker 官方推荐使用 overlay2 替代devicemapper。

图片

  • repositories.json

repositories.json 就是存储镜像元数据信息,主要是 image name 和 image id 的对应,digest 和 image id 的对应。当 pull 完一个镜像的时候 Docker 会更新这个文件。当我们 docker run 一个容器的时候也用到这个文件去索引本地是否存在该镜像,没有镜像的话就自动去 pull 这个镜像。

图片

/var/lib/docker/overlay2

图片

在 /var/lib/docker/overlay2 目录下,我们可以看到,镜像 layer 的内容都存放在一个 diff 的文件夹下,diff 的上级目录就是以镜像 layer 的 digest 为名的目录。其中还有个 l 文件夹,下面有一坨坨的硬链接文件指向上级目录的 layer 目录。这个 l 其实就是 link 的缩写,l 下的文件都是一些比 digest 文件夹名短一些的,方便不至于 mount 的参数过长。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值