跳转到主要内容

Nest 使用笔记

第二十三章:完整闭环——9 服务编排、Nginx 网关与上线清单

用一份 docker-compose 把 Nest + MySQL + Redis + MinIO + Mongo + RabbitMQ + Etcd + Elasticsearch + Nginx 同时拉起来,一条 docker compose up -d 让前 22 章每个中间件都在同一个网络里跑起来。然后逐个上游做冒烟巡检、谈优雅降级、给一份能上线的清单,并收尾整门课。

  • Nest
  • Docker
  • 部署

前 22 章把每一项中间件单独接进过 learnhub——MySQL、Redis、MinIO、Mongo、RabbitMQ、Etcd、Elasticsearch。但真实跑起来从不是一个一个起:你的应用同时依赖它们,部署要一键拉起整套,请求要从外部经过一个网关再到应用。这一章是整门课的收尾,做三件事:用一份 docker-compose.yml9 个服务编排到一起并验证每一条上游都通;把 Nginx 网关放在最前面承担反代、限流、TLS、静态资源;最后给一份「能上线」的清单,并把课程的心智模型收口。

先搞懂:全栈编排、网关、优雅降级

全栈编排:把应用和它依赖的所有中间件用一份 docker-compose.yml 描述出来——每个服务一份配置、共用一个 bridge 网络、用命名卷持久化数据。docker compose up -d 一条命令全部拉起,docker compose down 一条命令全部停掉。容器之间用服务名互访(mysqlredisminio……),同一个网络里 Docker 内置 DNS 把名字解析成容器 IP,代码里一个 IP 都不用写。

为什么前面还要一层 Nginx 网关:应用直接对外暴露 3000 端口也能跑,但生产里几乎没人这么干。前面挡一层 Nginx 是为了:单一入口(外部只开 80/443,应用端口不暴露)、TLS 终止(证书装在 Nginx 一处,应用不用管 HTTPS)、边缘限流(在请求进应用前先卡住恶意的 IP/接口)、隐藏应用拓扑(应用可以水平扩成多份,外面只看到 Nginx)、顺带托管前端静态资源。这一章会把 learnhub 真实在用的 nginx.conf 拆开讲。

优雅降级:learnhub 的设计哲学是「中间件挂了应用还能起」——Redis/MinIO/ES/RabbitMQ/Etcd 任一不可用,应用启动不崩,对应功能降级(搜索暂时不可用、上传报错、事件被丢弃)。这件事在 onModuleInit 里靠 try/catch 实现。代价是:故障被吞进 warn 日志,没有监控就等于不知道。这一章会把这条哲学的收益和代价都摆出来,因为它是上线清单里「监控」那条的根因。

这一章你会做出什么

  • 一条 docker compose up -d 拉起 9 服务全栈(api、mysql、redis、minio、mongo、rabbitmq、etcd、elasticsearch、nginx),并理解拓扑。
  • 看懂 learnhub 的 nginx.conf:反代 location /api/ → upstream、X-Real-IP/X-Forwarded-For、WebSocket 升级、限流、TLS 终止位置、静态资源兜底。
  • 巡检每一条上游都真的通:/api/health(应用存活)、/api/v1/posts(MySQL)、/api/v1/posts/search(ES)、/api/v1/auth/login(JWT + MySQL)、上传(MinIO)、WebSocket(chat 网关)。
  • 拿到一份上线清单:把前 22 章教过的生产加固点(bcrypt、refresh 黑名单、限流、winston、healthcheck、迁移开机自跑、非 root、secrets 走 env、备份、监控)汇成一栏。
  • 收口整门课:把「IoC + AOP 五件套 + 优雅降级」这三件事带进任何新项目。

前置:装好 Docker(含 compose v2,命令是 docker compose 不是 docker-compose);learnhub 仓库 clone 到本地。

第一步:9 服务拓扑和一份 compose

先把整张图画清楚——这是这一章后面所有巡检要对照的拓扑:

flowchart LR U["浏览器 / 客户端"] --> N["nginx :80"] N -->|"/api/ 反代"| API["learnhub-api :3000<br/>(Nest)"] API --- MySQL[("MySQL 8<br/>主库")] API --- Redis[("Redis 7<br/>缓存/排行榜")] API --- Mongo[("MongoDB 7<br/>行为日志")] API --- MinIO[("MinIO<br/>对象存储")] API ---|publish| RMQ["RabbitMQ<br/>事件队列"] API --- Etcd["Etcd<br/>配置/注册"] API --- ES[("Elasticsearch<br/>全文检索")] API --- WS["socket.io<br/>(同 :3000)"]

注意三件事:① 外部只对 Nginx 开 80,应用的 3000 可以不暴露;② 应用和所有中间件都在同一个 learnhub-net 桥接网络里,用服务名互访;③ WebSocket 和 HTTP 复用同一个 3000 端口(Nginx 靠 Upgrade 头升级),不需要单独开端口。9 份配置全在 learnhub 的 docker-compose.yml 里:

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

services:
  # ---------- 主服务:Nest API ----------
  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

几个真实项目才会注意的细节:

  • env_file + environment 双层.env 提供基础配置(MYSQL_PORT=3306JWT_SECRET=...),environment 在容器里覆盖 host 为服务名(MYSQL_HOST=mysql)。本地裸跑用 127.0.0.1,容器里改成 mysql,靠这层覆盖切换。
  • depends_on: condition: service_healthy:API 启动前等 MySQL 的 healthcheck 过——depends_on 只保证启动顺序,加 condition 才等「就绪」而不只是「容器拉起」。MySQL 的 healthcheck 是 mysqladmin ping(见下面 mysql 段),没就绪 API 不会起,避免「连不上库 → 重试 → 起不来」。
  • restart: unless-stopped:容器崩了自动重启,除非你手动 docker compose stop 过。生产基本配置。
  • healthcheck 打的是 /api/health:这个接口是 learnhub 专门给 Docker 和探针用的,下面第五步细讲。

剩下的 8 个服务,挑几个有讲头的看:

# learnhub/docker/docker-compose.yml(节选)
  # ---------- MySQL 8 ----------
  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   # 首次启动执行的初始化 SQL
    networks: [learnhub-net]
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p$$MYSQL_ROOT_PASSWORD"]
      interval: 5s
      timeout: 5s
      retries: 20

注意 $$MYSQL_ROOT_PASSWORD 是双美元——compose 里 $ 要转义,否则会被当成 compose 自己的变量插值。command: 给 MySQL 启动参数强制 utf8mb4,避免中文乱码。./mysql/init 挂到 /docker-entrypoint-initdb.d 是 MySQL 官方镜像的钩子:首次启动(数据卷为空)时按文件名顺序执行里面的 .sql,做建库之外的额外初始化;卷已有数据时不会重跑。

# 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"     # 学习用:关安全认证,走 HTTP 无账号
      ES_JAVA_OPTS: "-Xms512m -Xmx512m"   # 本机学习给 512m 堆;生产建议 2g+ 且 lock 内存
    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 启动慢,给 30s 宽限

ES 是这栈里启动最慢的——start_period: 30s 告诉 Docker「这 30 秒内 healthcheck 失败不算 unhealthy」,否则 ES 还在初始化就被 compose 判死了。xpack.security.enabled: "false" 是学习配置,生产必开。Redis、MinIO、Mongo、RabbitMQ、Etcd 段落结构一样(image + ports + volume + healthcheck),看 compose 文件就懂,不逐个贴。

networksvolumes 在文件末尾统一声明:

# learnhub/docker/docker-compose.yml(节选)
networks:
  learnhub-net:
    driver: bridge                # 桥接网络,容器间用服务名互访

volumes:
  mysql-data:
  redis-data:
  minio-data:
  mongo-data:
  rabbitmq-data:
  etcd-data:
  es-data:

每个有状态中间件一份命名卷。docker compose down 删容器后数据还在;只有 docker compose down -v 才连卷一起删(清空数据)。

注意:9 服务全栈只适合学习和本地集成测试。生产里有状态的那几个绝对不要自托管——MySQL 用云厂商的 RDS/PolarDB、Redis 用 ElastiCache/云数据库 Redis、ES 用 Elastic Cloud 或自建集群+专业运维、对象存储用 S3/OSS(MinIO 自建也行但要专门维护)。自托管这些中间件省的钱,扛不住一次数据丢失或备份没做带来的事故。把 compose 当「能跑起来的全栈模板」,生产用托管服务 + 应用镜像上 k8s 或 ECS。

第二步:起栈与健康巡检

一条命令:

cd learnhub
cp .env.example .env                       # 首次:复制环境变量样板
docker compose -f docker/docker-compose.yml up -d   # 拉起全部 9 服务

第一次拉镜像比较慢(ES、Mongo 都几百兆),起来后用 docker compose ps 看状态:

docker compose -f docker/docker-compose.yml ps

正常情况下每个服务都是 running (healthy)healthy 标记是 compose 读 healthcheck 结果给的——如果只 runninghealthy,看那个服务的日志:

docker compose -f docker/docker-compose.yml logs -f learnhub-api
docker compose -f docker/docker-compose.yml logs -f elasticsearch

learnhub-api 的日志里你会看到一系列启动信息,里面藏着几条关键 warn(如果某个中间件没起):

[learnhub-api] running migrations...
[Nest] LOG [NestApplication] Nest application successfully started

/api/health 是最轻量的存活探针,不依赖任何中间件,只表示「Node 进程还活着、能接 HTTP」:

// learnhub/src/app.controller.ts
@ApiTags('系统')
@Controller()
export class AppController {
  @Public()
  @Version(VERSION_NEUTRAL)            // 不带版本号:/api/health
  @Get('health')
  @ApiOperation({ summary: '健康检查(docker healthcheck 用)' })
  health() {
    return { status: 'ok', service: 'learnhub-api', ts: Date.now() };
  }
}

@Version(VERSION_NEUTRAL) 让这个路由不带版本号,访问的是 /api/health(不是 /api/v1/health),路径短、固定,给 docker healthcheck 和外部探针用最合适。@Public() 让它跳过全局登录守卫——探针不需要登录。

注意/api/health 只验证「进程活着」,验证「依赖都健康」。一个更生产化的 health 应该分别检查 db / redis / es 等下游并返回各项状态,配合编排工具做按依赖重启。learnhub 这里只做最薄的一层,因为优雅降级的设计已经保证了「中间件挂了应用不崩」,深入的健康检查由监控(第九步)承担。

第三步:Nginx 网关——反代、限流、TLS、静态

到这一步整个后端栈已经起来了,但应用直连 3000 端口只适合开发。learnhub 在前面挡了一层 Nginx,配置是 docker/nginx.conf

# learnhub/docker/nginx.conf
# 上游:learnhub-api 服务(compose 服务名)。
upstream learnhub_upstream {
  server learnhub-api:3000 weight=1;
  # server learnhub-api-2:3000 weight=1;   # 水平扩容时打开
  # server learnhub-api:3001 weight=1;     # 或同机多端口
}

server {
  listen 80;
  server_name _;

  access_log /var/log/nginx/learnhub-access.log;
  error_log  /var/log/nginx/learnhub-error.log;

  client_max_body_size 20m;             # 上传体大小上限(配合 multer)

  # ---- 反向代理 /api 到 Nest ----
  location /api/ {
    proxy_pass http://learnhub_upstream;
    proxy_http_version 1.1;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    # WebSocket 升级——同一端口复用
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 60s;
  }
}

逐段说为什么:

  • upstream:把后端地址抽象成一个虚拟上游 learnhub_upstream。现在是单实例 learnhub-api:3000,扩容时打开注释那一行再起一个 service,Nginx 加权轮询自动负载均衡。这层抽象的价值是 location 里只写 proxy_pass http://learnhub_upstream,扩容只改 upstream 块,应用无感。
  • proxy_set_header X-Real-IP/X-Forwarded-For:这是反代最关键的细节。请求从 Nginx 转给 Nest 后,Nest 看到的 request.ip 默认是 Nginx 的容器 IP,不是用户真实 IP。要把用户 IP 透传给应用,靠这两个头——X-Real-IP 是直连客户端 IP,X-Forwarded-For 是历史转发链路。Nest 里要拿到真实 IP 得配合 app.set('trust proxy', 1)(或对应中间件),否则限流、审计、地理统计全错。
  • proxy_set_header Upgrade/Connection "upgrade":WebSocket 握手靠 HTTP Upgrade 头从 1.1 升级到 WS 协议,Nginx 默认不透传这俩头——不加这两行,socket.io 的连接升级会失败、前端报 400/断连。learnhub 的 chat 网关就靠这个透传。
  • client_max_body_size 20m:上传文件大小上限。Nginx 默认 1m,超过直接 413——后端 multer 配得再大也没用,Nginx 先拦。这里 20m 和 multer 的限制要对齐。

server 块还有一段,托前端静态资源:

# learnhub/docker/nginx.conf(节选)
  # ---- 前端静态资源(构建后放到 /usr/share/nginx/html)----
  location / {
    root /usr/share/nginx/html;
    index index.html;
    try_files $uri $uri/ /index.html;   # SPA history 模式兜底
  }
}

try_files $uri $uri/ /index.html 是 SPA 部署的标准写法:用户刷新 /posts/123 这种前端路由时,Nginx 先找文件 → 找不到 → 全部回退到 index.html,交给前端路由器处理。这样前后端同域(都走 80 端口),/api/* 转给 Nest、/* 给前端静态——天然解决了 CORS 和跨域 cookie 问题。

网关的另外两个职责(learnhub 没启用,生产要做)

  • TLS 终止:在生产里这个 server 块加 listen 443 ssl;ssl_certificate/ssl_certificate_key,证书装在 Nginx 一处,Nest 内部还是 HTTP——证书续期、HSTS、cipher 调整全在 Nginx 这一层做,应用代码不动。learnhub 配的是 listen 80,给本地学习用,生产必须上 HTTPS
  • 边缘限流:Nginx 的 limit_req_zone + limit_req 可以在请求进应用前按 IP 或接口限流,比应用内的 Nest Throttler 更早把恶意流量挡掉,省下应用的 CPU。learnhub 的 nginx.conf 没写,因为限流在课程里是另一章的内容(应用内用 @nestjs/throttler);生产建议两层限流——Nginx 按 IP 粗粒度、应用内按用户/接口细粒度。

思考:为什么要把网关单独成一章/一层,而不是把这些活都塞进 Nest?因为职责分离——应用崩了 Nginx 还能返回 502 优雅降级页、限流挡掉的部分根本不打到应用、TLS 续期不影响业务发版。一个进程干所有事,崩了就全完。这是「让合适的人做合适的事」的工程判断,不是技术能力问题。

第四步:全栈冒烟巡检——每条上游都真的通

栈起来了、网关通了,下面挨个验证每条上游。这一步的价值是:让你看到「前 22 章每个中间件,现在都在同一个网络里协作」。

# 0. 应用存活(Nginx → Nest,无中间件)
curl http://localhost/api/health
# {"status":"ok","service":"learnhub-api","ts":...}

# 1. MySQL:列表接口(公开)
curl 'http://localhost/api/v1/posts?page=1&pageSize=5'
# {"list":[...],"total":N,"page":1,"pageSize":5}

# 2. Elasticsearch:全文检索
curl 'http://localhost/api/v1/posts/search?q=TypeORM'
# {"total":N,"rows":[{"id":"1","score":..,"source":{...},"highlight":{...}}]}

# 3. JWT + MySQL:登录拿双 token
curl -X POST http://localhost/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"admin123456"}'
# {"accessToken":"eyJ...","refreshToken":"eyJ..."}

# 4. 带 token 调需登录接口(验证守卫 + RBAC)
curl http://localhost/api/v1/auth/profile \
  -H 'Authorization: Bearer eyJ...'

注意第 1、2 步走的是 Nginx(http://localhost,80 端口),不是直连 3000——验证的是「外部 → Nginx → Nest → 中间件」整条链路。如果你在容器网络里直测,把 host 换成 learnhub-api:3000 走的是应用直连、跳过了网关。

剩下的几个中间件不是 HTTP,要单独验:

# 5. MinIO:登录管理后台
#    浏览器打开 http://localhost:9001,用 learnhub_minio / learnhub_minio_secret 登录
#    或拿预签名直传 URL:
curl -X POST http://localhost/api/v1/upload/presigned \
  -H 'Authorization: Bearer eyJ...' \
  -H 'Content-Type: application/json' \
  -d '{"filename":"test.png","contentType":"image/png"}'

# 6. RabbitMQ:管理界面
#    浏览器 http://localhost:15672 (guest/guest),看 learnhub.events 队列状态

# 7. Redis:进容器跑命令
docker exec -it learnhub-redis redis-cli
127.0.0.1:6379> KEYS *

# 8. MongoDB:看行为日志
docker exec -it learnhub-mongo mongosh -u root -p learnhub_mongo --authenticationDatabase admin
> use learnhub
> db.behavior_logs.find().limit(3)

# 9. WebSocket(socket.io 的 /chat namespace)
# 用 wscat 或前端连接 ws://localhost/chat,握手时带 token

# 10. Etcd
docker exec -it learnhub-etcd etcdctl endpoint status

到这里如果 10 条全过,说明这 9 个服务(Nest + 7 个中间件 + Nginx)在同一个 compose 网络里都通了。这是「前 22 章每个组件组装在一起」这件事第一次真实发生——旧课程只跑到 Nest+MySQL+Redis 三件套就收尾了。

第五步:优雅降级——learnhub 的哲学和它的代价

现在做一件破坏性实验:停掉 Elasticsearch,看应用会不会崩。

docker compose -f docker/docker-compose.yml stop elasticsearch
curl 'http://localhost/api/v1/posts/search?q=TypeORM'   # 搜索暂时不可用
curl 'http://localhost/api/v1/posts?page=1'             # 列表仍然正常
docker compose -f docker/docker-compose.yml start elasticsearch   # 恢复

应用没崩、列表仍然能用,只是搜索暂时报错。这不是巧合,是 learnhub 每个中间件模块都做的事——onModuleInittry/catch 吞掉连接错误:

// learnhub/src/modules/upload/minio.service.ts
async onModuleInit(): Promise<void> {
  try {
    const exists = await this.client.bucketExists(this.bucket);
    if (!exists) {
      await this.client.makeBucket(this.bucket);
      this.logger.log(`bucket 已创建:${this.bucket}`);
    }
    // 设匿名读:头像等资源可直接通过 URL 访问
    try {
      await this.client.setBucketPolicy(this.bucket, publicReadPolicy(this.bucket));
    } catch {
      /* 部分 minio 版本策略语法差异,失败忽略 */
    }
  } catch (e) {
    this.logger.warn(`MinIO 不可用,上传功能将不可用:${(e as Error).message}`);
  }
}

ES、RabbitMQ、Etcd 也都是这套:连得上就用、连不上 warn 一下、应用继续起。RabbitMQ 的发布端更是把降级延伸到运行时:

// learnhub/src/modules/amqp/amqp.service.ts
async onModuleInit(): Promise<void> {
  const url = process.env.RABBITMQ_URL || 'amqp://127.0.0.1:5672';
  try {
    const conn = await connect(url);
    this.channel = await conn.createChannel();
    await this.channel.assertQueue(QUEUE, { durable: true });
    this.logger.log(`已连 RabbitMQ,队列 ${QUEUE}(${url})`);
  } catch (e) {
    this.logger.warn(`RabbitMQ 连接失败,事件将被丢弃:${(e as Error).message}`);
  }
}

publish(type: string, payload: unknown): void {
  if (!this.channel) {
    this.logger.warn(`RabbitMQ 未就绪,丢弃事件 ${type}`);
    return;            // channel 没就绪:丢弃事件,业务请求不阻塞
  }
  const body = Buffer.from(JSON.stringify({ type, payload }));
  this.channel.sendToQueue(QUEUE, body, { persistent: true });
}

这套设计的收益很真实:单点中间件故障不会把整个应用拖死。ES 挂了只是搜索降级、MinIO 挂了只是上传报错、MQ 挂了只是事件丢失——其他功能不受影响。在 7 个中间件的环境里,这是必要的韧性。

代价同样真实:故障被吞进 warn 日志,没有监控就等于不知道。生产里如果你的 ES 挂了,应用照常起、列表照常返回,用户搜索报错来找客服,你才知道——这中间可能隔了几小时。onModuleInit 是软失败、运行时的 publish 是直接丢弃,丢失的数据(被丢的事件、没记的浏览量)在 MQ/Redis 恢复后不会补回来。

思考:如果每个中间件都软失败、应用都能起,你怎么真正知道 Redis 在生产里挂了?答案只能靠外部观测——/api/health 不够(它只看进程),要的是:① 各中间件自己的健康检查(compose 已经有了)+ 外部探针(Prometheus blackbox exporter);② 行为日志的异常模式(突然没有 cache.hit 事件了);③ 关键业务指标的告警(登录失败率、消息队列积压)。优雅降级让系统更韧,但韧性的代价是必须配观测——这就是为什么上线清单里「监控」是必选项。

第六步:上线清单——把前 22 章的加固点汇成一栏

整个课程一路过来,每个章节都散落着「生产应该这样」的提示。这一步把它们汇成一份能照着对错的清单。learnhub 是教学项目,部分项已经做、部分项留了坑让你升级——清单里标清楚。

教学项目状态生产必做
密码哈希learnhub 用 MD5 + saltauth.service.ts:31bcrypt:每用户独立 salt,慢哈希抗暴力破解
JWT 双 token + refresh 黑名单已做(access 30m + refresh 7d)refresh token 进 Redis 黑名单,登出/改密时拉黑
功能级权限 RBAC已做(@RequirePermission + 角色/权限表)补权限缓存:高频接口每次查 DB 太重,缓存进 Redis
数据级所有权已做(assertAuthor 本人或 admin)保持
限流 Throttlerlearnhub 没启用全局装 @nestjs/throttler,登录/注册接口按 IP 严限;Nginx 边缘再粗限一层
Bull 队列 + DLXlearnhub 用裸 amqplib 发布(无消费者)@nestjs/bullmq + Redis,消费失败入死信队列、可重试
结构化日志已做 winston(按天切割、分级别)加 requestId 链路追踪,error 日志接告警
健康检查已做 /api/health/api/health/ready 分项检查下游(db/redis/es)
迁移开机自跑已做(docker-entrypoint.sh + RUN_MIGRATIONS_ON_BOOT保持,但生产多副本时要加分布式锁,避免并发迁移
非 root 容器已做(DockerfileUSER node保持
secrets 走 env已做(env_file + ConfigService绝不进镜像/仓库;生产用 Secret Manager / k8s Secret 注入
数据备份learnhub 只挂卷没备份MySQL/Mongo 每日全量 + binlog 增量;ES 用 snapshot API;MinIO 跨区复制
监控部分做了(行为日志写 Mongo 做分析)上 Prometheus + Grafana,应用暴露 /metrics,关键接口埋点
TLSlearnhub Nginx 只听 80加 443 + 证书,HSTS,自动续期(certbot 或云托管证书)

注意:这张清单是「最低线」不是「上限」。每一条背后都是一次真实事故的教训——bcrypt 不是为了好看,是因为 MD5 几秒就能被爆破;refresh 黑名单不是装饰,是因为 access token 泄露后用户改密仍然无效;备份不是装样子,是因为没备份的那天凌晨硬盘坏道,你才知道「卷持久化」和「备份」是两回事(卷只防容器重建,不防磁盘故障)。

把这些项当成「上线前的 acceptance checklist」逐条过完,你的应用才算「准备好被用户用」。learnhub 把一部分故意留作练习(bcrypt、Throttler、Bull、备份、TLS),是因为做了这些会让代码复杂到不适合教学;但你的生产代码必须做

第七步:整门课收口——带走三件事

到这里整门课的实操就结束了。把 22 章浓缩成三件能带进任何新项目的心智模型:

flowchart LR A["入门启动<br/>main.ts 5 件事"] --> B["IoC + 模块化<br/>provider/consumer"] B --> C["AOP 五件套<br/>请求穿过 6 道关卡"] C --> D["数据层<br/>TypeORM/Prisma + migration"] D --> E["认证授权<br/>JWT + RBAC + 所有权"] E --> F["中间件矩阵<br/>缓存/存储/消息/搜索/注册"] F --> G["生产闭环<br/>compose + nginx + 加固"]

第一件:IoC(控制反转)。对象不自己 new 依赖,而是声明在构造函数上、由容器注入(@Injectable + providers + 构造函数参数)。这件事让单元测试能替换 mock、让模块边界清晰、让全局只有一个实例。Nest 的一切都建立在这之上。

第二件:AOP 五件套。请求穿过 Middleware → Guard → Interceptor(前) → Pipe → Handler → Interceptor(后) → ExceptionFilter。横切逻辑(日志、鉴权、参数校验、异常映射)不要塞进 Controller,各自落在对应的关卡。第十一章 JWT 鉴权在 Guard、第二章 ValidationPipe 在 Pipe、第十一章 winston 日志在 Interceptor、第二章全局异常过滤器在 Filter——你看,前 22 章几乎每个生产功能都是 AOP 某一环。

第三件:优雅降级。中间件会挂,应用不能跟着挂——onModuleInit 软失败 + 运行时 try/catch。代价是必须配观测。这条哲学贯穿了 Redis、MinIO、ES、RabbitMQ、Etcd、Mongo 的接入章节,是真实多中间件系统的生存之道。

按章节回顾你做出的东西:

阶段章节你做出的东西
入门1–6第一个 Nest 项目、参数校验、IoC、Module、AOP 五件套、文件上传
数据层7–10MySQL + SQL、TypeORM CRUD + migration、表关系、Prisma
认证/缓存11–13JWT 登录注册、RBAC 权限、Redis 缓存
文档/部署14–15Swagger 文档、Docker 多阶段镜像
实战16–17登录+文章管理小项目
进阶中间件18–22MongoDB、MinIO、RabbitMQ、Elasticsearch、Etcd
收尾239 服务全栈编排 + Nginx 网关 + 上线清单

这一章的成果

  1. 一条 docker compose up -d 拉起 9 服务全栈(api + 7 中间件 + nginx),用 docker compose ps 验证全部 healthy。
  2. 看懂 nginx.confupstream + location /api/ 反代、X-Real-IP/X-Forwarded-For 透传、WebSocket 升级、client_max_body_size 配合 multer、try_files 托管 SPA——以及为什么生产要加 TLS 和边缘限流。
  3. 巡检了 10 条链路:/api/health/api/v1/posts(MySQL)、/api/v1/posts/search(ES)、登录/profile(JWT)、MinIO 控制台、RabbitMQ 管理界面、Redis KEYS、Mongo 查询、WebSocket、Etcd——前 22 章每个组件在同一张网络里都通。
  4. 理解优雅降级的收益(中间件故障不拖死应用)和代价(必须配监控才能发现故障),并拿到一份 14 项的上线清单。
  5. 收口整门课:IoC、AOP 五件套、优雅降级三件心智模型。

常见问题

  • docker compose up 后某个中间件一直 unhealthy:看那个服务的日志(docker compose logs <service>)。常见是 ES 启动慢(给 start_period 加大)、MySQL 第一次初始化慢、Etcd 镜像在 Apple Silicon 上拉不下来(bitnamilegacy/etcd:3.5 不一定有 arm64 版本,换镜像)。
  • API 起不来、日志报 ECONNREFUSED mysql:3306:API 容器比 MySQL 先就绪了。compose 里已经用 depends_on: condition: service_healthy 处理了,如果你改过 compose 把这层去掉了,就会复现。或者 MySQL 端口冲突(本地有别的 MySQL 占了 3306)。
  • Nginx 转发到 /api/ 后 Nest 拿到的 IP 是 Nginx 容器 IP:忘了 proxy_set_header X-Real-IP / X-Forwarded-For,或 Nest 没设 trust proxy
  • WebSocket 连不上、报 400:Nginx 的 location /api/ 里少了 Upgrade/Connection 那两行头透传。
  • 上传超过 1m 报 413:Nginx 默认 client_max_body_size 是 1m,learnhub 配了 20m,如果你自己改过 conf 又改回去就会复现。
  • 搜索接口报错但其他接口正常:ES 没起或没就绪。learnhub 的 SearchService 软失败不影响应用启动,但接口调用会抛错。docker compose logs elasticsearch 看 ES 的状态。
  • docker compose down 后再起数据丢了:你是不是 down -v 了?-v 会连命名卷一起删。不带 -v 数据卷保留。

下一站

到这里整门 Nest 全栈实操课就结束了。按章节一步步做下来,你已经能独立搭出一个带登录、权限、数据库、缓存、文档、消息队列、搜索、对象存储、注册中心的完整后端,并把它编排成 9 服务的全栈。下面是几个继续深入的方向,这一节给的是路标不是深度——选一个最契合你下一步工作的展开:

  • 微服务:把单体拆成多个服务,用 @nestjs/microservices 的 TCP/Redis/NATS/gRPC transport 通信,Etcd/Nacos 做服务注册。learnhub 的 RabbitMQ 已经是「事件总线」的雏形,下一步是把消费者也变成独立服务。
  • 任务队列进阶:从 learnhub 的裸 amqplib 发布,换成 @nestjs/bullmq + Redis——拿到优先级队列、定时任务(repeatable)、死信队列(DLX)、重试和退避、Web 监控面板。这是处理耗时任务(发邮件、生成报表、视频转码)的标准方案。
  • 可观测性:上 OpenTelemetry——统一标准给 trace/metrics/logs 打点,导到 Jaeger/Prometheus/Loki。结合这一章第六步强调的「监控是优雅降级的代价」,OTel 是把那条代价付清的工具。
  • 容器编排:从 docker compose 走到 Kubernetes——Deployment、Service、Ingress、ConfigMap、Secret、StatefulSet(给有状态中间件)、HPA(自动扩缩容)。compose 适合单机,k8s 是多机生产的事实标准。
  • CI/CD:GitHub Actions / GitLab CI——push 触发测试 → 构建镜像 → 推 registry → 部署到 k8s/ECS。learnhub 有 e2e 测试 specs 可以挂进 pipeline,鼓励你也补单元测试。
  • 测试:learnhub 已经带了 e2e 测试骨架(test/ 目录),但单元测试覆盖薄。给 Service 写 jest mock 测试、给 Controller 写 e2e supertest,是验证重构不破坏行为的根本保障。

最后一条建议:把这门课做出来的 learnhub 当成你的「参考实现」——下次工作上遇到「怎么接 RabbitMQ」「JWT 黑名单怎么做」「migration 怎么管」的问题,回到对应章节看真实代码。课程结束了,但这份代码和这些模式会一直在你的工具箱里。