Docker 部署 Node.js:没人告诉你的生产级配置
多阶段构建、非 root 用户、健康检查、密钥管理和镜像体积优化。我在每个 Node.js 生产部署中使用的 Docker 模式。
大多数生产环境中的 Node.js Dockerfile 都是有问题的。不是"稍微不够优化"那种问题。我说的是以 root 运行、打了 600MB 镜像还带着 devDependencies、没有健康检查,密钥硬编码在环境变量里——任何人用 docker inspect 就能看到。
我知道,因为那些 Dockerfile 就是我写的。写了好几年。它们能跑,所以我从没质疑过。然后有一天安全审计标记了我们的容器——以 PID 1 root 运行,对整个文件系统拥有写权限——我才意识到"能用"和"生产就绪"完全是两码事。
这是我现在在每个 Node.js 项目中使用的 Docker 配置。不是理论性的。它运行着这个站点和我维护的其他几个服务。这里的每个模式都是因为我要么在替代方案上栽过跟头,要么亲眼看着别人栽跟头。
为什么你当前的 Dockerfile 大概率是错的#
让我猜猜你的 Dockerfile 长什么样:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]这是 Dockerfile 的"hello world"。它能跑。但它至少有五个在生产环境中会坑你的问题。
以 root 运行#
默认情况下,node Docker 镜像以 root 运行。这意味着你的应用进程在容器内拥有 root 权限。如果有人利用了你应用中的漏洞——路径遍历 bug、SSRF、某个带后门的依赖——他们就拥有了容器文件系统的 root 访问权限,可以修改二进制文件、安装包,甚至可能进一步提权,具体取决于你的容器运行时配置。
"但容器是隔离的啊!"部分是。容器逃逸是真实存在的。CVE-2024-21626、CVE-2019-5736——这些都是真实世界的容器突破事件。以非 root 运行是一种纵深防御措施。零成本,却能封堵整类攻击。
在生产环境安装 devDependencies#
不带参数的 npm install 会安装所有东西。你的测试运行器、linter、构建工具、类型检查器——全都堆在生产镜像里。这会让你的镜像膨胀数百 MB,并增加攻击面。每多一个包就是另一个 Trivy 或 Snyk 可能标记的潜在漏洞。
COPY 所有东西#
COPY . . 把你的整个项目目录复制到镜像里。包括 .git(可能非常大)、.env 文件(包含密钥)、node_modules(你马上就要重新安装的)、测试文件、文档、CI 配置——统统都有。
没有健康检查#
没有 HEALTHCHECK 指令,Docker 不知道你的应用是否真正在提供服务。进程可能在运行但已死锁、内存耗尽或陷入无限循环。Docker 会报告容器"运行中",因为进程没有退出。你的负载均衡器继续向一个僵尸容器发送流量。
没有层缓存策略#
在安装依赖之前复制所有东西意味着修改一行源代码就会使 npm install 缓存失效。每次构建都从头重新安装所有依赖。对于依赖很重的项目,每次构建浪费 2-3 分钟。
让我们逐一修复这些问题。
多阶段构建:最有价值的一步#
多阶段构建是你能对 Node.js Dockerfile 做的最有影响力的改变。概念很简单:用一个阶段构建应用,然后只把需要的产物复制到一个干净、精简的最终镜像中。
实际效果的对比:
# 单阶段:约 600MB
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
# 多阶段:约 150MB
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
CMD ["node", "dist/server.js"]builder 阶段有一切:完整的 Node.js、npm、构建工具、源代码、devDependencies。runner 阶段只有运行时需要的东西。builder 阶段被完全丢弃——它不会出现在最终镜像中。
真实的体积对比#
我在一个实际的 Express.js API 项目上测量了这些数据,大约有 40 个依赖:
| 方案 | 镜像大小 |
|---|---|
node:20 + npm install | 1.1 GB |
node:20-slim + npm install | 420 MB |
node:20-alpine + npm ci | 280 MB |
| 多阶段 + alpine + 仅生产依赖 | 150 MB |
| 多阶段 + alpine + 精简后的依赖 | 95 MB |
从粗放方案到最终方案,缩小了 10 倍。更小的镜像意味着更快的拉取、更快的部署和更小的攻击面。
为什么选 Alpine?#
Alpine Linux 使用 musl libc 而不是 glibc,并且不包含包管理器缓存、文档或标准 Linux 发行版中的大多数工具。基础的 node:20-alpine 镜像约 50MB,而 node:20-slim 约 350MB,完整的 node:20 超过 1GB。
代价是某些有原生绑定的 npm 包(如 bcrypt、sharp、canvas)需要针对 musl 编译。大多数情况下这直接就行——npm 会下载正确的预构建二进制文件。如果遇到问题,可以在 builder 阶段安装构建依赖:
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
# ... 其余构建步骤这些构建工具只存在于 builder 阶段。不会出现在最终镜像中。
完整的生产级 Dockerfile#
这是我每个 Node.js 项目的起点 Dockerfile。每一行都是有意为之的。
# ============================================
# 阶段 1:安装依赖
# ============================================
FROM node:20-alpine AS deps
# 安全:在做任何事之前先创建工作目录
WORKDIR /app
# 基于 lockfile 安装依赖
# 先只复制 package 文件——这对层缓存至关重要
COPY package.json package-lock.json ./
# ci 比 install 更好:更快、更严格、可复现
# --omit=dev 在这个阶段排除 devDependencies
RUN npm ci --omit=dev
# ============================================
# 阶段 2:构建应用
# ============================================
FROM node:20-alpine AS builder
WORKDIR /app
# 复制 package 文件并安装所有依赖(包括 dev)
COPY package.json package-lock.json ./
RUN npm ci
# 现在复制源代码——这里的改动不会使 npm ci 缓存失效
COPY . .
# 构建应用(TypeScript 编译、Next.js 构建等)
RUN npm run build
# ============================================
# 阶段 3:生产运行器
# ============================================
FROM node:20-alpine AS runner
# 添加标签用于镜像元数据
LABEL maintainer="your-email@example.com"
LABEL org.opencontainers.image.source="https://github.com/yourorg/yourrepo"
# 安全:安装 dumb-init 以正确处理 PID 1 的信号
RUN apk add --no-cache dumb-init
# 安全:在做任何事之前设置 NODE_ENV
ENV NODE_ENV=production
# 安全:使用非 root 用户
# node 镜像已经包含一个 'node' 用户(uid 1000)
USER node
# 创建归属于 node 用户的应用目录
WORKDIR /app
# 从 deps 阶段复制生产依赖
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
# 从 builder 阶段复制构建产物
COPY --from=deps --chown=node:node /app/package.json ./
COPY --from=builder --chown=node:node /app/dist ./dist
# 暴露端口(仅作文档——不会发布它)
EXPOSE 3000
# 健康检查:alpine 中没有 curl,使用 node
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
# 使用 dumb-init 作为 PID 1 以正确处理信号
ENTRYPOINT ["dumb-init", "--"]
# 启动应用
CMD ["node", "dist/server.js"]让我解释一下那些不太明显的部分。
为什么是三个阶段而不是两个?#
deps 阶段只安装生产依赖。builder 阶段安装所有东西(包括 devDependencies)并构建应用。runner 阶段从 deps 复制生产依赖、从 builder 复制构建后的代码。
为什么不在 builder 阶段安装生产依赖?因为 builder 阶段混入了 devDependencies。你得在构建后运行 npm prune --production,这比干净的生产依赖安装更慢也更不可靠。
为什么要 dumb-init?#
当你在容器中运行 node server.js 时,Node.js 成为 PID 1。PID 1 在 Linux 中有特殊行为:它不会接收默认的信号处理器。如果你向容器发送 SIGTERM(这就是 docker stop 做的事),作为 PID 1 的 Node.js 默认不会处理它。Docker 等待 10 秒,然后发送 SIGKILL,立即终止进程而不做任何清理——没有优雅关闭,没有关闭数据库连接,没有完成进行中的请求。
dumb-init 作为 PID 1 并正确地将信号转发给你的应用。你的 Node.js 进程按预期收到 SIGTERM,可以优雅关闭:
// server.js
const server = app.listen(3000);
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(() => {
console.log('HTTP server closed');
// 关闭数据库连接、刷新日志等
process.exit(0);
});
});另一个替代方案是 docker run 的 --init 参数,但把它写进镜像意味着无论容器怎么启动都能生效。
.dockerignore 文件#
这和 Dockerfile 本身一样重要。没有它,COPY . . 会把所有东西发送到 Docker 守护进程:
# .dockerignore
node_modules
npm-debug.log*
.git
.gitignore
.env
.env.*
!.env.example
Dockerfile
docker-compose*.yml
.dockerignore
README.md
LICENSE
.github
.vscode
.idea
coverage
.nyc_output
*.test.ts
*.test.js
*.spec.ts
*.spec.js
__tests__
test
tests
docs
.husky
.eslintrc*
.prettierrc*
tsconfig.json
jest.config.*
vitest.config.*
.dockerignore 中的每个文件都不会被发送到构建上下文、不会出现在镜像中,修改时也不会使层缓存失效。
层缓存:别再每次构建等 3 分钟了#
Docker 以层的方式构建镜像。每条指令创建一个层。如果一个层没有变化,Docker 使用缓存版本。但关键细节是:如果一个层变了,它之后的所有层都会失效。
这就是为什么指令的顺序极其重要。
错误的顺序#
COPY . .
RUN npm ci每次你修改任何文件——一个源文件中的一行——Docker 就会看到 COPY . . 这个层变了。它会使该层及之后的所有层失效,包括 npm ci。每次代码改动你都得重新安装所有依赖。
正确的顺序#
COPY package.json package-lock.json ./
RUN npm ci
COPY . .现在 npm ci 只在 package.json 或 package-lock.json 变化时才运行。如果你只改了源代码,Docker 会复用缓存的 npm ci 层。在一个有 500+ 依赖的项目上,这每次构建能省 60-120 秒。
npm 的缓存挂载#
Docker BuildKit 支持在构建之间持久化 npm 缓存的缓存挂载:
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev这在构建之间保留了 npm 下载缓存。如果某个依赖在之前的构建中已经下载过,npm 使用缓存版本而不是重新下载。在频繁构建的 CI 中特别有用。
要使用 BuildKit,设置环境变量:
DOCKER_BUILDKIT=1 docker build -t myapp .或者添加到 Docker 守护进程配置中:
{
"features": {
"buildkit": true
}
}使用 ARG 进行缓存失效#
有时你需要强制重建某个层。例如,如果你从 registry 拉取 latest 标签并想确保获取最新版本:
ARG CACHE_BUST=1
RUN npm ci用唯一值构建以失效缓存:
docker build --build-arg CACHE_BUST=$(date +%s) -t myapp .谨慎使用。缓存的全部意义在于速度——只在有理由时才失效缓存。
密钥管理:别再把密钥放到 Dockerfile 里了#
这是最常见也最危险的错误之一。我经常看到:
# 永远不要这样做
ENV DATABASE_URL=postgres://user:password@db:5432/myapp
ENV API_KEY=sk-live-abc123def456Dockerfile 中用 ENV 设置的环境变量会被写入镜像。任何拉取镜像的人都可以通过 docker inspect 或 docker history 看到它们。它们在设置之后的每一层中都可见。即使你之后 unset 它们,它们仍然存在于层历史中。
密钥的三个层级#
1. 构建时密钥(Docker BuildKit)
如果你在构建期间需要密钥(比如私有 npm registry 令牌),使用 BuildKit 的 --secret 参数:
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
# 在构建时挂载密钥——它永远不会存储在镜像中
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc \
npm ci
COPY . .
RUN npm run build构建命令:
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp ..npmrc 文件在 RUN 命令期间可用,但永远不会被提交到任何镜像层。它不会出现在 docker history 或 docker inspect 中。
2. 通过环境变量传递运行时密钥
对于应用在运行时需要的密钥,在启动容器时传递:
docker run -d \
-e DATABASE_URL="postgres://user:pass@db:5432/myapp" \
-e API_KEY="sk-live-abc123" \
myapp或者用 env 文件:
docker run -d --env-file .env.production myapp这些通过 docker inspect 在运行中的容器上可见,但不会写入镜像。任何拉取镜像的人都不会得到密钥。
3. Docker secrets(Swarm / Kubernetes)
在编排环境中进行正式的密钥管理:
# docker-compose.yml(Swarm 模式)
version: "3.8"
services:
api:
image: myapp:latest
secrets:
- db_password
- api_key
secrets:
db_password:
external: true
api_key:
external: trueDocker 将密钥挂载为 /run/secrets/<secret_name> 处的文件。你的应用从文件系统读取它们:
import { readFileSync } from "fs";
function getSecret(name) {
try {
return readFileSync(`/run/secrets/${name}`, "utf8").trim();
} catch {
// 本地开发时回退到环境变量
return process.env[name.toUpperCase()];
}
}
const dbPassword = getSecret("db_password");这是最安全的方式,因为密钥永远不会出现在环境变量、进程列表或容器检查输出中。
.env 文件和 Docker#
永远不要在 Docker 镜像中包含 .env 文件。你的 .dockerignore 应该排除它们(这就是我们前面列出 .env 和 .env.* 的原因)。本地开发使用 docker-compose 时,在运行时挂载它们:
services:
api:
env_file:
- .env.local健康检查:让 Docker 知道你的应用是否真正在工作#
健康检查告诉 Docker 你的应用是否正常运行。没有它,Docker 只知道进程是否在运行——而不知道它是否真的能处理请求。
HEALTHCHECK 指令#
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"让我分解一下参数:
--interval=30s:每 30 秒检查一次--timeout=10s:如果检查超过 10 秒,视为失败--start-period=40s:给应用 40 秒的启动时间后才开始计算失败--retries=3:连续 3 次失败后标记为不健康
为什么不用 curl?#
Alpine 默认不包含 curl。你可以安装它(apk add --no-cache curl),但那会给你的精简镜像增加另一个二进制文件。直接使用 Node.js 意味着零额外依赖。
更轻量的健康检查,你可以用专用脚本:
// healthcheck.js
const http = require("http");
const options = {
hostname: "localhost",
port: 3000,
path: "/health",
timeout: 5000,
};
const req = http.request(options, (res) => {
process.exit(res.statusCode === 200 ? 0 : 1);
});
req.on("error", () => process.exit(1));
req.on("timeout", () => {
req.destroy();
process.exit(1);
});
req.end();COPY --chown=node:node healthcheck.js ./
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD ["node", "healthcheck.js"]健康端点#
你的应用需要一个 /health 端点供检查访问。不要只返回 200——要真正验证你的应用是否健康:
app.get("/health", async (req, res) => {
const checks = {
uptime: process.uptime(),
timestamp: Date.now(),
status: "ok",
};
try {
// 检查数据库连接
await db.query("SELECT 1");
checks.database = "connected";
} catch (err) {
checks.database = "disconnected";
checks.status = "degraded";
}
try {
// 检查 Redis 连接
await redis.ping();
checks.redis = "connected";
} catch (err) {
checks.redis = "disconnected";
checks.status = "degraded";
}
const statusCode = checks.status === "ok" ? 200 : 503;
res.status(statusCode).json(checks);
});"degraded"状态配合 503 告诉编排器在恢复之前停止向这个实例路由流量,但不一定触发重启。
为什么健康检查对编排器很重要#
Docker Swarm、Kubernetes,甚至带 restart: always 的普通 docker-compose 都使用健康检查来做决策:
- 负载均衡器 停止向不健康的容器发送流量
- 滚动更新 等新容器健康后才停止旧容器
- 编排器 可以重启变得不健康的容器
- 部署流水线 可以验证部署是否成功
没有健康检查,滚动部署可能在新容器准备好之前就杀掉旧容器,导致停机。
开发用的 docker-compose#
你的开发环境应该尽可能接近生产,同时保留热重载、调试器和即时反馈的便利性。这是我用于开发的 docker-compose 配置:
# docker-compose.dev.yml
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
args:
NODE_VERSION: "20"
ports:
- "3000:3000"
- "9229:9229" # Node.js 调试器
volumes:
# 挂载源代码以实现热重载
- .:/app
# 匿名卷保留镜像中的 node_modules
# 防止宿主机的 node_modules 覆盖容器中的
- /app/node_modules
environment:
- NODE_ENV=development
- DATABASE_URL=postgres://postgres:devpassword@db:5432/myapp_dev
- REDIS_URL=redis://redis:6379
env_file:
- .env.local
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
command: npm run dev
db:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: myapp_dev
POSTGRES_USER: postgres
POSTGRES_PASSWORD: devpassword
volumes:
# 命名卷在容器重启之间持久化数据
- pgdata:/var/lib/postgresql/data
# 初始化脚本
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redisdata:/data
command: redis-server --appendonly yes
# 可选:数据库管理 UI
adminer:
image: adminer:latest
ports:
- "8080:8080"
depends_on:
- db
volumes:
pgdata:
redisdata:关键开发模式#
卷挂载实现热重载: .:/app 卷挂载将你本地的源代码映射到容器中。保存文件时,更改立即在容器内可见。配合监视变化的开发服务器(如 nodemon 或 tsx --watch),你获得即时反馈。
node_modules 技巧: 匿名卷 - /app/node_modules 确保容器使用自己的 node_modules(镜像构建时安装的),而不是宿主机的。这至关重要,因为在 macOS 上编译的原生模块在 Linux 容器中无法工作。
服务依赖: 带 condition: service_healthy 的 depends_on 确保数据库在你的应用尝试连接之前确实就绪了。没有健康检查条件,depends_on 只等待容器启动——而不是里面的服务准备好。
命名卷: pgdata 和 redisdata 在容器重启之间持久化。没有命名卷,每次运行 docker-compose down 你的数据库就没了。
开发用 Dockerfile#
你的开发 Dockerfile 比生产简单:
# Dockerfile.dev
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
WORKDIR /app
# 安装所有依赖(包括 devDependencies)
COPY package*.json ./
RUN npm ci
# 源代码通过卷挂载,不是复制
# 但初始构建仍然需要它
COPY . .
EXPOSE 3000 9229
CMD ["npm", "run", "dev"]不需要多阶段构建,不需要生产优化。目标是快速迭代,不是小镜像。
生产环境的 Docker Compose#
生产 docker-compose 是另一回事。这是我使用的:
# docker-compose.prod.yml
services:
app:
image: ghcr.io/yourorg/myapp:${TAG:-latest}
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
env_file:
- .env.production
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.25"
memory: 128M
replicas: 2
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"]
interval: 30s
timeout: 10s
start_period: 40s
retries: 3
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
- internal
- web
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: myapp
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
redis:
image: redis:7-alpine
restart: unless-stopped
command: >
redis-server
--appendonly yes
--maxmemory 256mb
--maxmemory-policy allkeys-lru
volumes:
- redisdata:/data
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
networks:
- internal
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "3"
volumes:
pgdata:
driver: local
redisdata:
driver: local
networks:
internal:
driver: bridge
web:
external: true与开发环境的区别#
重启策略: unless-stopped 在容器崩溃时自动重启,除非你手动停止了它。这处理了"凌晨三点崩溃"的场景。另一个选项 always 也会重启你有意停止的容器,这通常不是你想要的。
资源限制: 没有限制,Node.js 应用中的内存泄漏会消耗宿主机上的所有可用 RAM,可能杀死其他容器甚至宿主机本身。根据应用的实际用量加上一些余量来设置限制:
# 监控实际用量以设置合适的限制
docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"日志配置: 没有 max-size 和 max-file,Docker 日志会无限增长。我见过生产服务器因为 Docker 日志而磁盘空间耗尽。带轮转的 json-file 是最简单的方案。如果需要集中式日志,换成 fluentd 或 gelf 驱动:
logging:
driver: "fluentd"
options:
fluentd-address: "localhost:24224"
tag: "myapp.{{.Name}}"网络隔离: internal 网络只对这个 compose 栈中的服务可访问。数据库和 Redis 不暴露给宿主机或其他容器。只有 app 服务连接到 web 网络,你的反向代理(Nginx、Traefik)通过它来路由流量。
数据库不做端口映射: 注意生产配置中 db 和 redis 没有 ports。它们只能通过内部 Docker 网络访问。在开发中我们暴露它们以便使用本地工具(pgAdmin、Redis Insight)。在生产中,它们没有理由从 Docker 网络外部可访问。
Next.js 特有配置:standalone 输出#
Next.js 有一个很多人不知道的内置 Docker 优化:standalone 输出模式。它追踪你应用的 import 并只复制运行所需的文件——不需要 node_modules(依赖被打包了)。
在 next.config.ts 中启用:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;这极大地改变了构建输出。Next.js 不再需要整个 node_modules 目录,而是在 .next/standalone/ 中生成一个自包含的 server.js,只包含实际使用的依赖。
Next.js 生产 Dockerfile#
这是我在 Next.js 项目中使用的 Dockerfile,基于 Vercel 官方示例但加了安全加固:
# ============================================
# 阶段 1:安装依赖
# ============================================
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ============================================
# 阶段 2:构建应用
# ============================================
FROM node:20-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
# ============================================
# 阶段 3:生产运行器
# ============================================
FROM node:20-alpine AS runner
WORKDIR /app
RUN apk add --no-cache dumb-init
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# 非 root 用户
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# 复制公共资源
COPY --from=builder /app/public ./public
# 设置 standalone 输出目录
# 自动利用输出追踪来减小镜像大小
# https://nextjs.org/docs/advanced-features/output-file-tracing
RUN mkdir .next
RUN chown nextjs:nodejs .next
# 复制 standalone 服务器和静态文件
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
ENV HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]Next.js 的体积对比#
| 方案 | 镜像大小 |
|---|---|
node:20 + 完整 node_modules + .next | 1.4 GB |
node:20-alpine + 完整 node_modules + .next | 600 MB |
node:20-alpine + standalone 输出 | 120 MB |
standalone 输出是变革性的。1.4 GB 的镜像变成了 120 MB。原来需要 90 秒拉取的部署现在只需 10 秒。
静态文件处理#
Next.js standalone 模式不包含 public 文件夹和 .next/static 中的静态资源。你需要显式复制它们(我们在上面的 Dockerfile 中就是这样做的)。在生产中,你通常希望在这些文件前面放一个 CDN:
// next.config.ts
const nextConfig: NextConfig = {
output: "standalone",
assetPrefix: process.env.CDN_URL || undefined,
};如果不使用 CDN,Next.js 直接提供静态文件。standalone 服务器处理这个没问题——你只需确保文件在正确的位置(我们的 Dockerfile 保证了这一点)。
Sharp 用于图片优化#
Next.js 使用 sharp 进行图片优化。在基于 Alpine 的生产镜像中,你需要确保正确的二进制文件可用:
# 在 runner 阶段,切换到非 root 用户之前
RUN apk add --no-cache --virtual .sharp-deps vips-dev或者更好的做法,把它作为生产依赖安装,让 npm 处理特定平台的二进制文件:
npm install sharpnode:20-alpine 镜像与 sharp 的预构建 linux-x64-musl 二进制文件兼容。大多数情况下不需要特殊配置。
镜像扫描和安全#
构建一个使用非 root 用户的小镜像是个好的开始,但对于严肃的生产工作负载来说还不够。以下是如何更进一步。
Trivy:扫描你的镜像#
Trivy 是一个全面的容器镜像漏洞扫描器。在你的 CI 流水线中运行它:
# 安装 trivy
brew install aquasecurity/trivy/trivy # macOS
# 或
apt-get install trivy # Debian/Ubuntu
# 扫描你的镜像
trivy image myapp:latest示例输出:
myapp:latest (alpine 3.19.1)
=============================
Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
Node.js (node_modules/package-lock.json)
=========================================
Total: 2 (UNKNOWN: 0, LOW: 0, MEDIUM: 1, HIGH: 1, CRITICAL: 0)
┌──────────────┬────────────────┬──────────┬────────┬───────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Fixed Version │
├──────────────┼────────────────┼──────────┼────────┼───────────────┤
│ semver │ CVE-2022-25883 │ HIGH │ fixed │ 7.5.4 │
│ word-wrap │ CVE-2023-26115 │ MEDIUM │ fixed │ 1.2.4 │
└──────────────┴────────────────┴──────────┴────────┴───────────────┘
在 CI 中集成以在严重漏洞时使构建失败:
# .github/workflows/docker.yml
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
exit-code: 1
severity: CRITICAL,HIGH
ignore-unfixed: true只读文件系统#
你可以用只读根文件系统运行容器。这阻止攻击者修改二进制文件、安装工具或写入恶意脚本:
docker run --read-only \
--tmpfs /tmp \
--tmpfs /app/.next/cache \
myapp:latest--tmpfs 挂载提供了应用确实需要写入的可写临时目录(临时文件、缓存)。其他所有东西都是只读的。
在 docker-compose 中:
services:
app:
image: myapp:latest
read_only: true
tmpfs:
- /tmp
- /app/.next/cache丢弃所有能力#
Linux 能力是替代全有或全无 root 模型的细粒度权限。默认情况下,Docker 容器获得一个能力子集。你可以丢弃所有能力:
docker run --cap-drop=ALL myapp:latest如果你的应用需要绑定 1024 以下的端口,你需要 NET_BIND_SERVICE。但由于我们用非 root 用户使用 3000 端口,不需要任何能力:
services:
app:
image: myapp:latest
cap_drop:
- ALL
security_opt:
- no-new-privileges:trueno-new-privileges 阻止进程通过 setuid/setgid 二进制文件获取额外权限。这是一个零成本的纵深防御措施。
锁定基础镜像摘要#
不要使用 node:20-alpine(这是一个移动目标),而是锁定到特定的摘要:
FROM node:20-alpine@sha256:abcdef123456...获取摘要:
docker inspect --format='{{index .RepoDigests 0}}' node:20-alpine这确保你的构建是 100% 可复现的。代价是你不会自动获取基础镜像的安全补丁。使用 Dependabot 或 Renovate 来自动化摘要更新:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: docker
directory: "/"
schedule:
interval: weeklyCI/CD 集成:整合一切#
这是一个完整的 GitHub Actions 工作流,用于构建、扫描和推送 Docker 镜像:
# .github/workflows/docker.yml
name: Build and Push Docker Image
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha
type=ref,event=branch
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
exit-code: 1
severity: CRITICAL,HIGH
ignore-unfixed: true
- name: Upload Trivy results
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarifCI 中的 BuildKit 缓存#
cache-from: type=gha 和 cache-to: type=gha,mode=max 行使用 GitHub Actions 缓存作为 Docker 层缓存。这意味着你的 CI 构建在运行之间受益于层缓存。第一次构建需要 5 分钟;后续只有代码变化的构建只需 30 秒。
常见陷阱及如何避免#
镜像内的 node_modules 与宿主机冲突#
如果你把项目目录卷挂载到容器中(-v .:/app),宿主机的 node_modules 会覆盖容器的。在 macOS 上编译的原生模块在 Linux 中无法工作。始终使用匿名卷技巧:
volumes:
- .:/app
- /app/node_modules # 保留容器的 node_modulesTypeScript 项目中的 SIGTERM 处理#
如果你在开发中用 tsx 或 ts-node 运行 TypeScript,信号处理正常工作。但在生产中,如果你用编译后的 JavaScript 配合 node,确保编译输出保留了信号处理器。某些构建工具会优化掉"未使用的"代码。
内存限制和 Node.js#
Node.js 不会自动遵守 Docker 内存限制。如果你的容器有 512MB 内存限制,Node.js 仍会尝试使用默认的堆大小(64 位系统上约 1.5 GB)。设置最大旧生代空间大小:
CMD ["node", "--max-old-space-size=384", "dist/server.js"]在 Node.js 堆限制和容器内存限制之间留大约 25% 的余量,给非堆内存(缓冲区、原生代码等)。
或者使用自动检测参数:
ENV NODE_OPTIONS="--max-old-space-size=384"时区问题#
Alpine 默认使用 UTC。如果你的应用依赖特定时区:
RUN apk add --no-cache tzdata
ENV TZ=America/New_York但更好的做法是:编写与时区无关的代码。所有存储都用 UTC。只在表现层转换为本地时间。
构建参数 vs 环境变量#
ARG仅在构建期间可用。它不会持久化到最终镜像(除非你把它复制到ENV)。ENV持久化在镜像中,在运行时可用。
# 构建时配置
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
# 运行时配置
ENV PORT=3000
# 错误:这会使密钥在镜像中可见
ARG API_KEY
ENV API_KEY=${API_KEY}
# 正确:在运行时传递密钥
# docker run -e API_KEY=secret myapp生产环境监控#
没有可观测性,你的 Docker 配置就不算完整。这是一个最小但有效的监控栈:
# docker-compose.monitoring.yml
services:
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
ports:
- "9090:9090"
networks:
- internal
grafana:
image: grafana/grafana:latest
volumes:
- grafana_data:/var/lib/grafana
ports:
- "3001:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
networks:
- internal
volumes:
prometheus_data:
grafana_data:使用 prom-client 从你的 Node.js 应用暴露指标:
import { collectDefaultMetrics, Registry, Histogram } from "prom-client";
const register = new Registry();
collectDefaultMetrics({ register });
const httpRequestDuration = new Histogram({
name: "http_request_duration_seconds",
help: "Duration of HTTP requests in seconds",
labelNames: ["method", "route", "status_code"],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 5],
registers: [register],
});
// 中间件
app.use((req, res, next) => {
const end = httpRequestDuration.startTimer();
res.on("finish", () => {
end({ method: req.method, route: req.route?.path || req.path, status_code: res.statusCode });
});
next();
});
// 指标端点
app.get("/metrics", async (req, res) => {
res.set("Content-Type", register.contentType);
res.end(await register.metrics());
});检查清单#
在将容器化的 Node.js 应用发布到生产之前,验证:
- 非 root 用户 —— 容器以非 root 用户运行
- 多阶段构建 —— devDependencies 和构建工具不在最终镜像中
- Alpine 基础镜像 —— 使用最小化基础镜像
- .dockerignore ——
.git、.env、node_modules、测试文件已排除 - 层缓存 ——
package.json在源代码之前复制 - 健康检查 —— Dockerfile 中有 HEALTHCHECK 指令
- 信号处理 —— 用
dumb-init或--init正确处理 SIGTERM - 镜像中无密钥 —— Dockerfile 中没有包含敏感值的
ENV - 资源限制 —— 在 compose/编排器中设置了内存和 CPU 限制
- 日志轮转 —— 配置了带最大大小的日志驱动
- 镜像扫描 —— CI 流水线中有 Trivy 或类似工具
- 版本锁定 —— 基础镜像和依赖版本已锁定
- 内存限制 —— 已为 Node.js 堆设置
--max-old-space-size
这些大多是一次性配置。做一次,模板化,每个新项目从第一天起就有生产就绪的容器。
Docker 不复杂。但一个"能用"的 Dockerfile 和一个生产就绪的 Dockerfile 之间的差距,比大多数人想的要大。这份指南中的模式弥合了这个差距。使用它们,适应它们,别再部署以 root 运行、1GB 大小、没有健康检查的容器了。你未来的自己——那个凌晨三点被叫醒的人——会感谢你的。