在过去的一段时间里,如果你曾尝试将应用程序容器化,你可能会遇到一个令人头疼的问题:最终的 Docker 镜像体积过于臃肿。 在传统的构建流程中,为了确保应用程序能够顺利构建和运行,我们往往需要在镜像中安装各种构建工具、开发库、编译器甚至是文档。这些工具对于应用程序的运行时来说通常是多余的,但它们却会实实在在地占据宝贵的磁盘空间,增加攻击面,并拖慢部署速度。
为了解决这一痛点,我们引入了多阶段构建的概念。在这篇文章中,我们将深入探讨什么是多阶段 Dockerfile,它是如何工作的,以及如何利用它来构建生产级别的精简镜像。我们还将通过具体的代码示例,解析每一个指令背后的逻辑,并分享一些实战中的最佳实践,帮助你彻底掌握这一关键技术。
目录
为什么我们需要多阶段构建?
在 Docker 引入多阶段构建之前,我们要么需要维护两个独立的 Dockerfile(一个用于构建,一个用于运行),要么不得不忍受包含大量冗余文件的单一镜像。这不仅导致镜像体积庞大,还可能因为包含不必要的系统工具(如 gcc、curl)而带来潜在的安全风险。
现在,多阶段构建允许我们在单个 Dockerfile 中定义多个构建阶段。每个阶段就像是一个独立的容器,只专注于完成特定的任务。我们可以在第一个阶段安装所有繁重的构建工具并编译代码,然后在第二个阶段只复制编译好的产物到干净的运行时环境中。中间产生的所有临时文件、源代码包和构建工具都被丢弃,从而生成一个极其精简的生产级镜像。
图示:通过多阶段构建,我们可以显著减小最终镜像的体积。
多阶段 Dockerfile 的工作原理
在多阶段 Dockerfile 中,我们可以定义多个构建阶段,每个阶段都封装了一组特定的指令和依赖项。这些阶段可以通过名称互相引用,从而实现它们之间的无缝通信。简单来说,创建多阶段 Dockerfile 的第一阶段专门用于构建应用程序代码,而随后的阶段则专注于打包应用程序并为运行时做准备。
在早期阶段生成的中间镜像会在其使命完成后立即被丢弃,从而使生成的最终生产镜像仅包含运行应用程序所需的必要组件。
实战演练 1:Java 应用的多阶段构建
让我们从一个经典的 Java 应用程序示例开始。假设我们要构建一个 Maven 项目,并将其部署到 Tomcat 服务器中。如果不使用多阶段构建,我们可能需要在一个镜像中同时安装 Maven 和 Tomcat,这会导致镜像非常庞大。下面,让我们看看如何使用多阶段构建来优化它。
# =====================================
# 第一阶段:构建阶段
# =====================================
# 使用包含 Maven 和 JDK 8 的镜像作为基础镜像,并命名为 builder
FROM maven:3.5-jdk-8 AS builder
# 设置容器内的工作目录
WORKDIR /app
# 将当前目录(宿主机)的所有文件复制到容器内的 /app 目录
COPY . .
# 执行 Maven 命令,清理并打包项目,生成 WAR 文件
RUN mvn clean package
# =====================================
# 第二阶段:最终运行阶段
# =====================================
# 使用仅包含 JRE 8 的 Tomcat 镜像作为最终镜像的基础
# 注意:这里不需要 Maven,也不需要源代码
FROM tomcat:8.0.20-jre8
# 从上一阶段(builder)复制编译好的 WAR 文件到 Tomcat 部署目录
# --from=builder 参数指定了源阶段的名称
COPY --from=builder /app/target/maven-web-app*.war /usr/local/tomcat/webapps/maven-web-application.war
#### 代码深度解析
让我们逐行分析上面的 Dockerfile,看看每一步究竟发生了什么。
构建阶段
-
FROM maven:3.5-jdk-8 AS builder:
这一行是构建阶段的起点。我们拉取了官方的 Maven 镜像,它自带了 JDK 8。我们通过 AS builder 给这个阶段起了一个别名,方便后面引用。这个镜像体积相对较大(几百兆),因为它包含了 Maven 和 JDK,这是正常的,因为它不会出现在最终镜像中。
-
WORKDIR /app:
我们将工作目录设置为 /app。如果目录不存在,Docker 会自动创建它。接下来的所有指令都会在这个目录下执行。
-
COPY . .:
这条指令将宿主机当前目录下的所有文件(包括 INLINECODE4c506c05 和源代码)复制到容器的 INLINECODE5ed23c64 目录中。这是为了让 Maven 能够读取项目配置并进行编译。
-
RUN mvn clean package:
这是构建阶段的核心命令。我们在容器内运行 Maven,下载依赖、编译代码并打包成 WAR 文件。执行完这一步后,我们在 /app/target 目录下就得到了需要的制品。
最终阶段
-
FROM tomcat:8.0.20-jre8:
这是最终镜像的起点。注意,这里我们使用的是只包含 JRE(Java 运行时环境)的 Tomcat 镜像,而不是 JDK。这比带有编译工具的镜像要小得多。
-
COPY --from=builder ...:
这是多阶段构建的精髓所在。我们不再是从宿主机复制文件,而是从上一个名为 builder 的阶段复制文件。Docker 的守护进程会自动提取上一阶段 INLINECODE59ff35eb 目录下的 WAR 文件,并将其放入当前阶段的 Tomcat 部署目录中。通配符 INLINECODE9ccc6079 确保我们即使文件名带有版本号也能正确匹配。
构建过程的结果是,我们最终得到的镜像只包含 Tomcat 和我们的 WAR 包,完全不含 Maven、源代码或编译缓存。
构建阶段可视化
当你执行 docker build 时,你可以观察到这些阶段的执行情况:
命名构建阶段与最佳实践
在上面的例子中,我们已经使用了 AS builder 来命名阶段。这是一个非常重要的最佳实践。
INLINECODE346e234e 指令中的 INLINECODE8dd91aa2 关键字允许我们为构建阶段分配一个易于理解的名称。这种命名机制不仅让 Dockerfile 的可读性更强,还能确保如果以后我们需要重新编排 Dockerfile(例如调换阶段顺序),像 COPY --from=builder 这样的指令依然能保持有效。
实用见解:你可以创建任意数量的阶段。例如,你可以有一个用于编译的阶段,一个用于测试的阶段,还有一个用于最终打包的阶段。你可以选择只在测试通过后,才将文件从测试阶段复制到最终阶段。
在特定构建阶段停止
在开发和调试过程中,有时候我们只想验证构建是否成功,而不需要打包整个运行时。我们可以使用 --target 标志来指定构建停止的目标阶段。
docker build --target builder -t your-image-name .
命令解析:
- INLINECODE4b38030f:定义了构建的目标为 INLINECODEab3b62a6 阶段。Docker 引擎会执行完
builder阶段的所有指令后立即停止,而不会继续执行后续的 Tomcat 阶段。这对于调试构建脚本或获取编译后的二进制文件非常有用。 -t your-image-name:给生成的中间镜像打上一个标签。.:指定构建上下文为当前目录。
实战演练 2:现代前端应用
让我们看一个更现代的例子,比如 Node.js 或 React 应用。这类应用通常需要 INLINECODE01fa14e9 来安装庞大的 INLINECODE2a217065 依赖,还需要 npm run build 来生成静态资源。最终产物通常是 HTML、CSS 和 JS 文件,它们可以在 Nginx 中直接托管。
如果不使用多阶段构建,你的镜像里可能需要同时包含 Node.js 和 Nginx,或者包含了源代码和所有 node_modules(这显然是巨大的安全隐患)。
# 阶段 1: 构建前端资源
FROM node:18-alpine AS build-stage
WORKDIR /app
COPY package*.json ./
# 先安装依赖以利用 Docker 缓存层
RUN npm install
COPY . .
# 运行构建脚本,生成 dist 目录
RUN npm run build
# 阶段 2: 使用 Nginx 托管静态文件
FROM nginx:alpine AS production-stage
# 复制构建产物的 default.conf 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 从上一阶段复制构建好的静态文件到 Nginx 目录
COPY --from=build-stage /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
在这个例子中,第一阶段只有 Node.js 环境,用于打包;第二阶段只有 Nginx,用于提供 HTTP 服务。你的最终镜像可能只有几十兆,且不包含任何源代码。
实战演练 3:编译型语言
最后,让我们看一个 Golang 的例子。Go 是编译型语言,最终生成的是一个二进制可执行文件。这是多阶段构建最经典的用例。
# 阶段 1: 构建
FROM golang:1.21 AS builder
WORKDIR /src
COPY main.go .
# 编译代码,生成可执行文件 app
RUN CGO_ENABLED=0 go build -o app main.go
# 阶段 2: 运行
# 使用 scratch 基础镜像,这是一个空镜像
FROM scratch
# 从 builder 阶段复制编译好的二进制文件
COPY --from=builder /src/app /app
# 由于没有 shell,这里必须使用数组格式
CMD ["/app"]
注意:这里我们使用了 INLINECODE3ae13e3d 作为基础镜像。它是 Docker 中最小的基础镜像,几乎什么都没有。因为我们编译的 Go 程序是静态链接的,不需要任何系统库,所以可以直接运行在 INLINECODE84e7b531 上。这种镜像极其安全,因为根本没有 Shell 或其他工具可以被利用。
极致优化:Distroless 镜像
说到安全性,我们必须提到 Distroless 镜像。Distroless 镜像是由 Google 提供的一组特殊镜像,它们只包含您的应用程序及其运行时依赖项,而没有包管理器、Shell 或任何标准的操作系统实用工具(如 bash、curl、ls)。
为什么要使用 Distroless?
- 极致精简:由于没有 Shell 和包管理器,Distroless 镜像非常小,且减少了攻击面。
- 更高的安全性:如果攻击者攻破了你的应用程序并试图进入容器,他们将找不到 Shell 或任何调试工具,这大大限制了他们能做的事情。
如何使用?
多阶段构建是使用 Distroless 的最佳搭档。你可以使用标准的构建镜像进行编译,然后将产物复制到 Distroless 镜像中。
# 构建阶段
FROM golang:1.21 AS build
WORKDIR /src
# ... 编译步骤 ...
RUN go build -o app .
# 运行阶段:使用 Distroless
# 假设我们的应用需要 certs(CA 证书)
FROM gcr.io/distroless/static-debian12
COPY --from=build /src/app /app
CMD ["/app"]
常见问题:我该如何调试 Distroless 容器?
解决方案:因为 Distroless 镜像没有 Shell,你无法使用 docker exec -it /bin/bash 进入容器。Google 建议在生产环境中使用 Distroless,而在开发或调试阶段,你可以临时切换回带有调试工具(如 Alpine 或 Debian)的基础镜像,或者使用 sidecar 模式的调试容器。
总结与后续步骤
在这篇文章中,我们不仅学习了“什么是多阶段 Dockerfile”,更重要的是,我们学会了如何像专业 DevOps 工程师一样思考镜像优化。我们掌握了:
- 核心原理:通过 INLINECODEa9d5b62c 语法和 INLINECODE697ca91e 指令,分离构建环境与运行环境。
- 实战应用:通过 Java、Node.js 和 Go 的实际案例,看到了多阶段构建在减小镜像体积和提升安全性方面的巨大威力。
- 高级技巧:了解了如何命名阶段、如何中断构建,以及如何结合 Distroless 镜像实现极致安全。
给你的建议:
- 审查现有镜像:找一些你目前使用的 Dockerfile,看看是否可以通过引入多阶段构建来减小体积。
- 关注构建缓存:在多阶段构建中,尽量把不常变化的指令(如 INLINECODE7f3726da)放在前面,常变化的指令(如 INLINECODE3c12c2a5)放在后面,以便利用 Docker 的缓存机制加快构建速度。
- 安全第一:尝试将你的 Go 或 Python 应用迁移到 Distroless 镜像中,体验一下“零”操作系统依赖的安全感。
多阶段构建是现代容器化应用的标准配置。希望这篇文章能帮助你写出更高效、更专业的 Dockerfile。