慣性聚合 高效追蹤和閱讀你感興趣的部落格、新聞、科技資訊
閱讀原文 在慣性聚合中打開

推薦訂閱源

博客园 - 司徒正美
V
V2EX
T
Tailwind CSS Blog
有赞技术团队
有赞技术团队
aimingoo的专栏
aimingoo的专栏
Apple Machine Learning Research
Apple Machine Learning Research
IT之家
IT之家
Blog — PlanetScale
Blog — PlanetScale
A
About on SuperTechFans
月光博客
月光博客
T
The Blog of Author Tim Ferriss
宝玉的分享
宝玉的分享
Martin Fowler
Martin Fowler
博客园 - 聂微东
The GitHub Blog
The GitHub Blog
V
Visual Studio Blog
WordPress大学
WordPress大学
酷 壳 – CoolShell
酷 壳 – CoolShell
Engineering at Meta
Engineering at Meta
GbyAI
GbyAI

掘金

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
從本地開發到生產部署:用 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 部署後端項目最重要的一道門檻。