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.yml 把 9 个服务编排到一起并验证每一条上游都通;把 Nginx 网关放在最前面承担反代、限流、TLS、静态资源;最后给一份「能上线」的清单,并把课程的心智模型收口。
先搞懂:全栈编排、网关、优雅降级
全栈编排:把应用和它依赖的所有中间件用一份 docker-compose.yml 描述出来——每个服务一份配置、共用一个 bridge 网络、用命名卷持久化数据。docker compose up -d 一条命令全部拉起,docker compose down 一条命令全部停掉。容器之间用服务名互访(mysql、redis、minio……),同一个网络里 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
先把整张图画清楚——这是这一章后面所有巡检要对照的拓扑:
注意三件事:① 外部只对 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=3306、JWT_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 文件就懂,不逐个贴。
networks 和 volumes 在文件末尾统一声明:
# 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 结果给的——如果只 running 没 healthy,看那个服务的日志:
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 握手靠 HTTPUpgrade头从 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 每个中间件模块都做的事——onModuleInit 里 try/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 + salt(auth.service.ts:31) | 换 bcrypt:每用户独立 salt,慢哈希抗暴力破解 |
| JWT 双 token + refresh 黑名单 | 已做(access 30m + refresh 7d) | refresh token 进 Redis 黑名单,登出/改密时拉黑 |
| 功能级权限 RBAC | 已做(@RequirePermission + 角色/权限表) | 补权限缓存:高频接口每次查 DB 太重,缓存进 Redis |
| 数据级所有权 | 已做(assertAuthor 本人或 admin) | 保持 |
| 限流 Throttler | learnhub 没启用 | 全局装 @nestjs/throttler,登录/注册接口按 IP 严限;Nginx 边缘再粗限一层 |
| Bull 队列 + DLX | learnhub 用裸 amqplib 发布(无消费者) | 接 @nestjs/bullmq + Redis,消费失败入死信队列、可重试 |
| 结构化日志 | 已做 winston(按天切割、分级别) | 加 requestId 链路追踪,error 日志接告警 |
| 健康检查 | 已做 /api/health | 加 /api/health/ready 分项检查下游(db/redis/es) |
| 迁移开机自跑 | 已做(docker-entrypoint.sh + RUN_MIGRATIONS_ON_BOOT) | 保持,但生产多副本时要加分布式锁,避免并发迁移 |
| 非 root 容器 | 已做(Dockerfile 里 USER node) | 保持 |
| secrets 走 env | 已做(env_file + ConfigService) | 绝不进镜像/仓库;生产用 Secret Manager / k8s Secret 注入 |
| 数据备份 | learnhub 只挂卷没备份 | MySQL/Mongo 每日全量 + binlog 增量;ES 用 snapshot API;MinIO 跨区复制 |
| 监控 | 部分做了(行为日志写 Mongo 做分析) | 上 Prometheus + Grafana,应用暴露 /metrics,关键接口埋点 |
| TLS | learnhub Nginx 只听 80 | 加 443 + 证书,HSTS,自动续期(certbot 或云托管证书) |
注意:这张清单是「最低线」不是「上限」。每一条背后都是一次真实事故的教训——bcrypt 不是为了好看,是因为 MD5 几秒就能被爆破;refresh 黑名单不是装饰,是因为 access token 泄露后用户改密仍然无效;备份不是装样子,是因为没备份的那天凌晨硬盘坏道,你才知道「卷持久化」和「备份」是两回事(卷只防容器重建,不防磁盘故障)。
把这些项当成「上线前的 acceptance checklist」逐条过完,你的应用才算「准备好被用户用」。learnhub 把一部分故意留作练习(bcrypt、Throttler、Bull、备份、TLS),是因为做了这些会让代码复杂到不适合教学;但你的生产代码必须做。
第七步:整门课收口——带走三件事
到这里整门课的实操就结束了。把 22 章浓缩成三件能带进任何新项目的心智模型:
第一件: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–10 | MySQL + SQL、TypeORM CRUD + migration、表关系、Prisma |
| 认证/缓存 | 11–13 | JWT 登录注册、RBAC 权限、Redis 缓存 |
| 文档/部署 | 14–15 | Swagger 文档、Docker 多阶段镜像 |
| 实战 | 16–17 | 登录+文章管理小项目 |
| 进阶中间件 | 18–22 | MongoDB、MinIO、RabbitMQ、Elasticsearch、Etcd |
| 收尾 | 23 | 9 服务全栈编排 + Nginx 网关 + 上线清单 |
这一章的成果
- 一条
docker compose up -d拉起 9 服务全栈(api + 7 中间件 + nginx),用docker compose ps验证全部 healthy。 - 看懂
nginx.conf:upstream+location /api/反代、X-Real-IP/X-Forwarded-For透传、WebSocket 升级、client_max_body_size配合 multer、try_files托管 SPA——以及为什么生产要加 TLS 和边缘限流。 - 巡检了 10 条链路:
/api/health、/api/v1/posts(MySQL)、/api/v1/posts/search(ES)、登录/profile(JWT)、MinIO 控制台、RabbitMQ 管理界面、Redis KEYS、Mongo 查询、WebSocket、Etcd——前 22 章每个组件在同一张网络里都通。 - 理解优雅降级的收益(中间件故障不拖死应用)和代价(必须配监控才能发现故障),并拿到一份 14 项的上线清单。
- 收口整门课: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 怎么管」的问题,回到对应章节看真实代码。课程结束了,但这份代码和这些模式会一直在你的工具箱里。