13.我在B站学云原生之Docker容器镜像构建存储原理浅析与实践
全栈工程师修炼指南
编辑于 2022年02月12日 15:03
收录于文集
共39篇

帅哥(靓仔)、美女,点个关注后续不迷路!

本章目录

cut-off

0x00 镜像如何炼成

  • 1.OCI 标准协议

    • image-spec - 镜像规范

    • runtime-spec 运行时规范

    • distribution-spec 镜像仓库规范

  • 2.Dockerfile

  • 3.基础镜像

0x01 镜像存储原理

  • 本地存储 - Local

  • 镜像仓库 - Registry

  • 0x02 镜像搬运

    • pull - 镜像拉取

    • push - 推送镜像

    • python Docker-dray

    • skopeo - 镜像搬运神器

  • 0x03 镜像使用

cut-off

0x00 镜像如何炼成

在深入学习镜像之前我们需要知道镜像是如何成的(等同于构建镜像),当然是通过我们DockerFile一条条指令为镜像生成每一层,按照执行顺序镜像文件系统复写封装从下到上;

1.OCI 标准协议

关于容器镜像的OCI标准协议,那什么又是OCI标准协议?

答: Open Container Initiative(打开集装箱倡议)旨在围绕容器格式和运行时制定一个开放的工业化标准; 参考地址:容器开放接口规范(CRI OCI)

Docker 公司与 CoreOS 和 Google 共同创建了 OCI (Open Container Initial),并提供了三种规范

  • 镜像规范 image-spec : 制定镜像格式、操作等 (https://github.com/opencontainers/image-spec)

  • 运行时规范 runtime-spec : 描述如何运行filesystem bundle (https://github.com/opencontainers/runtime-spec)

  • 镜像仓库规范 distribution-spec (不常见)

关于 OCI 规范的作用说明:

  • 1.制定容器格式标准的宗旨就提高镜像通用性以便不限于某种特定操作系统、硬件、CPU架构、公有云等; 概括来说就是不受上层结构的绑定,如特定的客户端、编排栈

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

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

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

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

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

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

参考来源:

  • 1.OCI 镜像规范的主要由以下几个 markdown 文件组成:

代码块
Shell
自动换行
复制代码
├── annotations.md         # 注解规范
├── config.md              # image config 文件规范
├── considerations.md      # 注意事项
├── conversion.md          # 转换为 OCI 运行时
├── descriptor.md          # OCI Content Descriptors 内容描述
├── image-index.md         # manifest list 文件
├── image-layout.md        # 镜像的布局
├── implementations.md     # 使用 OCI 规范的项目
├── layer.md               # 镜像层 layer 规范
├── manifest.md            # manifest 规范
├── media-types.md         # 文件类型
├── README.md              # README 文档
├── spec.md                # OCI 镜像规范的概览
复制成功

  • 2.OCI 规范是免费的哦,不像大多数 ISO 规范还要交钱才能看(︶^︶)哼。

image-spec - 镜像规范

描述:它决定了我们镜像,以及,接着下文提到的 Dockerfile 则决定了镜像的 layer 内容以及镜像的一些元数据信息。

直白的说一个镜像规范 image-spec 和一个 Dockerfile 就指导着我们构建一个镜像;

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

  • Layer : Docker 以 layer (镜像层) 保存的文件系统以及每个Layer保存与上层之间变化部分,以及对保存哪些文件,怎么表示增加、修改和删除的文件等进行描述;

  • Image-Config : 保存了文件系统的层级信息(每个层级的 hash 值,以及历史信息)以及容器运行时需要的一些信息(比如环境变量、工作目录、命令参数、mount 列表),指定了镜像在某个特定平台和系统的配置:

代码块
Shell
自动换行
复制代码
# 比较接近我们使用 docker inspect <image|id> 看到的内容
{
  "architecture": "amd64",
  "config": {
    "Hostname": "",
    "Domainname": "",
    "User": "",
    "AttachStdin": false,
    "AttachStdout": false,
    "AttachStderr": false,
    "Tty": false,
    "OpenStdin": false,
    "StdinOnce": false,
    "Env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
    "Cmd": ["bash"],
    "Image": "sha256:ba8f577813c7bdf6b737f638dffbc688aa1df2ff28a826a6c46bae722977b549",
    "Volumes": null,
    "WorkingDir": "",
    "Entrypoint": null,
    "OnBuild": null,
    "Labels": null
  },
  "container": "38501d5aa48c080884f4dc6fd4b1b6590ff1607d9e7a12e1cef1d86a3fdc32df",
  "container_config": {
    "Hostname": "38501d5aa48c",
    "Domainname": "",
    "User": "",
    "AttachStdin": false,
    "AttachStdout": false,
    "AttachStderr": false,
    "Tty": false,
    "OpenStdin": false,
    "StdinOnce": false,
    "Env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
    "Cmd": [
      "/bin/sh",
      "-c",
      "#(nop) ",
      "CMD [\"bash\"]"
    ],
    "Image": "sha256:ba8f577813c7bdf6b737f638dffbc688aa1df2ff28a826a6c46bae722977b549",
    "Volumes": null,
    "WorkingDir": "",
    "Entrypoint": null,
    "OnBuild": null,
    "Labels": {}
  },
  "created": "2020-06-07T01:59:47.348924716Z",
  "docker_version": "19.03.5",
  "history": [{
      "created": "2020-06-07T01:59:46.877600299Z",
      "created_by": "/bin/sh -c #(nop) ADD file:a82014afc29e7b364ac95223b22ebafad46cc9318951a85027a49f9ce1a99461 in / "
      },{
      "created": "2020-06-07T01:59:47.348924716Z",
      "created_by": "/bin/sh -c #(nop)  CMD [\"bash\"]",
      "empty_layer": true
    }],
  "os": "linux",
  "rootfs": {
    "type": "layers",
    "diff_ids": ["sha256:d1b85e6186f67d9925c622a7a6e66faa447e767f90f65ae47cdc817c629fa956"]
  }
}
复制成功

  • manifest : 镜像的config文件索引包括了Layer/Annotation其文件中保存了很多和当前平台有关信息存放于在 registry 中 您可以在镜像仓库中通过Registry API请求获取镜像Manifest中的信息, 当我们拉取镜像的时候会根据该文件拉取相应的 layer,比如后面实现的不解压镜像拷贝;第一个目标是内容可寻址的图像,通过支持的图像模型,其中所述图像的配置可被散列以生成图像和它的组件的唯一ID。 第二个目标是让多架构的图像,通过“mainfest”,这对于图像的特定于平台的版本参考图像清单。在OCI,这是在图像索引编入。 第三个目标是要翻译到OCI运行规范。

    • 版本: 目前主流的版本是 Manifest Version 2, Schema 2 官方参考说明

    • 注意: manifest 中的 layer 和 config 中的 layer 表达的虽然都是镜像的 layer ,但二者代表的意义不太一样;

    • 注意: registry 中会有个 Manifest List 文件,该文件是为不同处理器体系架构而设计的,通过该文件指向与该处理器体系架构相对应的 Image Manifest;

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

    • 镜像的 manifest 文件(图像清单规范)主要有以下三个目标:

代码块
Shell
自动换行
复制代码
//Manifest List
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
  "manifests": [
    {
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "size": 7143,
      "digest": "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f",
      "platform": {
        "architecture": "ppc64le",
        "os": "linux",
      }
    },
    {
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "size": 7682,
      "digest": "sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270",
      "platform": {
        "architecture": "amd64",
        "os": "linux",
        "features": [
          "sse4"
        ]
      }
    }
  ]
}

// Image Manifest :  可通过 Registry  API 请求查看到;
{
// Manifest Version 2, Schema 2
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
// 其定义包括两个部分,分别是 Config 和 Layers 且都包含三个字段分别是 digest、mediaType 和 size
// Config 是一个 JSON 对象
  "config": {
    // 内容类型
    // 镜像的元数据: 关于容器镜像的配置,通常它会被镜像仓库用来在 UI 中展示信息,以及区分不同操作系统的构建等。
    "mediaType": "application/vnd.docker.container.image.v1+json",
    // 内容的大小
    "size": 1509,
    // 对象的 ID
    "digest": "sha256:a24bb4013296f61e89ba57005a7b3e52274d8edd3ae2077d04395f806b63d83e"
  },
// Layers 是一个由 JSON 对象组成的数组
  "layers": [
    {
      // 众所周知,容器镜像是分层构建的,每一层就对应着 Layers 中的一个对象。
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",  // 常见形式
      "size": 5844992,
      "digest": "sha256:50644c29ef5a27c9a40c393a73ece2479de78325cae7d762ef3cdc19bf42dd0a"
    }
  ]
}
复制成功
  • Image manifest index - OCI图像索引规范 描述:在 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 处理器的机器这些镜像上是跑不起来的。

代码块
Shell
自动换行
复制代码
$docker info | grep -i "runtime"
# Docker、Google等公司开源了用于运行容器的工具和库 runc,在此之后,各种运行时工具和库也慢慢出现,例如 rkt、containerd、cri-o 等,然而这些工具所拥有的功能却不尽相同,有的只有运行容器(runc、lxc),而有的除此之外也可以对镜像进行管理(containerd、cri-o)。
Runtimes: runc 
Default Runtime: runc
复制成功
  • low-level runtime : 关注如何与操作系统交互,创建并运行容器,使用 namespace 和 cgroup 实现资源隔离和限制,目前常见的 low-level runtime有:

    • lmctfy -- 是Google的一个项目,它是Borg使用的容器运行时

    • runc -- 目前使用最广泛的容器运行时。

    • rkt -- CoreOS开发的Docker/runc的一个流行替代方案,提供了其他 low-level runtimes (如runc)所提供的所有特性。

  • high-level runtime : 指包含了更多上层功能,例如 grpc调用,镜像存储管理等,目前主流的 high-level runtime 有:

    • docker

    • containerd

    • rkt

  • High-level runtimes相较于low-level runtimes位于堆栈的上层

  • low-level runtimes负责实际运行容器,而High-level runtimes负责传输和管理容器镜像、解压镜像,并传递给low-level runtimes来运行容器。

代码块
Shell
自动换行
复制代码
FROM alpine
LABEL name="test-image"
RUN apk -v add --no-cache bash 
RUN apk -v add --no-cache curl
COPY ./startService.sh /
  
CMD ["/bin/bash", "/startService.sh"]
复制成功
代码块
Shell
自动换行
复制代码
$docker build -t test-image .
Sending build context to Docker daemon  3.072kB
Step 1/6 : FROM alpine
 ---> 3f53bb00af94
Step 2/6 : LABEL name="test-image"
 ---> Running in 3bd6320fc291
Removing intermediate container 3bd6320fc291
 ---> bb97dd1fb1a1
Step 3/6 : RUN apk -v add --no-cache bash
 ---> Running in f9987ff57ad7
fetch http://dl-cdn.alpinelinux.org/alpine/v3.8/main/x86_64/APKINDEX.tar.gz
fetch http://dl-cdn.alpinelinux.org/alpine/v3.8/community/x86_64/APKINDEX.tar.gz
(1/5) Installing ncurses-terminfo-base (6.1_p20180818-r1)
(2/5) Installing ncurses-terminfo (6.1_p20180818-r1)
(3/5) Installing ncurses-libs (6.1_p20180818-r1)
(4/5) Installing readline (7.0.003-r0)
(5/5) Installing bash (4.4.19-r1)
Executing bash-4.4.19-r1.post-install
Executing busybox-1.28.4-r2.trigger
OK: 18 packages, 136 dirs, 2877 files, 13 MiB
Removing intermediate container f9987ff57ad7
 ---> a5635f1b1d00
Step 4/6 : RUN apk -v add --no-cache curl
 ---> Running in c49fb2e4b311
fetch http://dl-cdn.alpinelinux.org/alpine/v3.8/main/x86_64/APKINDEX.tar.gz
fetch http://dl-cdn.alpinelinux.org/alpine/v3.8/community/x86_64/APKINDEX.tar.gz
(1/5) Installing ca-certificates (20171114-r3)
(2/5) Installing nghttp2-libs (1.32.0-r0)
(3/5) Installing libssh2 (1.8.0-r3)
(4/5) Installing libcurl (7.61.1-r1)
(5/5) Installing curl (7.61.1-r1)
Executing busybox-1.28.4-r2.trigger
Executing ca-certificates-20171114-r3.trigger
OK: 23 packages, 141 dirs, 3040 files, 15 MiB
Removing intermediate container c49fb2e4b311
 ---> 9156d1521a2f
Step 5/6 : COPY ./startService.sh /
 ---> 704626646baf
Step 6/6 : CMD ["/bin/bash", "/startService.sh"]
 ---> Running in 1c5e6e861264
Removing intermediate container 1c5e6e861264
 ---> 6cd0a66e83f1
Successfully built 6cd0a66e83f1
Successfully tagged test-image:latest
复制成功
  • 1.Docker 客户端通过 REST API 和服务端进行交互,即客户端每发送一条指令,底层都会转化成 REST API 调用的形式发送给服务端,服务端处理客户端发送的请求并给出响应。

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

  • 3.Docker 客户端和服务端可以在同一个宿主机,也可以在不同的宿主机;

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

    • 如果不在同一个宿主机的化,Docker客户端则通过TCP通道(tcp://xxx.xx.xx.xx:2375)进行与服务端通信。

  • 1.Docker 构建镜像时候需要ROOT权限,而Buildah则可以采用非ROOT权限构建镜像

  • 2.Docker 镜像兼容性相对于Buildah较好;

  • 3.Docker 构建镜像使用占比比Buildah多;

  • 在Docker Cli 执行镜像构建命令并且使用-f参数来指定Dockerfile文件,-t 指定构建出的镜像标签信息;

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

  • Docker Deamon 收到客户端发送的 tar 包并解压,根据 Dockerfile 里面的指令进行镜像的分层构建;

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

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

  • 在RUN命令执行完毕后,会将当前层目录进行压缩从而形成新镜像中的新的一层, 同时为下一层提供基础镜像;

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

  • 在 Dockerfile 中包含的所有指令命令执行完毕后镜像构建完成,并为该镜像打上Tag;

代码块
Shell
自动换行
复制代码
# 自从 docker 1.5 版本开始在 Dockerfile 中 FROM scratch 指令并不进行任何操作也就是不会创建一个镜像层;
FROM scratch
# ADD指令把 rootfs.tar.xz 解压到 / 目录下,由此产生的一层镜像就是最终构建的镜像真实的 layer 内容
ADD rootfs.tar.xz /
# 指定这镜像在启动容器的时候执行的应用程序,一般基础镜像的 CMD 默认为 bash 或者 sh 。
CMD ["bash"]
复制成功
  • 1.上述中的scratch镜像并不是真实存在的,当您使用docker pull命令下载它时候会提示Error response from daemon: 'scratch' is a reserved name;

  • 2.上述中的rootfs.tar.xz是发行版源码编译的可以在docker-debian-artifacts中找到它,它是一个搓出来的根文件系统;

    • 如果对于其源码构建感兴趣可以参考debian 基础镜像的 Jenkins 流水线任务debuerreotype

    • 意外发现 Debian 官方是将所有 arch 和所有版本的 rootfs.tar.xz 都放在这个 repo 里的,以至于这个 repo 的大小接近 2.88 GiB;

  • 3.此时在上述环境中我们非常方便的自己构建debian:buster 基础镜像

  • Step1.Registry 镜像容器启动

  • Step2.上传本地镜像到 Registry 仓库

  • Step3.当我们在本地启动一个 registry 容器之后,容器内默认的存储位置为 /var/lib/registry

  • Step4.当我们 pull 镜像的时候如果不指定镜像的 tag名默认就是 latest, registry 会从 HTTP 请求中解析到这个tag名,然后根据tag名目录下的 link 文件找到该镜像的 manifest的位置返回给客户端,客户端接着去请求这个manifest 文件,客户端根据这个 manifest 文件来 pull 相应的镜像 layer 。

  • 1.同一镜像在不同Registry镜像仓库中,存储的方式、位置和内容完全一样因为它们的Layer digest在仓库中唯一。

    • 通过 Registry API 获得的两个镜像仓库中相同镜像的 manifest 信息完全相同。

    • 两个镜像仓库中相同镜像的 manifest 信息的存储路径和内容完全相同。

    • 两个镜像仓库中相同镜像的 blob 信息的存储路径和内容完全相同。

  • 2.registry 存储目录里并不会存储与该 registry 相关的信息,比如我们push镜像时给镜像加上 127.0.0.1:5000 前缀; 所以我们在迁移一个大的Registry镜像仓库时候最快捷的方法就是打包(tar -cvf - 不需要加z参数浪费 CPU 时间得不偿失)该registry存储然后将其tar包rsync到其它机器即可;

  • Step1.配置Registry镜像仓库的auth认证信息

  • Step2.dockerd 守护进程解析 docker 客户端参数由镜像名 + tag构成向 registry 请求Manifest 文件,

  • Step3.docker 守护进程解析这个 Manifest 文件获取镜像的 layer 的信息;然后dockerd守护进程并行下载各 layer ,HTTP 请求为GET /v2/<name>/blobs/<digest>

  • Step4.dockerd 起一个单独的进程 docker-untar 来 gzip 解压缩已经下载完成的 layer 文件;注意对于有些比较大的镜像(比如几十 GB 的镜像),往往镜像的 layer 已经下载完成了,但还没有解压完;

    • Step5.验证 image config 中的 RootFS.DiffIDs 是否与下载(解压后)hash 相同;

    • Step6.解析 Manifest 获取镜像 Configuration,验证镜像是否正确。

  • Step1.push 镜像到Registry仓库中需要进行鉴权同时返回一个Token(后续利用它作为身份验证);

  • Step2.向Registry发起请求POST /v2/<name>/blobs/uploads/,registry 返回一个上传镜像 layer 时要应到的 URL;

  • Step3.客户端通过HEAD /v2/<name>/blobs/<digest>请求检查 registry 中是否已经存在镜像的 layer。

  • Step4.客户端通过URL 使用 POST 方法来实时上传 layer 数据即上传镜像,但是上传镜像 layer 分为 Monolithic Upload (整体上传)Chunked Upload(分块上传)两种方式。

  • Step5.镜像的 layer 上传完成之后,客户端需要向 registry 发送一个 PUT HTTP 请求告知该 layer 已经上传完毕。

  • Step6.最后当所有的 layer 上传完之后客户端再 PUT 请求将 manifest 推送上去就完事儿了。

  • config.json: 该文件包含了容器运行的配置信息,该文件必须存在 bundle 的根目录,且名字必须为 config.json

  • 容器的根目录可以由 config.json 中的 root.path 指定

  • Step1.当我们启动一个容器之后我们使用Tree命令来分析Overlay2会发现,容器docker run 启动后较之前的overlay2目录下多了一个 merged 的文件夹并且该文件夹在容器中可见;

  • Step2.Docker 通过 overlayfs 联合挂载的技术将镜像的多层 layer 挂载为一层,这层的内容就是容器里所看到的也就是 merged 文件夹。

    • 从 docker 官方文档 Use the OverlayFS storage driver 里偷来的一张图片分别介绍各 Dir 的作用

  • Step 3.如果想对 overlayfs 文件系统有详细的了解,可以参考 Linux 内核官网上的这篇文档 overlayfs.txt 。

cut-off
cut-off
  • echo  "【点个赞】,动动你那粗壮的拇指或者芊芊玉手,亲!"

  • printf("%s", "【投个币】,万水千山总是情,投个硬币行不行,亲!")

  • fmt.Printf("【收个藏】,阅后即焚不吃灰,亲!")  

  • System.out.println("【关个注】,后续浏览查看不迷路哟,亲!")

  • console.info("【转个发】,让更多的志同道合的朋友一起学习交流,亲!")