Skip to content

Commit b4a418b

Browse files
committed
build: slim docker image and harden vps deploy
1 parent 35a8c06 commit b4a418b

6 files changed

Lines changed: 236 additions & 56 deletions

File tree

.dockerignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
.venv*/
2+
.venv-batch/
3+
.venv-mineru/
4+
.venv-vector/
5+
data/
6+
state/
7+
scripts/
28
data/raw_pdf/
39
data/parsed/
410
data/mineru_output/

.github/workflows/deploy.yml

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,5 @@ jobs:
1616
script: |
1717
cd /root/cross-subject-knowledge
1818
git pull origin main
19-
mkdir -p data/index state/logs state/cache state/tmp state/batch
20-
if [ ! -f data/textbook_chunks.index ] && [ ! -f data/index/textbook_chunks.index ]; then
21-
echo "ERROR: missing required runtime asset textbook_chunks.index (checked data/ and data/index/)"
22-
exit 1
23-
fi
24-
docker build -t textbook-knowledge .
25-
docker stop textbook-knowledge || true
26-
docker rm textbook-knowledge || true
27-
docker run -d \
28-
--name textbook-knowledge \
29-
--restart unless-stopped \
30-
-p 8080:8080 \
31-
-e PROJECT_ROOT=/app \
32-
-e DATA_ROOT=/data \
33-
-e STATE_ROOT=/state \
34-
-v /root/cross-subject-knowledge/data:/data \
35-
-v /root/cross-subject-knowledge/state:/state \
36-
textbook-knowledge
37-
# Wait for startup and verify health
38-
sleep 15
39-
curl -sf http://localhost:8080/api/health || echo "WARNING: health check failed"
40-
echo "=== Deployed at $(date) ==="
19+
chmod +x scripts/deploy_vps.sh
20+
./scripts/deploy_vps.sh

Dockerfile

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,43 @@ FROM python:3.13-slim
22

33
WORKDIR /app
44

5-
# Install system deps for faiss
6-
RUN apt-get update && apt-get install -y --no-install-recommends \
7-
build-essential && \
8-
rm -rf /var/lib/apt/lists/*
9-
10-
# Install Python deps: web framework + AI/NLP stack
11-
RUN pip install --no-cache-dir \
12-
fastapi uvicorn \
13-
faiss-cpu \
14-
sentence-transformers \
15-
jieba \
16-
cachetools
17-
18-
# Copy app
5+
ENV PYTHONDONTWRITEBYTECODE=1 \
6+
PYTHONUNBUFFERED=1 \
7+
PIP_NO_CACHE_DIR=1 \
8+
PROJECT_ROOT=/app \
9+
DATA_ROOT=/data \
10+
STATE_ROOT=/state \
11+
PORT=8080 \
12+
HF_HOME=/state/cache/huggingface \
13+
SENTENCE_TRANSFORMERS_HOME=/state/cache/sentence_transformers \
14+
TRANSFORMERS_CACHE=/state/cache/huggingface/transformers
15+
16+
COPY requirements.runtime.txt ./
17+
18+
# Force CPU-only torch wheels on Linux x86 to avoid pulling multi-GB CUDA deps
19+
# into a no-GPU production image.
20+
RUN python -m pip install --no-cache-dir --upgrade pip && \
21+
python -m pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cpu "torch==2.10.0+cpu" && \
22+
python -m pip install --no-cache-dir -r requirements.runtime.txt
23+
1924
COPY backend/ backend/
2025
COPY frontend/ frontend/
2126

2227
# Runtime-mounted data/state directories
23-
RUN mkdir -p /data/index /state/logs /state/cache /state/tmp /state/batch
28+
RUN mkdir -p \
29+
/data/index \
30+
/state/logs \
31+
/state/cache/huggingface \
32+
/state/cache/sentence_transformers \
33+
/state/tmp \
34+
/state/batch
2435

2536
# Images served from Cloudflare R2 CDN (img.rdfzer.com)
26-
# No longer baked into Docker image
27-
28-
ENV PROJECT_ROOT=/app
29-
ENV DATA_ROOT=/data
30-
ENV STATE_ROOT=/state
31-
ENV PORT=8080
32-
# Pre-download the embedding model at build time so startup is fast
33-
RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('BAAI/bge-m3')"
37+
# No longer baked into Docker image.
3438

3539
EXPOSE 8080
3640

37-
# Health check: auto-restart if backend is unresponsive
38-
HEALTHCHECK --interval=60s --timeout=10s --retries=3 \
39-
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/api/health')" || exit 1
41+
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
42+
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/api/health', timeout=5)" || exit 1
4043

4144
CMD ["sh", "/app/backend/entrypoint.sh"]

README.md

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,15 @@
121121
│ └── assets/
122122
│ ├── style.css # 暗色主题 + 响应式(640px/380px)
123123
│ └── app.js # D3.js 图谱 + 高级搜索 + AI 解读
124-
└── data/index/
125-
├── textbook_mineru_fts.db # FTS5 索引 + 概念图谱 (142MB)
126-
└── textbook_chunks.index # FAISS 向量索引 (61MB, 15,652 vectors)
124+
125+
/data/index/ # 宿主机挂载的运行时数据
126+
├── textbook_mineru_fts.db # FTS5 索引 + 概念图谱
127+
├── textbook_chunks.index # FAISS 向量索引
128+
└── textbook_chunks.manifest.json
129+
130+
/state/cache/ # 宿主机挂载的运行时缓存
131+
├── huggingface/ # Transformers / HF 模型缓存
132+
└── sentence_transformers/
127133
```
128134

129135
> 📷 **图片不在 Docker 中** — 87K 张原图托管在 Cloudflare R2(`img.rdfzer.com`),前端通过 CDN URL 直接加载。
@@ -242,12 +248,35 @@ docker run -d --name textbook-knowledge \
242248

243249
| 服务 | 内容 | 大小 |
244250
|------|------|------|
245-
| VPS | Docker 容器(代码 + 索引| 467 MB |
251+
| VPS | Docker 镜像 + 容器(代码,运行时数据走挂载| 约 1-2 GB |
246252
| Cloudflare R2 (`img.rdfzer.com`) | 87,156 张跨学科教材原图及页面图 | 4.2 GB |
247253
| GitHub | 源代码 | < 1 MB |
248254

249255
---
250256

257+
## VPS 推荐规格
258+
259+
当前线上运行时实测:
260+
261+
- 应用容器常驻内存约 **1.2 GiB**
262+
- 宿主机总内存 **5.8 GiB** 时运行稳定
263+
- 运行时数据目录约 **< 1 GiB**
264+
- 生产风险点主要不在数据库,而在 **Docker 镜像体积****历史镜像堆积**
265+
266+
推荐规格:
267+
268+
- **最低可用**:2 vCPU / 4 GB RAM / 25 GB SSD
269+
- **推荐生产**:4 vCPU / 8 GB RAM / 60 GB SSD
270+
- **如果同机还跑别的服务或要在 VPS 本机 `docker build`**:建议 4 vCPU / 8 GB RAM / 80 GB SSD
271+
272+
说明:
273+
274+
- 本项目生产不需要 GPU
275+
- FAISS 重建和批量数据加工继续放在离线机器,本 VPS 只承担运行时检索与对话服务
276+
- 模型缓存现在建议落在宿主机 `state/cache/`,不要再烘进镜像层
277+
278+
---
279+
251280
## 🏗️ 架构与部署逻辑 (CI/CD)
252281

253282
本项目采用了**「代码库与大体积数据彻底剥离」**的设计原则。
@@ -262,8 +291,10 @@ docker run -d --name textbook-knowledge \
262291
1. 开发者在本地修改代码后,`git push` 到 GitHub `main` 分支。
263292
2. GitHub Actions 自动触发,SSH 连入生产服务器 (VPS: `sun.bdfz.net`)。
264293
3. 在 VPS 上执行 `git pull` 拉取最新代码。
265-
4. 基于新的代码 `docker build` 重建应用镜像。
266-
5. 重启 Docker 容器,服务在后台无缝热更新。
294+
4. 在 VPS 上先构建新镜像,再停旧容器,避免“构建失败直接打挂线上”。
295+
5. 新容器通过 `/api/health` 健康检查后才算部署成功;失败则自动回滚到上一镜像。
296+
6. 运行时模型缓存保存在宿主机 `state/cache/`,避免每次发版都把 Hugging Face 缓存烘进镜像。
297+
7. 部署完成后自动清理悬空镜像,并只保留最近几份 `pre-*` 回滚镜像。
267298

268299
### 3. 服务器 (VPS) 迁移指南
269300
由于大头数据 (4GB+ 图片) 都在云端 CDN,如果未来需要更换服务器提供商,迁移将极其简单轻量:
@@ -290,9 +321,20 @@ uvicorn backend.main:app --host 0.0.0.0 --port 8080
290321
### Docker 运行
291322

292323
```bash
293-
# 需要将 FTS 数据库和 FAISS 索引放到 data/ 目录
324+
# 需要先准备运行时数据目录
294325
docker build -t textbook-knowledge .
295-
docker run -p 8080:8080 textbook-knowledge
326+
docker run -d --name textbook-knowledge \
327+
--restart unless-stopped \
328+
-p 8080:8080 \
329+
-e PROJECT_ROOT=/app \
330+
-e DATA_ROOT=/data \
331+
-e STATE_ROOT=/state \
332+
-e HF_HOME=/state/cache/huggingface \
333+
-e SENTENCE_TRANSFORMERS_HOME=/state/cache/sentence_transformers \
334+
-e TRANSFORMERS_CACHE=/state/cache/huggingface/transformers \
335+
-v "$(pwd)/data:/data" \
336+
-v "$(pwd)/state:/state" \
337+
textbook-knowledge
296338
```
297339

298340
### 从头处理数据

requirements.runtime.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
fastapi==0.135.1
2+
uvicorn==0.41.0
3+
faiss-cpu==1.13.2
4+
sentence-transformers==5.2.3
5+
jieba==0.42.1
6+
cachetools==7.0.2
7+
transformers==5.2.0
8+
huggingface_hub==1.5.0

scripts/deploy_vps.sh

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
APP_ROOT="/root/cross-subject-knowledge"
5+
CONTAINER_NAME="textbook-knowledge"
6+
REPO_NAME="textbook-knowledge"
7+
EMBEDDER_NAME="${EMBEDDER_NAME:-BAAI/bge-m3}"
8+
9+
cd "$APP_ROOT"
10+
11+
mkdir -p \
12+
data/index \
13+
state/logs \
14+
state/cache/huggingface \
15+
state/cache/sentence_transformers \
16+
state/tmp \
17+
state/batch
18+
19+
if [ ! -f data/index/textbook_mineru_fts.db ]; then
20+
echo "ERROR: missing runtime DB data/index/textbook_mineru_fts.db"
21+
exit 1
22+
fi
23+
24+
if [ ! -f data/index/textbook_chunks.index ]; then
25+
echo "ERROR: missing runtime FAISS index data/index/textbook_chunks.index"
26+
exit 1
27+
fi
28+
29+
commit_sha="$(git rev-parse --short HEAD)"
30+
build_stamp="$(date +%Y%m%d_%H%M%S)"
31+
build_tag="${REPO_NAME}:build-${commit_sha}-${build_stamp}"
32+
backup_tag="${REPO_NAME}:pre-${build_stamp}"
33+
rollback_image=""
34+
35+
host_cache_has_model() {
36+
find "${APP_ROOT}/state/cache/huggingface" -type f | grep -q .
37+
}
38+
39+
echo "=== deploy start $(date -u '+%Y-%m-%d %H:%M:%S UTC') commit=${commit_sha} ==="
40+
df -h /
41+
42+
if docker ps -a --format '{{.Names}}' | grep -qx "${CONTAINER_NAME}"; then
43+
rollback_image="$(docker inspect --format '{{.Image}}' "${CONTAINER_NAME}")"
44+
fi
45+
46+
if docker image inspect "${REPO_NAME}:latest" >/dev/null 2>&1; then
47+
docker tag "${REPO_NAME}:latest" "${backup_tag}" || true
48+
fi
49+
50+
export DOCKER_BUILDKIT=1
51+
docker build --pull -t "${build_tag}" -t "${REPO_NAME}:latest" .
52+
53+
if ! host_cache_has_model; then
54+
echo "=== bootstrapping host model cache ==="
55+
if docker ps -a --format '{{.Names}}' | grep -qx "${CONTAINER_NAME}"; then
56+
docker cp "${CONTAINER_NAME}:/root/.cache/huggingface/." "${APP_ROOT}/state/cache/huggingface/" >/dev/null 2>&1 || true
57+
fi
58+
fi
59+
60+
if ! host_cache_has_model; then
61+
echo "=== warming model cache with ${EMBEDDER_NAME} ==="
62+
docker run --rm \
63+
-e HF_HOME=/state/cache/huggingface \
64+
-e SENTENCE_TRANSFORMERS_HOME=/state/cache/sentence_transformers \
65+
-e TRANSFORMERS_CACHE=/state/cache/huggingface/transformers \
66+
-v "${APP_ROOT}/state:/state" \
67+
"${build_tag}" \
68+
python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('${EMBEDDER_NAME}')"
69+
fi
70+
71+
docker stop "${CONTAINER_NAME}" >/dev/null 2>&1 || true
72+
docker rm "${CONTAINER_NAME}" >/dev/null 2>&1 || true
73+
74+
docker run -d \
75+
--name "${CONTAINER_NAME}" \
76+
--restart unless-stopped \
77+
-p 8080:8080 \
78+
-e PROJECT_ROOT=/app \
79+
-e DATA_ROOT=/data \
80+
-e STATE_ROOT=/state \
81+
-e HF_HOME=/state/cache/huggingface \
82+
-e SENTENCE_TRANSFORMERS_HOME=/state/cache/sentence_transformers \
83+
-e TRANSFORMERS_CACHE=/state/cache/huggingface/transformers \
84+
-v "${APP_ROOT}/data:/data" \
85+
-v "${APP_ROOT}/state:/state" \
86+
"${build_tag}" >/dev/null
87+
88+
healthy="false"
89+
for _ in $(seq 1 24); do
90+
status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${CONTAINER_NAME}" 2>/dev/null || echo "missing")"
91+
if [ "${status}" = "healthy" ] || [ "${status}" = "running" ]; then
92+
if curl -sf http://127.0.0.1:8080/api/health >/tmp/textbook_health.json; then
93+
healthy="true"
94+
break
95+
fi
96+
fi
97+
sleep 5
98+
done
99+
100+
if [ "${healthy}" != "true" ]; then
101+
echo "ERROR: new container failed health checks"
102+
docker logs --tail 200 "${CONTAINER_NAME}" || true
103+
docker stop "${CONTAINER_NAME}" >/dev/null 2>&1 || true
104+
docker rm "${CONTAINER_NAME}" >/dev/null 2>&1 || true
105+
if [ -n "${rollback_image}" ]; then
106+
echo "=== rolling back to previous image ${rollback_image} ==="
107+
docker run -d \
108+
--name "${CONTAINER_NAME}" \
109+
--restart unless-stopped \
110+
-p 8080:8080 \
111+
-e PROJECT_ROOT=/app \
112+
-e DATA_ROOT=/data \
113+
-e STATE_ROOT=/state \
114+
-e HF_HOME=/state/cache/huggingface \
115+
-e SENTENCE_TRANSFORMERS_HOME=/state/cache/sentence_transformers \
116+
-e TRANSFORMERS_CACHE=/state/cache/huggingface/transformers \
117+
-v "${APP_ROOT}/data:/data" \
118+
-v "${APP_ROOT}/state:/state" \
119+
"${rollback_image}" >/dev/null
120+
fi
121+
exit 1
122+
fi
123+
124+
echo "=== health ==="
125+
cat /tmp/textbook_health.json
126+
echo
127+
128+
docker image prune -f >/dev/null 2>&1 || true
129+
130+
old_pre_tags="$(docker images --format '{{.Repository}}:{{.Tag}}' | grep '^textbook-knowledge:pre-' | sort -r | tail -n +4 || true)"
131+
if [ -n "${old_pre_tags}" ]; then
132+
while IFS= read -r image_ref; do
133+
[ -n "${image_ref}" ] || continue
134+
docker rmi "${image_ref}" >/dev/null 2>&1 || true
135+
done <<< "${old_pre_tags}"
136+
fi
137+
138+
echo "=== disk after deploy ==="
139+
df -h /
140+
docker system df
141+
echo "=== deploy ok $(date -u '+%Y-%m-%d %H:%M:%S UTC') tag=${build_tag} ==="

0 commit comments

Comments
 (0)