0. 系列闭环(不公开源码也能跟读)

端到端链路:Vue 前端 → api/routes/chat.py → Guide 多轮 SSE → run_analysis_pipeline(解析→分析→匹配→报告)→ tools/pdf_exporter PDF。
本篇:第 15/17 篇 · 部署环 · Docker

阶段 用户可见 代码入口 对应篇
建会话 欢迎语 POST /api/sessions 09
多轮对话 SSE 流式 chat/stream → run_guide_single_turn 06, 14
信息充分 开始分析 _run_analysis_background 05, 07
履历解析 进度 30% run_resume_parser 12
画像/RIASEC 进度 50% run_profile_analyzer 03, 13
职业匹配 进度 70% run_career_matcher 02
报告 进度 90% run_reporter 11
下载 PDF 文件 GET …/report/pdf 11, 15
说明
读本篇前 第 11 篇字体、第 17 篇 env
读完本篇 按 Dockerfile 阶段说明前后端如何进同一镜像
下一环 第 16 篇:入口中间件(第 16 篇)

全系列闭环索引:SERIES-LOOP.md

1. 要解决什么问题

iCan 不是纯 API 服务:前端 Vue 要打进静态目录,后端 FastAPI + LangGraph 要连 MySQL/Chroma,Embedding 依赖 sentence-transformers(间接拉 PyTorch),Reporter 还要用 ReportLab/matplotlib 出中文 PDF。

在 macOS 开发环境「一切正常」,一到 Linux 容器常见问题包括:

  • pip 自动装 CUDA 版 PyTorch,镜像体积暴涨、无 GPU 服务器白下载;
  • PDF/雷达图中文变方块(容器无 CJK 字体);
  • 前端 COPY frontend/ 把宿主机 node_modules 盖进镜像,Vite 构建异常;
  • Debian 新版的 GTK/Pango 包名变更导致 apt-get install 失败。

项目根目录的 Dockerfile + docker-compose.yml 记录了这些问题的实际解法。


2. 实现位置

文件 职责
Dockerfile 两阶段:Node 构建前端 → Python 运行镜像
docker-compose.yml 端口、环境变量默认值、模型与 Chroma 卷挂载
frontend/vite.config.js outDir: '../static'base: '/static/'
tools/pdf_exporter.py ReportLab / matplotlib 中文字体探测链
config.py EMBEDDING_MODEL_PATHCHROMA_PERSIST_DIR 等容器环境变量对应字段

Docker 多阶段构建


3. 多阶段 Dockerfile 结构

Stage 1:前端构建(node:18-alpine

1
2
3
4
5
6
7
FROM node:18-alpine AS frontend-builder
WORKDIR /build
COPY frontend/package.json frontend/package-lock.json ./
RUN npm install --registry=https://registry.npmmirror.com
COPY frontend/ ./
RUN rm -rf node_modules && npm install --registry=https://registry.npmmirror.com
RUN npx vite build && ls -la /static/

frontend/vite.config.js 把产物输出到仓库根目录的 static/(容器内 /build/../static/static/):

1
2
3
4
5
6
7
export default defineConfig({
base: '/static/',
build: {
outDir: '../static',
emptyOutDir: true,
},
})

FastAPI 从 ./static/ 挂载静态资源;base: '/static/' 保证打包后的 JS/CSS 路径与后端路由一致。

Stage 2:Python 运行(python:3.10-slim

1
2
3
4
5
6
7
8
9
10
11
FROM python:3.10-slim
WORKDIR /app
# apt:Pango/Cairo/GDK + fonts-noto-cjk
COPY requirements.txt .
RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu ... && \
pip install --no-cache-dir -r requirements.txt ... && \
pip install --no-cache-dir matplotlib ...
COPY src/ ./src/
COPY --from=frontend-builder /static/ ./static/
WORKDIR /app/src
CMD ["uvicorn", "ican.main:app", "--host", "0.0.0.0", "--port", "8000"]

注意 **WORKDIR 最终是 /app/src**,uvicorn ican.main:appsrc 为 Python 路径根,与本地 cd src && uvicorn 一致。


4. docker-compose 与环境覆盖

docker-compose.yml 不硬编码 DeepSeek,默认指向宿主机 Ollama:

1
2
3
4
5
6
7
8
environment:
- LLM_API_KEY=${LLM_API_KEY:-ollama}
- LLM_BASE_URL=${LLM_BASE_URL:-http://host.docker.internal:11434/v1}
- LLM_MODEL_CHAT=${LLM_MODEL_CHAT:-qwen3.5:9b}
- LLM_MODEL_LIGHT=${LLM_MODEL_LIGHT:-qwen3.5:9b}
- DB_URL=${DB_URL:-mysql+pymysql://root:ican2026@mysql:3306/ican?charset=utf8mb4}
- EMBEDDING_MODEL_PATH=${EMBEDDING_MODEL_PATH:-/app/models/bge-m3}
- CHROMA_PERSIST_DIR=${CHROMA_PERSIST_DIR:-/app/chroma_data}

Embedding 模型通过 volume 只读挂载:/ican/iCan/llm_models/bge-m3:/app/models/bge-m3:ro。Chroma 数据用命名卷 chroma-data 持久化。这与 config.py 里的 EMBEDDING_MODEL_PATHCHROMA_PERSIST_DIR 字段一一对应(见第 17 篇)。


5. 踩坑一:PyTorch CUDA 版被间接安装

现象

requirements.txtsentence-transformers 等依赖,若直接 pip install -r requirements.txt,pip 可能拉取带 CUDA 的 torch(体积 700MB+),CPU 服务器完全用不到。

项目解法

Dockerfile装 CPU 版 torch,再装其余依赖:

1
2
RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu -i https://pypi.tuna.tsinghua.edu.cn/simple && \
pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple

已存在的 torch 满足 sentence-transformers 依赖,避免重复拉 GPU 构建。matplotlib 单独再装一行,确保 PDF 图表依赖在 slim 镜像里齐全。


6. 踩坑二:容器内中文 PDF/图表乱码

现象

ReportLab 默认 Helvetica 不含中文;matplotlib 雷达图、柱状图标签显示为 tofu 方块。

镜像层

Dockerfile 安装 fonts-noto-cjk,并配合 PDF 渲染所需的 Pango/Cairo 栈:

1
2
3
apt-get install -y --no-install-recommends \
libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf-2.0-0 \
libcairo2 libffi-dev fonts-noto-cjk

代码层

tools/pdf_exporter.py_build_pdf 注册 ReportLab 字体时按路径探测,Linux Docker 路径排在前面

1
2
3
4
5
6
7
8
9
10
11
12
13
cn_font = "Helvetica"
for fp in [
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
# ...
"/System/Library/Fonts/PingFang.ttc", # macOS 开发机
]:
if os.path.exists(fp):
pdfmetrics.registerFont(TTFont("CNFont", fp))
cn_font = "CNFont"
break

matplotlib 部分(_generate_radar_chart_generate_bar_chart)设置:

1
2
3
4
5
6
plt.rcParams["font.sans-serif"] = [
"Noto Sans CJK SC", "WenQuanYi Zen Hei", "WenQuanYi Micro Hei",
"PingFang SC", "Heiti SC", "STHeiti", "SimHei",
"Microsoft YaHei", "Arial Unicode MS",
]
plt.rcParams["axes.unicode_minus"] = False

列表首位 "Noto Sans CJK SC" 与 apt 安装的 Noto 包名对齐。只装字体不改代码,macOS 能过、Docker 仍可能 fallback 到 Helvetica——镜像 + 代码两条线都要做


7. 踩坑三:COPY frontend 覆盖 node_modules

现象

npm install,再 COPY frontend/ ./ 会把开发者本机(可能是 macOS)的 node_modules 拷进镜像,覆盖 Linux 下刚装好的依赖,引发 Vite/esbuild 权限或平台二进制不匹配。

项目解法

1
2
COPY frontend/ ./
RUN rm -rf node_modules && npm install --registry=https://registry.npmmirror.com

等价做法是在 .dockerignore 里排除 frontend/node_modules,避免 COPY 带入。当前 Dockerfile 选择 COPY 后重装,显式且可重复。


8. 踩坑四:Debian 包名变更

现象

旧文档常见 libgdk-pixbuf2.0-0,在较新 Debian(如 trixie 系 slim 基础镜像)会报包不存在。

项目解法

使用新包名 libgdk-pixbuf-2.0-0(见 Dockerfile 第 20 行)。WeasyPrint/ReportLab 间接依赖 GDK-Pixbuf,缺包时 PDF 生成可能在 import 或渲染阶段失败,错误信息不一定直指包名,值得单独记一笔。


9. 踩坑五:compose 默认 DSN 与 MySQL 服务缺失

docker-compose.ymlDB_URL 默认指向 mysql:3306,但 compose 文件只定义了 app 服务,没有 mysql 容器。若不做额外编排,容器启动后数据库连接会失败——需要自行加 MySQL 服务、或把 DB_URL 改成宿主机可达的 DSN、或开发时用 config.py 默认的 SQLite。

同理,Embedding volume 源路径写死为 /ican/iCan/llm_models/bge-m3,换机器部署前必须改成宿主机实际路径,否则 EMBEDDING_MODEL_PATH 指向空目录,向量检索会在运行时报错。


10. 国内构建加速

同一 Dockerfile 里三处镜像源:

层级 做法
apt sed 替换 deb.debian.orgmirrors.aliyun.com(兼容 debian.sourcessources.list
pip -i https://pypi.tuna.tsinghua.edu.cn/simple
npm --registry=https://registry.npmmirror.com

PyTorch CPU 仍走官方 --index-url https://download.pytorch.org/whl/cpu,与清华源组合使用。


11. 运行时目录与数据卷

镜像内预创建:

1
RUN mkdir -p /app/chroma_data /app/uploads /app/logs /app/models

compose 把 ./logs 绑定到 /app/logs,Chroma 用命名卷。Embedding 权重不打进镜像(体积大),靠宿主机路径挂载——部署前需确认 EMBEDDING_MODEL_PATH 与 volume 源路径一致。


12. 小结

  1. 两阶段构建:Alpine Node 编前端 → slim Python 跑 API,静态文件 COPY --from=frontend-builder /static/
  2. 先 CPU torch 再 requirements,控制镜像体积,满足 Embedding 栈。
  3. fonts-noto-cjk + tools/pdf_exporter.py 字体链,PDF 与 matplotlib 中文才能稳定。
  4. COPY 前端后重装 node_modules,避免跨平台 node_modules 污染。
  5. **包名用 libgdk-pixbuf-2.0-0**,compose 用环境变量覆盖 LLM/DB,默认 Ollama 而非写死云端模型。
  6. 检查 MySQL 与 Embedding 挂载路径,compose 默认值不会自动帮你起数据库或下载模型。

下一篇:FastAPI 中间件与限流(第 16 篇)。


附录:关键源码(逐行注释)

以下代码摘自 iCan 实现,每行上方均有中文注释,不公开仓库也可跟读。
生成命令:python3 bin/build-ican-annotated-snippets.py

Dockerfile 多阶段(节选)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# ========== Dockerfile 多阶段(节选) ==========
# 源文件: iCan/Dockerfile 行 1-43

# L1: Docker 多阶段构建:定义基础镜像
FROM node:18-alpine AS frontend-builder

# L3: 设置容器内工作目录
WORKDIR /build
# L4: 复制文件/目录到镜像层
COPY frontend/package.json frontend/package-lock.json ./
# L5: 赋值:更新局部变量或 state 字段
RUN npm install --registry=https://registry.npmmirror.com
# L6: 复制文件/目录到镜像层
COPY frontend/ ./
# L7: 赋值:更新局部变量或 state 字段
RUN rm -rf node_modules && npm install --registry=https://registry.npmmirror.com
# L8: 构建时执行的 shell 命令(装依赖、编译前端等)
RUN npx vite build && ls -la /static/

# L10: Docker 多阶段构建:定义基础镜像
FROM python:3.10-slim

# L12: 设置容器内工作目录
WORKDIR /app

# L14: 构建时执行的 shell 命令(装依赖、编译前端等)
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null; \
# L15: 执行该语句(细节见上文业务描述)
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null; \
# L16: 执行该语句(细节见上文业务描述)
apt-get update && apt-get install -y --no-install-recommends \
# L17: 执行该语句(细节见上文业务描述)
build-essential \
# L18: 执行该语句(细节见上文业务描述)
libpango-1.0-0 \
# L19: 执行该语句(细节见上文业务描述)
libpangocairo-1.0-0 \
# L20: 执行该语句(细节见上文业务描述)
libgdk-pixbuf-2.0-0 \
# L21: 执行该语句(细节见上文业务描述)
libffi-dev \
# L22: 执行该语句(细节见上文业务描述)
libcairo2 \
# L23: 执行该语句(细节见上文业务描述)
fonts-noto-cjk \
# L24: 执行该语句(细节见上文业务描述)
&& rm -rf /var/lib/apt/lists/*

# L26: 复制文件/目录到镜像层
COPY requirements.txt .

# L28: 构建时执行的 shell 命令(装依赖、编译前端等)
RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu -i https://pypi.tuna.tsinghua.edu.cn/simple && \
# L29: 执行该语句(细节见上文业务描述)
pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple && \
# L30: 执行该语句(细节见上文业务描述)
pip install --no-cache-dir matplotlib -i https://pypi.tuna.tsinghua.edu.cn/simple

# L32: 复制文件/目录到镜像层
COPY pyproject.toml .
# L33: 复制文件/目录到镜像层
COPY src/ ./src/

# L35: 赋值:更新局部变量或 state 字段
COPY --from=frontend-builder /static/ ./static/

# L37: 构建时执行的 shell 命令(装依赖、编译前端等)
RUN mkdir -p /app/chroma_data /app/uploads /app/logs /app/models

# L39: 声明容器监听端口
EXPOSE 8000

# L41: 设置容器内工作目录
WORKDIR /app/src

# L43: 容器启动命令(生产入口)
CMD ["uvicorn", "ican.main:app", "--host", "0.0.0.0", "--port", "8000"]

PDF 字体注册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# ========== PDF 字体注册 ==========
# 源文件: tools/pdf_exporter.py 行 283-330

# L283: 同步函数 _build_pdf:路由决策或工厂方法
def _build_pdf(report_md: str, title: str, show_charts: bool) -> bytes:
# L284: 导入依赖模块
from reportlab.lib.pagesizes import A4
# L285: 导入依赖模块
from reportlab.platypus import (
# L286: 执行该语句(细节见上文业务描述)
SimpleDocTemplate, Paragraph, Spacer, Image, Table, TableStyle,
# L287: 执行该语句(细节见上文业务描述)
KeepTogether,
# L288: 执行该语句(细节见上文业务描述)
)
# L289: 导入依赖模块
from reportlab.lib.styles import ParagraphStyle
# L290: 导入依赖模块
from reportlab.lib.units import cm
# L291: 导入依赖模块
from reportlab.lib import colors
# L292: 导入依赖模块
from reportlab.pdfbase import pdfmetrics
# L293: 导入依赖模块
from reportlab.pdfbase.ttfonts import TTFont
# L294: 导入依赖模块
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT

# L296: 赋值:更新局部变量或 state 字段
buf = io.BytesIO()
# L297: 赋值:更新局部变量或 state 字段
doc = SimpleDocTemplate(
# L298: 执行该语句(细节见上文业务描述)
buf,
# L299: 赋值:更新局部变量或 state 字段
pagesize=A4,
# L300: 赋值:更新局部变量或 state 字段
leftMargin=2 * cm,
# L301: 赋值:更新局部变量或 state 字段
rightMargin=2 * cm,
# L302: 赋值:更新局部变量或 state 字段
topMargin=2 * cm,
# L303: 赋值:更新局部变量或 state 字段
bottomMargin=2 * cm,
# L304: 执行该语句(细节见上文业务描述)
)

# L306: 赋值:更新局部变量或 state 字段
cn_font = "Helvetica"
# L307: 循环
for fp in [
# L308: 执行该语句(细节见上文业务描述)
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
# L309: 执行该语句(细节见上文业务描述)
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
# L310: 执行该语句(细节见上文业务描述)
"/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf",
# L311: 执行该语句(细节见上文业务描述)
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
# L312: 执行该语句(细节见上文业务描述)
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
# L313: 执行该语句(细节见上文业务描述)
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
# L314: 执行该语句(细节见上文业务描述)
"/System/Library/Fonts/PingFang.ttc",
# L315: 执行该语句(细节见上文业务描述)
"/System/Library/Fonts/STHeiti Light.ttc",
# L316: 执行该语句(细节见上文业务描述)
"/System/Library/Fonts/Hiragino Sans GB.ttc",
# L317: 执行该语句(细节见上文业务描述)
"/Library/Fonts/Arial Unicode.ttf",
# L318: 执行该语句(细节见上文业务描述)
]:
# L319: 条件分支
if os.path.exists(fp):
# L320: 开始 try 块,后续 except 负责兜底
try:
# L321: 执行该语句(细节见上文业务描述)
pdfmetrics.registerFont(TTFont("CNFont", fp))
# L322: 赋值:更新局部变量或 state 字段
cn_font = "CNFont"
# L323: 执行该语句(细节见上文业务描述)
break
# L324: 捕获异常,避免整图/整请求崩溃
except Exception:
# L325: 执行该语句(细节见上文业务描述)
pass

# L327: 赋值:更新局部变量或 state 字段
accent_c = colors.HexColor("#0d9488")
# L328: 赋值:更新局部变量或 state 字段
text_c = colors.HexColor("#1f2937")
# L329: 赋值:更新局部变量或 state 字段
gray_c = colors.HexColor("#6b7280")
# L330: 赋值:更新局部变量或 state 字段
border_c = colors.HexColor("#e5e7eb")

系列导航

主题
1 系统全景
2 五 Agent 协作
3 霍兰德 RIASEC
4–7 状态 · 路由 · 嵌套 · 容错
8–11 LLM 层 · SSE/WS · DB 迁移 · PDF
12–14 JSON Prompt · RIASEC Prompt · Guide Prompt
15–17 Docker · 中间件 · 配置

← 返回 iCan 专题