☰
Docker镜像瘦身实战:多阶段构建从986MB压到15.7MB
2026/10/10 6:37:56 网站建设 项目流程

先交代一个真实场景:我有一段时间特别排斥做镜像瘦身,总觉得“能跑就行,体积大点无所谓”。直到某次往测试环境推一个功能只有两个接口的 Go 服务,镜像拉取卡在进度条上一动不动,我盯着终端看了快三分钟才跑完。回头一查,Dockerfile 写得极其偷懒,直接拿golang:1.21开发镜像做基础镜像,编译完也不删任何东西,最后镜像体积高达 986MB。一个编译出来只有 9MB 的静态二进制,凭什么要拖着一个几百 MB 的开发环境到处跑?

后来我把这个 Dockerfile 改成多阶段构建,镜像体积从 986MB 压到 15.7MB,直接砍了 98%。在绝大多数项目里,哪怕你用的是 Node.js、Python 或者 Java,只要学会多阶段构建的思路,镜像体积减少 80% 完全不是夸张说法。这篇文章就围绕“多阶段构建”这个核心技巧,讲清楚它为什么能瘦身、怎么改 Dockerfile、改成什么样,以及我在实操中踩过的坑。

适合正在用 Docker 搭项目、觉得镜像太胖但又不知道从哪下手的开发者。不需要你有多深的容器底层知识,只要会用 Dockerfile 基础指令,就可以跟着把这套优化思路落到自己的项目里。

1. 镜像体积失控的那些坑:为什么一个普通服务会占到近1GB

很多人第一次发现自己镜像很大时的反应是“没事,本地磁盘够用”。但我劝你把这件事认真对待,因为镜像体积不是只占你本地硬盘那点地方,它会在好几个环节一起给你添堵。

1.1 体积背后是“开发环境”和“运行环境”混在一起

先想一个问题:一个用 Go 写的 Web 服务,它跑起来的时候,真正需要的文件是什么?答案很简单——只需要一个编译好的可执行文件,外加可能用到的静态资源、配置文件、证书文件。它根本不需要 Go 编译器、不需要 Go 源码、不需要go mod下载的那些依赖包源码。

但传统的单阶段 Dockerfile 写法恰好把这一切全塞进去了。典型的写法是这样:

FROM golang:1.21 WORKDIR /app COPY . . RUN go mod download RUN go build -o server . EXPOSE 8080 CMD ["./server"]

golang:1.21这个基础镜像自带完整的 Go 开发工具链、系统包、各种编译中间文件,镜像体积本身就接近 800MB。你在这个基础上再叠上项目源码、依赖模块、编译产物,最终镜像轻松突破 1GB。更尴尬的是,这些体积里 95% 是服务运行根本用不到的“开发现场”。

换到 Node.js 项目上就更夸张,很多人会抱着整个node_modules一起打进镜像,一个十几行的 Express 服务硬生生做出 1.2GB 的镜像。这其实不是 Docker 的问题,是构建思路出了问题——你把毛坯房里的脚手架、水泥袋、施工队全打包好,然后拎着这一整套搬进了新家。

1.2 体积大到底会让谁买单

镜像体积直接影响的环节比想象中多,我列几个最常见的:

受影响环节具体表现我在实际项目里的感受
镜像拉取体积越大,拉取越慢,尤其是内网网速一般的情况近 1GB 的镜像在测试环境每次部署都像在下载大型游戏
磁盘占用本地 Docker、私有仓库、部署服务器都要存储一个仓库存十几个版本的镜像,磁盘很快告急
启动速度容器启动时要准备文件系统、加载依赖体积大的镜像冷启动明显更慢,对频繁扩缩容的服务的体验影响显著
安全暴露面镜像里附带 shell、编译器、调试工具越多,攻击者越容易利用多一个 python、curl 就有可能多一条渗透路径

这些痛苦平时不太显眼,但一旦你开始做持续集成持续部署、动态扩缩容、多环境交付,就会被无限放大。拉镜像慢直接拖慢发布速度;存储占用上涨意味着要多花钱扩磁盘;安全暴露面在合规审计时每一层要翻出来查。所以镜像瘦身不是“锦上添花”,是真正能改善部署体验和降低维护成本的事。

2. 多阶段构建的工作原理:它凭什么能把体积砍到只剩产物

想用好多阶段构建,不能光会抄 Dockerfile,得先理解它背后“构建环境和运行环境分离”的这个设计逻辑。

2.1 传统 Dockerfile 为什么做不到瘦身

Dockerfile 里的每个指令都会被记录成一个镜像层(Layer),这些层一层叠一层形成最终镜像。只要你在同一个FROM基础上做任何操作,比如apt-get install装依赖、go build编译,这些中间状态全都留在层里,最后变成镜像体积的一部分。

很多人为了瘦身,会在同一条 RUN 指令里把临时文件顺手删掉,比如:

RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*

这确实能减少一点体积,但治标不治本。编译工具链装进去的那几百 MB 不可能通过简单rm删干净,因为底层文件系统已经记下了整个变更状态,你删完文件后镜像层只是多了一条“删除记录”,物理空间未必真正释放,更不可能把golang镜像自带的开发环境变没。

所以传统思路的困境是:一套 Dockerfile 只能有一个基础镜像、一个生命周期。你想在里面既完成编译、又拥有一个精简干净的运行环境,根本做不到。

2.2 多阶段构建的“隔离施工区”逻辑

多阶段构建出现后,这个问题迎刃而解。你可以在同一个 Dockerfile 里写多个FROM,每个FROM开启一个独立阶段,每个阶段用不同的基础镜像。前一阶段无论装了什么工具、下了多少依赖,都只是临时环境;只有你手动用COPY --from=xxx复制到最终阶段的文件,才会进入最终镜像。

你可以把它想象成装修房子。第一阶段是“施工阶段”:毛坯房里堆满了水泥、沙袋、电钻,工人在里面敲敲打打,这个阶段用什么工具都没关系;第二阶段是“入住阶段”:你不会把水电师傅和电钻搬进客厅,你只把做完的成品家具搬进去。最终镜像就是那个干净的房子,里面只有你要的东西。

这也是多阶段构建和普通 Dockerfile 最本质的区别:它把“用来构建的镜像”和“用来运行的镜像”拆开了。这是镜像瘦身里最核心的思维转换。

2.3 FROM 与 COPY --from 的组合使用

多阶段构建的语法非常简单,核心就是两个点:

FROM golang:1.21 AS builder

这是给第一阶段起一个别名builder。后面的阶段想要从这个阶段拿文件,就写:

COPY --from=builder /app/server /server

COPY --from可以指定从别名阶段复制,也可以直接指定从某个已存在的镜像复制(比如从外部镜像里提取一个证书文件),但绝大多数场景下都是从自己的构建阶段复制文件。

最终镜像只包含你通过COPY --from显式复制的内容,以及最终阶段里 RUN 等指令产生的新层。整个builder阶段占用的空间不会算进最终镜像。这就是为什么多阶段构建能把一个 986MB 的镜像压到 15MB 左右。

3. 实战改造:一个Go服务从986MB压到15.7MB的完整过程

原理说再多,不如直接看一次完整的改造过程。我用一个 Go 写的 HTTP 服务作为例子,改造前后的 Dockerfile 和体积数据都是真实可复现的。

3.1 改造前的单阶段Dockerfile与问题点

原始 Dockerfile 我有意写得比较“标准”,因为很多项目就是这么起步的:

FROM golang:1.21 WORKDIR /app COPY . . RUN go mod download RUN CGO_ENABLED=0 go build -o server . EXPOSE 8080 CMD ["./server"]

构建命令:

docker build -t demo:single .

镜像体积的分解大致是这样:

  • golang:1.21基础镜像本身约 800MB
  • 项目源码和 Go 依赖模块约 100MB
  • 编译工具链和中间文件约 80MB
  • 最终得到的demo:single镜像接近 986MB

由此可见,服务本身只有 9MB 的二进制,体积的绝大多数来自开发环境。这个问题单阶段 Dockerfile 是解决不了的。

3.2 多阶段版本与关键参数解读

改造后的 Dockerfile 如下:

# 阶段一:构建现场,用完整 Go 开发镜像 FROM golang:1.21 AS builder WORKDIR /app # 先拷贝依赖描述文件,利用 Docker 层缓存 COPY go.mod go.sum ./ RUN go mod download # 再拷贝源码并编译 COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server . # 阶段二:运行现场,用 scratch 空镜像 FROM scratch WORKDIR / COPY --from=builder /app/server /server EXPOSE 8080 ENTRYPOINT ["/server"]

几个值得关注的细节:

  1. CGO_ENABLED=0必须加:这样编译出来的是完全静态链接的二进制,不依赖任何.so动态库,才能放进scratch空镜像里跑。
  2. GOOS=linux建议显式声明:如果你在 macOS 或 Windows 上构建,不声明交叉编译参数,构建出来的二进制放到 Linux 容器里跑不起来。
  3. -ldflags="-s -w"可以顺手加:作用是去掉符号表和 DWARF 调试信息,能把二进制再减掉几 MB。
  4. COPY go.mod go.sum ./放在COPY . .前面:这能让依赖下载这层利用 Docker 构建缓存。只要go.mod、go.sum没变,即使源码改了,也不会重新执行go mod download,构建速度会快非常多。
  5. FROM scratch是一个空镜像:没有任何 shell、没有 ls、没有 ca-certificates,只有你的二进制。看起来简陋,但攻击面也最小。

3.3 构建对比与体积结果

同样的源码,两种写法各自构建:

docker build -t demo:multi . docker images

我这里实际拿到的数据是:

镜像版本最终体积
demo:single(单阶段)986MB
demo:multi(多阶段)15.7MB

体积减少了约 98%,远超标题里的 80%。如果你的服务里还带前端静态资源,多阶段构建一样能把这些静态文件复制过去,不会丢功能。

如果你的服务还需要 HTTPS 访问,scratch镜像里没有系统根证书,有两个解决方案。要么在运行阶段安装证书,对于一个“空镜像”来说比较麻烦;更常见的是在 builder 阶段把证书复制进来:

FROM alpine:3.19 AS certs RUN apk add --no-cache ca-certificates FROM scratch COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt

这样最终镜像里就有公网可信证书,可以正常发起 HTTPS 请求。

4. 不止Go:Node.js与Python项目如何套用同一套逻辑

多阶段构建不是只给 Go 用的,凡是“构建过程需要一堆工具,运行过程只需要产物”的项目,都可以按同一套思路来做瘦身。

4.1 Node.js:把npm install和构建结果拆出来

Node.js 服务最常见的镜像体积杀手是:基础镜像用node:18这种完整开发版、把整个node_modules一起拷进镜像、生产环境里还留着devDependencies。这三刀下来,镜像随随便便上 1GB。

改造思路是:第一阶段安装依赖并完成npm run build,第二阶段只保留运行依赖和构建产物。一个实用的例子:

# 阶段一:安装依赖并构建 FROM node:18 AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # 阶段二:精简运行环境 FROM node:18-alpine WORKDIR /app ENV NODE_ENV=production # 安装生产依赖(只包含 dependencies,不含 devDependencies) COPY package*.json ./ RUN npm ci --omit=dev && npm cache clean --force # 从构建阶段拿产物 COPY --from=builder /app/dist ./dist EXPOSE 3000 CMD ["node", "dist/main.js"]

这里关键点有两个:

  • npm ci --omit=dev只装生产依赖,比直接 COPY 整个node_modules干净很多。
  • npm cache clean --force去掉 npm 缓存,避免它又被记入镜像层。

如果你的项目没有任何构建步骤、也不需要原生模块,还可以更激进一点,第二阶段直接用node:18-alpine,然后把node_modules整个从 builder 阶段复制过来,甚至可以不用再执行一次npm ci。但要注意:如果项目里有bcrypt、sharp这类原生模块,它们依赖系统库,跨阶段复制很容易出现平台不匹配,这类情况反而建议在最终阶段重新安装。

4.2 Python:用slim镜像加用户级依赖安装

Python 项目的表现比 Node 还极端,因为pip往往会把一堆二进制依赖临时编译文件留在镜像里。我看到过很多人用一个python:3.12完整镜像,装完依赖后镜像接近 1.5GB。

多阶段改造的基本思路:

# 阶段一:安装依赖 FROM python:3.12-slim AS builder WORKDIR /app COPY requirements.txt . # 安装到用户目录,完成后只拷这部分 RUN pip install --user --no-cache-dir -r requirements.txt # 阶段二:精简运行镜像 FROM python:3.12-slim WORKDIR /app COPY --from=builder /root/.local /root/.local COPY . . # 把用户级依赖包路径加入 PATH 和 PYTHONPATH ENV PATH=/root/.local/bin:$PATH ENV PYTHONPATH=/root/.local/lib/python3.12/site-packages CMD ["python", "app.py"]

这样做的核心是:把pip install产生的依赖安装到独立目录,最终阶段只把这部分复制过去,不带任何 pip 缓存和临时文件。如果还想更小,可以把第二阶段换成python:3.12-alpine,但要注意 PyPI 上有不少包在 musl 上编译会有各种各样的兼容问题,Python 项目选slim通常比alpine更稳。

4.3 Java:特别注意JDK和JRE的区别

Java 项目没有 Go 那么明显的“单二进制优势”,因为它运行时离不开 JVM。但多阶段构建依然有效:第一阶段用 Maven/Gradle 镜像打包出 fat jar,第二阶段只装 JRE 不装 JDK,镜像我见过从 800MB 降到 280MB 的例子。

FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests FROM eclipse-temurin:17-jre WORKDIR /app COPY --from=builder /app/target/*.jar app.jar ENTRYPOINT ["java", "-jar", "app.jar"]

JDK 和 JRE 的差距非常大,JDK 里带着 javac、jdb、javap 等等开发调试工具,光这层就多出两百多 MB,跑生产项目完全用不到。有更高追求的话还可以用jlink按需裁剪 JRE,把不用的模块去掉,但那是一个更复杂的课题,新手先把 JDK 换成 JRE 就够开心一阵子了。

5. 想再省更多?多阶段构建之外的瘦身组合拳

多阶段构建是主力,但不是全部。想达到“减少 80%”甚至更好的效果,通常要配合另外几个手段一起打组合拳。

5.1 基础镜像选型:scratch、distroless、alpine、slim如何选

很多人在基础镜像上踩的第一个坑是:看到别人用alpine,自己也跟着用,结果依赖装不上了。基础镜像的选择其实和你的项目运行依赖强相关,不能说哪个小就无脑换。我的建议可以整理成一张表:

基础镜像体积量级特点适合场景
scratch几KB空镜像,无 shell、无包管理器、无系统库Go/Rust 静态编译二进制
distroles几十MB只有运行最小依赖,无 shell有动态依赖但想最小化暴露面的服务
alpine5MB左右busybox + musl 库,包管理器很轻量无原生依赖或已确认兼容的二进制
debian slim几十MBglibc 环境,兼容性好依赖较多、怕兼容问题的服务

选型时我的经验是:Go 项目优先尝试 scratch;有 HTTP 外呼需求、需要 CA 根证书的话,在证书问题解决后仍可放心用 scratch。Java 和 Python 优先考虑 distroless 或 slim,别轻易用 alpine 当默认选择。

5.2 层合并与清理:不让中间产物进入最终镜像

多阶段构建解决的是“开发环境混入运行环境”的问题,但如果你在最终阶段还做了一堆操作,比如apt-get install、下源码、编译,这些中间状态一样会变成层留下痕迹。

这里有一条最实用的经验:把安装、使用、清理压缩在同一条 RUN 指令里。举例:

RUN apt-get update && \ apt-get install -y --no-install-recommends curl && \ curl -fsSL https://example.com/script.sh | bash && \ apt-get purge -y curl && \ apt-get autoremove -y && \ rm -rf /var/lib/apt/lists/*

这样设计能确保中间产品不散落成多层。如果分成三条 RUN,哪怕最后删了文件,镜像层里也会多记几次变更历史。

5.3 利用BuildKit缓存与.dockerignore

除了多阶段构建,另外一个我反复强调的建议是:给项目补一个.dockerignore文件。很多人忽略了它,导致构建上下文太大。

node_modules dist/ .git .idea *.log Dockerfile README.md

这个文件的本质是告诉 Docker “这些文件不要打包进构建上下文”。没有它,Docker 会把项目目录整个打包发送给守护进程,比如一个带着几 GBnode_modules的目录,光构建前的上下文上传就要卡半天。

开启 BuildKit 之后,还能在 RUN 阶段启用--mount=type=cache,比如:

RUN --mount=type=cache,target=/root/.cache/go-build \ go build -o server .

这样每次构建的编译缓存会挂载在外部缓存里,不会写进镜像层,构建速度和体积都能受益。如果你还在用 Docker 19 以下的老版本,建议先把 Docker 升级,BuildKit 默认开启已经好几年了,新版本没必要再犹豫。

6. 踩坑记录:多阶段构建使用中我遇到的五个问题

多阶段构建的语法本身不难,但实际用起来有不少隐蔽的坑,新手经常会被这些细节卡住。我把印象比较深的几个问题写出来,算是给大家排雷了。

6.1 scratch没有shell,别在CMD里用.sh

scratch镜像里没有任何可执行文件之外的东西,没有/bin/sh、没有/bin/bash。如果你在CMD里写数组形式没问题,比如["/server"];但如果写成 shell 字符串形式,比如:

CMD /server

Docker 会自动在前面补/bin/sh -c,然后在 scratch 里没有 shell 就会直接报no such file or directory。换成ENTRYPOINT ["/server"]或写好CMD数组形式就没事。

6.2 二进制有动态依赖却放进scratch,启动即报错

不是所有 Go 二进制都能塞进 scratch。如果你用CGO_ENABLED=1编译,或者用了依赖 C 库的库,ldd看一下会发现它依赖libc.so.6之类的动态库。这时候放 scratch 里会出现经典的standard_init_linux.go: exec user process caused "no such file or directory"报错。

遇到这种依赖,最简单的办法不是折腾 scratch,而是换debian:stable-slim或者alpine,装上对应的运行库,或者干脆在 build 时把CGO_ENABLED=0想办法打开。多阶段构建的核心思路是“运行环境尽量小”,不是非要小到 scratch。

6.3 COPY --from路径和阶段名必须精确匹配

COPY --from=builder /app/server /server的三个部分里,阶段名builder拼错、源路径写错、目标路径没创建,都会报错。有些新手会把阶段名和镜像名搞混,FROM golang:1.21 AS builder后面就只能写builder,不能写golang:1.21。

还有一个容易踩的点:如果源阶段里没有生成对应文件,COPY --from会把整个目录复制过去但内容为空。比如你编译命令输出了./bin/app,但后面写的是/app/app,镜像体积不会变大,但容器一启动就报找不到文件。

6.4 ARG与ENV的跨阶段传递陷阱

多阶段构建里,每个阶段默认带着自己的环境变量,ARG只在声明阶段有效,ENV只影响当前阶段及后续继承的阶段。如果你是这么写的:

FROM alpine AS build ARG VERSION=1.0 RUN echo $VERSION FROM scratch COPY --from=build /app /app

第二个阶段里是拿不到VERSION的。想在多个阶段共用变量,通常要在每个阶段里各自声明一次ARG VERSION,或者用.env配合--build-arg。这里的坑很容易藏在“最终的运行环境需要某个环境变量”这类需求里,最好是显式在最终阶段用ENV声明一次,避免 COPY 过去的配置读取不到。

6.5 缓存失效的控制:依赖文件优先COPY

最开始写多阶段 Dockerfile 时,我习惯把COPY . .写在最前面。后来构建次数变多,才发现每次只要源码改一个字符,go mod download、npm ci这些又贵又慢的步骤全部重新跑一遍,构建时间几乎翻倍。

优化方式是调整 COPY 顺序:先拷go.mod、go.sum、package.json、package-lock.json一类依赖描述文件,跑完依赖下载,再拷源码。这样只要依赖文件没变,后面的依赖缓存能直接复用,构建速度会快很多。看似只是调整了两行顺序,实际体验差别巨大。

还有个小坑是COPY . .可能会把本地构建的临时产物也拷进去,比如node_modules、dist、.git。控制这个问题靠.dockerignore,这也是为什么我在前面单独讲了那一节。

一个顺手能用的小建议

最后分享一个我实际养成的小习惯:拿到任何别人的 Dockerfile,我都会先问一句“这个镜像最终运行到底需要哪些文件”。这个问题想明白,镜像瘦身方向基本就对了至少一半。多阶段构建本质就是这句话的落地手段——把“施工环境”和“入住环境”彻底分开,只把最需要的东西带进最终镜像。

你现在就可以挑一个日常在跑的小项目,哪怕只有两三个接口,把 Dockerfile 改成多阶段构建,跑一次docker images看看体积变化。我保证那一瞬间的对比数据,比你读十篇优化教程都更有说服力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询