Docker 从入门到实战:容器化全栈项目,再顺手编排一个 Milvus 向量数据库
写在前面
你大概率遇到过这种情况:本地跑得好好的项目,发给同事或者部署到服务器,就各种 npm install 报错、Node 版本不对、缺依赖、端口冲突......
Docker 要解决的,正是这个问题:"我的电脑能跑,你的电脑怎么跑?"
一句话概括:
Docker = 应用 + 运行环境
它把除了代码之外那一堆有版本要求的运行环境(Node、Redis、MySQL、Nginx......)一起打包成一个整体容器,方便地在任何设备上部署。
这个类比很有意思:
ini
Agent = LLM + Harness(tool + mcp + rag + skill + ...)
Docker = 应用 + 运行环境
下面我们从一个最朴素的 Node 服务开始,一步步走到全栈项目容器化,最后再用 docker-compose 编排一个 Milvus 向量数据库。
一、先搞清楚两个核心概念
Docker 世界里最重要的两个词:
| 概念 | 理解 | 类比 |
|---|---|---|
| image(镜像) | 应用程序 + 环境的只读打包 | 一个"配方/模具" |
| container(容器) | 镜像跑起来后的实例 + 隔离资源 | 按配方做出来的"菜" |
- 镜像可以理解为:
git pull image,把别人打包好的镜像拉下来。 - 容器可以理解为:镜像被
docker run之后运行起来的进程,拥有隔离的 CPU、内存、网络。
举个特别现实的场景:你入职接手一个 N 年前的 Vue2 项目,要求 Node 16 + npm 8,而你电脑装的是 Node 22,直接跑不起来。容器化就是把这些依赖各自隔离安装,互不干扰。
二、实战一:一个 Node 服务的 Dockerfile
先写一个最简单的 HTTP 服务 index.js:
js
// node 早期的 commonjs 规范
const http = require('http');
const server = http.createServer((req, res) => {
res.end("hello world");
});
server.listen(1314, '0.0.0.0', () => {
console.log('node service run on 1314');
});
再写一个 Dockerfile:
dockerfile
# 1. 选一个基础镜像,node 是官方 Node 镜像,后续指令都在这之上叠加
FROM node
# 2. 设置工作目录为 /app,不存在会自动创建(相当于 cd /app)
WORKDIR /app
# 3. 把 Dockerfile 所在目录的 index.js 复制到容器 /app 下
COPY index.js .
# 4. 启动容器时执行的命令
CMD ["node", "index.js"]
怎么理解 Dockerfile?
一个很贴切的比喻------它是蜜雪冰城的标准操作手册(SOP):
写清"先加奶茶、再加奶、放 3 勺糖、摇匀",任何人照着做,出来的味道都一样,就成了连锁店。
Dockerfile 就是一个文本"配方文件",里面写着一步步"做菜"的指令,Docker 照着做就能自动做出一个一模一样的镜像。
构建、推送、拉取三板斧:
bash
docker build -t my-hello . # 根据 Dockerfile 构建镜像
docker push my-hello # 推送到镜像仓库
docker pull my-hello # 从仓库拉取
Dockerfile 是发布项目的标准方式之一。
三、实战二:Nginx 反向代理
为什么需要 Nginx?
我们的 Node 服务监听在 1314 端口,但用户浏览器访问的是 http://localhost,默认就是 80 端口。
用户不知道后端具体跑在哪个端口上,这时候需要一个对外的入口------这就是 Nginx 的职责:监听 80 端口,把请求反向代理转发到后端真实的 1314 端口。
配置文件
写一个 nginx.conf:
nginx
events {}
http {
server {
listen 80; # 监听自己的 80 端口,接住这个请求
location / {
# host.docker.internal 是 Docker 提供的特殊域名,
# 从容器内部解析出来就是宿主机(我的电脑)的 IP
proxy_pass http://host.docker.internal:1314;
proxy_set_header Host $host;
}
}
}
启动命令拆解
bash
docker run --name my-nginx-demo \
-p 80:80 \
-v C:/Users/13361/Desktop/docker/demo/nginx.conf:/etc/nginx/nginx.conf \
-d nginx
逐个参数看:
docker run:启动一个镜像,成为可运行的容器--name my-nginx-demo:容器名字-p 80:80:本机 80 端口 : 容器 80 端口,把本机 80 映射到 nginx 容器监听的 80-v 本机配置文件:/etc/nginx/nginx.conf:把本地配置文件挂载进容器-d nginx:后台运行 nginx 镜像
一张图看懂完整链路(运维考点)
scss
用户上网 intent
→ browser(chrome) (正向代理 http)
→ localhost:80
→ docker -p 端口映射 → container(80)
→ -v 映射配置文件(local:/etc/nginx/nginx.conf)
→ nginx(image) → nginx:80
→ nginx.conf 反向代理转发
← :1314 (后端真实端口)
关键结论:Nginx 作为对外的统一入口,用户只知道 localhost:80,完全不知道后端实际运行在 1314 端口------这就是"反向代理"的考点。
四、实战三:全栈项目容器化(React + NestJS + 跨域)
接下来是一个标准的全栈 Todo 项目。
技术栈
- 前端:React 19 + TypeScript + Zustand + Axios(Vite 构建)
- 后端:NestJS 11
- 代理 :Nginx 把
80转发到后端3000 - 核心问题:跨域(CORS)
跨域是怎么来的?
浏览器同源策略要求:协议、域名、端口三者一致才算同源。
前端 Vite 开发服务器跑在 5173,后端 NestJS 跑在 3000,端口不同 → 跨域。
yaml
5173 : 3000 ← 同源策略(安全机制)触发
解决跨域的两条路
方案一:后端 CORS(服务器端配置响应头)
在 main.ts 里开启 CORS:
ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors(); // 允许跨域
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
方案二:前端代理(Vite 代理 / Nginx 反向代理)
这里我们用 Nginx 反向代理的思路:前端统一请求 /api 前缀,交给 Nginx 转发到后端,从浏览器视角看就是"同源"。
前端:封装 Axios 实例
config.ts 里创建统一的 axios 实例:
ts
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios';
// 1. 创建独立 axios 实例,baseURL 配合 Nginx 反向代理
const service = axios.create({
baseURL: '/api', // 统一请求前缀
timeout: 10000,
});
// 2. 请求拦截器:注入 Token
service.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 3. 响应拦截器:直接返回 response.data
service.interceptors.response.use(
(response) => response.data,
(error) => {
console.error('API Error:', error.message);
return Promise.reject(error);
}
);
export default service;
接口定义 todos.ts:
ts
export const fetchTodos = () => service.get<Todo[]>('/todos');
export const createTodo = (title: string) => service.post<Todo>('/todos', { title });
export const updateTodo = (id: number, patch: Partial<Todo>) =>
service.patch<Todo>(`/todos/${id}`, patch);
export const deleteTodo = (id: number) => service.delete(`/todos/${id}`);
状态管理 todoStore.ts:
ts
import { create } from 'zustand';
import { fetchTodos, createTodo } from '../api/todos';
interface TodoStore {
todos: Todo[];
fetchTodos: () => Promise<void>;
addTodo: (title: string) => Promise<void>;
}
export const useTodoStore = create<TodoStore>((set) => ({
todos: [],
fetchTodos: async () => {
const res = await fetchTodos(); // 请求通过 Nginx 代理
set({ todos: res });
},
addTodo: async (title: string) => {
const res = await createTodo(title);
// ...
},
}));
注意:这里
fetchTodos返回的已经是被响应拦截器解包过的data,所以直接set({ todos: res }),不再需要res.json()。
五、实战四:docker-compose 编排 Milvus 向量数据库
前面都是"单个镜像"的玩法。但真实项目往往由多个镜像协作组成,比如 Milvus 向量数据库就不是一个镜像,而是依赖 etcd(元数据)+ MinIO(对象存储)+ Milvus(计算引擎)三兄弟。
这时候就需要 docker-compose 来编排多个 image 的工作流。
编排文件
milvus-standalone-docker-compose.yml 核心结构:
yaml
version: '3.5'
services:
etcd:
container_name: milvus-etcd
image: quay.io/coreos/etcd:v3.5.25
# ... 环境变量、健康检查、数据卷挂载
minio:
container_name: milvus-minio
image: minio/minio:RELEASE.2024-05-28T17-19-04Z
ports:
- "9001:9001"
- "9000:9000"
# ...
standalone:
container_name: milvus-standalone
image: milvusdb/milvus:v3.0.0
command: ["milvus", "run", "standalone"]
ports:
- "19530:19530"
- "9091:9091"
depends_on:
- "etcd"
- "minio"
networks:
default:
name: milvus
三个服务的职责:
| 服务 | 作用 |
|---|---|
| etcd | 存储元数据(集合结构、索引信息等) |
| minio | 对象存储(存放实际的向量/数据文件) |
| standalone | Milvus 单机版核心服务,对外暴露 19530 端口 |
depends_on 保证启动顺序:先起 etcd 和 minio,再起 Milvus。
连接并写入向量数据
index.mjs 展示了完整流程:连接 → 建集合 → 建索引 → 加载 → 插入。
js
import "dotenv/config";
import { MilvusClient, DataType, MetricType, IndexType } from '@zilliz/milvus2-sdk-node';
import { OpenAIEmbeddings } from "@langchain/openai";
const VECTOR_DIM = 1024;
// 用 OpenAI 做向量化
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
dimensions: VECTOR_DIM,
});
// 连接 Milvus(19530 是上面 compose 暴露的端口)
const client = new MilvusClient({ address: 'localhost:19530' });
// 1. 创建集合(字段:id / vector / content / date / mood / tags)
await client.createCollection({
collection_name: 'ai_diary',
fields: [
{ name: 'id', data_type: DataType.VarChar, max_length: 50, is_primary_key: true },
{ name: 'vector', data_type: DataType.FloatVector, dim: VECTOR_DIM },
{ name: 'content', data_type: DataType.VarChar, max_length: 5000 },
{ name: 'tags', data_type: DataType.Array, element_type: DataType.VarChar, max_capacity: 10, max_length: 50 },
],
});
// 2. 建索引(IVF_FLAT + COSINE 余弦相似度)
await client.createIndex({
collection_name: 'ai_diary',
field_name: 'vector',
index_type: IndexType.IVF_FLAT,
metric_type: MetricType.COSINE,
params: { nlist: 1024 },
});
// 3. 加载集合
await client.loadCollection({ collection_name: 'ai_diary' });
// 4. 给日记文本生成 embedding 并插入
const diaryData = await Promise.all(
diaryContents.map(async (diary) => ({
...diary,
vector: await embeddings.embedQuery(diary.content),
}))
);
await client.insert({ collection_name: 'ai_diary', data: diaryData });
这个例子非常典型:Docker 负责把 Milvus 这套多组件系统一键跑起来,SDK 负责在应用层做向量化的读写。
六、常用 Docker 命令速查
bash
docker pull nginx # 拉取任意想要的镜像
docker run ... # 运行镜像成为容器
docker build -t my-hello . # 构建镜像
docker push my-hello # 推送镜像
docker stop $(docker ps -q) # 停止所有容器
docker rm $(docker ps -aq) # 删除所有容器
docker rmi nginx # 删除 nginx 镜像
docker pull mysql:8.0 # 拉取 MySQL 8.0 镜像
总结
这篇文章从零梳理了 Docker 的完整学习路径:
- 核心概念 :
image(镜像)与container(容器)的区别。 - Dockerfile:用 SOP 的思路理解镜像的"配方"构建。
- Nginx 反向代理:理解"用户只知道 80,后端在 1314"的运维考点。
- 全栈容器化:React + NestJS 项目中用 CORS 和 Nginx 代理解决跨域。
- docker-compose 编排:多个镜像协作跑起 Milvus 向量数据库,并结合 LangChain 做向量化写入。
Docker 的本质,始终是那句话------把"代码 + 运行环境"打包成一个可移植的整体,让你的项目在任何机器上都能一致地跑起来。