惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

L
LangChain Blog
Security Latest
Security Latest
P
Proofpoint News Feed
GbyAI
GbyAI
PCI Perspectives
PCI Perspectives
博客园 - Franky
N
Netflix TechBlog - Medium
博客园_首页
WordPress大学
WordPress大学
K
Kaspersky official blog
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Vercel News
Vercel News
T
Threatpost
The Hacker News
The Hacker News
H
Help Net Security
S
Securelist
Recent Announcements
Recent Announcements
腾讯CDC
T
Tailwind CSS Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Engineering at Meta
Engineering at Meta
C
Cisco Blogs
V
V2EX
C
Check Point Blog
S
Schneier on Security
Cyberwarzone
Cyberwarzone
C
Cybersecurity and Infrastructure Security Agency CISA
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
B
Blog RSS Feed
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Jina AI
Jina AI
M
MIT News - Artificial intelligence
T
Threat Research - Cisco Blogs
博客园 - 叶小钗
A
Arctic Wolf
AWS News Blog
AWS News Blog
Latest news
Latest news
Martin Fowler
Martin Fowler
Recorded Future
Recorded Future
Last Week in AI
Last Week in AI
The GitHub Blog
The GitHub Blog
小众软件
小众软件
B
Blog
aimingoo的专栏
aimingoo的专栏
C
Cyber Attacks, Cyber Crime and Cyber Security
V
Visual Studio Blog
P
Palo Alto Networks Blog
Spread Privacy
Spread Privacy

掘金

Win 安装Claude Code FastAPI 的 CORSMiddleware 跨域中间件 Java 自研 ReAct Agent 半年后,我用 LangGraph 验证了这些设计取舍 🚀AI编程工作流终极形态:GitNexus!零Token消耗实现代码知识图谱化!让Claude Code和Codex拥有上帝视角彻底告别盲目改代码,复杂项目重 LeetCode 72. 编辑距离:动态规划经典题解 被The Graph的GraphQL查询坑了三天,我用一个真实DeFi项目把链上数据索引彻底搞懂了 (AI) 编写简单 AI 助手 (ds-agent) 别再让 pnpm 跟着 nvm 跑了!独立安装终极指南 Claude Code 为什么这么顺?Anthropic 最新复盘:真正撑住它的不是模型,而是缓存 从 /simplify 指令深挖 Claude Code 多 Agent 协同机制 Function-Calling与工具使用 新手上路(六):Claude code装上ECC全家桶:38 个子代理、156 个技能、生产级 Hooks 与 Rules 体系 我在 Claude、Kimi、opencode 三个 AI 之间搭了一条自动协作管道 【技能篇】OpenClaw Skill 详解:给 AI 装上"专业外挂" wagmi v2 多链钱包切换:一个 Uniswap 仿盘项目让我踩了三天坑 两周浅学 RAG 我把 Python re 模块比喻成摸金手套 新手上路(三):Claude Code Skills 装了一堆没用?20+ 个 Skill 横向对比 + 三套组合方案,按需抄 K2.6、DeepSeek V4、GPT-5.5 都来了,组合拳打起来 Claude Code 进阶之路:从记忆系统到子代理编排 [java] 编译之后的记录类(Record Classes)长什么样子(上) 国产大模型能力大比拼,社区有话说 我研读了 500 个 Spring Boot 生产级代码库,90% 都犯了这 7 个致命错误 JAVA重点难点 转发-中央网信办部署开展“清朗·整治AI应用乱象”专项行动 合同同步逻辑 【合并已排序数组的三种实现策略,哪一种更可取?】 30天减20斤挑战:少一斤发100红包(2) 我竟然被JavaScript的隐式类型转换坑了三天! 二十五.Electron 初体验与进阶 本地到生产,解决 AI 全栈最后一公里——构建&部署&运维 程序员创业半年:顺的事、不顺的事,和我一直没想清楚的事 UI组件库elementplus 像使用 Redis 一样操作 LocalStorage 向量检索的流程是怎样的?Embedding 和 Rerank 各自的作用? LangChain DeepAgents 速通指南(七)—— DeepAgents使用Agent Skill 为什么越来越多的大厂抛弃MCP,转向CLI? 【节点】[SquareRoot节点]原理解析与实际应用 juejin.cn juejin.cn 从 “存得下” 到 “算得快”:工业物联网需要新一代时序数据平台越来越多工业用户开始意识到一个问题:**数据是存下来了, - 掘金 放弃 Claude 订阅?我用 8 年前的服务器,强跑 Google 最强开源模型 Gemma 4 真实测评! Python开发者狂喜!200+课时FastAPI全栈实战合集,10大模块持续更新中🔥 从 Claw-Code 看 AI 驱动的大型项目开发:2 人 + 10 个自治 Agent 如何产出 48K 行 Rust 代码 秒级创建实例,火山引擎 Milvus Serverless 让 AI Agent 开发更快更省火山引擎MilvusSer MediaPlayer 播放器架构:NuPlayer 的 Source/Decoder/Renderer 三驾马车 juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn OrbStack:一键将你的 Mac 变为本地服务器 NginxPulse:Nginx日志监控革命!实时洞察Web流量与安全态势的智能利器引言:当Nginx日志成为运维的“数 - 掘金 juejin.cn 大V说’AI替代不了你’,但现实是——用AI的人正在替代你2026年是AI落地的元年,自从Claude Code爆火之后 - 掘金 juejin.cn 你以为是技术问题,其实是流程问题:工程效率的真相引言 在软件工程领域,效率问题始终是团队管理者和工程师们关注的焦点。当项 - 掘金 大模型工程三驾马车:Prompt Engineering、Context Engineering 与 Harness Engineering 深度解析 juejin.cn 4.响应式系统基础:从发布订阅模式的角度理解 Vue3 的数据响应式原理本文从发布订阅模式的核心思想出发,深入剖析了 V - 掘金 慌了!Android 17 取消图标文字,你的 App 可能要找不到了用户终于可以隐藏桌面图标下面的文字了。 这个功能在 juejin.cn 我用 AI 搓了一个"比谁更持久"的微信小游戏,AI实现只用了一天,微信审核却用了一个月!!!起因:一个沙雕想法的诞生 - 掘金 juejin.cn 第12章 工具(Tools)与函数调用(LangChain实战)在前几章中,我们搭建的RAG系统、对话链,核心能力局限 - 掘金 juejin.cn CmComposeUI —— 基于 Kotlin Multiplatform Compose 的 UI 组件库 Android 开发的 AI coding 与 AI debugging在目前整个行业都在大规模使用 AI coding juejin.cn juejin.cn juejin.cn juejin.cn 一文搞懂Harness Engineering与Meta-Harness 越用越强不是广告语:拆解 Hermes Agent 的三层学习机制 P2G-Python字符串方法完全指南-split、join、strip、replace的Python编程利器 AI 周刊【2026.04.06-04.12】:Anthropic 藏起最强模型、AI 社会矛盾激化、"欢乐马"登顶 从 AI Skills 学实战技能(六):让 AI 帮你总结网页、PDF、视频 关于10年工作经验的程序员对OpenClaw的实战经验分享以及看法 详解 karpathy 的 microgpt:实现一个浏览器运行的 gpt 不用 Tailscale:3 步把 Mac mini 通过 FRP 暴露到公网(稳定开机自启) P2B-Python可迭代对象完全指南-从列表到生成器的Python编程利器 手把手带你部署本地模型,让你Token自由(小白专属) juejin.cn 10分钟掌握 JSON-RPC 协议,面试加分、设计不踩坑 ReAct:让大模型学会边想边做 聊聊AI的发展史,AI的爆发并不是偶然 Python的列表推导式里藏了个坑,差点让我加班到凌晨 重排、重绘与合成——浏览器渲染性能的底层逻辑 podman与docker的区别和生产环境最佳实践 juejin.cn ConcurrentHashMap线程安全实现原理全解析 juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn OpenAI Codex深度解析:终端里的AI代码特工,一个指令重构整个项目 UE5.6 Cesium 插件编译踩坑记录(UE 5.6 + MSVC 14.38 + CMake 3.31)
从本地开发到生产部署:用 Docker Compose 跑通 NestJS、MySQL 与 Milvus
swipe · 2026-05-23 · via 掘金

本文不是一篇只介绍 Docker 概念的笔记,而是一篇围绕真实项目展开的部署教程。我们会基于当前项目 nest-dockerfile-test 的实际源码,讲清楚如何用 Docker Compose 提升本地开发效率,以及如何把 NestJS 服务、MySQL 数据库一起编排成一套可运行的生产环境。

这篇文章默认读者已经会写一点 JavaScript 或 TypeScript,但不要求你系统学过 Docker、Dockerfile、Docker Compose 或 NestJS 部署。读完之后,你应该能理解下面几件事:

  • 为什么后端项目不能只在本机 pnpm run start:dev 跑起来就算完成。
  • Docker 镜像、容器、数据卷、端口映射、容器网络分别解决什么问题。
  • 本地开发环境为什么通常只用 Compose 启动数据库和中间件,而业务代码仍然在宿主机热更新运行。
  • 生产环境为什么要把业务服务也打成镜像,并让业务容器通过容器名访问数据库容器。
  • 当前项目的 Dockerfile、docker-compose.dev.ymldocker-compose.prod.yml 每一段配置在做什么。
  • 新手如何从零执行命令,把项目在本地开发模式和生产部署模式都跑通。

本文基于当前源码,而不是抽象模板。项目里已经包含:

  • NestJS 11 后端服务。
  • TypeORM + MySQL 的书籍 CRUD 接口。
  • 一个 /books 静态管理页面。
  • 本地开发用的 docker-compose.dev.yml
  • 生产部署用的 docker-compose.prod.yml
  • 用来构建 NestJS 镜像的 Dockerfile
  • 用于镜像上下文裁剪的 .dockerignore

需要提前说明:当前项目的 Dockerfile 是单阶段构建,不是多阶段构建。单阶段写法更容易让新手理解完整流程,但生产环境里可以进一步优化镜像体积。本文会先按当前实现讲清楚,再在后面补充生产优化建议。

一、为什么后端项目一定要认真处理本地环境和部署环境

很多新手第一次写 NestJS、Express 或 Spring Boot 项目时,最熟悉的启动方式是:

pnpm install
pnpm run start:dev

只要控制台没有报错,浏览器能访问接口,就会觉得项目已经跑通了。但真实项目的问题通常不在“代码能不能启动”,而在“代码依赖的环境能不能稳定复现”。

一个稍微完整一点的后端项目,通常不会只有一个 HTTP 服务。它可能还会依赖:

  • MySQL:保存用户、订单、书籍、权限、配置等核心业务数据。
  • Redis:做缓存、分布式锁、验证码、会话、短期记忆。
  • Elasticsearch:做关键词检索和日志检索。
  • Milvus:做向量检索,常见于 RAG、知识库和语义搜索。
  • MinIO:做对象存储,保存文件、图片、语音、模型产物。
  • 消息队列:做异步任务、削峰填谷、事件解耦。

在 AI 应用开发里,这种依赖会更明显。一个 RAG 系统可能会同时用到 MySQL 存业务元数据,Milvus 存向量,MinIO 存原始文档,Redis 存会话和任务状态。Agent 系统也经常需要数据库、中间件、任务队列和模型服务一起协同。也就是说,后端代码只是整个系统中的“调度层”,真正支撑业务运行的是代码、数据库和中间件组成的一整套环境。

如果没有 Docker Compose,本地开发会遇到几个典型问题。

第一,环境安装成本高。每个新同事都要手动安装 MySQL、配置端口、建数据库、设置字符集,再安装 Milvus 依赖的 etcd 和 MinIO。只要某一步版本不一致,后面就可能出现奇怪问题。

第二,环境状态不可控。有人本地 MySQL 是 8.x,有人是 9.x;有人 root 密码是 admin,有人是 123456;有人端口是 3306,有人因为冲突改成了 3307。项目启动脚本看起来一样,实际连接到的环境却完全不同。

第三,部署方式和开发方式割裂。本地开发时服务连的是 localhost,上服务器后服务运行在容器里,localhost 就变成了容器自己。如果开发时没有理解容器网络,生产环境最常见的错误就是业务容器一直报 ECONNREFUSED 127.0.0.1:3306

Docker 和 Docker Compose 的价值就在这里:它们把环境从“靠人手动配置”变成“靠配置文件声明”。一个团队只要维护好 Compose 文件,新人可以用一条命令启动一组依赖服务,服务器也可以用一条命令拉起业务服务和数据库。

二、先把几个 Docker 核心概念讲明白

在正式看配置前,先把 Docker 里最容易混淆的几个概念讲清楚。新手学习 Docker 最大的问题不是命令记不住,而是不知道每个概念在系统链路里承担什么职责。

1. 镜像:应用运行环境的只读模板

镜像可以理解成一个打包好的运行模板。比如 mysql:latest 是 MySQL 的镜像,里面包含 MySQL 服务需要的程序和默认文件结构。node:24-alpine 是 Node.js 的镜像,里面有 Node 运行时和 Alpine Linux 基础环境。

镜像本身不会运行,它只是一个静态产物。你可以把它理解成“安装包 + 基础系统 + 默认配置”的组合。

当前项目的 Dockerfile 就是为了把 NestJS 项目构建成一个业务镜像。这个镜像里会包含:

  • Node.js 运行时。
  • pnpm 包管理器。
  • 项目依赖。
  • 编译后的 NestJS 代码。
  • 容器启动时执行的命令。

2. 容器:镜像运行起来后的实例

容器是镜像运行起来后的进程环境。同一个镜像可以启动多个容器,就像同一个类可以创建多个对象。

比如你可以用 mysql:latest 镜像启动一个叫 mysql-dev 的容器,也可以启动一个叫 mysql-prod 的容器。它们来自同一个镜像,但数据目录、容器名、网络、端口映射都可以不同。

在当前项目中:

  • 本地开发 Compose 会启动 mysql-devmilvus-etcdmilvus-miniomilvus-standalone
  • 生产 Compose 会启动 mysql-prodnest-app

3. 端口映射:把容器内端口暴露给宿主机

容器有自己的网络命名空间。MySQL 在容器里监听 3306,不代表你的 Mac 或 Linux 主机能直接访问它。要让宿主机访问容器内端口,需要做端口映射:

ports:
  - '3306:3306'

左边是宿主机端口,右边是容器端口。'3306:3306' 的意思是:访问宿主机的 3306,转发到容器内部的 3306

NestJS 服务也是一样:

ports:
  - '3000:3000'

访问宿主机的 http://localhost:3000,实际会进入 nest-app 容器内部的 3000 端口。

4. 数据卷:让容器数据持久化

容器可以删除重建。如果数据库的数据只放在容器内部,那么容器删除后数据也会丢失。数据库这种有状态服务必须把数据目录挂载到宿主机。

当前项目的 MySQL 开发环境配置是:

volumes:
  - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/mysql:/var/lib/mysql

右边 /var/lib/mysql 是 MySQL 容器内部保存数据的目录。左边 ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/mysql 是宿主机目录。这样 MySQL 写入的数据会落到项目目录下的 volumes/mysql,容器重建后仍然能复用。

这个 ${DOCKER_VOLUME_DIRECTORY:-.} 是 Compose 的变量语法。意思是:如果环境变量 DOCKER_VOLUME_DIRECTORY 有值,就用它;如果没有值,就用当前目录 .。这让数据目录既有默认值,也允许你在不同机器上自定义。

5. 容器网络:容器之间用服务名互相访问

Docker Compose 会为同一个 Compose 项目创建网络。处在同一个网络里的服务,可以通过服务名互相访问。

生产环境里,NestJS 不是通过 localhost 访问 MySQL,而是通过服务名 mysql-prod 访问:

host: isProduction ? 'mysql-prod' : 'localhost',

这行代码非常关键。因为当 NestJS 在容器里运行时,localhost 指的是 nest-app 容器自己,不是 MySQL 容器,也不是宿主机。如果继续写 localhost,业务容器会去自己内部找 MySQL,自然连接失败。

这个差异是很多人第一次 Docker 部署后端服务时最容易踩的坑。

三、当前项目的整体结构

先看项目结构中和部署有关的文件:

nest-dockerfile-test
├── Dockerfile
├── .dockerignore
├── docker-compose.dev.yml
├── docker-compose.prod.yml
├── .env.example
├── package.json
├── nest-cli.json
├── public
│   └── index.html
└── src
    ├── main.ts
    ├── app.module.ts
    └── book
        ├── book.controller.ts
        ├── book.service.ts
        ├── dto
        │   ├── create-book.dto.ts
        │   └── update-book.dto.ts
        └── entities
            └── book.entity.ts

业务功能很简单:一个书籍管理系统。后端提供 /book CRUD 接口,前端静态页面通过 fetch('/book') 调用后端接口,数据存到 MySQL 的 books 表。

整体链路可以用下面这张图理解:

flowchart LR
  Browser["浏览器"]
  BooksPage["/books 静态页面"]
  Api["/book REST 接口"]
  Controller["BookController"]
  Service["BookService"]
  TypeORM["TypeORM EntityManager"]
  MySQL[("MySQL book 数据库")]

  Browser --> BooksPage
  BooksPage --> Api
  Api --> Controller
  Controller --> Service
  Service --> TypeORM
  TypeORM --> MySQL

开发环境和生产环境的部署方式不同:

flowchart TB
  subgraph Dev["本地开发模式"]
    DevBrowser["浏览器"]
    DevNest["NestJS 在宿主机运行 pnpm run start:dev"]
    DevMysql[("mysql-dev 容器")]
    DevMilvus["Milvus 相关容器"]
    DevEtcd["etcd"]
    DevMinio["MinIO"]
    DevBrowser --> DevNest
    DevNest --> DevMysql
    DevMilvus --> DevEtcd
    DevMilvus --> DevMinio
  end

  subgraph Prod["生产 Compose 模式"]
    ProdBrowser["浏览器"]
    HostPort["宿主机 3000 端口"]
    NestContainer["nest-app 容器"]
    MysqlProd[("mysql-prod 容器")]
    ProdBrowser --> HostPort
    HostPort --> NestContainer
    NestContainer --> MysqlProd
  end

开发环境的重点是效率:数据库和中间件用容器跑,业务代码在宿主机用 watch 模式跑,这样改代码能立刻生效。

生产环境的重点是一致性:业务代码也构建成镜像,和 MySQL 一起由 Compose 管理,服务重启、网络、端口、依赖关系都写在配置文件里。

四、当前 NestJS 服务做了什么

部署教程不能只讲 Docker。你必须知道容器里跑的到底是什么服务,否则出了问题也不知道该检查哪一层。

项目入口是 src/main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

这段代码做了两件事。

第一,用 NestFactory.create(AppModule) 创建 Nest 应用实例。AppModule 是整个应用的根模块,里面会注册静态资源、数据库连接、业务模块。

第二,监听端口。这里优先读取 process.env.PORT,如果没有设置,就使用 3000。这对部署很重要,因为容器默认暴露的是 3000,生产 Compose 也把宿主机 3000 映射到容器 3000

根模块 src/app.module.ts 是理解开发和生产差异的关键:

const isProduction = process.env.NODE_ENV === 'production';

@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, 'public'),
      serveRoot: '/books',
    }),
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: isProduction ? 'mysql-prod' : 'localhost',
      port: 3306,
      username: 'root',
      password: 'admin',
      database: 'book',
      synchronize: true,
      logging: true,
      autoLoadEntities: true,
      entities: [Book],
    }),
    BookModule,
  ],
})
export class AppModule {}

这里有三个点值得展开。

第一,ServeStaticModule.forRoot 把静态页面挂到 /books。编译后静态文件会被放到 dist/public,运行时 __dirname 指向 dist,所以 join(__dirname, 'public') 会定位到 dist/public。这也是为什么 nest-cli.json 里必须配置 assets 拷贝规则,否则生产镜像里可能只有 JS,没有 public/index.html

第二,TypeOrmModule.forRoot 配置 MySQL 连接。开发时 NODE_ENV 不是 production,所以 host 是 localhost。这是因为开发模式下 NestJS 在宿主机运行,而 MySQL 容器通过端口映射暴露到了宿主机的 3306。生产时 NODE_ENV=production,NestJS 在 nest-app 容器中运行,此时要通过 Compose 服务名 mysql-prod 访问数据库。

第三,synchronize: true 会让 TypeORM 根据 Entity 自动同步表结构。它对 demo 和本地学习很方便,因为你不用手写建表 SQL。但在真实生产项目里不建议长期打开。生产环境应该使用 migration 管理表结构变更,否则一次字段调整可能直接影响线上数据。

书籍表结构定义在 Book Entity:

@Entity({ name: 'books' })
export class Book {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 255 })
  title: string;

  @Column({ length: 255 })
  author: string;

  @Column({ type: 'text' })
  description: string;

  @Column({ type: 'decimal', precision: 10, scale: 2 })
  price: number;

  @Column({ type: 'int', default: 0 })
  stock: number;

  @Column({ type: 'datetime' })
  publishedAt: Date;

  @CreateDateColumn({ type: 'datetime' })
  createdAt: Date;

  @UpdateDateColumn({ type: 'datetime' })
  updatedAt: Date;
}

这段 Entity 的作用是把 TypeScript 类映射成 MySQL 表。@Entity({ name: 'books' }) 指定表名,@PrimaryGeneratedColumn() 指定自增主键,@Column() 指定普通字段,@CreateDateColumn@UpdateDateColumn 让 TypeORM 自动维护创建时间和更新时间。

这里 price 使用了 decimal(10, 2),这是和金额相关字段更合适的做法。浮点数容易出现精度问题,数据库里用 decimal 可以避免很多不必要的金额误差。

业务逻辑在 BookService

@Injectable()
export class BookService {
  @Inject(EntityManager)
  private readonly entityManager: EntityManager;

  async create(createBookDto: CreateBookDto) {
    const book = this.entityManager.create(Book, {
      ...createBookDto,
      publishedAt: new Date(createBookDto.publishedAt),
    });
    return this.entityManager.save(Book, book);
  }

  async findAll() {
    return this.entityManager.find(Book, {
      order: { id: 'DESC' },
    });
  }

  async findOne(id: number) {
    const book = await this.entityManager.findOneBy(Book, { id });
    if (!book) {
      throw new NotFoundException(`Book #${id} not found`);
    }
    return book;
  }
}

这段代码在部署链路里的位置是“业务容器内部的应用逻辑”。浏览器访问 /book,请求先进入 Nest Controller,再调用 Service,最后通过 TypeORM 访问 MySQL。

publishedAt: new Date(createBookDto.publishedAt) 这个转换也值得注意。前端表单传上来的是字符串,比如 "2008-08-01",数据库字段是 datetime。在保存前显式转成 Date,比把字符串直接丢给 ORM 更清晰。

前端页面在 public/index.html,它不是独立前端工程,而是由 NestJS 静态托管。核心调用方式如下:

const loadBooks = async () => {
  const response = await fetch('/book');
  const books = await response.json();
  renderRows(books);
};

form.addEventListener('submit', async (event) => {
  event.preventDefault();

  const id = inputs.id.value.trim();
  const payload = mapFormData();
  const method = id ? 'PATCH' : 'POST';
  const url = id ? `/book/${id}` : '/book';

  await fetch(url, {
    method,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });

  await loadBooks();
});

这里用相对路径 /book,而不是写死 http://localhost:3000/book。这样做有一个部署优势:开发环境和生产环境只要页面和 API 同源,就不需要处理跨域,也不需要在前端代码里区分不同 API 地址。

五、Dockerfile:如何把 NestJS 项目变成镜像

当前项目的 Dockerfile 如下:

ARG NODE_IMAGE=node:24-alpine
FROM ${NODE_IMAGE}

WORKDIR /app

COPY package.json pnpm-lock.yaml ./

RUN npm config set registry https://registry.npmmirror.com/ \
  && npm install -g pnpm@10.14.0 \
  && pnpm install --frozen-lockfile

COPY . .

RUN pnpm run build

EXPOSE 3000

CMD ["node", "dist/main.js"]

逐行拆开看。

ARG NODE_IMAGE=node:24-alpine 定义了构建参数。默认使用 node:24-alpine,但生产 Compose 可以通过 build args 覆盖它。当前项目的 .env.example 就给了国内网络环境下的镜像源示例:

# Default Docker Hub images:
# NODE_IMAGE=node:24-alpine
# MYSQL_IMAGE=mysql:8.4

# 国内网络环境下的镜像源示例:
NODE_IMAGE=docker.m.daocloud.io/library/node:24-alpine

# 当前项目的 MySQL 使用华为云 SWR 镜像。
# 这个镜像当前按 linux/amd64 拉取,在 Apple Silicon 上会通过模拟方式运行。
MYSQL_IMAGE=swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/mysql:8.4
MYSQL_PLATFORM=linux/amd64
MYSQL_HOST_PORT=3307

这不是业务逻辑,而是工程部署上的容错设计。很多机器直接拉 Docker Hub 会超时,允许通过环境变量替换镜像源,可以降低部署门槛。当前配置里 MySQL 已经切到华为云 SWR 镜像,同时把宿主机端口默认改成 3307,用来避开本机或开发环境里已经占用的 3306

FROM ${NODE_IMAGE} 表示基于 Node 镜像构建业务镜像。这里的 Node 版本要和项目依赖兼容。当前项目使用 NestJS 11、TypeScript 5.7,并且 package 里已经有 @types/node 24.x,所以 node:24-alpine 是匹配的。

WORKDIR /app 设置容器内工作目录。后续 COPYRUNCMD 都以 /app 为默认目录。这样容器里的文件结构清晰,也避免命令在根目录里执行。

COPY package.json pnpm-lock.yaml ./ 只先复制依赖清单,不直接复制全部源码。这是 Docker 缓存优化的常见写法。依赖文件不变时,后面的安装依赖层可以复用缓存;你只是改了业务代码,不需要每次都重新安装依赖。

pnpm install --frozen-lockfile 表示严格按 pnpm-lock.yaml 安装依赖。如果 lock 文件和 package.json 不一致,安装会失败。这在构建镜像时是好事,因为它可以避免“我本地能装、服务器装出来不一样”的问题。

COPY . . 把项目代码复制进镜像。这里要配合 .dockerignore 使用,否则 node_modules.gitvolumes 这些不该进入镜像的目录都会被塞进去,镜像构建会又慢又大。

RUN pnpm run build 执行 Nest 编译。当前项目的 build 命令是:

{
  "scripts": {
    "build": "nest build"
  }
}

编译后会生成 dist 目录。因为 nest-cli.json 配置了 assets,public 静态文件也会被拷贝到 dist/public

{
  "compilerOptions": {
    "deleteOutDir": true,
    "assets": [
      {
        "include": "../public/**/*",
        "outDir": "dist/public"
      }
    ]
  }
}

如果没有这段配置,/books 页面在本地开发时可能正常,但生产镜像里访问会失败。原因是 TypeScript 编译默认只处理源码,不会自动把任意静态目录复制到 dist

EXPOSE 3000 只是声明容器内服务使用 3000 端口。它不会自动把端口暴露到宿主机。真正让宿主机访问容器的是 Compose 里的 ports

CMD ["node", "dist/main.js"] 是容器启动命令。镜像构建时执行 RUN,容器启动时执行 CMD。这两者要分清:构建阶段编译代码,运行阶段启动服务。

当前 Dockerfile 是单阶段构建,所以镜像里会保留安装依赖和构建过程所需的一些内容。学习阶段这样更直观,生产环境可以进一步改成多阶段构建:第一阶段安装完整依赖并编译,第二阶段只保留生产依赖和 dist。但如果你还没把单阶段流程跑通,不建议一开始就上复杂优化。

六、.dockerignore:控制什么文件不要进镜像

当前项目的 .dockerignore 是:

node_modules/
.vscode/
.git/
dist/
coverage/
volumes/
.env
.env.*
.tmp-*
*.log

这份文件非常重要。Docker 构建时会把当前目录作为 build context 发送给 Docker 引擎。如果不忽略这些文件,会带来几个问题。

第一,node_modules 很大,而且宿主机上的依赖不一定适合容器环境。比如 macOS 上安装的二进制依赖,不能直接拿到 Linux 容器里用。正确做法是在镜像内重新安装。

第二,.git 没必要进入镜像。它会增加构建上下文体积,也可能泄露提交信息。

第三,volumes 是数据库和中间件数据目录,绝对不能复制进业务镜像。数据库数据应该通过 volume 挂载管理,不应该混进应用镜像。

第四,.env.env.* 可能包含密码、Token、密钥。当前 demo 里密码很简单,但真实项目里一定不要把环境变量文件打进镜像。

第五,dist 应该由镜像构建过程生成。如果把宿主机旧的 dist 复制进去,很容易出现“源码已经改了但镜像里跑的是旧产物”的错觉。

七、本地开发 Compose:一条命令拉起数据库和中间件

当前项目的本地开发 Compose 文件是 docker-compose.dev.yml。它没有启动 NestJS 应用,而是启动 MySQL 和 Milvus 相关基础服务。

这个设计是合理的。开发阶段你希望改一行 TypeScript 代码就能热更新,所以 NestJS 仍然在宿主机上通过 pnpm run start:dev 运行。而 MySQL、Milvus 这类基础服务不需要频繁改代码,放到容器里统一管理即可。

开发环境 MySQL 配置如下:

services:
  mysql:
    image: mysql:latest
    container_name: mysql-dev
    ports:
      - '3306:3306'
    environment:
      MYSQL_ROOT_PASSWORD: admin
      MYSQL_DATABASE: book
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/mysql:/var/lib/mysql
    restart: always

这里 MYSQL_ROOT_PASSWORD: admin 会设置 root 密码,MYSQL_DATABASE: book 会在 MySQL 初始化时创建 book 数据库。NestJS 里 TypeORM 连接的数据库名也是 book,所以这两个地方必须一致。

command 设置了默认字符集为 utf8mb4。这对中文内容很重要。MySQL 早期的 utf8 并不是真正完整的 UTF-8,utf8mb4 才能完整支持中文、emoji 和更多 Unicode 字符。

Milvus 在开发 Compose 里由三个服务组成:etcdminiostandalone。它们的关系可以这样理解:

flowchart LR
  Client["本地 AI 应用或脚本"]
  Milvus["Milvus standalone"]
  Etcd["etcd 元数据"]
  Minio["MinIO 对象存储"]

  Client --> Milvus
  Milvus --> Etcd
  Milvus --> Minio

Milvus 本身负责向量检索。etcd 用来保存元数据和协调信息,MinIO 用来保存对象数据。你当前的 NestJS 书籍 CRUD 还没有直接调用 Milvus,但开发环境已经把 Milvus 编排进来了,这对后续扩展 RAG 或语义检索是有价值的。

standalone 服务里有两段配置很关键:

environment:
  MINIO_REGION: us-east-1
  ETCD_ENDPOINTS: etcd:2379
  MINIO_ADDRESS: minio:9000
depends_on:
  - 'etcd'
  - 'minio'

ETCD_ENDPOINTS: etcd:2379 说明 Milvus 通过服务名 etcd 访问 etcd。MINIO_ADDRESS: minio:9000 说明 Milvus 通过服务名 minio 访问 MinIO。这就是 Compose 网络的价值:容器之间不用写宿主机 IP,直接写服务名。

文件最后定义了默认网络:

networks:
  default:
    name: common-network

这会创建或复用一个叫 common-network 的网络。统一网络名的好处是后续其他 Compose 项目如果也接入这个网络,就能和这里的中间件互通。但也要注意,网络名固定后,不同项目之间可能发生命名和服务访问上的耦合,团队里要约定清楚。

八、本地开发从零启动教程

下面是新手可以直接照着执行的本地开发流程。

1. 准备基础环境

你需要先安装:

  • Docker Desktop 或 Docker Engine。
  • Node.js,建议使用和项目匹配的较新版本。
  • pnpm。

检查 Docker 是否可用:

docker version
docker compose version

检查 Node 和 pnpm:

node -v
pnpm -v

如果 docker compose version 能输出版本,说明你使用的是新版本 Compose 插件,命令写法是 docker compose。老教程里的 docker-compose 是旧命令,能不用就不用。

2. 进入项目目录

cd "/Users/zz/AI learning/codeing/tool-test/nest-dockerfile-test"

路径里有空格,所以命令里要加引号。新手经常在这里踩坑:AI learning 中间有空格,如果不加引号,shell 会把它拆成两个参数。

3. 安装项目依赖

pnpm install

这一步安装的是宿主机开发环境依赖。它和 Dockerfile 里的 pnpm install --frozen-lockfile 不是同一次安装。

  • 本地 pnpm install 是为了让你能在宿主机运行 pnpm run start:dev
  • Dockerfile 里的安装是为了构建生产镜像,让容器内部也有运行项目所需的依赖。

4. 启动开发环境基础服务

当前 package.json 已经提供了脚本:

{
  "scripts": {
    "docker:up": "docker compose -f docker-compose.dev.yml up -d",
    "docker:down": "docker compose -f docker-compose.dev.yml down"
  }
}

启动:

pnpm run docker:up

这个命令会在后台启动 MySQL、etcd、MinIO、Milvus。第一次执行会拉取镜像,耗时可能比较久,尤其是 Milvus 镜像比较大。

查看容器状态:

docker compose -f docker-compose.dev.yml ps

如果想看日志:

docker compose -f docker-compose.dev.yml logs -f mysql
docker compose -f docker-compose.dev.yml logs -f standalone

MySQL 启动成功后,宿主机的 3306 端口会连到 mysql-dev 容器。Milvus 默认暴露:

  • 19530:Milvus SDK 访问端口。
  • 9091:Milvus 健康检查端口。
  • 9000:MinIO API。
  • 9001:MinIO 控制台。

5. 启动 NestJS 开发服务

开发 Compose 不会启动 NestJS,所以还需要在宿主机执行:

pnpm run start:dev

启动后 NestJS 会读取 AppModule 中的配置。因为此时没有设置 NODE_ENV=production,所以数据库 host 是 localhost

host: isProduction ? 'mysql-prod' : 'localhost',

这时 localhost:3306 正好是 MySQL 容器映射到宿主机的端口,所以连接可以成功。

6. 浏览器访问页面

启动成功后访问:

http://localhost:3000
http://localhost:3000/books

/ 会返回 Hello World!/books 会打开书籍管理页面。页面里可以新增书籍、编辑书籍、删除书籍。

7. 用 curl 验证接口

新增一本书:

curl -X POST "http://localhost:3000/book" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Clean Code",
    "author": "Robert C. Martin",
    "description": "A handbook of agile software craftsmanship",
    "price": 99.9,
    "stock": 50,
    "publishedAt": "2008-08-01"
  }'

查询全部:

curl -X GET "http://localhost:3000/book"

查询单条:

curl -X GET "http://localhost:3000/book/1"

更新:

curl -X PATCH "http://localhost:3000/book/1" \
  -H "Content-Type: application/json" \
  -d '{
    "stock": 80,
    "price": 88.8
  }'

删除:

curl -X DELETE "http://localhost:3000/book/1"

这套 CRUD 验证了完整链路:浏览器或 curl 发请求,NestJS Controller 接收请求,Service 调用 TypeORM,TypeORM 写入 MySQL,MySQL 数据通过 volume 持久化到宿主机。

8. 停止本地开发环境

停止容器:

pnpm run docker:down

注意,docker compose down 会删除容器和默认网络,但不会删除你挂载到宿主机的 volumes/mysql 数据目录。所以下次 pnpm run docker:up 后,数据仍然存在。

如果你想彻底清空数据库数据,需要删除对应的宿主机目录。这个操作有破坏性,真实项目里要非常谨慎。

九、生产 Compose:把业务服务和数据库一起编排

本地开发模式下,NestJS 在宿主机运行。生产模式下,NestJS 应该作为容器运行。当前项目的 docker-compose.prod.yml 就是为这个目标准备的:

services:
  mysql-prod:
    image: ${MYSQL_IMAGE:-mysql:8.4}
    platform: ${MYSQL_PLATFORM:-linux/amd64}
    container_name: mysql-prod
    environment:
      MYSQL_ROOT_PASSWORD: admin
      MYSQL_DATABASE: book
    ports:
      - '${MYSQL_HOST_PORT:-3307}:3306'
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/mysql-prod:/var/lib/mysql
    restart: always

  nest-app:
    container_name: nest-app
    build:
      context: .
      dockerfile: Dockerfile
      args:
        NODE_IMAGE: ${NODE_IMAGE:-node:24-alpine}
    ports:
      - '3000:3000'
    environment:
      NODE_ENV: production
    depends_on:
      - mysql-prod
    restart: always

这份文件只包含两个服务:mysql-prodnest-app。没有包含 Milvus,因为当前生产业务链路只需要 NestJS + MySQL。如果后续业务真的用到向量检索,再把 Milvus 加进生产 Compose 会更合理。不要因为开发环境里有某个中间件,就默认生产环境也必须带上它。生产环境每多一个组件,就多一份资源消耗、监控成本和故障面。

mysql-prod 和开发环境的 mysql-dev 很像,但数据目录变成了:

volumes:
  - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/mysql-prod:/var/lib/mysql

这可以避免开发数据和生产数据混在一起。即使是在同一台机器上做演示,也应该把开发和生产数据目录分开。

这里还有两个和镜像源相关的细节。image: ${MYSQL_IMAGE:-mysql:8.4} 表示默认使用 Docker Hub 的 mysql:8.4,但如果 .env 里设置了 MYSQL_IMAGE,就会改用 .env 中的镜像。当前项目里 MYSQL_IMAGE 指向华为云 SWR:

MYSQL_IMAGE=swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/mysql:8.4
MYSQL_PLATFORM=linux/amd64
MYSQL_HOST_PORT=3307

platform: ${MYSQL_PLATFORM:-linux/amd64} 是为了配合当前这份华为云 MySQL 镜像。它明确告诉 Docker 拉取并运行 amd64 架构镜像。如果你在 Apple Silicon 机器上运行,Docker Desktop 会通过模拟方式运行这个容器。MYSQL_HOST_PORT=3307 则表示宿主机访问 MySQL 时使用 localhost:3307,但容器内部 MySQL 仍然监听 3306

nest-app 使用 build,说明它不是直接拉现成镜像,而是在当前项目目录里根据 Dockerfile 构建:

build:
  context: .
  dockerfile: Dockerfile
  args:
    NODE_IMAGE: ${NODE_IMAGE:-node:24-alpine}

context: . 表示构建上下文是当前项目根目录。dockerfile: Dockerfile 指定构建文件。args 把 Compose 里的 NODE_IMAGE 传给 Dockerfile 里的 ARG NODE_IMAGE

environment 设置了:

environment:
  NODE_ENV: production

这会影响 AppModule 里的数据库 host 判断:

const isProduction = process.env.NODE_ENV === 'production';
host: isProduction ? 'mysql-prod' : 'localhost',

所以生产容器启动后,NestJS 会连接 mysql-prod:3306。这个地址只在 Compose 网络内部有意义。当前生产 Compose 默认把 MySQL 映射到宿主机 3307,所以宿主机访问 MySQL 是 localhost:3307;但容器访问另一个容器仍然应该使用服务名和容器内部端口,也就是 mysql-prod:3306

depends_on 表示 nest-app 依赖 mysql-prod

depends_on:
  - mysql-prod

这里要注意一个边界:depends_on 只能保证启动顺序,不能保证 MySQL 已经完全就绪。MySQL 容器进程启动了,不代表数据库已经可以接受连接。当前 demo 比较简单,通常重启后可以正常连接;真实生产建议给 MySQL 加 healthcheck,并让应用有连接重试能力,或者使用等待脚本。

十、生产环境从零部署教程

下面按新手可执行的方式,把生产 Compose 跑起来。

1. 确认不要和开发环境抢端口

开发 Compose 把 MySQL 映射到宿主机 3306

ports:
  - '3306:3306'

当前生产 Compose 已经把宿主机端口默认改成 3307

ports:
  - '${MYSQL_HOST_PORT:-3307}:3306'

这样开发环境的 mysql-dev 可以占用宿主机 3306,生产演示环境的 mysql-prod 可以占用宿主机 3307。如果你不想同时运行两套 MySQL,最简单的做法仍然是先停掉开发环境:

pnpm run docker:down

如果你的机器上 3307 也被占用了,可以在 .env 里继续改:

MYSQL_HOST_PORT=3308

这表示宿主机用 3308 访问生产 MySQL,但容器内部仍然是 3306。注意 NestJS 容器访问 mysql-prod:3306 不受这个映射影响,因为容器之间走内部网络,不走宿主机映射端口。

2. 准备 .env

项目里有 .env.example

# Default Docker Hub images:
# NODE_IMAGE=node:24-alpine
# MYSQL_IMAGE=mysql:8.4

# 国内网络环境下的镜像源示例:
NODE_IMAGE=docker.m.daocloud.io/library/node:24-alpine

# 当前项目的 MySQL 使用华为云 SWR 镜像。
MYSQL_IMAGE=swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/mysql:8.4
MYSQL_PLATFORM=linux/amd64
MYSQL_HOST_PORT=3307

如果你在国内网络环境拉 Docker Hub 容易失败,可以复制一份:

cp .env.example .env

Docker Compose 默认会读取当前目录的 .env。这样 docker-compose.prod.yml 里的 ${NODE_IMAGE:-node:24-alpine}${MYSQL_IMAGE:-mysql:8.4}${MYSQL_PLATFORM:-linux/amd64}${MYSQL_HOST_PORT:-3307} 就会使用 .env 中的配置。也就是说,是否使用华为云 SWR 镜像、MySQL 用哪个宿主机端口,都可以不改 Compose 文件,直接通过 .env 控制。

真实生产环境里,.env 不应该提交到 Git。当前 .dockerignore 已经忽略了 .env.env.*,这是正确的。

3. 构建并启动生产环境

项目 package.json 里提供了生产启动脚本:

{
  "scripts": {
    "docker:prod:up": "docker compose -f docker-compose.prod.yml up -d --build"
  }
}

执行:

pnpm run docker:prod:up

这个命令会做几件事:

  1. 读取 docker-compose.prod.yml
  2. 根据 Dockerfile 构建 nest-app 镜像。
  3. 拉起 mysql-prod 容器。
  4. 拉起 nest-app 容器。
  5. 后台运行整套服务。

如果你想直接用 Docker 命令,也可以执行:

docker compose -f docker-compose.prod.yml up -d --build

4. 查看服务状态

docker compose -f docker-compose.prod.yml ps

你应该能看到 mysql-prodnest-app 两个容器。看日志:

docker compose -f docker-compose.prod.yml logs -f mysql-prod
docker compose -f docker-compose.prod.yml logs -f nest-app

如果 nest-app 日志里出现 MySQL 连接错误,优先检查:

  • NODE_ENV 是否是 production
  • mysql-prod 容器是否已经启动完成。
  • MYSQL_ROOT_PASSWORDMYSQL_DATABASE 是否和 TypeORM 配置一致。
  • 两个服务是否在同一个 Compose 项目网络里。

5. 访问生产服务

生产 Compose 把宿主机 3000 映射到 nest-app 容器的 3000。访问:

http://localhost:3000
http://localhost:3000/books

如果是在云服务器上,需要把 localhost 换成服务器 IP 或域名:

http://服务器IP:3000/books

同时确保云服务器安全组或防火墙放行了 3000 端口。真实上线时通常不会直接暴露 3000,而是用 Nginx 或网关把 80443 转发到内部 3000

6. 验证 CRUD

可以继续使用 curl:

curl -X POST "http://localhost:3000/book" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Docker Compose 实战",
    "author": "Local Dev",
    "description": "从本地开发到生产部署的一本示例书",
    "price": 66.6,
    "stock": 20,
    "publishedAt": "2026-05-23"
  }'

再查询:

curl "http://localhost:3000/book"

如果能查询到数据,说明生产链路已经跑通:宿主机请求进入 nest-app 容器,NestJS 通过 mysql-prod 服务名访问 MySQL,MySQL 写入挂载的数据卷。

7. 停止生产环境

docker compose -f docker-compose.prod.yml down

如果只是想重启:

docker compose -f docker-compose.prod.yml restart

如果改了代码并想重新构建:

docker compose -f docker-compose.prod.yml up -d --build

--build 很重要。没有它时,Compose 可能直接复用旧镜像,你会以为代码没生效。

十一、把项目部署到一台真实服务器

本地生产 Compose 跑通后,上服务器的思路其实一样,只是执行环境从你的电脑变成了云主机。

一个最小可行的部署流程是:

  1. 准备一台 Linux 服务器。
  2. 安装 Docker 和 Docker Compose 插件。
  3. 把项目代码上传到服务器。
  4. 在服务器上准备 .env
  5. 执行 docker compose -f docker-compose.prod.yml up -d --build
  6. 开放访问端口或配置反向代理。
  7. 配置日志、备份、重启策略和监控。

假设代码已经上传到服务器的 /opt/nest-dockerfile-test

cd /opt/nest-dockerfile-test
cp .env.example .env
docker compose -f docker-compose.prod.yml up -d --build

查看状态:

docker compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.prod.yml logs -f nest-app

如果服务器使用云厂商安全组,要放行端口。学习阶段可以先放行 3000,真实生产更建议:

  • 只对外开放 80443
  • 用 Nginx 把外部请求转发到本机 3000
  • MySQL 的 3306 不对公网开放。

一个简化的 Nginx 反向代理示例:

server {
  listen 80;
  server_name example.com;

  location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  }
}

这样用户访问 http://example.com/books,Nginx 会转发到宿主机的 3000,再进入 nest-app 容器。

这里有一个边界要说清楚:当前 Compose 把 MySQL 端口默认映射到了宿主机 3307,方便本机调试。但在真实公网服务器上,数据库端口不应该直接暴露给互联网。你可以删除生产 Compose 里 MySQL 的 ports,只保留容器内部访问:

mysql-prod:
  image: ${MYSQL_IMAGE:-mysql:8.4}
  platform: ${MYSQL_PLATFORM:-linux/amd64}
  environment:
    MYSQL_ROOT_PASSWORD: admin
    MYSQL_DATABASE: book

没有 ports 后,宿主机外部不能直接访问 MySQL,但 nest-app 仍然可以通过 Compose 内部网络访问 mysql-prod:3306。这是更安全的生产形态。

十二、开发环境和生产环境的核心差异

很多新手会问:既然 Docker Compose 可以启动所有服务,为什么本地开发不直接把 NestJS 也放进 Compose?

答案是可以,但不是必须。是否放进 Compose,取决于你更看重什么。

当前项目采用的是“开发只容器化基础设施,业务代码本地运行”的方式:

本地开发:
浏览器 -> 宿主机 NestJS -> localhost:3306 -> mysql-dev 容器

优点是开发体验好。pnpm run start:dev 可以 watch 文件变化,IDE 调试也更直接。缺点是宿主机需要安装 Node 和 pnpm。

生产环境采用的是“业务服务也容器化”的方式:

生产部署:
浏览器 -> 宿主机 3000 -> nest-app 容器 -> mysql-prod:3306 -> MySQL 容器

优点是部署环境更一致。服务器不需要手动安装项目依赖,只要有 Docker 就能构建和启动。缺点是调试不如本地直接运行方便,镜像构建也需要时间。

如果团队希望本地开发也完全容器化,可以再加一个 nest-dev 服务,把源码挂载进容器,并在容器里运行 pnpm run start:dev。但这会引入文件监听、依赖缓存、容器内外权限等新问题。对学习和中小项目来说,当前方式更容易理解,也更稳。

十三、当前实现里值得注意的工程边界

1. synchronize: true 适合学习,不适合严肃生产

当前 TypeORM 配置里有:

synchronize: true,

它会自动根据 Entity 同步表结构。学习阶段这很方便,因为你只要写 Entity,表就能自动创建。

但真实生产里,表结构变更需要可审计、可回滚、可灰度。推荐使用 TypeORM migration 或其他数据库迁移工具。生产环境可以改成:

synchronize: false,
migrationsRun: true,

然后用 migration 文件管理字段新增、索引变更和数据修复。

2. 密码不应该写死在源码和 Compose 里

当前 demo 里 MySQL root 密码是 admin

MYSQL_ROOT_PASSWORD: admin

TypeORM 里也写了:

password: 'admin',

学习项目可以接受,但真实项目应该通过环境变量注入:

environment:
  MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}

NestJS 里也应该读取:

password: process.env.DB_PASSWORD,

这样密码不会写死在代码仓库,也方便不同环境使用不同配置。

3. 固定 MySQL 版本比使用 latest 更适合生产

当前配置已经避免直接使用 mysql:latest,默认固定在 MySQL 8.4:

image: ${MYSQL_IMAGE:-mysql:8.4}

同时,.env 里可以把它替换成华为云 SWR 镜像:

MYSQL_IMAGE=swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/mysql:8.4

这种写法比 mysql:latest 更稳。latest 会随着镜像仓库更新而变化,今天拉到的可能是一个版本,几个月后重新部署拉到的是另一个版本。固定到 8.4 后,至少主版本和小版本是明确的;再通过 .env 切换华为云镜像源,可以同时兼顾可复现性和国内网络下的拉取成功率。

生产更建议固定版本,例如:

image: mysql:8.4

Node 镜像也一样。版本越明确,部署越可复现。

4. depends_on 不是健康检查

depends_on 只保证启动顺序,不保证依赖服务已经就绪。MySQL 启动通常需要几秒到几十秒。业务容器如果启动太快,可能第一次连接失败。

更稳的做法有两个:

  • 给 MySQL 配置 healthcheck。
  • 应用层数据库连接开启重试。

例如 MySQL 可以增加:

healthcheck:
  test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-padmin']
  interval: 10s
  timeout: 5s
  retries: 5

然后应用层也要允许短暂失败后重连。不要把服务启动顺序当成服务可用性。

5. 当前 Dockerfile 还能继续优化

当前单阶段 Dockerfile 易懂,但镜像会包含构建所需的 devDependencies。生产可以改成多阶段构建,例如:

ARG NODE_IMAGE=node:24-alpine

FROM ${NODE_IMAGE} AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm config set registry https://registry.npmmirror.com/ \
  && npm install -g pnpm@10.14.0 \
  && pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build

FROM ${NODE_IMAGE} AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json pnpm-lock.yaml ./
RUN npm config set registry https://registry.npmmirror.com/ \
  && npm install -g pnpm@10.14.0 \
  && pnpm install --prod --frozen-lockfile
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/main.js"]

多阶段构建的价值是:构建阶段可以安装完整依赖,运行阶段只保留生产运行需要的东西。镜像更小,攻击面更小,部署传输也更快。

但优化要建立在理解之上。新手先跑通当前单阶段版本,再做多阶段改造,会更容易定位问题。

十四、常见问题排查

1. 端口 3306 被占用

现象:

Bind for 0.0.0.0:3306 failed: port is already allocated

原因通常是本机已经安装了 MySQL,或者开发 Compose 的 mysql-dev 已经在运行,生产 Compose 又要启动 mysql-prod

处理方式:

docker ps
docker compose -f docker-compose.dev.yml down

当前生产 Compose 已经通过 MYSQL_HOST_PORT=3307 避开了开发 MySQL 的 3306。如果你的机器上 3307 也被占用,就继续改 .env

MYSQL_HOST_PORT=3308

2. NestJS 报连接 MySQL 失败

先判断当前运行模式。

如果你执行的是 pnpm run start:dev,NestJS 在宿主机运行,应该连接 localhost:3306。检查:

docker ps
docker compose -f docker-compose.dev.yml logs mysql

如果你执行的是生产 Compose,NestJS 在容器里运行,应该连接 mysql-prod:3306。检查:

docker compose -f docker-compose.prod.yml logs -f nest-app
docker compose -f docker-compose.prod.yml logs -f mysql-prod

重点看 NODE_ENV 是否为 production。如果生产容器里没有这个环境变量,代码会走开发分支,尝试连接容器自己的 localhost,然后失败。

3. /books 页面 404 或空白

优先检查 nest-cli.json

"assets": [
  {
    "include": "../public/**/*",
    "outDir": "dist/public"
  }
]

然后重新构建:

pnpm run build

如果是容器里访问失败,重新构建镜像:

docker compose -f docker-compose.prod.yml up -d --build

因为静态文件需要进入 dist/public,再进入镜像。只改了 public/index.html 但没有重新 build,生产镜像不会自动变化。

4. Docker 拉镜像很慢或失败

可以使用 .env.example 中的镜像源配置:

cp .env.example .env
docker compose -f docker-compose.prod.yml up -d --build

注意当前开发 Compose 里 Milvus、etcd、MinIO 的镜像地址是直接写死的。如果这些镜像拉取慢,需要单独替换为可访问的镜像源,或者提前在网络较好的环境拉取。

5. 改了代码但容器里没生效

生产容器跑的是镜像里的编译产物,不会自动读取宿主机源码。改代码后需要:

docker compose -f docker-compose.prod.yml up -d --build

如果仍然没生效,可以强制重新构建:

docker compose -f docker-compose.prod.yml build --no-cache nest-app
docker compose -f docker-compose.prod.yml up -d

6. 数据删不掉或一直存在

这是 volume 持久化的正常表现。docker compose down 删除容器,但不会删除宿主机目录:

volumes/mysql
volumes/mysql-prod

如果你要清空学习数据,需要停止容器后删除对应目录。真实项目不要随便做这个操作,因为这等价于删除数据库数据。

十五、命令速查

本地开发:

cd "/Users/zz/AI learning/codeing/tool-test/nest-dockerfile-test"
pnpm install
pnpm run docker:up
pnpm run start:dev

查看开发环境容器:

docker compose -f docker-compose.dev.yml ps
docker compose -f docker-compose.dev.yml logs -f mysql

停止开发环境:

pnpm run docker:down

生产构建与启动:

cp .env.example .env
pnpm run docker:prod:up

查看生产环境:

docker compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.prod.yml logs -f nest-app
docker compose -f docker-compose.prod.yml logs -f mysql-prod

停止生产环境:

docker compose -f docker-compose.prod.yml down

重新构建生产镜像:

docker compose -f docker-compose.prod.yml up -d --build

测试接口:

curl "http://localhost:3000/book"

访问页面:

http://localhost:3000/books

十六、如何从这个项目继续演进

当前项目是一个非常适合学习 Docker Compose 的起点,但还不是完整生产架构。后续可以按下面的顺序演进。

第一步,把配置环境变量化。数据库 host、端口、用户名、密码、数据库名都应该从环境变量读取,而不是写死在 AppModule。可以引入 @nestjs/config,并建立 .env.development.env.production 的配置规范。

第二步,关闭生产环境 synchronize,改用 migration。数据库结构是系统资产,不能让 ORM 在生产环境里自动猜测和修改。

第三步,优化 Dockerfile 为多阶段构建,并固定 Node、MySQL 镜像版本。这样可以减少镜像体积,提高可复现性。

第四步,给 MySQL 和 NestJS 增加 healthcheck。Compose 不等于编排平台,但基本健康检查仍然能提升部署可靠性。

第五步,引入 Nginx 和 HTTPS。真实用户不应该直接访问 3000,对外应该是域名和 HTTPS。

第六步,建立备份策略。MySQL 数据挂载在宿主机目录里,不代表它天然安全。生产需要定期备份、异地备份和恢复演练。

第七步,如果业务开始使用 Milvus,再把 Milvus 纳入生产 Compose,并明确它的数据目录、备份策略、资源限制和监控方式。不要只因为本地开发有 Milvus,就默认线上也必须启动。

十七、总结

Docker Compose 解决的不是“怎么运行一个命令”这么简单的问题,而是把一组服务的运行方式固化下来:镜像用什么版本,容器叫什么名字,端口怎么映射,数据放在哪里,服务之间怎么互相访问,重启策略是什么。

在当前 NestJS 项目里,本地开发环境用 Compose 启动 MySQL 和 Milvus,业务代码在宿主机用 pnpm run start:dev 热更新运行。这种方式兼顾了依赖环境的一致性和开发调试效率。

生产环境则用 docker-compose.prod.yml 同时编排 mysql-prodnest-app。NestJS 通过 NODE_ENV=production 切换数据库 host,从开发模式的 localhost 变成生产模式的 mysql-prod。这是容器化部署里最关键的认知:容器里的 localhost 不是宿主机,也不是另一个容器;容器之间应该通过 Compose 服务名通信。

如果你是新手,建议按本文顺序实践:先理解项目接口和数据库链路,再跑本地开发 Compose,最后跑生产 Compose。只要你能清楚解释“请求从浏览器到 NestJS,再到 TypeORM,再到 MySQL”的路径,也能清楚解释“开发模式和生产模式数据库 host 为什么不同”,你就已经跨过了 Docker Compose 部署后端项目最重要的一道门槛。