如何构建更小的容器镜像:Docker 多阶段构建

文摘   2024-11-12 10:30   美国  

如果你使用 Docker 构建容器镜像,而 Dockerfile 不是多阶段构建,那么你很可能会将不必要的臃肿内容发送到生产环境。这不仅会增加镜像的大小,还会扩大其潜在的攻击面。

具体是什么导致了这种臃肿,又该如何避免?

在本文中,我们将探索生产容器镜像中不必要包的最常见来源。一旦问题清晰,我们将看看如何使用 多阶段构建[1] 来帮助生成更精简和更安全的镜像。最后,我们将为一些流行的软件栈重新构建 Dockerfile - 不仅是为了更好地内化新知识,还要展示通常只需稍加努力就能获得显著更好的镜像。

让我们开始吧!

为什么我的镜像如此庞大?

几乎任何应用程序,无论其类型(Web 服务、数据库、CLI 等)或语言栈(Python、Node.js、Go 等),都有两种类型的依赖:构建时依赖和运行时依赖。

通常,构建时依赖比运行时依赖更多且更嘈杂(意味着它们有更多的 CVE)。因此,在大多数情况下,你只希望在最终镜像中包含生产依赖。

然而,构建时依赖往往会出现在生产容器中,其主要原因是:

⛔  使用完全相同的镜像来构建和运行应用程序。

在容器中构建代码是一种常见(且好的)做法 - 它保证了在开发人员的机器、CI 服务器或任何其他环境中使用相同的工具集进行构建。

在容器中运行应用程序是当今的事实标准实践。即使你没有使用 Docker,你的代码很可能仍在容器或 类似容器的虚拟机[2] 中运行。

然而,构建和运行应用程序是两个完全不同的问题,具有不同的需求和约束。因此,构建和运行时镜像也应该完全分开! 尽管如此,对这种分离的需求常常被忽视,生产镜像最终会包含 linter、编译器和其他开发工具。

以下是几个说明这种情况通常是如何发生的示例。

如何不组织 Go 应用程序的 Dockerfile

从一个更明显的例子开始:

FROM golang:1.23
WORKDIR /app
COPY . .
RUN go build -o myapp
CMD ["./myapp"]

上述 Dockerfile 的问题在于  golang[3]  从未打算作为生产应用程序的基础镜像。但是,如果你想在容器中构建 Go 代码,这个镜像是默认选择。一旦你编写了一段可以将源代码编译成可执行文件的 Dockerfile,就很容易简单地添加一个 CMD 指令来调用此二进制文件并认为完成了。

如何不为 Go 应用程序构建 Dockerfile。

关键是,这样的镜像不仅包含应用程序本身(你希望在生产中的部分),还包含整个 Go 编译器工具链及其所有依赖(你最不希望在生产中的部分):

$ docker images
REPOSITORY   TAG      IMAGE ID       CREATED         SIZE
myapp        latest   abc123def456   2 minutes ago   1.2GB

$ docker scan myapp
...

golang:1.23 带来了超过 800MB 的包和大约相同数量的 CVE 🤯

如何不组织 Node.js 应用程序的 Dockerfile

一个类似但稍微更微妙的例子:

FROM node:lts-slim
WORKDIR /app
COPY . .
RUN npm install
CMD ["npm", "start"]

golang 镜像不同, node:lts-slim[4] 。然而,这个 Dockerfile 仍然存在潜在问题。如果使用它构建镜像,最终可能会得到以下组成:

如何不为 Node.js 应用程序构建 Dockerfile。

该图显示了 iximiuz Labs 前端应用(使用 Nuxt 3 编写)的实际数字。如果它使用如上所示的单阶段 Dockerfile,生成的镜像将有近 500MBnode_modules,而 .output 文件夹中只有大约 50MB 的"打包"JavaScript(和静态资源)构成生产应用。

这次的"臃肿"是由 npm ci 步骤引起的,该步骤同时安装了生产和开发依赖。但是不能简单地使用 npm ci --omit=dev,因为这会破坏后续的 npm run build 命令,该命令需要生产和开发依赖来生成最终的应用程序包。因此,需要一个更微妙的解决方案。

精简镜像在多阶段构建之前是如何生成的

在前一节的 Go 和 Node.js 示例中,解决方案可能涉及将原始 Dockerfile 拆分为两个文件。

第一个 Dockerfile 以 FROM <sdk-image> 开始,并包含应用程序构建指令:

Dockerfile.build

FROM node:lts-slim
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build

使用 Dockerfile.build 运行 docker build 命令将生成一个辅助镜像:

docker build -t build:v1 -f Dockerfile.build .

...然后可以用于 将构建的应用程序(我们的构件)提取到构建主机[5]

docker cp $(docker create build:v1):/app/.output .

第二个 Dockerfile 以 FROM <runtime-image> 开始,并简单地将构建的应用程序从主机 COPY 到其未来的运行时环境:

Dockerfile.run

FROM node:lts-slim
WORKDIR /app
COPY .output .
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "/app/.output/index.mjs"]

第二次使用 Dockerfile.run 运行 docker build 命令将生成最终的精简生产镜像:

docker build -t app:v1 -f Dockerfile.run .

这种技术,称为 构建器模式[6] ,在 Docker 添加多阶段构建支持之前被广泛使用。

然而,尽管功能完整,构建器模式的用户体验相对较为粗糙。它需要:

  • 编写多个相互依赖的 Dockerfile。
  • 将构建构件复制到构建主机和从构建主机复制。
  • 设计额外的脚本来执行 docker build 命令。

此外,人们需要记住始终先运行 docker build -f Dockerfile.build 命令,然后再运行 docker build -f Dockerfile.run 命令(否则,最终镜像可能会使用先前构建的陈旧构件),并且通过主机发送构建构件的体验也远非完美。

同时,"原生"构建器模式实现可以:

  • 优化构件复制。
  • 简化构建顺序组织。
  • 在不同团队间标准化技术。

幸运的是,后来确实实现了这一点!

理解多阶段构建的简单方法

本质上,多阶段构建是经过增强的构建器模式,直接在 Docker 内部实现。要理解多阶段构建的工作原理,了解两个看似独立的 Dockerfile 特性很重要。

你可以从 <另一个镜像> COPY 文件

最常用的 Dockerfile 指令之一是 COPY。大多数情况下,我们从主机 COPY 文件到容器镜像:

COPY host/path/to/file image/path/to/file

然而,你也可以 直接从其他镜像[7]  🤯

这是一个示例,从 Docker Hub 的 nginx:latest 镜像将 nginx.conf 文件复制到正在构建的镜像中:

COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf

这个特性在实现构建器模式时也很方便。现在,我们可以直接从辅助构建镜像 COPY 构建的构件:

Dockerfile.run

FROM node:lts-slim
WORKDIR /app
COPY --from=build:v1 /app/.output .
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "/app/.output/index.mjs"]

因此,COPY --from=<image> 技巧使得在从构建镜像复制构件到运行时镜像时可以绕过构建主机。

然而,编写多个 Dockerfile 和构建顺序依赖问题仍然存在...

你可以在一个 Dockerfile 中定义多个镜像

从历史上看,一个 Dockerfile 会以 FROM <base-image> 指令开始:

Dockerfile.simple

FROM node:lts-slim
COPY ...
RUN ["node", "/path/to/app"]

...然后 docker build 命令会用它生成一个镜像:

docker build -f Dockerfile.simple -t app:latest .

然而,自 2018 年起,Docker 支持复杂的"多租户" Dockerfile。你可以在一个 Dockerfile 中放置任意多个_命名的_ FROM 指令:

Dockerfile.complex

FROM busybox:stable AS from1
CMD ["echo", "busybox"]
FROM alpine:3 AS from2
CMD ["echo", "alpine"]
FROM debian:stable-slim AS from3
CMD ["echo", "debian"]

...并且每个 FROM 都将成为 docker build 命令的 单独_目标_[8]

docker build -f Dockerfile.complex --target from1 -t my-busybox
docker run my-busybox

同一个 Dockerfile,但是是一个完全不同的镜像:

docker build -f Dockerfile.complex --target from2 -t my-alpine
docker run my-alpine

...还有一个来自完全相同 Dockerfile 的镜像:

docker build -f Dockerfile.complex --target from3 -t my-debian
docker run my-debian

回到我们的构建器模式问题,这意味着我们可以在一个复合 Dockerfile 中使用两个不同的 FROM 指令将 构建运行时 Dockerfile 重新组合在一起!

多阶段 Dockerfile 的威力

下面是一个"复合" Node.js 应用的 Dockerfile 可能的样子:

# The "build" stage
FROM node:lts-slim AS build
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
# The "runtime" stage
FROM node:lts-slim AS runtime
WORKDIR /app
COPY --from=build /app/.output .
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "/app/.output/index.mjs"]

使用官方术语,每个 FROM 指令定义的不是镜像而是一个阶段,从技术上讲,COPY 是从一个阶段 --from 复制。然而,正如我们上面看到的,将阶段视为独立的镜像有助于理解其中的联系。

最后但同样重要的是,当所有阶段和 COPY --from=<stage> 指令都在一个 Dockerfile 中定义时,Docker 构建引擎(BuildKit)可以计算正确的构建顺序,跳过未使用的,并并发执行独立的阶段 🧙

在编写第一个多阶段 Dockerfile 之前,有几个重要的事实需要记住:

  • Dockerfile 中阶段的顺序很重要 - 不可能从当前阶段下方定义的阶段 COPY --from
  • AS 别名是可选的 - 如果不命名阶段,仍然可以通过其序列号引用。
  • 当未使用 --target 标志时,docker build 命令将构建最后一个阶段(以及它复制的所有阶段)。

多阶段构建实践

下面是使用多阶段构建为不同语言和框架生成更小、更安全的容器镜像的示例。

Node.js

Node.js 应用有不同的形态 - 有些只在开发和构建阶段需要 Node.js,而有些在运行时容器中也需要 Node.js。

以下是一些 Node.js 应用多阶段 Dockerfile 的示例:

多阶段构建示例:React 应用 React[9]  应用在构建后是完全静态的,可以由任何静态文件服务器提供服务。但是,构建过程需要安装 Node.js、npmpackage.json 中的所有依赖。因此,从 可能很大的构建镜像[3] 中精心"挑选"静态构建产物很重要。

# Build stage
FROM node:lts-slim AS build
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build
# Runtime stage
FROM nginx:alpine
WORKDIR /usr/share/nginx/html
RUN rm -rf ./*
COPY --from=build /app/build .
ENTRYPOINT ["nginx", "-g", "daemon off;"]

多阶段构建示例:Next.js 应用 Next.js[10]  应用可以是:

  • 完全静态[11] :构建过程和多阶段 Dockerfile 与上面的 React 示例几乎相同。
  • 带服务器端功能[12] :构建过程类似于 React,但运行时镜像也需要 Node.js。

下面是一个带服务器端功能的 Next.js 应用的多阶段 Dockerfile 示例:

# Lifehack: Define the Node.js image only once
FROM node:lts-slim AS base
# Build stage
FROM base AS build
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build
# Runtime stage
FROM base AS runtime
RUN addgroup --system --gid 1001 nextjs
RUN adduser --system --uid 1001 nextjs
USER nextjs
WORKDIR /app
COPY --from=build /app/public ./public
RUN mkdir .next
COPY --from=build --chown=nextjs /app/.next/standalone .
COPY --from=build --chown=nextjs /app/.next/static ./.next/static
ENV NODE_ENV=production
CMD ["node", "server.js"]

多阶段构建示例:Vue 应用从构建过程的角度来看, Vue[13]  应用与 React 应用非常相似。构建过程需要安装 Node.js、npmpackage.json 中的所有依赖,但产生的构建产物是可以由任何静态文件服务器提供服务的静态文件。

# Build stage
FROM node:lts-slim AS build
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build
# Runtime stage
FROM nginx:alpine
WORKDIR /usr/share/nginx/html
RUN rm -rf ./*
COPY --from=build /app/dist .

多阶段构建示例:Nuxt 应用与 Next.js 类似, Nuxt[14]  应用可以是完全静态的,也可以 带服务器端支持[15] 。下面是一个在 Node.js 服务器上运行的 Nuxt 应用的多阶段 Dockerfile 示例:

# Build stage
FROM node:lts-slim AS build
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build
# Runtime stage
FROM node:lts-slim
WORKDIR /app
COPY --from=build --chown=node:node /app/.output  .
ENV NODE_ENV=production
ENV NUXT_ENVIRONMENT=production
ENV NITRO_HOST=0.0.0.0
ENV NITRO_PORT=8080
EXPOSE 8080
USER node:node
ENTRYPOINT ["node"]
CMD ["/app/server/index.mjs"]

Go

Go 应用在构建阶段始终会被编译。然而,生成的二进制文件可以是静态链接的(CGO_ENABLED=0)或动态链接的(CGO_ENABLED=1)。运行时阶段的基础镜像选择将取决于生成的二进制文件的类型:

  • 对于静态链接的二进制文件,您可以选择最小化的  gcr.io/distroless/static[16]  甚至 scratch 基础镜像(后者需要极其谨慎)。
  • 对于动态链接的二进制文件,需要一个带有标准共享 C 库的基础镜像(例如 gcr.io/distroless/ccalpinedebian)。

在大多数情况下,运行时基础镜像的选择不会影响多阶段 Dockerfile 的结构。

多阶段构建示例:Go 应用

# Build stage
FROM golang:1.23 AS build
WORKDIR /app
COPY go.* .
RUN go mod download
COPY . .
RUN go build -o binary .
# Runtime stage
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app/binary /app/binary
ENTRYPOINT ["/app/binary"]

Rust

Rust[17]  应用通常使用 cargo 从源代码编译。Docker 官方的  rust[18]  镜像包含 cargorustc 和许多其他开发和构建工具,使镜像总大小接近 2GB。对于 Rust 应用,多阶段构建是必须的,以保持运行时镜像小。请注意,运行时基础镜像的最终选择将取决于 Rust 应用的库需求。

多阶段构建示例:Rust 应用

# Build stage
FROM rust:1.67 AS build
WORKDIR /usr/src/app
COPY . .
RUN cargo install --path .
# Runtime stage
FROM debian:bullseye-slim
RUN apt-get update && \
    apt-get install -y extra-runtime-dependencies && \
    rm -rf /var/lib/apt/lists/*
COPY --from=build /usr/local/cargo/bin/app /usr/local/bin/app
CMD ["myapp"]

Java

Java 应用从源代码使用 Maven 或 Gradle 等构建工具编译,并需要 Java 运行时环境(JRE)来执行。

对于容器化的 Java 应用,通常对构建和运行时阶段使用不同的基础镜像。构建阶段需要 Java 开发工具包(JDK),其中包含编译和打包代码的工具,而运行时阶段通常只需要更小、更轻量的 Java 运行时环境(JRE)来执行。

多阶段构建示例:Java 应用。此示例改编自 官方 Docker 文档[19] 。Dockerfile 比之前的示例更复杂,因为它包含一个额外的测试阶段,且 Java 构建过程比 Node.js 和 Go 应用的简单过程涉及更多步骤。

# Base stage (reused by test and dev stages)
FROM eclipse-temurin:21-jdk-jammy AS base
WORKDIR /build
COPY --chmod=0755 mvnw mvnw
COPY .mvn/ .mvn/
# Test stage
FROM base as test
WORKDIR /build
COPY ./src src/
RUN --mount=type=bind,source=pom.xml,target=pom.xml \
    --mount=type=cache,target=/root/.m2 \
    ./mvnw test
# Intermediate stage
FROM base AS deps
WORKDIR /build
RUN --mount=type=bind,source=pom.xml,target=pom.xml \
    --mount=type=cache,target=/root/.m2 \
    ./mvnw dependency:go-offline -DskipTests
# Intermediate stage
FROM deps AS package
WORKDIR /build
COPY ./src src/
RUN --mount=type=bind,source=pom.xml,target=pom.xml \
    --mount=type=cache,target=/root/.m2 \
    ./mvnw package -DskipTests && \
    mv target/$(./mvnw help:evaluate -Dexpression=project.artifactId -q -DforceStdout)-$(./mvnw help:evaluate -Dexpression=project.version -q -DforceStdout).jar target/app.jar
# Build stage
FROM package AS extract
WORKDIR /build
RUN java -Djarmode=layertools -jar target/app.jar extract --destination target/extracted
# Development stage
FROM extract AS development
WORKDIR /build
RUN cp -r /build/target/extracted/dependencies/. ./
RUN cp -r /build/target/extracted/spring-boot-loader/. ./
RUN cp -r /build/target/extracted/snapshot-dependencies/. ./
RUN cp -r /build/target/extracted/application/. ./
ENV JAVA_TOOL_OPTIONS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000"
CMD [ "java", "-Dspring.profiles.active=postgres", "org.springframework.boot.loader.launch.JarLauncher" ]
# Runtime stage
FROM eclipse-temurin:21-jre-jammy AS runtime
ARG UID=10001
RUN adduser \
    --disabled-password \
    --gecos "" \
    --home "/nonexistent" \
    --shell "/sbin/nologin" \
    --no-create-home \
    --uid "${UID}" \
    appuser
USER appuser
COPY --from=extract build/target/extracted/dependencies/ ./
COPY --from=extract build/target/extracted/spring-boot-loader/ ./
COPY --from=extract build/target/extracted/snapshot-dependencies/ ./
COPY --from=extract build/target/extracted/application/ ./
EXPOSE 8080
ENTRYPOINT [ "java", "-Dspring.profiles.active=postgres", "org.springframework.boot.loader.launch.JarLauncher" ]

PHP

PHP[20]  应用从源代码解释执行,因此不需要编译。然而,开发和生产所需的依赖项通常不同,因此使用多阶段构建来仅安装生产依赖项并将其复制到运行时镜像通常是个好主意。

多阶段构建示例:PHP 应用

# Install dependencies stage
FROM composer:lts AS deps
WORKDIR /app
COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/tmp/cache \
    composer install --no-dev --no-interaction
# Runtime stage
FROM php:8-apache AS runtime
RUN docker-php-ext-install pdo pdo_mysql
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY ./src /var/www/html
COPY --from=deps /app/vendor/ /var/www/html/vendor
USER www-data

结论

生产镜像常常存在"被遗忘"的开发包,增加了不必要的膨胀和安全风险。多阶段构建通过让我们在单个 Dockerfile 中分离构建和运行时环境来解决这个问题,从而实现更高效的构建。正如我们所见,只需几个直接的调整就可以减小镜像大小,提高安全性,并使构建脚本更清晰、更易于维护。

多阶段构建还支持许多 高级用例[21] ,如条件 RUN 指令(分支)、在 docker build 步骤中进行单元测试等。开始使用多阶段构建,保持您的容器精简且适合生产 🚀

参考链接

  1. 多阶段构建: https://docs.docker.com/build/building/multi-stage/
  2. 类似容器的虚拟机: https://iximiuz.com/en/posts/oci-containers/
  3. golang: https://hub.docker.com/_/golang
  4. node:lts-slim: https://labs.iximiuz.com/tutorials/how-to-choose-nodejs-container-image
  5. 将构建的应用程序(我们的构件)提取到构建主机: https://labs.iximiuz.com/tutorials/extracting-container-image-filesystem
  6. 构建器模式: https://blog.alexellis.io/mutli-stage-docker-builds/
  7. 直接从其他镜像: https://docs.docker.com/reference/dockerfile/#copy---from
  8. 单独_目标_: https://docs.docker.com/reference/cli/docker/buildx/build/#target
  9. React: https://react.dev/
  10. Next.js: https://nextjs.org/
  11. 完全静态: https://nextjs.org/docs/app/building-your-application/deploying/static-exports
  12. 带服务器端功能: https://nextjs.org/docs/app/building-your-application/deploying#docker-image
  13. Vue: https://vuejs.org/
  14. Nuxt: https://nuxt.com/
  15. 带服务器端支持: https://nuxt.com/docs/getting-started/deployment#nodejs-server
  16. gcr.io/distroless/static: https://iximiuz.com/en/posts/containers-distroless-images/
  17. Rust: https://www.rust-lang.org/
  18. rust: https://hub.docker.com/_/rust
  19. 官方 Docker 文档: https://docs.docker.com/guides/java/run-tests/#run-tests-when-building
  20. PHP: https://www.php.net/
  21. 高级用例: https://www.docker.com/blog/advanced-dockerfiles-faster-builds-and-smaller-images-using-buildkit-and-multistage-builds/

幻想发生器
图解技术本质
 最新文章