自托管类项目的劝退时刻通常出现在 README 的第一条命令。SOIT 的那条长这样:
bash
docker compose --env-file .env -f docker/docker-compose.yml up -d \
postgres redis minio etcd milvus vault migrate bootstrap api web \
knowledge-ingest-worker outbox-dispatcher
12 个服务名。issue #25 问的就是这件事:只是想看看它长什么样,非得全起吗?
不用。这篇文章把这 12 个拆开讲:哪些是真的动不了、哪一组可以整组砍掉、哪个不用砍而是折进 API 进程,以及每砍一个会失去什么。
更重要的是:我把裁剪后的拓扑真跑了一遍。这很关键------读代码得出的三条结论里有两条被实跑推翻了。如果只写纸面推论,你照着做会得到一个看起来能起、实际界面打不开的栈。那两条错误我原样留在文里,因为它们比正确结论更有信息量。
先看结论
| 服务 | 能不能砍 | 砍掉的代价 |
|---|---|---|
postgres |
❌ 不能 | readiness 硬门槛,挂了直接 503 |
minio + minio-init |
❌ 不能(详见第二节) | 纸面上能换成本地文件系统,实测在官方镜像里跑不通 |
migrate / bootstrap |
❌ 不能 | 一次性任务,建表和建管理员账号 |
api / web |
❌ 不能 | 就是被演示的东西 |
milvus + etcd |
✅ 能(成组,但有副作用) | 向量检索调用时报错;且 readiness 变慢导致 api 被判 unhealthy |
vault |
✅ 能 | 密钥换成进程内实现,重启即失 |
redis |
✅ 能(演示场景) | 权限缓存关闭、事件总线退回单进程 |
knowledge-ingest-worker |
✅ 能 | 文档解析入库不可用 |
outbox-dispatcher |
🔁 不用砍 | 一个环境变量折进 API 进程 |
12 个变 7 个,其中 minio-init / migrate / bootstrap 跑完就退出,常驻容器 4 个:postgres、minio、api、web。
省下来的是 Milvus(镜像 2.6GB)、etcd、Vault、Redis、知识 worker 和 outbox 容器。
一、哪些是硬门槛:看 readiness 就知道
判断依赖硬不硬,最快的办法不是读部署文档,是读健康检查------部署文档写的是期望,健康检查写的是事实。
/health/ready 的实现(server/app/api/v1/health/router.py)里,三个后端被区别对待:
python
try:
db.execute(text("SELECT 1"))
db_status = "connected"
except Exception:
raise HTTPException(status_code=503, detail="Database is unavailable")
try:
await storage.ensure_ready()
storage_status = "connected"
except Exception:
raise HTTPException(status_code=503, detail="Object storage is unavailable")
try:
await vector.check_ready()
vector_status = "connected"
except Exception:
vector_status = "unavailable"
数据库和对象存储挂了返 503,向量库挂了只把状态记成 unavailable,接口照样 200。docstring 把理由写清楚了:向量库故障时平台优雅降级(非向量接口继续服务),所以不该把整个实例踢出轮转。
于是硬门槛是两个:能连上的 Postgres,和一个能写的存储根目录。
第二条说的是「存储根目录」而不是「MinIO」------我原本以为这个区别意味着 MinIO 可以砍。这就是被实测推翻的第一条。
二、被推翻的第一条:本地文件系统存储在容器里用不了
存储适配器走 fsspec(server/app/adapters/storage/fsspec.py),base URL 的取值顺序是:
python
self.base_url = base_url or settings.storage_url or self._default_local_base_url()
_default_local_base_url() 返回仓库根下 .soit/storage 的 file:// URI。看起来只要把 STORAGE_URL 留空或指向一个本地目录,存储就落到本地磁盘,MinIO 可以不起。
我这么配了,然后 api 起来就是 503:
plaintext
{"success":false,"code":"SERVICE_UNAVAILABLE","message":"Object storage is unavailable"}
进容器直接构造那个适配器,报错是这个:
plaintext
PermissionError: [Errno 13] Permission denied: '/app/home'
/app/home 这个路径很奇怪------我给的明明是 /home/appuser/soit-storage。根因在这个函数:
python
@staticmethod
def _normalize_root_path(root_path: str) -> str:
return root_path.replace("\\", "/").strip("/")
strip("/") 把前导斜杠也去掉了 ,绝对路径 /home/appuser/soit-storage 变成相对路径 home/appuser/soit-storage,再由 fsspec 的 LocalFileSystem 相对于进程工作目录解析------镜像里 WORKDIR /app/,于是落到 /app/home/...。
而 /app 是写不进去的:server/Dockerfile 里 COPY ./ /app/ 没有 --chown ,/app 归 root,最后一行又是 USER appuser(uid 10001)。
所以在官方镜像里,本地文件系统这条路基本是死的 :路径怎么写都会落在 /app 底下,而那里非 root 进程建不了目录。挂卷也不解决------Docker 建出来的挂载点同样 root 所有。真要走通得让 api 以 root 跑、或预先 chown 一个挂载点,两条都不适合写进「给新手的最小拓扑」。
结论:MinIO 留着。 好在它便宜------一个常驻容器加一个跑完就退的 minio-init,镜像两百多兆,比 Milvus 那一组小一个量级。
(顺带说清楚:MinIO 在完整拓扑里是两个身份------平台的制品存储,以及 Milvus 的对象存储后端。所以它本来也不可能和 Milvus 分开砍。)
三、向量那一组:砍得掉,但不是优雅降级
milvus 依赖 etcd(元数据)和 minio(数据落盘)。砍掉 milvus 和 etcd 之后 API 能不能起来?能,原因写在适配器构造函数的注释里(server/app/adapters/vector/milvus.py):连接是首次使用时才建立的,这样依赖注入阶段构造这个 port 不会因为向量库暂时不可用而失败。
但别抱着「会降级成空结果」的预期去砍:向量 port 没有 env 级别的降级 。看容器装配(server/app/wiring/container.py):
python
def _create_vector_port(self) -> VectorPort:
import os
if os.getenv("PYTEST_CURRENT_TEST") or os.getenv("SOIT_TESTING") == "1":
from app.adapters.vector.memory import InMemoryVectorPort
return InMemoryVectorPort()
from app.adapters.vector.milvus import MilvusVectorPort
return MilvusVectorPort()
内存实现只在测试运行时 启用,不像密钥那一块会看 ENVIRONMENT。所以真实效果是:平台正常启动、非向量功能正常、readiness 诚实报告 vector: "unavailable",而一旦点到知识库检索就会在调用时报错。
实跑出来的 readiness 正是这样:
json
{"status":"ready","database":"connected","storage":"connected","vector":"unavailable"}
这一条结论对上了。但同一次实跑里冒出来一件代码上看不出来的事,就是下一节。
四、被推翻的第二条:砍掉 Milvus 之后,web 起不来
上面那个 readiness 响应,我这边实测耗时 34 秒。
原因不难想:vector.check_ready() 要先解析 milvus 这个主机名再建连接,而那个容器根本不存在,于是每次请求都得等 DNS 和连接超时走完。代码里向量探测是 fail-soft 的,但它不是 fail-fast 的------没给这次探测设超时。
这就撞上了 compose 自己的健康检查:
json
{"Test": ["CMD-SHELL", "python -c \"...urlopen('http://localhost:9200/health/ready', timeout=3)\""],
"Interval": "10s", "Timeout": "5s", "Retries": 5}
探针自己 3 秒超时、compose 再给 5 秒,而接口要 34 秒------必然失败 。实跑里 api 容器稳定处在 unhealthy:
plaintext
soit-api-1 Up 3 minutes (unhealthy)
服务本身完全正常(我用它登录成功了),只是健康检查过不去。而 web 的 depends_on 写的是 api: condition: service_healthy,所以你按常规 up -d web,web 永远不会启动------你会得到一个 API 好好的、界面打不开的栈,还很难看出为什么。
绕法很简单,web 也加 --no-deps:它是个静态前端,只要浏览器能访问到 API 就行,不需要等 compose 认定 api 健康。
bash
docker compose ... up -d --no-deps web
实测这样起来的 web 是 healthy 的,首页 HTTP 200。
这一条是整篇文章里唯一必须实跑才能发现的。 只读代码,你会得到一份「看起来对、照做打不开界面」的指南。
五、Vault:这个是真能降级的
密钥这块的装配逻辑就不一样了(同一个 container.py):
python
if not settings.vault_url or not settings.vault_token:
if self._allows_in_memory_adapters():
from app.adapters.secrets.memory import InMemorySecretValueStore
return InMemorySecretValueStore()
raise RuntimeError("Production requires Vault URL and token for the secrets adapter")
_allows_in_memory_adapters() 认的 ENVIRONMENT 值是 dev / development / local / test / testing,compose 默认值正是 development。所以把 VAULT_URL 和 VAULT_TOKEN 留空,密钥存储自动换成进程内实现,Vault 容器可以不起。实跑验证:migrate、bootstrap、api 全程没有 Vault,正常工作。
代价写在名字里:进程内,意味着不持久。演示里配的模型 API key,容器一重启就没了。
六、Redis:三条路径,三种脾气
Redis 有意思的地方在于它不是「有或没有」的问题------用到它的三处代码,对缺失的态度完全不同。
事件总线 ------可以切。默认后端本来就是 memory,是 compose 把它改成了 redis:
python
backend = (settings.event_bus_backend or "memory").lower()
if backend == "redis":
return RedisEventBus(...)
if backend == "memory" and self._allows_in_memory_adapters():
return InMemoryEventBus()
同样受 ENVIRONMENT 约束,生产走到 memory 分支会直接抛错。内存总线只在单个进程内送达------这也是它和下一节「把后台任务折进 API 进程」配套的原因。
权限缓存 ------优雅降级。server/app/kernel/identity/permissions.py 里拿 Redis 客户端的函数连不上就返回 None,调用方当作缓存未命中,回数据库再判一次。少一层缓存,不影响正确性。
速率限制 ------硬依赖,但通常不触发。RateLimiter(server/app/kernel/ports/common/rate_limiter.py)是基于 Redis 的滑动窗口,没有内存实现。不过调用点是有条件的(server/app/kernel/ports/tools/policy.py):
python
rate_limit = kwargs.get("rate_limit_per_minute") or self.rate_limit_per_minute
if rate_limit:
await self.rate_limiter.check_rate_limit(...)
if self.daily_quota:
await self.rate_limiter.check_rate_limit(...)
没配限额就不会走到 Redis。所以演示拓扑里砍掉 Redis 是安全的------前提是别在演示里给工具配速率限制或日配额。这是全文唯一一条要你自己拿捏场景的。
七、有一个服务不用砍,折进 API 就行
outbox-dispatcher 是事务性发件箱的派发进程。它对应的设置项注释写得很直白(server/app/settings/settings.py):
python
outbox_dispatcher_enabled: bool = False
"""Enable background outbox dispatcher in the API process."""
这个开关的语义不是「要不要派发」,而是「在不在 API 进程里派发 」。compose 把它显式设成 false,再单起一个容器跑同一份逻辑。演示时反过来:给 api 加 OUTBOX_DISPATCHER_ENABLED=true,然后不起那个容器------server/app/main.py 启动时读这个开关,把派发服务挂进 API 的生命周期。
生产为什么不这么干:validate_runtime_requirements() 里有一条 if self.outbox_dispatcher_enabled: raise ValueError("Production requires the dedicated outbox dispatcher process")。派发和请求处理挤在一个进程里,扩容互相抢资源,重启同时中断两件事。演示无所谓,生产不行------这条限制是代码强制的,不是文档建议。
八、知识入库 worker:不做 RAG 就整个不起
knowledge-ingest-worker 用的是单独的镜像 target(server/Dockerfile),构建时多装一个 extra:
dockerfile
FROM base AS knowledge-worker
RUN --mount=type=cache,target=/root/.cache/uv \
/bin/uv sync --frozen --no-dev --extra knowledge-worker
这个 extra 是 docling[rapidocr]------文档解析和 OCR。顺带澄清一个常见误会:torch 那一套(torch / torchvision / torchaudio)在 pyproject.toml 里属于 local-embedding extra,不在 knowledge-worker 里,也不在 API 镜像里。这个 worker 没有想象中那么重,但演示不涉及「上传文档建知识库」的话,它没有存在的必要。
九、完整命令(实跑过的那一套)
先说一个 compose 的坑:api 的 depends_on 里列着 milvus、vault,你命令行不写它们,compose 也会替你拉起来 。所以每一步都要显式 --no-deps,一次性任务的顺序自己排。
env 文件(都在覆盖 compose 的默认值):
bash
printf '%s\n' \
'ENVIRONMENT=development' \
'VAULT_URL=' \
'VAULT_TOKEN=' \
'EVENT_BUS_BACKEND=memory' \
'OUTBOX_DISPATCHER_ENABLED=true' \
> .env.minimal
用发布好的镜像起(叠加 docker-compose.images.yml 并加 --no-build,否则 compose 会从源码构建):
bash
COMPOSE="docker compose --env-file .env.minimal -f docker/docker-compose.yml -f docker/docker-compose.images.yml"
$COMPOSE up -d --no-build postgres minio minio-init
$COMPOSE run --rm --no-deps migrate
$COMPOSE run --rm --no-deps bootstrap
$COMPOSE up -d --no-build --no-deps api web
migrate 会打出一串 alembic 升级日志,bootstrap 会打出 Bootstrap completed. 和管理员的 id。
然后验证。注意 readiness 要等 30 秒以上(第四节说的向量探测超时),curl 记得放宽超时:
bash
curl -s -m 60 http://localhost:9200/health/ready
实跑输出:
json
{"status":"ready","database":"connected","storage":"connected","vector":"unavailable"}
vector: unavailable 而整体 ready,正是第一节那段代码的直接结果------这个输出本身就是结论的自证。
再验一次真业务路径,别只信健康检查:
bash
curl -s -X POST http://localhost:9200/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@example.com","password":"changeme123"}'
返回里带 access_token 就说明数据库、鉴权这条链路都通了。界面在 http://localhost:5000,用同一组账号登录。
docker ps 里 api 会显示 unhealthy,这是预期的(第四节),服务本身是好的。
坦白局
按惯例说清楚这套裁剪不适用于什么:
- 这不是受支持的部署形态 ,是演示用的裁剪。真上生产,
ENVIRONMENT=production会把上面这些捷径逐条堵死:validate_runtime_requirements()会要求 Redis 事件总线、独立 outbox 进程、Vault、OpenTelemetry、插件签名与摘要校验,缺一个直接启动失败。这是刻意的 fail-closed。 - api 常驻 unhealthy,意味着你不能拿这套拓扑去接任何依赖容器健康状态的编排(k8s 探针、按 healthcheck 排启动顺序都会挂)。
- 内存实现的东西重启就没:密钥、内存事件总线里在途的事件。
- 砍掉 Milvus 之后向量功能是报错而不是空结果;要演示知识库,milvus 和 etcd 得加回来。
- 速率限制那条要自己拿捏:砍 Redis 的前提是演示里不配限额。
- 默认
SECRET_KEY=change-me,bootstrap 会打一条InsecureKeyLengthWarning。演示无所谓,但别让这个值活到演示以外的地方。
顺带发现的两个问题
写这篇文章翻出来两个我们自己的问题,都已经开了 issue,写在这里也算把话说在明处:
_normalize_root_path()的strip("/")把绝对路径变成了相对路径 ,导致本地文件系统存储后端在容器里实际不可用(第二节)。这个函数的本意应该是规范化对象存储的 key 前缀,对file://这类真有绝对路径语义的后端不该一视同仁。(issue #43)- 向量库 readiness 探测没有超时 ,向量库缺席时把
/health/ready拖到 30 秒以上,进而让 compose 健康检查恒定失败(第四节)。fail-soft 做到了,fail-fast 没做到------给check_ready()加一个秒级超时就能同时保住两者。(issue #44)
顺手说一句:这两条都是「只有真去跑才会撞上」的问题,而我们自己的 CI 跑的是完整拓扑,所以一直没暴露。这大概也是最小拓扑值得被官方支持的理由之一。
为什么值得把这件事写清楚
既然能砍到 4 个常驻容器,为什么默认要起 12 个?
因为默认拓扑对齐的是生产形态,不是演示形态。上面每一个被砍掉的服务,都对应生产环境里一条被代码强制的要求:密钥要有真的密钥管理器、事件要跨进程可达、派发要能独立扩容、向量要能持久化。默认给你一套能直接对着生产做实验的东西,代价就是第一条命令看起来很吓人。
这篇文章想说的是:这两种形态之间的距离,是可以用几个环境变量量化的 ,而量化它的过程本身就是读懂这套架构最快的路径------从健康检查读出硬依赖,从装配代码读出哪些 port 有降级实现,从 validate_runtime_requirements() 读出生产的底线在哪。
但也别忘了这次的教训:读代码给出的是假设,跑一遍才是结论。三条推论错了两条,而且错的那两条恰好是会让你卡住的那两条。
来试试
SOIT 是 Apache-2.0 的,代码全在 GitHub:
- 仓库:github.com/soit-ai/soi...
- 完整 quickstart(12 个服务那条路):仓库里的
docs/quickstart.md,有中文版docs/quickstart.zh-CN.md - 治理演示:
docs/governance-demo.md------20 分钟本地跑通,把权限、密钥、调用审计、成本归集、重放、回归挨个演示一遍
如果你按上面的最小拓扑跑起来了,或者在某一步卡住了,欢迎来 issue 区说一声。这套裁剪目前只是文章形态,如果反馈说有用,我们会把它做成 compose profile,让 --profile minimal 一条命令搞定------顺便把上面那两个问题修掉。
利益相关:我是 SOIT 的维护者。