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:latest。latest 指向的版本会随时间变化,不同机器、不同时间拉到的可能是不同的大版本号,构建出来的行为很容易不一致,排查起来很费时间。镜像 tag 一定要写死,比如 node:20.11-alpine,甚至在严肃的生产环境里可以用 digest 锁死(node:20-alpine@sha256:...),这样无论谁、什么时候构建,拿到的都是同一个底座。
alpine 镜像确实小,但它用的是 musl libc 而不是 glibc。大多数纯 JS 项目没问题,可一旦依赖里有需要本地编译的原生模块(比如老版本的 node-gyp、sharp、bcrypt),在 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 \ 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 /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 /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/static 和 public,不用把整个仓库塞进镜像。前端容器化不是把本地目录完整搬进容器,而是只带运行时真正需要的东西。
1FROM node:20-alpine AS runner 2WORKDIR /app 3ENV NODE_ENV=production 4COPY /app/.next/standalone ./ 5COPY /app/.next/static ./.next/static 6COPY /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 \ 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 真正要打包封死的东西。