把 Node.js 应用放进 Docker 容器,听起来只是写几行 Dockerfile 的事,但真正落地时往往会遇到一堆问题:镜像体积动辄七八百兆、每次改一行代码依赖就要全部重装、容器启动后进程莫名退出、日志里全是乱码。这篇文章以一个典型的 Express 项目为例,从零开始走一遍完整的 Docker 化流程,覆盖 Dockerfile 编写、多阶段构建、运行时配置和 docker compose 编排,把常见坑点逐一说明。

一、从一个最简单的 Dockerfile 说起
假设项目结构非常朴素:入口文件是 app.js,依赖在 package.json 中声明。很多人第一版 Dockerfile 会写成这样:
FROM node:20 COPY . /app WORKDIR /app RUN npm install CMD ["node", "app.js"]
这个版本能跑,但毛病不少。首先是 COPY . . 把所有东西都塞进了镜像,包括 node_modules、.git 目录和本地日志,既浪费空间又可能因为平台差异导致二进制模块无法使用——在 macOS 上执行 npm install 装下来的某些原生模块,放进 Linux 容器里根本加载不了。所以必须先写一个 .dockerignore 文件:
node_modules npm-debug.log .git .gitignore .env Dockerfile docker-compose.yml
其次是分层顺序问题。Docker 构建镜像时是按层缓存的,如果 COPY . . 写在 npm install 之前,那么任何一行业务代码的改动都会让缓存失效,导致依赖重新下载。正确做法是先只拷贝 package.json 和锁文件,安装完依赖之后再拷贝源码:
FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . EXPOSE 3000 CMD ["node", "app.js"]
这里用了 npm ci 代替 npm install,前者严格按照 lock 文件安装,速度更快也更可复现。基础镜像换成 node:20-alpine 后,镜像体积从接近 1GB 降到 150MB 左右,差距非常明显。
二、基础镜像怎么选:alpine、slim 还是完整版
官方提供了三类 Node 镜像:完整版(如 node:20)、slim 版(如 node:20-slim)和 alpine 版(如 node:20-alpine)。完整版基于 Debian,包含完整的编译工具链和 glibc,兼容性最好但体积最大;slim 版去掉了大部分不必要工具,保留 glibc,适合大多数生产场景;alpine 版基于 musl libc,体积最小,但如果项目依赖了原生模块(如 sharp、bcrypt、canvas),编译时可能遇到兼容性问题,需要额外安装构建工具。
一个实用的经验法则:如果项目是纯 JavaScript 或者原生模块有 alpine 预编译版本,选 alpine;如果构建时报各种奇怪的链接错误,果断换 slim,多出来的几十兆体积换来的是省心。切换 alpine 后如果需要编译原生模块,可以临时安装构建依赖再清理:
FROM node:20-alpine RUN apk add --no-cache --virtual .build-deps make g++ python3 WORKDIR /app COPY package*.json ./ RUN npm ci --only=production && apk del .build-deps COPY . . CMD ["node", "app.js"]
还可以用 docker images 对比几种基础镜像构建出来的实际体积,做到心中有数,而不是盲目追求最小化而牺牲可维护性。
三、多阶段构建与生产环境细节
如果项目里有 TypeScript 或者构建步骤(比如用 webpack 打包前端资源),多阶段构建就派上用场了。思路是在一个阶段里安装全部依赖并完成编译,再把编译产物复制到一个干净的生产镜像中,构建工具不会进入最终镜像:
# 构建阶段 FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # 运行阶段 FROM node:20-alpine WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/dist ./dist COPY package*.json ./ RUN npm ci --only=production && npm cache clean --force USER node EXPOSE 3000 CMD ["node", "dist/index.js"]
除了镜像体积,生产环境还有几个细节值得注意。NODE_ENV=production 一定要显式设置,它能让 Express 跳过中间件重绑定等开发期逻辑,性能提升可观。运行用户方面,默认以 root 跑容器有安全隐患,官方镜像自带 node 用户,一行 USER node 就能切换过去,但要注意目录权限,必要时先 chown。
另一个高频坑是优雅停机。容器收到停止信号时,如果 Node 进程没有正确处理 SIGTERM,Docker 等待超时后会直接 SIGKILL,正在处理的请求会被硬生生掐断。用 CMD ["node", "app.js"] 的数组形式启动(而不是 CMD node app.js 让 shell 包一层),信号才能直达 Node 进程。代码里再配合监听处理:
process.on('SIGTERM', () => {
server.close(() => {
console.log('服务已优雅关闭');
process.exit(0);
});
});
四、用 docker compose 串起本地开发环境
实际项目很少只有一个 Node 服务,通常还要连数据库、Redis 之类的中间件。写一个 docker-compose.yml 把整套环境编排起来,本地一条命令就能启动:
version: "3.8"
services:
web:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://app:secret@db:5432/mydb
depends_on:
- db
db:
image: postgres:16
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=secret
- POSTGRES_DB=mydb
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
这里有个容易踩的点:depends_on 只保证容器启动顺序,不保证数据库已经就绪。Node 应用如果在 PG 还没初始化完就去连接会直接报错,稳妥的做法是在代码里做连接重试,或者借助 healthcheck 配合 condition: service_healthy 来等待依赖真正可用。
启动和验证的常用命令:docker compose up --build 会重新构建并启动,docker compose logs -f web 跟踪应用日志,docker compose exec web sh 进入容器排查问题。构建时如果拉取基础镜像很慢,可以为 Docker daemon 配置国内镜像加速地址,或者给 Node 配置 npm 镜像源,在 Dockerfile 里加一句 RUN npm config set registry https://registry.npmmirror.com 即可,能显著缩短构建时间。
整体来看,Node 应用的 Docker 化核心就三件事:合理的分层顺序让缓存生效、选择合适的基础镜像控制体积、处理好环境变量与信号让容器 behave 得像一等公民。把这些细节照顾到,无论部署到单机还是 Kubernetes 集群,镜像都能稳定复用,整个交付流程也会顺畅很多。
Node.jsDocker镜像dockerfile修改时间:2026-09-10 18:16:44