# Docker 部署、配置与安全设计 | 项目 | 设计值 | |---|---| | 部署方式 | Docker Compose 单机 | | 长期服务 | 当前 `web`、`gateway`、`api`、`model-gateway`、`db`;后续加入 `worker` | | 一次性服务 | `migrate` | | 对外端口 | 仅 Nginx/Web | | 密钥 | Docker Secret / Secret Manager | | 数据库 | PostgreSQL 17 + pgvector 0.8.x(实现时固定 digest) | ## 1. 部署目标 第一版的“最简单启动”定义为: ```bash docker compose up -d --build ``` 这条命令当前完成数据库健康等待、一次性迁移、`model-gateway`、API、入口 Gateway 和 Web 启动;Worker 落地后也遵循同一依赖链。它不等于“所有数据和模型凭证自动生成”,首次部署仍必须完成密钥轮换、Secret 创建和三模型能力探测。 架构定位是适合毕设、演示和单机内部使用的生产式单节点,不宣称跨主机高可用。互联网生产环境应替换托管数据库、对象存储和集中密钥管理。 ## 2. Compose 服务设计 ### 2.1 服务列表 | 服务 | 镜像/构建 | 职责 | 网络暴露 | |---|---|---|---| | `db` | 固定版本 pgvector 镜像 | 元数据、向量、任务、会话、评测 | 仅内部网络 | | `migrate` | 与后端同镜像 | 运行 Alembic,一次成功退出 | 无 | | `api` | `backend/Dockerfile` | FastAPI、检索、问答、管理 API | 仅 internal data/model 网络 | | `gateway` | 与后端同镜像 | 固定上游、请求边界、脱敏错误和流式转发 | 仅 internal ingress/data 网络 | | `model-gateway` | 与后端同镜像 | 唯一读取百炼 Key,代理 Embedding/Rerank/Chat | 仅 internal model + egress 网络,无宿主机端口 | | `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}" x-model-client-config: &model-client-config MODEL_GATEWAY_BASE_URL: http://model-gateway:8000 MODEL_GATEWAY_TOKEN_FILE: /run/secrets/model_gateway_api_token MODEL_GATEWAY_CALLER: api 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" model-gateway: build: ./backend command: ["uvicorn", "app.model_gateway:app", "--host", "0.0.0.0", "--port", "8000"] environment: <<: *rag-config MODEL_GATEWAY_ALLOWED_TOKEN_FILES: >- api=/run/secrets/model_gateway_api_token,worker=/run/secrets/model_gateway_worker_token secrets: - bailian_api_key - model_gateway_api_token - model_gateway_worker_token networks: [model, egress] healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=2)"] restart: unless-stopped api: build: ./backend command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] depends_on: migrate: condition: service_completed_successfully model-gateway: condition: service_healthy environment: <<: [*runtime-config, *model-client-config] secrets: - postgres_app_password - model_gateway_api_token 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, model] 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, *model-client-config] MODEL_GATEWAY_TOKEN_FILE: /run/secrets/model_gateway_worker_token MODEL_GATEWAY_CALLER: worker WORKER_CAPABILITIES: ${WORKER_CAPABILITIES:-document_parse,embedding,rerank,evaluation} secrets: - postgres_app_password - model_gateway_worker_token volumes: - uploads:/data/uploads networks: [data, model] restart: unless-stopped provider-smoke: build: ./backend command: ["python", "-m", "app.tools.provider_smoke"] profiles: ["tools"] depends_on: model-gateway: condition: service_healthy environment: *model-client-config secrets: - model_gateway_api_token networks: [model] 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, *model-client-config] MODEL_GATEWAY_TOKEN_FILE: /run/secrets/model_gateway_worker_token MODEL_GATEWAY_CALLER: worker secrets: - postgres_app_password - model_gateway_worker_token networks: [data, model] 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 model: 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 model_gateway_api_token: file: ./secrets/model_gateway_api_token model_gateway_worker_token: file: ./secrets/model_gateway_worker_token bailian_api_key: file: ./secrets/bailian_api_key ``` 实际实现固定数据库镜像 digest。`migrate` 只有一个实例执行;成功应用到 head 后退出码为 0,随后在 `docker compose ps -a` 显示 `Exited (0)` 是一次性任务的正常终态,不是服务暂停。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` 读取 Secret,不把明文密码放进 `.env` 或 `DATABASE_URL`。 网络分为五层:`web` 位于普通 `edge` 和 internal `ingress`;入口 `gateway` 位于 `ingress + data`;API 位于 internal `data + model`;数据库/迁移位于 internal `data`;只有 `model-gateway` 位于 internal `model + egress`。`provider-smoke` 只持 API 内部 token,真实 `seed-demo` 只持数据库 Secret 和 Worker 内部 token;二者都不挂载百炼 Key、不加入 `egress`。未来 Worker 沿用相同边界。`model-gateway` 不连接数据库、不挂载上传卷、不发布端口。入口隔离见 [ADR-0004](adr/0004-secretless-web-ingress.md),模型出口隔离见 [ADR-0005](adr/0005-isolate-model-egress.md)。必须注意:普通 `edge` bridge 为 Web 提供宿主机端口发布时,也给 Web 留有技术上的默认公网出口;生产仍需主机防火墙或出口代理只允许 `model-gateway` 到百炼域名。 ### 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;本地值环境变量仅作为临时兼容。`bailian_api_key` 只挂载到 `model-gateway`。API 与 Worker 分别挂载 `model_gateway_api_token`、`model_gateway_worker_token`;调用同时使用 Bearer token 和 `X-RAG-Caller`,服务端以常量时间比较并拒绝 token/身份不匹配。应用启动后不打印 Settings 的 Secret 字段,异常对象不得包含请求头。 Secret 或模型端点配置在运行中发生变化时,`model-gateway` 会原子清空旧 provider 与内部 token,并进入 `restart_required`;旧、新 token 都不能在旧进程继续调用。Docker 的 `restart: unless-stopped` 不会仅因 unhealthy 自动重启,因此轮换操作必须协调执行 `docker compose restart model-gateway api`(Worker 落地后同时重启 Worker),待 readiness 恢复后再运行 provider smoke。禁止只替换文件而让旧进程长期携带旧凭据。 ### 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://.cn-beijing.maas.aliyuncs.com/compatible-mode/v1 BAILIAN_NATIVE_BASE_URL=https://.cn-beijing.maas.aliyuncs.com/api/v1 BAILIAN_RERANK_BASE_URL=https://.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 MODEL_GATEWAY_BASE_URL=http://model-gateway:8000 MODEL_GATEWAY_TOKEN_FILE=/run/secrets/model_gateway_api_token MODEL_GATEWAY_CALLER=api ``` 启动时 fail fast: - URL 使用 HTTPS; - 三个 URL 主机相同且地域为北京; - Rerank 路径不是 `compatible-mode/v1`; - Embedding 维度为 schema 支持的 1024; - 生产没有默认密码和通配 CORS; - Secret 文件存在、非空且权限合理; - 内部模型地址必须精确为 `http://model-gateway:8000`,不能通过配置改成公网或用户控制的 URL; - API/Worker 内部 token 必须不同,caller 必须与 token 身份一致; - `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 路径和三个模型权限都正常,最小实际调用才是最终验证。 截至 2026-07-13,最近一次真实探测已到达百炼端点,但 Embedding、Rerank、Chat 三项均返回 401。改由内部 `model-gateway` 路由后必须重新运行同一探测;已有 401 证明不了模型授权可用。必须核对 Key 所属工作空间、北京地域、端点和计费/模型权限后重新验收。401 不进入自动重试风暴,也不能把 Stage 1 标记完成。 ## 6. 网络与接口安全 ### 6.1 网络边界 - 只发布 Nginx 端口;数据库、API、Worker 不直接暴露公网; - Nginx 到 API 使用内部网络; - `data` 和 `model` 网络启用 Docker internal 隔离,Web 不加入;API/Worker 只经 `model-gateway` 访问云服务,自身不加入 `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 明确忽略文档内命令; - 上下文使用结构化 `` 包裹并编号; - 模型无直接数据库、文件或网络工具权限; - 不根据文档内容修改系统配置或权限; - 回答引用编号由后端校验; - 将注入样例纳入安全回归集。 ### 7.2 数据出域 向量化、重排和生成都会把相应文本发送到百炼。因此: - `cloud_processing_allowed=false` 的文档不可进入云模型链; - 权限和保密字段在排队前检查,Worker 再次检查; - 日志与遥测不附带文档正文; - 对个人信息和精确敏感坐标执行脱敏或拒绝; - 若未来需要处理受限资料,必须采用审批后的本地模型部署并新增 ADR,不能悄悄切换。 ### 7.3 输出安全 - Markdown 清理 HTML 和危险 URL; - 对资源量、储量、投资、施工类问题显示专业免责声明; - 生成内容不自动写回知识库; - 用户反馈与更正进入人工审核,不直接成为新事实; - 不让模型生成不存在的下载链接和文件路径。 ## 8. 健康检查与可观测性 ### 8.1 健康端点 - `/health/live`:进程事件循环可用,不检查外部模型; - API `/health/ready`:当前验证数据库可用;后续再纳入迁移版本和存储路径对账; - `model-gateway /health/ready`:只验证本地 Key/token/端点配置可加载且未漂移,不产生计费云调用; - `/admin/providers/health`:按需调用三个模型,结果短期缓存; - `/admin/index/health`:`documents.active_version_id`、searchable 切片投影、非空向量、文本哈希和 embedding profile 一致性。 百炼短时波动不应让编排器不断重启健康 API 容器。真实三能力是否可用只能由显式 provider smoke 证明,不能从 `model-gateway` readiness 推断。 ### 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,不以“更云原生”为唯一理由。