Docker 入门:容器化你的应用

同一份前端代码,在我本地跑得好好的,一推到测试环境就报 node-gyp 编译失败,再到生产又是另一副面孔。根子几乎都指向同一件事:三套环境的 Node 版本、系统库、依赖装法各不相同。Docker 值得掌握的第一个理由,就是把这份“运行环境”本身也变成可交付的产物——开发机、测试、生产都拉同一份镜像跑,那些“我本地可以”的争论就少了大半。

我们现在的构建语境是 Node 20 LTS 加上 Docker Engine 23 之后默认开启 BuildKit。如果项目还停在更早的 Node 或 Docker,下面提到的镜像 tag 和 BuildKit 开关都要按实际环境往回调,别照抄。

真正容易被表面现象带偏的,是把容器当成“小虚拟机”。它更轻,但轻的代价是更依赖宿主机内核和构建规范。容器只是把进程和它的文件系统、依赖一起隔离打包,内核仍然是宿主机那一个,并不像虚拟机那样自带一份独立内核。这个区别在跨架构场景下会直接变成报错:在 M1 芯片的 Mac 上构建出来的镜像默认是 arm64,传到一台 amd64 架构的服务器上运行,会直接报 exec format error——因为容器共享宿主机内核,架构对不上,压根跑不起来。跨架构的事,要么用 docker buildx build --platform linux/amd64 显式指定目标架构,要么干脆在和生产同架构的机器上构建。

几个概念先理顺再动手

照着命令敲很容易,但排查问题时真正救命的是把几个名词的关系摆清楚。一份 Dockerfile 是构建脚本,docker build 按里面的指令一条条执行,产出的是镜像(Image)——一个只读的模板。镜像本身不跑,docker run 把它实例化成容器(Container)才是真正运行的进程。容器是临时的,删掉就没了,所以凡是需要活过容器生命周期的数据,比如数据库文件,都要放进数据卷(Volume)里持久化。构建好的镜像可以推到仓库(Registry),Docker Hub 或公司内部私有仓库都行,其他环境再从仓库把它拉下来跑。

一句话串起来:Dockerfile 构建出镜像,镜像运行成容器,容器要留存的数据放进卷,镜像推到仓库供各环境拉取。后面几乎所有的坑,都能落回到这条链路上的某一环。

创建一个简单的 Dockerfile

以下是一个简单的 Node.js 应用的 Dockerfile:

1FROM node:20-alpine
2WORKDIR /app
3COPY package*.json ./
4RUN npm ci --omit=dev
5COPY . .
6EXPOSE 3000
7CMD [ "node", "server.js" ]

这里有几个细节:

  • WORKDIR 设置容器内工作目录
  • 先复制 package*.json 再安装依赖,可以更好利用构建缓存
  • npm ci 更适合 CI 和镜像构建,因为它严格依赖锁文件
  • --omit=dev 可以避免把开发依赖装进生产镜像

如果项目没有锁文件,就不能直接使用 npm ci。这时要么补齐锁文件,要么退回 npm install,但生产构建最好保持依赖可复现。

基础镜像也别图省事直接写 FROM node:latestlatest 指向的版本会随时间变化,不同机器、不同时间拉到的可能是不同的大版本号,构建出来的行为很容易不一致,排查起来很费时间。镜像 tag 一定要写死,比如 node:20.11-alpine,甚至在严肃的生产环境里可以用 digest 锁死(node:20-alpine@sha256:...),这样无论谁、什么时候构建,拿到的都是同一个底座。

alpine 镜像确实小,但它用的是 musl libc 而不是 glibc。大多数纯 JS 项目没问题,可一旦依赖里有需要本地编译的原生模块(比如老版本的 node-gypsharpbcrypt),在 alpine 上经常缺编译工具链报错。我的经验是:能用预编译二进制就尽量用,实在不行就在 alpine 上补一句 RUN apk add --no-cache python3 make g++,或者干脆换成体积大一点但兼容性更好的 node:20-slim(基于 Debian)。别为了几十兆的镜像体积,把构建稳定性搭进去。

.dockerignore 很重要

构建镜像时,Docker 会把当前目录作为构建上下文发送给 Docker daemon。如果没有 .dockerignore,很多无关文件也会进入上下文,导致构建慢、镜像脏,甚至泄露敏感文件。

常见 .dockerignore

1node_modules
2dist
3.next
4.git
5.env
6npm-debug.log
7Dockerfile
8docker-compose.yml

.env 这类文件尤其要注意。不要把本地密钥、数据库密码、第三方 token 打进镜像。

镜像分层和缓存

Dockerfile 里的每条指令大致都会形成一层。构建缓存会按顺序复用,如果前面的层变了,后面的层也要重新构建。

这就是为什么常见写法会先复制依赖声明文件:

1COPY package*.json ./
2RUN npm ci --omit=dev
3COPY . .

只要 package.json 和锁文件不变,依赖安装层就可以复用。业务代码变了,只需要重新执行后面的复制和启动相关层。

如果一开始就 COPY . .,任何源码变化都可能导致依赖层缓存失效,构建会慢很多。

这个差别在本地可能感觉不强烈,但放到 CI 里就很明显。我们项目早期没注意顺序,每次提交(哪怕只改一个 README)CI 都要重新 npm ci 一遍,一次构建四五分钟。把 COPY package*.json ./ 提到前面之后,只要依赖没动,这一层直接命中缓存,构建时间降到一分钟以内。

不过 CI 环境每次都是干净的容器,本地的层缓存带不过去,所以光调整顺序还不够。后来我们用了 BuildKit 的缓存挂载,把 npm 的下载缓存单独挂出来:

1# syntax=docker/dockerfile:1
2FROM node:20-alpine
3WORKDIR /app
4COPY package*.json ./
5RUN --mount=type=cache,target=/root/.npm \
6    npm ci --omit=dev
7COPY . .

--mount=type=cache 让 npm 的下载缓存在多次构建之间复用,即便上层缓存失效需要重装依赖,包也不用重新从网络拉一遍。要用它得开启 BuildKit;Docker Engine 23 之后的常见环境基本已经默认走 BuildKit,老版本则要显式设置 DOCKER_BUILDKIT=1。在网络不稳的内网 CI 上,这一招省下来的时间相当可观。

构建和运行 Docker 容器

构建镜像:

1docker build -t my-node-app .

运行容器:

1docker run -p 3000:3000 my-node-app

-p 3000:3000 的意思是把宿主机的 3000 端口映射到容器内的 3000 端口。前一个是宿主机端口,后一个是容器端口。

如果要传环境变量,可以这样:

1docker run -p 3000:3000 -e NODE_ENV=production my-node-app

不要把生产密钥写死在 Dockerfile 里。镜像应该尽量通用,环境差异通过运行时配置注入。

调试的时候我最常用的是直接进容器里看一眼:

1# 拿到正在运行的容器 ID
2docker ps
3# 进去开个 shell(alpine 没有 bash,用 sh)
4docker exec -it <container_id> sh
5# 实时看日志
6docker logs -f <container_id>

容器起来就秒退是常见的排查场景,docker ps 这时根本看不到它,因为它已经挂了。这种情况要用 docker ps -a 看全部容器,再 docker logs 把它退出前打印的那几行错误捞出来——容器退出不代表日志没了,只要别急着把它删掉。更直接的排查方式是先把启动命令换成 sh 让容器"先活着",进去手动跑一遍真正的启动命令,错误信息往往就一目了然了。

另外提一句资源:容器默认不限制内存,Node 应用如果内存泄漏会把整台宿主机拖垮。线上我都会带上 --memory--cpus

1docker run -p 3000:3000 --memory=512m --cpus=1 -e NODE_ENV=production my-node-app

这样即使某个容器失控,影响也被框在它自己那一份配额里。

多阶段构建

前端项目常见场景是构建阶段需要完整依赖,运行阶段只需要静态文件或最小 Node 环境。可以用多阶段构建减少最终镜像体积:

1FROM node:20-alpine AS build
2WORKDIR /app
3COPY package*.json ./
4RUN npm ci
5COPY . .
6RUN npm run build
7
8FROM nginx:alpine
9COPY --from=build /app/dist /usr/share/nginx/html
10EXPOSE 80

第一阶段负责安装依赖和构建,第二阶段只保留构建产物和运行环境。这样最终镜像不会包含源码、开发依赖和构建工具。

如果是 Next.js、Nuxt 或 Node 服务,就要根据项目运行方式调整运行阶段,不一定都适合 Nginx。

拿一个需要常驻 Node 进程的服务举例,运行阶段我一般会再独立出来,只把生产依赖和构建产物拷进去,顺便换成非 root 用户跑:

1FROM node:20-alpine AS build
2WORKDIR /app
3COPY package*.json ./
4RUN npm ci
5COPY . .
6RUN npm run build
7
8FROM node:20-alpine AS runner
9WORKDIR /app
10ENV NODE_ENV=production
11COPY package*.json ./
12RUN npm ci --omit=dev
13COPY --from=build /app/dist ./dist
14# 别用 root 跑应用
15USER node
16EXPOSE 3000
17CMD ["node", "dist/server.js"]

这里 USER node 是个容易被忽略但很值得做的细节。容器默认以 root 身份运行进程,万一应用被攻破,攻击者拿到的就是容器内的 root,逃逸的风险更大。node 官方镜像已经内置了一个非特权的 node 用户,直接用就行。

效果上,多阶段构建对体积的优化是实打实的。我们一个 Next.js 项目,单阶段镜像差不多 1.2G(带着源码、devDependencies、构建缓存全在里面),改成多阶段后压到 200M 出头,推拉镜像快了一大截,部署回滚也更利索。

Next.js 这类框架还要看它自己的产物模式。比如开启 standalone 输出后,运行阶段可以只拷贝 .next/standalone.next/staticpublic,不用把整个仓库塞进镜像。前端容器化不是把本地目录完整搬进容器,而是只带运行时真正需要的东西。

1FROM node:20-alpine AS runner
2WORKDIR /app
3ENV NODE_ENV=production
4COPY --from=build /app/.next/standalone ./
5COPY --from=build /app/.next/static ./.next/static
6COPY --from=build /app/public ./public
7USER node
8EXPOSE 3000
9CMD ["node", "server.js"]

这类写法要跟框架版本和构建配置对应,不要照抄。关键思路是:构建阶段可以很重,运行阶段要尽量薄。

Docker Compose

对于多容器应用,可以使用 Docker Compose 来定义和运行多个 Docker 容器。

1version: '3'
2services:
3  web:
4    build: .
5    ports:
6      - "3000:3000"
7  db:
8    image: mongo
9    volumes:
10      - ./data:/data/db

Compose 更适合本地开发和测试环境,把应用、数据库、缓存等服务放到一份配置里。上面的例子还可以补上环境变量和命名数据卷:

1services:
2  web:
3    build: .
4    ports:
5      - "3000:3000"
6    environment:
7      NODE_ENV: development
8    depends_on:
9      - db
10  db:
11    image: mongo
12    volumes:
13      - mongo-data:/data/db
14
15volumes:
16  mongo-data:

这里的 volumes 能保证数据库数据不会因为容器删除就丢失。

depends_on 这里要泼一盆冷水:它只保证 db 容器先启动,不保证数据库已经准备好接收连接。数据库容器刚启动时往往还在做初始化,这时 web 容器如果已经在尝试连接,很容易连接被拒、直接崩溃退出。真要等服务“健康”,得配 healthcheck,再让依赖方等这个健康状态:

1services:
2  web:
3    build: .
4    ports:
5      - "3000:3000"
6    depends_on:
7      db:
8        condition: service_healthy
9  db:
10    image: mongo
11    volumes:
12      - mongo-data:/data/db
13    healthcheck:
14      test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
15      interval: 10s
16      timeout: 5s
17      retries: 5
18
19volumes:
20  mongo-data:

更稳妥的做法其实是让应用自己具备重连能力——外部依赖什么时候掉、什么时候恢复,本来就不该假设。健康检查只是把启动期的竞态收敛掉,运行期的容错还得靠应用自己。

日常开发我用得最多的几条 Compose 命令:docker compose up -d 后台拉起,docker compose logs -f web 跟某个服务的日志,改完代码 docker compose up -d --build 重新构建,收工 docker compose down(加 -v 会把命名卷一起删掉,数据库数据要清空时才用,别手滑)。

健康检查和优雅退出

生产容器最好暴露健康检查。它不是给人看的,而是给编排平台判断“这个容器是否还能接流量”用的。

1HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
2  CMD wget -qO- http://localhost:3000/health || exit 1

应用里也要处理退出信号。Docker 停容器时会先发 SIGTERM,如果进程不处理,可能正在处理的请求直接断掉。

1process.on('SIGTERM', async () => {
2  await closeServer();
3  process.exit(0);
4});

前端静态站点可能不需要复杂退出逻辑,但 Node 服务、SSR 应用、BFF 服务都应该考虑。容器化之后,部署和重启更频繁,优雅退出会直接影响用户请求是否被打断。

镜像安全不要等上线后再补

镜像越小,攻击面通常越小,但“小”不等于“安全”。基础镜像要定期更新,CI 里最好跑一次漏洞扫描,至少知道当前镜像里有哪些高危依赖。

我还会注意几件事:

  • 不在镜像里写死密钥
  • 不把 .git、本地配置和测试数据打进去
  • 使用非 root 用户运行
  • 只开放应用真正需要的端口
  • 给镜像打明确版本 tag,不用 latest 当生产发布依据

这些都不是高级技巧,但它们能挡住很多低级事故。

常见边界

Docker 入门时容易踩几个坑:

  • node_modules 从宿主机复制进镜像,导致平台不兼容
  • 忘记 .dockerignore,构建上下文巨大
  • 把密钥写进镜像,造成安全风险
  • 容器里写入的数据没有挂载卷,重建后数据丢失
  • 镜像里同时包含构建工具和运行环境,体积过大

还有一个常见误解是“用了 Docker 就一定生产稳定”。Docker 解决的是环境一致性和交付方式,应用本身的日志、健康检查、错误处理、资源限制、监控告警仍然要单独设计。

Docker 的核心链路是:写 Dockerfile,构建镜像,运行容器,再通过 Compose 或部署平台组织多个服务。

对前端和 Node 项目来说,真正值得掌握的是镜像分层、构建缓存、环境变量、数据卷和多阶段构建。命令可以查,但那份把 node-gyp 编译失败甩在测试环境、上线又换一副面孔的运行环境本身,才是 Docker 真正要打包封死的东西。