编写Dockerfile的6个最佳实践

前言

Docker 自 2013 年问世以来,仅经过了10年多的发展,如今已成为容器技术的行业标准。它支持所有主流操作系统和云平台,几乎可以对任何类型的应用程序进行容器化,使得应用程序能够在不同的机器、集群甚至云服务之间轻松迁移。每个 Docker 容器的构建都始于一个 Dockerfile,因此遵循最佳实践来编写 Dockerfile 是非常重要的。下面让我们来探讨一些这样的实践。

1. 按需添加文件

编写 Dockerfile 时,最关键的是要考虑缓存机制。每次根据 Dockerfile 构建 Docker 镜像时,Docker 都会保存构建过程中产生的缓存。当你再次构建镜像时,如果缓存可用,可以显著加快构建速度。例如,执行 npm install 命令可能需要几分钟时间来下载和安装 Node.js 项目的所有依赖。因此,你希望在执行 docker build 命令时能够利用这个缓存,以便在下一次构建时能够迅速从缓存中提取,而不是每次都要等待几分钟,这样既烦人又低效。如果你不关心缓存,你的 Dockerfile 可能看起来像这样:

FROM node:20
COPY . .
RUN npm install
RUN npm build

这个 Dockerfile 使用 DockerCOPY 指令将所有项目文件(包括源代码)添加到镜像中,然后执行 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

FROM node:20
COPY package*.json .
RUN npm install
COPY . .
RUN npm build

这里我使用了 package*.json 来同时复制 package.jsonpackage-lock.json。如你所见,我仅在执行 npm install 之后,且在执行 npm build 之前复制了应用程序的整个源代码,因为 npm build 依赖于源代码。这样,如果源代码发生变化,由于 package.json 保持不变,npm install 可以从缓存中提取。只有当我们更改 package.json 中的某些依赖项时,才需要重新运行 npm install

注意:示例 Dockerfile 旨在说明缓存机制的工作原理。一个实际的 Node.js 应用程序的 Dockerfile 会有所不同。例如,在添加文件和执行 npm 命令之前,你应该先设置 WORKDIR。

2. 添加 .dockerignore 文件

当你不希望将某个文件推送到 Git 仓库时,你会将其添加到 .gitignore 文件中。同样地,当你不希望将文件包含在 Docker 构建上下文中时,你应该将其添加到 .dockerignore 文件中。构建 Docker 镜像时,会指定构建上下文路径,例如 docker build -t image_tag .。这里的最后一个点表示使用当前工作目录作为构建上下文。构建上下文随后会被发送(复制)到 Docker 守护进程,由其构建镜像。以 Node.js 为例,假设在构建 Docker 镜像之前,我们使用 npm installnpm start 在本地运行了应用程序。由于这些命令是直接在本地机器上执行的,npm 会在项目目录中创建 node_modules 目录,存放所有下载的依赖。项目目录的结构可能如下所示:

node_modules/
public/
src/
package.json
package-lock.json

请注意,node_modules 目录的大小很容易达到 1GB。假设我们使用 npm start 在本地测试了应用程序,现在想要构建应用程序的 Docker 镜像。因此,我们在项目目录中创建了一个 Dockerfile,类似于前面提到的那个。然后我们执行 docker build -t image_tag . 命令。但是,查看构建日志,你会发现构建上下文的大小接近 1GB:

 => [internal] load build context
 => => transferring context: 893.00MB

这是因为整个项目目录(包括 node_modules)都被作为构建上下文发送了。我们希望避免发送 node_modules,因此创建了一个 .dockerignore 文件,并在其中添加了 node_modules。现在再构建 Docker 镜像,日志显示的构建后的大小比上次要小得多:

 => [internal] load build context
 => => transferring context: 11.41kB

我们的项目结构现在变为:

node_modules/
public/
src/
Dockerfile
.dockerignore
package.json
package-lock.json

记住,.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 指令,不如合并为一个:

RUN apt-get update && apt-get install -y \
  git \
  jq \
  kubectl

注意我如何将包名分成多行,并按字母顺序排列以提高可读性。如果使用多个 RUN 指令,每条指令都会创建一个新的层,这会使构建过程变慢,并且占用更多的存储空间。

4. 设置环境变量和版本

使用 ENV 指令,你可以在构建过程中设置环境变量,这些变量将保留在镜像中,并在容器运行时可用。例如,你可以这样优雅地修改 PATH 变量:

ENV PATH=/opt/maven/bin:${PATH}

或者,如果你运行的是 Node.js 并且在启动服务器时读取 process.env.PORT,你可以在 Dockerfile 中设置服务器的端口:

ENV PORT=8080

通常建议尽可能使用环境变量来配置你的应用程序。当应用程序部署时,更改环境变量总是比修改代码中的某个文件然后重新部署应用程序来得容易。你也可以使用 ENV 指令以直观的方式设置某些依赖项的版本:

ENV KUBECTL_VERSION=1.27
RUN curl -fsSL https://pkgs.k8s.io/core:/stable:/v${KUBECTL_VERSION}/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
...

谈到版本和 Docker,强烈建议不要使用镜像的 "latest" 标签。同样,也不应该使用过于具体的版本号,如 1.27.4,因为这样你将无法获得重要的补丁更新,这些更新可能会修复错误或提高安全性。相反,你应该使用主版本号(x)或次版本号(x.y):

FROM python:3.10

只写 "python" 也可以,但那样 Docker 将始终拉取最新版本,如果新版本中有任何破坏性变更,这可能会破坏你的应用程序。

5. 使用多阶段构建

多阶段构建是 Docker 的一个强大且可能被低估的功能。这个概念是将镜像的构建过程分为多个阶段。最终,只有最后一个阶段的内容会被合并到最终的镜像中,而之前的阶段则会被丢弃。一个典型的用例是在第一阶段使用构建工具和源代码来构建二进制文件,然后只将这些二进制文件复制到下一个阶段。最终的镜像将不包含源代码和构建工具,这很有意义,因为最终的镜像应该只需要运行应用程序,而不是构建它。

FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build-env
WORKDIR /App

# Build the app
COPY . ./
RUN dotnet restore
RUN dotnet publish -c Release -o out

# Build runtime image
FROM mcr.microsoft.com/dotnet/aspnet:7.0
WORKDIR /App
COPY --from=build-env /App/out .
ENTRYPOINT ["dotnet", "DotNet.Docker.dll"]

这个官方示例 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 镜像

Alpine 镜像基于以小巧著称的 Alpine Linux,这使得 Alpine 镜像在构建、拉取和运行时理论上更快。

REPOSITORY   TAG           SIZE
python       3.10-alpine   50.4MB
python       3.10-slim     128MB
python       3.10          1GB

以 Python 为例,Alpine 镜像的大小是完整 Debian 基础镜像的 1/20。Python 还提供了 Slim 镜像,它基于 Debian,但去除了大多数标准包。这些小型镜像的常见问题是,如果你的应用程序稍显复杂,你可能需要安装额外的包,这将不可避免地延长构建时间并增加镜像大小,从而违背了最初选择它们的初衷。

关于我
loading