Docker 基础体系 · 第 38/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Node.js 应用 Docker 镜像:依赖、构建产物、PID 1、信号和安全

Node.js 应用的 Docker 镜像并不是把整个项目目录复制进去就结束了。一个可维护的镜像至少要回答五个问题:

  1. 运行时究竟需要哪些依赖?
  2. TypeScript、前端资源或其他源码经过构建后,哪些文件才是运行时输入?
  3. 容器中的 Node.js 进程是否是 PID 1?
  4. SIGTERMSIGINTSIGKILL 如何到达应用,应用如何优雅退出?
  5. 镜像构建和容器运行时如何限制权限、避免泄露凭据?

这些问题彼此相关。依赖决定镜像内容,构建产物决定运行时边界,PID 1 决定信号和子进程管理,信号处理决定停止行为,而用户身份、文件权限和构建输入决定安全边界。


一、先区分四类输入:源码、构建依赖、生产依赖和构建产物

Node.js 项目通常同时包含以下几类文件:

项目目录
├── package.json
├── package-lock.json
├── src/
├── tsconfig.json
├── scripts/
├── test/
├── dist/
├── node_modules/
├── .env
└── .git/

它们在镜像中的作用不同:

  • 源码:例如 src/,用于构建或运行。
  • 构建依赖:例如 TypeScript、Webpack、Vite、测试框架,通常位于 devDependencies
  • 生产依赖:应用运行时实际需要的包,通常位于 dependencies
  • 构建产物:例如 dist/,是编译或打包之后的运行时输入。
  • 本地状态和敏感文件:例如 node_modules/.env.git/,通常不应直接进入构建上下文或镜像。

如果应用入口是编译后的 dist/server.js,运行时通常不需要 src/ 和 TypeScript 编译器。一个运行时镜像可以抽象为:

Iruntime=Bruntime+Dprod+Abuild+CruntimeI_{\text{runtime}} = B_{\text{runtime}} + D_{\text{prod}} + A_{\text{build}} + C_{\text{runtime}}

其中:

  • BruntimeB_{\text{runtime}}:运行时基础镜像;
  • DprodD_{\text{prod}}:生产依赖;
  • AbuildA_{\text{build}}:构建产物;
  • CruntimeC_{\text{runtime}}:运行时配置,例如环境变量,而不是构建时秘密。

开发依赖和源码属于构建过程:

Ibuilder=Bbuilder+Dprod+Ddev+SsourceI_{\text{builder}} = B_{\text{builder}} + D_{\text{prod}} + D_{\text{dev}} + S_{\text{source}}

多阶段构建的目标,就是让最终镜像接近 IruntimeI_{\text{runtime}},而不是把整个 IbuilderI_{\text{builder}} 留在最终层中。


二、依赖固定:package.json 不是完整的依赖解析结果

package.json 声明了直接依赖范围,例如:

{
  "dependencies": {
    "express": "^5.1.0"
  },
  "devDependencies": {
    "typescript": "^5.8.0"
  }
}

这里的 ^5.1.0 并不唯一确定安装版本。它允许符合 SemVer 规则的一组版本。真正的安装结果还包括:

  • 间接依赖;
  • 每个包的精确版本;
  • 包的完整性校验值;
  • 依赖树结构;
  • 某些平台相关的可选依赖。

package-lock.json 保存了这些解析结果。对 npm 项目而言,生产构建通常应使用:

npm ci

而不是:

npm install

npm ci 的核心约束是:锁文件必须存在,并且与 package.json 的依赖声明一致;否则安装失败。它不会基于当前环境重新解析出一棵新的依赖树。

这并不表示构建就完全与环境无关。还必须考虑:

  • Node.js 主版本;
  • CPU 架构,例如 amd64arm64
  • 操作系统和 C 库,例如 glibc 与 musl;
  • 原生模块的编译器和 ABI;
  • npm 版本;
  • lockfile 的生成方式。

因此,更准确的构建输入是:

D=f(L,Vnode,Vnpm,Pos,Parch)D = f(L, V_{\text{node}}, V_{\text{npm}}, P_{\text{os}}, P_{\text{arch}})

其中 LL 是锁文件,其他变量表示运行环境。锁文件固定了依赖解析结果,但原生模块仍可能针对不同平台生成不同的二进制文件。

依赖安装的缓存边界

推荐先复制依赖清单,再复制源码:

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

COPY . .
RUN npm run build

这样做不是为了语法简洁,而是为了利用 Docker 的层缓存。

如果 package.jsonpackage-lock.json 没有变化,前面的 npm ci 层可以复用;修改 src/ 时不必重新下载所有依赖。反过来,如果先执行:

COPY . .
RUN npm ci

那么任何源码变化都会使 COPY . . 失效,后续的依赖安装也会重新执行。


三、一个完整的多阶段 Dockerfile

下面假设项目具有以下约定:

{
  "scripts": {
    "build": "tsc",
    "start": "node dist/server.js"
  }
}

项目源文件位于 src/,构建结果位于 dist/。可以使用以下 Dockerfile:

# syntax=docker/dockerfile:1

FROM node:22-bookworm-slim AS dependencies

WORKDIR /app

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

FROM node:22-bookworm-slim AS builder

WORKDIR /app

COPY --from=dependencies /app/node_modules ./node_modules
COPY package.json package-lock.json ./
COPY tsconfig.json ./
COPY src ./src

RUN npm run build

FROM dependencies AS production-dependencies

RUN npm prune --omit=dev \
    && npm cache clean --force

FROM node:22-bookworm-slim AS runtime

WORKDIR /app

ENV NODE_ENV=production

COPY --from=production-dependencies /app/package.json ./package.json
COPY --from=production-dependencies /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist

USER node

EXPOSE 3000

STOPSIGNAL SIGTERM

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

构建命令:

docker build -t example-node-app:1.0 .

运行命令:

docker run --rm --name example-node-app -p 3000:3000 example-node-app:1.0

如果应用提供 /healthz

curl -i http://127.0.0.1:3000/healthz

预期应得到 HTTP 200,具体响应内容由应用实现决定。

每个阶段为什么存在

dependencies

FROM node:22-bookworm-slim AS dependencies

这个阶段安装完整依赖,包括开发依赖,因为构建 TypeScript 或打包资源通常需要它们。

这里使用 bookworm-slim 而不是随意使用 latest,有两个原因:

  1. Node.js 主版本在 Dockerfile 中可见;
  2. 基础系统系列在构建过程中相对稳定。

这仍不是完整的供应链固定。生产环境可以进一步固定基础镜像 digest:

FROM node:22-bookworm-slim@sha256:<digest> AS dependencies

digest 必须从实际发布的镜像清单中取得,不能随意填写。固定 digest 后,升级基础镜像需要显式更新 Dockerfile,而不是在不修改源码的情况下悄然改变结果。

builder

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

构建阶段复用完整依赖,然后只复制构建所需输入。示例没有复制测试目录、文档和 Git 历史,因为它们不是 npm run build 的输入。

如果构建脚本会读取其他文件,必须把这些文件明确复制进来。例如:

COPY public ./public
COPY vite.config.ts ./

不能仅凭文件名判断构建输入,应该根据实际构建命令和工具配置确认。

production-dependencies

RUN npm prune --omit=dev

这个阶段把完整依赖树裁剪为生产依赖。它继承了 dependencies 阶段的文件系统,但最终阶段只选择性复制 node_modulespackage.json,不会把前一阶段的所有文件自动带入最终镜像。

这种写法对原生模块比较有利:依赖在与运行时相同的 Node.js 和基础系统系列中安装,构建出的 .node 二进制文件更容易与最终运行环境兼容。

另一种写法是在运行时阶段重新执行:

RUN npm ci --omit=dev

这可以进一步缩小中间阶段,但如果生产依赖需要编译原生模块,运行时镜像可能缺少编译器、Python 或系统头文件,导致构建失败。应根据项目依赖选择方案,而不是机械追求某种 Dockerfile 形式。

runtime

最终阶段只复制:

  • package.json
  • 剪裁后的 node_modules
  • dist/ 构建产物。

因此,TypeScript 编译器、测试框架、源码和构建工具不在最终镜像中。

需要注意,COPY --from=builder --chown=node:node 只改变复制到最终阶段的文件属主。node_modules 也必须对运行用户可读;官方 Node.js 镜像通常提供名为 node 的非 root 用户,但具体镜像仍应通过检查确认。


四、构建上下文和 .dockerignore 是镜像边界的一部分

执行:

docker build -t example-node-app:1.0 .

最后的 . 是构建上下文。Docker 客户端会把上下文发送给构建器;Dockerfile 中的 COPY 只能读取上下文中的文件。

因此,.dockerignore 不仅影响上传速度,也决定哪些文件有机会进入构建过程:

node_modules
dist
.git
.gitignore
.env
.env.*
npm-debug.log*
Dockerfile*
docker-compose*.yml
coverage
.vscode

这里有几个边界需要说明:

  • 忽略 node_modules 可以避免把宿主机依赖复制进 Linux 镜像;
  • 忽略 dist 可以确保构建产物来自容器内的构建步骤,而不是开发者工作区的旧结果;
  • 忽略 .env 可以减少凭据进入构建上下文的风险;
  • 忽略 Dockerfile 是否合适取决于项目结构;某些构建流程需要复制额外 Dockerfile,此时不能盲目照抄。

.dockerignore 不是秘密保护机制。已经进入构建上下文的敏感文件,即使之后没有 COPY,仍可能暴露给构建过程或构建基础设施。因此,秘密文件应在源头排除,并使用专门的秘密注入机制。


五、构建时秘密和运行时配置不是一回事

以下写法存在泄露风险:

ARG NPM_TOKEN
RUN npm config set //registry.example.com/:_authToken=$NPM_TOKEN \
    && npm ci

ARGENV 都不应被当作安全的秘密存储:

  • ARG 可能出现在构建历史、元数据或日志中;
  • ENV 会成为镜像配置的一部分,容器运行时可以看到;
  • 把秘密写入文件后再删除,也不一定能从已经生成的层中消除。

BuildKit 支持秘密挂载时,可以这样使用:

# syntax=docker/dockerfile:1

FROM node:22-bookworm-slim AS dependencies
WORKDIR /app

COPY package.json package-lock.json ./

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

构建命令:

docker build \
  --secret id=npmrc,src="$HOME/.npmrc" \
  -t example-node-app:1.0 .

秘密只在对应的 RUN 步骤中以挂载文件形式出现,不应被复制到最终阶段。

如果应用运行时需要数据库密码或 API 密钥,应通过运行平台的 secret 机制或受控环境变量注入,而不是在 Dockerfile 中写死:

docker run --rm \
  -e DATABASE_URL='postgres://...' \
  example-node-app:1.0

上面的命令仅用于说明机制;真实环境不应把生产密码直接写入 shell 历史或 CI 日志。


六、CMD 的 exec form 决定谁是 PID 1

Linux 容器中,容器的第一个进程在其 PID 命名空间中拥有 PID 1。Docker 默认把容器配置的启动进程作为这个 PID 1。

下面两种写法看起来相似,行为却不同:

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

这是 exec form。容器中的进程树通常是:

PID 1  node dist/server.js

而下面是 shell form:

CMD node dist/server.js

它通常等价于由 shell 启动命令:

PID 1  /bin/sh -c "node dist/server.js"
└──     node dist/server.js

此时 Docker 发给 PID 1 的信号首先到达 /bin/sh。shell 是否转发 SIGTERM 给 Node.js,取决于 shell 和启动方式;不能把它当作可靠的进程管理器。

同样的问题常见于:

CMD ["sh", "-c", "node dist/server.js"]

即使使用了 JSON 数组,只要第一个程序是 shell,Node.js 就不再是直接的 PID 1。

ENTRYPOINTCMD 的关系

例如:

ENTRYPOINT ["node"]
CMD ["dist/server.js"]

Docker 实际执行的是:

node dist/server.js

ENTRYPOINT 通常表示固定的执行程序,CMD 表示默认参数。执行:

docker run --rm example-node-app:1.0 dist/other.js

时,默认参数会被替换为 dist/other.js

如果应用需要直接接收 Docker 或 Compose 发送的停止信号,最简单且可预测的启动形式通常是:

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

七、PID 1 不只是一个编号:它承担信号和孤儿进程职责

在 Linux 中,PID 1 有两个重要语义。

1. 信号默认动作存在特殊边界

普通进程收到未处理的 SIGTERM 时,默认动作通常是终止。PID 1 对信号有特殊规则:对于没有显式处理程序的信号,某些默认动作不会像普通进程那样直接生效。这是为了避免系统初始化进程被随意终止。

这意味着“应用能处理信号”和“应用恰好是 PID 1”是两个不同问题:

  • Node.js 必须实际注册或保留适合的信号行为;
  • 启动链不能让 shell 截住信号;
  • 应用必须在收到终止请求后完成清理;
  • 如果没有清理能力,可以使用一个合适的 init 进程转发信号。

Node.js 应用可以显式处理 SIGTERM

import http from "node:http";

const server = http.createServer((req, res) => {
  if (req.url === "/healthz") {
    res.writeHead(200, { "content-type": "text/plain" });
    res.end("ok\n");
    return;
  }

  res.writeHead(404);
  res.end();
});

server.listen(3000, "0.0.0.0", () => {
  console.log("listening on :3000");
});

let shuttingDown = false;

function shutdown(signal) {
  if (shuttingDown) return;
  shuttingDown = true;

  console.log(`received ${signal}, shutting down`);

  // 不再接受新的连接;已有连接按 Node.js 的关闭语义完成。
  server.close((error) => {
    if (error) {
      console.error("server close failed", error);
      process.exitCode = 1;
    }
  });

  // 防止某个连接、定时器或外部资源无限阻塞退出。
  setTimeout(() => {
    console.error("shutdown timed out");
    process.exitCode = 1;
  }, 15000).unref();
}

process.once("SIGTERM", () => shutdown("SIGTERM"));
process.once("SIGINT", () => shutdown("SIGINT"));

这段代码的状态转换是:

stateDiagram-v2
    [*] --> Running
    Running --> Stopping: SIGTERM/SIGINT
    Stopping --> Draining: server.close()
    Draining --> Exited: 连接和资源关闭
    Draining --> ForcedExit: 超时
    ForcedExit --> [*]
    Exited --> [*]

关键路径如下:

  1. Docker 发送 SIGTERM
  2. Node.js 的信号监听器调用 shutdown
  3. server.close() 停止接受新连接;
  4. 已有请求和资源完成关闭;
  5. 回调成功后进程自然退出;
  6. 如果超过超时时间,进程以失败状态退出;
  7. 如果 Docker 的停止宽限期先到,Docker 会发送 SIGKILL,应用无法再执行清理。

生产应用还需要关闭数据库连接池、消息消费者、定时任务和子进程。只关闭 HTTP server 并不意味着所有资源都已停止。

2. PID 1 需要回收孤儿进程

当子进程的父进程提前退出时,子进程可能成为孤儿进程,由 PID 1 接管。PID 1 需要通过 wait 回收结束的子进程,否则可能积累僵尸进程。

单纯的 HTTP Node.js 服务通常没有大量子进程;但以下场景更需要 init:

  • 应用启动 ffmpeg、脚本或其他 worker;
  • 使用 child_process.spawn()
  • 进程树中有多个长期运行的子进程;
  • 应用本身不会正确转发和回收子进程。

Docker 可以通过 --init 注入一个轻量 init:

docker run --rm --init \
  --name example-node-app \
  -p 3000:3000 \
  example-node-app:1.0

Compose 中可写:

services:
  app:
    build: .
    init: true

这个 init 通常负责转发信号和回收子进程,但它不是应用优雅关闭逻辑的替代品。应用仍应在收到 SIGTERM 后关闭自己的连接和资源。


八、停止流程:SIGTERM、宽限期和 SIGKILL

容器停止通常遵循如下过程:

sequenceDiagram
    participant U as docker stop/Compose
    participant D as Docker Engine
    participant P as PID 1
    participant N as Node.js 应用
    participant K as Linux

    U->>D: 请求停止
    D->>P: SIGTERM 或 stop_signal
    P->>N: 直接处理或转发信号
    N->>N: 停止接收新请求,关闭资源
    N-->>D: 进程退出
    D-->>U: 停止完成

    Note over D,K: 超过 stop timeout
    D->>P: SIGKILL
    K->>P: 立即终止,不能捕获或清理

执行:

docker stop example-node-app

Docker 会先发送停止信号,并等待一段时间;如果进程仍未退出,最终发送 SIGKILLSIGKILL 不能被捕获、阻塞或忽略,因此没有“收到 SIGKILL 后再清理”的实现方式。

Dockerfile 中可以声明停止信号:

STOPSIGNAL SIGTERM

SIGTERM 是常见的优雅终止信号。某些程序约定其他信号,但不能只因为“程序支持某个信号”就修改它;必须同时确认:

  • Docker Engine 使用该信号;
  • init 或 shell 会转发该信号;
  • Node.js 代码监听该信号;
  • 编排系统的停止超时足够长。

Compose 可以进一步配置:

services:
  app:
    build: .
    init: true
    stop_signal: SIGTERM
    stop_grace_period: 20s

stop_grace_period 是停止宽限期,不是应用的业务超时。它至少应覆盖:

Tgrace>Tin-flight+Tresource-close+TmarginT_{\text{grace}} > T_{\text{in-flight}} + T_{\text{resource-close}} + T_{\text{margin}}

例如,应用允许最长请求执行 10 秒,数据库连接关闭最多需要 3 秒,预留 2 秒余量,则宽限期至少应大于 15 秒。实际值还要考虑负载和故障情况下的最坏路径。

常见失败表现

shell 截住信号

CMD node dist/server.js

执行 docker stop 后,Node.js 日志中的 received SIGTERM 没有出现,容器直到超时后被强制杀死。常见原因是 PID 1 是 shell,shell 没有可靠转发信号。

修复为:

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

应用收到信号但立即退出

如果代码中调用:

process.exit(0);

可能导致正在处理的请求被截断,数据库事务或消息确认未完成。优雅关闭的重点是先停止接收新工作,再等待已有工作结束,最后自然退出。

宽限期太短

应用日志显示收到了 SIGTERM,但仍被 Docker 强制终止。这通常不是信号没到达,而是 server.close()、连接池关闭或消费者停止超过了 Docker 的宽限期。

可以通过以下命令观察状态:

docker inspect -f \
  '{{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' \
  example-node-app

调试进程树:

docker top example-node-app

使用 docker kill 测试时要注意:

docker kill --signal=SIGTERM example-node-app

这适合验证应用的信号处理。直接执行:

docker kill example-node-app

默认发送 SIGKILL,只能验证强制终止,不能验证优雅关闭。


九、健康检查是状态观测,不是进程管理器

Docker 的 HEALTHCHECK 给容器增加健康状态:

  • starting:仍在启动宽限期内;
  • healthy:最近若干次检查成功;
  • unhealthy:连续失败达到重试条件。

它不等同于进程是否存在,也不自动重启容器。一个进程可能还活着,但已经无法接受请求;反过来,一个健康检查命令失败,也不一定意味着主进程需要重启。

Node.js 22 自带 fetch,可以避免在 node:bookworm-slim 中依赖 curl

HEALTHCHECK \
  --interval=30s \
  --timeout=3s \
  --start-period=10s \
  --retries=3 \
  CMD ["node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]

参数含义是:

  • interval:两次检查的间隔;
  • timeout:单次检查最长时间;
  • start-period:应用启动阶段的宽限时间;
  • retries:连续失败多少次后标记为 unhealthy

健康端点应该反映真正需要的状态。例如:

if (req.url === "/healthz") {
  res.writeHead(200);
  res.end("ok\n");
}

这只能说明 HTTP 服务器还能响应。如果应用必须连接数据库才能提供服务,则健康检查需要检查数据库连接;如果数据库短暂不可用时应用仍可处理缓存请求,则数据库检查是否纳入健康定义应由服务语义决定。

不要把所有昂贵检查都放到每次 probe 中。健康检查本身会消耗 CPU、网络和连接池资源;检查命令失败时还要区分:

  • 应用尚未启动;
  • 应用过载;
  • 依赖服务不可用;
  • 检查程序本身不存在;
  • DNS 或网络配置错误。

检查结果:

docker inspect -f '{{json .State.Health}}' example-node-app

Compose 或编排平台可能根据 unhealthy 做进一步处理,但 Docker Engine 的 HEALTHCHECK 本身不会因为状态变成 unhealthy 就自动重启容器。重启策略、服务替换和流量摘除属于更高层的运行平台行为。


十、非 root 运行:降低影响范围而不是消除漏洞

默认使用 root 运行应用,会让应用漏洞具有更大的容器内权限。Dockerfile 中应切换到非特权用户:

USER node

但这要求应用运行目录和需要写入的目录具有正确权限。例如,如果应用需要写临时文件,可以显式建立目录:

RUN mkdir -p /app/tmp \
    && chown -R node:node /app
USER node

更理想的设计是让应用尽量无状态,不写入镜像文件系统;必须写入时使用专门挂载点。

Compose 中可以增加运行时限制:

services:
  app:
    build: .
    init: true
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

这些配置的效果不同:

  • read_only: true:容器根文件系统只读;
  • tmpfs: /tmp:为需要临时文件的程序提供内存文件系统;
  • cap_drop: ALL:删除 Linux capabilities,减少特权操作能力;
  • no-new-privileges:阻止进程通过某些机制获得更高权限。

它们不是无条件可用的开关。应用如果需要写 /app、创建 Unix socket 或使用特定内核能力,启用后可能启动失败。应先在测试环境运行,再根据失败路径授予最小权限,而不是重新恢复 root 和全部 capabilities。


十一、原生模块和基础镜像选择:体积不是唯一变量

Node.js 依赖中可能包含原生模块,例如通过 node-gyp 编译的包。它们会受到以下因素影响:

  • Node.js ABI;
  • CPU 架构;
  • glibc 或 musl;
  • 编译器版本;
  • 系统库版本;
  • 包是否提供对应平台的预编译二进制。

因此,下面这种迁移可能失败:

在 macOS 上生成 node_modules
→ COPY 到 Linux 镜像

即使 JavaScript 文件本身跨平台,node_modules 中的 .node 文件也可能不是目标平台可加载的格式。

同样,node:alpine 使用 musl libc,而 node:bookworm-slim 使用 glibc。Alpine 镜像可能更小,但并不自动意味着最终镜像更小或更可靠,因为:

  • 某些依赖没有 musl 预编译包;
  • 需要额外编译工具;
  • 运行时兼容性问题更难排查;
  • 实际安装的系统包可能抵消体积收益。

选择基础镜像应基于依赖兼容性、调试能力、漏洞维护和目标平台,而不是只比较标签大小。


十二、调试层与最终层应分开

极简运行时镜像通常缺少 shell、包管理器、编辑器和诊断命令。这有利于缩小攻击面,但也会降低现场排查能力。

可以保留一个调试阶段:

FROM node:22-bookworm-slim AS debug

WORKDIR /app
ENV NODE_ENV=production

COPY --from=production-dependencies /app/package.json ./package.json
COPY --from=production-dependencies /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist

USER node
CMD ["node", "dist/server.js"]

构建调试镜像:

docker build --target debug -t example-node-app:debug .

最终生产镜像仍使用 runtime 阶段:

docker build --target runtime -t example-node-app:1.0 .

这样可以把“生产运行时的最小内容”和“故障排查所需工具”分开,而不是为了偶尔调试把工具永久放入生产镜像。


十三、验证镜像是否真的满足设计

构建成功只说明 Dockerfile 执行完成,不说明镜像的进程、依赖和停止行为正确。可以按以下路径验证。

检查镜像层和启动配置

docker image inspect example-node-app:1.0

重点观察:

  • Config.Cmd
  • Config.Entrypoint
  • Config.User
  • Config.Healthcheck
  • 基础镜像和标签信息。

检查运行用户:

docker run --rm example-node-app:1.0 id

预期输出中的用户不应是 root,例如可能包含:

uid=1000(node) gid=1000(node) groups=1000(node)

具体 UID/GID 由基础镜像决定,不应硬编码为示例中的数字。

检查容器内进程

docker run -d --name example-node-app -p 3000:3000 example-node-app:1.0
docker top example-node-app

预期能看到 Node.js 是容器的主进程。更直接地检查:

docker exec example-node-app sh -c 'tr "\0" " " </proc/1/cmdline; echo'

如果最终镜像没有 sh,该命令会失败;这不是应用失败,而是极简镜像没有 shell。可以改用 docker top 或在调试镜像中检查。

检查优雅关闭

docker logs -f example-node-app

另一个终端执行:

docker stop --time 20 example-node-app

预期日志至少应包含:

received SIGTERM, shutting down

如果没有这条日志,依次检查:

  1. CMD 是否为 exec form;
  2. PID 1 是否是 Node.js 或正确的 init;
  3. Dockerfile 的 STOPSIGNAL
  4. Compose 的 stop_signal
  5. 应用是否真的注册了 SIGTERM 监听器。

检查健康状态

docker inspect -f '{{.State.Health.Status}}' example-node-app

预期从 starting 变为 healthy。如果变为 unhealthy,查看详细记录:

docker inspect -f '{{json .State.Health.Log}}' example-node-app

常见原因包括:

  • 应用只监听了 127.0.0.1,而不是容器内可用的地址;
  • 健康检查使用了镜像中不存在的 curl
  • 端口配置错误;
  • /healthz 路径不存在;
  • 启动时间超过 start-period
  • 应用启动成功但依赖服务尚未就绪。

十四、常见误区的因果关系

误区一:把 npm install 换成 npm ci 就能完全复现

npm ci 能固定 lockfile 描述的依赖解析结果,但不能消除 Node.js 版本、操作系统、架构和原生模块 ABI 的差异。要获得可重复的运行时,还需要固定或审计这些环境变量。

误区二:删除文件就能从镜像中删除秘密

Docker 镜像由多层组成。某一层写入秘密,后续层再执行 rm,秘密可能仍存在于前一层。构建秘密应该通过 BuildKit secret mount 使用,且不复制到最终阶段。

误区三:Node.js 是 PID 1,所以它一定会优雅退出

PID 1 只描述进程身份和特殊内核语义,不会自动关闭 HTTP 连接、数据库连接或子进程。优雅关闭需要信号到达、应用处理、资源排空和足够的停止时间共同成立。

误区四:健康检查失败会自动重启容器

HEALTHCHECK 只记录健康状态。是否摘流量、替换实例或重启服务由 Compose、Swarm、Kubernetes 或其他平台决定,不能把 Docker 的健康状态和重启策略混为一谈。

误区五:镜像越小越安全

更小的镜像通常意味着组件更少,但安全性还取决于:

  • 依赖是否有已知漏洞;
  • 基础镜像是否持续更新;
  • 是否使用非 root;
  • 是否限制 capabilities;
  • 是否泄露秘密;
  • 应用是否能正确处理停止和异常。

一个缺少调试工具但依赖过时、以 root 运行的镜像,并不因为体积小就自动安全。


十五、最终设计的成立条件

一个 Node.js 生产镜像可以用以下条件判断是否成立:

可运行=运行时依赖完整构建产物完整入口可执行\text{可运行} = \text{运行时依赖完整} \land \text{构建产物完整} \land \text{入口可执行}

可停止=信号到达 PID 1应用注册处理逻辑资源能在宽限期内关闭\text{可停止} = \text{信号到达 PID 1} \land \text{应用注册处理逻辑} \land \text{资源能在宽限期内关闭}

风险可控=非 root秘密不进入镜像依赖和基础镜像可审计运行权限最小化\text{风险可控} = \text{非 root} \land \text{秘密不进入镜像} \land \text{依赖和基础镜像可审计} \land \text{运行权限最小化}

这几个条件缺一不可。例如:

  • 只有构建产物,没有生产依赖,容器会在启动时出现 MODULE_NOT_FOUND
  • 依赖完整但入口使用 shell,应用可能收不到 SIGTERM
  • 信号处理正确但宽限期不足,仍会被 SIGKILL
  • 镜像很小但运行用户是 root,应用漏洞造成的影响范围仍然较大;
  • 依赖锁定但把宿主机 node_modules 复制进来,目标平台仍可能无法加载原生模块。

因此,Dockerfile 的质量不应只用镜像大小评价。更重要的是,最终镜像是否只包含运行时真正需要的内容,主进程是否可观测、可停止,依赖是否能重建,权限是否足够小,以及每一个失败路径是否有明确的诊断方法。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。