ARTICLE DETAIL

资讯详情

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

Docker多阶段构建实战:从根源解决镜像臃肿问题

Docker多阶段构建实战:从根源解决镜像臃肿问题 1. 为什么你的镜像动辄几个G先聊聊多阶段构建的价值作为一个常年跟 Docker 打交道的人我见过太多这样的场景新同事拉下一个 Java 后端项目docker build构建完一看镜像 1.2GB。问他为什么这么大他也说不清反正基础镜像就用了openjdk:latest一顿操作下来整个镜像臃肿得不行推到仓库慢拉下来更慢磁盘直接被占满。其实这个问题用 Docker 多阶段构建就能很好地解决。多阶段构建是 Docker 17.05 版本引入的特性核心思路特别朴素一个 Dockerfile 里可以有多个FROM每个FROM是一个独立的构建阶段前面阶段产出的文件可以只挑有用的部分复制到后面阶段最终镜像只保留最后一段的内容。编译工具链、依赖源码、中间缓存这些“只参与构建、不参与运行”的东西可以全部留在中间阶段不污染最终镜像。这篇文章我不打算写成像官方文档那样条目化而是想结合我自己在项目里从“镜像怎么这么大”到“极其熟练地把镜像从 GB 级压到几十 MB”的真实过程把多阶段构建是什么、怎么用好、有哪些坑、怎么排查全部串一遍。无论你是刚开始玩 Docker还是已经在生产环境里维护镜像都可以从中找到用得上的东西。写这篇内容之前我还顺手看了一眼大家最近都在搜什么。很多人卡在 Docker 安装、镜像下载慢、容器启动失败这些最基础的环节这些我也经历过。所以后面会穿插一些实用的避坑提示比如镜像源怎么配、构建缓存怎么命中、BuildKit 怎么开尽量让每个问题都有对应的落地解法。2. 多阶段构建的正确打开方式一个 Go 项目的对比实践2.1 先看单阶段构建是怎么把镜像搞大的假设你有一个 Go 编写的 HTTP 服务代码项目结构大概是这样的myapp/ main.go go.mod go.sum如果从来没接触过多阶段构建很多人会这样写 DockerfileFROM golang:1.21 WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -o myapp . CMD [./myapp]这段 Dockerfile 在功能上没毛病golang:1.21这个镜像包含了完整的 Go 编译工具链、git、各种系统库所以你本地能编译容器里也能编译。但问题就来了golang:1.21镜像本身就接近 800MB你编译出来的二进制包可能只有 30MB可最终镜像把编译工具链也全装进去了。如果项目还依赖 CGO 或者额外的系统库体积会直接往 2GB 以上走。这些构建产物在运行阶段真的需要吗完全不需要。真正跑容器时只需要那个 30MB 的二进制文件其余的编译器、包管理工具、缓存、系统头文件全是垃圾负担。这就是单阶段构建最大的弊端把构建环境当作运行环境用最终镜像体积虚高。这在本地开发时感受还不太明显一旦进了团队协作或者生产环境问题就放大了。镜像推到私有仓库要等发布平台拉镜像要等多个服务同时更新时磁盘被撑爆也是常有的事。2.2 用多阶段构建把镜像从 800MB 降到 30MB多阶段构建的写法其实很简单就是在同一个 Dockerfile 里写多个FROM每个FROM可以用AS起一个名字。先看改造后的完整 Dockerfile# 第一阶段编译 FROM golang:1.21 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 go build -o myapp . # 第二阶段运行 FROM alpine:3.19 WORKDIR /app COPY --frombuilder /app/myapp . EXPOSE 8080 CMD [./myapp]这个 Dockerfile 里有两个阶段第一阶段叫builder基于golang:1.21负责把源码编译成可执行文件第二阶段基于alpine:3.19一个只有几 MB 的极简 Linux 镜像只负责运行。第二个阶段通过COPY --frombuilder /app/myapp .把第一阶段编译出的myapp复制过来整个最终镜像里既没有 Go 编译器也没有源代码只有一个静态编译的二进制文件。最终的镜像体积通常能从 800MB 左右降到 20-30MB压缩后甚至不到 10MB。这段 Dockerfile 解决了两个核心问题一是构建环境与运行环境彻底解耦二是镜像里只保留运行时需要的内容。这也是多阶段构建最核心的语义每个阶段都是独立的阶段之间的交互只发生在显式声明的地方。注意COPY --frombuilder里的builder是第一阶段AS后面定义的别名也可以用数字序号从 0 开始计数比如COPY --from0。但为了可读性强烈建议用有名阶段不然等 Dockerfile 写长了根本不知道--from0到底指的是哪个阶段。2.3 运行阶段的细节不能只看体积镜像变小只是一个方面多阶段构建还有一个容易忽略的优势运行阶段的系统环境变得更可控。golang:1.21是基于 Debian 的完整系统里面有什么软件你未必完全清楚。而alpine:3.19是一个极简系统里面只有最基本的工具和库攻破面天然小很多。同时最终镜像里的依赖更少意味着漏洞出现的概率也更低这对生产环境来说是很重要的安全收益。不过alpine 也有一个需要留意的点它用的是 musl libc跟常见的 Debian/Ubuntu 用的 glibc 不是同一套。如果你的二进制文件是动态链接的直接丢进 alpine 里可能跑不起来会报No such file or directory或者类似加载器找不到的错误。解决方式无非两种一是像上面例子一样编译时设置CGO_ENABLED0让 Go 直接编出纯静态的二进制完全不依赖系统库二是运行阶段改用distroless镜像或debian:stable-slim这样能和 glibc 动态库保持兼容。后面我在常见问题部分会再展开讲这个坑。3. 一个真实案例Node.js 服务镜像瘦身全过程3.1 前端工程化和 Node 服务的镜像痛点Go 这种编译型语言做多阶段构建非常直观很多教程也都拿 Go 举例。不过实际工作中Node.js 和 Python 这类解释型语言才是大多数团队的主力它们的多阶段构建思路略微不同但效果同样显著。我一个朋友的团队做过一个基于 Express 的 Node.js 服务最初 Dockerfile 是这样写的FROM node:18 WORKDIR /app COPY package*.json ./ RUN npm install COPY . . EXPOSE 3000 CMD [node, server.js]这个镜像构建完大概 1.1GB。原因很直接node:18完整镜像里包含了 npm 源码、构建工具链、全局文档还有为了兼容各种 Node 模块而预装的编译器和 Python。再加上npm install时安装的全部是带开发依赖的包很多只在测试阶段用到的库也进了最终镜像。这种镜像不仅构建慢部署也痛苦。每次升级发布传输 1GB 多的镜像在弱网环境下基本就是灾难。3.2 分阶段拆解依赖安装和生产部署分离Node.js 是多阶段构建里非常典型的案例我建议按下面这个思路拆分# 第一阶段安装依赖并构建 FROM node:18 AS build WORKDIR /app COPY package*.json ./ # 创建一个临时目录在生产模式下安装依赖 RUN mkdir -p /tmp/app \ cp package*.json /tmp/app/ \ cd /tmp/app \ npm ci --omitdev # 复制业务源码 COPY . . # 如果项目有构建步骤比如 TypeScript 编译、前端打包在这里做 # RUN npm run build # 第二阶段生产运行环境 FROM node:18-alpine AS runtime ENV NODE_ENVproduction WORKDIR /app # 只把生产依赖复制过来 COPY --frombuild /tmp/app/node_modules ./node_modules # 复制业务代码可以根据需要在构建阶段把关键代码过滤后再复制 COPY --frombuild /app/server.js . COPY --frombuild /app/src ./src # 如果项目有 dist 目录或其他产物也在这里复制 EXPOSE 3000 USER node CMD [node, server.js]这里面的关键动作是什么我一个个说。第一第一阶段用了npm ci --omitdev只装生产环境需要的依赖不装 devDependencies。很多人写 Dockerfile 时习惯npm install这会把测试、构建相关的依赖全装进来。npm ci是专门针对 CI/CD 场景的命令会严格按照package-lock.json安装比npm install更稳定也更快。第二依赖安装在临时目录/tmp/app再把node_modules复制到最终镜像。这样做的原因是如果你直接在工作目录安装依赖然后COPY . .源码目录里的node_modules可能已经被本地或者其他方式污染了。在一个干净的临时目录里安装出来的依赖树是纯的。第三运行阶段用了node:18-alpine一个精简的 Node 运行时没有 npm、没有编译器体积小得多。最终镜像从 1.1GB 降到了 160MB 左右压缩后更小。如果想进一步瘦身还可以考虑把 Node 服务做成编译产物后跑在 distroless 里。不过这一步对 Node 项目来说属于进阶操作通常团队用 alpine 版本就够了没必要为了追求极限体积增加排查难度。3.3 实测对比和运行验证改造完成后我用同样的代码分别构建两个版本实测数据大致是这样的镜像构建方式镜像体积压缩后体积启动时间单阶段 node:18 全量依赖单阶段约 1.1GB约 400MB约 1.5s多阶段 node:18-alpine 生产依赖多阶段约 160MB约 60MB约 1.2s体积差距接近 7 倍启动时间基本没变化但拉取和推送镜像的时间缩短了非常明显。第二版发布时镜像从仓库拉取到集群只需要几秒到十几秒而以前可能要等几分钟。这里要特别提醒一个容易踩的坑如果第一阶段运行了npm run build之类的构建命令生成的产物一定要通过COPY --frombuild显式复制到运行阶段不要指望构建阶段产生的文件自动带过来。多阶段构建中每一个阶段都是独立文件系统阶段结束不代表文件保存只有被显式复制的文件才会进入后续阶段。4. BuildKit 加持下多阶段构建还能更高效4.1 开启 BuildKit默认它就更好用Docker 多阶段构建从 17.05 开始可用但真正让多阶段构建能力实现飞跃的是后来引入的 BuildKit 构建引擎。BuildKit 是 Docker 官方的下一代构建系统你现在的 Docker Desktop 或新版 Docker Engine 其实已经默认在用了只不过很多人没有主动感知。BuildKit 带来的几项关键能力我列一下实用性最高的几个更好的构建缓存复用机制多阶段构建中每个阶段都能独立缓存支持--mounttypecache可以为依赖目录设置专属缓存避免每次都重复下载支持--mounttypebind可以把源码只读挂载进构建环境而不是一层层COPY复制支持构建时密钥和 SSH agent 转发构建时可以从私有仓库拉依赖而不需要把密钥写进镜像。如果你用的是旧版 Docker可以在构建前设置环境变量DOCKER_BUILDKIT1 docker build -t myapp:latest .新版 Docker 默认已经启用不需要额外配置。4.2 用缓存挂载把依赖下载时间压缩一半以上多阶段构建虽然能大幅缩小最终镜像但如果每个阶段每次构建都重新下载依赖构建时长就会拖得很长。举个实际例子一个 Java 项目的 Maven 依赖可能有 200MB每次从头下都会让人崩溃。BuildKit 的缓存挂载功能就是来解决这个问题的。在 Maven 或 Gradle 项目中这样写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 FROM eclipse-temurin:17-jre-alpine WORKDIR /app COPY --frombuild /app/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, app.jar]关键在RUN --mounttypecache,target/root/.m2这一句。第一次构建时Maven 会把下载的依赖写到/root/.m2这个临时挂载点第二次构建时BuildKit 会找到之前缓存的内容直接复用跳过重复下载。这个挂载的缓存只在构建过程中存在不会进入最终镜像层。对于 Maven 项目这一条就能把大部分构建时间从 5-8 分钟压到 1 分钟以内。Node.js 项目同理可以把~/.npm挂载为缓存RUN --mounttypecache,target/root/.npm npm ci --omitdev4.3 利用阶段目标调试中间步骤多阶段构建在实际调试中还有一个很好用的技巧用--target参数指定只构建到某个阶段。比如服务跑起来报错了你想进容器里看看编译阶段生成的中间文件不需要把整个镜像构建完。直接docker build --target builder -t myapp:debug .这样 Docker 只会构建到builder阶段你可以基于myapp:debug这个镜像启动容器进去查看源码、依赖、编译产物等等。再配合docker run -it --entrypoint sh myapp:debug排查问题要方便很多。这个技巧在优化 Dockerfile 时特别有用。你想验证某个安装命令是否成功、某个目录有没有生成预期文件先构建到对应阶段发现问题后只需要修改该阶段之后的命令重新构建不需要从头跑完全过程。5. 多阶段构建中绕不开的常见问题和排查技巧5.1 问题速查表我根据自己踩过的坑和帮别人排查时遇到的典型案例整理了一张速查表。问题现象可能原因解决思路构建时提示COPY --fromxxx失败找不到阶段阶段名拼写错误或该阶段尚未定义检查 Dockerfile 中AS后面的名称是否一致注意大小写最终镜像里文件缺失忘记写COPY --frombuild复制文件多阶段中每个阶段文件系统独立必须显式复制产物alpine 里运行二进制报No such file or directory二进制是动态链接的alpine 的 musl 找不到 glibc 加载器编译时设置CGO_ENABLED0或改用debian:stable-slim/distroless作为运行镜像容器运行时间显示 UTC比北京时间慢 8 小时alpine/精简镜像没有配置时区数据在运行阶段安装tzdata或者通过环境变量TZAsia/Shanghai配合系统时区文件解决以 root 用户运行容器存在安全风险运行阶段没有创建普通用户在运行阶段RUN addgroup -S app adduser -S app -G app然后USER app每次构建都重新下载依赖构建很慢没有利用层缓存或 BuildKit 缓存挂载把pom.xml/package*.json等依赖清单单独COPY再执行依赖安装最后才复制源码构建上下文太大构建很慢源码目录包含node_modules、.git、临时文件等没有排除合理配置.dockerignore把不需要的文件全部排除掉5.2 最容易踩的三个隐蔽坑上面表格里的问题都比较明确我再单独拎出三个隐蔽坑因为它们排查起来相当费劲。第一个坑是动态链接库问题。我帮人排查过一个 Go 服务本地跑得非常好docker build也没报错但容器一启动就报错提示找不到libpcap.so.1之类的动态库。原因就是这个程序不是静态编译的它在运行阶段依赖某些系统库而 alpine 里根本没有这些库。排查方法很简单在运行阶段临时进入容器用ldd ./myapp查看动态库依赖缺什么装什么或者干脆回到编译阶段把 CGO 关掉做静态编译。第二个坑是时区。默认情况下 alpine 镜像使用 UTC 时间很多应用日志打印出来比北京时间慢 8 小时。问题的根因是缺少/usr/share/zoneinfo数据。可以在运行阶段加一句RUN apk add --no-cache tzdata \ cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ echo Asia/Shanghai /etc/timezone \ apk del tzdata这里先安装 tzdata复制完时区文件后再删除它是为了避免把额外的数据留在最终镜像里。有些人的做法是在启动命令里设置TZAsia/Shanghai环境变量实测在某些极简镜像里并不生效因为镜像里根本没有时区数据库所以复制文件才是更稳妥的方案。第三个坑是默认用户是 root。很多 Dockerfile 从头到尾没有写过USER指令容器内应用一直以 root 身份运行。这在开发环境可能感受不到风险但生产环境一旦有漏洞被利用权限就是最高级别。多阶段构建的最终阶段建议创建并切换到普通用户Go 二进制可以这么做FROM alpine:3.19 RUN addgroup -S appgroup adduser -S appuser -G appgroup WORKDIR /app COPY --frombuilder /app/myapp . USER appuser CMD [./myapp]addgroup和adduser是 alpine 里添加用户组的命令Debian 系镜像则用groupadd和useradd。要求应用监听 80 端口时需要注意切换用户后没有权限绑定 80 以下端口通常改成监听 8080 或者配置端口转发。5.3 配合镜像加速源解决下载慢的困扰多阶段构建本身有个前置条件你得先把基础的编译镜像和运行镜像拉下来。很多新手卡在这一步就是因为 Docker 默认从 Docker Hub 拉取镜像国内访问时快时慢甚至经常超时。这个问题的解法是配置镜像加速源。以 Windows 上的 Docker Desktop 为例在设置里的 Docker Engine 配置文件中添加 registry-mirrors 即可。Linux 上则直接修改/etc/docker/daemon.json{ registry-mirrors: [ https://docker.m.daocloud.io, https://dockerproxy.com ] }修改完后重启 Docker 服务。需要注意的是镜像源可用的稳定性会有波动如果某个源拉不动了可以换一个试试。这一步不影响后面多阶段构建的写法但能明显改善构建体验。提醒配置镜像源和编写多阶段构建是两个维度的事情。镜像源解决的是镜像下载通道的速度问题多阶段构建解决的是镜像本身内容臃肿的问题两者叠加效果最佳。一个让你拉得快一个让你拉得小。6. 多阶段构建在真实项目里的进阶玩法6.1 不只有 Go 和 NodeJava 和 Python 同样适用很多教程把多阶段构建写成“编译型语言专用”其实解释型语言也同样适用。Java 项目里常见的问题是运行镜像里残留全套 JDK 编译工具实际上生产环境只需要 JRE。多阶段构建可以直接做到这一点第一阶段用 JDK 镜像编译或打包第二阶段用 JRE 镜像运行。来看一个简化版的 Spring Boot 项目 Dockerfile# 阶段一使用 Maven 和 JDK 进行打包 FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests # 阶段二只需要 JRE 运行环境 FROM eclipse-temurin:17-jre-alpine WORKDIR /app COPY --frombuild /app/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, app.jar]从一个完整的 JDK 镜像切到 JRE 镜像体积从 800MB 级别直接降到 200MB 左右。Python 项目同理第一阶段安装依赖到指定目录第二阶段把这个目录复制过来FROM python:3.12 AS builder WORKDIR /app COPY requirements.txt . RUN pip install --prefix/install -r requirements.txt FROM python:3.12-slim WORKDIR /app COPY --frombuilder /install /usr/local COPY . . CMD [python, app.py]这里用pip install --prefix/install把依赖装到一个独立的目录运行时阶段只需要把/install复制进去/usr/local这个默认路径会在sys.path的搜索范围内所以包能正常被找到。这个做法也能避免把pip的缓存目录一并带进镜像。6.2 使用 distroless 镜像实现极致精简如果追求极致的镜像体积和安全性Google 的 distroless 镜像是非常值得尝试的方案。distroless 镜像里面没有任何 shell、包管理器或者多余命令只有一个裸的操作系统运行时环境用途就是单一地跑你的应用。Go 服务配合 distroless 可以这样写FROM golang:1.21 AS build WORKDIR /app COPY . . RUN CGO_ENABLED0 go build -o myapp . FROM gcr.io/distroless/static-debian12 WORKDIR /app COPY --frombuild /app/myapp . USER nonroot ENTRYPOINT [./myapp]这样构建出来的镜像几乎只剩一个二进制文件体积大概 15MB 左右。但要注意distroless 镜像里没有 shell也没办法用docker exec进容器执行任何调试命令。日志只能通过标准输出查看排错手段极其有限。生产环境用 distroless 前建议先确保你的日志系统、监控系统都已经规范到位不然真正出问题时可能会抓瞎。如果是 Java 或 Node 项目对应有gcr.io/distroless/java17-debian12和gcr.io/distroless/nodejs18-debian12这样的现成镜像按需选用就行。6.3 多架构镜像构建一次构建多平台运行现在 ARM 架构的设备越来越多很多团队需要同一个镜像同时跑在 x86 和 ARM 的服务器上。多阶段构建和 Buildx 结合可以很轻松地构建多平台镜像docker buildx build \ --platform linux/amd64,linux/arm64 \ -t your-registry.com/team/myapp:latest \ --push .Buildx 会在构建时自动处理平台差异前提是你的 Dockerfile 中没有写入只在某个架构下有效的命令。比如安装某个软件包时Debian 系镜像的包名在不同架构下可能不一样需要多加留意。多平台构建会一次性把多个平台的镜像打包推送对于统一发布流程来说非常有用。我个人的经验是先在本地用单平台构建验证业务功能确认没问题后再执行多平台构建和推送。因为多平台构建的速度通常比单平台慢不少每次都等完整构建太浪费时间。7. 写在最后我实际使用中的一些体会多阶段构建这个功能初看只是 Dockerfile 的一种写法真正用起来会发现它带来的思维方式转变挺重要的。把“构建环境”和“运行环境”当作两个独立的事物去设计你自然就会开始思考最终镜像里真正需要的是什么哪些只是过程中的临时产物。这种思考习惯对一个服务化架构的长期维护是有益的。我自己的习惯是每个新项目启动时就写好两阶段的 Dockerfile运行阶段优先选 alpine 或 slim依赖安装严格区分生产和开发编译型语言尽量做静态编译。这样一开始镜像体积就控制在合理范围内后面就不会出现“这个镜像怎么这么大了”的返工。另外再分享一个小技巧构建完镜像后可以用docker history 镜像名查看每一层的大小定位到底哪一层占了空间。多阶段构建虽然能解决绝大多数体积问题但如果某个阶段里误执行了apt-get install后又没清理缓存镜像体积还是会悄悄涨上去。清洗工作放在同一个 RUN 指令里完成是控制层大小的关键。Docker 的官方文档永远是最好的参考资料但真正把这些特性用得行云流水还是得靠一次次构建、排查、调整换来的经验。希望这篇实践指南能帮你少走一些弯路把你的镜像体积和构建效率都优化到让你满意的程度。
返回列表