The project begins with architecture, data governance, reproducible evaluation, deployment boundaries, and secret-handling contracts so later code has measurable acceptance criteria. Constraint: Real provider credentials, workspace identities, and restricted geological data must never enter Git Rejected: Add placeholder runnable services in the design commit | would imply unverified implementation readiness Confidence: high Scope-risk: narrow Reversibility: clean Directive: Run make verify before every commit and update ADRs when architecture boundaries change Tested: Secret scan, Markdown links, YAML parse, shell syntax, and staged diff validation Not-tested: Application runtime is intentionally deferred to the implementation stage
20 KiB
Docker 部署、配置与安全设计
| 项目 | 设计值 |
|---|---|
| 部署方式 | Docker Compose 单机 |
| 长期服务 | web、api、worker、db |
| 一次性服务 | migrate |
| 对外端口 | 仅 Nginx/Web |
| 密钥 | Docker Secret / Secret Manager |
| 数据库 | PostgreSQL 17 + pgvector 0.8.x(实现时固定 digest) |
1. 部署目标
第一版的“最简单启动”定义为:
docker compose up -d --build
这条命令应完成数据库健康等待、迁移、Web/API/Worker 启动。它不等于“所有数据和模型凭证自动生成”,首次部署仍必须完成密钥轮换、Secret 创建和三模型能力探测。
架构定位是适合毕设、演示和单机内部使用的生产式单节点,不宣称跨主机高可用。互联网生产环境应替换托管数据库、对象存储和集中密钥管理。
2. Compose 服务设计
2.1 服务列表
| 服务 | 镜像/构建 | 职责 | 网络暴露 |
|---|---|---|---|
db |
固定版本 pgvector 镜像 | 元数据、向量、任务、会话、评测 | 仅内部网络 |
migrate |
与后端同镜像 | 运行 Alembic,一次成功退出 | 无 |
api |
backend/Dockerfile |
FastAPI、检索、问答、管理 API | 仅由 Web 代理 |
worker |
同一后端镜像 | 解析、向量化、评测后台任务 | 无 |
web |
frontend/Dockerfile |
Nginx 静态资源和反向代理 | 宿主机 8080/HTTPS |
ocr-worker |
可选 profile | PaddleOCR 重型任务 | 无 |
prometheus/grafana |
可选 profile | 本地观测 | 默认不对公网 |
2.2 Compose 骨架(设计示意)
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: ["python", "-m", "app.main"]
depends_on:
migrate:
condition: service_completed_successfully
environment:
<<: [*runtime-config, *rag-config]
APP_SECRET_KEY_FILE: /run/secrets/app_secret_key
secrets:
- postgres_app_password
- app_secret_key
- bailian_api_key
volumes:
- uploads:/data/uploads
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health/ready', timeout=2)"]
interval: 10s
timeout: 3s
retries: 12
start_period: 10s
networks: [edge, data, egress]
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:
api:
condition: service_healthy
ports:
- "8080:80"
networks: [edge]
restart: unless-stopped
volumes:
postgres_data:
uploads:
networks:
edge:
driver: bridge
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
app_secret_key:
file: ./secrets/app_secret_key
bailian_api_key:
file: ./secrets/bailian_api_key
最终实现固定镜像 digest,示意版本只说明可行结构。migrate 只有一个实例执行;API 和 Worker 不在启动时并发自动迁移。首次初始化脚本只在 db 容器内使用 bootstrap 凭据创建 vector 扩展、无超级用户权限的 migrator/app 登录角色和专属 schema;migrator 拥有 schema/DDL,app 仅获运行期所需的 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 + api 位于 edge,api + worker + migrate + db 位于 data,只有 api + worker 位于 egress。data 设置 internal: true,所以 Web 无法横向连接数据库;普通 bridge 本身不构成出站域名白名单,生产机仍需用主机防火墙/出口代理仅放行百炼和对象存储域名。
2.3 后台任务为何不用 Redis
第一版用 PostgreSQL 任务表和租约。领取必须在一个事务中完成选择、fencing token 更新和返回,不能先 SELECT 再 UPDATE:
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:
- 在百炼北京地域控制台重置或删除;
- 审查旧 Key 的调用和账单记录;
- 新建最小权限 Key,仅允许本项目所需模型和部署出口 IP;
- 新 Key 写入本机
secrets/bailian_api_key,权限设为仅部署用户可读; - 不使用命令行参数或会进入 shell history 的写法传递密钥;
- 运行三模型最小探测;
- 记录轮换时间和责任人,不记录 Key 值。
百炼文档说明北京业务空间 Key 可配置模型范围和 IP 白名单;生产应使用最小权限,见API Key 权限。
3.2 应用读取
配置层支持 *_FILE:
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 后复检。
首次克隆执行:
make setup-hooks
make verify
扫描器只输出可疑文件名,不回显密钥。它是防线而非保证;Code Review 仍需检查配置、测试 fixture、截图、Notebook 输出和文档。
如果密钥进入 Git 历史,先轮换,再评估历史清理;顺序不能反过来。
4. 配置契约
4.1 非敏感配置
.env.example 只保留占位符:
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 总览。
5. 首次部署与模型 Preflight
5.1 部署前
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 的预期行为:
- Embedding:一个短文本,断言 1024 维;
- Rerank:一个 query、两个 documents、
top_n=1,断言下标和分数; - Chat:要求返回极短固定文本,关闭思考并限制输出;
- 输出只含能力名、状态、模型、耗时、request ID 和脱敏错误码;
- 不输出请求头、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。下列字段永久屏蔽:
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 恢复演练
至少完成一次:
- 在空白 Compose project 中启动数据库;
- 恢复数据库和允许恢复的文件;
- 校验 schema revision;
- 比较文档数、切片数、向量维度、哈希抽样;
- 运行 20 道固定冒烟问题;
- 保存恢复耗时和缺口。
只有生成备份文件而未恢复不算验收完成。
9.3 删除
用户删除文档时:
- 事务内清空
documents.active_version_id并令对应切片searchable=false,阻断检索; - 创建物理删除任务;
- 清理切片、文件和衍生缓存;
- 保留最小审计记录和不可逆哈希,不保留原文;
- 对备份中的保留周期和销毁策略做文档说明。
10. 上云演进
| 单机组件 | 生产替代 | 触发条件 |
|---|---|---|
| Docker volume | 阿里云 OSS | 多主机、备份和生命周期需求 |
| 单机 PostgreSQL | RDS PostgreSQL + pgvector | 高可用、PITR、容量需求 |
| DB 任务队列 | Redis/Celery 或云消息服务 | 实测队列吞吐/调度瓶颈 |
| pgvector | Qdrant/专用向量服务 | 百万级、高 QPS、复杂过滤瓶颈 |
| 本地日志 | 集中日志/指标 | 多实例和审计需求 |
任何演进都需要迁移计划、回滚方案、数据对账和 ADR,不以“更云原生”为唯一理由。