跳转到主要内容

Nest 使用笔记

第十五章:Docker 部署——多阶段镜像、entrypoint 迁移闸门、九服务 compose

打开 learnhub 的 Dockerfile / docker-entrypoint.sh / docker-compose.yml,在真实代码里讲透多阶段构建为什么这么分、迁移闸门为什么放在启动前、九服务拓扑为什么用一张 compose。每段代码都能在 learnhub 里指到对应文件。

  • Nest
  • Docker
  • 部署

前面几章 learnhub 的服务是直接在本地 npm run start:dev 跑起来的,背后要连 MySQL / Redis / MinIO / Mongo / RabbitMQ / etcd / Elasticsearch 七个中间件,全靠 docker compose up mysql redis ... 在后台顶着。这一章把 learnhub 的整个部署链路打开——多阶段 Dockerfile、entrypoint 迁移闸门、九服务 compose——讲清楚每个生产镜像为什么长这样。旧版这一章只演示了最小 Dockerfile,这次直接上 learnhub 真在用的那套。

先搞懂:Docker 是什么 / 为什么需要 / 企业级怎么用

Docker 是什么:容器化部署工具,把「应用 + 依赖 + 运行环境」一起打包成镜像,容器跑镜像。同一份镜像在任何装了 Docker 的机器上跑出来的结果完全一致。隔离靠 Linux namespace + cgroups,比虚拟机轻量、秒级启动,一台机器能跑几十上百个容器。

镜像 vs 容器:镜像是静态的分层文件,容器是镜像跑起来的活进程。一个镜像可以起 N 个容器。类比:镜像是类(class),容器是实例(new)。

为什么需要它:「本地能跑线上报错」的环境差异、Node 版本不一致、少装依赖、部署流程不统一。用 Docker 后部署从「登服务器装环境」变成 docker compose up -d 一条命令,可复现、可回滚、可横向扩。判断标准:环境复杂 / 多服务 / 要求一致性 → 上 Docker;一次性脚本、单机玩具 → 没必要。

企业级怎么用(下面每一条,这一章都会在 learnhub 里真做一遍,不是空头支票):

  • 多阶段构建:builder 阶段装全量依赖 + 编译,production 阶段只带产物 + 生产依赖,最终镜像小、不漏 devDeps——本章第三步落地;
  • 分层缓存:先 COPY package.jsonnpm install,最后才 COPY . .,源码改动不破坏依赖层,重复构建快——本章第三步落地;
  • 非 root 运行:以镜像自带的 node 用户起进程,容器被攻破也不能直接拿 root——本章第三步落地;
  • 启动前跑迁移:容器入口先 migration:run 再启动 Nest,schema 不对就 fail fast 退出——本章第四步落地,对应第八章的迁移工作流;
  • compose 编排:一张 yaml 把九个服务 + 网络 + 卷 + 健康检查 + 依赖顺序全声明完,一条命令拉起整套系统——本章第五步落地。

这一章你会做出什么

  • 打开 learnhub 的 docker/Dockerfiledocker/docker-entrypoint.shdocker/docker-compose.yml,逐段讲清楚它为什么这么写。
  • 在真实代码里吃透:多阶段构建(builder vs production 的分层与复用)、entrypoint 迁移闸门RUN_MIGRATIONS_ON_BOOT)、compose 编排(healthcheck / depends_on / named volume / 单 bridge 网络 / env_file)。
  • 跑通从 build 到 up 的完整部署链路,区分生产 api 和 dev profile 的两种用法。

前置:装好 Docker(下一步),learnhub 仓库已拉到本地能打开看。

第一步:装 Docker 并确认能跑

docker.com 装 Docker Desktop(Mac / Windows)或 Docker Engine(Linux)。终端能跑通这两条就说明装好了:

docker -v                # Docker version 24.x.x
docker compose version   # Docker Compose version v2.x.x

dockerdocker compose 是两个命令。老的 docker-compose(带横线)是 Python 写的 v1,已废弃;现在的 docker compose(子命令形式)是 Go 写的 v2,集成进 Docker CLI,learnhub 所有命令都用它。

第二步:.dockerignore——把垃圾挡在构建上下文外

docker build 会把构建上下文(Dockerfile 所在目录或 context 指定目录)整个发给 Docker daemon。没 .dockerignore 的话,node_modulesdist.git、本地 .env 全会被打包发给 daemon——又慢又危险(.env 里的密码可能跟着进镜像)。learnhub 的写法:

# learnhub/.dockerignore
node_modules
dist
.git
.gitignore
*.log
coverage
.env
.env.*
!.env.example
README.md

几个细节值得讲:

  • node_modules 必须忽略——容器里要按 Linux 重新 npm install,把本机 macOS 装的 node_modules 拷进去跑不起来(原生模块的二进制对不上平台)。
  • .env / .env.* 全忽略,但 !.env.example! 取反保留——样板文件留着方便构建期跑测试,真实密钥绝不进镜像。
  • dist 忽略:构建阶段会自己 npm run build 生成新的,本地的旧 dist 不该污染镜像。

注意.dockerignore 是按「构建上下文根目录」生效的。learnhub 的 Dockerfile 在 docker/Dockerfile,但 compose 里 context: ..(项目根),所以 .dockerignore 必须放在项目根才生效。放错位置等于没忽略,密钥照旧被拷进镜像。

第三步:多阶段 Dockerfile——builder 与 production 各管一段

这是和「写个能跑的 Dockerfile」差距最大的一节。learnhub 的 Dockerfile 三个 stage:base(公共底)、builder(全量依赖 + 编译)、production(精简运行)。一段一段拆。

公共底:base,只放依赖清单

# learnhub/docker/Dockerfile
FROM node:24-alpine AS base
WORKDIR /usr/src/app
# 仅复制依赖清单,利用 Docker 层缓存
COPY package.json package-lock.json* ./
RUN npm config set fund false && npm config set audit false

base 只做两件事:定工作目录、复制 package.json + package-lock.json关键点:base 不装依赖,它只把依赖清单放进镜像,由后面的 builder 和 production 各自决定怎么装。为什么不在这层直接 npm install?因为 builder 要全量(含 devDependencies 才能 nest build),production 只要生产依赖,两边需求不同,让它们各自继承 base 再分别装。

node:24-alpine 是基于 alpine 的精简镜像(约 50MB),比 node:24(约 350MB)小一截;package-lock.json* 后面的 * 是 glob——万一项目用 yarn/pnpm 没这个文件也不报错。npm config set fund false 关掉「请赞助」提示,audit false 关掉漏洞扫描——两个都会写 stderr,干扰构建日志。

builder:装全量依赖 + 编译

# learnhub/docker/Dockerfile
FROM base AS builder
RUN npm install
COPY . .
RUN npm run build

builder 继承 base(拿到 package.json),先 npm install 装全量依赖(含 devDependencies 里的 typescript、@nestjs/cli),再 COPY . . 把源码拷进来,最后 npm run build(即 nest build)编译输出到 dist/为什么全量:编译要 TypeScript,devDependencies 不装就编译不了。

production:只带产物 + 生产依赖 + 非 root

# learnhub/docker/Dockerfile
FROM base AS production
ENV NODE_ENV=production
RUN npm install --omit=dev
COPY --from=builder /usr/src/app/dist ./dist
COPY docker/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# winston 日志目录(按日切割写文件)
RUN mkdir -p logs && chown -R node:node /usr/src/app
# 以非 root 运行(node 镜像自带 node 用户)
USER node
EXPOSE 3000
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "dist/main.js"]

production 也继承 base(package.json 在),但 npm install --omit=dev 只装生产依赖——devDependencies(typescript、jest、ts-node 等)一概不带,最终镜像小一半还多。然后 COPY --from=builder /usr/src/app/dist ./dist 从 builder 阶段只拷贝 dist/ 过来——源码、node_modules 里的 devDependencies、测试文件、tsconfig.tsbuildinfo,全留在 builder 阶段,不进 production 镜像。这就是多阶段构建的核心收益:编译需要的工具不污染运行镜像

最后几行是生产细节:COPY docker/docker-entrypoint.sh 把启动脚本拷进来(第四步细讲);mkdir logs && chown -R node:node 建 winston 日志目录并把整个 /usr/src/app 改属主给 node 用户;USER node 切换到 node 用户(node:alpine 镜像自带这个非 root 用户)——之后 ENTRYPOINT / CMD 都以 node 身份跑。为什么非 root:容器和宿主机共享内核,容器内的 root 在某些配置下等价于宿主机 root;进程被攻破时非 root 能限制爆破半径,是生产镜像的标配。

ENTRYPOINT + CMD 的组合写法:ENTRYPOINT 固定是 docker-entrypoint.shCMD 是默认参数 node dist/main.js——脚本最后一行 exec "$@" 把 CMD 当命令启动。这套写法的好处是参数可覆盖:docker run learnhub-api node dist/other.js 就能改启动命令而不用动 entrypoint。

注意(铁律):分层缓存能不能命中,看的是「这一层的输入有没有变」。learnhub 的顺序是 base 的 COPY package.json → builder 的 npm install → builder 的 COPY . .npm run build。你改一行业务代码,前两层(package.json / npm install)输入没变,缓存命中跳过;只有 COPY . .npm run build 重跑。反过来如果把 COPY . . 写在 npm install 前面,每次改源码都把依赖层连带 bust,重新 npm install 一遍——慢得离谱。这是 Dockerfile 写法的头号铁律,记住「依赖清单在前、源码在后」就行。

注意:多阶段最终镜像里不该有 source map 和 devDependencies——它们只在编译期有价值,运行期是负担(占体积、还可能泄露源码)。检查方式:docker run --rm learnhub-api ls node_modules/typescript——查不到说明 typescript 没漏进 production 镜像。

思考:为什么不直接在 production 阶段也 npm install 全量、跑 npm run build、再删掉 devDependencies?——一是麻烦(要手动 prune),二是镜像层是只读的,删文件不会让镜像变小(上一层的数据还在分层 tar 里)。多阶段构建用「从零开始的另一个 stage」干净地拿到「只有这些文件」的镜像,是 Docker 官方推荐做法。

第四步:entrypoint 迁移闸门——schema 不对就别启动

容器跑起来前,learnhub 强制先跑一遍数据库迁移。这个闸门放在启动脚本里:

# learnhub/docker/docker-entrypoint.sh
#!/bin/sh
# 容器启动入口:可选地先跑 migration 再启动 Nest
# RUN_MIGRATIONS_ON_BOOT=true 时执行 migration:run(基于编译后的 data-source.js)
set -e

if [ "$RUN_MIGRATIONS_ON_BOOT" = "true" ]; then
  echo "[learnhub] running migrations..."
  node node_modules/typeorm/cli.js migration:run -d dist/data-source.js || {
    echo "[learnhub] migration failed, exiting.";
    exit 1;
  }
fi

echo "[learnhub] starting app: $@"
exec "$@"

逻辑很直白:环境变量 RUN_MIGRATIONS_ON_BOOT=true 时,先用编译后的 dist/data-source.js(第八章讲过这份独立 data-source 的来由)跑一遍 migration:run——只执行没跑过的迁移,跑过的跳过;然后 exec "$@" 把控制权交给 CMD(默认 node dist/main.js)启动 Nest。set -e 保证脚本里任何一行非零退出立刻终止。迁移失败时 || { ... exit 1; } 让容器直接退出,编排器看到非零退出码会重启(compose 里 restart: unless-stopped),但不会让带着旧 schema 的应用起来。

为什么放在启动前?因为 schema 和代码是强耦合的——新版代码引用了新列,库里没这列,启动后第一个查询就炸。迁移闸门保证「代码和库结构一致」这个前置条件在应用启动前已满足。这就是 fail fast:与其启动后才发现 schema 不对、报一堆诡异错误,不如启动前就拦下。

思考:为什么不把迁移做成独立的「一次性 job」(起个临时容器跑完就退出),而是塞进每个应用容器的启动流程?两条理由——一是方便:一张 compose、一条命令整套起来,不用额外编排;二是幂等migration:run 只跑 pending 迁移,多次执行无副作用,重复跑不会重复建表。代价是多副本竞争:生产环境同一个服务起 N 个副本,全部同时跑迁移会撞(TypeORM 的 migrations 表有锁,但不是所有 ORM 都这么稳)。真正的生产做法是单独的 init container 或加分布式锁先跑一次迁移,再起业务容器。learnhub 是教学项目单副本,闸门放 entrypoint 足够;多副本场景留给你上线时自己进化。

第五步:docker-compose.yml——九服务拓扑的完整声明

learnhub 的中间件不止 MySQL——Redis、MinIO、Mongo、RabbitMQ、etcd、Elasticsearch 全要,加上 api 本身和前面的 Nginx,一共九个服务(外加一个 dev profile 容器)。这张 compose 把它们全声明了:网络、卷、健康检查、依赖顺序、环境变量一键搞定。先看 api 本身:

# learnhub/docker/docker-compose.yml
name: learnhub

services:
  learnhub-api:
    build:
      context: ..
      dockerfile: docker/Dockerfile
    container_name: learnhub-api
    restart: unless-stopped
    env_file:
      - ../.env
    environment:
      - NODE_ENV=production
      # 容器内用服务名访问中间件(覆盖 .env 里的 127.0.0.1,桥接网络)
      - MYSQL_HOST=mysql
      - REDIS_HOST=redis
      - MINIO_ENDPOINT=minio
      - MONGO_URI=mongodb://root:learnhub_mongo@mongo:27017/learnhub?authSource=admin
      - RABBITMQ_URL=amqp://rabbitmq:5672
      - ETCD_HOSTS=http://etcd:2379
      - ES_NODE=http://elasticsearch:9200
    ports:
      - "3000:3000"
    depends_on:
      mysql:
        condition: service_healthy
    networks: [learnhub-net]
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 20s

逐段拆。build 块告诉 compose 怎么构建镜像:context: .. 是项目根(Dockerfile 里 COPY . . 拷的就是这个目录),dockerfile: docker/Dockerfile 指向 Dockerfile 相对 context 的位置。restart: unless-stopped 是崩溃自动重启策略——进程挂了自动拉起,但 docker compose down 主动停掉的不会拉起。

环境变量两道关卡env_file: ../.env 把整个 .env 文件作为环境变量灌进来(基础值);environment: 块再覆盖其中几个——把 .env127.0.0.1 的 host 改成服务名(mysqlredisminio 等)。为什么要这么做?同一份 .env 既给本地裸跑(host = 127.0.0.1)用,又给容器(host = 服务名)用,容器里 compose 用 environment: 覆盖掉,省得维护两份 .env

注意(铁律):密钥绝不烤进镜像。docker build 时构建上下文里没有 .env(被 .dockerignore 挡了),镜像里也就没有密钥;密钥是 docker compose up 时通过 env_file 运行时注入容器的。这样同一个镜像能跑测试、预发、生产——只是各自挂不同的 .env。这是镜像可移植性的核心,记成「镜像里只放代码不放密钥」即可。

depends_on + healthcheck:等「READY」不是等「起来」

# learnhub/docker/docker-compose.yml(learnhub-api 段)
    depends_on:
      mysql:
        condition: service_healthy

depends_oncondition: service_healthy 意思是:api 容器等 MySQL 健康检查通过才启动,而不是「MySQL 容器进程起来」就启动。这两个区别很大——MySQL 进程刚起来还在初始化(建库、加载权限表),这时候 api 连上去会报 ECONNREFUSEDservice_healthy 等 MySQL 自己的 healthcheck(下面)通过,才认为它就绪。

MySQL 服务段:

# learnhub/docker/docker-compose.yml(mysql 段)
  mysql:
    image: mysql:8.0
    container_name: learnhub-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-learnhub_root_2024}
      MYSQL_DATABASE: ${MYSQL_DATABASE:-learnhub}
      MYSQL_USER: ${MYSQL_USER:-learnhub}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD:-learnhub_2024}
      TZ: Asia/Shanghai
    command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql
      - ./mysql/init:/docker-entrypoint-initdb.d:ro
    networks: [learnhub-net]
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p$$MYSQL_ROOT_PASSWORD"]
      interval: 5s
      timeout: 5s
      retries: 20

healthcheck 的 testmysqladmin ping 探活——MySQL 能响应这个命令才算就绪。interval: 5s 每 5 秒探一次,timeout: 5s 单次超时,retries: 20 连续 20 次失败才算不健康(也就是 MySQL 启动后最多等 100 秒)。$$ 是 compose 里转义 $ 的写法——$MYSQL_ROOT_PASSWORD 在容器内由 docker 展开。

${MYSQL_ROOT_PASSWORD:-learnhub_root_2024} 是 compose 的变量替换:从启动 compose 的 shell 环境读 MYSQL_ROOT_PASSWORD,没设就用 learnhub_root_2024 兜底。实际上 compose 还会自动读 .env 文件作为这些变量的来源,所以兜底值只是双保险。

注意depends_on(不带 condition)只等「容器进程启动」,不等「中间件能服务」。比如 MySQL 进程跑起来了但还在建库,depends_on: mysql 直接放行,应用连上去就炸。所以一定要配 healthcheck + condition: service_healthy,让 depends 等的是「能服务」而不是「进程在」。区别就这一行 condition,效果天差地别。

命名卷:数据跨重启存活

# learnhub/docker/docker-compose.yml(mysql volumes 段)
    volumes:
      - mysql-data:/var/lib/mysql
      - ./mysql/init:/docker-entrypoint-initdb.d:ro

mysql-data:/var/lib/mysql 把容器内 /var/lib/mysql(MySQL 数据目录)挂到名为 mysql-data 的命名卷上——容器删了重建,卷还在,数据不丢。./mysql/init:/docker-entrypoint-initdb.d:ro绑定挂载(bind mount,宿主机路径:容器路径):把宿主机的初始化 SQL 目录挂给 MySQL,MySQL 首次启动时会自动跑这些 .sql / .sh(learnhub 用它做创建库、灌种子的兜底)。

compose 最底部声明所有命名卷:

# learnhub/docker/docker-compose.yml
volumes:
  mysql-data:
  redis-data:
  minio-data:
  mongo-data:
  rabbitmq-data:
  etcd-data:
  es-data:

每个有状态服务一个卷。docker compose down 默认不删卷,docker compose down -v 才删——后者是「彻底清空数据」的核武器,正式环境慎用。

单 bridge 网络:服务名互访

# learnhub/docker/docker-compose.yml(每服务里)
    networks: [learnhub-net]

# 文件底部
networks:
  learnhub-net:
    driver: bridge

所有服务都挂在同一个 bridge 网络 learnhub-net 上。Docker 自带 DNS——同一网络内,容器之间可以用服务名互相访问:api 连 MySQL 写 mysql:3306,连 Redis 写 redis:6379,不用关心 IP。这就是上面 environment:MYSQL_HOST=mysql 的来历——mysql 是服务名,DNS 自动解析到 MySQL 容器的当前 IP。

思考:为什么不用老的 links: 或把所有服务都 network_mode: hostlinks 是 compose v2 之前的写法,现在服务名自带 DNS 不需要它;host 网络模式放弃隔离、容器内端口和宿主机混在一起、跨平台不一致(Mac / Windows 的 Docker Desktop 跑在虚拟机里,host 模式拿不到真正的宿主机网络)。bridge + 服务名 DNS 是 compose 的标准做法,干净、可移植。

剩下的服务:Redis / MinIO / Mongo / RabbitMQ / etcd / ES / Nginx

每个的模式都一样:官方镜像 + 必要环境变量 + 卷 + healthcheck + 挂同一网络。挑两个有讲究的看:

# learnhub/docker/docker-compose.yml(elasticsearch 段)
  elasticsearch:
    image: elasticsearch:8.11.1
    container_name: learnhub-elasticsearch
    restart: unless-stopped
    environment:
      discovery.type: single-node
      xpack.security.enabled: "false"
      ES_JAVA_OPTS: "-Xms512m -Xmx512m"
    ports:
      - "9200:9200"
    volumes:
      - es-data:/usr/share/elasticsearch/data
    networks: [learnhub-net]
    healthcheck:
      test: ["CMD-SHELL", "curl -fs http://localhost:9200 >/dev/null || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 20
      start_period: 30s

ES 几个关键点:discovery.type: single-node 跳过集群发现(学习用单节点);xpack.security.enabled: "false" 关掉安全认证(仅学习——生产一定要开 TLS + 账号);ES_JAVA_OPTS: "-Xms512m -Xmx512m" 限制 JVM 堆 512MB(学习机扛不住默认值,生产建议 2g+ 并 lock 内存);start_period: 30s 给 ES 30 秒「启动宽限期」,这段时间健康检查失败不算 unhealthy——ES 启动慢,没这个宽限期 compose 会以为它挂了。

# learnhub/docker/docker-compose.yml(nginx 段)
  nginx:
    image: nginx:stable-alpine
    container_name: learnhub-nginx
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on: [learnhub-api]
    networks: [learnhub-net]

Nginx 这里没加 condition,只 depends_on: [learnhub-api]——意思是 api 容器进程起来就放行,不等 api 健康检查通过(Nginx 是反向代理,后端不健康它也能跑、上游不可用时返回 502,自己不需要等 api ready)。./nginx.conf 用绑定挂载而不是命名卷——配置文件改完 docker compose restart nginx 即可生效,不用重建镜像。:ro 是 read-only,容器内不能改这个文件。Nginx 的具体配置第十六章细讲。

注意:为什么 learnhub 把九个服务塞进一张 compose,而不是拆成几张?因为这是教学项目,目标是 docker compose up -d 一条命令把整套系统拉起来,谁想学谁就能跑。真实生产通常拆分:状态服务(MySQL / Redis / ES)走托管(云数据库 / RDS),无状态服务(api / nginx)走 k8s 或独立 compose;中间件不和应用部署耦合。learnhub 把所有东西放一起是为了学习便利,不是生产推荐架构。

第六步:api-dev profile——挂源码热重载,两种用法切换

learnhub 除了生产 api,还声明了一个开发专用的 learnhub-api-dev

# learnhub/docker/docker-compose.yml
  learnhub-api-dev:
    build:
      context: ..
      dockerfile: docker/Dockerfile
      target: builder            # 只构建到 builder 阶段(带全量依赖)
    container_name: learnhub-api-dev
    profiles: ["dev"]            # 不在默认启动范围
    env_file:
      - ../.env
    environment:
      - NODE_ENV=development
      - MYSQL_HOST=mysql
      # ...其余 host 覆盖同 learnhub-api
    command: npm run start:dev   # 覆盖 CMD:跑热重载而不是 node dist/main.js
    volumes:
      - ../:/usr/src/app         # 宿主机源码挂进容器
      - /usr/src/app/node_modules  # 匿名卷:屏蔽宿主机的 node_modules
    ports:
      - "3000:3000"
      - "9229:9229"              # Node 调试端口
    depends_on:
      mysql:
        condition: service_healthy
    networks: [learnhub-net]

几个值得讲的差异:

  • target: builder:只构建到 Dockerfile 的 builder 阶段(带 devDependencies、有 typescript),不进 production 阶段。开发需要全量依赖。
  • profiles: ["dev"]:profile 是 compose v2 的特性——带 profile 的服务默认不会docker compose up 启动,要显式 docker compose --profile dev up。这样生产部署 docker compose up -d 不会把 dev 容器也拉起来。
  • command: npm run start:dev:覆盖 Dockerfile 的 CMD,跑 nest start --watch(热重载)而不是 node dist/main.js
  • volumes: ../:/usr/src/app:把项目根目录绑定挂载到容器工作目录,宿主机改代码容器内立刻看到——配合热重载,等于「在容器里跑但用宿主机 IDE 改代码」。
  • /usr/src/app/node_modules(匿名卷):这条没有冒号左边,是匿名卷——目的是屏蔽宿主机的 node_modules(可能是 macOS 装的),让容器用自己 builder 阶段装的 Linux node_modules。这是「绑定挂载源码 + 容器依赖」的标准技巧,没这条容器会拿宿主机 node_modules 跑,原生模块二进制对不上。
  • 9229:9229:Node Inspector 调试端口,VSCode 连上能在容器里打断点单步调试(第一章提过,后面会展开)。

思考:本地开发为什么还要把 Nest 也容器化,而不是 npm run start:dev 裸跑?两条理由——一是环境一致:开发、测试、生产全跑在 node:24-alpine 上,避免「本地能跑容器里不行」;二是接中间件方便:dev 容器直接挂在 learnhub-net 上,连 MySQL / Redis 用服务名,和未来生产部署的网络拓扑一致。但开发体验上热重载、断点调试都得跟得上,否则不如裸跑。learnhub 的 dev profile 就是为此设计。

第七步:build 和 up——把整套系统拉起来

cd learnhub
cp .env.example .env           # 先准备环境变量,按需改密码
docker compose -f docker/docker-compose.yml build api   # 构建 api 镜像
docker compose -f docker/docker-compose.yml up -d       # 后台拉起所有默认服务
docker compose -f docker/docker-compose.yml ps          # 看每个容器状态

-f docker/docker-compose.yml 是因为 compose 文件不在项目根;也可以 cd dockerdocker compose up -d。第一次会拉一堆基础镜像(mysql / redis / minio / mongo / rabbitmq / etcd / elasticsearch / nginx),慢一些;之后秒起。

up -d 完看状态:

NAME                       STATUS                   PORTS
learnhub-api               Up (healthy)             0.0.0.0:3000->3000/tcp
learnhub-mysql             Up (healthy)             0.0.0.0:3306->3306/tcp
learnhub-redis             Up                       0.0.0.0:6379->6379/tcp
...(其余中间件)

api 的 STATUSUp (healthy) 说明 healthcheck 通过——/api/health 返回 200,整个链路通了。看启动日志能确认迁移闸门跑了:

docker compose -f docker/docker-compose.yml logs api | head -30
# [learnhub] running migrations...
# query: SELECT * FROM `informations_schema`...
# [learnhub] starting app: node dist/main.js
# [Nest] LOG [NestApplication] Nest application successfully started

running migrations... 就是 entrypoint 的输出,紧接着是 typeorm 跑的 SQL,然后才启动 Nest。验证 api 健康:

curl http://localhost:3000/api/health
# {"status":"ok"}

日常运维命令:

docker compose -f docker/docker-compose.yml logs -f api    # 跟随 api 日志
docker compose -f docker/docker-compose.yml restart api    # 重启某个服务
docker compose -f docker/docker-compose.yml down           # 停并删容器(保留卷)
docker compose -f docker/docker-compose.yml down -v        # 同时删卷(清空数据,慎用)

注意down 删容器不删卷;down -v 把命名卷也删掉,所有数据库数据、ES 索引、MinIO 文件全没。本地学习想从零开始(比如重新灌种子)才用 -v;日常停机用 down 就够。

这一章的成果

  1. 在 learnhub 的真实 Dockerfile 里吃透多阶段构建:base 共享依赖清单、builder 装全量 + 编译、production 只带产物 + 生产依赖,最终镜像不含 devDeps、源码、测试。
  2. 学了 Dockerfile 几条铁律:依赖清单单独一层先 install 后 copy 源码(缓存友好)、USER node 非 root、ENTRYPOINT + CMD 分离可覆盖、.dockerignore.envnode_modules 挡在构建上下文外。
  3. 搞懂了启动迁移闸门docker-entrypoint.shRUN_MIGRATIONS_ON_BOOT 控制,schema 不对 fail fast 退出,迁移幂等可重放——以及多副本场景为什么要换成 init container。
  4. 在九服务 compose 里看懂真实拓扑:healthcheck + depends_on 的 service_healthy 条件、命名卷保数据、单 bridge 网络走服务名 DNS、env_file + environment 两道关卡注入密钥但不烤进镜像。
  5. 学了 dev profile:target: builder 复用构建阶段、profiles: ["dev"] 不参与默认启动、绑定挂载源码 + 匿名卷屏蔽 node_modules、9229 调试端口。

常见问题

  • 镜像太大:检查是否漏了多阶段(devDependencies 不该进 production 镜像);基础镜像用 node:XX-alpinenode:XX 小几百 MB;.dockerignore 别漏 node_modules / dist / .git
  • 改了代码镜像没变docker build / docker compose build 必须重跑才会重新打包。开发用 dev profile(挂源码热重载)或裸跑 start:dev,不要每次改代码都 build。
  • npm install:Dockerfile 里 npm config set registry https://registry.npmmirror.com/ 换国内源;或用 pnpm(learnhub package.jsonpackageManager 已声明)走 pnpm install --prod 更快更省空间。
  • api 启动报 ECONNREFUSED mysql:3306:MySQL 还在初始化就放行了——检查 api 的 depends_on.mysql.condition: service_healthy 有没有写、MySQL 的 healthcheck 配对没配。
  • down 后数据还在吗:在。down 只删容器不删命名卷;想清空数据要 down -v(不可逆)。
  • 密钥进了镜像怎么办:立刻把密钥从镜像里清掉、重新 build;已经推到镜像仓库的密钥视为泄露,轮换(重新生成新密钥)才是正路,光删镜像不够。learnhub 的 .dockerignore 已经把 .env 挡在构建上下文外,构建期根本读不到。

下一章讲 Nginx——用 Nginx 做反向代理,把前端静态资源 + 后端接口统一到一个入口,并了解负载均衡。这一章里 Nginx 已经在 compose 里作为九服务之一跑起来了,下一章打开它的 nginx.conf 看具体配置。