跳至内容
11 分钟阅读

Docker 部署 Node.js:没人告诉你的生产级配置

多阶段构建、非 root 用户、健康检查、密钥管理和镜像体积优化。我在每个 Node.js 生产部署中使用的 Docker 模式。

分享:X / TwitterLinkedIn

大多数生产环境中的 Node.js Dockerfile 都是有问题的。不是"稍微不够优化"那种问题。我说的是以 root 运行、打了 600MB 镜像还带着 devDependencies、没有健康检查,密钥硬编码在环境变量里——任何人用 docker inspect 就能看到。

我知道,因为那些 Dockerfile 就是我写的。写了好几年。它们能跑,所以我从没质疑过。然后有一天安全审计标记了我们的容器——以 PID 1 root 运行,对整个文件系统拥有写权限——我才意识到"能用"和"生产就绪"完全是两码事。

这是我现在在每个 Node.js 项目中使用的 Docker 配置。不是理论性的。它运行着这个站点和我维护的其他几个服务。这里的每个模式都是因为我要么在替代方案上栽过跟头,要么亲眼看着别人栽跟头。

为什么你当前的 Dockerfile 大概率是错的#

让我猜猜你的 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 做的最有影响力的改变。概念很简单:用一个阶段构建应用,然后只把需要的产物复制到一个干净、精简的最终镜像中。

实际效果的对比:

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 install1.1 GB
node:20-slim + npm install420 MB
node:20-alpine + npm ci280 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 包(如 bcryptsharpcanvas)需要针对 musl 编译。大多数情况下这直接就行——npm 会下载正确的预构建二进制文件。如果遇到问题,可以在 builder 阶段安装构建依赖:

dockerfile
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
# ... 其余构建步骤

这些构建工具只存在于 builder 阶段。不会出现在最终镜像中。

完整的生产级 Dockerfile#

这是我每个 Node.js 项目的起点 Dockerfile。每一行都是有意为之的。

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,可以优雅关闭:

javascript
// 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 使用缓存版本。但关键细节是:如果一个层变了,它之后的所有层都会失效。

这就是为什么指令的顺序极其重要。

错误的顺序#

dockerfile
COPY . .
RUN npm ci

每次你修改任何文件——一个源文件中的一行——Docker 就会看到 COPY . . 这个层变了。它会使该层及之后的所有层失效,包括 npm ci。每次代码改动你都得重新安装所有依赖。

正确的顺序#

dockerfile
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

现在 npm ci 只在 package.jsonpackage-lock.json 变化时才运行。如果你只改了源代码,Docker 会复用缓存的 npm ci 层。在一个有 500+ 依赖的项目上,这每次构建能省 60-120 秒。

npm 的缓存挂载#

Docker BuildKit 支持在构建之间持久化 npm 缓存的缓存挂载:

dockerfile
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev

这在构建之间保留了 npm 下载缓存。如果某个依赖在之前的构建中已经下载过,npm 使用缓存版本而不是重新下载。在频繁构建的 CI 中特别有用。

要使用 BuildKit,设置环境变量:

bash
DOCKER_BUILDKIT=1 docker build -t myapp .

或者添加到 Docker 守护进程配置中:

json
{
  "features": {
    "buildkit": true
  }
}

使用 ARG 进行缓存失效#

有时你需要强制重建某个层。例如,如果你从 registry 拉取 latest 标签并想确保获取最新版本:

dockerfile
ARG CACHE_BUST=1
RUN npm ci

用唯一值构建以失效缓存:

bash
docker build --build-arg CACHE_BUST=$(date +%s) -t myapp .

谨慎使用。缓存的全部意义在于速度——只在有理由时才失效缓存。

密钥管理:别再把密钥放到 Dockerfile 里了#

这是最常见也最危险的错误之一。我经常看到:

dockerfile
# 永远不要这样做
ENV DATABASE_URL=postgres://user:password@db:5432/myapp
ENV API_KEY=sk-live-abc123def456

Dockerfile 中用 ENV 设置的环境变量会被写入镜像。任何拉取镜像的人都可以通过 docker inspectdocker history 看到它们。它们在设置之后的每一层中都可见。即使你之后 unset 它们,它们仍然存在于层历史中。

密钥的三个层级#

1. 构建时密钥(Docker BuildKit)

如果你在构建期间需要密钥(比如私有 npm registry 令牌),使用 BuildKit 的 --secret 参数:

dockerfile
# 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

构建命令:

bash
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .

.npmrc 文件在 RUN 命令期间可用,但永远不会被提交到任何镜像层。它不会出现在 docker historydocker inspect 中。

2. 通过环境变量传递运行时密钥

对于应用在运行时需要的密钥,在启动容器时传递:

bash
docker run -d \
  -e DATABASE_URL="postgres://user:pass@db:5432/myapp" \
  -e API_KEY="sk-live-abc123" \
  myapp

或者用 env 文件:

bash
docker run -d --env-file .env.production myapp

这些通过 docker inspect 在运行中的容器上可见,但不会写入镜像。任何拉取镜像的人都不会得到密钥。

3. Docker secrets(Swarm / Kubernetes)

在编排环境中进行正式的密钥管理:

yaml
# 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: true

Docker 将密钥挂载为 /run/secrets/<secret_name> 处的文件。你的应用从文件系统读取它们:

javascript
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 时,在运行时挂载它们:

yaml
services:
  api:
    env_file:
      - .env.local

健康检查:让 Docker 知道你的应用是否真正在工作#

健康检查告诉 Docker 你的应用是否正常运行。没有它,Docker 只知道进程是否在运行——而不知道它是否真的能处理请求。

HEALTHCHECK 指令#

dockerfile
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 意味着零额外依赖。

更轻量的健康检查,你可以用专用脚本:

javascript
// 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();
dockerfile
COPY --chown=node:node healthcheck.js ./
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
  CMD ["node", "healthcheck.js"]

健康端点#

你的应用需要一个 /health 端点供检查访问。不要只返回 200——要真正验证你的应用是否健康:

javascript
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 配置:

yaml
# 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 卷挂载将你本地的源代码映射到容器中。保存文件时,更改立即在容器内可见。配合监视变化的开发服务器(如 nodemontsx --watch),你获得即时反馈。

node_modules 技巧: 匿名卷 - /app/node_modules 确保容器使用自己的 node_modules(镜像构建时安装的),而不是宿主机的。这至关重要,因为在 macOS 上编译的原生模块在 Linux 容器中无法工作。

服务依赖:condition: service_healthydepends_on 确保数据库在你的应用尝试连接之前确实就绪了。没有健康检查条件,depends_on 只等待容器启动——而不是里面的服务准备好。

命名卷: pgdataredisdata 在容器重启之间持久化。没有命名卷,每次运行 docker-compose down 你的数据库就没了。

开发用 Dockerfile#

你的开发 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 是另一回事。这是我使用的:

yaml
# 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,可能杀死其他容器甚至宿主机本身。根据应用的实际用量加上一些余量来设置限制:

bash
# 监控实际用量以设置合适的限制
docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"

日志配置: 没有 max-sizemax-file,Docker 日志会无限增长。我见过生产服务器因为 Docker 日志而磁盘空间耗尽。带轮转的 json-file 是最简单的方案。如果需要集中式日志,换成 fluentdgelf 驱动:

yaml
logging:
  driver: "fluentd"
  options:
    fluentd-address: "localhost:24224"
    tag: "myapp.{{.Name}}"

网络隔离: internal 网络只对这个 compose 栈中的服务可访问。数据库和 Redis 不暴露给宿主机或其他容器。只有 app 服务连接到 web 网络,你的反向代理(Nginx、Traefik)通过它来路由流量。

数据库不做端口映射: 注意生产配置中 dbredis 没有 ports。它们只能通过内部 Docker 网络访问。在开发中我们暴露它们以便使用本地工具(pgAdmin、Redis Insight)。在生产中,它们没有理由从 Docker 网络外部可访问。

Next.js 特有配置:standalone 输出#

Next.js 有一个很多人不知道的内置 Docker 优化:standalone 输出模式。它追踪你应用的 import 并只复制运行所需的文件——不需要 node_modules(依赖被打包了)。

next.config.ts 中启用:

typescript
// 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 官方示例但加了安全加固:

dockerfile
# ============================================
# 阶段 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 + .next1.4 GB
node:20-alpine + 完整 node_modules + .next600 MB
node:20-alpine + standalone 输出120 MB

standalone 输出是变革性的。1.4 GB 的镜像变成了 120 MB。原来需要 90 秒拉取的部署现在只需 10 秒。

静态文件处理#

Next.js standalone 模式不包含 public 文件夹和 .next/static 中的静态资源。你需要显式复制它们(我们在上面的 Dockerfile 中就是这样做的)。在生产中,你通常希望在这些文件前面放一个 CDN:

typescript
// next.config.ts
const nextConfig: NextConfig = {
  output: "standalone",
  assetPrefix: process.env.CDN_URL || undefined,
};

如果不使用 CDN,Next.js 直接提供静态文件。standalone 服务器处理这个没问题——你只需确保文件在正确的位置(我们的 Dockerfile 保证了这一点)。

Sharp 用于图片优化#

Next.js 使用 sharp 进行图片优化。在基于 Alpine 的生产镜像中,你需要确保正确的二进制文件可用:

dockerfile
# 在 runner 阶段,切换到非 root 用户之前
RUN apk add --no-cache --virtual .sharp-deps vips-dev

或者更好的做法,把它作为生产依赖安装,让 npm 处理特定平台的二进制文件:

bash
npm install sharp

node:20-alpine 镜像与 sharp 的预构建 linux-x64-musl 二进制文件兼容。大多数情况下不需要特殊配置。

镜像扫描和安全#

构建一个使用非 root 用户的小镜像是个好的开始,但对于严肃的生产工作负载来说还不够。以下是如何更进一步。

Trivy:扫描你的镜像#

Trivy 是一个全面的容器镜像漏洞扫描器。在你的 CI 流水线中运行它:

bash
# 安装 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 中集成以在严重漏洞时使构建失败:

yaml
# .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

只读文件系统#

你可以用只读根文件系统运行容器。这阻止攻击者修改二进制文件、安装工具或写入恶意脚本:

bash
docker run --read-only \
  --tmpfs /tmp \
  --tmpfs /app/.next/cache \
  myapp:latest

--tmpfs 挂载提供了应用确实需要写入的可写临时目录(临时文件、缓存)。其他所有东西都是只读的。

在 docker-compose 中:

yaml
services:
  app:
    image: myapp:latest
    read_only: true
    tmpfs:
      - /tmp
      - /app/.next/cache

丢弃所有能力#

Linux 能力是替代全有或全无 root 模型的细粒度权限。默认情况下,Docker 容器获得一个能力子集。你可以丢弃所有能力:

bash
docker run --cap-drop=ALL myapp:latest

如果你的应用需要绑定 1024 以下的端口,你需要 NET_BIND_SERVICE。但由于我们用非 root 用户使用 3000 端口,不需要任何能力:

yaml
services:
  app:
    image: myapp:latest
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

no-new-privileges 阻止进程通过 setuid/setgid 二进制文件获取额外权限。这是一个零成本的纵深防御措施。

锁定基础镜像摘要#

不要使用 node:20-alpine(这是一个移动目标),而是锁定到特定的摘要:

dockerfile
FROM node:20-alpine@sha256:abcdef123456...

获取摘要:

bash
docker inspect --format='{{index .RepoDigests 0}}' node:20-alpine

这确保你的构建是 100% 可复现的。代价是你不会自动获取基础镜像的安全补丁。使用 Dependabot 或 Renovate 来自动化摘要更新:

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: docker
    directory: "/"
    schedule:
      interval: weekly

CI/CD 集成:整合一切#

这是一个完整的 GitHub Actions 工作流,用于构建、扫描和推送 Docker 镜像:

yaml
# .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.sarif

CI 中的 BuildKit 缓存#

cache-from: type=ghacache-to: type=gha,mode=max 行使用 GitHub Actions 缓存作为 Docker 层缓存。这意味着你的 CI 构建在运行之间受益于层缓存。第一次构建需要 5 分钟;后续只有代码变化的构建只需 30 秒。

常见陷阱及如何避免#

镜像内的 node_modules 与宿主机冲突#

如果你把项目目录卷挂载到容器中(-v .:/app),宿主机的 node_modules 会覆盖容器的。在 macOS 上编译的原生模块在 Linux 中无法工作。始终使用匿名卷技巧:

yaml
volumes:
  - .:/app
  - /app/node_modules  # 保留容器的 node_modules

TypeScript 项目中的 SIGTERM 处理#

如果你在开发中用 tsxts-node 运行 TypeScript,信号处理正常工作。但在生产中,如果你用编译后的 JavaScript 配合 node,确保编译输出保留了信号处理器。某些构建工具会优化掉"未使用的"代码。

内存限制和 Node.js#

Node.js 不会自动遵守 Docker 内存限制。如果你的容器有 512MB 内存限制,Node.js 仍会尝试使用默认的堆大小(64 位系统上约 1.5 GB)。设置最大旧生代空间大小:

dockerfile
CMD ["node", "--max-old-space-size=384", "dist/server.js"]

在 Node.js 堆限制和容器内存限制之间留大约 25% 的余量,给非堆内存(缓冲区、原生代码等)。

或者使用自动检测参数:

dockerfile
ENV NODE_OPTIONS="--max-old-space-size=384"

时区问题#

Alpine 默认使用 UTC。如果你的应用依赖特定时区:

dockerfile
RUN apk add --no-cache tzdata
ENV TZ=America/New_York

但更好的做法是:编写与时区无关的代码。所有存储都用 UTC。只在表现层转换为本地时间。

构建参数 vs 环境变量#

  • ARG 仅在构建期间可用。它不会持久化到最终镜像(除非你把它复制到 ENV)。
  • ENV 持久化在镜像中,在运行时可用。
dockerfile
# 构建时配置
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 配置就不算完整。这是一个最小但有效的监控栈:

yaml
# 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 应用暴露指标:

javascript
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.envnode_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 大小、没有健康检查的容器了。你未来的自己——那个凌晨三点被叫醒的人——会感谢你的。

相关文章