Docker 教程

Docker 多阶段构建

Docker 多阶段构建(Multi-stage builds)是一项非常强大的功能,它允许你通过分离“构建时依赖”和“运行时依赖”来创建高度优化的 Docker 镜像。这个过程主要是在单个 Dockerfile 中使用多个 FROM 指令,其中每个 FROM 语句代表构建过程中的一个不同阶段。你可以将早期阶段生成的产物(Artifacts)有选择性地复制到后续更精简的阶段中,从而得到一个只包含应用程序及其核心运行时组件的、体积明显更小的最终镜像。这种方法直接解决了由于包含不必要的构建工具和库而导致的镜像体积过大、攻击面增加等常见痛点。

1. 理解多阶段构建的概念

传统上,为应用程序创建 Docker 镜像意味着你需要将所有必要的构建工具、编译器和依赖项直接安装到一个单一的镜像中。例如,要构建一个 Go 应用程序,你的 Dockerfile 可能会以一个 Go 基础镜像开始,安装 git 来克隆代码仓库,编译应用程序,最终得到的镜像将包含 Go 编译器、git 以及其他在运行时根本不需要的构建工具。这会导致镜像变得臃肿,占用更多磁盘空间,拉取时间变长,并暴露出更大的安全风险。

多阶段构建通过利用“中间镜像”解决了这个问题,这些中间镜像在完成其使命(例如编译代码)后就会被丢弃。最终镜像基于一个极简的基础镜像构建,并且只从构建阶段复制已编译好的产物。

让我们以一个简单的应用程序为例:

  • 构建阶段 (Build Stage): 该阶段使用一个包含所有必要构建工具的基础镜像(例如,Go 编译器、用于处理前端静态资源的 Node.js、Java 开发工具包 JDK)。它负责编译源代码、运行测试,并生成最终的可执行文件或静态资源。
  • 运行时阶段 (Runtime Stage): 该阶段使用一个体积小得多的基础镜像,通常是一个 “scratch”(空白)镜像或针对体积优化过的操作系统镜像(如 alpine)。然后,它仅仅将构建阶段编译好的产物(可执行文件、静态文件、配置文件)复制到这个极简的镜像中。

2. 多阶段构建的核心优势

  • 显著减小镜像体积: 这是最显著的好处。通过排除构建工具和不必要的库,最终镜像的体积可以大幅度缩减。例如,一个 Go 应用程序的镜像可能会从几百兆字节缩小到几十兆字节,如果是基于 scratch 构建,甚至只有几兆字节。
  • 提升安全性: 更小的镜像意味着更小的攻击面。安装的软件越少,潜在的、可被利用的漏洞就越少。
  • 加快镜像构建速度(针对最终阶段): 虽然整体构建过程可能涉及更多步骤,但最终镜像的层数更少,且运行时阶段的基础镜像通常更小,这在完成首次完整构建后,有可能加快后续的构建速度。
  • 保持镜像纯净: 运行时镜像只包含应用程序运行绝对必需的内容,从而提供了一个更加专注且易于管理的运行环境。
  • 关注点分离 (Separation of Concerns): 构建逻辑与运行时逻辑完全分离,使得 Dockerfile 更加清晰,也更易于维护。

3. 多阶段构建的工作原理

Dockerfile 中的每一个 FROM 指令都会启动一个新的构建阶段。你可以选择使用 AS <stage-name> 来为每个阶段命名。随后,你就可以在 COPY --from=<stage-name> 指令中引用这个名称。

基础示例:

# 阶段 1:构建应用程序
FROM golang:1.20 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/my-app ./cmd/server

# 阶段 2:创建最终的精简镜像
FROM alpine:3.18
WORKDIR /app
# 从 'builder' 阶段复制已编译的可执行文件
COPY --from=builder /app/my-app .
EXPOSE 8080
CMD ["./my-app"]

在这个例子中:

  1. 第一个 FROM golang:1.20 AS builder 定义了 builder(构建器)阶段,它使用了一个相对庞大的 Go 开发环境镜像。
  2. go build 命令负责编译 Go 应用程序。
  3. 第二个 FROM alpine:3.18 定义了最终阶段,使用了一个极其小巧的 Alpine Linux 镜像。
  4. COPY --from=builder /app/my-app . 是最核心的部分。它只将 builder 阶段编译好的 my-app 可执行文件复制到当前(最终)阶段。builder 阶段的 Go 编译器和开发工具都不会被打包进最终的 alpine 镜像中。

4. 实战应用与代码示例

让我们通过几个不同技术栈的例子来深入探索多阶段构建的应用。

4.1 示例 1:构建 Go 应用程序

由于 Go 能够将程序编译成单一的静态二进制文件,它是多阶段构建最经典的应用场景。

场景: 我们有一个简单的 Go HTTP 服务器应用需要容器化。

应用程序代码 (main.go):

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "你好,来自 Go 容器!")
	})
	fmt.Println("服务器正在 8080 端口启动...")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

使用多阶段构建的 Dockerfile:

# 阶段 1:构建阶段 (The Build Stage)
# 使用 Go 开发镜像来编译应用程序。
FROM golang:1.20 AS builder

# 在容器内部设置用于构建过程的工作目录。
WORKDIR /app

# 先复制 go.mod 和 go.sum 文件以缓存依赖项。
# 这样可以确保如果只有源代码发生改变,就不会重新下载依赖项。
COPY go.mod go.sum ./
RUN go mod download

# 复制剩余的应用程序源代码。
COPY . .

# 编译 Go 应用程序。
# CGO_ENABLED=0 确保生成一个没有 C 依赖的静态链接二进制文件。
# GOOS=linux 确保它是为 Linux(容器的目标操作系统)编译的。
# -o /app/my-app 指定输出的可执行文件名称和路径。
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/my-app ./

# 阶段 2:最终(运行时)阶段 (The Final Runtime Stage)
# 使用最小化的 Alpine Linux 镜像。
FROM alpine:3.18

# 为最终的应用程序设置工作目录。
WORKDIR /app

# 仅仅从 'builder' 阶段将编译好的可执行文件复制到最终镜像中。
# 这是多阶段构建的核心所在。
COPY --from=builder /app/my-app .

# 暴露应用程序监听的端口。
EXPOSE 8080

# 定义容器启动时运行应用程序的命令。
CMD ["./my-app"]

构建并运行:

docker build -t go-app-optimized .
docker run -p 8080:8080 go-app-optimized

在浏览器中访问 http://localhost:8080,你将看到 “你好,来自 Go 容器!”。相比于直接使用 golang:1.20 作为运行时的单阶段构建,最终生成的镜像体积要小得多。

4.2 示例 2:构建包含前端的 Node.js 应用 (React/Vue/Angular)

这个场景涉及构建后端和前端资源,而前端构建工具在运行时是不需要的。

场景: 一个 Node.js 后端,用于提供 React 前端编译后的静态文件服务。

后端代码 (server.js):

const express = require('express');
const path = require('path');
const app = express();
const port = 3000;

// 提供来自 'build' 目录的静态文件服务
app.use(express.static(path.join(__dirname, 'build')));

app.get('/api/hello', (req, res) => {
    res.json({ message: '你好,来自 Node.js API!' });
});

// 对于任何其他请求,返回 React 应用的 index.html
app.get('*', (req, res) => {
    res.sendFile(path.join(__dirname, 'build', 'index.html'));
});

app.listen(port, () => {
    console.log(`服务器正在监听 http://localhost:${port}`);
});

前端代码(React 应用示例 package.json):

{
  "name": "my-react-app",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-scripts": "5.0.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build"
  }
}

(假设 React 应用具有标准的 src/App.jspublic/index.html 目录结构)

使用多阶段构建的 Dockerfile:

# 阶段 1:构建 React 前端
FROM node:18-alpine AS frontend_builder
# 设置工作目录
WORKDIR /app/frontend
# 复制前端 package.json 和 package-lock.json 以安装依赖
COPY frontend/package*.json ./
RUN npm install
# 复制前端其余源码
COPY frontend .
# 构建 React 应用程序。这会生成包含静态文件的 'build' 目录。
RUN npm run build

# 阶段 2:构建 Node.js 后端
FROM node:18-alpine AS backend_builder
WORKDIR /app/backend
# 复制后端 package.json 和 package-lock.json
COPY backend/package*.json ./
RUN npm install --production # 仅安装生产环境依赖
# 复制后端源码
COPY backend .

# 阶段 3:创建最终的运行时镜像
FROM node:18-alpine
WORKDIR /app
# 从 'frontend_builder' 阶段复制构建好的前端静态资源
COPY --from=frontend_builder /app/frontend/build ./build
# 从 'backend_builder' 阶段复制生产环境依赖和后端代码
COPY --from=backend_builder /app/backend/node_modules ./node_modules
COPY --from=backend_builder /app/backend/server.js .
COPY --from=backend_builder /app/backend/package.json . # 运行 npm start 等命令所需
EXPOSE 3000
CMD ["node", "server.js"]

原理解析:

  • frontend_builder:编译 React 应用,在 /app/frontend/build 生成静态资源。
  • backend_builder:为 Node.js 后端安装生产环境依赖。
  • 最终阶段将 frontend_builder 产出的前端静态文件与 backend_builder 产出的后端代码及生产依赖结合在一起。最终镜像中完全不包含用于 npm install 开发依赖和 npm run build 的相关构建工具。

4.3 示例 3:构建 Java Spring Boot 应用

Java 应用程序通常有大量的依赖项。通过将 Maven/Gradle 构建环境与 JRE 运行时分离,多阶段构建可以极大地帮助减小镜像体积。

场景: 一个简单的 Spring Boot Web 应用程序。

使用多阶段构建的 Dockerfile:

# 阶段 1:构建 Java 应用程序
FROM maven:3.8.7-openjdk-17 AS builder
# 设置工作目录
WORKDIR /app
# 优先复制 Maven 项目文件 (pom.xml) 以利用构建缓存
COPY pom.xml .
# 下载依赖项 - 如果 pom.xml 没有改变,这一步会被缓存
RUN mvn dependency:go-offline -B
# 复制应用程序源代码
COPY src ./src
# 构建应用程序,在 target/ 目录下生成一个 JAR 文件
RUN mvn package -DskipTests

# 阶段 2:创建最终运行时镜像
FROM openjdk:17-jre-slim-buster
# 设置工作目录
WORKDIR /app
# 从 'builder' 阶段复制编译好的 JAR 文件
COPY --from=builder /app/target/*.jar app.jar
# 暴露 Spring Boot 通常运行的端口
EXPOSE 8080
# 定义运行 JAR 文件的命令
ENTRYPOINT ["java", "-jar", "app.jar"]

原理解析:

  • builder:使用包含 OpenJDK 17 的 Maven 镜像。它复制 pom.xml 下载依赖,然后复制源代码并构建出 JAR 文件。
  • 最终阶段使用了小得多的 openjdk:17-jre-slim-buster 镜像(仅包含 JRE,不包含开发工具包),并仅仅从 builder 阶段复制了编译好的 app.jar