5月27日 23:03

NestJS 部署到生产环境有哪些关键步骤?

从开发到生产:部署的全局视角

把一个 NestJS 应用从本地跑通到稳定上线,中间要跨越的不仅仅是"能跑起来"这么简单。生产环境面对的是真实流量、不可控的依赖服务、随时可能出现的故障——部署方案的选择直接影响应用的可用性和团队迭代效率。

这篇内容围绕 NestJS 应用的生产部署展开,从容器化打包、编排调度、CI/CD 自动化、环境配置管理、可观测性建设到弹性伸缩,把每个环节中值得关注的实践细节梳理清楚。

Docker 容器化:构建可复制的运行环境

容器化是现代部署的起点。把应用和它的依赖打包成一个不可变的镜像,消除了"我这能跑你那不行"的环境差异问题。对 NestJS 来说,多阶段构建是减少镜像体积的关键手段。

多阶段 Dockerfile

一个面向生产的 Dockerfile 应该把构建和运行分开:

dockerfile
# 构建阶段:安装全部依赖,编译 TypeScript FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # 运行阶段:只装生产依赖,复制编译产物 FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production # 非 root 用户运行,提升安全性 RUN addgroup -S appgroup && adduser -S appuser -G appgroup COPY package*.json ./ RUN npm ci --only=production COPY --from=builder /app/dist ./dist USER appuser EXPOSE 3000 CMD ["node", "dist/main.js"]

为什么用 node:20-alpine 而不是 node:20?Alpine 镜像只有约 50MB,相比完整 Debian 镜像的 350MB,体积差距明显。对于 NestJS 这类不需要原生 C++ 编译的应用,Alpine 完全够用。

npm ci 代替 npm install 的原因是:ci 严格按 package-lock.json 安装,版本完全锁定,构建结果可重复。这在 CI 环境下尤其重要。

.dockerignore 配置

text
node_modules dist .git .env *.log coverage .vscode

不要把 node_modulesdist 打进构建上下文——前者体积大且会在容器内重新安装,后者会被容器内编译覆盖。.env 文件包含敏感信息,绝对不能进镜像。

镜像构建与本地验证

bash
# 构建镜像 docker build -t nestjs-app:1.0.0 . # 本地运行验证 docker run --rm -p 3000:3000 \ -e DATABASE_HOST=host.docker.internal \ nestjs-app:1.0.0

加上 --rm 参数,容器退出后自动清理,避免本地堆积无用容器。数据库地址用 host.docker.internal 可以在开发阶段方便地连接宿主机上的数据库。

Docker Compose:本地联调与多服务编排

开发环境通常需要同时启动应用、数据库、缓存等多个服务。Docker Compose 把这些服务的启动顺序和依赖关系统一定义,一条命令就能拉起完整的本地环境。

完整的 Compose 配置

yaml
version: '3.8' services: app: build: context: . dockerfile: Dockerfile ports: - "3000:3000" environment: - NODE_ENV=development - DATABASE_HOST=db - DATABASE_PORT=3306 - DATABASE_USER=root - DATABASE_PASSWORD=password - DATABASE_NAME=nestjs - REDIS_HOST=redis - REDIS_PORT=6379 depends_on: db: condition: service_healthy redis: condition: service_started volumes: - ./src:/app/src restart: unless-stopped db: image: mysql:8.0 environment: - MYSQL_ROOT_PASSWORD=password - MYSQL_DATABASE=nestjs ports: - "3306:3306" volumes: - mysql_data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s timeout: 5s retries: 5 restart: unless-stopped redis: image: redis:7-alpine ports: - "6379:6379" restart: unless-stopped volumes: mysql_data:

这里有几个容易忽略的细节:

depends_on 配合 condition: service_healthy 确保数据库真正就绪后才启动应用,而不仅仅是容器启动。如果只用 depends_on: db,应用可能比数据库初始化先跑起来,导致连接失败。

volumes: ./src:/app/src 把源码挂载进容器,配合 NestJS 的热重载,开发时改代码不需要重新构建镜像。但这个挂载只在开发环境使用,生产镜像不挂载任何源码卷。

Kubernetes:生产级容器编排

当应用需要高可用、自动伸缩、滚动更新时,Kubernetes 是最主流的编排方案。NestJS 作为无状态应用,在 K8s 上部署相对直观,但配置细节决定稳定性。

Deployment:声明式管理应用实例

yaml
apiVersion: apps/v1 kind: Deployment metadata: name: nestjs-app labels: app: nestjs-app spec: replicas: 3 selector: matchLabels: app: nestjs-app strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 template: metadata: labels: app: nestjs-app spec: containers: - name: nestjs-app image: registry.example.com/nestjs-app:1.0.0 ports: - containerPort: 3000 env: - name: NODE_ENV value: "production" - name: DATABASE_HOST valueFrom: secretKeyRef: name: db-secret key: host - name: DATABASE_PASSWORD valueFrom: secretKeyRef: name: db-secret key: password resources: requests: memory: "256Mi" cpu: "250m" limits: memory: "512Mi" cpu: "500m" livenessProbe: httpGet: path: /health port: 3000 initialDelaySeconds: 30 periodSeconds: 10 failureThreshold: 3 readinessProbe: httpGet: path: /health port: 3000 initialDelaySeconds: 5 periodSeconds: 5 failureThreshold: 3

strategy 部分的 maxUnavailable: 0 表示滚动更新时不允许任何时刻有实例不可用——每次先启动新实例,健康检查通过后才销毁旧实例,实现零停机部署。

resources 的 requests 和 limits 必须设置。不设 limits 的容器可能占用节点全部内存导致 OOM Killer 波及其他 Pod;不设 requests 则调度器无法做出合理的节点分配决策。NestJS 应用的资源需求取决于业务复杂度,建议从 requests 256Mi/250m、limits 512Mi/500m 起步,根据监控数据逐步调优。

Service 和 Ingress:流量入口

yaml
apiVersion: v1 kind: Service metadata: name: nestjs-app-service spec: selector: app: nestjs-app ports: - protocol: TCP port: 80 targetPort: 3000 type: ClusterIP --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nestjs-app-ingress annotations: cert-manager.io/cluster-issuer: letsencrypt-prod nginx.ingress.kubernetes.io/rate-limit: "100" spec: ingressClassName: nginx tls: - hosts: - api.example.com secretName: nestjs-tls rules: - host: api.example.com http: paths: - path: / pathType: Prefix backend: service: name: nestjs-app-service port: number: 80

Service 用 ClusterIP 类型(默认值),不直接对外暴露,流量统一由 Ingress 管理。Ingress 配合 cert-manager 自动管理 TLS 证书,加上 rate-limit 注解做基础的限流保护。

CI/CD 管道:自动化构建与发布

手动部署容易出错且无法追溯。CI/CD 管道把测试、构建、发布串联成自动化流程,每次代码变更都经过完整验证后才到达生产环境。

GitHub Actions 实战配置

yaml
name: CI/CD Pipeline on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - name: Install dependencies run: npm ci - name: Run unit tests run: npm run test - name: Run e2e tests run: npm run test:e2e - name: Run lint run: npm run lint - name: Check build run: npm run build build-and-push: needs: test runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' && github.event_name == 'push' steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: | ghcr.io/${{ github.repository }}:latest ghcr.io/${{ github.repository }}:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max deploy: needs: build-and-push runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' environment: production steps: - name: Deploy to Kubernetes uses: azure/k8s-deploy@v4 with: manifests: | k8s/deployment.yaml k8s/service.yaml k8s/ingress.yaml images: | ghcr.io/${{ github.repository }}:${{ github.sha }} kubeconfig: ${{ secrets.KUBE_CONFIG }}

管道分为三个阶段,职责清晰:

test 阶段跑在每次 PR 和 main 分支推送时,验证代码质量。npm ci 保证依赖版本一致,e2e 测试确保接口行为正确。

build-and-push 只在 main 分支的 push 事件触发,构建镜像并推送到 GitHub Container Registry。镜像标签同时使用 latest 和 commit SHA,前者方便拉取最新版,后者用于精确回滚。cache-from: type=gha 利用 GitHub Actions 缓存加速 Docker 构建。

deploy 阶段通过 environment: production 配置保护规则——可以在 GitHub 仓库设置中要求审批人确认后才能部署到生产环境。部署时用 commit SHA 标签精确指定镜像版本,K8s 滚动更新自动完成实例替换。

环境变量与密钥管理

环境变量是配置管理的基石,但不同环境的管理策略差异很大。

分层配置方案

typescript
// config/configuration.ts export default () => ({ port: parseInt(process.env.PORT, 10) || 3000, database: { host: process.env.DATABASE_HOST, port: parseInt(process.env.DATABASE_PORT, 10) || 3306, username: process.env.DATABASE_USER, password: process.env.DATABASE_PASSWORD, name: process.env.DATABASE_NAME, }, jwt: { secret: process.env.JWT_SECRET, expiresIn: process.env.JWT_EXPIRES_IN || '1h', }, });
typescript
// app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import configuration from './config/configuration'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, load: [configuration], envFilePath: [`.env.${process.env.NODE_ENV}`, '.env'], validationSchema: Joi.object({ DATABASE_HOST: Joi.string().required(), DATABASE_PORT: Joi.number().default(3306), JWT_SECRET: Joi.string().required(), }), }), ], }) export class AppModule {}

validationSchema 用 Joi 校验必填变量——启动时如果缺少 DATABASE_HOSTJWT_SECRET,应用直接报错退出,而不是带着空值跑起来然后在运行时莫名其妙地失败。这种 fail-fast 策略在容器环境中尤其有价值,能被健康检查迅速捕获。

密钥的安全存储

本地开发用 .env 文件没问题,但生产环境的密钥不应该以明文存储。Kubernetes Secrets 虽然只是 Base64 编码而非加密,但配合 RBAC 权限控制和外部密钥管理服务(如 HashiCorp Vault、AWS Secrets Manager),能形成完整的密钥保护链路。

yaml
apiVersion: v1 kind: Secret metadata: name: db-secret type: Opaque stringData: host: "your-db-host.internal" port: "3306" user: "app_user" password: "s3cur3P@ssw0rd"

注意这里用 stringData 而不是 data——前者直接写明文字符串,K8s 自动做 Base64 编码;后者需要自己先编码。功能上等价,但 stringData 在编写时不容易出错。

健康检查:让编排系统了解应用状态

Kubernetes 的自愈能力依赖健康检查。如果应用没有暴露健康端点,K8s 只能根据进程是否存在来判断状态——进程活着但已经死锁的情况无法检测。

Terminus 健康检查

typescript
import { Controller, Get } from '@nestjs/common'; import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator, MemoryHealthIndicator, DiskHealthIndicator, } from '@nestjs/terminus'; @Controller('health') export class HealthController { constructor( private health: HealthCheckService, private db: TypeOrmHealthIndicator, private memory: MemoryHealthIndicator, private disk: DiskHealthIndicator, ) {} @Get() @HealthCheck() check() { return this.health.check([ () => this.db.pingCheck('database'), () => this.memory.checkHeap('memory_heap', 150 * 1024 * 1024), () => this.disk.checkStorage('storage', { thresholdPercent: 0.9, path: '/', }), ]); } }

这个端点同时检查三个维度:数据库连通性、堆内存是否接近上限(150MB)、磁盘空间是否快满。任何一项失败,健康检查返回 503,K8s 就会把该实例从 Service 后端摘除,流量不再路由到异常实例。

livenessProbereadinessProbe 的区别要注意:liveness 检测应用是否需要重启,readiness 检测应用是否可以接收流量。数据库连不上时 readiness 应该失败(不接流量但不重启),而只有应用内部死锁无法恢复时 liveness 才应该失败(触发重启)。把两者搞混会导致频繁重启或者流量打进有问题的实例。

日志与监控:生产环境的眼睛

部署不是终点,而是运维的起点。没有可观测性的生产环境就像盲飞——出了问题完全不知道发生了什么。

结构化日志

生产日志必须结构化,方便日志平台(ELK、Loki)检索和聚合:

typescript
import { WinstonModule } from 'nest-winston'; import * as winston from 'winston'; @Module({ imports: [ WinstonModule.forRoot({ transports: [ new winston.transports.Console({ format: winston.format.combine( winston.format.timestamp(), winston.format.json(), ), }), ], }), ], }) export class AppModule {}

用 JSON 格式输出到 stdout,这是容器日志的最佳实践——由日志收集器(Fluentd、Promtail)统一采集,不需要应用自己写文件。timestamp 字段确保日志时间不受采集延迟影响。

Prometheus 指标采集

typescript
import { Controller, Get } from '@nestjs/common'; import { Injectable } from '@nestjs/common'; import { makeCounterProvider, makeHistogramProvider, NestPromModule } from '@digikare/nestjs-prom'; @Module({ imports: [ NestPromModule.forRoot({ defaultMetrics: { enabled: true }, }), ], providers: [ makeCounterProvider({ name: 'http_requests_total', help: 'Total number of HTTP requests', labelNames: ['method', 'route', 'status'], }), makeHistogramProvider({ name: 'http_request_duration_seconds', help: 'HTTP request duration in seconds', labelNames: ['method', 'route'], buckets: [0.1, 0.3, 0.5, 1, 3, 5], }), ], }) export class AppModule {}

关键指标包括请求总数(按路由和状态码分类)、请求耗时分布(P50/P95/P99)。这些数据配合 Grafana 仪表板,能直观反映系统健康状况和性能瓶颈。

告警规则示例

yaml
groups: - name: nestjs-alerts rules: - alert: HighErrorRate expr: rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.05 for: 5m labels: severity: critical annotations: summary: "NestJS 5xx error rate exceeds 5%" - alert: HighLatency expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 3 for: 10m labels: severity: warning annotations: summary: "NestJS P95 latency exceeds 3 seconds"

5xx 错误率超过 5% 持续 5 分钟触发 critical 告警,P95 延迟超过 3 秒持续 10 分钟触发 warning。阈值根据业务 SLA 调整,不是固定值。

负载均衡与流量管理

多实例部署后,流量如何分发到各个实例是可用性的关键环节。

Nginx 反向代理

nginx
upstream nestjs_backend { least_conn; server nestjs-app-1:3000; server nestjs-app-2:3000; server nestjs-app-3:3000; keepalive 32; } server { listen 80; server_name api.example.com; location / { proxy_pass http://nestjs_backend; 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; proxy_http_version 1.1; proxy_set_header Connection ""; } }

least_conn 策略把新请求分配给当前连接数最少的后端,比默认的轮询更适合请求耗时不均匀的场景。keepalive 32 维持与后端的 32 个长连接,避免每次请求都重新建 TCP 连接。proxy_http_version 1.1Connection "" 是 Nginx 与后端保持长连接的必要配置,很多人漏掉。

云平台负载均衡

在 AWS 上用 ALB 时,Target Group 的健康检查路径设为 /health,检查间隔建议 10 秒,不健康阈值设为 3 次。 deregistration_delay 设为 60 秒——实例从 Target Group 移除后等待 60 秒才断开连接,确保正在处理的请求能正常完成。

弹性伸缩与故障恢复

HPA 自动伸缩

yaml
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: nestjs-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: nestjs-app minReplicas: 2 maxReplicas: 10 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 - type: Pods pods: metric: name: http_requests_per_second target: type: AverageValue averageValue: "100" behavior: scaleUp: stabilizationWindowSeconds: 60 policies: - type: Pods value: 2 periodSeconds: 60 scaleDown: stabilizationWindowSeconds: 300

伸缩策略不只是看 CPU——加了自定义指标 http_requests_per_second,每秒 100 请求就扩容。behavior 配置了扩容窗口 60 秒(快速响应流量增长)、缩容窗口 300 秒(避免流量抖动时反复缩容扩容),每次最多扩 2 个 Pod。

数据库备份与恢复

bash
#!/bin/bash set -euo pipefail DATE=$(date +%Y%m%d_%H%M%S) BACKUP_DIR="/backups" DATABASE="nestjs" mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" "$DATABASE" \ --single-transaction \ --quick \ | gzip > "$BACKUP_DIR/db_backup_$DATE.sql.gz" # 保留最近 7 天的备份 find "$BACKUP_DIR" -name "db_backup_*.sql.gz" -mtime +7 -delete # 上传到对象存储 aws s3 cp "$BACKUP_DIR/db_backup_$DATE.sql.gz" \ s3://your-backup-bucket/mysql/

--single-transaction 保证 InnoDB 备份的一致性而不锁表。set -euo pipefail 让脚本在任何命令失败时立即退出,避免静默失败。备份不仅要留本地,还要上传到对象存储做异地容灾。

部署策略选型

不同场景适合不同的发布策略,理解它们的差异才能做出正确选择:

滚动更新(K8s 默认):逐步替换旧实例。优点是简单无需额外资源,缺点是新旧版本短暂共存,如果有数据库 schema 不兼容变更可能出问题。

蓝绿部署:同时维护两套完整环境,切换流量瞬间完成。优点是回滚极快,缺点是资源成本翻倍。

金丝雀发布:先让少量流量到新版本,观察无误后逐步放大比例。优点是风险可控,缺点是需要流量管理能力(Istio、Nginx Ingress canary annotation)。

对 NestJS 应用来说,API 版本管理比部署策略更基础——如果接口做到了向后兼容,滚动更新就够了;如果有破坏性变更,金丝雀发布是更稳妥的方案。

从开发到生产的检查清单

在点下部署按钮之前,确认这些事项:

  • 环境变量通过密钥管理服务注入,没有硬编码或明文存储
  • Docker 镜像使用多阶段构建,非 root 用户运行
  • 健康检查端点就绪,liveness 和 readiness 探针配置正确
  • CI 管道覆盖单元测试、集成测试和构建验证
  • 日志以 JSON 格式输出到 stdout,由收集器统一处理
  • Prometheus 指标采集就绪,关键告警规则已配置
  • HPA 最小副本数大于 1,保证单实例故障不影响可用性
  • 数据库备份脚本经过恢复演练验证
  • 回滚方案明确:kubectl rollout undo 或切换镜像标签
  • Ingress 配置了 TLS 和基础限流

这套部署体系的核心思路是:每个环节都有自动化保障,每个故障都有检测和恢复手段。容器化保证环境一致性,编排系统保证可用性,CI/CD 保证发布可追溯,可观测性保证问题可定位。把这些拼起来,就是一个经得起生产考验的 NestJS 部署方案。

标签:NestJS