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.json再npm install,最后才COPY . .,源码改动不破坏依赖层,重复构建快——本章第三步落地; - 非 root 运行:以镜像自带的
node用户起进程,容器被攻破也不能直接拿 root——本章第三步落地; - 启动前跑迁移:容器入口先
migration:run再启动 Nest,schema 不对就 fail fast 退出——本章第四步落地,对应第八章的迁移工作流; - compose 编排:一张 yaml 把九个服务 + 网络 + 卷 + 健康检查 + 依赖顺序全声明完,一条命令拉起整套系统——本章第五步落地。
这一章你会做出什么
- 打开 learnhub 的
docker/Dockerfile、docker/docker-entrypoint.sh、docker/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
docker 和 docker compose 是两个命令。老的 docker-compose(带横线)是 Python 写的 v1,已废弃;现在的 docker compose(子命令形式)是 Go 写的 v2,集成进 Docker CLI,learnhub 所有命令都用它。
第二步:.dockerignore——把垃圾挡在构建上下文外
docker build 会把构建上下文(Dockerfile 所在目录或 context 指定目录)整个发给 Docker daemon。没 .dockerignore 的话,node_modules、dist、.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.sh,CMD 是默认参数 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: 块再覆盖其中几个——把 .env 里 127.0.0.1 的 host 改成服务名(mysql、redis、minio 等)。为什么要这么做?同一份 .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_on 加 condition: service_healthy 意思是:api 容器等 MySQL 健康检查通过才启动,而不是「MySQL 容器进程起来」就启动。这两个区别很大——MySQL 进程刚起来还在初始化(建库、加载权限表),这时候 api 连上去会报 ECONNREFUSED。service_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 的 test 用 mysqladmin 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: host?links 是 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 阶段装的 Linuxnode_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 docker 再 docker 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 的 STATUS 是 Up (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 就够。
这一章的成果
- 在 learnhub 的真实 Dockerfile 里吃透多阶段构建:base 共享依赖清单、builder 装全量 + 编译、production 只带产物 + 生产依赖,最终镜像不含 devDeps、源码、测试。
- 学了 Dockerfile 几条铁律:依赖清单单独一层先 install 后 copy 源码(缓存友好)、
USER node非 root、ENTRYPOINT+CMD分离可覆盖、.dockerignore把.env和node_modules挡在构建上下文外。 - 搞懂了启动迁移闸门:
docker-entrypoint.sh用RUN_MIGRATIONS_ON_BOOT控制,schema 不对 fail fast 退出,迁移幂等可重放——以及多副本场景为什么要换成 init container。 - 在九服务 compose 里看懂真实拓扑:healthcheck + depends_on 的
service_healthy条件、命名卷保数据、单 bridge 网络走服务名 DNS、env_file + environment 两道关卡注入密钥但不烤进镜像。 - 学了 dev profile:
target: builder复用构建阶段、profiles: ["dev"]不参与默认启动、绑定挂载源码 + 匿名卷屏蔽node_modules、9229 调试端口。
常见问题
- 镜像太大:检查是否漏了多阶段(devDependencies 不该进 production 镜像);基础镜像用
node:XX-alpine比node: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(learnhubpackage.json的packageManager已声明)走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 看具体配置。