每次看到有项目的 Dockerfile 是一个 FROM node:18 一把梭到底,我就脑壳疼。一个简单的 Next.js 或者 Go 应用,打出来的镜像居然有 1GB 甚至 2GB,而且每次 CI/CD 构建都要重新 npm install 或者 go mod download 耗时五六分钟。这不仅浪费了团队的时间,还在宿主机上堆了一堆废弃镜像,磁盘空间每个月都要被撑爆一次,简直是折腾。

其实只要做好了“多阶段构建”(Multi-stage builds)和“依赖缓存挂载”,构建时间能瞬间缩短到 30 秒以内,镜像体积也能直接缩减到原来的十分之一。

很多人在写 Dockerfile时,最常犯的错误就是没有利用好 Docker 的 Layer 缓存机制。Docker 在构建时,如果 package.jsongo.mod 没变,其实是不需要重新下载依赖的。但如果你把 COPY . . 写在 RUN npm install 之前,哪怕你只是改了一行注释,Docker 都会认为上下文变了,从而缓存失效,重新拉包。

另外,像 Go 或 Node.js,在构建阶段需要很多辅助工具(比如编译器、各种 devDependencies),但运行阶段只需要编译后的二进制文件或打包好的 dist 目录。我们干脆使用多阶段构建,把编译环境和运行环境剥离。

下面是我们目前在生产环境跑的 Node.js (Vite/Next.js) 的多阶段构建 Dockerfile 模板,并且在 CI 流程中启用了最新的 BuildKit 缓存挂载:

# syntax=docker/dockerfile:1.4
# ==============================================
# 第一阶段:依赖安装与缓存
# ==============================================
FROM node:18-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

# 仅拷贝包管理配置文件,最大化利用 Docker 层缓存
COPY package.json package-lock.json ./

# 启用 BuildKit 缓存挂载,加速 npm install
# 这会把宿主机的 npm 缓存目录挂载到容器内,避免重复下载
RUN --mount=type=cache,target=/root/.npm \
    npm ci

# ==============================================
# 第二阶段:源码编译
# ==============================================
FROM node:18-alpine AS builder
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

# 禁用 Next.js 遥测数据收集
ENV NEXT_TELEMETRY_DISABLED 1

# 开始打包构建
RUN npm run build

# ==============================================
# 第三阶段:最小化运行环境
# ==============================================
FROM node:18-alpine AS runner
WORKDIR /app

ENV NODE_ENV production

# 创建非 root 安全用户运行程序
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# 仅拷贝生产环境所需的 runtime 文件,抛弃所有开发依赖和源码
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000
ENV PORT 3000

CMD ["node", "server.js"]

配合这个 Dockerfile,我们在 CI 脚本(比如 GitHub Actions 或 Gitlab CI)里需要显式开启 DOCKER_BUILDKIT=1。每次触发构建,只要 package-lock.json 没改,安装依赖这一步就会直接命中缓存,瞬间跳过。

最爽的是,最后打出来的 runner 镜像里只有打包好的静态文件和 standalone 的 Node 运行体,不含任何源码和编译工具,镜像体积直接从 1.2GB 瘦身到了 120MB,安全性也提升了几个档次,黑客拿到容器也找不到编译环境。

别再用单阶段构建去虐待你们的 CI 服务器和宿主机硬盘了。赶紧把手头项目的 Dockerfile 重构一下,看看能省下多少喝咖啡的时间。