Docker 镜像体积过大会带来三个直接后果:拉取时间变长、磁盘占用上升、构建和部署链路里的网络传输成本增加。很多项目的 Dockerfile 仍然采用单阶段构建,把源码、编译器、缓存和最终运行时依赖放在同一层,哪怕在后续用 rm 删除文件,也已经无法缩小历史层的体积。要真正解决问题,需要从镜像分层和多阶段构建入手,并配合依赖清理和 .dockerignore 来减少构建上下文。本文会分析体积膨胀的原因,给出多阶段构建的标准写法,再通过 Go 和 Node.js 两个项目的实例演示如何将镜像从数百 MB 压缩到几十 MB。

一、镜像体积为什么失控
Docker 镜像采用分层存储机制,每条 RUN、COPY、ADD 指令都会创建一个新的只读层。这种设计方便复用缓存,但也带来一个代价:后续层无法真正删除前面层写入的数据。假设你在同一条 RUN 指令里执行了 apt-get install 安装编译工具,然后又在另一条 RUN 指令里执行 rm -rf 删除这些工具,镜像最终大小并不会减小。原因是删除操作只是在新的层里将这些文件标记为不可见,历史层中的内容仍然保留在镜像里,并继续占用仓库空间和网络传输带宽。
此外,包管理器在安装依赖时会留下大量缓存文件。以 Debian/Ubuntu 为例,apt-get update 会把软件包索引写入 /var/lib/apt/lists,apt-get install 下载的 .deb 文件则存放在 /var/cache/apt/archives。Python 的 pip 会把下载的 wheel 文件保存到本地缓存目录,npm 也有自己的缓存机制。这些缓存对构建过程可能有用,但对最终运行镜像来说几乎都是无用数据。如果构建上下文里还混入了 .git 目录、本地 node_modules、测试报告或日志文件,镜像体积会进一步膨胀。
二、多阶段构建的核心思路与写法
多阶段构建是 Docker 17.05 引入的能力,它允许在一个 Dockerfile 中声明多个 FROM 指令,每个 FROM 启动一个独立的构建阶段。通常做法是:第一个阶段使用完整的基础镜像,安装编译器、下载依赖、执行测试和构建,产出可执行文件或静态资源;第二个阶段换成一个轻量运行镜像,只复制上一阶段的必要产物。这样做的好处非常直观,编译阶段引入的 SDK、头文件、源码、包管理器缓存都不会进入最终镜像。
下面是一个 Go 项目的多阶段构建示例:
# 构建阶段 FROM golang:1.22 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o /app/main . # 运行阶段 FROM alpine:3.20 RUN apk add --no-cache ca-certificates WORKDIR /root/ COPY --from=builder /app/main . CMD ["./main"]
这段 Dockerfile 中,golang:1.22 镜像本身约 800 MB,它包含 Go 编译器、标准库源码和大量调试工具,但这些只在构建时需要。运行阶段选择 alpine:3.20 作为基础镜像,仅安装 CA 证书,复制编译好的 main 二进制文件。最终镜像大小通常只有 15 MB 左右。COPY --from=builder 指令中的 builder 就是构建阶段的别名,它告诉 Docker 从上个阶段的文件系统中复制文件,而不是从宿主机构建上下文中复制。
多阶段构建也不是银弹。不同语言和应用对运行环境的要求不同,比如动态链接库、解释器、运行时环境等。Node.js 应用最终需要 node 运行时,Python 需要解释器和依赖包,Java 需要 JRE。此时仍然应该用多阶段将构建依赖和运行依赖分离,但最终基础镜像要保留必要的运行时组件。
三、依赖清理:包管理器和系统缓存
即使使用多阶段构建,最终运行阶段的基础镜像仍然可能自带包管理器缓存,或者需要在运行阶段安装少量系统库。此时必须注意把安装和清理放在同一条 RUN 指令中完成。如果分开写,删除动作无法减少之前层中已经写入的缓存文件。下面是一条典型的 Debian 安装命令,它把更新、安装和清理合并到一起,并且使用 --no-install-recommends 避免带入不需要的推荐包:
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates && rm -rf /var/lib/apt/lists/*
不同包管理器的清理参数差异较大。Alpine 使用 apk 时,加上 --no-cache 参数就不会保留索引缓存,所以常见写法是 apk add --no-cache ca-certificates。CentOS/RedHat 系使用 yum clean all 或 dnf clean all。Python 的 pip 可以在安装时加 --no-cache-dir 禁止写入缓存。npm 虽然提供 npm cache clean --force,但在 Dockerfile 中更推荐使用 npm ci --omit=dev 或 npm install --omit=dev,直接从源头减少开发依赖的安装。
还有一个容易被忽略的环节是构建上下文。docker build 默认会把当前目录下所有文件发送给 Docker daemon,如果目录里包含 .git、node_modules、dist、日志等大目录,构建过程会变得缓慢,即使 Dockerfile 没有使用这些文件,它们也可能被 COPY . . 带入镜像。通过 .dockerignore 文件可以排除这些无关路径,例如:
.git node_modules dist *.log README.md
这个文件放在 Dockerfile 同级目录,语法与 .gitignore 类似。它不仅能减小构建上下文,还能防止敏感信息如 .env 文件被意外复制进镜像。
四、两个完整示例与体积对比
先看 Go 后端服务的优化效果。单阶段构建如果直接使用 golang:1.22 并在同一个镜像里运行,最终镜像通常大于 800 MB。采用上一节的多阶段 Dockerfile 后,镜像体积可以降到 15 MB 左右。这里有几个关键点:CGO_ENABLED=0 关闭 CGO,强制 Go 编译器进行纯静态编译,避免依赖 glibc 动态库;GOOS=linux 指定目标操作系统;运行阶段安装 ca-certificates 是为了让程序能够校验 HTTPS 证书,如果程序不访问外部 HTTPS 接口,这步也可以省略。
再看一个前端 React 应用,单阶段构建一般会在 node 镜像中安装依赖、构建,然后用 node 服务托管静态文件,镜像可能达到 1 GB 以上。改成多阶段后如下:
# 构建阶段 FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # 运行阶段 FROM nginx:1.27-alpine COPY --from=builder /app/dist /usr/share/nginx/html EXPOSE 80
构建阶段使用 node:20-alpine 执行 npm ci 和 npm run build,生成 dist 静态目录。运行阶段换用 nginx:1.27-alpine,只复制 dist 目录并暴露 80 端口。由于 nginx 镜像只有约 45 MB,整个最终镜像通常不会超过 60 MB。这比原先在 node 镜像中启动静态服务器的方案减少了 90% 以上。
对于 Node.js 后端服务,不能直接换成 nginx,最终阶段需要保留 node 运行时。可以使用 node:20-alpine 作为运行基础镜像,并在安装依赖时使用 npm ci --omit=dev,这样 devDependencies 不会被安装,node_modules 体积也会显著下降。如果构建产物是纯 JavaScript 代码,还可以考虑使用 pkg 或 esbuild 打包,但需要额外评估兼容性。
五、常见误区和检查命令
第一个常见误区是在最终运行阶段仍然安装整套构建工具。很多人为了省事,直接在运行镜像里执行 npm install 或 pip install -r requirements.txt,这样构建依赖和运行依赖混在一起,失去多阶段的意义。正确做法是只在构建阶段安装完整依赖,运行阶段只复制必需的运行依赖或产物。
第二个误区是把清理命令单独写成一条 RUN。比如先 RUN apt-get update,再 RUN apt-get install -y curl,最后 RUN rm -rf /var/lib/apt/lists/*。这种写法看似清理了缓存,实际上前两层中已经永久保留了缓存数据,镜像体积并不会下降。必须把安装和清理写进同一条 RUN 指令,才能确保缓存不会成为独立镜像层。
第三个误区是只关注最终镜像的 SIZE,却忽略了中间层和历史层对仓库存储的影响。即使最终镜像很小,构建过程中产生的临时层也会被 Docker 缓存,定期执行 docker system prune 可以清理悬空镜像和构建缓存。在日常检查中,docker images 查看镜像列表和大小,docker history 镜像名 可以列出每一层的创建指令和大小,帮助定位体积来源。还可以使用 dive 这类开源工具交互式分析镜像内部目录占用,快速找出可以删除的大文件。
最后一个建议是,优化体积之后务必重新测试容器的启动和核心功能。某些清理操作可能误删运行时需要的文件,例如 CA 证书、时区数据、地区编码信息等。压缩镜像的目标是保持功能一致的前提下减少体积,而不是追求数字最小化。只有经过实际运行验证,才能确认多阶段构建和依赖清理没有引入新的问题。