Docker 多阶段构建实战:把 Node 应用镜像从 1.18GB 压到 78MB
小标题测试
上周帮同事排查一个部署问题,顺手把他的前端镜像从 1.18GB 压到了 78MB。整个过程没有换技术栈、没有改业务代码,只是把 Dockerfile 重新组织了一遍。这篇把每一步的量化结果和踩过的坑记下来,供同样被"大镜像"拖慢发布流程的人参考。
一、先看现象:一个"能跑但巨大"的镜像
服务器是 2 核 2G 的 Flexus L 实例,磁盘 40GB。某天 df -h 报警:
$ df -h /
Filesystem Size Used Avail Use% Mounted on
/dev/vda1 40G 36G 2.1G 95% /
进一步查大文件,发现 Docker 的存储目录占了 21GB:
$ sudo du -sh /var/lib/docker/* | sort -rh | head -5
18G /var/lib/docker/overlay2
再列镜像:
$ docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
REPOSITORY TAG SIZE
web-front latest 1.18GB
一个纯静态前端,1.18GB。每次发布要往镜像仓库推一次,按 10Mbps 上行算,光推送就要十几分钟,CI 里最慢的一步就是它。
二、先量再改:看清每一层在占什么
改之前一定要先量化,否则容易优化错方向。两个命令足够:
$ docker history --no-trunc web-front:latest | head -20
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
wagoodman/dive:latest web-front:latest
dive 的层分析结果很直观,占用前三名是:
node_modules全量安装(含 devDependencies 的 vite、typescript、eslint),约 620MB;npm缓存目录/root/.npm,约 180MB;.git目录和dist构建产物一起被COPY . .带进去,约 90MB。
也就是说,这个镜像里真正需要运行的只有 dist 里的几 MB 静态文件,其余 99% 都是构建期的中间产物。问题根源是典型的"单阶段构建 + COPY . ."。
顺带说清一个容易被误解的点:Docker 镜像是分层只读的,每一条 RUN、COPY 都新增一层,层只会叠加、不会合并。所以在 RUN npm ci && rm -rf node_modules 这种写法里,被删掉的文件依然存在上一层里,体积一分不减。这也是后面"为什么必须把清理和安装写在同一个 RUN 里"的原因。真正能瘦身的手段只有两个:让脏东西不要进镜像(多阶段构建),或者让它在进入镜像的同一层就被清掉。
三、第一刀:.dockerignore
最小成本的一步,先堵住上下文膨胀:
node_modules
.git
.gitignore
dist
build
coverage
*.log
.DS_Store
.vscode
.idea
注意 .git 一定要排除,很多项目忘了这一条,一个仓库历史就能带进上百 MB。这一步之后镜像从 1.18GB 降到 1.05GB,降幅不大但构建上下文传输快了很多——在远端 BuildKit 上构建时,这个改善比体积更明显。
四、第二刀:多阶段构建,构建期和运行期彻底分开
既然线上只需要静态文件,那就让 builder 干完活就走,最终镜像里只留 nginx 和产物:
# ---------- 构建阶段 ----------
FROM node:20-bookworm-slim AS builder
WORKDIR /app
# 先只拷贝依赖清单,利用层缓存
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
# ---------- 运行阶段 ----------
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1
结果:1.05GB → 78MB,构建耗时基本不变(依赖还是全量装),但推送时间从十几分钟降到 40 秒左右。
这一步有个必踩的坑:SPA 用 history 路由时,直接刷新二级路径会 404。nginx.conf 里要补 fallback:
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|svg|woff2)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
另外 index.html 千万不要长缓存,否则发版后用户拿到旧入口、引用了已被替换的 hash 资源,页面直接白屏。可以单独给它加 add_header Cache-Control "no-cache"。
五、如果服务端也要跑,Node 运行镜像怎么压
另一个项目是 Node 后端(Express + 一个 /health 接口),不能只留 nginx。做法是把"装依赖"和"拷产物"也拆成两个阶段:
FROM node:20-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
FROM node:20-bookworm-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
USER node
CMD ["node", "dist/server.js"]
这里几个细节值得单独说:
npm ci而不是npm install,前者严格按 lock 文件装,版本可复现,也比 install 快;--omit=dev只装生产依赖,devDependencies 不进最终镜像;npm cache clean --force必须和npm ci写在同一个 RUN 里。分成两条 RUN 的话,缓存文件已经在上一层固化,删掉只是多了一个"白洞"层,镜像一点没瘦;USER node切非 root,配合COPY --chown=node:node才不会出现权限问题。
最终 178MB,比 1.18GB 少了 85%。
六、踩坑清单(按踩到的顺序)
1. alpine 不是所有原生模块都能用。 一开始图省事用 node:20-alpine,构建直接失败:
Error: Could not load the "sharp" module using the linux-x64 runtime
原因是 alpine 用 musl libc,而 sharp、bcrypt、canvas、sqlite3 这类带原生二进制的包默认下载的是 glibc 版本。三个选择:换 bookworm-slim(多约 40MB,最省事)、在 alpine 里装 libc6-compat 并重编译、或者干脆在 builder 里用 --platform=linux/amd64 npm ci 重新拉对应二进制。生产环境我选第一个,稳定比体积重要。
2. 分层缓存很容易做废。 一定要先 COPY package.json package-lock.json 再 npm ci,最后 COPY . .。顺序反过来,改一行业务代码就会让依赖层失效,每次全量重装。加上 BuildKit 的 cache mount 更好:
RUN --mount=type=cache,target=/root/.npm npm ci
3. --mount=type=cache 需要 BuildKit。 老版本 Docker 或者 CI 里禁用了 BuildKit 时会报 unknown flag,构建时加 DOCKER_BUILDKIT=1,或者用 docker buildx build。
4. .dockerignore 里别把 lock 文件排掉。 有人图省事写 *.json,结果 npm ci 找不到 lock,直接失败。
5. 健康检查要真的能通。 wget 在 alpine 和 bookworm 里都有,但有些极简镜像没有;用 curl 也一样要确认装了。检查写错的表现是容器一直 unhealthy 被重启,日志里却什么都看不到。
七、优化前后对比
| 阶段 | 镜像大小 | 推送耗时(10Mbps 上行) | 节点磁盘占用 |
|---|---|---|---|
| 原始单阶段 | 1.18GB | 约 16 分钟 | 21GB |
| + .dockerignore | 1.05GB | 约 14 分钟 | 19GB |
| + 多阶段(nginx) | 78MB | 约 40 秒 | 2.4GB |
| Node 服务版(三阶段) | 178MB | 约 2.5 分钟 | 5.1GB |
镜像瘦下来之后,最直观的变化不是体积,而是发布节奏:以前一次发布要等十几分钟推送,出问题回滚一次成本很高;现在 40 秒推完,回滚和灰度都变得随手可做。服务器磁盘的告警也一并消失了。
八、把优化固化到 CI,别让它回退
手工改好的 Dockerfile,很容易被后来的人加一句 COPY . . 或者退回 npm install。所以我在流水线里加了两道闸门。
第一道是体积阈值,超过就红:
#!/usr/bin/env bash
set -euo pipefail
IMAGE="$1"
LIMIT=200 # MB
SIZE=$(docker image inspect "$IMAGE" --format '{{.Size}}')
MB=$(( SIZE / 1024 / 1024 ))
echo "$IMAGE = ${MB}MB (limit ${LIMIT}MB)"
if [ "$MB" -gt "$LIMIT" ]; then
echo "镜像超过阈值,请检查是否误把 node_modules 或 .git 打进镜像" >&2
exit 1
fi
第二道是在 CI 里开 BuildKit 并复用缓存,否则每次冷构建都要重新下载依赖,流水线时间会翻倍:
- name: Build and push
run: |
docker buildx create --use --name grow-builder || docker buildx use grow-builder
docker buildx build \
--cache-from type=registry,ref=registry.example.com/web-front:cache \
--cache-to type=registry,ref=registry.example.com/web-front:cache,mode=max \
-t registry.example.com/web-front:$GITHUB_SHA --push .
另外,如果构建机和运行机的架构不同(比如 M 系列芯片本地开发、x86 服务器部署),记得加 --platform linux/amd64,否则跑起来会报 exec format error,报错信息里完全不提架构,很容易排查半天。
还有一个真实场景值得提醒:镜像瘦身之后,节点上仍然可能出现 no space left on device。那通常不是镜像本身,而是历史镜像和悬空层没清。定期跑一次下面两条,配合 docker system df 看回收效果:
docker image prune -af --filter "until=168h"
docker builder prune -af --filter "until=168h"
注意别在生产机上随手 docker system prune -a --volumes,它会连带清掉没有容器引用但你还想保留的卷。
九、小结
一句话概括这次改造:构建产物和构建环境必须分离,运行镜像里只留"运行需要的文件"。三步就能拿到 90% 的收益——写 .dockerignore、改成多阶段构建、运行阶段换小基础镜像。剩下 10% 靠清理缓存、非 root、健康检查这些细节补齐。改之前先用 dive 和 docker history 量一遍,别凭感觉优化。
文一行。试
- 点赞
- 收藏
- 关注作者
评论(0)