Docker 多阶段构建实战:把 Node 应用镜像从 1.18GB 压到 78MB

举报
yd_238266496 发表于 2026/10/31 10:28:35 2026/10/31
【摘要】 小标题测试上周帮同事排查一个部署问题,顺手把他的前端镜像从 1.18GB 压到了 78MB。整个过程没有换技术栈、没有改业务代码,只是把 Dockerfile 重新组织了一遍。这篇把每一步的量化结果和踩过的坑记下来,供同样被"大镜像"拖慢发布流程的人参考。 一、先看现象:一个"能跑但巨大"的镜像服务器是 2 核 2G 的 Flexus L 实例,磁盘 40GB。某天 df -h 报警:$ ...

小标题测试

上周帮同事排查一个部署问题,顺手把他的前端镜像从 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 的层分析结果很直观,占用前三名是:

  1. node_modules 全量安装(含 devDependencies 的 vite、typescript、eslint),约 620MB;
  2. npm 缓存目录 /root/.npm,约 180MB;
  3. .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 量一遍,别凭感觉优化。
文一行。试

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。