ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Docker多阶段构建实战:从1GB镜像到几十MB的优化指南

Docker多阶段构建实战:从1GB镜像到几十MB的优化指南 说实话我第一次在团队看到同事上传的镜像时差点没绷住。一个内部小工具代码量不到两千行打出来的镜像 1.2GB。问了下 Dockerfile 内容果然还是老套路FROM ubuntu装一堆编译依赖直接把源码 COPY 进去顺手把 go build 的中间缓存也留在了镜像里。这个现象不是个例我见过很多项目直到 CI 磁盘告警、镜像推送越来越慢才开始认真思考怎么给镜像“减肥”。而不管是后端 Java、Go还是前端 Node 项目最优解法基本都是同一个Docker 多阶段构建。多阶段构建不是什么新功能Docker 17.05 就开始支持了但到现在依然有大量项目没用起来或者用得很糙。这篇文章我不打算只贴一个 Dockerfile 模板就完事我会把多阶段构建的原理、语法细节、不同语言的实战写法、缓存优化技巧还有我踩过的坑和排查思路全部一次性讲清楚。无论你是刚接触 Docker 的新手还是已经在生产环境维护镜像的老手这篇都能给你一些可以参考的东西。1. 为什么需要多阶段构建先搞清楚它到底解决了什么问题1.1 传统单阶段构建的三大痛点咱们先回到最原始的问题不用多阶段构建会怎样最典型的 Dockerfile 写法是这样的FROM ubuntu:22.04 RUN apt-get update apt-get install -y build-essential COPY . /app WORKDIR /app RUN make build CMD [./app]这种写法最大的问题是“什么都往一个镜像里塞”。你为了编译代码装了 GCC、Make、各种 header 文件编译完这些工具链根本不运行但已经写进了镜像层里。最终镜像体积可能比编译出来的二进制大几十倍。仓库里存的是几百 MB 甚至上 GB 的镜像实际上需要的东西就几十 MB。再一个痛点是安全。编译工具链里有大量潜在漏洞组件等于平白无故扩大了攻击面。而且很多项目会把源码、测试文件、CI 配置一起打进镜像里镜像一旦被拉取这些东西就相当于公开了。我见过有人把数据库密码写进构建脚本里还打进了镜像最后镜像推到公共仓库几个小时就被扫描工具扒了出来。第三个痛点是构建环境漂移。你用 ubuntu 做基础镜像今天 apt 源里 GCC 版本是 11明天可能变成了 12别人在另外一台机器上构建可能装到的依赖版本全不一样。最后“在我机器上是好的在容器里就不行”这种经典问题就来了。单阶段构建把所有动作都堆在一个环境里环境漂移的风险天然存在。1.2 多阶段构建的工作原理一个 Dockerfile多个 FROM多阶段构建的思路其实很朴素一个 Dockerfile 里可以有多个 FROM 指令每个 FROM 都会开启一个独立的构建阶段。前几个阶段负责编译、打包、生成产物最后一个阶段负责运行。最终镜像只保留最后一个阶段的内容之前所有阶段的空间都会被丢弃。文件如何跨阶段传递关键就是 COPY --fromxxx 这条指令。你可以把前面某个阶段里生成的文件直接拷贝到最终阶段里比如把编译好的二进制、构建好的静态文件拷贝过去。最终镜像里只有这个二进制和你显式指定的运行时环境干干净净。用生活类比来理解多阶段构建就像在中央厨房先把菜做好然后只把成品菜端到分店卖。分店里不用摆下整个中央厨房的锅碗瓢盆、食材仓库和厨师团队只需要一个加热设备和成品菜就够了。传统单阶段构建则是把整个中央厨房原封不动搬进分店后厨还继续开着火门店能不拥挤吗1.3 多阶段构建的实际效果从 1GB 到几十 MB 并不夸张我拿自己的项目举例。之前用 Go 写了一个内部 API 服务单阶段构建用了 golang:1.22 作为基础镜像装了各种调试工具最终镜像 850MB。改成多阶段构建后编译阶段用 golang 镜像运行阶段用 distroless 镜像最终产物只有 28MB。没错从 850MB 降到 28MB体积缩小了 30 倍。Java 项目更明显。一个 Spring Boot 应用单阶段用 maven 镜像直接跑镜像动辄 500MB 以上。多阶段构建后编译阶段用 maven 镜像运行阶段用 jre 镜像体积能控制在 150MB 左右。再配合 jlink 裁掉用不到的模块能压到 80MB 以内。前端项目也一样。React 应用构建出来一堆静态文件单阶段如果直接用 node 镜像跑镜像 1GB 都不奇怪。多阶段构建把 npm run build 的产物 COPY 到 nginx 镜像里最终镜像只有几十 MB。体积缩小带来的收益是连锁反应拉取时间变短、启动变快、磁盘占用减少、安全扫描面积变小。这也是为什么多阶段构建值得每一个团队认真实践。2. 多阶段构建的核心语法与关键细节2.1 最基本的阶段命名和 COPY --from多阶段构建的写法门槛其实很低核心语法就两条# 给阶段命名用 AS 后跟别名 FROM node:20-alpine AS build # 从指定阶段拷贝文件用 COPY --from阶段名 COPY --frombuild /app/dist /usr/share/nginx/html第一阶段叫 build后面可以从 build 阶段拷贝文件。阶段名本质上就是一个指针指向那个阶段最终文件系统状态。COPY --from 支持从当前 Dockerfile 前面的任何阶段拷贝也支持从外部镜像拷贝比如COPY --fromnginx:1.25-alpine /etc/nginx/nginx.conf /etc/nginx/nginx.conf这个能力很实用有时候你想复用某个基础镜像里已有的配置文件或者二进制不需要先 RUN 一堆 apt install 自己装直接 COPY 过来就行。要注意的点是阶段名必须在 COPY --from 之前已经定义。Dockerfile 是按顺序执行的你不可能从后面定义的阶段往前面拷贝。2.2 每个阶段的上下文是独立的别想当然共享环境刚接触多阶段构建的人最容易犯的一个错误是以为前面阶段的目录、环境变量、文件后面阶段会自动继承。事实并非如此。每个 FROM 都是全新的文件系统起点你是基于什么基础镜像这个阶段就只有什么。两个阶段之间唯一的桥梁就是你显式写的 COPY --from。这意味着如果你在编译阶段设置了某个环境变量比如 GOOSlinux到了运行阶段这个变量是不存在的你需要重新声明。如果你在编译阶段安装了 CA 证书运行阶段如果用的是 scratch 镜像那 CA 证书也得 COPY 过去。别指望环境能“自动延续”。2.3 ARG 和 ENV 在阶段间的作用域规则ARG 和 ENV 的作用域是很多人搞混的地方。ARG 是构建参数ENV 是环境变量两者在阶段间传递规则完全不同。ARG 在 Dockerfile 里如果声明在第一个 FROM 之前它默认对所有阶段可见但不作为构建参数传给 RUN 指令除非你在该阶段内部再声明一次同样的 ARG。举个实际例子ARG VERSION1.0 FROM golang:1.22 AS build ARG VERSION RUN echo version$VERSION外层的 ARG VERSION 只在外层作用域生效进入 build 阶段后如果你不再次声明 ARG VERSIONRUN 指令里就拿不到这个变量。这是 Docker 的一个设计限制每个阶段都是一个隔离的构建环境外层 ARG 只是默认值真正要使用必须在阶段内重新声明。ENV 则不同它在当前阶段以及后续阶段都生效但它不会跨越 FROM 边界。也就是说你在 build 阶段设置了 ENV GOOSlinux运行阶段不会继承这个 GOOS。如果需要运行阶段也有某个环境变量必须在运行阶段再设置一次。2.4 阶段缓存与构建顺序的关系Docker 构建缓存是按指令一层层缓存的。一条指令的输入没变输出就会复用缓存。多阶段构建中阶段之间的缓存是独立的每个阶段都有自己独立的层缓存。但这带来一个实际影响只要前一阶段某条指令变了后面的指令缓存全部失效。所以在写 Dockerfile 时指令顺序极其讲究。依赖下载这种重操作要放在最前面源码复制放在后面。比如 Node 项目先 COPY package.json再 RUN npm ci最后才 COPY . .。因为源码经常变而 package.json 很少变。如果先把整个项目 COPY 进去再 npm ci那每次源码改动都会导致依赖重新下载构建时间翻几倍。这个优化思路在多阶段构建里面尤为重要因为编译阶段的缓存失效了后面所有阶段都跟着受影响。3. 多阶段构建实战四种典型场景的完整 Dockerfile3.1 Java Spring Bootmaven 编译加 jre 运行Java 项目是多阶段构建收益最明显的场景之一。一个 Spring Boot 项目如果用 maven 镜像全程构建镜像老大不小。正确做法是 maven 镜像做编译jre 镜像做运行。# 第一阶段编译打包 FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /app COPY pom.xml . # 提前下载依赖利用构建缓存 RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests # 第二阶段运行 FROM eclipse-temurin:17-jre WORKDIR /app # 从编译阶段拷贝 jar 包 COPY --frombuild /app/target/*.jar app.jar EXPOSE 8080 # 以非 root 用户运行降低风险 USER nobody ENTRYPOINT [java, -jar, app.jar]这里最关键的优化是 RUN mvn dependency:go-offline。这一行会把 pom.xml 里声明的所有依赖先下载到本地仓库并生成一个独立的镜像层。只要 pom.xml 没变这一层就会走缓存后续源码改动后重新构建时依赖下载这步会被跳过构建速度提升非常明显。如果哪天你的 pom.xml 改了这层缓存失效依赖就需要重新下载但源码 COPY 那步还是可以继续走缓存。运行阶段用 JRE 而不是 JDK是因为编译工作已经在第一阶段完成了运行阶段只需要解析字节码不需要 javac 编译器。eclipse-temurin:17-jre 这类镜像就是专门为运行 Java 应用准备的体积比 JDK 镜像小很多。加 USER nobody 是为了避免容器内以 root 身份运行减少安全隐患。3.2 Node.js 前端npm 构建加 nginx 托管前端项目的 Docker 化思路和后端略有不同。现在前端项目构建产物基本是一堆静态文件托管在 nginx 上即可。多阶段构建可以让我们只把构建产物交给 nginx源码、node_modules、构建工具全部排除在最终镜像之外。# 第一阶段构建静态资源 FROM node:20-alpine AS build WORKDIR /app COPY package.json package-lock.json ./ # npm ci 比 npm install 更严格锁定依赖版本 RUN npm ci COPY . . RUN npm run build # 第二阶段nginx 托管 FROM nginx:1.25-alpine # 把构建产物复制到 nginx 默认站点目录 COPY --frombuild /app/dist /usr/share/nginx/html # 如果有自定义 nginx 配置也可以从构建阶段或外部拷贝 COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD [nginx, -g, daemon off;]npm ci 和 npm install 的区别值得多说一句。npm ci 严格按照 package-lock.json 安装依赖版本完全锁定而且会先删掉 node_modules 再安装保证构建环境的可复现。这一点在 CI 里尤其重要因为它能避免“本地没问题构建机就报错”的版本漂移问题。前端项目还有一个特殊的优化点dist 目录通常很小几百 KB 到几 MB 而已但 node_modules 可能动辄几百 MB。通过多阶段构建node_modules 完全不会进入最终镜像。nginx 镜像本身也就几十 MB最终镜像体积非常健康。3.3 Go 服务静态编译加 scratch/distrolessGo 是静态编译语言非常适合做多阶段构建。因为 Go 能编译出几乎不依赖外部库的静态二进制最终镜像甚至可以用 scratch也就是一个空镜像。# 第一阶段编译 FROM golang:1.22 AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . # CGO_ENABLED0 放弃 cgo生成纯静态二进制 # -ldflags-s -w 去掉符号表和调试信息 RUN CGO_ENABLED0 GOOSlinux go build -ldflags-s -w -o /out/app ./cmd/server # 第二阶段运行从 scratch 开始 FROM scratch # CA 证书在 scratch 里不存在如果程序要访问外部 HTTPS 服务需要带上 COPY --frombuild /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --frombuild /out/app /app # 非 root 用户 USER 10001:10001 EXPOSE 8080 ENTRYPOINT [/app]这里有两个细节容易被忽略。第一个是 CGO_ENABLED0。只要程序里没有强制依赖 cgo 的库关闭 cgo 后编译器会生成纯静态二进制不需要链接任何动态库scratch 镜像才能跑。第二个是 CA 证书。如果程序需要调用外部的 HTTPS APIscratch 镜像里没有任何证书文件必须从基础镜像里把 ca-certificates.crt 拷贝过来。如果不拷贝程序会在 TLS 握手时报 x509 certificate signed by unknown authority。USER 10001:10001 是数字形式的 UID因为 scratch 镜像里根本没有 /etc/passwd 文件写用户名反而执行不了。如果觉得 scratch 太极致、不方便调试可以用 distroless 镜像替代。distroless 是 Google 维护的瘦身版基础镜像包含运行时基础库和 glibc但没有 shell、没有包管理器、没有系统工具。相比 scratchdistroless 对动态链接的二进制兼容更好相比 alpine它又更纯粹而且同样是非 root 运行。3.4 Python 应用依赖层与运行层的拆分Python 项目的多阶段构建相对特殊因为 Python 通常是解释执行没有明显的“编译”阶段。但多阶段构建依然有用主要目的是把 pip 安装的依赖和最终运行环境分开同时避免源码、测试文件、缓存文件进入最终镜像。# 第一阶段安装依赖到临时目录 FROM python:3.12-slim AS builder WORKDIR /app COPY requirements.txt . RUN pip install --prefix/install -r requirements.txt # 第二阶段运行 FROM python:3.12-slim WORKDIR /app # 把依赖从 /install 拷贝到系统目录 COPY --frombuilder /install /usr/local COPY app/ ./app/ # 非 root 用户 RUN useradd -m appuser chown -R appuser:appuser /app USER appuser EXPOSE 8000 CMD [uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000]关键点在于 pip install --prefix/install。这个方法把依赖安装到一个指定目录然后在第二阶段拷贝过去。好处是最终镜像里不包含 pip 的缓存文件也不包含任何构建中间产物。这里还可以继续优化如果项目里有 requirements.txt 之外的本地包依赖需要相应调整 COPY 路径。Python 基础镜像选型上我推荐 slim 变体它带常见系统依赖但体积小很多。alpine 虽然更小但 musl libc 和 glibc 的差异偶尔会踩坑有些 pip 包在 alpine 上装不起来或用不了。做生产镜像时不要为了追求小体积牺牲兼容性。4. 多阶段构建的进阶优化缓存、构建目标与安全性4.1 用好 BuildKit 的缓存挂载构建速度再翻一倍如果你还在用传统 docker build一定要尽快切换到 BuildKit。BuildKit 是 Docker 的新一代构建引擎从 Docker 23.0 开始已经是默认构建器。它提供了很多传统构建器没有的能力其中最实用的就是 RUN --mounttypecache。普通多阶段构建里即使你把依赖下载放在独立的层只要依赖文件版本更新缓存依然会失效依赖需要全部重新下载。使用 BuildKit 的缓存挂载指定目录会被挂载成一个持久化缓存即使构建层失效缓存也不会被删除。举个例子FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /app COPY pom.xml . RUN --mounttypecache,target/root/.m2 mvn dependency:go-offline COPY src ./src RUN --mounttypecache,target/root/.m2 mvn package -DskipTests这里把 /root/.m2 目录挂载成缓存。即使 pom.xml 改了导致依赖层失效之前下载过的依赖仍然在缓存里新的依赖只需要增量下载不需要全部重来。Go 项目同理RUN --mounttypecache,target/go/pkg/mod go mod download前端项目对应的是 npm 缓存目录 /root/.npm。BuildKit 还有另一个实用功能是 RUN --mounttypesecret可以把敏感信息通过临时文件挂载到构建环境里而不会写进镜像层。比如构建时要用到私有仓库的 token可以这样做RUN --mounttypesecret,idgithub_token \ GITHUB_TOKEN$(cat /run/secrets/github_token) \ npm ci构建时用 --secret idgithub_token,src./.token 传入。这样做敏感信息不会残留在镜像层里比用 --build-arg 传密码安全得多。4.2 利用 --target 参数调试单个阶段多阶段构建的每个阶段其实都可以单独构建和调试。如果你只想构建中间的某个阶段不需要完成最终镜像可以用 --target 参数指定。docker build --target build -t myapp:build .这个用法在实际排障中非常有用。比如最终镜像启动失败你怀疑问题出在编译阶段产出的二进制可以直接构建 build 阶段然后临时写一个 Dockerfile 基于这个阶段运行看二进制能不能正常工作。不用每次都要完成整个多阶段流程才能在容器里实验。还有一个高阶用法一个 Dockerfile 里定义多个运行目标用 --target 切换。典型的场景是同一个后端项目开发环境想用带热加载和调试工具的镜像生产环境想用精简的运行镜像。你可以在一个 Dockerfile 里写好 dev、test、prod 三个阶段然后用 --target 选择构建哪个镜像。这样版本管理和维护成本都会降低。4.3 安全加固最小化基础镜像与权限控制多阶段构建天然比单阶段构建安全因为它剥离了构建工具链和大部分非必要文件。但就算用了多阶段构建最终阶段如果还是从 ubuntu:latest 开始镜像依然臃肿且攻击面不小。我建议把最终阶段的基础镜像也细化一下。Java 运行时用 eclipse-temurin 的 jre 变体不要用完整 JDKGo 静态二进制用 scratch 或 distroless前端静态文件用 nginx:alpine体积小且足够稳定Python 应用用 python:slim 并注意依赖兼容权限控制是另一个容易被忽略的点。很多项目的基础镜像默认是 root 用户运行这导致容器一旦被攻破攻击者直接拥有宿主机 root 权限的风险很高。在多阶段构建的最终阶段尽量加上 USER 指令切换成非 root 用户。可以参考前面的各场景 Dockerfile已经包含了 USER 相关配置。4.4 镜像体积优化技巧除了多阶段还能做什么多阶段构建解决了“编译工具链进镜像”的问题但镜像是还可以再瘦身的。这里有几个和构建协同使用的优化点减少镜像层数。多个 RUN 指令会生成多个镜像层每层都会永久保存。可以把相关命令合并到同一条 RUN 里用 连接并在最后清理缓存文件。比如 apt 安装完顺手把 /var/lib/apt/lists 删掉。前端 npm ci 完顺手删掉 npm 缓存目录。注意 COPY 的粒度。尽量不要 COPY . . 一股脑把所有文件拷进去。Docker 会把整个构建上下文打包发送给守护进程不必要的文件既拖慢构建速度也容易把敏感文件带进镜像。可以用 .dockerignore 文件过滤掉 node_modules、target、.git、*.md 等不需要的文件。这个文件对构建的影响不亚于 Dockerfile 本身建议每个项目都要维护。多阶段的层数不是越少越好。从镜像体积角度看层越少体积越小合并优化效果有限但从缓存利用率和构建可维护性角度看适度分阶段是好事情。核心矛盾在于“最终镜像体积”和“构建缓存效率”两个目标。最佳平衡点是根据团队实际构建频率和代码变更模式来确定。5. 常见问题与排查技巧实录5.1 常见问题速查表症状可能原因排查思路解决方案构建缓存一直不生效每次全量重来COPY 指令顺序靠前源码变化导致后续缓存失效用docker build --progressplain查看哪些层走了缓存把依赖相关 COPY 和安装提前项目源码放最后COPY --from 报错找不到阶段阶段名拼错或者提前引用后面定义的阶段检查 Dockerfile 各阶段的 AS 命名是否一致且唯一统一阶段命名规范确保 COPY 指令在该阶段定义之后镜像体积减不下来最终阶段基础镜像太大或者 COPY 了多余文件用 docker history 看每层大小换更小的基础镜像检查 COPY 的源路径是否包含多余文件容器启动报证书错误scratch/distroless 镜像没有 CA 证书确认程序是否访问外部的 HTTPS 接口从认证过的镜像阶段 COPY 证书文件docker build 很慢依赖下载每次都重来没有使用 BuildKit 缓存挂载检查 docker buildx 是否启用用 RUN --mounttypecache,target... 挂载依赖目录多阶段构建的 ARG 拿不到值外层 ARG 没有在各阶段内部重新声明检查变量作用域在每个阶段内再次声明 ARG最终镜像里还有源码没有正确使用 .dockerignore 或 COPY 了过多文件检查构建上下文和 COPY 指令细化 .dockerignoreCOPY 只拉取需要的目录容器以 root 运行有安全风险最终阶段没有 USER 指令切换用户docker inspect 查看容器的 User在最终阶段加 USER 指令或使用数字 UID5.2 排查技巧用 docker history 定位大文件镜像体积没降下来时第一步不是猜而是看每一层到底占了多少空间。docker history --no-trunctrue 镜像名这个命令会按时间倒序列出镜像的所有层以及每层大小。哪一层体积异常大问题就出在哪一层对应的 Dockerfile 指令。比如你看到某层有 200MB而上一条指令是 RUN pip install基本就能断定是把安装在最终阶段跑了应该移到构建阶段。或者看到了 COPY 一个 300MB 目录的层那赶紧检查 COPY 的源路径和 .dockerignore。5.3 本地复现 Docker build 异常环境的小技巧如果你遇到构建时某个命令的报错想进到该指令对应阶段的中间状态去排查可以用一个临时的 Dockerfile 配合 --target 来重建现场。比如我的 Java 项目在 maven 打包那一步失败想看看容器里 /root/.m2 的情况可以临时把该阶段的 ENTRYPOINT 覆盖成 sleep然后 docker exec 进去看docker build --target build -t app-build-debug . docker run -it --rm app-build-debug bash这个技巧排查多阶段构建问题特别高效尤其是在依赖下载、编译报错这类场景。不用反复改 Dockerfile直接进容器手动跑指令看到什么都能立刻定位。5.4 一个真实的构建缓存踩坑案例我某次调整前端项目 Dockerfile发现改了源码后构建依然要 3 分钟以上不对劲。用 buildkit 的 plain 模式跑了一次构建看到 npm ci 这步每次都在重新执行。排查后发现问题出在这条指令COPY . . RUN npm ci虽然第一眼是 COPY . . 放在 npm ci 之前但 npm ci 本身不依赖生产代码把 COPY . . 放前面导致任何源码改动都会触发 npm ci 缓存失效。修正方案是调整顺序COPY package.json package-lock.json . RUN npm ci COPY . .调整后源码改动后 npm ci 层能直接命中缓存构建时间从 3 分钟降到了 40 秒以内。这个例子说明多阶段构建不仅仅是把阶段分开那么简单每个阶段内部的指令顺序也需要精心设计。写在最后的个人体会多阶段构建给我带来的最大收益不是某个项目从 1GB 变成 30MB 的“爽感”而是它逼着你去思考容器里真正需要什么。每次写 Dockerfile 时先问一句“这个阶段是为构建服务还是为运行服务最终产物到底是什么”想清楚之后镜像体积、构建速度、安全性这些问题会跟着解决掉大半。如果你还没开始用多阶段构建建议拿手头的一个旧项目练手。不用追求一步到位先在原有 Dockerfile 基础上拆出编译阶段和运行阶段把产物 COPY 过去体验一下从 1GB 到几百 MB 的过程。熟悉之后再逐步引入 BuildKit 缓存、dockerignore 瘦身、非 root 运行等进阶手段。改完之后把前后的镜像体积和构建时间记下来这个数据会让你和你的团队都重新审视 Dockerfile 的价值。
返回列表