Jason Pan

揭秘容器(三):容器镜像

黄杰 / 2023-11-24


本文翻译并补充了 CNCF 上的博客,系列文章共4篇,旨在从容器发展的历史角度,带领读者认识容器。文章会通过简单的示例,结合历史背景,引导你从最小 Linux 环境开始认识内核层次的一些技术,一直到构建安全容器,逐层地去认识现代云架构

最终目的,是更深入地理解 Linux 内核、容器工具、运行时、软件定义网络和编排软件(如 Kubernetes)的设计理念、底层工作原理,完美地适应当今和未来的容器编排世界


本篇为系列文章的第三篇,专门讨论容器镜像。跟前两篇类似,我们会从不同容器镜像格式的历史背景和演变开始。之后,将通过制作、修改和拆解我们自建的容器镜像作为示例,以了解最新的开放容器计划 (OCI) 镜像规范。除此之外,我们将通过使用 buildah、podman 和 skopeo 等工具来学习现代容器镜像创建的一些最佳实践。

一、简介

尽管云原生世界也存在多种趋势,但目前“在 Kubernetes 内运行持续集成 (CI) 和部署 (CD) 流水线”等主题无疑最受关注。

将测试和部署基础设施进行统一越来越受欢迎,许多开源项目在努力尝试实现这一目标。而具体的公司作为这些 CI/CD 软件项目的消费者,有必要制定和运行高效的软件开发生命周期 (SDLC) ,包括开发、测试、部署一些将会被上百万甚至几十亿人使用的软件。

ci-cd-flow

当我们开始考虑这些场景的时候,我们脑子中会涌现出很多问题:“如何确保我们的系统安全?” “我们该使用何种基础设施解决方案?” 为了回答这些问题,我们必须得搞清楚两个更基础的问题:

今时今日,要快速访问可用基础设施,和十年前已经不可同日而语了。Amazon Web ServicesGoogle Cloud 等解决方案为大多数用例提供了实用且维护良好的解决方案,只需单击几下即可安全使用。然而,这类解决方案大多不是开源的,这就意味着有些技术和行为是不透明的。

除了选择基础设施解决方案之外,最困难的部分可能是找到有效、安全地开发和部署软件的好方法。安全方面有时会被严重低估,因为人们可能认为 CI/CD 管道不需要具有与为生产用例构建的设置相同的安全约束,这对于一些特别安全的本地环境来说,可能是正确的。但在 Kubernetes 的世界中,我们希望忽略专有基础设施相关的限制,轻松地移动我们的应用程序。考虑到这一点,如果我们要在测试集群或者生产环境来提供服务应用,甚至是开发CI/CD软件本身,就必须为整体部署的每个部分都独立的提供安全模式。

大规模的服务都需要使用容器进行部署,因此所有的应用程序都需要打在容器镜像中。那么就会出现这样的问题:“基于容器的部署应该是什么样子?”、“哪种基础发行版最适合我的应用程序?” 或“这些安全问题与容器和镜像有何关系?” 为了回答这些问题,我们将从头开始,然后通过我们自己的尝试来深入了解容器镜像的光明世界!

二、简史

前面的博文有提到过,在 Docker 产生之前 ,容器的基本概念已经流行了一段时间;尽管如此,Docker 还是在2013年成为了第一个能够将容器打包到镜像中的工具,可以让容器镜像在机器之间移动,这也标志着基于容器的应用程序部署的诞生。

docker-image.png

截至目前,容器镜像格式存在着多个版本。Docker 开发人员早在 2016 年就决定弃用版本1,而是创建镜像清单的 版本 2 (V2) 架构1。2017年在经过不同镜像格式迭代之后,版本 2 (V2) 架构2又取代了架构1。架构 2 使用镜像模型并对镜像配置进行哈希生成镜像的 ID,实现了以下两个主要目标:

V2 镜像规范后来被捐赠给开放容器倡议 (OCI),成为了 OCI 镜像规范的基础。来到2017年,OCI 镜像格式 v1.0.0 发布了,让我们来看看它到底是什么。

三、OCI 镜像规范

oci-logo

一般来说,OCI 容器镜像由所谓的 manifest(清单) 、配置、层集和可选的镜像索引组成。这就是我们描述容器镜像所需的全部内容,其中除了层信息之外,其他所有内容都是用 JSON 编写的

在镜像创建过程中,规范的所有部分将被捆绑在一起,形成一个单一制品——容器镜像。之后,可以从容器注册表(Container Registry )等网络资源服务去下载该容器镜像,也可以将其打成 tar 包来进行分发。

拉取并保存镜像

接下来,我们使用容器镜像工具 skopeo 检查镜像的内容(之前文章中也有使用到):

> skopeo copy docker://saschagrunert/mysterious-image oci:mysterious-image

执行上边的指令,可以将镜像从远端拉下来,并存储到本地,就在当前目录下的 mysterious-image 目录内。而在命令输出中,可以看到从远端注册表服务拉取不同的 blob(块),并且有前面提到的 manifest (清单) 和配置。这也为我们提供了有关镜像如何远程存储的信息

container-image-local

**但我们究竟下载了什么?**现在,我们了解一下镜像内部结构:

> tree mysterious-image
mysterious-image
├── [4.0K]  blobs/
│   └── [4.0K]  sha256/
│       ├── [2.7M]  0503825856099e6adb39c8297af09547f69684b7016b7f3680ed801aa310baaa
│       ├── [ 120]  6d8c9f2df98ba6c290b652ac57151eab8bcd6fb7574902fbd16ad9e2912a6753
│       ├── [ 502]  703c711516a88a4ec99d742ce59e1a2f0802a51dac916975ab6f2b60212cd713
│       └── [ 850]  8b2633baa7e149fe92e6d74b95e33d61b53ba129021ba251ac0c7ab7eafea825
├── [ 235]  index.json
└── [  31]  oci-layout

每个 blob 都保存在单层次目录中,该目录以 sha256 结果作为名字。另外,镜像索引 (index.json) 和 oci-layout 分别单独存储。后者现在可以忽略,因为它只包含镜像的布局版本:{"imageLayoutVersion": "1.0.0"}

镜像索引

首先,让我们检查一下镜像索引 index.json 下载的镜像包含哪些内容(前面也已经提到,镜像索引是完全可选的):

> jq . mysterious-image/index.json

通过解析JSON,可以看到 index.json 好像只是一个更高级别的清单,包含指向更具体的镜像清单的指针:

index-json-content

因为我们上边使用 skopeo copy 的目标是 oci:xxxx,表示存成 OCI 镜像清单规范,因此其媒体类型 mediaType 是application/vnd.oci.image.manifest.v1+json。而当涉及到 Docker 的多架构镜像时,它的工作原理与 OCI 镜像基本相同,只是它们的媒体类型是 application/vnd.docker.distribution.manifest.list.v2+json

在镜像拉取过程中,Docker 会根据主机架构自动选择正确的清单,这在多架构镜像场景下发挥了巨大作用。

镜像清单

让我们看一下索引中链接到的镜像清单:

> jq . mysterious-image/blobs/sha256/703c711516a*

镜像清单提供了特定架构和操作系统的单个容器镜像的配置层集。顾名思义,该 size 字段表示对象的总体大小。

{
  "schemaVersion": 2,
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:8b2633baa7e149fe92e6d74b95e33d61b53ba129021ba251ac0c7ab7eafea825",
    "size": 850
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:0503825856099e6adb39c8297af09547f69684b7016b7f3680ed801aa310baaa",
      "size": 2789742
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:6d8c9f2df98ba6c290b652ac57151eab8bcd6fb7574902fbd16ad9e2912a6753",
      "size": 120
    }
  ]
}

配置

我们可以看看有关镜像配置的更多信息,如下所示:

> jq . mysterious-image/blobs/sha256/8b2633baa7e1*

在 JSON 的顶部,我们可以找到一些元数据,如:创建日期 created、镜像架构 architecture、操作系统 os,以及 config 中的环境 Env、要执行的命令 Cmdconfig 部分可以包含更多参数,例如工作目录 WorkingDir、登入用户 User、以及 Entrypoint。这意味着如果你通过 Dockerfile 创建一个容器镜像,所有这些参数都会被转换成基于 JSON 的配置:

  "created": "2019-08-09T12:09:13.129872299Z",
  "architecture": "amd64",
  "os": "linux",
  "config": {
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ],
    "Cmd": [
      "/bin/sh"
    ]
  }

OCI 镜像只是根文件系统变更以及相关的关执行参数的有序集合,我们可以看到 history 中,记录了镜像创建过程中执行的操作。比如,上边 JSON 中 history 字段中有描述:首先在基础镜像中添加了一个文件,并按顺序执行了 echo 和命令,然后 touchempty_layer布尔值用于标记历史项是否创建了文件系统差异:

"history": [
    {
      "created": "2019-07-11T22:20:52.139709355Z",
      "created_by": "/bin/sh -c #(nop) ADD file:0eb5ea35741d23fe39cbac245b3a5d84856ed6384f4ff07d496369ee6d960bad in / "
    },
    {
      "created": "2019-07-11T22:20:52.375286404Z",
      "created_by": "/bin/sh -c #(nop)  CMD [\"/bin/sh\"]",
      "empty_layer": true
    },
    {
      "created": "2019-08-09T14:09:12.848554218+02:00",
      "created_by": "/bin/sh -c echo Hello",
      "empty_layer": true
    },
    {
      "created": "2019-08-09T12:09:13.129872299Z",
      "created_by": "/bin/sh -c touch my-file"
    }
  ]

该配置还表明 rootfs 被拆分为 layers,而 diff_ids 与实际层摘要不同,因为它们引用了未压缩 tar 档案的摘要。

  "rootfs": {
    "type": "layers",
    "diff_ids": [ 
      "sha256:1bfeebd65323b8ddf5bd6a51cc7097b72788bc982e9ab3280d53d3c613adffa7",
      "sha256:56e2c46a3576a8c1a222f9a263dc57ae3c6b8baf6a68d03065f4b3ea6c0ae8d1"
    ]
  }

上边最后 rootfs 的配置中,表明各层的最终形式主要还是 tar 包。这些包可以使用 GNU tar 等传统工具解压和提取。将不同的层分开,意味着我们可以并行下载各层,但在提取时必须按照顺序。从远端拉取镜像的过程需要时间,这也是一个能被我们明显感知到的缺点。

现在,让我们观察第一层,我们期望它是根文件系统(rootfs):

> mkdir rootfs 
> tar -C rootfs -xf mysterious-image/blobs/sha256/05038258*
> ls rootfs/ 
./  ../  bin/  dev/  etc/  home/  lib/  media/  mnt/  opt/  proc/  root/  run/  sbin/  srv/  sys/  tmp/  usr/  var/

这里边的目录布局,看起来像一个真正的文件系统。实际上也确实如此,因此我们可以轻松检查容器镜像的基础发行版信息,比如看一下操作系统的版本:

> cat rootfs/etc/os-release 
NAME="Alpine Linux" 
ID=alpine 
VERSION_ID=3.10.1 
PRETTY_NAME="Alpine Linux v3.10" 
HOME_URL="https://alpinelinux.org/" 
BUG_REPORT_URL="https:// /bugs.alpinelinux.org/”

接下来看看其他层里有什么。正如我们在镜像配置文件 history 一节中看到的那样,这个层里边只包含一个刚刚通过 touch my-file 添加的文件:

> mkdir layer
> tar -C layer -xf mysterious-image/blobs/sha256/6d8c9f*
> tree layer
layer
└── [   0]  my-file

上边的操作,实际是我们对 Dockerfile 构建镜像进行的逆向工程。据此,我们可以恢复的出这个镜像对应的 Dockerfile 内容,可能如下所示:

FROM alpine:latest
RUN echo Hello
RUN touch my-file

我们的例子比较简单,通过 layer 就能复原出 Dockerfile。但你要清楚,Docker 并不是能唯一构建镜像的工具。容器镜像构建器中的一颗后起之秀是 buildah,它很好地遵循了 Unix 哲学——专注于构建 OCI 容器镜像这单个专用任务。

四、buildah

buildah-logo

早在 2017 年,红帽公司就决定构建一个新的开源工具来创建 OCI 镜像。于是,buildah 就诞生了,它提供了目前构建容器镜像时最灵活的解决方案之一。像 SUSE 这样的公司也决定为 buildah 做出贡献,将其集成到他们的容器生态系统中,并为基于 Docker 的镜像构建提供维护良好的替代方案。(注:因为原文作者是SUSE的,所以在这里有侧重)

如果我们要从 Docker 世界迁移到使用 buildah,肯定可以使用标准 Dockerfile 和 buildah 来构建容器镜像。我们以上边复原的 Dockerfile 为例,可以通过buildah bud在同一目录中执行来构建它:

> buildah bud

buildah-bud

这个输出跟使用 docker build 指令得到数据有些类似。buildah 提供了很多子命令,例如:

运行与构建容器

等等,列出正在运行的容器?buildah 不就是一个容器构建工具而不是容器运行工具吗?

现在来解答这个疑问:如果使用 docker 或 buildah 构建容器镜像,实际上会生成中间容器,这些容器将在运行时进行修改,而不单单是文件的操作。每一步修改,都会创建一个新的 histroy 条目,而在文件系统被修改后会创建新层

构建容器时生成中间容器这个动作会有安全问题:只需劫持构建过程(通过 docker exec 进入到容器)就可以在正在构建的容器中注入任何内容。

这么说有些抽象,我们通过一个具体的例子来看一下。假设我们有一个 Dockerfile,其中包含非常耗时的一些操作,例如安装 clang

FROM debian:buster
RUN apt-get update -y && apt-get install -y clang

在构建镜像的安装过程中,我们可以看到有一个 docker 容器在运行。而我们可以直接通过 docker exec 指令,进入那个容器并做一些变更:

exec-command-when-build-image

我们可以在当前构建的容器中进行任何容器修改。成功构建 docker 容器镜像后,我们可以验证创建的镜像是否 my-file 存在:

exec-command-after-build-image

这种劫持的一个严重副作用是:我们执行的 echo 命令,不是容器命令历史记录的一部分,而是运行 apt-get 指令时创建的层的一部分。

无 Dockerfile 构建容器

Buildah 为我们可以在 Dockerfile 中执行的每个可用命令提供专用子命令,例如 RUNCOPY。相比于单个、巨大、爆炸性 Dockerfile,buildah 的这些子命令带来了巨大的灵活性。我们可以在不同的容器镜像构建步骤之间使用我们可以想象到的每个 UNIX 工具,来操作目标容器文件系统或在其本机环境中运行任务,并使用其输出来执行其他容器构建步骤。

让我们在 Alpine Linux 之上创建一个新的基础容器来尝试一下:

> buildah from alpine:latest
alpine-working-container

我们现在有一个正在运行的工作容器,名为 alpine-working-container,我们可以使用 buildah ps 列出它:

> buildah ps
CONTAINER ID  BUILDER  IMAGE ID     IMAGE NAME                       CONTAINER NAME
d43d3ff6a4b9     *     8ca4688f4f35 docker.io/library/alpine:latest  alpine-working-container

现在,我们可以在该容器中运行命令,例如这个命令来证明它实际上是一个 Alpine Linux 发行版,或者在容器内创建一个新文件:

> buildah run alpine-working-container cat /etc/os-release
> echo test > file
> buildah copy alpine-working-container file /test-file

buildah-ps-run

默认情况下,buildah 不会在容器中创建新的历史记录条目,这意味着命令的顺序或调用频率对生成的镜像的层没有影响。值得一提的是,可以通过添加 --add-history 命令行标志或设置 BUILDAH_HISTORY=true 环境变量来更改此行为。

使用 commit 来完成我们构建容器的过程,这样操作之后,新的容器镜像 my-image 应该在本地注册表中可用:

> buildah commit alpine-working-container my-image
> buildah images

buildah-commit

使用 buildah push 能够将容器镜像推送到 Docker 注册表服务或本地磁盘 OCI 格式,如下所示:

> buildah push my-image oci:my-image

上边的操作会在当前目录下创建一个 my-image 目录,其中包含完全 OCI 兼容的镜像布局,其中包含:镜像索引配置、一组清单

> tree -L 1 my-image/
my-image/
├── [ 4.0K]  blobs/
├── [  186]  index.json
└── [   31]  oci-layout

1 directory, 2 files
> jq . my-image/index.json 

重要的是,我们还能从这个目录拉回镜像。为了这一点,我们先将镜像从本地注册表中删除,然后再执行拉取操作:

> buildah rmi my-image 
> buildah images
> buildah pull oci:my-image

buildah-rmi

我们 commit 容器之后,仍然可以继续修改 alpine-working-container 容器并继续提交中间状态,这意味着一个镜像的构建结果可以由另一个镜像重复使用。这对于构建多阶段容器镜像非常有用,同时也简化了容器的构建过程,因为简单的 Bash 交互比单个 Dockerfile 具有更多的功能和灵活性。

再次强调,这里的 alpine-working-container 是容器名称,而不是镜像名称。 下边的通过交互式的 shell 来检查刚才的修改仍然可用(因为进入的是容器,而不是从原来的镜像重新加载):

> buildah run -t alpine-working-container sh
/ # ls
bin        etc        lib        mnt        proc       run        srv
test-file  usr        dev        home       media      opt        root
sbin       sys        tmp        var
/ # cat test-file
test

**使用 buildah,甚至可以在本地挂载容器文件系统,以摆脱 Docker 守护进程的构建上下文限制。**所以,我们可以做这样的事情:

> buildah unshare
> export MOUNT=$(buildah mount alpine-working-container)
> echo it-works > "$MOUNT"/test-from-mount
> buildah commit alpine-working-container my-new-image

为了更好的理解“摆脱 Docker 守护进程的构建上下文限制”,对上边指令做一下简单说明:

就这样,我们通过本地挂载成功修改了容器文件系统!test-from-mount 我们可以通过测试我们在工作容器中创建的文件来验证这一点。

> buildah run -t alpine-working-container cat test-from-mount 
it-works

我们看看这个 $MOUNT 环境变量,存储的内容指向的是一个 overlay 的挂载。而 overlay 的挂载说明,可以通过 man mount 来查看,这里就不做说明。

mount-link

使用这种方法的一大好处是我们不必复制所有数据,例如将构建上下文发送到 Docker 守护进程时。这在慢速硬件上可能会很耗时,并且在本地运行守护进程的机器上不会有任何好处(什么意思?)

现在,让我们做一些疯狂的事情,看看我们是否可以摆脱与 Docker-in-Docker 相关的问题——为了构建镜像而将docker.sock 挂载到容器内部。

buildah 中运行 buildah

作者原文用的小标题是"buildah’ception"。这个**“ception”**后缀是源自电影《盗梦空间》(Inception),用于表示嵌套或递归的概念。本小节,主要就是介绍在 buildah 之上运行的容器中运行 buildah

Buildah 没有任何守护进程,这样能通过降低项目的整体复杂性,来最大限度的降低安全面的攻击。没有守护进程,也意味着不必挂载 docker.sock 到容器,就可以使用 Docker 的命令行界面 (CLI) 执行某些操作。这让 buildah 的使用变得比较简单,尤其是在跨越 CI/CD 和生产环境之间边界的项目,能够完全避免反模式(anti-patterns)以保持安全基础。

接下来,我们来实践一下在 buildah 中运行 buildah。为此,我们首先在容器中安装 buildah,而该容器已经由 buildah 运行:

> buildah from ubuntu:22.04
ubuntu-working-container
> buildah run -t ubuntu-working-container bash # 以下会进入容器内
# apt update
# apt install -y buildah curl

现在,buildah 应该可以在该容器中使用了。请注意,我们必须选择 vfs 容器内的存储驱动程序才能拥有工作的文件系统堆栈(以下是在 容器ubuntu-working-container 中继续运行 buildah):

# buildah --storage-driver=vfs from alpine:latest
alpine-working-container
# buildah --storage-driver=vfs commit alpine-working-container my-image

我这里操作完之后,又重进容器操作一遍,会省掉一些中间打印,看上去更形象。我们在容器镜像内构建了一个容器镜像,没有任何额外的技巧:

buildah-ception-1

但**如何运行该镜像呢?**在生产用例中,我们会将镜像推送到注册表中,但出于演示目的,我们还是直接访问容器存储以获取本地计算机上的镜像。

首先,将上边产生的镜像推送到容器中目录 /my-image

# buildah --storage-driver=vfs push my-image oci:my-image
buildah-ception-2

然后,我们退出内部容器,并通过挂载其文件系统将构建的镜像复制出正在运行的工作容器:

> buildah unshare
> export MOUNT=$(buildah mount ubuntu-working-container)
> cp -R $MOUNT/my-image .
> buildah unmount ubuntu-working-container

我们现在可以将镜像从目录直接拉入 buildah 的本地注册表:

> buildah pull oci:my-image 
> buildah images my-image

我们在容器中构建了容器,并且在外部运行了内部容器:

buildah-ception-3

Buildah 的另一个重要功能我们之前一直没有提到:在整个演示过程中,没有使用过任何类似 sudo 的调用,而是完全运行在无根(rootless)模式,这意味着所有必要的操作都是使用当前用户的权限完成的。

Buildah 为每个用户创建专用的存储和配置目录,按照用户区分配置,可以增强安全。这些目录可以在本地计算机上找到:

Buildah 还有一些我们尚未介绍的功能,例如一个很好的 Dockerfile CPP 预处理器宏支持,可以将单个 Dockerfile 分解为多个 Dockerfile。

podman 作为 buildah 的接口

Podman 的目标是成为 Docker 的直接替代品。我们在这里不再占用时间和篇幅来详细介绍它。尽管如此,podman 的 podman build 指令,是通过 buildah 作为 API 来提供原生 Dockerfile 构建支持。

这意味着它在后台共享相同的存储来运行使用 buildah 构建的容器。这相当简洁,因此我们实际上可以像这样运行之前构建的容器,而且值得再次一提的是,它也以无根模式运行

> podman run -it my-image sh
/ # ls
bin    dev    etc    home   lib    media  mnt    opt    proc
root   run    sbin   srv    sys    tmp    usr    var

五、结论

本文是容器揭秘的第三篇,我们探索了容器镜像的简史,并有演示了使用 buildah 构建容器。现在,各位读者应该对容器镜像的组成有了基本了解。

这对于深入了解接下来的容器安全以及 Kubernetes 工作原理等主题是必要的。当然,我们没有机会详细讨论 OCI 镜像规范或 buildah 的每个功能,后边有机会,可以再做深入探讨。