全部文章

把 12 个容器的栈砍到 4 个:三条推论错了两条

SOIT Team

把 12 个容器的栈砍到 4 个:三条推论错了两条

自托管项目的潜在用户,大半是在 README 的第一条命令那里流失的,而我们那条命令一行里写了十二个服务名。issue #25 问的是最自然的问题:我只是想看看它长什么样,这些真的都得起吗?答案是不用。十二个里留七个就够,而这七个里有三个是跑完就退出的一次性任务,所以常驻的只有四个容器:postgres、minio、api、web。不过这次的价值不在这个数字,而在于砍完之后是真的跑了一遍,而不是纸上推演——读代码得到的三条结论,实跑推翻了两条,而且被推翻的那两条恰好是会让第一次上手的人对着一个打不开的地址发呆的那种。

判断一个依赖是不是硬依赖,最快的办法不是部署文档而是健康检查,因为文档记录的是意图,健康检查记录的是行为。readiness 接口对三个后端的处理并不一样:数据库连不上直接抛 503,对象存储连不上直接抛 503,向量库连不上则只是把那个字段写成 unavailable,接口照样返回 200。这个不对称把硬要求直接点了名:一个连得上的 Postgres,以及一个可写的存储根目录。其余的原则上都可以谈,而这篇剩下的部分讲的就是——这些「可以谈」,有哪些经得起线上镜像的检验。

存储根目录和 MinIO 并不是同一个词,存储适配器在什么都不配的情况下确实会落到本地 file 协议上,所以第一条推论是对象存储可以砍。它不能。把存储指向本地目录之后,API 起来就报「对象存储不可用」;进容器里手工构造一次适配器,才看到真正的错误:一个谁都没有传过的路径上的权限拒绝。原因是一个归一化函数对根路径两端都做了去斜杠,于是绝对路径被变成了相对路径,接着被解析到镜像的工作目录下——而那个目录是 Dockerfile 以 root 身份拷进去的,进程却以非特权用户运行。挂卷也救不回来,因为 Docker 建出来的挂载点同样属于 root。所以 MinIO 留下,好在它便宜,两百多兆而已;底下那个 bug 记在 issue #43。

向量那一组是整组走的,这件事的代价值得说准确。Milvus 的元数据依赖 etcd、数据依赖 MinIO,所以这两个名字是绑在一起的。没有它们 API 照样能起来,因为向量连接是首次使用时才惰性建立的,而不是在依赖注入阶段就连。但别指望它会退化成返回空结果:内存版向量实现是留给测试用的,而且和密钥端口不同,它根本不看环境配置。于是实际表现是:平台照常启动、非向量功能正常、readiness 诚实地把向量报成 unavailable 同时仍然自称 ready,而知识检索会在你用到它的那一刻抛错。这一条在实跑里站住了。

被推翻的第二条,是再怎么读代码也读不出来的那种。上面那个诚实的 readiness 响应,回来花了三十四秒——因为向量探测要去解析一个已经不存在的主机名,而没有任何东西给这个等待设上限。这个探测写成了 fail-soft,却没有写成 fail-fast。与此同时,Compose 对 API 的健康检查是三秒的请求加五秒的宽限,于是这个检查永远过不了,API 容器就一直挂着 unhealthy——尽管它背后的服务好得很,登录都能登进去。而 web 容器声明了自己依赖「API 健康」,所以按常规方式拉起来,界面根本不会启动。修法是加一个跳过依赖的参数单独起 web;缺失的探测超时记在 issue #44。

真正可选的有三个服务,各自可选的理由并不相同。Vault 可以不起:把它的地址和 token 留空,装配层会换上进程内的密钥存储(开发环境下允许这么做),代价就写在这个类的名字里——密钥活不过一次重启。Redis 不是一个问题而是三个,答案也各不相同:事件总线可以切回默认的内存实现,但它只在单进程内投递;权限缓存是优雅降级的,连不上 Redis 会被读成缓存未命中,调用方回头去查数据库;限流器则完全没有内存版替代,但它只有在配置了单工具限流或每日配额时才会被调用。所以演示里砍掉 Redis 是安全的——前提是你不去配那两样,这也是本文清单里唯一一条取决于你打算演示什么的。

有一个服务不是删掉而是折叠进去。outbox dispatcher 跑的是事务性发件箱,它那个开关控制的是这件事在哪里做,而不是做不做:compose 把进程内那条路关掉、让同一份逻辑跑在独立容器里,做演示时一个环境变量就能反过来。生产环境走的是相反方向,而且是写在代码里强制的、不是写在文档里建议的——因为投递和请求处理挤在同一个进程里会争同一份资源,一次重启会同时打断两件事。至于知识摄取 worker,它只干一件事,如果你的演示压根不上传文档,它就没有存在的理由;顺带澄清一个常见误解:大家以为压在这个镜像里的那些重量级机器学习依赖,其实在另一个可选依赖组里。

上面这套都不是受支持的部署形态,而默认拓扑为什么要十二个,值得说清楚。这里砍掉的每一个服务,都对应生产环境在启动时强制的一项:运行时校验会因为缺少 Redis 事件总线、缺少独立的 dispatcher 进程、缺少真正的密钥管理、缺少可观测性、缺少插件签名校验而拒绝启动。默认拓扑瞄准的是生产形态而不是演示形态,而这两种形态之间的距离,最后是可以用一把环境变量量出来的——这恰好也是理解这套架构最快的路径。另一条教训一并留着:读代码给你的是假设,跑一遍给你的才是结论。仓库在 github.com/soit-ai/soit,完整 quickstart 在 docs/quickstart.md;如果你在这个精简版的某一步卡住了,欢迎直接开 issue 说。