Docker多阶段构建:镜像从1.6G压到180M

一、为什么个人站的镜像总是越做越大

几乎所有自建服务的站长都有同一个经历:一开始拉个官方的 node:18 或者 php:8.1-apache 镜像,几百兆,能用就好;随着项目迭代,配置文件越加越多,依赖越装越杂,某天一敲 docker images,发现自己那个看起来没什么功能的应用镜像居然有 1.6GB。更糟的是每次改一行代码就要重新构建、重新推送、重新拉取,国内网络下这一套流程能把人逼疯。

问题的根源通常不在代码,而在构建过程本身写错了。很多人用一个 Dockerfile 从装系统依赖一路写到最终启动命令,所有编译工具链、开发依赖、缓存文件、源码全留在最后的镜像里。容器跑起来只用得着其中一个可执行文件和几个配置文件,但镜像把它祖宗十八代都背上了。

这篇文章讲的是我自己站点上几个服务(一个 Node 接口、一个 Go 小工具、一个 PHP 站点)的瘦身过程。核心只有一个概念:多阶段构建——在构建阶段把东西编译出来,在运行阶段只把那几个产物复制过去,中间的一切都扔掉。做完之后 1.6GB 变 180MB,1.2GB 变 24MB,推送时间从十分钟降到十几秒。

二、单阶段构建到底浪费在哪

先看清浪费。以一个典型的 Node 应用为例,如果写成单阶段:

FROM node:18
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "dist/server.js"]

这个镜像里至少有四层重量是白搭的:

  • 完整的构建工具链:node:18 基于 Debian,带有 gcc、make、python3、git,加起来几百兆,运行时一个都用不上。
  • 开发依赖:npm ci 默认把 devDependencies 也装了,webpack、eslint、typescript 全在里面,而运行时只需要那几个 runtime 依赖。
  • 源代码:COPY . . 把整个项目目录搬进去了,包括 .git、测试用例、文档、历史构建产物。
  • 包管理器缓存:npm 的缓存目录、apt 的 /var/lib/apt/lists,几百兆,构建完就没用了。

更隐蔽的一层是安全面的浪费:装了 gcc 和 git 的镜像一旦被入侵,攻击者手上就多了一整套编译工具,可以从容地在容器里落地木马。镜像小不只是省空间,也是减攻击面。

先量一下现状,别凭感觉:

docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.Size}}' | sort -k3 -h -r | head -15

再看某一层到底有多大:

docker history --no-trunc --human myapp:latest

输出里会按层列出 SIZE,从下往上就是每一层的增量。哪一层大得离谱,基本就是 RUN 里装了不该装的东西,或者 COPY 把不该拷的目录拷进去了。我第一次跑这条命令时发现,最大的一层是 720MB 的 RUN npm ci——因为项目根目录下有个 400MB 的测试数据集被写进了 .dockerignore 之外。

三、多阶段构建的基本骨架

多阶段构建就是在同一个 Dockerfile 里写多个 FROM,用 AS 给每个阶段命名,后面的阶段可以用 COPY --from=阶段名 从前面的阶段取文件。最终镜像只由最后一个阶段组成,前面的阶段在构建结束后被丢弃(用 BuildKit 时还能用 --target 单独构建中间阶段,方便调试)。

最小骨架长这样:

# 阶段一:构建
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# 阶段二:运行
FROM node:18-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./
CMD ["node", "dist/server.js"]

仅仅把基础镜像从 node:18 换成 node:18-slim,体积就从 1.1GB 掉到 380MB左右。但这还远远不够,因为我仍然把完整的 node_modules 复制过去了,里面还含着一堆构建期依赖。

四、把运行依赖单独装一遍

正确的做法是:构建阶段装全量依赖用于编译,运行阶段只装生产依赖。简单粗暴的写法是运行阶段再跑一次 npm ci --omit=dev,这样依赖树是按 package.json 重新解析的,不会带上 devDependencies:

FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:18-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]

这样做的代价是运行阶段也要下载一次依赖,构建时间变长,但换来的是干净的生产依赖树。缓存方面因为 COPY package*.json 和 RUN npm ci 在 COPY . . 之前,只要 package.json 没变,这一层就能命中缓存,实际增量很小。

一个更彻底的做法是用 npm ci --omit=dev 装到一个单独的目录,然后用 COPY --from 搬过去。但对于绝大多数中小项目,直接在运行阶段重装一遍就够了,收益和复杂度平衡得最好。

注意 npm cache clean --force 这一句,不要省。npm 缓存默认放在 ~/.npm,npm ci 之后可能留下上百兆的 tarball 缓存,清理掉能省一大截,而且这条命令必须在同一个 RUN 里执行,否则又被单独写进一层,删了也没用。

五、静态编译语言:镜像可以做到极小

Go 和 Rust 这类静态编译语言才是多阶段构建收益最大的地方。整个运行镜像可以只有一个二进制文件,甚至可以基于 scratch 或 alpine:

FROM golang:1.21 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app

FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /out/app /app/app
EXPOSE 8080
USER 65534:65534
ENTRYPOINT ["/app/app"]

几个关键点:

  • CGO_ENABLED=0 关掉 C 语言交互,编出的是纯静态二进制,不依赖 glibc,可以直接跑在 alpine 或 scratch 上。
  • -trimpath 去掉二进制里记录的本地路径,既省一点体积,也避免泄露构建机的目录结构。
  • -ldflags="-s -w" 去掉符号表和调试信息,通常能让二进制缩小 25%~30%。
  • 必须带 ca-certificates 和 tzdata,否则程序里任何 https 请求都会报证书错误,time.LoadLocation("Asia/Shanghai") 会报时区不存在。这两个坑我各踩过一次。
  • USER 65534:65534 用 nobody 用户跑,容器默认是 root,改成非 root 是安全基线。

这套下来,Go 服务的镜像能稳定做到 20MB 上下,其中 alpine 基础镜像 7MB,二进制 12MB,证书和时区 2MB。相比之下,单阶段的 golang 镜像 900MB 起。

六、.dockerignore 比任何优化都便宜

在动手改 Dockerfile 之前,先写 .dockerignore。这是投入产出比最高的一步,一行配置能省几百兆:

.git
.gitignore
node_modules
dist
build
*.log
*.sql
*.tar.gz
data/
test/
docs/
.vscode
.idea
Dockerfile
docker-compose*.yml

为什么 node_modules 要排除?因为容器里会重新装。为什么 dist 要排除?因为构建阶段会重新编。为什么 data/ 要排除?因为那是运行时挂载进来的。这几个目录一旦进了构建上下文,不光是镜像变大,docker build 每次都要把它们打包发给守护进程,构建速度慢得离谱。

验证方法很简单:

docker build -t myapp:test . 2>&1 | grep "transferring context"

如果看到 transferring context: 400MB,说明 .dockerignore 没生效或者漏写了。改完之后通常在 1MB 以内。

七、层合并与清理命令的写法

Docker 的每一层是增量的,RUN 里删掉的东西如果和安装不在同一层,就不会真正减小体积。经典的错误写法:

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

四条 RUN 就是四层,最后两条虽然在最终文件系统里删了文件,但被删的文件仍然留在前两层里,镜像照样大。正确写法是全部合成一条:

RUN apt-get update \
 && apt-get install -y --no-install-recommends build-essential libpng-dev \
 && apt-get clean \
 && rm -rf /var/lib/apt/lists/*

注意 --no-install-recommends 这个参数,Debian/Ubuntu 的 apt 默认会装「推荐包」,一个 libpng-dev 能顺带拉进几十个包,加上这个参数能省掉一大半。另外 apt-get clean 清的是 /var/cache/apt/archives 里的 deb 包,也要在同一层里做。

同样的道理适用于 PHP 的 docker-php-ext-install、Python 的 pip install。装完立刻清理,不能拆成两条命令。

八、BuildKit 缓存挂载:编译快十倍

Docker 23 以后默认启用 BuildKit,可以给 RUN 挂载缓存目录,让包管理器缓存跨构建保留,但不进最终镜像。这是多阶段构建之上再省一次的做法:

# syntax=docker/dockerfile:1
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci
COPY . .
RUN --mount=type=cache,target=/root/.npm \
    npm run build

Go 项目同理:

RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build -o /out/app ./cmd/app

这样即使 package.json 变了,npm 也只是增量下载变动的包,而不是从零开始拉整棵依赖树。我第一次用这个特性时,一个 Node 项目的构建时间从 4 分 20 秒降到 40 秒。缓存目录在宿主机的 Docker 数据目录里,不进镜像层,也就不会增大推送体积。

注意第一行的 # syntax=docker/dockerfile:1 不能省,否则老版本构建器不认识 --mount 语法,会直接报错。

九、镜像里不该有的四类东西

瘦身到一定程度后,剩下的体积都来自「不该在镜像里的东西」。逐个清掉:

  • 密钥和配置文件:数据库密码、API key 不能写进镜像,用环境变量或挂载的配置文件。镜像层是可以被任何人 docker history 和 docker save 出来的,写进去就是泄露。临时需要的构建密钥用 BuildKit 的 --mount=type=secret。
  • 日志和运行时数据:日志、上传目录、缓存都应该挂卷,不该落在镜像里。有一个简单判据:凡是运行一段时间后会变大的目录,都不该在镜像的文件系统里。
  • 调试工具:curl、vim、tcpdump 这些在正式镜像里不要留,真需要排查时用 docker exec 起一个临时容器共享网络命名空间,或者构建一个带工具的同版本 debug 镜像。
  • 多份副本的依赖:PHP 项目里常见的 vendor 目录被复制了两遍(一次在 composer install,一次在 COPY),先 docker history 看清哪层大再动手。

十、长期维护:让镜像别偷偷变胖

瘦身不是一次性的。项目迭代几周后,镜像又会慢慢变大,直到某天发现已经是 800MB。建议做三件小事:

  1. CI 里加一道体积断言:构建完成后取镜像大小,超过阈值就让流水线失败。阈值按项目定,Go 服务 40MB,Node 服务 250MB,PHP 站点 300MB,超了说明有人加了不该加的东西。
  2. 固定基础镜像 tag:用 node:18.19-slim 而不是 node:latest,避免某次构建突然拉到新版本、体积和行为一起变。真正的长期方案是把基础镜像 digest 一起写死。
  3. 每月跑一次 history 巡检:把 docker history --human 的输出贴进值班记录,任何一层突然多出 100MB 都要问一句为什么。

体积断言的写法参考:

size=$(docker image inspect myapp:latest --format '{{.Size}}')
limit=$((250*1024*1024))
if [ "$size" -gt "$limit" ]; then
  echo "image too large: $((size/1024/1024))MB > 250MB"
  exit 1
fi

十一、小结:先量再改,一次只动一处

镜像瘦身的顺序应该是:先 docker history 量清楚每层多大,再写 .dockerignore 砍掉构建上下文,然后用多阶段构建把编译链留在构建阶段,最后用 BuildKit 缓存挂载缩短构建时间、用非 root 和精简基础镜像收口安全面。

每一次改动后都重新跑一遍 docker images 记录数字,才知道哪一步真正有效。我自己的三个服务按这个顺序做完,最终体积分别是 178MB、23MB、192MB,构建时间从平均 6 分钟降到 50 秒,推送带宽占用降了一个数量级。这些数字本身不重要,重要的是你也能用自己的服务跑出同样量级的下降。

Last modification:November 4th, 2026 at 10:28 pm

Leave a Comment