目录
用 Docker 部署 Node.js 应用(Express、TypeScript、Next.js)
Dockerfile 入门 用过一个最简单的 Node.js 示例。这一节整理正式项目应该注意的地方,每个建议都附上实际运行的结果。
Express:项目文件
express-app/
├── package.json
├── package-lock.json
├── server.js
├── Dockerfile
├── .dockerignore
└── compose.yaml ← 开发模式才用到
package.json:
{
"name": "express-app",
"version": "1.0.0",
"scripts": { "start": "node server.js", "dev": "node --watch server.js" },
"dependencies": { "express": "^5.1.0" }
}
server.js:
const express = require("express");
const app = express();
app.get("/", (req, res) => res.json({ message: "Hello from Express", node: process.version }));
const server = app.listen(3000, () => console.log("Listening on port 3000"));
process.on("SIGTERM", () => {
console.log("SIGTERM received, closing server");
server.close(() => process.exit(0));
});
最后几行处理 SIGTERM:收到停止信号时,先停止接受新连接、等进行中的请求完成,再结束程序。为什么需要这段,后面会实测。
没有安装 Node.js 也能生成 package-lock.json
npm ci 需要 package-lock.json。本机没有 Node.js 的话,可以用容器生成:
$ docker run --rm -v "$(pwd):/app" -w /app node:24-alpine npm install --package-lock-only
up to date, audited 66 packages in 3s
28 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
对应 Windows 的写法(点击标签切换):
docker run --rm -v "$(pwd):/app" -w /app node:24-alpine npm install --package-lock-only
docker run --rm -v "${PWD}:/app" -w /app node:24-alpine npm install --package-lock-only
docker run --rm -v "%cd%:/app" -w /app node:24-alpine npm install --package-lock-only
--package-lock-only 只生成锁文件,不创建 node_modules。
Express:生产环境 Dockerfile
FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=node:node . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]
| 写法 | 原因 |
|---|---|
node:24-alpine | Node.js 24 是 LTS(长期支持)版本;alpine 版本体积小。如果有原生模块在 alpine 上编译失败,改用 node:24-slim |
NODE_ENV=production | Express 等框架会激活生产环境的优化 |
npm ci 而不是 npm install | 严格依照 package-lock.json 安装,每次构建结果相同;锁文件与 package.json 不一致时直接失败 |
--omit=dev | 不安装 devDependencies |
npm cache clean --force | 删除 npm 的下载缓存,不留在镜像中 |
COPY --chown=node:node | 文件拥有者设为 node 用户 |
USER node | 官方镜像内置 node 用户,避免以 root 运行 |
CMD ["node", "server.js"] | exec 格式直接运行 node |
.dockerignore:
node_modules
npm-debug.log
dist
.env
.git
一定要排除 node_modules。主机上的 node_modules 可能包含为其他操作系统(Windows、macOS)编译的原生模块,拷贝进 Linux 容器会出错,而且会覆盖掉 npm ci 刚装好的版本。
构建:
$ docker build -t express-app .
[+] Building 4.4s (10/10) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 263B 0.0s
=> [internal] load metadata for docker.io/library/node:24-alpine 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 82B 0.0s
=> [1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 0.0s
=> => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 31.48kB 0.0s
=> CACHED [2/5] WORKDIR /app 0.0s
=> [3/5] COPY package.json package-lock.json ./ 0.0s
=> [4/5] RUN npm ci --omit=dev && npm cache clean --force 3.6s
=> [5/5] COPY --chown=node:node . . 0.1s
=> exporting to image 0.5s
=> => exporting layers 0.3s
=> => exporting manifest sha256:8917f79cf02c3ec90fb6b405b264d6947606b4483a1a54c9136d3d897c055cfe 0.0s
=> => exporting config sha256:38cb9b0da0deb6a0d8682ddb8bf8705ccccec4315c3e58075409bc2f4b07c82a 0.0s
=> => exporting attestation manifest sha256:84686e085a4e55d8b9480ab770fb2b2e55b93126c779a2a58fd9647b434fb638 0.0s
=> => exporting manifest list sha256:b571835fa0e0ed6b89f34a1a725e847dd40b13a61bc13c9aadf30c405db340af 0.0s
=> => naming to docker.io/library/express-app:latest 0.0s
=> => unpacking to docker.io/library/express-app:latest 0.2s
运行并确认:
$ docker run -d --name express -p 3000:3000 express-app
4d0122c37234c4396ed8cb924d4c206c77c07f2db19b9a9a89ea83eeb53831df
$ curl -s http://localhost:3000
{"message":"Hello from Express","node":"v24.21.0"}
$ docker exec express whoami
node
$ docker exec express ps
PID USER TIME COMMAND
1 node 0:00 {MainThread} node server.js
18 node 0:00 ps
$ time docker stop express
express
real 0m 0.15s
user 0m 0.00s
sys 0m 0.01s
$ docker logs express
Listening on port 3000
SIGTERM received, closing server
whoami是node,不是 root。ps显示node server.js的 PID 是 1。docker stop只花了 0.15 秒,日志中看得到SIGTERM received, closing server,程序有正常收到信号并自行结束。
实测:为什么 docker stop 要等 10 秒
把 server.js 中处理 SIGTERM 的那几行删掉,另存成 server-nohandler.js,用同一个镜像运行它:
$ docker run -d --name nohandler express-app node server-nohandler.js
9278a6a9ad9bab77e08edcb06b52baed2f372b0f98248ebf2bed344a96c4f4a3
$ time docker stop nohandler
nohandler
real 0m 10.13s
user 0m 0.01s
sys 0m 0.00s
$ docker inspect -f "{{.State.ExitCode}}" nohandler
137
这次 docker stop 花了 10.13 秒,退出码是 137(128 + 9,表示被 SIGKILL 强制终止)。
原因是 Linux 对 PID 1 的特殊规则:PID 1 没有注册处理函数的信号会被忽略。一般程序收到 SIGTERM 时,默认动作就是结束;但在容器中 node 是 PID 1,没有写 process.on("SIGTERM") 的话,SIGTERM 会被直接忽略。Docker 等了 10 秒还没结束,只好送出 SIGKILL 强制终止,进行中的请求就被直接中断。
解法有两种:
- 像前面的
server.js一样,在程序中处理 SIGTERM(建议,可以优雅地结束)。 - 加上
--init(Compose 写init: true),由 Docker 内置的小型 init 进程当 PID 1,负责转发信号:
$ docker run -d --name withinit --init express-app node server-nohandler.js
6adf48ca4dc8eeed68ebd49689f417001ae4282d14995dd6299b62aa39b9c0b6
$ docker exec withinit ps
PID USER TIME COMMAND
1 node 0:00 /sbin/docker-init -- docker-entrypoint.sh node server-nohandler.js
7 node 0:00 {MainThread} node server-nohandler.js
15 node 0:00 ps
$ time docker stop withinit
withinit
real 0m 0.22s
user 0m 0.00s
sys 0m 0.00s
ps 看到 PID 1 变成 /sbin/docker-init,node 变成 PID 7。node 不再是 PID 1,SIGTERM 的默认动作恢复成结束程序,docker stop 只花了 0.22 秒。
那 npm start 呢?
Dockerfile.npm 与前面相同,只把最后一行改成 CMD ["npm", "start"]:
$ docker build -q -f Dockerfile.npm -t express-npm .
sha256:b1de8acaa7f9b683dccff25420ff08edd33926938788525d304fa327c5ed5895
$ docker run -d --name npmstart express-npm
bf95495c7e1ea9f990a00980423d78d238a38acca6a653a858548b100a88d846
$ docker exec npmstart ps
PID USER TIME COMMAND
1 node 0:00 npm start
18 node 0:00 {MainThread} node server.js
25 node 0:00 ps
$ time docker stop npmstart
npmstart
real 0m 0.11s
user 0m 0.00s
sys 0m 0.01s
$ docker logs npmstart
> express-app@1.0.0 start
> node server.js
Listening on port 3000
SIGTERM received, closing server
npm notice
npm notice New major version of npm available! 11.19.0 -> 12.2.0
npm notice Changelog: https://github.com/npm/cli/releases/tag/v12.2.0
npm notice To update run: npm install -g npm@12.2.0
npm notice
npm 是 PID 1,node 是它的子进程(PID 18)。实测 npm 11 会把 SIGTERM 转发给 node,所以 0.11 秒就停止了,日志中也看到 SIGTERM received。
不过仍然建议直接用 node 启动:
- 多一个 npm 进程常驻,占用额外的内存。
- 日志中混进 npm 的消息(上面最后的
npm notice就是 npm 在提示有新版本)。 - 信号转发依赖 npm 的实作,旧版 npm 或用 shell 脚本包装时不一定可靠。
TypeScript:多阶段构建
需要编译的项目(TypeScript、Babel、打包工具)使用 多阶段构建:第一阶段安装全部包并编译,第二阶段只安装生产环境包,再从第一阶段拷贝编译结果。
示例项目 ts-app:
ts-app/
├── package.json
├── package-lock.json
├── tsconfig.json
├── src/
│ └── index.ts
├── Dockerfile
└── .dockerignore
package.json:
{
"name": "ts-app",
"version": "1.0.0",
"scripts": { "build": "tsc", "start": "node dist/index.js" },
"dependencies": { "express": "^5.1.0" },
"devDependencies": { "typescript": "^5.9.0", "@types/express": "^5.0.0", "@types/node": "^24.0.0" }
}
tsconfig.json:
{
"compilerOptions": {
"target": "ES2023",
"module": "commonjs",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true
}
}
src/index.ts:
import express, { Request, Response } from "express";
const app = express();
const port = Number(process.env.PORT ?? 3000);
app.get("/", (_req: Request, res: Response) => {
res.json({ message: "Hello from TypeScript", node: process.version });
});
const server = app.listen(port, () => console.log(`Listening on port ${port}`));
process.on("SIGTERM", () => server.close(() => process.exit(0)));
Dockerfile:
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
.dockerignore 除了 node_modules,还要排除 dist,避免本机编译过的旧文件被拷贝进去。
$ docker run --rm -v "$(pwd):/app" -w /app node:24-alpine npm install --package-lock-only --no-update-notifier
up to date, audited 78 packages in 5s
28 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
$ docker build -t ts-app .
[+] Building 16.7s (13/13) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 391B 0.0s
=> [internal] load metadata for docker.io/library/node:24-alpine 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 68B 0.0s
=> [build 1/6] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 0.0s
=> => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 35.99kB 0.0s
=> CACHED [build 2/6] WORKDIR /app 0.0s
=> [build 3/6] COPY package.json package-lock.json ./ 0.0s
=> [stage-1 4/5] RUN npm ci --omit=dev && npm cache clean --force 7.0s
=> [build 4/6] RUN npm ci 7.2s
=> [build 5/6] COPY . . 0.1s
=> [build 6/6] RUN npm run build 8.3s
=> [stage-1 5/5] COPY --from=build /app/dist ./dist 0.1s
=> exporting to image 0.9s
=> => exporting layers 0.6s
=> => exporting manifest sha256:58a91539ec3d0dbcee5d303babb4c767a09c85b53abe6c195a321e6fd2d80b2e 0.0s
=> => exporting config sha256:2213fe6fe167daa9912ceec2e6c55883f67baa9b637f7cefa4b6006741d51c7c 0.0s
=> => exporting attestation manifest sha256:411a11454dc2f749351eb4f363d87425763a77331094da33f74e46fd273aeb88 0.0s
=> => exporting manifest list sha256:6ed92551a94607ebcbfbd4fa123342429cc46737266344add01c334b682ea897 0.0s
=> => naming to docker.io/library/ts-app:latest 0.0s
=> => unpacking to docker.io/library/ts-app:latest 0.2s
$ docker run -d --name ts -p 3000:3000 ts-app
15ddc10ace29ece5e4cc013a30d2b990bba52b901b918154e64463d5ee581d46
$ curl -s http://localhost:3000
{"message":"Hello from TypeScript","node":"v24.21.0"}
$ docker exec ts ls
dist
node_modules
package-lock.json
package.json
$ docker exec ts ls node_modules | grep -c .
65
$ docker exec ts ls node_modules/typescript
ls: node_modules/typescript: No such file or directory
$ docker images --filter reference=ts-app
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
ts-app:build fcf8f6c88e74 297MB 73.9MB
ts-app:latest 6ed92551a946 250MB 62.8MB U
对应 Windows 的写法(点击标签切换):
docker run --rm -v "$(pwd):/app" -w /app node:24-alpine npm install --package-lock-only --no-update-notifier
docker run --rm -v "${PWD}:/app" -w /app node:24-alpine npm install --package-lock-only --no-update-notifier
docker run --rm -v "%cd%:/app" -w /app node:24-alpine npm install --package-lock-only --no-update-notifier
观察重点:
[build 4/6] RUN npm ci和[stage-1 4/5] RUN npm ci --omit=dev几乎同时进行。BuildKit 发现两个阶段互不依赖,会并行构建。- 最终镜像只有
dist、node_modules、两个 package 文件,没有src和tsconfig.json。 node_modules/typescript不存在,表示 devDependencies 没有进入最终镜像。- 用
--target build另外建出第一阶段来比较:ts-app:build是 297 MB,最终的ts-app:latest是 250 MB。
Next.js:standalone 输出
Next.js 的 node_modules 很大,但运行时只用到其中一小部分。打开 standalone 输出后,next build 会分析程序实际用到的文件,整理到 .next/standalone,里面包含一个精简的 node_modules 和启动用的 server.js。
示例项目 next-app:
next-app/
├── app/
│ ├── layout.js
│ └── page.js
├── public/
├── next.config.mjs
├── package.json
├── package-lock.json
├── Dockerfile
└── .dockerignore
next.config.mjs:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
};
export default nextConfig;
package.json:
{
"name": "next-app",
"version": "1.0.0",
"private": true,
"scripts": { "dev": "next dev", "build": "next build", "start": "next start" },
"dependencies": { "next": "^16.4.0", "react": "^19.3.0", "react-dom": "^19.3.0" }
}
app/page.js(force-dynamic 让页面每次请求时才在服务器生成,用来确认 Node.js 服务器有在运作):
export const dynamic = "force-dynamic";
export default function Home() {
return (
<main>
<h1>Hello from Next.js in Docker</h1>
<p>Node.js {process.version}</p>
<p>Rendered at {new Date().toISOString()}</p>
</main>
);
}
Dockerfile:
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build --chown=node:node /app/public ./public
COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
ENV HOSTNAME=0.0.0.0 PORT=3000
CMD ["node", "server.js"]
注意:
.next/standalone不包含public和.next/static,必须另外拷贝,否则网页会缺少 CSS、JS 与图片。HOSTNAME=0.0.0.0让 Next.js 监听所有网络接口,否则容器外连不进来。.dockerignore要排除.next和node_modules。- 项目没有
public文件夹时,COPY --from=build /app/public会失败,创建一个空的public文件夹即可。
$ ls -a
.
..
.dockerignore
Dockerfile
app
next.config.mjs
package-lock.json
package.json
public
$ docker build -t next-app .
[+] Building 35.7s (14/14) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 482B 0.0s
=> [internal] load metadata for docker.io/library/node:24-alpine 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 74B 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 33.45kB 0.0s
=> [build 1/6] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 0.0s
=> => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 0.0s
=> CACHED [build 2/6] WORKDIR /app 0.0s
=> [build 3/6] COPY package.json package-lock.json ./ 0.0s
=> [build 4/6] RUN npm ci 14.3s
=> [build 5/6] COPY . . 0.2s
=> [build 6/6] RUN npm run build 17.2s
=> [stage-1 3/5] COPY --from=build --chown=node:node /app/public ./public 0.2s
=> [stage-1 4/5] COPY --from=build --chown=node:node /app/.next/standalone ./ 0.3s
=> [stage-1 5/5] COPY --from=build --chown=node:node /app/.next/static ./.next/static 0.0s
=> exporting to image 2.5s
=> => exporting layers 1.6s
=> => exporting manifest sha256:c4c7e9e6017fcef276d48566c8abdef44faaef63030e3b7de7200dee31704008 0.0s
=> => exporting config sha256:01db87f2d8cbbf50e3bdb7f43bbee19b88ad6cde31600d80119a5e95a45e0411 0.0s
=> => exporting attestation manifest sha256:6294f1b5a03f9b4eeabdbc13d9ba940f09b5985beae5d12a090884d7f1840a86 0.0s
=> => exporting manifest list sha256:9beae036b818492afcf23060792cd3d7931346272bc5ba7a207f16f8aaf07d1a 0.0s
=> => naming to docker.io/library/next-app:latest 0.0s
=> => unpacking to docker.io/library/next-app:latest 0.8s
$ docker run -d --name next -p 3000:3000 next-app
7f81f881182ce1789b49adf30c942a835204e411925b96db56ddaf3576c705e1
$ docker logs next
▲ Next.js 16.4.0
- Local: http://localhost:3000
- Network: http://0.0.0.0:3000
✓ Ready in 0ms
✓ Running next.config took 4ms
$ docker exec next ls -a
.
..
.next
node_modules
package.json
public
server.js
$ docker exec next du -sh node_modules
38.1M node_modules
$ docker images --filter reference=next-app
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
next-app:build 3d79e3a5a419 968MB 273MB
next-app:latest 9beae036b818 295MB 73.6MB U
npm ci和next build分别花了 14 秒和 17 秒,是构建中最久的步骤。- 容器中只有
server.js、.next、public、package.json和精简过的node_modules(38.1 MB)。 - 第一阶段(包含完整
node_modules和原代码)是 968 MB,最终镜像只有 295 MB,与基础镜像node:24-alpine(242 MB)相比只多了约 53 MB。
打开 http://localhost:3000:

开发环境:修改代码自动重新加载
方法一:挂载代码文件夹
compose.yaml:
services:
app:
build: .
command: ["npm", "run", "dev"]
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
environment:
NODE_ENV: development
command覆盖 Dockerfile 的CMD,改运行npm run dev,也就是node --watch server.js(Node.js 内置的监看模式,文件变动时自动重新启动)。.:/app把本机文件夹挂载到容器的/app。- /app/node_modules是常见技巧:用一个匿名 Volume 「遮住」/app/node_modules。否则.:/app会让容器看到本机的文件夹,而本机根本没有node_modules(或是有,但是给别的操作系统用的),程序就会找不到 express。
$ docker compose up -d
[+] Building 0.2s (12/12) FINISHED
=> [internal] load local bake definitions 0.0s
=> => reading from stdin 482B 0.0s
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 263B 0.0s
=> [internal] load metadata for docker.io/library/node:24-alpine 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 82B 0.0s
=> [1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 0.0s
=> => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 31.48kB 0.0s
=> CACHED [2/5] WORKDIR /app 0.0s
=> CACHED [3/5] COPY package.json package-lock.json ./ 0.0s
=> CACHED [4/5] RUN npm ci --omit=dev && npm cache clean --force 0.0s
=> CACHED [5/5] COPY --chown=node:node . . 0.0s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => exporting manifest sha256:598eee33b39a29caaadb626ac5a43ae91fb56735f09794f8d8303f3373c9df1e 0.0s
=> => exporting config sha256:0acccf4eb17effd018f4e6c68845df68bd4d15b9659c4e0aeabea9d04682149a 0.0s
=> => exporting attestation manifest sha256:a4c7af50552b6a58aeeafc3ad29e68921bf929ea4b331b85b9f8afa58a9184b0 0.0s
=> => exporting manifest list sha256:74dc3d863e3f716a52493ec5c98edd8170da21d1a11716ab93254b104f017bb0 0.0s
=> => naming to docker.io/library/express-app-app:latest 0.0s
=> => unpacking to docker.io/library/express-app-app:latest 0.0s
=> resolving provenance for metadata file 0.0s
[+] up 3/3
✔ Image express-app-app Built 0.3s
✔ Network express-app_default Created 0.0s
✔ Container express-app-app-1 Started 0.7s
$ curl -s http://localhost:3000
{"message":"Hello from Express","node":"v24.21.0"}
用编辑器把 server.js 的消息改成 Hello after edit 并存盘,不用重新构建:
$ curl -s http://localhost:3000
{"message":"Hello after edit","node":"v24.21.0"}
$ docker compose logs app
app-1 |
app-1 | > express-app@1.0.0 dev
app-1 | > node --watch server.js
app-1 |
app-1 | Listening on port 3000
app-1 | Change detected in '/app/server.js'
app-1 | Restarting 'server.js'
app-1 | SIGTERM received, closing server
app-1 | Listening on port 3000
$ docker compose exec app ls node_modules | head -n 3
accepts
body-parser
bytes
日志中依序出现 Change detected in '/app/server.js'、Restarting 'server.js',以及我们自己写的 SIGTERM received:node --watch 也是用 SIGTERM 通知旧的进程结束。最后的 ls node_modules 确认容器使用的是镜像中安装的包。
如果项目有 devDependencies(例如 nodemon、TypeScript),开发用的镜像就不能用 --omit=dev。可以在 build 下加 target: build 指定使用多阶段构建中的第一阶段。
方法二:docker compose watch
Compose 的 watch 功能不使用挂载,而是检测本机的文件变动,自动拷贝到容器内(sync),或重新构建镜像(rebuild):
services:
app:
build: .
command: ["node", "--watch", "server.js"]
ports:
- "3000:3000"
develop:
watch:
- action: sync
path: .
target: /app
ignore:
- node_modules/
- action: rebuild
path: package.json
- 改代码 →
sync把文件拷贝进容器,node --watch检测到后重新启动。 - 改
package.json→rebuild重新构建镜像(重新npm ci)并重建容器。
$ docker compose watch
...(构建与启动的输出省略)
Container express-app-app-1 Started
Watch enabled
Syncing service "app" after 2 changes were detected
存盘后 Compose 显示 Syncing service "app" after 2 changes were detected(编辑器存盘时常会生成不只一个文件事件),重新请求就能看到新内容。按 Ctrl+C 结束 watch。
watch 的好处是不受主机与容器之间文件系统差异的影响,在 Windows 与 macOS 上更可靠,也不需要 /app/node_modules 的技巧。
疑难排解
在 macOS / Windows 上文件变更没有被检测
用挂载方式时,某些环境中文件监听事件无法传进容器。可以改用上面的 docker compose watch,或让开发工具改用轮询,例如设置环境变量 CHOKIDAR_USEPOLLING=true、WATCHPACK_POLLING=true。Windows 用户也可以把项目放在 WSL 2 的文件系统中。
npm ci 很慢
确认 .dockerignore 有排除 node_modules,并先拷贝 package*.json 再安装,只改代码时这一层就能使用缓存。还可以加上 BuildKit 缓存挂载,让 npm 下载的包在多次构建之间共用:
RUN --mount=type=cache,target=/root/.npm npm ci
日志中出现 npm notice
npm notice New major version of npm available! 是 npm 的更新提醒,不影响运行。可以在 Dockerfile 加上 ENV NPM_CONFIG_UPDATE_NOTIFIER=false 关闭,或在命令加上 --no-update-notifier。直接用 node 启动就不会有这个消息。
重点回顾
npm ci --omit=dev、USER node、exec 格式的CMD ["node", ...]。- node 是 PID 1 时要自己处理 SIGTERM,或使用
--init。 - TypeScript 与 Next.js 用多阶段构建,只把运行需要的文件放进最终镜像。
- 开发时用挂载 +
node --watch,或docker compose watch。
动手练习
练习 1:把 server-nohandler.js 用 docker run --init 启动,docker stop 要多久?为什么?
答案
约 0.2 秒。--init 让 docker-init 成为 PID 1 并转发信号,node 不再是 PID 1,SIGTERM 的默认动作恢复为结束程序。
练习 2:Next.js standalone 的最终镜像少了 public 和 .next/static 会怎样?
答案
网页可以打开,但 CSS、JavaScript 与图片会加载失败。这两个文件夹不在 .next/standalone 中,必须另外 COPY。
常见问题
npm install 和 npm ci 该用哪个?
Dockerfile 中用 npm ci,它严格依照 package-lock.json 安装,每次结果相同。
Next.js 镜像太大?
打开 output: "standalone",实测从 968 MB 降到 295 MB,见本章。
Windows 上挂载代码后,修改文件没有触发重新加载?
改用 docker compose watch,或让开发工具使用轮询(CHOKIDAR_USEPOLLING=true、WATCHPACK_POLLING=true)。
更新于 2026年10月14日