简介
容器化的主要目标是使应用程序的构建、部署和运行变得简单。整个流程如下所示:
- 从应用程序代码和依赖开始
- 创建一个描述应用程序、依赖关系以及运行方式的Dockerfile
- 通过将Dockerfile传递给docker
- build命令来将其构建成一个镜像
- 将新镜像推送到注册表(可选)
- 从镜像中运行一个容器
图8.1以图示形式展示了这个过程。
深入
我们将按以下方式分解这个“深入探讨”部分:
- 对单容器应用进行容器化
- 使用多阶段构建迈向生产环境
- 多平台构建
- 一些最佳实践
容器化单容器应用程序
本示例中使用的应用程序可以在本书的GitHub存储库中找到:
github.com/nigelpoulto… 运行以下命令来克隆存储库。您需要安装git才能完成此步骤。
$ git clone https://github.com/nigelpoulton/ddd-book.git
Cloning into 'ddd-book'...
remote: Enumerating objects: 47, done.
remote: Counting objects: 100% (47/47), done.
remote: Compressing objects: 100% (32/32), done.
remote: Total 47 (delta 11), reused 44 (delta 11), pack-reused 0
Receiving objects: 100% (47/47), 167.30 KiB | 1.66 MiB/s, done.
Resolving deltas: 100% (11/11), done.
克隆操作会在您的工作目录中创建一个名为ddd-book的新目录。进入ddd-book/web-app目录并列出其内容。
$ cd ddd-book/web-app
$ ls -l
total 20
-rw-rw-r-- 1 ubuntu ubuntu 324 May 20 07:44 Dockerfile
-rw-rw-r-- 1 ubuntu ubuntu 377 May 20 07:44 README.md
-rw-rw-r-- 1 ubuntu ubuntu 341 May 20 07:44 app.js
-rw-rw-r-- 1 ubuntu ubuntu 404 May 20 07:44 package.json
drwxrwxr-x 2 ubuntu ubuntu 4096 May 20 07:44 views
这个目录被称为构建上下文,其中包含了所有的应用程序源代码,以及一个包含依赖关系列表的文件。通常还会将应用程序的Dockerfile保留在构建上下文中。
现在我们有了应用程序代码,让我们来看看它的Dockerfile。
检查 Dockerfile
一个Dockerfile描述了一个应用程序,并告诉Docker如何将其构建成一个镜像。
不要低估Dockerfile作为文档形式的影响。它是一个很好的文档,可以弥合开发人员和运维之间的差距。它还具有加快新团队成员入职速度的能力。这是因为该文件以易于阅读的格式准确地描述了应用程序及其依赖关系。您应该像对待源代码一样对待它,并将其保存在版本控制系统中。
让我们来看看这个应用程序的Dockerfile的内容。
$ cat Dockerfile
FROM alpine
LABEL maintainer="nigelpoulton@hotmail.com"
RUN apk add --update nodejs npm
COPY . /src
WORKDIR /src
RUN npm install
EXPOSE 8080
ENTRYPOINT ["node", "./app.js"]
在高层次上,这个示例的Dockerfile表示:以alpine镜像为基础,注明“nigelpoulton@hotmail.com”是维护者,安装Node.js和NPM,将构建上下文中的所有内容复制到镜像中的/src目录,将工作目录设置为/src,安装依赖项,记录应用程序的网络端口,并将app.js设置为默认要运行的应用程序。
让我们稍微详细地看一下。
Dockerfile通常以FROM指令开始。这会拉取一个镜像,作为Dockerfile构建的图像的基本层 - 其他所有内容将作为在此基本层之上添加的新层。在这个Dockerfile中定义的应用程序是一个Linux应用程序,因此很重要的一点是,FROM指令引用了一个基于Linux的镜像。如果您要容器化一个Windows应用程序,您需要指定一个适当的Windows基础镜像。
在Dockerfile的这一点上,图8.2显示了一个单一的图层。
接下来,Dockerfile创建了一个标签,将“nigelpoulton@hotmail.com”指定为图像的维护者。标签是可选的键值对,是添加自定义元数据的一种很好的方式。列出维护者被认为是最佳实践,这样其他用户就有了一个联系点,可以报告问题等。
指令RUN apk add --update nodejs nodejs-npm 使用apk软件包管理器将nodejs和nodejs-npm安装到图像中。它通过添加一个新的图层并将软件包安装到该图层来完成此操作。在Dockerfile的这一点上,图像的外观如图8.3所示。
指令COPY . /src 创建了另一个新的图层,并从构建上下文中复制应用程序和依赖文件。现在图像有了三个图层,如图8.4所示。
接下来,Dockerfile使用WORKDIR指令为接下来的指令设置工作目录。这会创建元数据,但不会创建新的图像层。
指令RUN npm install 在前面设置的工作目录的上下文中运行,并将package.json中列出的依赖项安装到另一个新的图层中。在Dockerfile的这一点上,图像有了四个图层,如图8.5所示。
该应用程序在端口8080上暴露了一个Web服务,因此Dockerfile使用EXPOSE 8080指令记录了这一点。最后,ENTRYPOINT指令在容器启动时设置要运行的应用程序。这两者都被添加为元数据,不会创建新的图层。
将应用程序容器化/构建镜像
既然我们理解了理论知识,让我们来看看实际操作。
以下命令将构建一个名为ddd-book:ch8.1的新镜像。命令末尾的句点(.)告诉Docker使用工作目录作为构建上下文。请确保包括末尾的句点(.),并确保从web-app目录中运行该命令。
$ docker build -t ddd-book:ch8.1 .
[+] Building 16.2s (10/10) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 335B 0.0s
=> => transferring context: 2B 0.0s
=> [1/5] FROM docker.io/library/alpine 0.1s
=> CACHED [2/5] RUN apk add --update nodejs npm curl 0.0s
=> [3/5] COPY . /src 0.0s
=> [4/5] WORKDIR /src 0.0s
=> [5/5] RUN npm install 10.4s
=> exporting to image 0.2s
=> => exporting layers 0.2s
=> => writing image sha256:f282569b8bd0f0...016cc1adafc91 0.0s
=> => naming to docker.io/library/ddd-book:ch8.1
注意构建输出中报告的五个编号步骤。这些步骤创建了五个镜像图层。
检查镜像是否存在于您的Docker主机的本地仓库中。
$ docker images
REPO TAG IMAGE ID CREATED SIZE
ddd-book ch8.1 f282569b8bd0 4 minutes ago 95.4MB
恭喜,应用程序已经容器化!
您可以使用docker inspect ddd-book:ch8.1命令来验证镜像的配置。它将列出从Dockerfile配置的所有设置。请注意镜像层的列表和Entrypoint命令。
$ docker inspect ddd-book:ch8.1
[
{
"Id": "sha256:f282569b8bd0...016cc1adafc91",
"RepoTags": [
"ddd-book:ch8.1"
"WorkingDir": "/src",
"Entrypoint": [
"node",
"./app.js"
],
"Labels": {
"maintainer": "nigelpoulton@hotmail.com"
"Layers": [
"sha256:94dd7d531fa5695c0c033dcb69f213c2b4c3b5a3ae6e497252ba88da87169c3f",
"sha256:a990a785ba64395c8b9d05fbe32176d1fb3edd94f6fe128ed7415fd7e0bb4231",
"sha256:efeb99f5a1b27e36bc6c46ea9eb2ba4aab942b47547df20ee8297d3184241b1d",
"sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef",
"sha256:ccf07adfaecfba485ecd7274c092e7343c45e539fa4371c5325e664122c7c92b"
]
推送镜像
一旦您创建了一个镜像,最好将其存储在一个镜像注册表中,以确保其安全性并使其对他人可用。Docker Hub是最常见的公共镜像注册表,也是docker push命令的默认推送位置。
如果您想要将镜像推送到Docker Hub,您将需要一个Docker ID,并且需要适当地标记镜像。
如果您还没有Docker Hub ID,请前往hub.docker.com并立即注册一个,它们是免费的。
请务必在示例中将我的Docker ID替换为您自己的ID。因此,每当您看到nigelpoulton时,请将其替换为您的Docker ID(Docker Hub用户名)。
$ docker login
Login with your Docker ID to push and pull images from Docker Hub.
Username: nigelpoulton
Password:
WARNING! Your password will be stored unencrypted in /home/ubuntu/.docker/config.json.
Configure a credential helper to remove this warning.
在推送镜像之前,需要适当地标记它们。这是因为标签包含以下重要的与注册表相关的信息:
- 注册表的DNS名称
- 仓库名称
- 标签
Docker在注册表方面有自己的看法 - 它假设您想要推送到Docker Hub。您可以通过将注册表URL添加到镜像标签的开头来推送到其他注册表。
先前的docker images输出显示镜像已标记为ddd-book:ch8.1。docker push将尝试将其推送到名为ddd-book的Docker Hub仓库。但是,该仓库不存在,并且我无法访问它,因为我所有的仓库都存在于nigelpoulton的第二层命名空间中。这意味着我需要重新为镜像添加我的Docker ID作为标签。请记得替换为您自己的Docker ID。
命令的格式是docker tag
。这会添加一个额外的标签,不会覆盖原有标签。
$ docker tag ddd-book:ch8.1 nigelpoulton/ddd-book:ch8.1
再运行docker images会显示该镜像现在有两个标签。
$ docker images
REPO TAG IMAGE ID CREATED SIZE
ddd-book ch8.1 f282569b8bd0 13 mins ago 95.4MB
nigelpoulton/ddd-book ch8.1 f282569b8bd0 13 mins ago 95.4MB
现在我们可以将它推送到Docker Hub。请确保替换为您的Docker ID。
$ docker push nigelpoulton/ddd-book:ch8.1
The push refers to repository [docker.io/nigelpoulton/ddd-book]
ccf07adfaecf: Pushed
5f70bf18a086: Layer already exists
efeb99f5a1b2: Pushed
a990a785ba64: Pushed
94dd7d531fa5: Layer already exists
ch8.1: digest: sha256:80063789bce73a17...09ea29c5e6a91c28b4 size: 1365
现在镜像已经推送到注册表,您可以在任何有互联网连接的地方访问它。您还可以授权其他人访问以拉取它并推送更改。
运行应用
容器化的应用程序是一个在端口8080上监听的Web服务器。您可以在从GitHub克隆的构建上下文中的app.js文件中验证这一点。
以下命令将基于您刚刚创建的ddd-book:ch8.1镜像启动一个名为c1的新容器。它将Docker主机上的端口80映射到容器内部的端口8080。这意味着您可以将Web浏览器指向运行容器的Docker主机的DNS名称或IP地址,然后访问该应用程序。
注意:如果您的主机已经在端口80上运行了一个服务,您将会收到端口已分配的错误。如果发生这种情况,请指定一个不同的端口,例如5000或5001。例如,要将应用程序映射到Docker主机上的端口5000,请使用 -p 5000:8080 标志。
$ docker run -d --name c1
-p 80:8080
ddd-book:ch8.1
-d 标志将容器在后台运行,而 -p 80:8080 标志将主机上的端口80映射到正在运行的容器内部的端口8080。
检查容器是否在运行,并验证端口映射。
$ docker ps
ID IMAGE COMMAND STATUS PORTS NAMES
49.. ddd-book:ch8.1 "node ./app.js" UP 18 secs 0.0.0.0:80->8080/tcp c1
上面的输出已经剪切以提高可读性,但显示容器正在运行。请注意,端口80被映射到所有主机接口(0.0.0.0:80)。
测试应用
打开一个网络浏览器,并将其指向正在运行容器的主机的DNS名称或IP地址。如果您正在使用Docker Desktop或另一种在本地计算机上运行容器的技术,您可以将localhost用作DNS名称。否则,请使用Docker主机的IP或DNS。
如果测试不起作用,请尝试以下操作:
- 确保容器正在运行,使用docker ps命令。容器的名称是c1,您应该会看到端口映射为0.0.0.0:80->8080/tcp。
- 检查防火墙和其他网络安全设置是否阻止了对Docker主机上端口80的流量。
- 重试docker run命令,指定Docker主机上的一个高编号端口,例如 -p 5001:8080。
恭喜,应用程序已经容器化并作为容器运行!
更仔细地查看
既然应用程序已经容器化,让我们更仔细地了解一下其中的一些机制。
docker build命令逐行解析Dockerfile,从顶部开始。
注释行以#字符开头。
所有非注释行都是指令(Instructions),其格式为 [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 407B 0.0s
=> [build-client 1/1] RUN go build -o /bin/client ./cmd/client 15.8s
=> [build-server 1/1] RUN go build -o /bin/server ./cmd/server 14.8s
运行docker images以查看新的镜像
$ docker images
REPO TAG IMAGE ID CREATED SIZE
multi stage 638e639de548 3 minutes ago 15MB
最终的生产镜像只有15MB。这比用于创建构建的250MB基础镜像要小得多。这是因为多阶段构建的最后一个阶段使用了微小的scratch镜像,仅添加了已编译的客户端和服务器二进制文件。
以下docker history命令显示了最终的生产镜像,只有两个图层 - 一个复制客户端二进制文件,另一个复制服务器二进制文件。此最终生产镜像不包括之前的任何构建阶段。
$ docker history multi:stage
IMAGE CREATED CREATED BY SIZE
638e639de548 6 minutes ago ENTRYPOINT ["/bin/server"] 0B
6 minutes ago COPY /bin/server /bin/ # buildkit 7.46MB
6 minutes ago COPY /bin/client /bin/ # buildkit 7.58MB
多阶段构建和构建目标
也可以从一个单独的Dockerfile构建多个镜像。
在我们的示例中,我们可能希望为客户端和服务器二进制文件创建单独的镜像。我们可以通过将Dockerfile中的最终prod阶段拆分为两个阶段来实现。在存储库中,这被命名为Dockerfile-final。
FROM golang:1.20-alpine AS base
WORKDIR /src
COPY go.mod go.sum .
RUN go mod download
COPY . .
FROM base AS build-client
RUN go build -o /bin/client ./cmd/client
FROM base AS build-server
RUN go build -o /bin/server ./cmd/server
FROM scratch AS prod-client
COPY --from=build-client /bin/client /bin/
ENTRYPOINT [ "/bin/client" ]
FROM scratch AS prod-server
COPY --from=build-server /bin/server /bin/
ENTRYPOINT [ "/bin/server" ]
唯一的变化是之前称为prod的最后两个构建阶段现在被拆分为两个阶段。
我们可以在两个docker build命令中引用这些阶段名称,如下所示。使用 -f 标志来引用名为Dockerfile-final的Dockerfile,其中包含了这两个独立的prod阶段。
$ docker build -t multi:client --target prod-client -f Dockerfile-final .
$ docker build -t multi:server --target prod-server -f Dockerfile-final .
检查构建和镜像的大小
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
multi client 0d318210282f 23 minutes ago 7.58MB
multi server f1dbe58b5dbe 39 minutes ago 7.46MB
multi stage 638e639de548 23 minutes ago 15MB
所有三个镜像都存在。客户端和服务器镜像的大小各约为阶段镜像的一半。这是因为阶段镜像包含了客户端和服务器二进制文件。
多平台构建
docker build命令允许您使用单个命令为多个不同的平台构建镜像。举个快速的例子,我在配有ARM芯片的M1 Mac上构建了本书的所有镜像。然而,我使用多平台构建来为其他平台如AMD64构建镜像。这样,无论您是在ARM还是AMD(x64)上,都可以使用本书中的镜像。
您需要使用docker buildx命令来执行多平台构建。幸运的是,它随着Docker Desktop以及许多现代Docker引擎安装一起提供。
以下步骤将配置docker buildx,并为您演示一个多平台构建。这些步骤已在M1 Mac上的Docker Desktop上进行了测试。
确保您已安装了Buildx。
$ docker buildx version
github.com/docker/buildx v0.10.4 c513d34
创建一个名为docker的构建器,使用docker-container端点。
$ docker buildx create --driver=docker-container --name=container
从本书的GitHub存储库的web-fe目录中运行以下命令。该命令为以下三个平台构建镜像,并直接导出到Docker Hub:
- linux/amd64
- linux/arm64
- linux/arm/v7
请务必替换为您自己的Docker ID,因为该命令会直接推送到Docker Hub,并且如果尝试推送到我的存储库,将会失败。
$ docker buildx build --builder=container
--platform=linux/amd64,linux/arm64,linux/arm/v7
-t nigelpoulton/ddd-book:ch8.1 --push .
[+] Building 79.3s (24/24) FINISHED
=> CACHED [linux/amd64 2/5] RUN apk add --update nodejs npm curl 0.0s
=> CACHED [linux/arm64 2/5] RUN apk add --update nodejs npm curl 0.0s
=> CACHED [linux/arm/v7 2/5] RUN apk add --update nodejs npm curl 0.0s
=> [linux/amd64 3/5] COPY . /src 0.0s
=> [linux/arm/v7 3/5] COPY . /src 0.0s
=> [linux/arm64 3/5] COPY . /src 0.0s
=> => pushing layers 31.5s
=> => pushing manifest for docker.io/nigelpoulton/ddd-book:web0.2@sha256:8fc61... 3.6s
=> [auth] nigelpoulton/ddd-book:pull,push token for registry-1.docker.io 0.0s
输出被截断了,但请注意两个重要的事情。Dockerfile中的所有指令都会执行三次 - 每个目标平台执行一次。最后三行显示了图像层被推送到Docker Hub。
一些最佳实践
在结束本章之前,让我们列出一些最佳实践。这个列表并不意味着是详尽无遗的。
充分利用构建缓存
Docker使用的构建器使用缓存来加速构建过程。最好的方式是在干净的Docker主机上构建一个新的镜像,然后立即重复相同的构建。第一次构建会拉取镜像并花费时间构建图层。第二次构建几乎会立即完成。这是因为第一次构建的图层和其他构建产物被缓存并在后续构建中利用。
正如我们所知,docker构建过程从顶部开始逐行迭代Dockerfile。对于每个指令,Docker会查看它的缓存中是否已经有该指令的图像层。如果有,这是一个缓存命中,它将使用该图层。如果没有,这是一个缓存未命中,它会从该指令构建一个新的图层。获得缓存命中可以显著加速构建过程。
让我们更详细地看一下。
我们将使用以下Dockerfile作为示例:
FROM alpine
RUN apk add --update nodejs nodejs-npm
COPY . /src
WORKDIR /src
RUN npm install
EXPOSE 8080
ENTRYPOINT ["node", "./app.js"]
第一条指令告诉Docker使用alpine:latest镜像作为基础镜像。如果此镜像已经存在于主机上,构建器将继续执行下一条指令。如果镜像不存在,它将从Docker Hub拉取。
接下来的指令(RUN apk...)运行一个命令来更新包列表并安装nodejs和nodejs-npm。在执行该指令之前,Docker会检查构建缓存,以查找是否存在一个图像层,该图像层是使用相同的基础镜像并使用与即将执行的相同指令构建的。在这种情况下,它正在寻找一个直接在alpine:latest之上构建的图像层,通过执行RUN apk add --update nodejs nodejs-npm指令。
如果找到了一个图层,它会链接到该图层,并在缓存的情况下继续构建。如果没有找到图层,则会使缓存失效并构建该图层。这个使缓存失效的操作会在构建的剩余部分内使其失效。这意味着所有后续的Dockerfile指令都会完整地完成,而不会尝试引用构建缓存。
假设Docker已经有一个针对这个指令的图层,所以我们有了一个缓存命中。假设该图层的ID是AAA。
接下来的指令将一些代码复制到镜像中(COPY . /src)。先前的指令导致了一个缓存命中,这意味着Docker可以检查是否有一个缓存的图层,该图层是使用AAA图层和COPY . /src命令构建的。如果有,它会链接到该图层并继续执行下一条指令。如果没有,它会构建该图层,并使缓存在构建的其余过程中失效。
这个过程会在Dockerfile的其余部分中继续进行。
还有几点是很重要的。
首先,只要任何指令导致缓存未命中(没有找到该指令的现有图层),缓存就会失效,并且在构建的其余过程中不再检查。这对于如何编写Dockerfile具有重要影响。例如,您应该尽量以将可能导致缓存失效的指令放在Dockerfile的末尾的方式编写它们。这意味着直到构建的后期阶段才会发生缓存未命中 - 使构建尽可能充分地从缓存中受益。
您可以通过将--no-cache标志传递给docker build命令来强制构建过程忽略整个缓存。
另外,还需要理解COPY和ADD指令包括一些步骤,以确保复制到镜像中的内容自上次构建以来没有发生变化。例如,Dockerfile中的COPY . /src指令自上次构建以来可能没有改变,但是...被复制到镜像中的目录的内容发生了变化!
为了防止这种情况,Docker对每个要复制的文件执行了校验和。如果校验和不匹配,缓存将失效并且会构建一个新的图层。
压缩镜像
将镜像压缩并不是一个真正的最佳实践,因为它有利弊。
从高层次来看,压缩镜像遵循正常的构建过程,但添加了一个额外的步骤,将所有内容压缩到单个图层中。它可以减小镜像的大小,但不允许与其他镜像共享图层。
如果想创建一个压缩的镜像,只需在docker build命令中添加--squash标志。
图8.11显示了压缩图像带来的一些低效问题。这两个镜像完全相同,除了一个被压缩了,另一个没有。非压缩的图像与主机上的其他图像共享图层(节省磁盘空间),但压缩的图像不共享。在docker push命令中,压缩的图像还需要将每一个字节发送到Docker Hub,而非压缩的图像只需要发送唯一的图层。
如果您正在构建Linux镜像并使用apt软件包管理器,您应该在apt-get安装命令中使用no-install-recommends标志。这将确保apt只安装主要依赖项(在Depends字段中的软件包),而不是建议或建议的软件包。这可以大大减少下载到您的镜像中的不需要的软件包的数量。
容器化一个应用程序 - 命令
-
docker build是一个命令,它读取Dockerfile并将一个应用程序容器化。-t标志用于给镜像打标签,-f标志允许您指定Dockerfile的名称和位置。使用-f标志,您可以使用任意名称和任意位置的Dockerfile。构建上下文是您的应用程序文件所在的位置,可以是本地Docker主机上的目录,也可以是远程Git存储库。
-
Dockerfile中的FROM指令指定了正在构建的新镜像的基础镜像。通常情况下,它是Dockerfile中的第一条指令,最佳实践是在此行上使用官方仓库中的镜像。FROM也用于在多阶段构建中区分新的构建阶段。
-
Dockerfile中的RUN指令允许您在构建过程中在镜像内部运行命令。通常用于更新软件包和安装依赖项。每个RUN指令都会向总体镜像添加一个新的图层。
-
Dockerfile中的COPY指令将文件添加到镜像中作为一个新的图层。通常用于将应用程序代码复制到镜像中。
-
Dockerfile中的EXPOSE指令记录应用程序使用的网络端口。
-
Dockerfile中的ENTRYPOINT指令设置在将镜像作为容器启动时要运行的默认应用程序。
-
其他一些Dockerfile指令包括LABEL、ENV、ONBUILD、HEALTHCHECK、CMD等。