Dockerfile 解决什么问题#
Dockerfile 用代码描述如何构建镜像。相比在容器中手动安装软件,它具有几个重要特点:
- 构建过程可以重复;
- 配置可以进入版本控制;
- 团队和 CI 可以得到一致镜像;
- 每一步构建结果可以形成可复用的缓存层。
最初的单阶段 Dockerfile:
FROM node:22-alpine
RUN corepack enable
WORKDIR /app
COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm --filter web build
EXPOSE 3000
CMD ["pnpm", "--filter", "web", "start", "--hostname", "0.0.0.0"]它能够工作,但还没有考虑缓存效率和最终镜像体积。
常用 Dockerfile 指令#
FROM#
FROM node:22-alpine指定基础镜像。这里使用带 Node.js 22 的 Alpine Linux。
WORKDIR#
WORKDIR /app设置后续指令的默认工作目录。目录不存在时 Docker 会创建它。
COPY#
COPY . .把构建上下文中的文件复制到镜像。
RUN#
RUN pnpm install --frozen-lockfile在构建镜像时执行命令,结果会进入镜像层。
ENV#
ENV NODE_ENV=production设置镜像和容器中的环境变量。
EXPOSE#
EXPOSE 3000声明应用预期监听的端口。它主要是镜像元数据,不等于把端口发布到宿主机。真正的端口发布由 docker run -p 或 Compose 的 ports 完成。
CMD#
CMD ["node", "apps/web/server.js"]指定容器默认启动命令。RUN 在构建时执行,CMD 在容器启动时执行。
USER#
USER nextjs指定运行应用的用户。生产运行阶段尽量不要使用 root 用户。
构建上下文与 .dockerignore#
docker build -t docker-demo-web:v1 .最后的 . 表示当前目录是构建上下文。Dockerfile 中的 COPY 只能读取构建上下文中的文件。
不需要参与构建的文件应写进 .dockerignore:
node_modules
**/node_modules
.next
**/.next
dist
**/dist
.git好处包括:
- 减少传给 Docker Builder 的文件;
- 避免把宿主机依赖复制进 Linux 镜像;
- 避免本地构建产物污染镜像;
- 减少无关文件导致缓存失效。
Docker 为什么能使用缓存#
Dockerfile 中的每条构建指令都会形成一个逻辑层。再次构建时,如果某一步的指令和输入没有变化,Docker 可以复用旧结果。
例如:
COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm --filter web build只要修改任意源码,COPY . . 的输入就发生变化。它之后的依赖安装和应用构建都会重新执行。
这解释了为什么我只修改了一行页面文案,pnpm install 仍然重新运行。
优化依赖安装缓存#
依赖只由 package.json、锁文件和 workspace 清单决定,因此应先复制这些稳定文件:
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/web/package.json ./apps/web/package.json
COPY apps/docs/package.json ./apps/docs/package.json
COPY packages/ui/package.json ./packages/ui/package.json
COPY packages/eslint-config/package.json ./packages/eslint-config/package.json
COPY packages/typescript-config/package.json ./packages/typescript-config/package.json
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm --filter web build新的顺序是:
复制依赖清单
↓
安装依赖
↓
复制经常变化的源码
↓
构建应用现在只修改源码时,输出中会出现:
RUN pnpm install --frozen-lockfile
CACHED只有修改依赖清单或锁文件,依赖安装层才会失效。
缓存优化的核心不是让所有步骤永远缓存,而是让变化频率不同的文件分层复制。
为什么单阶段镜像很大#
单阶段构建把所有工作都放在同一个镜像中:
完整源码
开发依赖
TypeScript 和构建工具
构建缓存
生产运行依赖
最终构建产物构建完成后,编译工具和源码通常不再是运行应用的必要条件,但它们仍留在最终镜像中。
本次单阶段 Next.js 镜像结果:
DISK USAGE 1.19 GB
CONTENT SIZE 284 MB多阶段构建#
多阶段 Dockerfile 包含多个 FROM:
FROM node:22-alpine AS deps
# 安装依赖
FROM node:22-alpine AS builder
# 构建应用
FROM node:22-alpine AS runner
# 只运行最终产物阶段之间通过 COPY --from 传递需要的文件:
COPY --from=builder /app/apps/web/.next/standalone ./本项目的职责划分:
deps
└─ 安装 pnpm workspace 依赖
builder
├─ 复制依赖和源码
└─ 执行 next build
runner
├─ 复制 standalone 产物
├─ 复制 public 和 .next/static
└─ 使用非 root 用户运行 server.js多阶段构建不会同时启动三个容器。默认最终镜像只使用最后一个目标阶段,前面的阶段用于生成产物和构建缓存。
Next.js standalone 输出#
Next.js 配置:
const nextConfig = {
output: "standalone",
outputFileTracingRoot: path.join(currentDir, "../.."),
};standalone 会收集运行服务器所需的最小依赖。运行阶段还需要单独复制:
public/
.next/static/
.next/standalone/最终启动命令:
CMD ["node", "apps/web/server.js"]非 root 用户运行#
运行阶段创建专用用户:
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs ...
USER nextjs这样应用进程不以 root 权限运行。即使应用出现漏洞,攻击者在容器中默认获得的权限也更受限制。
优化结果#
优化前先为旧镜像保留标签:
docker tag docker-demo-web:v1 docker-demo-web:single-stage重新构建后比较:
docker image ls --filter "reference=docker-demo-web:*"实测结果:
| 镜像 | Disk Usage | Content Size |
|---|---|---|
| 单阶段 | 1.19 GB | 284 MB |
| 多阶段 | 313 MB | 78.2 MB |
磁盘占用减少约 74%。
这里的两个大小可以粗略理解为:
DISK USAGE:本地解包和存储后占用;CONTENT SIZE:镜像内容层的压缩体积。
镜像不可变与容器替换#
修改代码并重新构建镜像,不会让正在运行的旧容器自动变成新容器。
本次曾出现:
源码已修改
新镜像已构建
页面仍显示旧内容检查后发现镜像标签已经指向新镜像,但容器仍引用旧镜像 ID。
强制重新创建:
docker compose up -d --force-recreate web构建与替换可以合并:
docker compose up -d --build --force-recreate web需要牢记:
构建新镜像 ≠ 修改已有容器
新镜像需要通过新容器运行本篇速记#
RUN 在构建时执行
CMD 在容器启动时执行
EXPOSE 不等于发布端口
.dockerignore 控制构建上下文
稳定文件先 COPY,易变源码后 COPY
多阶段构建让构建环境与运行环境分离
COPY --from 用于跨阶段复制产物
新镜像不会自动替换旧容器自测问题#
- 为什么
COPY . .放在pnpm install前面会降低缓存命中率? RUN和CMD分别在什么时候执行?- Dockerfile 写了
EXPOSE 3000,为什么宿主机仍可能无法访问? - 多阶段构建是否会产生三个运行中的容器?
- 构建新镜像后,为什么可能仍看到旧页面?
