每个Linux容器都是基于一个镜像。镜像是重新构建为运行中容器的基本定义,就像虚拟磁盘在启动时成为虚拟机一样。Docker或Open Container Initiative (OCI)镜像为你使用Docker部署和运行的所有内容提供基础。要启动一个容器,你必须要么下载一个公共镜像,要么创建自己的镜像。你可以把镜像看作是一个主要代表容器文件系统的单一资产。然而,在现实中,每个镜像由一个或多个关联的文件系统层组成,这些层通常与用于创建该镜像的每个构建步骤具有直接的一对一映射。
由于镜像是由单独的层构建而成的,它们对Linux内核提出了特殊的要求,内核必须提供Docker运行存储后端所需的驱动程序。对于镜像管理,Docker在很大程度上依赖于这个存储后端,它与底层Linux文件系统通信,以构建和管理合并成一个可用镜像的多个层。支持的主要存储后端包括以下几种:
- Overlay2
- B-Tree文件系统(Btrfs)
- Device Mapper
每个存储后端为镜像管理提供了快速的写时复制(CoW)系统。我们将在第11章中讨论各种后端的具体情况。目前,我们将使用默认后端并探索镜像的工作原理,因为它们构成了你使用Docker几乎所有其他操作的基础,包括:
- 构建镜像
- 将镜像上传(推送)到镜像注册表
- 从镜像注册表下载(拉取)镜像
- 使用镜像创建和运行容器。
Dockerfile的构造要素
为了使用默认工具创建自定义Docker镜像,你需要熟悉Dockerfile。这个文件描述了创建镜像所需的所有步骤,通常包含在你的应用程序源代码库的根目录中。
一个典型的Dockerfile可能看起来像下面这个例子,它创建一个用于Node.js应用程序的容器:
FROM node:18.13.0
ARG email="anna@example.com"
LABEL "maintainer"=$email
LABEL "rating"="Five Stars" "class"="First Class"
USER root
ENV AP /data/app
ENV SCPATH /etc/supervisor/conf.d
RUN apt-get -y update
# The daemons
RUN apt-get -y install supervisor
RUN mkdir -p /var/log/supervisor
# Supervisor Configuration
COPY ./supervisord/conf.d/* $SCPATH/
# Application Code
COPY *.js* $AP/
WORKDIR $AP
RUN npm install
CMD ["supervisord", "-n"]
解剖这个Dockerfile会让你初步了解一些可能用于控制如何组装镜像的指令。Dockerfile中的每一行都会创建一个新的镜像层,由Docker存储。这个层包含了由该命令发出所导致的所有更改。这意味着当你构建新的镜像时,Docker只需要构建与之前构建不同的层:你可以重用所有没有改变的层。
虽然你可以从一个普通的基本Linux镜像构建一个Node实例,但你也可以在Docker Hub上寻找Node的官方镜像。Node.js社区维护了一系列Docker镜像和标签,允许你快速确定哪些版本是可用的。如果你想将镜像锁定到Node的特定版本点,可以指向像node:18.13.0这样的镜像。以下的基础镜像会提供一个运行Node 11.11.x的Ubuntu Linux镜像:
FROM docker.io/node:18.13.0
ARG参数提供了一种设置变量及其默认值的方式,这些变量仅在镜像构建过程中可用:
ARG email="anna@example.com"
为镜像和容器应用标签允许你通过键值对添加元数据,这些元数据可以后续用于搜索和识别Docker镜像和容器。你可以使用docker image inspect命令查看应用到任何镜像的标签。对于maintainer标签,我们正在利用前面Dockerfile中定义的email构建参数的值。这意味着每当我们构建这个镜像时,这个标签的值可以被更改:
LABEL "maintainer"=$email
LABEL "rating"="Five Stars" "class"="First Class"
默认情况下,Docker在容器内以root用户身份运行所有进程,但你可以使用USER指令来更改这一点:
USER root
与ARG指令不同,ENV指令允许你设置可以在运行时由你的应用程序用于配置的shell变量,除了在构建过程中可用。ENV和ARG指令可以用来简化Dockerfile并帮助保持DRY原则(不要重复自己):
ENV AP /data/app
ENV SCPATH /etc/supervisor/conf.d
在下面的代码中,你将使用一系列的RUN指令来启动和创建所需的文件结构,并安装一些必要的软件依赖项:
RUN apt-get -y update
# The daemons
RUN apt-get -y install supervisor
RUN mkdir -p /var/log/supervisor
COPY指令用于将文件从本地文件系统复制到您的镜像中。通常,这将包括您的应用程序代码和任何所需的支持文件。由于COPY将文件复制到镜像中,一旦构建完成,您就不再需要访问本地文件系统来访问它们。您还将开始使用在前一节中定义的构建变量,以节省一些工作量,并帮助防止输入错误:
# Supervisor Configuration
COPY ./supervisord/conf.d/* $SCPATH/
# Application Code
COPY *.js* $AP/
使用WORKDIR指令,您可以在镜像中更改工作目录,以用于后续的构建指令和在任何生成的容器中启动的默认进程:
WORKDIR $AP
RUN npm install
最后,您使用CMD指令定义启动容器内所需运行的进程的命令:
CMD ["supervisord", "-n"]
构建镜像(Building an Image)
要构建您的第一个镜像,请继续并克隆一个包含示例应用程序 docker-node-hello 的 Git 存储库,如下所示:
$ git clone https://github.com/spkane/docker-node-hello.git \
--config core.autocrlf=input
Cloning into 'docker-node-hello'…
remote: Counting objects: 41, done.
remote: Total 41 (delta 0), reused 0 (delta 0), pack-reused 41
Unpacking objects: 100% (41/41), done.
$ cd docker-node-hello
这将会下载一个包含工作的 Dockerfile 和相关源代码文件的存储库,存储库名为 docker-node-hello。如果您忽略 Git 存储库目录并查看其内容,您应该会看到如下所示的内容:
$ tree -a -I .git
.
├── .dockerignore
├── .gitignore
├── Dockerfile
├── index.js
├── package.json
└── supervisord
└── conf.d
├── node.conf
└── supervisord.conf
让我们来回顾一下存储库中最相关的文件。 Dockerfile 应该与您刚刚审查的文件相同。 .dockerignore 文件允许您定义在构建镜像时不希望上传到Docker主机的文件和目录。在这个例子中,.dockerignore 文件包含以下行:
.git
这指示 docker image build 在构建过程中排除 .git 目录,该目录包含整个源代码仓库。其余的文件反映了您当前所选分支的源代码状态。构建Docker镜像时不需要 .git 目录的内容,而且由于随着时间的推移,该目录可能会变得非常庞大,您不希望在每次构建时都浪费时间复制它。
package.json 定义了 Node.js 应用程序,并列出了其所依赖的任何库。index.js 是应用程序的主要源代码。
supervisord 目录包含 supervisord 的配置文件,您将使用这些配置文件来启动和监控应用程序。
正如我们在第三章中讨论的那样,您需要在构建Docker镜像之前确保Docker服务器正在运行,并且您的客户端正确设置以与其通信。假设这一切都正常工作,您应该能够通过运行接下来的命令来启动一个新的构建过程,该命令将根据当前目录中的文件构建并打上标签。
以下输出中标识的每个步骤直接对应Dockerfile中的一行,并且每个步骤都基于前一个步骤创建一个新的镜像层。第一次构建可能需要几分钟,因为您需要下载基本的node镜像。后续的构建应该会快得多,除非发布了我们基本镜像标签的新版本。
在构建命令的末尾,您会注意到一个句点。这表示构建上下文,它告诉Docker应该上传哪些文件到服务器,以便构建我们的镜像。在许多情况下,您将在构建命令的末尾看到一个句点,因为单个句点代表当前目录。这个构建上下文就是 .dockerignore 文件所过滤的内容,这样我们就不会上传比需要的更多的文件。
让我们运行构建命令:
$ docker image build -t example/docker-node-hello:latest .
=> [internal] load build definition from Dockerfile
=> => transferring dockerfile: 37B
=> [internal] load .dockerignore
=> => transferring context: 34B
=> [internal] load metadata for docker.io/library/node:18.13.0
=> CACHED [1/8] FROM docker.io/library/node:18.13.0@19a9713dbaf3a3899ad…
=> [internal] load build context
=> => transferring context: 233B
=> [2/8] RUN apt-get -y update
=> [3/8] RUN apt-get -y install supervisor
=> [4/8] RUN mkdir -p /var/log/supervisor
=> [5/8] COPY ./supervisord/conf.d/* /etc/supervisor/conf.d/
=> [6/8] COPY *.js* /data/app/
=> [7/8] WORKDIR /data/app
=> [8/8] RUN npm install
=> exporting to image
=> => exporting layers
=> => writing image sha256:991844271ca5b984939ab49d81b24d4d53137f04a1bd…
=> => naming to docker.io/example/docker-node-hello:latest
如果您在用于其他并行进程的系统上构建Docker镜像,可以通过使用我们在第5章中将讨论的许多相同的cgroup方法来限制构建可用的资源。您可以在官方文档中找到有关docker image build参数的详细文档。
如果在构建过程中遇到任何问题,您可能希望直接跳到本章的“多阶段构建”和“故障排除破损构建”部分进行阅读。
运行镜像
一旦成功构建了镜像,您可以使用以下命令在Docker主机上运行它:
$ docker container run --rm -d -p 8080:8080 example/docker-node-hello:latest
该命令告诉Docker使用带有示例/docker-node-hello:latest标签的镜像在后台创建一个正在运行的容器,然后将容器中的端口8080映射到Docker主机的端口8080。如果一切按预期进行,新的Node.js应用程序应该在主机上的一个容器中运行。您可以通过运行docker container ls来验证这一点。为了查看正在运行的应用程序,您需要在Web浏览器中打开并指向Docker主机的8080端口。通常,您可以通过检查标有星号的docker context list条目或者检查DOCKER_HOST环境变量的值(如果设置了)来确定Docker主机的IP地址。如果DOCKER_HOST设置为Unix套接字,则IP地址很可能是127.0.0.1:
$ docker context list
NAME TYPE … DOCKER ENDPOINT …
default * moby … unix:///var/run/docker.sock …
…
获取IP地址并在您的Web浏览器地址栏中输入类似http://127.0.0.1:8080/(或者如果您的远程Docker地址不同,使用它),或者使用类似curl的命令行工具。您应该会看到以下文本:
Hello World. Wish you were here.
构建参数
如果您检查我们构建的镜像,您将看到维护者标签被设置为anna@example.com:
$ docker image inspect \
example/docker-node-hello:latest | grep maintainer
"maintainer": "anna@example.com",
如果我们想要更改维护者标签,我们只需重新运行构建,并通过 --build-arg 命令行参数提供新的 email ARG 值,如下所示:
$ docker image build --build-arg email=me@example.com \
-t example/docker-node-hello:latest .
…
=> => naming to docker.io/example/docker-node-hello:latest
构建完成后,我们可以通过重新检查新的镜像来查看结果:
$ docker image inspect \
example/docker-node-hello:latest | grep maintainer
"maintainer": "me@example.com",
ARG和ENV指令可以使Dockerfile非常灵活,同时避免许多重复的值,这些值可能很难保持更新。
将环境变量作为配置信息
如果您阅读index.js文件,您会注意到文件的一部分引用了变量$WHO,应用程序使用该变量来确定向谁打招呼:
var DEFAULT_WHO = "World";
var WHO = process.env.WHO || DEFAULT_WHO;
app.get('/', function (req, res) {
res.send('Hello ' + WHO + '. Wish you were here.\n');
});
让我们快速了解如何在启动应用程序时通过传入环境变量进行配置。首先,您需要使用两个命令停止现有的容器。第一个命令将提供容器ID,您需要在第二个命令中使用它:
$ docker container ls
CONTAINER ID IMAGE STATUS …
b7145e06083f example/centos-node-hello:latest Up 4 minutes …
然后,使用上一个输出中的容器ID,您可以通过输入以下命令停止正在运行的容器:
$ docker container stop b7145e06083f
b7145e06083f
然后,在先前的 docker container run 命令中添加一个 --env 参数的实例后,您可以重新启动容器:
$ docker container run --rm -d \
--publish mode=ingress,published=8080,target=8080 \
--env WHO="Sean and Karl" \
example/docker-node-hello:latest
如果重新加载您的Web浏览器,您应该会看到网页上的文本现在如下所示:
Hello Sean and Karl. Wish you were here.
您现在可以停止该容器了,使用 docker container stop 命令并传入正确的容器ID。
自定义基础镜像
基础镜像是其他Docker镜像的最底层镜像。通常,这些基础镜像基于类似Ubuntu、Fedora或Alpine Linux等Linux发行版的最小安装,但它们也可以更小,仅包含一个静态编译的二进制文件。对大多数人来说,使用他们喜爱的发行版或工具的官方基础镜像是一个不错的选择。
然而,有时候最好构建自己的基础镜像,而不是使用别人创建的镜像。这样做的一个原因是为了在硬件、虚拟机和容器的所有部署方法中保持一致的操作系统镜像。另一个原因是大大减小镜像大小。例如,如果您的应用程序是一个静态构建的C或Go应用程序,那么没有必要运送整个Ubuntu发行版。您可能只需要用于调试的工具和其他一些Shell命令和二进制文件。努力构建这样一个镜像可能会在部署时间上产生好处,并更容易分发应用程序。
在这两种方法之间的常见折衷方案是使用Alpine Linux构建镜像,它被设计得非常小,且作为Docker镜像的基础而受欢迎。为了保持发行版的体积非常小,Alpine Linux基于现代、轻量级的musl标准库,而不是传统的GNU C Library(glibc)。总体而言,这不是一个大问题,因为许多软件包支持musl,但需要注意的是,它对基于Java的应用程序和DNS解析有较大的影响。然而,由于其小巧的镜像大小,Alpine Linux在生产中被广泛使用。Alpine Linux在空间上进行了高度优化,这也是其默认安装了/bin/sh而不是/bin/bash的原因。但是,如果需要,您也可以在Alpine Linux上安装glibc和bash,在JVM容器的情况下通常会这样做。
在官方Docker文档中,有一些关于如何在不同的Linux发行版上构建基础镜像的好资料。
存储镜像
现在您已经创建了一个满意的Docker镜像,您希望将其存储在某个地方,以便任何想要部署该镜像的Docker主机可以轻松访问。这也是构建镜像和将其存储在未来部署的正常交接点。通常情况下,您不会在生产服务器上构建镜像然后运行它们。在我们讨论应用程序部署团队之间的交接时,已经描述了这个过程。通常,部署是从存储库拉取镜像并在一个或多个Linux服务器上运行的过程。有几种方法可以将您的镜像存储到中央存储库中,以便轻松检索。
公共镜像仓库
Docker提供了一个镜像仓库,供社区共享公开镜像。这些镜像包括Linux发行版的官方镜像、即可使用的WordPress容器等等。
如果您有可以发布到互联网的镜像,最佳选择是使用公共镜像仓库,比如Docker Hub。然而,还有其他选择。当Docker的核心工具开始流行时,Docker Hub并不存在。为了填补社区中这个明显的空白,Quay.io应运而生。此后,Quay.io经历了几次收购,现在由Red Hat拥有。谷歌等云供应商和GitHub等SaaS公司也提供了自己的镜像仓库服务。在这里,我们将只讨论其中的两个。
Docker Hub和Quay.io都提供了可从互联网的任何地方访问的集中式Docker镜像仓库,并提供存储私有镜像的方法。两者都有良好的用户界面,并具有分离团队访问权限和管理用户的功能。同时,它们也提供了合理的商业选择,用于私有SaaS托管镜像,类似GitHub在其系统上销售私有镜像仓库。如果您对Docker感兴趣,并且尚未发布足够的代码需要内部托管解决方案,这可能是正确的第一步。
对于大量使用Docker的公司来说,这些仓库最大的缺点之一是它们不属于部署应用程序的网络本地。这意味着为了部署一个应用程序,可能需要将每个部署的每个层从互联网上拉取。互联网延迟对软件部署有实质性的影响,而影响这些仓库的中断可能会对公司顺利和按计划部署的能力产生非常有害的影响。这可以通过良好的镜像设计来缓解,其中您可以制作易于在互联网上移动的薄层。
私有镜像仓库
许多公司考虑的另一种选择是在内部托管一种Docker镜像仓库,该仓库可以与Docker客户端交互,支持推送、拉取和搜索镜像。开源的Distribution项目提供了大多数其他镜像仓库构建的基本功能。
在私有镜像仓库领域的其他强大竞争者包括Harbor和Red Hat Quay。除了基本的Docker镜像仓库功能外,这些产品还具有稳健的GUI界面和许多附加功能,如镜像验证。
向镜像仓库进行身份验证
与存储容器镜像的镜像仓库通信是使用Docker的日常工作的一部分。对于许多镜像仓库来说,这意味着您需要进行身份验证才能访问镜像。但Docker也试图让自动化变得简单,它会存储您的登录信息,并在您请求拉取私有镜像等操作时代替您使用这些信息。默认情况下,Docker假设镜像仓库将是Docker Hub,这是由Docker,Inc.托管的公共仓库。
创建一个Docker Hub账号
在这些示例中,您将在Docker Hub上创建一个账号。您不需要账号来下载公开共享的镜像,但是为了避免速率限制并上传您构建的任何容器,您需要登录账号。
要创建您的账号,请使用您选择的Web浏览器导航到Docker Hub的网站。从那里,您可以通过现有的账号登录,或者根据您的电子邮件地址创建一个新的登录账号。创建账号后,Docker Hub会向您在注册时提供的地址发送一封验证邮件。您应该立即登录您的电子邮件账号,点击邮件内的验证链接,以完成验证过程。
此时,您已经创建了一个公共仓库,您可以将新的镜像上传到该仓库。在您的个人资料图片下方有一个"账号设置"选项,其中有一个"默认隐私"部分,允许您将您的仓库默认可见性更改为私有,如果这是您需要的。
登录到一个镜像仓库
现在让我们使用我们的账号登录到Docker Hub镜像仓库:
$ docker login
Login with your Docker ID to push and pull images from Docker Hub. If you
don't have a Docker ID, head over to https://hub.docker.com to create one.
Username: <hub_username>
Password: <hub_password/token>
Login Succeeded
当您从服务器得到“Login Succeeded”回复时,说明您已经准备好从镜像仓库拉取镜像了。但是背后发生了什么?事实证明,Docker已经在您的主目录中为您写入了一个dotfile(隐藏文件),以缓存此信息。权限设置为0600,以防其他用户读取您的凭证信息,这是出于安全考虑。您可以使用类似下面的命令检查该文件:
$ ls -la ${HOME}/.docker/config.json
-rw-------@ 1 … 158 Dec 24 10:37 /Users/someuser/.docker/config.json
$ cat ${HOME}/.docker/config.json
在Linux上,您可能会看到类似以下内容:
{
"auths": {
"https://index.docker.io/v1/": {
"auth":"cmVsaEXamPL3hElRmFCOUE=",
"email":"someuser@example.com"
}
}
}
在这里,您可以看到${HOME}/.docker/config.json文件包含了用户名为someuser@example.com的Docker Hub凭证信息,以JSON格式存储。该配置文件支持存储多个镜像仓库的凭证信息。在这个例子中,您只有一个条目,用于Docker Hub,但如果需要,您可以添加更多条目。从现在开始,当镜像仓库需要身份验证时,Docker会查找${HOME}/.docker/config.json,以查看您是否存储了此主机名的凭证信息。如果有的话,Docker会使用它们进行认证。您会注意到这里完全缺少一个值:时间戳。这些凭证信息将被永久缓存,或者直到您告诉Docker将其删除,以先到者为准。
与登录一样,如果您不再希望缓存凭证信息,您也可以注销镜像仓库:
$ docker logout
Removing login credentials for https://index.docker.io/v1/
$ cat ${HOME}/.docker/config.json
{
"auths": {
}
}
您已经删除了缓存的凭证信息,它们将不再被Docker保存。某些版本的Docker甚至可能会在该文件为空时删除它。如果您试图登录的是Docker Hub之外的其他镜像仓库,您可以在命令行上提供主机名:
$ docker login someregistry.example.com
这将在您的${HOME}/.docker/config.json文件中添加另一个auth条目。
将镜像推送到仓库
推送镜像的第一步是确保您已登录到要使用的Docker仓库。在这个例子中,我们将重点介绍Docker Hub,所以请确保您使用您的优选凭证登录到Docker Hub:
$ docker login
Login with your Docker ID to push and pull images from Docker Hub. If you
don't have a Docker ID, head over to https://hub.docker.com to create one.
Username: <hub_username>
Password: <hub_password/token>
Login Succeeded
Logging in with your password grants your terminal complete access to
your account.
登录后,您可以上传一个镜像。之前,您使用以下命令构建了docker-node-hello镜像:docker image build -t example/docker-node-hello:latest .。
实际上,Docker客户端以及为了兼容性,许多其他容器工具,实际上将example/docker-node-hello:latest解释为docker.io/example/docker-node-hello:latest。在这里,docker.io表示镜像仓库的主机名,而example/docker-node-hello是仓库内包含所需镜像的仓库名称。
在本地构建镜像时,镜像仓库和仓库名称可以任意设置。然而,当您要上传镜像到实际的镜像仓库时,它们需要与登录凭证匹配。
您可以通过运行以下命令,并将${<myuser>}替换为您的Docker Hub用户名,轻松编辑已创建的镜像标签:
$ docker image tag example/docker-node-hello:latest \
docker.io/${<myuser>}/docker-node-hello:latest
如果您需要使用新的命名约定重新构建镜像,或者只是想试试,您可以在之前在本章中执行Git checkout时生成的docker-node-hello工作目录中运行以下命令来完成这个过程。
$ docker image build -t docker.io/${<myuser>}/docker-node-hello:latest .
…
在第一次构建时,这可能需要一些时间。如果您重新构建镜像,您可能会发现它非常快。这是因为大多数(如果不是全部)层在之前的构建中已经存在于您的Docker服务器上。我们可以通过运行以下命令快速验证我们的镜像确实存在于服务器上:docker image ls ${<myuser>}/docker-node-hello:
$ docker image ls ${<myuser>}/docker-node-hello
REPOSITORY TAG IMAGE ID CREATED SIZE
myuser/docker-node-hello latest f683df27f02d About an hour ago 649MB
此时,您可以使用docker image push命令将镜像上传到Docker仓库:
$ docker image push ${<myuser>}/docker-node-hello:latest
Using default tag: latest
The push refers to repository [docker.io/myuser/docker-node-hello]
5f3ee7afc69c: Pushed
…
5bb0785f2eee: Mounted from library/node
latest: digest: sha256:f5ceb032aec36fcacab71e468eaf0ba8a832cfc8244fbc784d0…
如果这个镜像被上传到了公共仓库,现在世界上任何人都可以通过运行docker image pull命令轻松地下载它。
$ docker image pull ${<myuser>}/docker-node-hello:latest
Using default tag: latest
latest: Pulling from myuser/docker-node-hello
Digest: sha256:f5ceb032aec36fcacab71e468eaf0ba8a832cfc8244fbc784d040872be041cd5
Status: Image is up to date for myuser/docker-node-hello:latest
docker.io/myuser/docker-node-hello:latest
在Docker Hub中探索镜像
除了简单地使用Docker Hub网站来浏览可用的镜像外,您还可以使用docker search命令来查找可能有用的镜像。
运行docker search node将返回一个包含"node"一词的镜像名称或描述的镜像列表:
$ docker search node
NAME DESCRIPTION STARS OFFICIAL AUTOMATED
node Node.js is a JavaScript-ba… 12267 [OK]
mongo-express Web-based MongoDB admin in… 1274 [OK]
nodered/node-red Low-code programming for e… 544
nodered/node-red-docker Deprecated - older Node-RE… 356 [OK]
circleci/node Node.js is a JavaScript-ba… 130
kindest/node sigs.k8s.io/kind node imag… 78
bitnami/node Bitnami Node.js Docker Ima… 69 [OK]
cimg/node The CircleCI Node.js Docke… 14
opendronemap/nodeodm Automated build for NodeOD… 10 [OK]
bitnami/node-exporter Bitnami Node Exporter Dock… 9 [OK]
appdynamics/nodejs-agent Agent for monitoring Node.… 5
wallarm/node Wallarm: end-to-end API se… 5 [OK]
…
OFFICIAL头部说明该镜像是Docker Hub上官方策划的镜像之一。这通常意味着该镜像由负责该应用程序的公司或官方开发社区维护。AUTOMATED表示该镜像是通过CI/CD流程自动构建和上传的,该流程通过提交到底层源代码仓库触发。官方镜像总是自动化的。
运行一个私有镜像仓库
遵循开源社区的精神,Docker默认鼓励社区通过Docker Hub共享Docker镜像。然而,有时由于商业、法律、镜像保留或可靠性等方面的考虑,这可能不是一个可行的选择。
在这些情况下,最好建立一个内部私有镜像仓库。设置一个基本的镜像仓库并不困难,但是对于生产环境,您应该花时间了解开源Docker Registry(Distribution)的所有可用配置选项。
在这个例子中,我们将创建一个非常简单的、使用SSL和HTTP基本认证的安全镜像仓库。
首先,让我们在我们的Docker服务器上创建一些目录和文件。如果您使用虚拟机或云实例运行Docker服务器,则需要通过SSH登录到该服务器来执行下面的命令。如果您使用的是Docker Desktop或Community Edition,则应该可以在本地系统上运行这些命令。
首先,让我们克隆一个包含设置简单认证的Docker镜像仓库所需基本文件的Git仓库:
$ git clone https://github.com/spkane/basic-registry \
--config core.autocrlf=input
Cloning into 'basic-registry'…
remote: Counting objects: 10, done.
remote: Compressing objects: 100% (8/8), done.
remote: Total 10 (delta 0), reused 10 (delta 0), pack-reused 0
Unpacking objects: 100% (10/10), done.
一旦你在本地有了这些文件,你可以切换目录并查看刚刚下载的文件:
$ cd basic-registry
$ ls
Dockerfile config.yaml.sample registry.crt.sample
README.md htpasswd.sample registry.key.sample
Dockerfile只是从Docker Hub获取上游注册表镜像,并将一些本地配置和支持文件复制到一个新的镜像中。 在测试中,你可以使用一些包含的示例文件,但请不要在生产环境中使用它们。
如果你的Docker服务器可以通过localhost(127.0.0.1)访问,则可以通过简单地像这样复制每个文件来直接使用这些文件:
$ cp config.yaml.sample config.yaml
$ cp registry.key.sample registry.key
$ cp registry.crt.sample registry.crt
$ cp htpasswd.sample htpasswd
然而,如果你的Docker服务器位于远程IP地址上,则需要进行一些额外的工作。 首先,将config.yaml.sample复制到config.yaml:
$ cp config.yaml.sample config.yaml
然后编辑config.yaml,将其中的127.0.0.1替换为你的Docker服务器的IP地址,以便:
http:
host: https://127.0.0.1:5000
变成类似这样的内容:
http:
host: https://172.17.42.10:5000
接下来,你需要为你的注册表IP地址创建一个SSL密钥对。 一种方法是使用以下OpenSSL命令。请注意,你需要在命令的这部分,/CN=172.17.42.10,设置IP地址,使其与你的Docker服务器的IP地址匹配:
$ openssl req -x509 -nodes -sha256 -newkey rsa:4096 \
-keyout registry.key -out registry.crt \
-days 14 -subj '{/CN=172.17.42.10}'
最后,你可以通过复制示例htpasswd文件来使用它:
$ cp htpasswd.sample htpasswd
或者你可以通过使用类似下面的命令来创建自己的用户名和密码对进行身份验证,将{}替换为你喜欢的值:
$ docker container run --rm --entrypoint htpasswd g \
-Bbn ${<username>} ${<password>} > htpasswd
如果你再次查看目录列表,现在应该是这样的:
$ ls
Dockerfile config.yaml.sample registry.crt registry.key.sample
README.md htpasswd registry.crt.sample
config.yaml htpasswd.sample registry.key
如果这些文件中有任何一个丢失,请回顾之前的步骤,确保没有漏掉任何一个,然后再继续。 如果一切看起来正确,那么你就准备好构建和运行注册表了:
$ docker image build -t my-registry .
$ docker container run --rm -d -p 5000:5000 --name registry my-registry
$ docker container logs registry
测试私有注册表
现在注册表已经在运行,你可以对其进行测试。你需要做的第一件事是进行身份验证。确保在docker login中的IP地址与运行注册表的Docker服务器的IP地址匹配。
$ docker login 127.0.0.1:5000
Username: <registry_username>
Password: <registry_password>
Login Succeeded
现在,让我们看看是否可以将刚刚构建的镜像推送到本地的私有注册表中。
$ docker image tag my-registry 127.0.0.1:5000/my-registry
$ docker image push 127.0.0.1:5000/my-registry
Using default tag: latest
The push refers to repository [127.0.0.1:5000/my-registry]
f09a0346302c: Pushed
…
4fc242d58285: Pushed
latest: digest: sha256:c374b0a721a12c41d5b298930d11e658fbd37f22dc2a0fac7d6a2…
然后,你可以尝试从你的仓库拉取相同的镜像:
$ docker image pull 127.0.0.1:5000/my-registry
Using default tag: latest
latest: Pulling from my-registry
Digest: sha256:c374b0a721a12c41d5b298930d11e658fbd37f22dc2a0fac7d6a2ecdc0ba5490
Status: Image is up to date for 127.0.0.1:5000/my-registry:latest
127.0.0.1:5000/my-registry:latest
如果没有遇到任何错误,那么你现在有一个可以用于开发的工作中的注册表,并且可以在此基础上构建一个生产注册表。此时,你可能想要暂时停止注册表。你可以通过运行以下命令来轻松实现这一点:
$ docker container stop registry
优化镜像
在使用Docker一段时间后,你会很快注意到,保持镜像大小较小和构建时间较快可以在减少构建和部署新版本软件到生产环境所需的时间上带来很大好处。在本节中,我们将讨论一些设计镜像时应该始终牢记的考虑因素,以及一些可以帮助你实现这些目标的技术。
保持镜像的大小较小
在大多数现代企业中,从互联网上的远程位置下载一个1 GB的文件并不是人们经常担心的事情。在互联网上很容易找到软件,人们通常会简单地重新下载它,如果他们将来再次需要它,而不是保留一个本地副本。当你真正需要在单个服务器上拥有这个软件的单个副本时,这通常是可以接受的,但是当你需要在100多个节点上使用相同的软件,并且每天部署多次新版本时,它很快就会成为一个扩展性问题。下载这些大文件可能会迅速导致网络拥塞和较慢的部署周期,对生产环境产生真正的影响。
为了方便起见,大量的Linux容器继承自包含最小Linux发行版的基础镜像。虽然这是一个简单的起点,但并非必需。容器只需要包含在主机内核上运行应用程序所需的文件,而无需包含其他任何内容。最好的解释方式是探索一个非常简单的容器。
Go是一种编译型编程语言,可以轻松生成静态编译的二进制文件。对于这个示例,我们将使用一个非常小的用Go编写的Web应用程序,可以在GitHub上找到。
现在,让我们试试这个应用程序,这样你就可以看看它的功能。运行以下命令,然后打开一个Web浏览器,将其指向Docker主机的8080端口(例如,对于Docker Desktop和Community Edition,打开http://127.0.0.1:8080):
$ docker container run --rm -d -p 8080:8080 spkane/scratch-helloworld
如果一切顺利,你应该在Web浏览器中看到以下消息:“Hello World from Go in minimal Linux container.” 现在让我们来看看这个容器包含了哪些文件。你可能会合理地认为,至少它将包含一个可用的Linux环境和所有编译Go程序所需的文件,但很快你会发现事实并非如此。 在容器仍在运行时,执行以下命令来确定容器的ID。以下命令返回你创建的最后一个容器的信息:
$ docker container ls -l
CONTAINER ID IMAGE COMMAND CREATED …
ddc3f61f311b spkane/scratch-helloworld "/helloworld" 4 minutes ago …
接着,你可以使用之前运行的命令得到的容器ID,将容器中的文件导出到一个tarball(tar压缩包),以便进行进一步的查看:
$ docker container export ddc3f61f311b -o web-app.tar
使用tar命令,你现在可以查看导出时容器的内容:
$ tar -tvf web-app.tar
-rwxr-xr-x 0 0 0 0 Jan 7 15:54 .dockerenv
drwxr-xr-x 0 0 0 0 Jan 7 15:54 dev/
-rwxr-xr-x 0 0 0 0 Jan 7 15:54 dev/console
drwxr-xr-x 0 0 0 0 Jan 7 15:54 dev/pts/
drwxr-xr-x 0 0 0 0 Jan 7 15:54 dev/shm/
drwxr-xr-x 0 0 0 0 Jan 7 15:54 etc/
-rwxr-xr-x 0 0 0 0 Jan 7 15:54 etc/hostname
-rwxr-xr-x 0 0 0 0 Jan 7 15:54 etc/hosts
lrwxrwxrwx 0 0 0 0 Jan 7 15:54 etc/mtab -> /proc/mounts
-rwxr-xr-x 0 0 0 0 Jan 7 15:54 etc/resolv.conf
-rwxr-xr-x 0 0 0 3604416 Jul 2 2014 helloworld
drwxr-xr-x 0 0 0 0 Jan 7 15:54 proc/
drwxr-xr-x 0 0 0 0 Jan 7 15:54 sys/
在这里,你可能注意到的第一件事是,这个容器几乎没有任何文件,几乎所有的文件长度都为零字节。所有长度为零的文件都是每个Linux容器中都必须存在的,并且在创建容器时会自动从主机绑定到容器中。所有这些文件(除了.dockerenv之外)都是内核需要正常工作的关键文件。这个容器中唯一有实际大小并与我们的应用程序相关的文件是静态编译的helloworld二进制文件。
从这个例子中我们可以得出的结论是,你的容器只需要包含它们在底层内核上运行所需的内容,其他的都是不必要的。由于在容器中可以方便地进行故障排除,人们通常会妥协,从非常轻量级的Linux发行版(如Alpine Linux)构建镜像。
为了深入了解这一点,让我们再次看一下同样的容器,这样我们可以深入挖掘底层文件系统,并将其与流行的alpine基础镜像进行比较。
虽然我们可以简单地通过运行docker container run -ti alpine:latest /bin/sh来浏览alpine镜像,但我们无法对spkane/scratch-helloworld镜像这样做,因为它不包含shell或SSH。这意味着我们不能使用ssh、nsenter或docker container exec来查看它,虽然在“调试没有Shell的容器”一节中有一个高级技巧可以讨论。前面,我们利用了docker container export命令来创建一个.tar文件,其中包含容器中所有文件的副本,但这一次我们将通过直接连接到Docker服务器,然后查看容器的文件系统本身来检查容器的文件系统。为此,我们需要找出镜像文件实际存储在服务器的磁盘上的位置。
要确定我们的文件实际存储在服务器上的位置,请对alpine:latest镜像运行docker image inspect命令:
$ docker image inspect alpine:latest
[
{
"Id": "sha256:3fd…353",
"RepoTags": [
"alpine:latest"
],
"RepoDigests": [
"alpine@sha256:7b8…f8b"
],
…
"GraphDriver": {
"Data": {
"MergedDir":
"/var/lib/docker/overlay2/ea8…13a/merged",
"UpperDir":
"/var/lib/docker/overlay2/ea8…13a/diff",
"WorkDir":
"/var/lib/docker/overlay2/ea8…13a/work"
},
"Name": "overlay2"
…
}
}
…
]
然后在spkane/scratch-helloworld:latest镜像上运行相同的docker image inspect命令:
$ docker image inspect spkane/scratch-helloworld:latest
[
{
"Id": "sha256:4fa…06d",
"RepoTags": [
"spkane/scratch-helloworld:latest"
],
"RepoDigests": [
"spkane/scratch-helloworld@sha256:46d…a1d"
],
…
"GraphDriver": {
"Data": {
"LowerDir":
"/var/lib/docker/overlay2/37a…84d/diff:
/var/lib/docker/overlay2/28d…ef4/diff",
"MergedDir":
"/var/lib/docker/overlay2/fc9…c91/merged",
"UpperDir":
"/var/lib/docker/overlay2/fc9…c91/diff",
"WorkDir":
"/var/lib/docker/overlay2/fc9…c91/work"
},
"Name": "overlay2"
…
}
}
…
]
由于我们使用的是Docker Desktop,我们需要使用nsenter技巧进入没有SSH的虚拟机并探索文件系统:
$ docker container run --rm -it --privileged --pid=host debian \
nsenter -t 1 -m -u -n -i sh
/ #
在虚拟机内部,我们现在可以探索docker image inspect命令中GraphDriver部分列出的各个目录。
在这个例子中,如果我们查看alpine镜像的第一个条目,我们会看到它标记为MergedDir,并列出了文件夹/var/lib/docker/overlay2/ea86408b2b15d33ee27d78ff44f82104705286221f055ba1331b58673f4b313a/merged。如果我们列出该目录,会得到一个错误,但是通过列出父目录,我们很快发现我们实际上想查看的是diff目录:
/ # ls -lFa /var/lib/docker/overlay2/ea…3a/merged
ls: /var/lib/docker/overlay2/ea..3a/merged: No such file or directory
/ # ls -lF /var/lib/docker/overlay2/ea…3a/
total 8
drwxr-xr-x 18 root root 4096 Mar 15 19:27 diff/
-rw-r--r-- 1 root root 26 Mar 15 19:27 link
/ # ls -lF /var/lib/docker/overlay2/ea…3a/diff
total 64
drwxr-xr-x 2 root root 4096 Jan 9 19:37 bin/
drwxr-xr-x 2 root root 4096 Jan 9 19:37 dev/
drwxr-xr-x 15 root root 4096 Jan 9 19:37 etc/
drwxr-xr-x 2 root root 4096 Jan 9 19:37 home/
drwxr-xr-x 5 root root 4096 Jan 9 19:37 lib/
drwxr-xr-x 5 root root 4096 Jan 9 19:37 media/
drwxr-xr-x 2 root root 4096 Jan 9 19:37 mnt/
dr-xr-xr-x 2 root root 4096 Jan 9 19:37 proc/
drwx------ 2 root root 4096 Jan 9 19:37 root/
drwxr-xr-x 2 root root 4096 Jan 9 19:37 run/
drwxr-xr-x 2 root root 4096 Jan 9 19:37 sbin/
drwxr-xr-x 2 root root 4096 Jan 9 19:37 srv/
drwxr-xr-x 2 root root 4096 Jan 9 19:37 sys/
drwxrwxrwt 2 root root 4096 Jan 9 19:37 tmp/
drwxr-xr-x 7 root root 4096 Jan 9 19:37 usr/
drwxr-xr-x 11 root root 4096 Jan 9 19:37 var/
/ # du -sh /var/lib/docker/overlay2/ea…3a/diff
4.5M /var/lib/docker/overlay2/ea…3a/diff
现在,alpine恰好是一个非常小的基础镜像,只有4.5 MB,非常适合在其之上构建容器。然而,我们可以看到在我们开始构建任何内容之前,这个容器中仍然有很多东西。
现在,让我们来看看spkane/scratch-helloworld镜像中的文件。在这种情况下,我们想要查看docker image inspect输出的LowerDir条目中的第一个目录,你会注意到它也以一个名为diff的目录结尾:
/ # ls -lFh /var/lib/docker/overlay2/37…4d/diff
total 3520
-rwxr-xr-x 1 root root 3.4M Jul 2 2014 helloworld*
/ # exit
你会注意到在这个目录中只有一个文件,大小为3.4 MB。这个helloworld二进制文件是这个容器中唯一的文件,并且比alpine镜像在添加任何应用程序文件之前的初始大小还要小。
多阶段构建(Multistage builds)
在许多情况下,你可以通过一种方式将容器约束为更小的大小:多阶段构建。这是我们建议你构建大多数生产容器的方法。你不必过多担心引入额外的资源来构建你的应用程序,仍然可以运行一个精简的生产容器。多阶段容器还鼓励在Docker内部进行构建,这对于构建系统的可重复性是一个很好的模式。
正如scratch-helloworld应用程序的原始作者所写的,Docker自身支持多阶段构建的发布使得创建小型容器的过程比过去要简单得多。在过去,要做到与多阶段几乎免费提供的相同功能,你需要构建一个编译你的代码的镜像,提取生成的二进制文件,然后构建一个不包含所有构建依赖项的第二个镜像,然后将该二进制文件注入其中。这通常很难设置,并且不总是在标准部署流程中立即生效。
现在,你可以通过一个简单的Dockerfile实现类似的结果,如下所示:
# Build container
FROM docker.io/golang:alpine as builder
RUN apk update && \
apk add git && \
CGO_ENABLED=0 go install -a -ldflags '-s' \
github.com/spkane/scratch-helloworld@latest
# Production container
FROM scratch
COPY --from=builder /go/bin/scratch-helloworld /helloworld
EXPOSE 8080
CMD ["/helloworld"]
你会注意到这个Dockerfile看起来很像两个Dockerfile合并成了一个。事实上,确实是这样,但其中还有更多内容。FROM命令已经扩展,使你可以在构建阶段为镜像命名。在这个例子中,第一行写着FROM docker.io/golang as builder,意味着你想基于golang镜像进行构建,并将这个构建的镜像/阶段称为builder。
在第四行,你会看到另一行FROM命令,在多阶段构建引入之前是不被允许的。这个FROM命令使用了一个特殊的镜像名称,叫做scratch,它告诉Docker从一个空镜像开始,该镜像不包含任何附加文件。接下来的一行,写着COPY --from=builder /go/bin/scratch-helloworld /helloworld,允许你将在builder镜像中构建的二进制文件直接复制到当前镜像中。这将确保你最终得到最小的容器。
EXPOSE 8080行是用来描述文档的,它旨在告知用户服务监听的端口(和协议,默认协议是TCP)。
让我们尝试构建这个镜像,看看会发生什么。首先,创建一个工作目录,然后使用你喜欢的文本编辑器,将前面示例中的内容粘贴到一个名为Dockerfile的文件中。
$ mkdir /tmp/multi-build
$ cd /tmp/multi-build
$ vi Dockerfile
现在我们可以开始进行多阶段构建了:
$ docker image build .
[+] Building 9.7s (7/7) FINISHED
=> [internal] load build definition from Dockerfile
=> => transferring dockerfile: 37B
=> [internal] load .dockerignore
=> => transferring context: 2B
=> [internal] load metadata for docker.io/library/golang:alpine
=> CACHED [builder 1/2] FROM docker.io/library/golang:alpine@sha256:7cc6257…
=> [builder 2/2] RUN apk update && apk add git && CGO_ENABLED=0 go install …
=> [stage-1 1/1] COPY --from=builder /go/bin/scratch-helloworld /helloworld
=> exporting to image
=> => exporting layers
=> => writing image sha256:bb853f23418161927498b9631f54692cf11d84d6bde3af2d…
你会注意到输出看起来和大多数其他构建一样,并且最后仍然报告成功创建了我们的最终、非常精简的镜像。 你不限于只有两个阶段,实际上,这些阶段之间甚至不需要有关联。它们将按顺序执行。举例来说,你可以有一个基于公共Go镜像的阶段,用于构建你的基础Go应用程序来提供API,另一个基于Angular容器的阶段用于构建你的前端Web界面。最后的阶段可以将这两者的输出合并起来。
图层是累积的
直到你深入了解镜像构建的过程,你可能不会注意到一个重要的事实:构成镜像的文件系统图层在设计上是严格累积的。虽然你可以在之前的图层中隐藏或屏蔽文件,但不能删除这些文件。实际上,这意味着你不能通过简单地删除在之前步骤中生成的文件来减小镜像的大小。
图像层的累积性最简单的解释方式是通过一些实际的例子来说明。在一个新的目录中,下载或创建以下文件,它将生成一个运行在Fedora Linux上的Apache Web服务器的镜像:
FROM docker.io/fedora
RUN dnf install -y httpd
CMD ["/usr/sbin/httpd", "-DFOREGROUND"]
然后按照以下方式构建它:
$ docker image build .
[+] Building 63.5s (6/6) FINISHED
=> [internal] load build definition from Dockerfile
=> => transferring dockerfile: 130B
=> [internal] load .dockerignore
=> => transferring context: 2B
=> [internal] load metadata for docker.io/library/fedora:latest
=> [1/2] FROM docker.io/library/fedora
=> [2/2] RUN dnf install -y httpd
=> exporting to image
=> => exporting layers
=> => writing image sha256:543d61c956778b8ea3b32f1e09a9354a864467772e6…
让我们继续给生成的镜像打上标签,这样你可以在后续的命令中轻松引用它:
$ docker image tag sha256:543d61c956778b8ea3b32f1e09a9354a864467772e6… size1
现在让我们用docker image history命令查看一下我们的镜像。这个命令将为我们提供关于镜像使用的文件系统图层和构建步骤的一些信息:
$ docker image history size1
IMAGE CREATED CREATED BY SIZE …
543d61c95677 About a minute ago CMD ["/usr/sbin/httpd" "-DFOREGROU…"] 0B
<missing> About a minute ago RUN /bin/sh -c dnf install -y httpd … 273MB
<missing> 6 weeks ago /bin/sh -c #(nop) CMD ["/bin/bash"]… 0B
<missing> 6 weeks ago /bin/sh -c #(nop) ADD file:58865512c… 163MB
<missing> 3 months ago /bin/sh -c #(nop) ENV DISTTAG=f36co… 0B
<missing> 15 months ago /bin/sh -c #(nop) LABEL maintainer=… 0B
你会注意到其中三个图层对最终镜像大小没有增加任何内容,但有两个图层使大小大大增加。大小为163 MB的图层是有道理的,因为这是包含最小Linux发行版的基本Fedora镜像;然而,大小为273 MB的图层令人惊讶。Apache Web服务器不应该这么大,所以究竟发生了什么?
如果你有使用过apk、apt、dnf或yum等软件包管理器的经验,那么你可能知道这些工具大多依赖于一个大型缓存,其中包含有关可用于安装的所有软件包的详细信息。这个缓存占用了大量空间,一旦你安装了所需的软件包,它就变得完全无用了。最明显的下一步是简单地删除这个缓存。在Fedora系统上,你可以通过编辑你的Dockerfile,使其看起来像这样:
FROM docker.io/fedora
RUN dnf install -y httpd
RUN dnf clean all
CMD ["/usr/sbin/httpd", "-DFOREGROUND"]
然后构建、打标签,并检查生成的镜像:
$ docker image build .
[+] Building 0.5s (7/7) FINISHED
…
=> => writing image sha256:b6bf99c6e7a69a1229ef63fc086836ada20265a793cb8f2d…
$ docker image tag sha256:b6bf99c6e7a69a1229ef63fc086836ada20265a793cb8f2d17…
IMAGE CREATED CREATED BY SIZE …
b6bf99c6e7a6 About a minute ago CMD ["/usr/sbin/httpd" "-DFOREGROU…"] 0B
<missing> About a minute ago RUN /bin/sh -c dnf clean all # build… 71.8kB
<missing> 10 minutes ago RUN /bin/sh -c dnf install -y httpd … 273MB
<missing> 6 weeks ago /bin/sh -c #(nop) CMD ["/bin/bash"]… 0B
<missing> 6 weeks ago /bin/sh -c #(nop) ADD file:58865512c… 163MB
<missing> 3 months ago /bin/sh -c #(nop) ENV DISTTAG=f36co… 0B
<missing> 15 months ago /bin/sh -c #(nop) LABEL maintainer=… 0B
仔细观察docker image history命令的输出,你会发现你创建了一个新的图层,将71.8kB添加到镜像中,但是并没有减小问题图层的大小。究竟发生了什么?
重要的是要理解镜像图层的累积性质。一旦创建了一个图层,就不能从中删除任何内容。这意味着你不能通过在后续图层中删除文件来减小镜像中先前图层的大小。当你在后续图层中删除或编辑文件时,实际上只是用新图层中修改或删除的版本掩盖了旧版本。这意味着唯一的方法是在保存图层之前删除文件来减小图层的大小。
最常见的解决方法是在单个Dockerfile行上串联命令。你可以很容易地利用&&运算符来实现。这个运算符作为一个布尔AND语句,基本上翻译成英语就是“如果前一个命令运行成功,那么运行这个命令”。此外,你还可以利用/运算符,在换行后继续命令。这可以提高长命令的可读性。
有了这些知识,你可以像这样重写Dockerfile:
FROM docker.io/fedora
RUN dnf install -y httpd && \
dnf clean all
CMD ["/usr/sbin/httpd", "-DFOREGROUND"]
现在你可以重新构建镜像,看看这个更改对包含http守护进程的图层的大小产生了什么影响:
$ docker image build .
[+] Building 0.5s (7/7) FINISHED
…
=> => writing image sha256:14fe7924bb0b641ddf11e08d3dd56f40aff4271cad7a421fe…
$ docker image tag sha256:14fe7924bb0b641ddf11e08d3dd56f40aff4271cad7a421fe9b…
IMAGE CREATED CREATED BY SIZE …
14fe7924bb0b About a minute ago CMD ["/usr/sbin/httpd" "-DFOREGROUN"]… 0B
<missing> About a minute ago RUN /bin/sh -c dnf install -y httpd &… 44.8MB
<missing> 6 weeks ago /bin/sh -c #(nop) CMD ["/bin/bash"] … 0B
<missing> 6 weeks ago /bin/sh -c #(nop) ADD file:58865512ca… 163MB
<missing> 3 months ago /bin/sh -c #(nop) ENV DISTTAG=f36con… 0B
<missing> 15 months ago /bin/sh -c #(nop) LABEL maintainer=C… 0B
在前两个示例中,该图层的大小为273 MB,但现在你已经删除了许多不必要的文件,将该图层缩小为44.8 MB。这是一个非常大的节省空间,特别是考虑到在任何给定的部署过程中可能有多少服务器会下载该镜像。
利用图层缓存
这里要介绍的最后一个构建技巧与尽可能保持构建时间快速有关。DevOps运动的一个重要目标是尽可能缩短反馈循环。这意味着重要的是要尽快发现和报告问题,这样在人们仍然完全专注于相关代码且没有转向其他无关任务时,问题可以被及时修复。
在任何标准的构建过程中,Docker使用图层缓存来尝试避免重建任何已经构建过且没有包含任何明显变化的镜像层。由于这个缓存,你在Dockerfile内部进行操作的顺序可能会对构建的平均耗时产生显著影响。
首先,让我们从之前的示例中取出Dockerfile,并稍作定制,使其看起来像这样。
FROM docker.io/fedora
RUN dnf install -y httpd && \
dnf clean all
RUN mkdir -p /var/www && \
mkdir -p /var/www/html
ADD index.xhtml /var/www/html
CMD ["/usr/sbin/httpd", "-DFOREGROUND"]
现在,在同一个目录中,我们还要创建一个名为index.xhtml的新文件,内容如下:
<html>
<head>
<title>My custom Web Site</title>
</head>
<body>
<p>Welcome to my custom Web Site</p>
</body>
</html>
首先进行测试,我们将在构建时完全不使用Docker缓存,使用以下命令计时构建过程:
$ time docker image build --no-cache .
time docker image build --no-cache .
[+] Building 48.3s (9/9) FINISHED
=> [internal] load build definition from Dockerfile
=> => transferring dockerfile: 238B
=> [internal] load .dockerignore
=> => transferring context: 2B
=> [internal] load metadata for docker.io/library/fedora:latest
=> CACHED [1/4] FROM docker.io/library/fedora
=> [internal] load build context
=> => transferring context: 32B
=> [2/4] RUN dnf install -y httpd && dnf clean all
=> [3/4] RUN mkdir -p /var/www && mkdir -p /var/www/html
=> [4/4] ADD index.xhtml /var/www/html
=> exporting to image
=> => exporting layers
=> => writing image sha256:7f94d0d6492f2d2c0b8576f0f492e03334e6a535cac85576c…
real 1m21.645s
user 0m0.428s
sys 0m0.323s
time命令的输出告诉我们,没有使用缓存的构建耗时约为1分21秒,只从图层缓存中拉取了基础镜像。如果紧接着重新构建镜像并允许Docker使用缓存,你会发现构建速度非常快:
$ time docker image build .
[+] Building 0.1s (9/9) FINISHED
=> [internal] load build definition from Dockerfile
=> => transferring dockerfile: 37B
=> [internal] load .dockerignore
=> => transferring context: 2B
=> [internal] load metadata for docker.io/library/fedora:latest
=> [1/4] FROM docker.io/library/fedora
=> [internal] load build context
=> => transferring context: 32B
=> CACHED [2/4] RUN dnf install -y httpd && dnf clean all
=> CACHED [3/4] RUN mkdir -p /var/www && mkdir -p /var/www/html
=> CACHED [4/4] ADD index.xhtml /var/www/html
=> exporting to image
=> => exporting layers
=> => writing image sha256:0d3aeeeeebd09606d99719e0c5197c1f3e59a843c4d7a21af…
real 0m0.416s
user 0m0.120s
sys 0m0.087s
由于没有更改任何图层,缓存可以在所有四个构建步骤中充分发挥作用,构建只花费了不到一秒钟的时间就完成了。现在,让我们对index.xhtml文件进行一点小改进,使其内容如下:
<html>
<head>
<title>My custom Web Site</title>
</head>
<body>
<div align="center">
<p>Welcome to my custom Web Site!!!</p>
</div>
</body>
</html>
然后我们再次计时重新构建的过程:
$ time docker image build .
[+] Building 0.1s (9/9) FINISHED
=> [internal] load build definition from Dockerfile
=> => transferring dockerfile: 37B
=> [internal] load .dockerignore
=> => transferring context: 2B
=> [internal] load metadata for docker.io/library/fedora:latest
=> [internal] load build context
=> => transferring context: 214B
=> [1/4] FROM docker.io/library/fedora
=> CACHED [2/4] RUN dnf install -y httpd && dnf clean all
=> CACHED [3/4] RUN mkdir -p /var/www && mkdir -p /var/www/html
=> [4/4] ADD index.xhtml /var/www/html
=> ADD index.xhtml /var/www/html
=> exporting to image
=> => exporting layers
=> => writing image sha256:daf792da1b6a0ae7cfb2673b29f98ef2123d666b8d14e0b74…
real 0m0.456s
user 0m0.120s
sys 0m0.068s
如果你仔细观察输出,你会发现大部分构建过程都使用了缓存。直到第4步/4步时,Docker需要复制index.xhtml时,缓存才会失效,图层必须重新创建。由于大部分构建过程可以使用缓存,构建仍然没有超过一秒。
但是,如果你改变Dockerfile中命令的顺序,使其看起来像这样:
FROM docker.io/fedora
RUN mkdir -p /var/www && \
mkdir -p /var/www/html
ADD index.xhtml /var/www/html
RUN dnf install -y httpd && \
dnf clean all
CMD ["/usr/sbin/httpd", "-DFOREGROUND"]
让我们快速测试一次没有缓存的构建,以获得一个基准:
$ time docker image build --no-cache .
[+] Building 51.5s (9/9) FINISHED
…
=> => writing image sha256:1cc5f2c5e4a4d1cf384f6fb3a34fd4d00e7f5e7a7308d5f1f…
real 0m51.859s
user 0m0.237s
sys 0m0.159s
在这种情况下,构建完成需要51秒:因为我们使用了--no-cache参数,我们知道除了基础镜像之外,没有从图层缓存中拉取任何内容。与第一次测试的时间差异完全是由于网络速度的波动,与你对Dockerfile所做的更改无关。
现在,让我们再次编辑index.xhtml文件,如下所示:
<html>
<head>
<title>My custom Web Site</title>
</head>
<body>
<div align="center" style="font-size:180%">
<p>Welcome to my custom Web Site</p>
</div>
</body>
</html>
现在,让我们在使用缓存的情况下计时重新构建镜像:
$ time docker image build .
[+] Building 43.4s (9/9) FINISHED
=> [internal] load build definition from Dockerfile
=> => transferring dockerfile: 37B
=> [internal] load .dockerignore
=> => transferring context: 2B
=> [internal] load metadata for docker.io/library/fedora:latest
=> [1/4] FROM docker.io/library/fedora
=> [internal] load build context
=> => transferring context: 233B
=> CACHED [2/4] RUN mkdir -p /var/www && mkdir -p /var/www/html
=> [3/4] ADD index.xhtml /var/www/html
=> [4/4] RUN dnf install -y httpd && dnf clean all
=> exporting to image
=> => exporting layers
=> => writing image sha256:9a05b2d01b5870649e0ad1d7ad68858e0667f402c8087f0b4…
real 0m43.695s
user 0m0.211s
sys 0m0.133s
第一次在编辑了index.xhtml文件后重新构建镜像时,只花费了0.456秒,但这一次花费了43.695秒,几乎与完全不使用缓存构建整个镜像所花费的时间相同。
这是因为你修改了Dockerfile,使得index.xhtml文件在构建过程的早期就被复制到镜像中。这种做法的问题在于index.xhtml文件经常更改,经常会导致缓存失效。另一个问题是,它不必要地放在我们Dockerfile中耗时很长的步骤之前:安装Apache Web服务器。
从中我们可以得到的重要教训是顺序很重要,通常情况下,你应该尽量使你的Dockerfile的顺序,让构建过程中最稳定和耗时最长的部分首先进行,而将代码尽可能晚地添加到过程中。
对于那些需要使用npm和bundle等工具根据你的代码安装依赖的项目,还可以进行一些研究,优化你的Docker构建。这通常包括锁定依赖版本,并将它们与代码一起存储,这样在每次构建时就不需要下载它们。
目录缓存
BuildKit为镜像构建体验增加的众多功能之一就是目录缓存(Directory Caching)。目录缓存是一种非常有用的工具,可以加快构建时间,同时又不会将很多对运行时来说不必要的文件保存到镜像中。本质上,它允许你在镜像内部保存一个目录的内容,并在构建时进行绑定挂载,然后在镜像快照生成之前卸载。通常用于处理一些目录,比如Linux软件安装程序(apt、apk、dnf等)和语言依赖管理器(npm、bundler、pip等)下载它们的数据库和归档文件的情况。
要使用目录缓存,你必须启用BuildKit。在大多数情况下,BuildKit应该已经启用了,但你可以从客户端强制启用它,通过设置环境变量DOCKER_BUILDKIT=1:
$ export DOCKER_BUILDKIT=1
让我们通过检出以下 Git 存储库,并看看如何利用目录缓存可以显著提高连续构建的效率,同时保持生成的镜像大小更小:
$ git clone https://github.com/spkane/open-mastermind.git \
--config core.autocrlf=input
$ cd open-mastermind
$ cat Dockerfile
FROM python:3.9.15-slim-bullseye
RUN mkdir /app
WORKDIR /app
COPY . /app
RUN pip install -r requirements.txt
WORKDIR /app/mastermind
CMD ["python", "mastermind.py"]
这个代码库中有一个非常通用的 Dockerfile。让我们继续查看构建此镜像需要多长时间,使用或不使用层缓存,并且我们还将检查生成的镜像大小:
$ time docker build --no-cache -t docker.io/spkane/open-mastermind:latest .
[+] Building 67.5s (12/12) FINISHED
…
=> => naming to docker.io/spkane/open-mastermind:latest 0.0s
real 0m28.934s
user 0m0.222s
sys 0m0.248s
$ docker image ls --format "{{ .Size }}" spkane/open-mastermind:latest
293MB
$ time docker build -t docker.io/spkane/open-mastermind:latest .
[+] Building 1.5s (12/12) FINISHED
…
=> => naming to docker.io/spkane/open-mastermind:latest 0.0s
real 0m1.083s
user 0m0.098s
sys 0m0.095s
从这个输出中,我们可以看到这个镜像在没有使用层缓存时需要将近29秒的构建时间,而在充分利用层缓存的情况下,仅需不到2秒的构建时间。生成的镜像总大小为293 MB。
如果你想测试构建,可以继续运行它:
$ docker container run -ti --rm docker.io/spkane/open-mastermind:latest
这将启动一个基于终端的开源版本的猜数字游戏(Mastermind)。游戏中有屏幕上的说明,如果需要,你可以随时通过输入Ctrl-C来退出游戏。
由于这是一个Python应用程序,它使用requirements.txt文件列出应用程序所需的所有库,然后在Dockerfile中使用pip应用程序来安装这些依赖项。
请打开requirements.txt文件,并添加一行内容:log-symbols,让它看起来像这样:
colorama
# These are not required - but are used for demonstration purposes
pandas
flask
log-symbols
现在让我们重新运行构建:
$ time docker build -t docker.io/spkane/open-mastermind:latest \
--progress=plain .
#1 [internal] load build definition from Dockerfile
…
#9 [5/6] RUN pip install -r requirements.txt
#9 sha256:82dbc10f1bb9fa476d93cc0d8104b76f46af8ece7991eb55393d6d72a230919e
#9 1.954 Collecting colorama
#9 2.058 Downloading colorama-0.4.5-py2.py3-none-any.whl (16 kB)
…
real 0m16.379s
user 0m0.112s
sys 0m0.082s
如果你查看步骤5/6的完整输出,你会注意到所有的依赖都被重新下载了,尽管pip通常会将大多数这些依赖项缓存在/root/.cache中。这种低效性是因为构建器检测到我们对该层进行了影响,因此完全重新创建了该层,因此我们失去了那个缓存,尽管我们将其存储在镜像层中。
让我们继续改进这种情况。为了做到这一点,我们需要利用BuildKit目录缓存,并对Dockerfile进行一些更改,使其如下所示:
# syntax=docker/dockerfile:1
FROM python:3.9.15-slim-bullseye
RUN mkdir /app
WORKDIR /app
COPY . /app
RUN --mount=type=cache,target=/root/.cache pip install -r requirements.txt
WORKDIR /app/mastermind
CMD ["python", "mastermind.py"]
这里有两个重要的更改。首先,我们添加了以下这行代码:
# syntax=docker/dockerfile:1
这告诉Docker我们将使用较新版本的Dockerfile前端,从而可以访问BuildKit的新功能。
然后,我们编辑了RUN命令,将其修改为如下所示:
RUN --mount=type=cache,target=/root/.cache pip install -r requirements.txt
这行命令告诉BuildKit在这个构建步骤的过程中将一个缓存层挂载到容器的/root/.cache目录下。这将为我们实现两个目标:将该目录的内容从生成的镜像中删除,并在后续的构建中重新挂载并提供给pip使用。
让我们根据这些更改进行完整的镜像重建,以生成初始的缓存目录内容。如果你跟随输出,你会发现pip会像之前一样下载所有的依赖项:
$ time docker build --no-cache -t docker.io/spkane/open-mastermind:latest .
[+] Building 15.2s (15/15) FINISHED
…
=> => naming to docker.io/spkane/open-mastermind:latest 0.0s
…
real 0m15.493s
user 0m0.137s
sys 0m0.096s
好的,现在让我们打开requirements.txt文件,并添加一行内容:py-events:
colorama
# These are not required - but are used for demonstration purposes
pandas
flask
log-symbols
py-events
这时候,我们的改变开始见效了。当我们重新构建镜像时,你会发现只有py-events及其依赖项被下载了;其他的所有内容都使用了我们之前构建的现有缓存,它已经被挂载到了这个构建步骤的镜像中:
$ time docker build -t docker.io/spkane/open-mastermind:latest \
--progress=plain .
#1 [internal] load build definition from Dockerfile
…
#14 [stage-0 5/6] RUN --mount=type=cache,target=/root/.cache pip install …
#14 sha256:9bc72441fdf2ec5f5803d4d5df43dbe7bc6eeef88ebee98ed18d8dbb478270ba
#14 1.711 Collecting colorama
#14 1.714 Using cached colorama-0.4.5-py2.py3-none-any.whl (16 kB)
…
#14 2.236 Collecting py-events
#14 2.356 Downloading py_events-0.1.2-py3-none-any.whl (5.8 kB)
…
#16 DONE 1.4s
real 0m12.624s
user 0m0.180s
sys 0m0.112s
$ docker image ls --format "{{ .Size }}" spkane/open-mastermind:latest
261MB
由于不再需要每次重新下载所有内容,构建时间缩短了,而且镜像大小也减小了32MB,尽管我们已经向镜像添加了新的依赖项。这仅仅是因为缓存目录不再直接存储在包含应用程序的镜像中。
BuildKit和新的Dockerfile前端为镜像构建过程带来了许多非常有用的功能,你会想要了解这些功能。我们强烈建议你花时间阅读参考指南,并熟悉所有可用的功能。
故障排除破损的构建过程
通常情况下,我们期望构建过程是正常工作的,特别是当我们已经将其脚本化了。但在现实世界中,事情往往会出错。让我们花一点时间讨论一下如何排查失败的Docker构建。在本节中,我们将探讨两种选项:一种适用于先前的非BuildKit镜像构建方法,另一种适用于BuildKit。
为了进行演示,我们将再次使用之前本章中的docker-hello-node存储库。如果需要,你可以像这样再次克隆它:
$ git clone https://github.com/spkane/docker-node-hello.git \
--config core.autocrlf=input
Cloning into 'docker-node-hello'…
remote: Counting objects: 41, done.
remote: Total 41 (delta 0), reused 0 (delta 0), pack-reused 41
Unpacking objects: 100% (41/41), done.
$ cd docker-node-hello
调试非BuildKit镜像
我们需要一个失败的构建用于下一组练习,因此让我们创建一个失败的构建。为此,请编辑Dockerfile,将以下行:
RUN apt-get -y update
现在修改为:
RUN apt-get -y update-all
如果现在尝试构建镜像,你应该会收到以下错误信息:
$ DOCKER_BUILDKIT=0 docker image build -t example/docker-node-hello:latest \
--no-cache .
Sending build context to Docker daemon 9.216kB
Step 1/14 : FROM docker.io/node:18.13.0
---> 9ff38e3a6d9d
…
Step 6/14 : ENV SCPATH /etc/supervisor/conf.d
---> Running in e903367eaeb8
Removing intermediate container e903367eaeb8
---> 2a236efc3f06
Step 7/14 : RUN apt-get -y update-all
---> Running in c7cd72f7d9bf
E: Invalid operation update-all
The command '/bin/sh -c apt-get -y update-all' returned a non-zero code: 100
那么,我们如何排除这个问题,特别是如果我们不是在Linux系统上开发呢?真正的技巧在于记住几乎所有的Docker镜像都是在其他Docker镜像的基础上构建的,而且你可以从任何镜像启动一个容器。虽然表面上的意义并不明显,但如果你查看步骤6的输出,你会看到这样的信息:
Step 6/14 : ENV SCPATH /etc/supervisor/conf.d
---> Running in e903367eaeb8
Removing intermediate container e903367eaeb8
---> 2a236efc3f06
第一行“Running in e903367eaeb8”告诉你构建过程已经启动一个新的容器,该容器基于步骤5创建的镜像。下一行“Removing intermediate container e903367eaeb8”告诉你Docker正在根据步骤6的指令对容器进行修改,并在修改后删除了该容器。在本例中,它只是通过ENV SCPATH /etc/supervisor/conf.d添加了一个默认的环境变量。最后一行“--→ 2a236efc3f06”才是我们真正关心的,因为这给出了由步骤6生成的镜像的镜像ID。在排除构建问题时,你需要这个ID,因为它是构建中最后一个成功步骤的镜像。
有了这些信息,你可以运行一个交互式容器,以便尝试确定为什么你的构建没有正常工作。记住,每个容器镜像都是基于它下面的镜像层构建的。这其中的一个巨大好处是,我们可以将较低的层级本身作为一个容器运行,并使用shell来查看其中的内容!
$ docker container run --rm -ti 2a236efc3f06 /bin/bash
root@b83048106b0f:/#
在容器内部,你现在可以运行任何命令,以确定造成构建失败的原因,以及需要如何修复你的Dockerfile:
root@b83048106b0f:/# apt-get -y update-all
E: Invalid operation update-all
root@b83048106b0f:/# apt-get --help
apt 1.4.9 (amd64)
…
Most used commands:
update - Retrieve new lists of packages
…
root@b83048106b0f:/# apt-get -y update
Get:1 http://security.debian.org/debian-security stretch/updates … [53.0 kB]
…
Reading package lists… Done
root@b83048106b0f:/# exit
exit
一旦确定了根本原因,就可以修复Dockerfile,将RUN apt-get -y update-all更改为RUN apt-get -y update,然后重新构建镜像应该成功。
$ DOCKER_BUILDKIT=0 docker image build -t example/docker-node-hello:latest .
Sending build context to Docker daemon 15.87kB
…
Successfully built 69f5e83bb86e
Successfully tagged example/docker-node-hello:latest
调试 BuildKit 镜像
使用 BuildKit 进行调试需要采取稍微不同的方法,因为在构建容器和 Docker 守护进程之间没有导出任何中间构建层。
目前,调试 BuildKit 的选项几乎肯定会随着时间的推移而演变,但让我们来看一个现在可行的方法。
假设 Dockerfile 已经被恢复到原始状态,我们来修改该行,将:
RUN npm install
所以现在它变成了:
RUN npm installer
然后尝试构建镜像
$ docker image build -t example/docker-node-hello:debug --no-cache .
[+] Building 51.7s (13/13) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
…
=> [7/8] WORKDIR /data/app 0.0s
=> ERROR [8/8] RUN npm installer 0.4s
______
> [8/8] RUN npm installer:
#13 0.399
#13 0.399 Usage: npm <command>
…
#13 0.402 Did you mean one of these?
#13 0.402 install
#13 0.402 install-test
#13 0.402 uninstall
______
executor failed running [/bin/sh -c npm installer]: exit code: 1
我们看到了一个预期中的错误,但我们要如何访问该层以便进行故障排除呢? 一个有效的方法是利用多阶段构建和docker image build命令的--target参数。 首先,让我们在两个地方修改Dockerfile。将以下行改为:
FROM docker.io/node:18.13.0
因此,现在变成:
FROM docker.io/node:18.13.0 as deploy
然后,在导致错误的那行代码之前,我们会添加一个新的FROM行:
FROM deploy
RUN npm installer
通过这样做,我们创建了一个多阶段构建,其中第一个阶段包含了所有我们知道工作正常的步骤,第二个阶段从我们有问题的步骤开始。 如果我们尝试使用之前相同的命令重新构建,它仍然会失败:
$ docker image build -t example/docker-node-hello:debug .
[+] Building 51.7s (13/13) FINISHED
…
executor failed running [/bin/sh -c npm installer]: exit code: 1
所以,不要这样做,让我们告诉Docker,我们只想构建多阶段Dockerfile中的第一个镜像:
$ docker image build -t example/docker-node-hello:debug --target deploy .
[+] Building 0.8s (12/12) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 37B 0.0s
…
=> exporting to image 0.1s
=> => exporting layers 0.1s
=> => writing image sha256:a42dfbcfc7b18ee3d30ace944ad4134ea2239a2c0 0.0s
=> => naming to docker.io/example/docker-node-hello:debug 0.0s
现在,我们可以从这个镜像创建一个容器,进行所需的测试:
$ docker container run --rm -ti docker.io/example/docker-node-hello:debug \
/bin/bash
root@17807997176e:/data/app# ls
index.js package.json
root@17807997176e:/data/app# npm install
…
added 18 packages from 16 contributors and audited 18 packages in 1.248s
…
root@17807997176e:/data/app# exit
exit
然后,一旦我们了解了Dockerfile中的问题,就可以恢复调试所做的更改,并修复npm命令,以便整个构建按预期运行。
多架构构建(Multiarchitecture Builds)
自Docker发布以来,AMD64/X86_64架构一直是大多数容器的主要目标平台。然而,这情况正在发生显著变化。越来越多的开发者开始使用基于ARM64/AArch64的系统,云计算公司也开始通过其平台提供基于ARM架构的虚拟机,因为ARM平台具有更低的计算成本。
这对于那些需要构建和维护针对多种架构的镜像的人来说可能会带来一些有趣的挑战。如何在仍然支持所有这些不同目标的情况下维护一个单一、简化的代码库和流水线呢?
幸运的是,Docker发布了一个名为buildx的插件,可以帮助使这个过程非常简单。在许多情况下,docker-buildx已经安装在您的系统上,您可以通过以下方式验证:
$ docker buildx version
github.com/docker/buildx v0.9.1 ed00243a0ce2a0aee75311b06e32d33b44729689
默认情况下,docker-buildx会利用基于QEMU的虚拟化和binfmt_misc来支持与底层系统不同的架构。这可能已经在您的Linux系统上设置好了,但为了确保QEMU文件被正确注册和更新,当您第一次设置新的Docker服务器时,建议运行以下命令:
$ docker container run --rm --privileged multiarch/qemu-user-static \
--reset -p yes
Setting /usr/bin/qemu-alpha-static as binfmt interpreter for alpha
Setting /usr/bin/qemu-arm-static as binfmt interpreter for arm
Setting /usr/bin/qemu-armeb-static as binfmt interpreter for armeb
…
Setting /usr/bin/qemu-aarch64-static as binfmt interpreter for aarch64
Setting /usr/bin/qemu-aarch64_be-static as binfmt interpreter for aarch64_be
…
与原始的内置Docker构建功能不同,BuildKit在构建镜像时可以利用构建容器,这意味着可以通过该构建容器提供很多功能上的灵活性。接下来,我们将创建一个名为"builder"的默认buildx容器。 通过接下来的两个命令,我们将创建构建容器,将其设置为默认容器,然后启动它:
$ docker buildx create --name builder --driver docker-container --use
builder
$ docker buildx inspect --bootstrap
[+] Building 9.6s (1/1) FINISHED
=> [internal] booting buildkit 9.6s
=> => pulling image moby/buildkit:buildx-stable-1 8.6s
=> => creating container buildx_buildkit_builder0 0.9s
Name: builder
Driver: docker-container
Nodes:
Name: builder0
Endpoint: unix:///var/run/docker.sock
Status: running
Buildkit: v0.10.5
Platforms: linux/amd64, linux/amd64/v2, linux/arm64, linux/riscv64,
linux/ppc64le, linux/s390x, linux/386, linux/mips64le,
linux/mips64, linux/arm/v7, linux/arm/v6
对于这个示例,让我们先下载 wordchain Git 存储库,其中包含一个有用的工具,可以生成随机和确定性的单词序列,以满足动态命名的需求:
$ git clone https://github.com/spkane/wordchain.git \
--config core.autocrlf=input
$ cd wordchain
让我们继续查看一下包含的 Dockerfile。你会注意到这是一个相当普通的多阶段 Dockerfile,没有与平台架构相关的特殊内容:
FROM golang:1.18-alpine3.15 AS build
RUN apk --no-cache add \
bash \
gcc \
musl-dev \
openssl
ENV CGO_ENABLED=0
COPY . /build
WORKDIR /build
RUN go install github.com/markbates/pkger/cmd/pkger@latest && \
pkger -include /data/words.json && \
go build .
FROM alpine:3.15 AS deploy
WORKDIR /
COPY --from=build /build/wordchain /
USER 500
EXPOSE 8080
ENTRYPOINT ["/wordchain"]
CMD ["listen"]
在第一步中,我们将构建静态编译的 Go 二进制文件,然后在第二步中,我们将将其打包到一个小型的部署镜像中。
我们可以继续构建这个镜像,并通过运行以下命令将它侧向加载到我们的本地 Docker 服务器中:
$ docker buildx build --tag wordchain:test --load .
[+] Building 2.4s (16/16) FINISHED
=> [internal] load .dockerignore 0.0s
=> => transferring context: 93B 0.0s
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 461B 0.0s
…
=> exporting to oci image format 0.3s
=> => exporting layers 0.0s
=> => exporting manifest sha256:4bd1971f2ed820b4f64ffda97707c27aac3e8eb7 0.0s
=> => exporting config sha256:ce8f8564bf53b283d486bddeb8cbb074ff9a9d4ce9 0.0s
=> => sending tarball 0.2s
=> importing to docker 0.0s
我们可以通过运行以下命令来快速测试镜像:
$ docker container run wordchain:test random
witty-stack
$ docker container run wordchain:test random -l 3 -d .
odd.goo
$ docker container run wordchain:test --help
wordchain is an application that can generate a readable chain
of customizable words for naming things like
containers, clusters, and other objects.
…
只要你在前两个命令中得到了一些随机的词对,那么一切都符合预期。 现在,要为多个架构构建这个镜像,我们只需要在构建中添加 --platform 参数。 你可以像这样为 linux/amd64 和 linux/arm64 平台构建此镜像:
$ docker buildx build --platform linux/amd64,linux/arm64 \
--tag wordchain:test .
[+] Building 114.9s (23/23) FINISHED
…
=> [linux/arm64 internal] load metadata for docker.io/library/alpine:3.1 2.7s
=> [linux/amd64 internal] load metadata for docker.io/library/alpine:3.1 2.7s
=> [linux/arm64 internal] load metadata for docker.io/library/golang:1.1 3.0s
=> [linux/amd64 internal] load metadata for docker.io/library/golang:1.1 2.8s
…
=> CACHED [linux/amd64 build 5/5] RUN go install github.com/markbates/pk 0.0s
=> CACHED [linux/amd64 deploy 2/3] COPY --from=build /build/wordchain / 0.0s
=> [linux/arm64 build 5/5] RUN go install github.com/markbates/pkger/c 111.7s
=> [linux/arm64 deploy 2/3] COPY --from=build /build/wordchain / 0.0s
WARNING: No output specified with docker-container driver. Build result will
only remain in the build cache. To push result image into registry
use --push or to load image into docker use --load
在构建的输出中,你会注意到以“=> [linux/amd64 *]”或“=> [linux/arm64 *]”开头的行。每一行代表了构建器在指定平台上执行此构建步骤。许多这些步骤将并行运行,并且由于缓存和其他因素的考虑,每个构建可能以不同的速度进行。
由于我们没有在构建中添加 --push 参数,所以你还会注意到在构建结束时收到了一个警告。这是因为构建器使用的 docker-container 驱动器将所有内容保留在构建缓存中,这意味着我们无法运行生成的镜像;此时,我们只能确信构建是有效的。
因此,当我们将这个镜像上传到仓库时,Docker 是如何知道在本地平台上使用哪个镜像呢?这些信息是通过一个称为镜像清单(image manifest)的东西提供给 Docker 服务器的。我们可以通过运行以下命令查看 docker.io/spkane/workdchain 的镜像清单:
$ docker manifest inspect docker.io/spkane/wordchain:latest
{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
"manifests": [
{
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"size": 739,
"digest": "sha256:4bd1…bfc0",
"platform": {
"architecture": "amd64",
"os": "linux"
}
},
{
…
"platform": {
"architecture": "arm64",
"os": "linux"
}
},
…
]
}
如果你仔细查看输出,你会看到有一些块标识了镜像所支持的每个平台所需的信息。这是通过各个摘要条目实现的,然后与一个平台块配对。当服务器需要一个镜像时,它会下载这个镜像清单文件,并在引用了清单后,根据本地平台下载正确的镜像。这就是为什么我们的 Dockerfile 能正常工作。每个 FROM 行列出了我们要使用的基础镜像,但实际上是 Docker 服务器通过使用这个镜像清单文件来确定为每个构建所针对的平台下载哪个镜像。
总结
此时,你应该对 Docker 的镜像创建有很高的熟练度,并且对许多核心工具和功能有了坚实的理解,可以利用它们来优化你的构建流程。在下一章中,我们将开始探讨如何使用这些镜像为你的项目创建容器化的进程。