自 2013 年推出以来,Docker 已发展了十多年,现已成为容器技术的行业标准。
它支持所有主流操作系统和云平台,几乎可以将任何类型的应用程序容器化,使应用程序可以在不同的机器、集群甚至云服务之间轻松迁移。
每个 Docker 容器都是通过 Dockerfile 构建的,因此在编写 Dockerfile 时必须遵循最佳实践。
下面我们就来探讨其中的一些做法。
1.根据需要添加文件
在编写 Dockerfile 时,最关键的是要考虑缓存机制。
每次从 Dockerfile 构建 Docker 镜像时,Docker 都会保存在构建过程中生成的缓存。
如果在重建镜像时缓存可用,就能大大加快构建过程。例如,执行 npm install 命令可能需要几分钟才能下载并安装 Node.js 项目的所有依赖项。
因此,在运行 docker 构建命令时,你需要利用这个缓存,这样下一次构建就能快速从缓存中提取数据,而不是每次都要等上几分钟,这既恼人又低效。
如果你不在乎缓存,你的 Dockerfile 可能看起来像这样:
1 | FROM node:20 |
该 Dockerfile 使用 Docker 的 COPY 命令将所有项目文件(包括源代码)添加到镜像中,然后执行 npm install 安装依赖项,最后运行 npm build 从源代码中构建应用程序。
虽然这是可行的,但效率不高。假设你运行了 docker build,然后修改了项目文件中的一些业务逻辑,现在又想重建。
第一行 FROM node:20 保持不变,因此 Docker 会使用缓存来处理这部分内容,但缓存会在第二行 COPY … 中断,因为文件已经更改。
Docker 使用分层缓存机制,Dockerfile 中的每一行通常代表一层。
这意味着,一旦某个层的缓存被破坏,所有后续层将不会使用缓存进行构建。
这是因为 Docker 假定后面的每一层都依赖于前面的所有层,这是一个合理的假设。
在我们的示例中,npm install 会在项目文件发生变化时运行,但它实际上并不依赖于项目的源代码;它只依赖于 package.json 和 package-lock.json 文件。
package.json 定义了 npm 需要安装的所有依赖项。那么,让我们来改进 Dockerfile:
1 | FROM node:20 |
在这里,我使用 package*.json 来复制 package.json 和 package-lock.json。
如你所见,我只在运行 npm install 之后和运行 npm build 之前复制整个应用程序的源代码,因为 npm build 依赖于源代码。
这样,如果源代码发生变化,只要 package.json 保持不变,就可以从缓存中调用 npm install。
只有当我们更改 package.json 中的某些依赖项时,才需要重新运行 npm install。
真正的 Node.js 应用程序 Dockerfile 会有所不同。例如,在添加文件和运行 npm 命令之前,应该设置 WORKDIR。
2.添加 .dockerignore 文件
当你不想把某个文件推送到 Git 仓库时,你可以把它添加到 .gitignore 文件中。
同样,当你不想在 Docker 构建上下文中包含某个文件时,你应该把它添加到 .dockerignore 文件中。
构建 Docker 镜像时,需要指定构建上下文路径,如 docker build -t image_tag .
这里,最后一个点表示使用当前工作目录作为构建上下文。
然后,构建上下文会被发送(复制)到 Docker 守护进程,由它来构建镜像。
以 Node.js 为例,假设我们在本地使用 npm install 和 npm start 来运行应用程序,然后再构建 Docker 镜像。
由于这些命令是直接在本地计算机上执行的,因此 npm 会在项目目录中创建一个 node_modules 目录,以存储所有下载的依赖项。
项目目录结构可能如下:
1 | node_modules/ |
node_modules 目录的大小很容易达到 1GB。假设我们使用 npm start 在本地测试了应用程序,现在想为应用程序构建 Docker 镜像。
因此,我们在项目目录中创建了一个 Dockerfile,与前面提到的类似。
然后,我们执行命令 docker build -t image_tag 。然而,查看构建日志,你会发现构建上下文的大小接近 1GB:
1 | => [internal] load build context |
这是因为整个项目目录(包括 node_modules)都是作为构建上下文发送的。
我们希望避免上传 node_modules,因此创建了一个 .dockerignore 文件,并在其中添加了 node_modules。
现在,当我们再次构建 Docker 镜像时,构建日志显示上下文大小比之前小了很多:
1 | => [internal] load build context |
我们现在的项目结构是:
1 | node_modules/ |
请记住,.dockerignore 文件应始终放在构建上下文的根目录下。
你可能会问,为什么要将 node_modules 排除在构建上下文之外?
这是因为 node_modules 是由 npm 创建的目录,其中并不包含应用程序的源代码。
它是由本地机器上的本地 npm 创建的。在 Docker 中运行的 npm 应在 Docker 镜像中创建自己的 node_modules。
将本地 node_modules 添加到我们的 Docker 镜像中并不是一个好做法。
只需向 Docker 提供应用程序的源代码,然后在 Docker 内部运行构建命令来构建应用程序。这样,Docker 构建就不会与本地构建冲突。
3.一次性执行所有命令
这很简单。你经常会发现自己在使用 apt 或其他软件包管理器安装必要的软件包。
在运行 apt install 之前,必须先运行 apt update。
与其在 Dockerfile 中使用多个 RUN 指令,不如将它们合并为一个指令:
1 | RUN apt-get update && apt-get install -y \ |
请注意我是如何将软件包名称分成多行,并按字母顺序排列,以提高可读性的。
如果使用多条 RUN 指令,每条指令都会创建一个新层,这会减慢构建过程并占用更多存储空间。
4.设置环境变量和版本
使用 ENV 指令,您可以在构建过程中设置环境变量,这些变量将保留在镜像中,并在容器运行时可用。
可以像这样优雅地修改 PATH 变量:
1 | ENV PATH=/opt/maven/bin:${PATH} |
如果正在运行一个 Node.js 应用程序,并读取 process.env.PORT,启动服务器时,可以在 Dockerfile 中设置服务器的端口:
1 | ENV PORT=8080 |
一般建议尽可能使用环境变量配置应用程序。
在部署应用程序时,更改环境变量总是比修改代码中的文件然后重新部署应用程序要容易得多。
还可以使用 ENV 指令直观地设置某些依赖项的版本:
1 | ENV KUBECTL_VERSION=1.27 |
说到版本和 Docker,强烈建议不要对镜像使用 “latest” 标签。
同样,应避免使用过于具体的版本号(如 1.27.4),因为这将使你无法收到重要的补丁更新,而这些更新可能会修复漏洞或提高安全性。
而应使用主版本号(x)或次版本号(x.y):
1 | FROM python:3.10 |
也可以只写 “python”,但这样 Docker 就会一直调用最新版本,如果新版本中有任何破坏性更改,就会破坏应用程序。
5.使用多阶段构建
多阶段构建是 Docker 的一项强大功能,但可能被低估了。其概念是将镜像的构建过程分为多个阶段。
最终,只有最后一个阶段的内容会被包含在最终图像中,而之前的阶段则会被丢弃。
典型的用例是在第一阶段使用构建工具和源代码创建二进制文件,然后只将这些二进制文件复制到下一阶段。
最终镜像将不包含源代码或构建工具,这是有道理的,因为最终镜像只需要运行应用程序,而不需要构建它。
1 | FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build-env |
这个官方示例 Dockerfile 用于构建和运行 ASP.NET 应用程序。请注意 COPY –from=build-env /App/out .命令,它将二进制文件从第一阶段复制到第二阶段。
第一阶段基于包含构建工具的镜像 (mcr.microsoft.com/dotnet/sdk:7.0),而第二阶段基于较小的运行时奖项 (mcr.microsoft.com/dotnet/aspnet:7.0)。
此外,请注意他们是如何指定镜像的次版本的。
6. 优先考虑使用 Slim 和 Alpine Image
Alpine 镜像基于 Alpine Linux,而 Alpine Linux 以轻量级著称,这使得 Alpine 镜像理论上可以更快地构建、调用和运行。
1 | REPOSITORY TAG SIZE |
以 Python 为例,Alpine 镜像的大小是基于 Debian 的完整镜像的 1/20。
Python 还提供 Slim 镜像,它基于 Debian,但去除了大部分标准软件包。