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_PATH、CHROMA_PERSIST_DIR 等容器环境变量对应字段 |

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
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:app 以 src 为 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_PATH、CHROMA_PERSIST_DIR 字段一一对应(见第 17 篇)。
5. 踩坑一:PyTorch CUDA 版被间接安装
现象
requirements.txt 含 sentence-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", ]: 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.yml 里 DB_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.org → mirrors.aliyun.com(兼容 debian.sources 与 sources.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. 小结
- 两阶段构建:Alpine Node 编前端 → slim Python 跑 API,静态文件
COPY --from=frontend-builder /static/。
- 先 CPU torch 再 requirements,控制镜像体积,满足 Embedding 栈。
fonts-noto-cjk + tools/pdf_exporter.py 字体链,PDF 与 matplotlib 中文才能稳定。
- COPY 前端后重装 node_modules,避免跨平台 node_modules 污染。
- **包名用
libgdk-pixbuf-2.0-0**,compose 用环境变量覆盖 LLM/DB,默认 Ollama 而非写死云端模型。
- 检查 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
|
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/
FROM python:3.10-slim
WORKDIR /app
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null; \
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null; \
apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgdk-pixbuf-2.0-0 \
libffi-dev \
libcairo2 \
fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
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 && \
pip install --no-cache-dir matplotlib -i https://pypi.tuna.tsinghua.edu.cn/simple
COPY pyproject.toml .
COPY src/ ./src/
COPY --from=frontend-builder /static/ ./static/
RUN mkdir -p /app/chroma_data /app/uploads /app/logs /app/models
EXPOSE 8000
WORKDIR /app/src
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
|
def _build_pdf(report_md: str, title: str, show_charts: bool) -> bytes:
from reportlab.lib.pagesizes import A4
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Image, Table, TableStyle,
KeepTogether,
)
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import cm
from reportlab.lib import colors
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT
buf = io.BytesIO()
doc = SimpleDocTemplate(
buf,
pagesize=A4,
leftMargin=2 * cm,
rightMargin=2 * cm,
topMargin=2 * cm,
bottomMargin=2 * cm,
)
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",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/Library/Fonts/Arial Unicode.ttf",
]:
if os.path.exists(fp):
try:
pdfmetrics.registerFont(TTFont("CNFont", fp))
cn_font = "CNFont"
break
except Exception:
pass
accent_c = colors.HexColor("#0d9488")
text_c = colors.HexColor("#1f2937")
gray_c = colors.HexColor("#6b7280")
border_c = colors.HexColor("#e5e7eb")
|
系列导航
← 返回 iCan 专题