本文不是一篇只介绍 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.yml、docker-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 项目时,最熟悉的启动方式是:
bash
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-dev、milvus-etcd、milvus-minio、milvus-standalone。 - 生产 Compose 会启动
mysql-prod和nest-app。
3. 端口映射:把容器内端口暴露给宿主机
容器有自己的网络命名空间。MySQL 在容器里监听 3306,不代表你的 Mac 或 Linux 主机能直接访问它。要让宿主机访问容器内端口,需要做端口映射:
yaml
ports:
- '3306:3306'
左边是宿主机端口,右边是容器端口。'3306:3306' 的意思是:访问宿主机的 3306,转发到容器内部的 3306。
NestJS 服务也是一样:
yaml
ports:
- '3000:3000'
访问宿主机的 http://localhost:3000,实际会进入 nest-app 容器内部的 3000 端口。
4. 数据卷:让容器数据持久化
容器可以删除重建。如果数据库的数据只放在容器内部,那么容器删除后数据也会丢失。数据库这种有状态服务必须把数据目录挂载到宿主机。
当前项目的 MySQL 开发环境配置是:
yaml
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 访问:
ts
host: isProduction ? 'mysql-prod' : 'localhost',
这行代码非常关键。因为当 NestJS 在容器里运行时,localhost 指的是 nest-app 容器自己,不是 MySQL 容器,也不是宿主机。如果继续写 localhost,业务容器会去自己内部找 MySQL,自然连接失败。
这个差异是很多人第一次 Docker 部署后端服务时最容易踩的坑。
三、当前项目的整体结构
先看项目结构中和部署有关的文件:
text
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 表。
整体链路可以用下面这张图理解:
开发环境和生产环境的部署方式不同:
开发环境的重点是效率:数据库和中间件用容器跑,业务代码在宿主机用 watch 模式跑,这样改代码能立刻生效。
生产环境的重点是一致性:业务代码也构建成镜像,和 MySQL 一起由 Compose 管理,服务重启、网络、端口、依赖关系都写在配置文件里。
四、当前 NestJS 服务做了什么
部署教程不能只讲 Docker。你必须知道容器里跑的到底是什么服务,否则出了问题也不知道该检查哪一层。
项目入口是 src/main.ts:
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 是理解开发和生产差异的关键:
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:
ts
@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:
ts
@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 静态托管。核心调用方式如下:
js
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 如下:
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 就给了国内网络环境下的镜像源示例:
env
NODE_IMAGE=docker.m.daocloud.io/library/node:24-alpine
MYSQL_IMAGE=docker.m.daocloud.io/library/mysql:latest
这不是业务逻辑,而是工程部署上的容错设计。很多机器直接拉 Docker Hub 会超时,允许通过环境变量替换镜像源,可以降低部署门槛。
FROM ${NODE_IMAGE} 表示基于 Node 镜像构建业务镜像。这里的 Node 版本要和项目依赖兼容。当前项目使用 NestJS 11、TypeScript 5.7,并且 package 里已经有 @types/node 24.x,所以 node:24-alpine 是匹配的。
WORKDIR /app 设置容器内工作目录。后续 COPY、RUN、CMD 都以 /app 为默认目录。这样容器里的文件结构清晰,也避免命令在根目录里执行。
COPY package.json pnpm-lock.yaml ./ 只先复制依赖清单,不直接复制全部源码。这是 Docker 缓存优化的常见写法。依赖文件不变时,后面的安装依赖层可以复用缓存;你只是改了业务代码,不需要每次都重新安装依赖。
pnpm install --frozen-lockfile 表示严格按 pnpm-lock.yaml 安装依赖。如果 lock 文件和 package.json 不一致,安装会失败。这在构建镜像时是好事,因为它可以避免"我本地能装、服务器装出来不一样"的问题。
COPY . . 把项目代码复制进镜像。这里要配合 .dockerignore 使用,否则 node_modules、.git、volumes 这些不该进入镜像的目录都会被塞进去,镜像构建会又慢又大。
RUN pnpm run build 执行 Nest 编译。当前项目的 build 命令是:
json
{
"scripts": {
"build": "nest build"
}
}
编译后会生成 dist 目录。因为 nest-cli.json 配置了 assets,public 静态文件也会被拷贝到 dist/public:
json
{
"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 是:
text
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 配置如下:
yaml
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 里由三个服务组成:etcd、minio、standalone。它们的关系可以这样理解:
Milvus 本身负责向量检索。etcd 用来保存元数据和协调信息,MinIO 用来保存对象数据。你当前的 NestJS 书籍 CRUD 还没有直接调用 Milvus,但开发环境已经把 Milvus 编排进来了,这对后续扩展 RAG 或语义检索是有价值的。
standalone 服务里有两段配置很关键:
yaml
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,直接写服务名。
文件最后定义了默认网络:
yaml
networks:
default:
name: common-network
这会创建或复用一个叫 common-network 的网络。统一网络名的好处是后续其他 Compose 项目如果也接入这个网络,就能和这里的中间件互通。但也要注意,网络名固定后,不同项目之间可能发生命名和服务访问上的耦合,团队里要约定清楚。
八、本地开发从零启动教程
下面是新手可以直接照着执行的本地开发流程。
1. 准备基础环境
你需要先安装:
- Docker Desktop 或 Docker Engine。
- Node.js,建议使用和项目匹配的较新版本。
- pnpm。
检查 Docker 是否可用:
bash
docker version
docker compose version
检查 Node 和 pnpm:
bash
node -v
pnpm -v
如果 docker compose version 能输出版本,说明你使用的是新版本 Compose 插件,命令写法是 docker compose。老教程里的 docker-compose 是旧命令,能不用就不用。
2. 进入项目目录
bash
cd "/Users/zz/AI learning/codeing/tool-test/nest-dockerfile-test"
路径里有空格,所以命令里要加引号。新手经常在这里踩坑:AI learning 中间有空格,如果不加引号,shell 会把它拆成两个参数。
3. 安装项目依赖
bash
pnpm install
这一步安装的是宿主机开发环境依赖。它和 Dockerfile 里的 pnpm install --frozen-lockfile 不是同一次安装。
- 本地
pnpm install是为了让你能在宿主机运行pnpm run start:dev。 - Dockerfile 里的安装是为了构建生产镜像,让容器内部也有运行项目所需的依赖。
4. 启动开发环境基础服务
当前 package.json 已经提供了脚本:
json
{
"scripts": {
"docker:up": "docker compose -f docker-compose.dev.yml up -d",
"docker:down": "docker compose -f docker-compose.dev.yml down"
}
}
启动:
bash
pnpm run docker:up
这个命令会在后台启动 MySQL、etcd、MinIO、Milvus。第一次执行会拉取镜像,耗时可能比较久,尤其是 Milvus 镜像比较大。
查看容器状态:
bash
docker compose -f docker-compose.dev.yml ps
如果想看日志:
bash
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,所以还需要在宿主机执行:
bash
pnpm run start:dev
启动后 NestJS 会读取 AppModule 中的配置。因为此时没有设置 NODE_ENV=production,所以数据库 host 是 localhost:
ts
host: isProduction ? 'mysql-prod' : 'localhost',
这时 localhost:3306 正好是 MySQL 容器映射到宿主机的端口,所以连接可以成功。
6. 浏览器访问页面
启动成功后访问:
text
http://localhost:3000
http://localhost:3000/books
/ 会返回 Hello World!,/books 会打开书籍管理页面。页面里可以新增书籍、编辑书籍、删除书籍。
7. 用 curl 验证接口
新增一本书:
bash
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"
}'
查询全部:
bash
curl -X GET "http://localhost:3000/book"
查询单条:
bash
curl -X GET "http://localhost:3000/book/1"
更新:
bash
curl -X PATCH "http://localhost:3000/book/1" \
-H "Content-Type: application/json" \
-d '{
"stock": 80,
"price": 88.8
}'
删除:
bash
curl -X DELETE "http://localhost:3000/book/1"
这套 CRUD 验证了完整链路:浏览器或 curl 发请求,NestJS Controller 接收请求,Service 调用 TypeORM,TypeORM 写入 MySQL,MySQL 数据通过 volume 持久化到宿主机。
8. 停止本地开发环境
停止容器:
bash
pnpm run docker:down
注意,docker compose down 会删除容器和默认网络,但不会删除你挂载到宿主机的 volumes/mysql 数据目录。所以下次 pnpm run docker:up 后,数据仍然存在。
如果你想彻底清空数据库数据,需要删除对应的宿主机目录。这个操作有破坏性,真实项目里要非常谨慎。
九、生产 Compose:把业务服务和数据库一起编排
本地开发模式下,NestJS 在宿主机运行。生产模式下,NestJS 应该作为容器运行。当前项目的 docker-compose.prod.yml 就是为这个目标准备的:
yaml
services:
mysql-prod:
image: ${MYSQL_IMAGE:-mysql:latest}
container_name: mysql-prod
environment:
MYSQL_ROOT_PASSWORD: admin
MYSQL_DATABASE: book
ports:
- '3306: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-prod 和 nest-app。没有包含 Milvus,因为当前生产业务链路只需要 NestJS + MySQL。如果后续业务真的用到向量检索,再把 Milvus 加进生产 Compose 会更合理。不要因为开发环境里有某个中间件,就默认生产环境也必须带上它。生产环境每多一个组件,就多一份资源消耗、监控成本和故障面。
mysql-prod 和开发环境的 mysql-dev 很像,但数据目录变成了:
yaml
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/mysql-prod:/var/lib/mysql
这可以避免开发数据和生产数据混在一起。即使是在同一台机器上做演示,也应该把开发和生产数据目录分开。
nest-app 使用 build,说明它不是直接拉现成镜像,而是在当前项目目录里根据 Dockerfile 构建:
yaml
build:
context: .
dockerfile: Dockerfile
args:
NODE_IMAGE: ${NODE_IMAGE:-node:24-alpine}
context: . 表示构建上下文是当前项目根目录。dockerfile: Dockerfile 指定构建文件。args 把 Compose 里的 NODE_IMAGE 传给 Dockerfile 里的 ARG NODE_IMAGE。
environment 设置了:
yaml
environment:
NODE_ENV: production
这会影响 AppModule 里的数据库 host 判断:
ts
const isProduction = process.env.NODE_ENV === 'production';
host: isProduction ? 'mysql-prod' : 'localhost',
所以生产容器启动后,NestJS 会连接 mysql-prod:3306。这个地址只在 Compose 网络内部有意义。宿主机访问 MySQL 仍然是 localhost:3306,但容器访问另一个容器应该使用服务名。
depends_on 表示 nest-app 依赖 mysql-prod:
yaml
depends_on:
- mysql-prod
这里要注意一个边界:depends_on 只能保证启动顺序,不能保证 MySQL 已经完全就绪。MySQL 容器进程启动了,不代表数据库已经可以接受连接。当前 demo 比较简单,通常重启后可以正常连接;真实生产建议给 MySQL 加 healthcheck,并让应用有连接重试能力,或者使用等待脚本。
十、生产环境从零部署教程
下面按新手可执行的方式,把生产 Compose 跑起来。
1. 确认不要和开发环境抢端口
开发 Compose 和生产 Compose 都把 MySQL 映射到宿主机 3306:
yaml
ports:
- '3306:3306'
所以不能同时启动开发 MySQL 和生产 MySQL,除非你改其中一个的宿主机端口。最简单的做法是先停掉开发环境:
bash
pnpm run docker:down
如果你不想停开发环境,可以把生产 MySQL 改成:
yaml
ports:
- '3307:3306'
这表示宿主机用 3307 访问生产 MySQL,但容器内部仍然是 3306。注意 NestJS 容器访问 mysql-prod:3306 不受这个映射影响,因为容器之间走内部网络,不走宿主机映射端口。
2. 准备 .env
项目里有 .env.example:
env
# Default Docker Hub images:
# NODE_IMAGE=node:24-alpine
# MYSQL_IMAGE=mysql:latest
# Mainland China / Docker Hub timeout workaround:
NODE_IMAGE=docker.m.daocloud.io/library/node:24-alpine
MYSQL_IMAGE=docker.m.daocloud.io/library/mysql:latest
如果你在国内网络环境拉 Docker Hub 容易失败,可以复制一份:
bash
cp .env.example .env
Docker Compose 默认会读取当前目录的 .env。这样 docker-compose.prod.yml 里的 ${NODE_IMAGE:-node:24-alpine} 和 ${MYSQL_IMAGE:-mysql:latest} 就会使用 .env 中的镜像地址。
真实生产环境里,.env 不应该提交到 Git。当前 .dockerignore 已经忽略了 .env 和 .env.*,这是正确的。
3. 构建并启动生产环境
项目 package.json 里提供了生产启动脚本:
json
{
"scripts": {
"docker:prod:up": "docker compose -f docker-compose.prod.yml up -d --build"
}
}
执行:
bash
pnpm run docker:prod:up
这个命令会做几件事:
- 读取
docker-compose.prod.yml。 - 根据
Dockerfile构建nest-app镜像。 - 拉起
mysql-prod容器。 - 拉起
nest-app容器。 - 后台运行整套服务。
如果你想直接用 Docker 命令,也可以执行:
bash
docker compose -f docker-compose.prod.yml up -d --build
4. 查看服务状态
bash
docker compose -f docker-compose.prod.yml ps
你应该能看到 mysql-prod 和 nest-app 两个容器。看日志:
bash
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_PASSWORD、MYSQL_DATABASE是否和 TypeORM 配置一致。- 两个服务是否在同一个 Compose 项目网络里。
5. 访问生产服务
生产 Compose 把宿主机 3000 映射到 nest-app 容器的 3000。访问:
text
http://localhost:3000
http://localhost:3000/books
如果是在云服务器上,需要把 localhost 换成服务器 IP 或域名:
text
http://服务器IP:3000/books
同时确保云服务器安全组或防火墙放行了 3000 端口。真实上线时通常不会直接暴露 3000,而是用 Nginx 或网关把 80、443 转发到内部 3000。
6. 验证 CRUD
可以继续使用 curl:
bash
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"
}'
再查询:
bash
curl "http://localhost:3000/book"
如果能查询到数据,说明生产链路已经跑通:宿主机请求进入 nest-app 容器,NestJS 通过 mysql-prod 服务名访问 MySQL,MySQL 写入挂载的数据卷。
7. 停止生产环境
bash
docker compose -f docker-compose.prod.yml down
如果只是想重启:
bash
docker compose -f docker-compose.prod.yml restart
如果改了代码并想重新构建:
bash
docker compose -f docker-compose.prod.yml up -d --build
--build 很重要。没有它时,Compose 可能直接复用旧镜像,你会以为代码没生效。
十一、把项目部署到一台真实服务器
本地生产 Compose 跑通后,上服务器的思路其实一样,只是执行环境从你的电脑变成了云主机。
一个最小可行的部署流程是:
- 准备一台 Linux 服务器。
- 安装 Docker 和 Docker Compose 插件。
- 把项目代码上传到服务器。
- 在服务器上准备
.env。 - 执行
docker compose -f docker-compose.prod.yml up -d --build。 - 开放访问端口或配置反向代理。
- 配置日志、备份、重启策略和监控。
假设代码已经上传到服务器的 /opt/nest-dockerfile-test:
bash
cd /opt/nest-dockerfile-test
cp .env.example .env
docker compose -f docker-compose.prod.yml up -d --build
查看状态:
bash
docker compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.prod.yml logs -f nest-app
如果服务器使用云厂商安全组,要放行端口。学习阶段可以先放行 3000,真实生产更建议:
- 只对外开放
80和443。 - 用 Nginx 把外部请求转发到本机
3000。 - MySQL 的
3306不对公网开放。
一个简化的 Nginx 反向代理示例:
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 端口映射到了宿主机 3306,方便本机调试。但在真实公网服务器上,数据库端口不应该直接暴露给互联网。你可以删除生产 Compose 里 MySQL 的 ports,只保留容器内部访问:
yaml
mysql-prod:
image: ${MYSQL_IMAGE:-mysql:latest}
environment:
MYSQL_ROOT_PASSWORD: admin
MYSQL_DATABASE: book
没有 ports 后,宿主机外部不能直接访问 MySQL,但 nest-app 仍然可以通过 Compose 内部网络访问 mysql-prod:3306。这是更安全的生产形态。
十二、开发环境和生产环境的核心差异
很多新手会问:既然 Docker Compose 可以启动所有服务,为什么本地开发不直接把 NestJS 也放进 Compose?
答案是可以,但不是必须。是否放进 Compose,取决于你更看重什么。
当前项目采用的是"开发只容器化基础设施,业务代码本地运行"的方式:
text
本地开发:
浏览器 -> 宿主机 NestJS -> localhost:3306 -> mysql-dev 容器
优点是开发体验好。pnpm run start:dev 可以 watch 文件变化,IDE 调试也更直接。缺点是宿主机需要安装 Node 和 pnpm。
生产环境采用的是"业务服务也容器化"的方式:
text
生产部署:
浏览器 -> 宿主机 3000 -> nest-app 容器 -> mysql-prod:3306 -> MySQL 容器
优点是部署环境更一致。服务器不需要手动安装项目依赖,只要有 Docker 就能构建和启动。缺点是调试不如本地直接运行方便,镜像构建也需要时间。
如果团队希望本地开发也完全容器化,可以再加一个 nest-dev 服务,把源码挂载进容器,并在容器里运行 pnpm run start:dev。但这会引入文件监听、依赖缓存、容器内外权限等新问题。对学习和中小项目来说,当前方式更容易理解,也更稳。
十三、当前实现里值得注意的工程边界
1. synchronize: true 适合学习,不适合严肃生产
当前 TypeORM 配置里有:
ts
synchronize: true,
它会自动根据 Entity 同步表结构。学习阶段这很方便,因为你只要写 Entity,表就能自动创建。
但真实生产里,表结构变更需要可审计、可回滚、可灰度。推荐使用 TypeORM migration 或其他数据库迁移工具。生产环境可以改成:
ts
synchronize: false,
migrationsRun: true,
然后用 migration 文件管理字段新增、索引变更和数据修复。
2. 密码不应该写死在源码和 Compose 里
当前 demo 里 MySQL root 密码是 admin:
yaml
MYSQL_ROOT_PASSWORD: admin
TypeORM 里也写了:
ts
password: 'admin',
学习项目可以接受,但真实项目应该通过环境变量注入:
yaml
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
NestJS 里也应该读取:
ts
password: process.env.DB_PASSWORD,
这样密码不会写死在代码仓库,也方便不同环境使用不同配置。
3. mysql:latest 不适合长期生产
当前配置使用:
yaml
image: ${MYSQL_IMAGE:-mysql:latest}
latest 对学习很方便,但对生产有风险。因为它会随着镜像仓库更新而变化。今天拉到的可能是一个版本,几个月后重新部署拉到的是另一个版本。
生产更建议固定版本,例如:
yaml
image: mysql:8.4
Node 镜像也一样。版本越明确,部署越可复现。
4. depends_on 不是健康检查
depends_on 只保证启动顺序,不保证依赖服务已经就绪。MySQL 启动通常需要几秒到几十秒。业务容器如果启动太快,可能第一次连接失败。
更稳的做法有两个:
- 给 MySQL 配置 healthcheck。
- 应用层数据库连接开启重试。
例如 MySQL 可以增加:
yaml
healthcheck:
test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-padmin']
interval: 10s
timeout: 5s
retries: 5
然后应用层也要允许短暂失败后重连。不要把服务启动顺序当成服务可用性。
5. 当前 Dockerfile 还能继续优化
当前单阶段 Dockerfile 易懂,但镜像会包含构建所需的 devDependencies。生产可以改成多阶段构建,例如:
dockerfile
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 被占用
现象:
text
Bind for 0.0.0.0:3306 failed: port is already allocated
原因通常是本机已经安装了 MySQL,或者开发 Compose 的 mysql-dev 已经在运行,生产 Compose 又要启动 mysql-prod。
处理方式:
bash
docker ps
docker compose -f docker-compose.dev.yml down
或者把某个 Compose 的宿主机端口改成 3307:3306。
2. NestJS 报连接 MySQL 失败
先判断当前运行模式。
如果你执行的是 pnpm run start:dev,NestJS 在宿主机运行,应该连接 localhost:3306。检查:
bash
docker ps
docker compose -f docker-compose.dev.yml logs mysql
如果你执行的是生产 Compose,NestJS 在容器里运行,应该连接 mysql-prod:3306。检查:
bash
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:
json
"assets": [
{
"include": "../public/**/*",
"outDir": "dist/public"
}
]
然后重新构建:
bash
pnpm run build
如果是容器里访问失败,重新构建镜像:
bash
docker compose -f docker-compose.prod.yml up -d --build
因为静态文件需要进入 dist/public,再进入镜像。只改了 public/index.html 但没有重新 build,生产镜像不会自动变化。
4. Docker 拉镜像很慢或失败
可以使用 .env.example 中的镜像源配置:
bash
cp .env.example .env
docker compose -f docker-compose.prod.yml up -d --build
注意当前开发 Compose 里 Milvus、etcd、MinIO 的镜像地址是直接写死的。如果这些镜像拉取慢,需要单独替换为可访问的镜像源,或者提前在网络较好的环境拉取。
5. 改了代码但容器里没生效
生产容器跑的是镜像里的编译产物,不会自动读取宿主机源码。改代码后需要:
bash
docker compose -f docker-compose.prod.yml up -d --build
如果仍然没生效,可以强制重新构建:
bash
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 删除容器,但不会删除宿主机目录:
text
volumes/mysql
volumes/mysql-prod
如果你要清空学习数据,需要停止容器后删除对应目录。真实项目不要随便做这个操作,因为这等价于删除数据库数据。
十五、命令速查
本地开发:
bash
cd "/Users/zz/AI learning/codeing/tool-test/nest-dockerfile-test"
pnpm install
pnpm run docker:up
pnpm run start:dev
查看开发环境容器:
bash
docker compose -f docker-compose.dev.yml ps
docker compose -f docker-compose.dev.yml logs -f mysql
停止开发环境:
bash
pnpm run docker:down
生产构建与启动:
bash
cp .env.example .env
pnpm run docker:prod:up
查看生产环境:
bash
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
停止生产环境:
bash
docker compose -f docker-compose.prod.yml down
重新构建生产镜像:
bash
docker compose -f docker-compose.prod.yml up -d --build
测试接口:
bash
curl "http://localhost:3000/book"
访问页面:
text
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-prod 和 nest-app。NestJS 通过 NODE_ENV=production 切换数据库 host,从开发模式的 localhost 变成生产模式的 mysql-prod。这是容器化部署里最关键的认知:容器里的 localhost 不是宿主机,也不是另一个容器;容器之间应该通过 Compose 服务名通信。
如果你是新手,建议按本文顺序实践:先理解项目接口和数据库链路,再跑本地开发 Compose,最后跑生产 Compose。只要你能清楚解释"请求从浏览器到 NestJS,再到 TypeORM,再到 MySQL"的路径,也能清楚解释"开发模式和生产模式数据库 host 为什么不同",你就已经跨过了 Docker Compose 部署后端项目最重要的一道门槛。