使用多阶段构建优化 Docker 镜像:一份综合指南

掌握 Docker 多阶段构建,以大幅缩小镜像体积、加速部署并增强安全性。本综合指南提供了分步说明、针对 Go 和 Node.js 的实用示例以及基本的最佳实践。了解如何通过分离构建依赖项来优化 Dockerfile,确保只有必要的组件进入最终的运行时镜像。对于任何希望构建高效安全容器化应用的人来说,这都是必读内容。

39 浏览量

使用多阶段构建优化 Docker 镜像:一份综合指南

Docker 容器通过提供隔离、一致的环境彻底改变了应用程序的开发和部署。然而,随着应用程序复杂性的增加,其 Docker 镜像也随之增大。大型镜像会导致更慢的构建时间、增加的存储需求和更长的部署周期。此外,将构建时依赖项包含在最终的运行时镜像中可能会引入不必要的安全漏洞。多阶段构建为这些挑战提供了一种优雅且高效的解决方案。

本综合指南将引导您了解多阶段 Docker 构建的概念和实际实现。到最后,您将了解如何利用这种强大的技术来为您的应用程序创建明显更小、更安全、更高效的 Docker 镜像。我们将探索基本原理,演示真实世界的示例,并讨论优化容器化工作流程的最佳实践。

理解问题:臃肿的 Docker 镜像

传统上,构建 Docker 镜像通常涉及一个执行所有步骤的 Dockerfile:安装依赖项、编译代码和设置运行时环境。这种单一的(monolithic)方法经常导致镜像中包含大量仅在构建过程中需要但应用程序实际运行时不需要的工具和库。

以一个典型的 Go 应用程序构建为例。您需要 Go 编译器、SDK 以及潜在的构建工具。一旦应用程序被编译成二进制文件,这些特定于 Go 的依赖项就不再需要了。如果它们仍然保留在最终镜像中,它们会:

  • 增加镜像大小:更多的层,更多需要拉取和存储的数据。
  • 延长部署时间:较大的镜像传输时间更长。
  • 引入安全风险:由于不必要的软件而使攻击面扩大。
  • 模糊运行时环境:更难理解真正需要什么。

多阶段构建旨在将这些构建时伪影精确地从最终的运行时镜像中移除。

什么是多阶段构建?

多阶段构建允许您在单个 Dockerfile 中使用多个 FROM 指令。每个 FROM 指令启动一个新的构建阶段。您可以有选择地将伪影(如编译后的二进制文件、静态资源或配置文件)从一个阶段复制到另一个阶段,丢弃早期阶段中的所有其他内容。这意味着您的最终镜像将只包含运行应用程序所需的必要组件,而不是用于构建它的工具和依赖项。

关键概念:

  • 阶段 (Stages):每个 FROM 指令定义一个新的构建阶段。除非您明确链接它们,否则阶段之间是相互独立的。
  • 命名阶段:您可以使用 AS <stage-name>(例如 FROM golang:1.21 AS builder)为阶段命名。这使得以后引用它们更加容易。
  • 复制伪影COPY --from=<stage-name> 指令对于在阶段之间传输文件至关重要。您指定源阶段以及要复制的文件/目录。

实现多阶段构建:分步示例(Go 应用程序)

让我们用一个简单的 Go Web 服务器来说明多阶段构建。目标是拥有一个仅包含编译后二进制文件的小型、高效的镜像。

main.go(一个简单的 Go Web 服务器)

package main

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

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello from optimized Docker image!")
}

func main() {
    http.HandleFunc("/", handler)
    log.Println("Server starting on :8080...")
    http.Fatal(http.ListenAndServe(":8080", nil))
}

不使用多阶段构建的 Dockerfile(用于比较)

这是构建 Go 应用程序一种常见但效率较低的方式。

# Stage 1: Build the Go application
FROM golang:1.21 AS builder

WORKDIR /app

COPY go.mod go.sum ./ 
RUN go mod download

COPY *.go .
RUN go build -o myapp

# Stage 2: Create the final runtime image
FROM alpine:latest

WORKDIR /app

# Copy the compiled binary from the builder stage
COPY --from=builder /app/myapp .

EXPOSE 8080
CMD ["./myapp"]

等等,上面的示例确实*使用了多阶段构建!让我们先更正它并展示一个真正低效的版本,然后再展示多阶段版本。

低效的 Dockerfile(单阶段)

此 Dockerfile 在最终镜像中安装了 Go 工具链,这对于运行时是不必要的。

# 使用包含构建和运行工具的 Go 镜像
FROM golang:1.21-alpine

WORKDIR /app

COPY go.mod go.sum ./ 
RUN go mod download

COPY *.go .
RUN go build -o myapp

EXPOSE 8080
CMD ["./myapp"]

当您构建此镜像(docker build -t go-app-inefficient .)时,您会注意到它的大小明显更大(例如 ~300MB),因为它包含了整个 golang:1.21-alpine 镜像,包括 Go 编译器和 SDK。

使用多阶段构建的优化 Dockerfile

现在,让我们实现多阶段方法。我们将使用一个 Go 镜像进行构建,并使用一个最小的 alpine 镜像进行运行时构建。

# 阶段 1:构建 Go 应用程序
# 使用特定的 Go 版本进行构建,别名为 'builder'
FROM golang:1.21-alpine AS builder

# 设置容器内的工作目录
WORKDIR /app

# 复制 go.mod 和 go.sum以下载依赖项
COPY go.mod go.sum ./ 
RUN go mod download

# 复制应用程序的其余源代码
COPY *.go .

# 静态编译 Go 应用程序(对最小镜像很重要)
# -ldflags='-w -s' 标志会剥离调试信息和符号表,进一步减小大小。
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o myapp

#-----------------------------------------------------------

# 阶段 2:创建最终的运行时镜像
# 为运行时环境使用像 alpine 这样的最小基础镜像
FROM alpine:latest

# 设置工作目录
WORKDIR /app

# 仅从 'builder' 阶段复制编译后的二进制文件
COPY --from=builder /app/myapp .

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

# 运行可执行文件的命令
CMD ["./myapp"]

解释:

  1. FROM golang:1.21-alpine AS builder:此行启动第一个阶段并将其命名为 builder。我们使用具有编译应用程序所需工具的 Go 镜像。
  2. WORKDIR /app, COPY go.mod go.sum ./, RUN go mod download:标准的依赖管理步骤。
  3. COPY *.go .:复制源代码。
  4. RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o myapp:这会编译 Go 应用程序。CGO_ENABLED=0GOOS=linux 确保生成静态二进制文件,这对于在 Alpine 等最小镜像中运行至关重要。-ldflags='-w -s' 是用于减小二进制文件大小的优化,通过移除调试信息。
  5. FROM alpine:latest:这启动了第二个阶段。关键在于,它使用了一个完全不同、小得多的基础镜像(alpine)。
  6. WORKDIR /app:设置运行时阶段的工作目录。
  7. COPY --from=builder /app/myapp .:这就是魔力所在!它将编译后的 myapp 二进制文件从 builder 阶段(第一个阶段)复制到当前阶段。来自 builder 阶段的整个 Go 工具链和源代码都被丢弃了。
  8. EXPOSE 8080CMD ["./myapp"]:运行应用程序的标准指令。

构建优化后的镜像

要构建此镜像,请保存 Dockerfile 并运行:

docker build -t go-app-optimized .

您会发现 go-app-optimized 镜像比低效版本小得多(例如 ~10-20MB),这展示了多阶段构建的强大功能。

适用于其他语言/框架的多阶段构建

该原则几乎可以扩展到任何语言或构建过程:

  • Node.js:使用 node 镜像和 npm/yarn 安装依赖项并构建前端资产(例如 React, Vue),然后仅将静态构建输出复制到轻量级的 nginxhttpd 镜像中进行服务。
  • Java:使用 Maven 或 Gradle 镜像来编译您的 .jar.war 文件,然后将该工件复制到最小的 JRE 镜像中。
  • Python:使用带有 pip 的 Python 镜像安装依赖项,然后将您的应用程序代码和已安装的包复制到精简的 Python 运行时镜像中。

示例:Node.js 前端构建

# 阶段 1:构建前端资产
FROM node:20-alpine AS frontend-builder

WORKDIR /app

COPY frontend/package.json frontend/package-lock.json ./ 
RUN npm install

COPY frontend/ . 
RUN npm run build

# 阶段 2:使用 Nginx 服务静态资产
FROM nginx:alpine

# 从 frontend-builder 阶段复制构建的资产
COPY --from=frontend-builder /app/dist /usr/share/nginx/html

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]