前几天在 CI 里看到构建日志,一个简单的 Node.js 应用镜像拉了我快两分钟。一看大小——1.2G。我当时就觉得不对劲,一个 Express 的 CRUD 后端凭什么这么大?
翻了一下 Dockerfile,前任(其实就是半年前的我)写的:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "index.js"]
这大概是大多数人的第一个 Dockerfile。能跑,但问题大了去了。
坑一:基础镜像选错了
node:18 是完整镜像,基于 Debian,自带 git、curl、gcc 一堆东西。你的生产环境根本用不到这些。
换成 node:18-slim 镜像直接从 900M 降到了 240M。再激进一点用 node:18-alpine,降到了 120M。Alpine 用的是 musl libc,某些 native 模块(比如 bcrypt)可能会炸,但大部分 Node 项目没问题。
如果只是跑静态编译的 Go 二进制?用 scratch 或 alpine:latest,镜像能压到 10M 以内。
坑二:COPY . . 导致层缓存全废
Docker 的层缓存机制:每一行指令产生一个镜像层,只要这一行的输入没变,Docker 就会复用缓存。
但 COPY . . 的问题在于,你改了 README,改了 .gitignore,甚至改了个注释——整个 COPY 层都失效,后面的 RUN npm install 也得重新跑。
正确的做法是先把 package.json 和 lock 文件拷进去安装依赖,然后再拷源码:
COPY package*.json ./
RUN npm ci --only=production
COPY . .
这样只有 package.json 变了才会重新安装依赖。日常改代码只需要重建最后一层,CI 构建从 3 分钟变成 15 秒。
我第一次改完发现没快多少——因为忘了用 .dockerignore,node_modules 还是每次都拷进去了。
坑三:多阶段构建不是噱头
之前我觉得多阶段构建是"高级用法",没必要。直到有个 Go 项目镜像居然有 800M——因为把整个 Go SDK 都打包进去了。
多阶段构建的逻辑很简单:第一个阶段用完整的 SDK 编译出二进制;第二个阶段只拿这个二进制,放在一个干净的基础镜像里。Go 的例子:
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o server .
FROM alpine:3.19
COPY --from=builder /app/server /usr/local/bin/server
CMD ["server"]
这个镜像最后不到 15M,而没拆分之前是 800M+。
Node 项目也能用这招——build 阶段跑 npm run build,最终阶段只放 dist 目录和 production 依赖。
坑四:apt-get install 留下的垃圾
在 Dockerfile 里但凡跑过 apt-get update && apt-get install,就会留下 apt 缓存。这些缓存几百 MB 毫不夸张。
标准写法:
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl && \
rm -rf /var/lib/apt/lists/*
--no-install-recommends 跳过推荐包的安装,后面 rm -rf 把缓存干掉。这一行我自己踩过——没加这个清理,镜像凭空多了 200M。
坑五:.dockerignore 没配
这个是低级错误但我犯过不止一次。没配 .dockerignore,node_modules、.git、dist、*.log、DS_Store 全打包进镜像了。
一个基本的 .dockerignore:
node_modules
.git
dist
*.log
.env
.DS_Store
coverage
这个文件跟 Dockerfile 放在同一个目录,Docker 会自动读取。每次用 COPY . . 之前先检查 .dockerignore,能省很多体积。
加完 .dockerignore 后我的那个 Node 镜像又瘦了 40M——之前把整个 .git 目录都拷进去了。
我现在的 Dockerfile 模板
综合下来,我给自己攒了一个 Node.js 项目的 Dockerfile 模板:
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:18-alpine
RUN apk add --no-cache tini
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/index.js"]
加上 tini 是为了正确处理 SIGTERM——Docker 默认把信号发给 PID 1,但 Node 进程是子进程收不到的。tini 做 init 进程转发信号,K8s 里优雅下线就靠它。
效果
最开始那个 1.2G 的 Node 镜像,按上面的优化走下来是 82M。对,82MB。体积减了 93%,CI 构建从 3 分钟变 15 秒,部署时 kubelet 拉镜像的时间几乎感觉不到。
如果你也遇到 Docker 镜像太大的问题,建议从这三步下手:
- 先换 slim/alpine 基础镜像
- 加上 .dockerignore
- 上多阶段构建
这三步够覆盖 80% 的场景了。等这三步不够用了,再翻 docker-slim、dive 这些工具也不迟。
(我的项目是 Node 为主,Go 偶尔用。Java 的 jlink + jdeps 瘦身方案以后另外写一篇。)
评论一下?