Files
RAG/docs/02-deployment-and-security.md
YoVinchen c3bad0f186
Some checks failed
verify / verify (push) Has been cancelled
Make the offline RAG chain observable without widening credential access
Expose the verified synthetic retrieval path through a typed React client and a non-root Nginx edge while keeping database credentials and data-network reachability out of the browser tier. A fixed-origin gateway preserves request boundaries and now closes upstream streams even when downstream disconnects before body iteration. The deployment ADR and runbooks record the four-network topology and its accepted Web edge-egress risk.

Constraint: The previously exposed Bailian key must be revoked and no live provider credential may enter Git, images, logs, or the browser.
Rejected: Connect Web directly to the data network | expands lateral reach to PostgreSQL.
Rejected: Publish the database-aware API on the edge network | gives a credential-bearing process a public default route.
Rejected: Buffer streaming responses in either proxy | prevents incremental chat delivery in the future.
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Do not mark Stage 1 or Stage 2 complete until the rotated-key live smoke and remaining stage gates pass.
Tested: make verify; 65 backend tests; 14 frontend tests; Docker image build; container health and isolation probes; real HTTP demo/status/search/docs/OpenAPI checks.
Not-tested: Live Bailian models, real document ingestion, business chat SSE generation, and final browser screenshot automation because the browser skill runtime was unavailable.
2026-07-12 17:38:57 +08:00

511 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Docker 部署、配置与安全设计
| 项目 | 设计值 |
|---|---|
| 部署方式 | Docker Compose 单机 |
| 长期服务 | `web``api``worker``db` |
| 一次性服务 | `migrate` |
| 对外端口 | 仅 Nginx/Web |
| 密钥 | Docker Secret / Secret Manager |
| 数据库 | PostgreSQL 17 + pgvector 0.8.x实现时固定 digest |
## 1. 部署目标
第一版的“最简单启动”定义为:
```bash
docker compose up -d --build
```
这条命令应完成数据库健康等待、迁移、Web/API/Worker 启动。它不等于“所有数据和模型凭证自动生成”首次部署仍必须完成密钥轮换、Secret 创建和三模型能力探测。
架构定位是适合毕设、演示和单机内部使用的生产式单节点,不宣称跨主机高可用。互联网生产环境应替换托管数据库、对象存储和集中密钥管理。
## 2. Compose 服务设计
### 2.1 服务列表
| 服务 | 镜像/构建 | 职责 | 网络暴露 |
|---|---|---|---|
| `db` | 固定版本 pgvector 镜像 | 元数据、向量、任务、会话、评测 | 仅内部网络 |
| `migrate` | 与后端同镜像 | 运行 Alembic一次成功退出 | 无 |
| `api` | `backend/Dockerfile` | FastAPI、检索、问答、管理 API | 仅 internal data 网络 |
| `gateway` | 与后端同镜像 | 固定上游、请求边界、脱敏错误和流式转发 | 仅 internal ingress/data 网络 |
| `worker` | 同一后端镜像 | 解析、向量化、评测后台任务 | 无 |
| `web` | `frontend/Dockerfile` | React 静态资源、Nginx 同源入口和 SSE 代理 | `127.0.0.1:8000`;生产部署 HTTPS |
| `ocr-worker` | 可选 profile | PaddleOCR 重型任务 | 无 |
| `prometheus/grafana` | 可选 profile | 本地观测 | 默认不对公网 |
### 2.2 Compose 骨架(设计示意)
```yaml
name: geological-rag
x-runtime-config: &runtime-config
APP_ENV: ${APP_ENV:-production}
APP_NAME: ${APP_NAME:-geological-rag}
POSTGRES_HOST: db
POSTGRES_PORT: "5432"
POSTGRES_DB: ${POSTGRES_DB:-geological_rag}
POSTGRES_USER: ${POSTGRES_APP_USER:-geological_rag_app}
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_app_password
UPLOAD_ROOT: ${UPLOAD_ROOT:-/data/uploads}
MAX_UPLOAD_MB: "${MAX_UPLOAD_MB:-100}"
x-rag-config: &rag-config
DASHSCOPE_API_KEY_FILE: /run/secrets/bailian_api_key
BAILIAN_OPENAI_BASE_URL: "${BAILIAN_OPENAI_BASE_URL:?set BAILIAN_OPENAI_BASE_URL}"
BAILIAN_NATIVE_BASE_URL: "${BAILIAN_NATIVE_BASE_URL:?set BAILIAN_NATIVE_BASE_URL}"
BAILIAN_RERANK_BASE_URL: "${BAILIAN_RERANK_BASE_URL:?set BAILIAN_RERANK_BASE_URL}"
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-text-embedding-v4}
EMBEDDING_DIMENSION: "${EMBEDDING_DIMENSION:-1024}"
RERANK_MODEL: ${RERANK_MODEL:-qwen3-rerank}
LLM_MODEL: ${LLM_MODEL:-deepseek-v4-flash}
CHUNK_TARGET_TOKENS: "${CHUNK_TARGET_TOKENS:-512}"
CHUNK_MAX_TOKENS: "${CHUNK_MAX_TOKENS:-800}"
CHUNK_OVERLAP_TOKENS: "${CHUNK_OVERLAP_TOKENS:-64}"
VECTOR_TOP_K: "${VECTOR_TOP_K:-50}"
RERANK_TOP_N: "${RERANK_TOP_N:-10}"
CONTEXT_TOP_N: "${CONTEXT_TOP_N:-8}"
MAX_CONTEXT_TOKENS: "${MAX_CONTEXT_TOKENS:-10000}"
MODEL_TIMEOUT_SECONDS: "${MODEL_TIMEOUT_SECONDS:-90}"
MODEL_MAX_RETRIES: "${MODEL_MAX_RETRIES:-3}"
MODEL_MAX_CONCURRENCY: "${MODEL_MAX_CONCURRENCY:-4}"
services:
db:
image: pgvector/pgvector:0.8.2-pg17
environment:
POSTGRES_DB: ${POSTGRES_DB:-geological_rag}
POSTGRES_USER: ${POSTGRES_BOOTSTRAP_USER:-postgres}
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_bootstrap_password
POSTGRES_MIGRATOR_USER: ${POSTGRES_MIGRATOR_USER:-geological_rag_migrator}
POSTGRES_MIGRATOR_PASSWORD_FILE: /run/secrets/postgres_migrator_password
POSTGRES_APP_USER: ${POSTGRES_APP_USER:-geological_rag_app}
POSTGRES_APP_PASSWORD_FILE: /run/secrets/postgres_app_password
secrets:
- postgres_bootstrap_password
- postgres_migrator_password
- postgres_app_password
volumes:
- postgres_data:/var/lib/postgresql/data
- ./ops/postgres/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $$POSTGRES_USER -d $$POSTGRES_DB && test -f $$PGDATA/.rag-bootstrap-complete"]
interval: 5s
timeout: 3s
retries: 20
networks: [data]
restart: unless-stopped
migrate:
build: ./backend
command: ["alembic", "upgrade", "head"]
depends_on:
db:
condition: service_healthy
environment:
POSTGRES_HOST: db
POSTGRES_PORT: "5432"
POSTGRES_DB: ${POSTGRES_DB:-geological_rag}
POSTGRES_USER: ${POSTGRES_MIGRATOR_USER:-geological_rag_migrator}
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_migrator_password
secrets:
- postgres_migrator_password
networks: [data]
restart: "no"
api:
build: ./backend
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
depends_on:
migrate:
condition: service_completed_successfully
environment: *runtime-config
secrets:
- postgres_app_password
volumes:
- uploads:/data/uploads
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=2)"]
interval: 10s
timeout: 3s
retries: 12
start_period: 10s
networks: [data]
restart: unless-stopped
gateway:
build: ./backend
command: ["uvicorn", "app.gateway:app", "--host", "0.0.0.0", "--port", "8000"]
depends_on:
api:
condition: service_healthy
networks: [ingress, data]
restart: unless-stopped
worker:
build: ./backend
command: ["python", "-m", "app.workers.main"]
depends_on:
migrate:
condition: service_completed_successfully
environment:
<<: [*runtime-config, *rag-config]
WORKER_CAPABILITIES: ${WORKER_CAPABILITIES:-document_parse,embedding,rerank,evaluation}
secrets:
- postgres_app_password
- bailian_api_key
volumes:
- uploads:/data/uploads
networks: [data, egress]
restart: unless-stopped
provider-smoke:
build: ./backend
command: ["python", "-m", "app.tools.provider_smoke"]
profiles: ["tools"]
environment: *rag-config
secrets:
- bailian_api_key
networks: [egress]
restart: "no"
seed-demo:
build: ./backend
command: ["python", "-m", "app.tools.seed_demo"]
profiles: ["tools"]
depends_on:
migrate:
condition: service_completed_successfully
environment:
<<: [*runtime-config, *rag-config]
secrets:
- postgres_app_password
- bailian_api_key
networks: [data, egress]
restart: "no"
web:
build: ./frontend
depends_on:
gateway:
condition: service_healthy
ports:
- "127.0.0.1:8000:8080"
networks: [edge, ingress]
restart: unless-stopped
volumes:
postgres_data:
uploads:
networks:
edge:
driver: bridge
ingress:
driver: bridge
internal: true
data:
driver: bridge
internal: true
egress:
driver: bridge
secrets:
postgres_bootstrap_password:
file: ./secrets/postgres_bootstrap_password
postgres_migrator_password:
file: ./secrets/postgres_migrator_password
postgres_app_password:
file: ./secrets/postgres_app_password
bailian_api_key:
file: ./secrets/bailian_api_key
```
最终实现固定镜像 digest示意版本只说明可行结构。`migrate` 只有一个实例执行API 和 Worker 不在启动时并发自动迁移。首次初始化脚本只在 `db` 容器内使用 bootstrap 凭据创建 `vector` 扩展、无超级用户权限的 migrator/app 登录角色和专属 schemamigrator 拥有 schema/DDLapp 仅获运行期所需的 DML/sequence 权限及默认权限API/Worker 永远不挂载 bootstrap 或 migrator Secret。最后一个初始化脚本只有在全部 SQL 事务和授权成功后,才在 `PGDATA` 内用同文件系统 rename 原子写入 `.rag-bootstrap-complete`healthcheck 同时检查 `pg_isready` 与该哨兵,防止临时初始化 server 提前放行迁移。备份再使用独立只读角色。Compose 的 `.env` 仅做变量插值,不会自动注入容器,因此可配置的非敏感项和 `*_FILE` 路径通过 YAML anchor 在 `environment` 中完整声明。后端从各自的 `POSTGRES_PASSWORD_FILE` 读取密码后在内存中组装 DSN不把明文密码放进 `.env``DATABASE_URL`
网络分为四层:`web` 位于普通 `edge` 和 internal `ingress``gateway` 位于 `ingress + data`API、迁移、Worker 和数据库位于 internal `data`;只有明确需要云调用的一次性工具或未来 Worker 才能加入命名的 `egress`。因此浏览器入口不能横向连接数据库,数据库感知 API 也没有公网默认出口。只有无 Secret 的 Web 容器发布回环端口gateway 固定上游并继续执行请求边界和脱敏错误契约。详细理由和被否决方案见 [ADR-0004](adr/0004-secretless-web-ingress.md)。必须注意:普通 `edge` bridge 为 Web 提供宿主机端口发布时,也给 Web 留有技术上的默认公网出口;当前接受这一点的前提是 Web 无 Secret、无数据网络、无挂载且代理上游固定。若部署基线要求入口容器也完全禁网必须在宿主机防火墙/出口代理层阻断,不能把“未加入名为 egress 的网络”误写成“没有外网出口”。普通 bridge 本身也不构成域名白名单,正式联网 Worker 仍需主机防火墙或出口代理只放行百炼和对象存储域名。
### 2.3 后台任务为何不用 Redis
第一版用 PostgreSQL 任务表和租约。领取必须在一个事务中完成选择、fencing token 更新和返回,不能先 SELECT 再 UPDATE
```sql
WITH candidate AS (
SELECT id
FROM background_jobs
WHERE attempt < max_attempts
AND required_capability = ANY(:worker_capabilities)
AND (
(status = 'QUEUED' AND run_after <= now())
OR (status = 'RUNNING' AND lease_until < now())
)
ORDER BY priority DESC, run_after, created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE background_jobs AS job
SET status = 'RUNNING',
lease_owner = :worker_id,
lease_token = gen_random_uuid(),
lease_until = now() + :lease_interval,
attempt = attempt + 1,
updated_at = now()
FROM candidate
WHERE job.id = candidate.id
RETURNING job.*;
```
`background_jobs` 还包含 `job_type``required_capability``resource_type/resource_id` 和版本化 payload普通 Worker 只领取解析/向量/评测能力,`ocr-worker` 只领取 OCR 能力。心跳、阶段完成和最终提交都必须同时匹配 `job_id + lease_owner + lease_token`;旧 Worker 即使恢复也无法覆盖新租约结果。捕获到任务失败时必须在同一事务分支:若当前 `attempt < max_attempts`,转回 `QUEUED` 并按指数退避设置 `run_after`;否则直接转 `FAILED`,绝不能留下已耗尽且不可领取的 QUEUED 任务。
普通 Worker 内置每 30 秒运行一次的 maintenance loop并用固定 PostgreSQL advisory lock 保证同一时刻只有一个实例执行 reaper。reaper 只兜底 Worker 崩溃来不及回写的 RUNNING 任务:租约过期且尚有重试次数则原子转回 `QUEUED`,已耗尽则转 `FAILED`。领取、reaper 和最终提交都使用 fencing 条件;测试必须强杀执行中的 Worker分别验证“有剩余次数后可重领”“最后一次崩溃后进入 FAILED”“旧 lease token 不能提交”。为领取路径建立覆盖 `required_capability/status/run_after/lease_until/priority/created_at` 的索引,并监控过期租约与失败率。这样才可证明崩溃恢复,同时避免增加 Redis、Celery 和另一套持久化语义。只有当队列吞吐、调度或跨服务 fan-out 有实测瓶颈时再新增消息系统,并记录 ADR。
## 3. 凭证生命周期
### 3.1 立即轮换
任何已出现在对话中的旧 Key
1. 在百炼北京地域控制台重置或删除;
2. 审查旧 Key 的调用和账单记录;
3. 新建最小权限 Key仅允许本项目所需模型和部署出口 IP
4. 新 Key 写入本机 `secrets/bailian_api_key`,权限设为仅部署用户可读;
5. 不使用命令行参数或会进入 shell history 的写法传递密钥;
6. 运行三模型最小探测;
7. 记录轮换时间和责任人,不记录 Key 值。
百炼文档说明北京业务空间 Key 可配置模型范围和 IP 白名单;生产应使用最小权限,见[API Key 权限](https://help.aliyun.com/zh/model-studio/get-api-key)。
### 3.2 应用读取
配置层支持 `*_FILE`
```python
def read_secret(file_env: str, value_env: str) -> str:
secret_file = os.getenv(file_env)
if secret_file:
return Path(secret_file).read_text(encoding="utf-8").strip()
return os.environ[value_env]
```
生产只允许文件/Secret Manager本地值环境变量仅作为临时兼容。应用启动后不打印 Settings 的 Secret 字段,异常对象不得包含请求头。
### 3.3 Git 防泄漏
仓库已有三层控制:
- `.gitignore` 忽略 `.env``secrets/`、私钥、原始/私有数据和备份;
- `.githooks/pre-commit` 只扫描 staged 文件,发现长密钥格式即阻止提交;
- `.gitea/workflows/secret-scan.yml` 在 push/PR 后复检。
首次克隆执行:
```bash
make setup-hooks
make verify
```
扫描器只输出可疑文件名不回显密钥。它是防线而非保证Code Review 仍需检查配置、测试 fixture、截图、Notebook 输出和文档。
如果密钥进入 Git 历史,先轮换,再评估历史清理;顺序不能反过来。
## 4. 配置契约
### 4.1 非敏感配置
`.env.example` 只保留占位符:
```dotenv
BAILIAN_OPENAI_BASE_URL=https://<workspace-id>.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
BAILIAN_NATIVE_BASE_URL=https://<workspace-id>.cn-beijing.maas.aliyuncs.com/api/v1
BAILIAN_RERANK_BASE_URL=https://<workspace-id>.cn-beijing.maas.aliyuncs.com/compatible-api/v1
DASHSCOPE_API_KEY_FILE=/run/secrets/bailian_api_key
EMBEDDING_MODEL=text-embedding-v4
EMBEDDING_DIMENSION=1024
RERANK_MODEL=qwen3-rerank
LLM_MODEL=deepseek-v4-flash
```
启动时 fail fast
- URL 使用 HTTPS
- 三个 URL 主机相同且地域为北京;
- Rerank 路径不是 `compatible-mode/v1`
- Embedding 维度为 schema 支持的 1024
- 生产没有默认密码和通配 CORS
- Secret 文件存在、非空且权限合理;
- `APP_ENV=production` 时关闭开发文档或增加管理员保护。
### 4.2 地域与计费方案
业务空间专属域名只能用所属工作空间的 Key北京、新加坡等地域 Key 不通用Token Plan/Coding Plan 的 Key 和 Base URL 也不能用于后端服务。错误组合通常表现为 401。官方说明见[Base URL 总览](https://help.aliyun.com/zh/model-studio/base-url)。
## 5. 首次部署与模型 Preflight
### 5.1 部署前
```text
1. 轮换 Key
2. 建立 secrets/ 文件(不提交)
3. 配置真实工作空间 URL不提交
4. make setup-hooks
5. make verify
6. docker compose config确认无密钥被渲染到日志
7. docker compose up -d --build
8. 检查 /health/live 和 /health/ready
9. 执行 provider-smoke
10. 导入无敏感演示数据
```
不要在支持工单、截图或 CI 日志中粘贴 `docker compose config` 的完整敏感输出。
### 5.2 三模型最小探测
管理员命令 `provider-smoke` 的预期行为:
1. Embedding一个短文本断言 1024 维;
2. Rerank一个 query、两个 documents、`top_n=1`,断言下标和分数;
3. Chat要求返回极短固定文本关闭思考并限制输出
4. 输出只含能力名、状态、模型、耗时、request ID 和脱敏错误码;
5. 不输出请求头、Key、完整 Base URL 查询参数和文档正文。
`GET {OPENAI_BASE}/models` 可作为辅助清单,但不能证明 Rerank 路径和三个模型权限都正常,最小实际调用才是最终验证。
## 6. 网络与接口安全
### 6.1 网络边界
- 只发布 Nginx 端口数据库、API、Worker 不直接暴露公网;
- Nginx 到 API 使用内部网络;
- `data` 网络启用 Docker internal 隔离Web 不加入API/Worker 通过独立 `egress` 网络访问云服务;
- 生产入口启用 TLS、HSTS 和安全响应头;
- 出站只允许百炼/OSS 等必要域名;
- 数据库账号按迁移、应用、备份职责分权;
- 容器使用非 root 用户、只读根文件系统(需要写入的 volume 单独挂载);
- Docker socket 不挂入应用容器。
### 6.2 认证和授权
- 管理员、资料维护者、普通用户、研究者 RBAC
- Refresh Token 使用 HttpOnly、Secure、SameSite Cookie
- 每次检索 SQL 都包含授权知识库/文档过滤;
- 权限过滤必须在向量候选生成时生效;
- 文档下载、页图和原文 API 再做对象级权限校验;
- 可选 PostgreSQL Row-Level Security 作为纵深防御,不能代替应用层测试。
### 6.3 上传安全
- 扩展名和真实 MIME 双重验证;
- 流式上传,不把大文件整体放入内存;
- 限制文件大小、页数、压缩比和处理时间;
- 服务端 UUID 文件名,禁止用户路径;
- 拒绝宏、可执行文件和未知类型;
- 一期不允许用户输入任意 URL 抓文档,避免 SSRF
- 可选 ClamAV/云安全扫描;
- 加密 PDF 进入人工处理,不让 Worker 暴力破解。
## 7. RAG 特有安全
### 7.1 Prompt Injection
检索到的文档始终是不可信数据:
- 系统 Prompt 明确忽略文档内命令;
- 上下文使用结构化 `<source>` 包裹并编号;
- 模型无直接数据库、文件或网络工具权限;
- 不根据文档内容修改系统配置或权限;
- 回答引用编号由后端校验;
- 将注入样例纳入安全回归集。
### 7.2 数据出域
向量化、重排和生成都会把相应文本发送到百炼。因此:
- `cloud_processing_allowed=false` 的文档不可进入云模型链;
- 权限和保密字段在排队前检查Worker 再次检查;
- 日志与遥测不附带文档正文;
- 对个人信息和精确敏感坐标执行脱敏或拒绝;
- 若未来需要处理受限资料,必须采用审批后的本地模型部署并新增 ADR不能悄悄切换。
### 7.3 输出安全
- Markdown 清理 HTML 和危险 URL
- 对资源量、储量、投资、施工类问题显示专业免责声明;
- 生成内容不自动写回知识库;
- 用户反馈与更正进入人工审核,不直接成为新事实;
- 不让模型生成不存在的下载链接和文件路径。
## 8. 健康检查与可观测性
### 8.1 健康端点
- `/health/live`:进程事件循环可用,不检查外部模型;
- `/health/ready`:数据库、迁移版本、存储路径和配置可用;
- `/admin/providers/health`:按需调用三个模型,结果短期缓存;
- `/admin/index/health``documents.active_version_id`、searchable 切片投影、非空向量、文本哈希和 embedding profile 一致性。
百炼短时波动不应让编排器不断重启健康 API 容器。
### 8.2 日志脱敏
结构化日志保留 `trace_id`、阶段、状态、模型、Token、耗时、request ID。下列字段永久屏蔽
```text
authorization
api_key
cookie
password
secret
raw_document
embedding vector values
```
Provider 异常可能把请求信息嵌入消息,记录前使用 allowlist 选择安全字段,而不是对整段异常做不可靠字符串替换。
## 9. 备份、恢复和删除
### 9.1 备份
- PostgreSQL定期 `pg_dump`;生产可使用 WAL/PITR
- 原始文件:开发 volume 备份,生产 OSS 版本控制;
- 评测产物:配置、指标和脱敏预测与 Git commit 关联;
- Secret 不进入普通数据备份,使用密钥管理系统单独恢复流程;
- 每次正式演示前生成已验证的恢复点。
### 9.2 恢复演练
至少完成一次:
1. 在空白 Compose project 中启动数据库;
2. 恢复数据库和允许恢复的文件;
3. 校验 schema revision
4. 比较文档数、切片数、向量维度、哈希抽样;
5. 运行 20 道固定冒烟问题;
6. 保存恢复耗时和缺口。
只有生成备份文件而未恢复不算验收完成。
### 9.3 删除
用户删除文档时:
1. 事务内清空 `documents.active_version_id` 并令对应切片 `searchable=false`,阻断检索;
2. 创建物理删除任务;
3. 清理切片、文件和衍生缓存;
4. 保留最小审计记录和不可逆哈希,不保留原文;
5. 对备份中的保留周期和销毁策略做文档说明。
## 10. 上云演进
| 单机组件 | 生产替代 | 触发条件 |
|---|---|---|
| Docker volume | 阿里云 OSS | 多主机、备份和生命周期需求 |
| 单机 PostgreSQL | RDS PostgreSQL + pgvector | 高可用、PITR、容量需求 |
| DB 任务队列 | Redis/Celery 或云消息服务 | 实测队列吞吐/调度瓶颈 |
| pgvector | Qdrant/专用向量服务 | 百万级、高 QPS、复杂过滤瓶颈 |
| 本地日志 | 集中日志/指标 | 多实例和审计需求 |
任何演进都需要迁移计划、回滚方案、数据对账和 ADR不以“更云原生”为唯一理由。