前言:一次被镜像拖垮的发布
先讲个真事。
我们有个 Node.js 的订单服务,业务代码其实不到 3 万行,但镜像体积一路涨到了 1.2GB。带来的连锁反应是这样的:
- CI 每次
docker build要 6 分钟,docker push又要 3 分钟; - K8s 扩容时,新节点第一次拉镜像要等将近 2 分钟,弹性形同虚设;
- 安全扫描每次报出 200+ 个 CVE,其中 95% 来自我们根本没用到的系统包;
- 镜像仓库存储费按月涨,一个服务留 30 个历史版本就是 36GB。
最后我们花了两天把它压到了 128MB ,构建时间从 6 分钟降到 1 分 40 秒。这篇文章把整个过程拆开讲清楚:怎么定位胖在哪、每一步能省多少、以及哪些"看起来很香"的优化其实是坑。
文中的方法对 Node.js / Go / Python / Java 都通用,我会用 Node.js 做主线,Go 做对照。
一、别拍脑袋,先量化
优化的第一步永远是测量。很多人上来就换 alpine,结果换完发现只省了 100MB,还引入了一堆兼容问题。
1.1 看总体积
bash
docker images myapp --format "{{.Repository}}:{{.Tag}}\t{{.Size}}"
1.2 看每一层花在哪
bash
docker history myapp:latest --no-trunc --format "{{.Size}}\t{{.CreatedBy}}"
输出大概长这样(我把无关行删了):
bash
1.02GB RUN /bin/sh -c npm install
118MB COPY . /app
0B WORKDIR /app
997MB /bin/sh -c #(nop) ADD file:xxx in /
一眼就能看出来:npm install 这一层 1GB,COPY . /app 又塞了 118MB 进去。这两个就是我们的主战场。
1.3 更细的工具:dive
docker history 只能看到层级粒度,想看每层具体多了哪些文件,用 dive:
bash
# macOS
brew install dive
# 或者直接用容器跑
docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
wagoodman/dive:latest myapp:latest
dive 会给你一个 efficiency score ,并把「被后续层覆盖/删除但仍占空间的文件」标红。第一次跑的时候我看到 /app/.git 占了 80MB,当场就明白问题在哪了。
💡 一个反直觉的点:在后面的层里
rm掉文件,并不会让镜像变小。因为镜像是分层的联合文件系统,删除只是在上层加了一个 whiteout 标记,下层的数据还在。这是很多人「明明删了怎么还这么大」的根因。
二、第一刀:.dockerignore(5 分钟,省 118MB)
这是投入产出比最高的一步,但极其容易被忽略。
docker build 的第一件事是把整个构建上下文(也就是你指定的那个目录)打包发给 Docker daemon。如果你的目录里有 node_modules、.git、本地打的包,它们全都会被传过去 ,而且一旦你写了 COPY . /app,它们还会原封不动进到镜像里。
判断你有没有中招,看 build 输出的第一行:
css
Sending build context to Docker daemon 623.4MB
超过 10MB 就该警惕了。
一份合理的 .dockerignore:
gitignore
# 依赖与构建产物
node_modules
dist
build
coverage
*.tsbuildinfo
# 版本控制与 IDE
.git
.gitignore
.vscode
.idea
# 环境与密钥(重要!)
.env
.env.*
*.pem
*.key
# 日志与临时文件
*.log
npm-debug.log*
.DS_Store
tmp/
# 文档与测试(按需)
docs/
*.md
!README.md
__tests__/
注意 .env 和 *.pem 那两行。把密钥打进镜像是很常见的事故 ------镜像会被推到仓库,任何能 pull 的人 docker save 之后解开 tar 就能拿到你的文件,哪怕你在 Dockerfile 后面 rm 了也一样(原因见上面那个 whiteout 的注解)。
我们加完 .dockerignore 后,构建上下文从 623MB 降到 2.1MB,镜像直接少了 118MB。零风险,五分钟。
三、第二刀:多阶段构建(省 700MB+)
这是瘦身的核心武器。
3.1 优化前的典型写法
dockerfile
FROM node:20
WORKDIR /app
COPY . .
RUN npm install # 装了全部依赖,包括 devDependencies
RUN npm run build # 需要 typescript / webpack / vite 等构建工具
EXPOSE 3000
CMD ["node", "dist/server.js"]
这个镜像里装着什么?
- 完整的 Debian 系统 + 编译工具链(
node:20基于buildpack-deps,自带 gcc、make、python、git)→ ~997MB devDependencies:TypeScript、ESLint、Jest、Webpack...... → 几百 MB- 源码
.ts文件(运行时根本用不到)
运行时需要的只有:Node 运行时 + 生产依赖 + dist 产物。 其余全是负担。
3.2 多阶段改造
dockerfile
# syntax=docker/dockerfile:1
# ---------- Stage 1: 安装生产依赖 ----------
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ---------- Stage 2: 构建 ----------
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci # 这里需要 devDependencies
COPY . .
RUN npm run build
# ---------- Stage 3: 运行 ----------
FROM node:20-slim AS runner
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
几个关键点:
① 为什么把 deps 和 builder 拆成两个 stage?
因为生产依赖和构建依赖的变化频率不同。拆开后,只要 package-lock.json 没变,deps 阶段就能完全命中缓存。而且这两个 stage 之间没有依赖关系,BuildKit 会并行执行它们。
② npm ci 而不是 npm install
npm ci 严格按 lockfile 安装,速度更快(跳过依赖解析),且会先删除已有的 node_modules 保证干净。在 CI 环境里没有理由用 npm install。
③ --omit=dev 而不是 --only=production
后者从 npm 7 开始已废弃。yarn 用 yarn install --production,pnpm 用 pnpm install --prod。
④ USER node
官方 Node 镜像内置了一个 uid=1000 的 node 用户。容器默认以 root 运行是个安全隐患------一旦应用被 RCE,攻击者在容器里就是 root,配合内核漏洞可能逃逸到宿主机。加一行就能规避。
注意 USER 要放在所有 COPY 之后,否则会因为权限问题写不进去。如果你的应用需要写文件,记得 RUN chown -R node:node /app/data。
改造后:1.2GB → 210MB。
四、第三刀:换基础镜像(省 80MB,但要小心)
node:20 的三个常见变体:
| 镜像 | 体积(uncompressed) | 说明 |
|---|---|---|
node:20 |
~1.1GB | Debian + 完整编译工具链 |
node:20-slim |
~200MB | Debian slim,去掉了编译工具 |
node:20-alpine |
~135MB | Alpine Linux,musl libc |
看起来 alpine 最香,但先别急着换。
4.1 Alpine 的三个真实坑
坑一:musl libc 兼容性
Alpine 用 musl 而不是 glibc。绝大多数纯 JS 包没问题,但涉及原生模块(node-gyp 编译的,比如 sharp、canvas、bcrypt、部分数据库驱动)时,预编译的 .node 二进制是按 glibc 编的,在 alpine 上会直接报错:
vbnet
Error: Error loading shared library ld-linux-x86-64.so.2: No such file or directory
解决办法是装编译工具链现场编译:
dockerfile
RUN apk add --no-cache --virtual .build-deps \
python3 make g++ \
&& npm ci --omit=dev \
&& apk del .build-deps
但这样构建时间会显著变长,而且省下来的体积可能又被吐回去。
坑二:DNS 解析行为不同
musl 的 resolver 在某些环境下不支持 search 域的多次尝试,也不会自动重试 TCP。在 K8s 里表现为偶发的 DNS 解析失败,非常难排查。这个问题在高并发场景更容易触发。
坑三:时区
Alpine 默认不带时区数据,new Date().toLocaleString('zh-CN', {timeZone:'Asia/Shanghai'}) 会拿不到正确结果。需要:
dockerfile
RUN apk add --no-cache tzdata
ENV TZ=Asia/Shanghai
4.2 我的建议
默认用
-slim,只有在体积极其敏感、且依赖树里没有原生模块时才用 alpine。
从 1.1GB 的 node:20 换到 node:20-slim,你已经拿到了 80% 的收益;再从 slim 换到 alpine 只多省 65MB,却要承担上面三类风险。这笔账多数团队是不划算的。
我们最终选了 slim。210MB → 210MB(因为上一步已经用了 slim)。
五、第四刀:BuildKit 缓存挂载(体积不变,构建快 3 倍)
瘦身之外,构建速度同样值得优化。
传统 Dockerfile 的痛点是:只要 package-lock.json 改一个字节,npm ci 层就失效,所有依赖要重新从网络下载。
BuildKit 的 cache mount 可以把 npm 缓存目录挂载成跨构建持久化的卷:
dockerfile
# syntax=docker/dockerfile:1
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
三个注意点:
- 第一行的
# syntax=docker/dockerfile:1不能省 ,否则--mount语法不被识别; - cache mount 的内容不会进入镜像层,所以完全不影响体积;
- 需要 BuildKit(Docker 23+ 默认开启;老版本用
DOCKER_BUILDKIT=1 docker build ...)。
其他语言的对应目录:
| 工具 | cache target |
|---|---|
| npm | /root/.npm |
| pnpm | /root/.local/share/pnpm/store |
| yarn (classic) | /usr/local/share/.cache/yarn |
| pip | /root/.cache/pip |
| Go | /root/.cache/go-build 和 /go/pkg/mod |
| Maven | /root/.m2 |
| Cargo | /usr/local/cargo/registry |
加上这个之后,我们的依赖安装从 95 秒降到 12 秒(缓存命中时)。
5.1 CI 里怎么复用缓存
本地构建 cache mount 自动生效,但 CI 每次都是新 runner。用 --cache-from / --cache-to 把缓存推到镜像仓库:
bash
docker buildx build \
--cache-from type=registry,ref=registry.example.com/myapp:buildcache \
--cache-to type=registry,ref=registry.example.com/myapp:buildcache,mode=max \
-t registry.example.com/myapp:$GIT_SHA \
--push .
mode=max 会把所有中间层都推上去,缓存命中率更高,代价是缓存镜像更大。
六、第五刀:distroless(Go 场景的杀手锏)
到这一步,Node.js 侧已经压到 128MB(后面会说怎么从 210 到 128),基本触底了------Node 运行时本身就 40MB 左右。
但如果你写的是 Go / Rust 这类能静态编译的语言,还能更极致。
6.1 Go 的极限瘦身
dockerfile
# syntax=docker/dockerfile:1
# ---------- Build ----------
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux \
go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
# ---------- Runtime ----------
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
参数解释:
CGO_ENABLED=0:关闭 cgo,产出完全静态的二进制,不依赖任何 .so;-ldflags="-s -w":去掉符号表和 DWARF 调试信息,二进制能小 25%~30%;-trimpath:去掉编译机的绝对路径,让构建可复现,也避免泄露目录结构;distroless/static-debian12:只有 ~2MB,内含 ca-certificates、/etc/passwd、tzdata、/tmp,没有 shell、没有包管理器、没有 libc;:nonroottag:默认以 uid 65532 运行,省掉自己建用户的步骤。
结果:golang:1.22 基础镜像 ~830MB,最终镜像 = 2MB + 你的二进制。我们的 Go 网关服务最后是 14.3MB。
6.2 Node.js 也能用 distroless
dockerfile
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
CMD ["dist/server.js"]
注意这里 CMD 里不写 node 。distroless 的 nodejs 镜像已经把 ENTRYPOINT 设成了 ["/nodejs/bin/node"],你只需要给参数。写成 CMD ["node", "dist/server.js"] 会变成 node node dist/server.js,启动直接失败------这是最常见的踩坑点。
这一步让我们从 210MB 降到了 128MB。
6.3 distroless 怎么调试?
没有 shell 意味着 docker exec -it xxx sh 直接报错。三个办法:
方法一:用 :debug tag
bash
docker run -it --entrypoint sh gcr.io/distroless/static-debian12:debug
:debug 变体内置了 busybox,仅用于排查,不要上生产。
方法二:K8s 临时容器
bash
kubectl debug -it my-pod \
--image=busybox:1.36 \
--target=my-container \
--share-processes
临时容器和目标容器共享 PID / network namespace,可以直接 ps、netstat、看 /proc/1/。
方法三:把可观测性做进应用
说到底,需要进容器 curl 一下才能排查问题,本身就说明可观测性不够。健康检查端点、结构化日志、metrics、trace 做好了,绝大多数情况根本不需要进容器。
七、容易被忽略的细节
7.1 层的顺序决定缓存命中率
原则:越不容易变的东西越往前放。
dockerfile
# ❌ 错误:改一行业务代码,依赖就要重装
COPY . .
RUN npm ci --omit=dev
# ✅ 正确:只有 lockfile 变了才重装
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
这个改动不影响体积,但能让 90% 的构建跳过依赖安装。
7.2 RUN 指令要合并,但别过度
每个 RUN 都会产生一层。经典写法:
dockerfile
# ❌ apt 缓存留在了第一层,删不掉
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# ✅ 同一层内完成安装和清理
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
--no-install-recommends 也很关键,能避免装进来一堆推荐依赖(有时候能省 100MB+)。
但别把所有东西塞进一个 RUN ------那样任何一处改动都会让整层失效,缓存效率反而变差。合理的粒度是:按「变化频率」分组。
7.3 别用 latest 做基础镜像
dockerfile
FROM node:latest # ❌ 不可复现,今天和明天构建出来可能不一样
FROM node:20 # ⚠️ 会跟随 20.x 小版本
FROM node:20.11.1-slim # ✅ 明确版本
更严格的做法是锁 digest:
dockerfile
FROM node:20.11.1-slim@sha256:xxxxxxxx...
配合 Renovate / Dependabot 自动提 PR 升级,兼顾可复现和安全更新。
7.4 加上 HEALTHCHECK 和 OCI 标签
dockerfile
LABEL org.opencontainers.image.source="https://github.com/acme/myapp" \
org.opencontainers.image.revision="${GIT_SHA}" \
org.opencontainers.image.licenses="MIT"
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
注意 distroless 里没有 curl 和 wget,健康检查要用语言运行时自己实现(上面用的是 Node 18+ 内置的 fetch)。K8s 环境下更推荐用 livenessProbe / readinessProbe,Dockerfile 里的 HEALTHCHECK 是给 docker run 和 Swarm 用的。
7.5 顺手做个安全扫描
体积下来了,CVE 数量也会跟着断崖式下降。验证一下:
bash
# Docker 官方
docker scout cves myapp:latest
# 或者 Trivy
trivy image --severity HIGH,CRITICAL myapp:latest
我们的镜像 CVE 从 214 个(其中 HIGH 12 个)降到了 3 个 LOW。这不是玄学------你删掉的那 1GB 里,本来就装着 perl、git、gcc、python2 这些跟业务毫无关系但持续有漏洞的东西。
八、最终成绩单
| 阶段 | 操作 | 镜像体积 | 构建耗时(冷/热) |
|---|---|---|---|
| 起点 | FROM node:20 + COPY . . |
1.24 GB | 6m10s / 5m40s |
| 步骤 1 | 加 .dockerignore |
1.12 GB | 5m20s / 4m50s |
| 步骤 2 | 多阶段构建 + slim | 210 MB | 3m40s / 2m30s |
| 步骤 3 | BuildKit cache mount | 210 MB | 3m30s / 48s |
| 步骤 4 | distroless nodejs | 128 MB | 3m35s / 52s |
对照组(Go 网关服务):
| 阶段 | 镜像体积 |
|---|---|
FROM golang:1.22 单阶段 |
968 MB |
多阶段 + debian:12-slim |
92 MB |
多阶段 + distroless/static |
14.3 MB |
拉取时间(千兆内网):1.24GB 约 18 秒,128MB 约 2.4 秒。放到跨区域拉取或者需要同时扩 50 个 Pod 的场景,这个差距会被放大得非常明显。
九、一份可以直接抄的 Checklist
优化任何一个镜像,按这个顺序走一遍:
-
docker history/dive先定位大头,不要凭感觉 - 写
.dockerignore,确认 build context < 10MB - 确认
.env、私钥、证书没有被打进镜像 - 改成多阶段构建,运行阶段只 COPY 产物和生产依赖
- 基础镜像换成
-slim(alpine 需评估原生模块风险) -
COPY顺序调整:lockfile → 装依赖 → 源码 -
RUN按变化频率合并,apt/apk 缓存同层清理 - 加
--mount=type=cache缓存包管理器目录 - 基础镜像锁到具体小版本或 digest
- 加
USER,不要用 root 跑业务进程 - 静态语言考虑 distroless / scratch
- 跑一次
trivy或docker scout,确认 CVE 收敛 - CI 配置
--cache-from/--cache-to
结语
镜像瘦身这件事,收益的 80% 来自前两步 :.dockerignore 和多阶段构建。这两步几乎没有技术风险,半小时就能做完。
而 alpine、distroless、cache mount 这些属于「进阶档」,值不值得做要看你的具体场景:一天部署 50 次的服务,构建速度的价值远大于最后那几十 MB;而边缘设备或者 Serverless 冷启动敏感的场景,体积每降一点都是实打实的收益。
最怕的是反过来------为了追求 alpine 那 65MB,引入了 musl 的 DNS 偶发失败,然后花三天排查线上问题。先测量,再决定,别为了优化而优化。
如果这篇对你有帮助,欢迎点赞收藏。你在镜像优化上踩过什么坑,也欢迎在评论区聊聊。