尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

12 个服务砍到 4 个常驻:最小演示拓扑实测,以及砍掉之后哪些东西会哑掉

12 个服务砍到 4 个常驻:最小演示拓扑实测,以及砍掉之后哪些东西会哑掉 「装一下看看」这件事在自托管项目里常常止步于 README 的第一条命令。SOIT 的那条要一次拉起 12 个容器dockercompose --env-file .env-fdocker/docker-compose.yml up-d\postgres redis minio etcd milvus vault migrate bootstrap api web\knowledge-ingest-worker outbox-dispatcherissue #25 把话问得很直接只是想看看它长什么样这 12 个非得全起吗不用常驻容器能压到 4 个。下面按服务逐个交代哪些是 readiness 卡着不放的硬门槛、哪一组可以整组删掉、哪个压根不用删而是折进 API 进程里以及每删掉一个之后界面上具体是哪块功能会哑掉。不过这篇更值得看的大概不是那份清单而是清单是怎么得出来的裁剪后的拓扑我用 v1.0.0 官方镜像真跑了一遍端到端跑到登录成功为止。这一步很关键——先读代码得出的三条结论实跑推翻了其中两条。只照纸面推论去删你会得到一个「容器全绿、界面却打不开」的栈而且极难定位问题出在哪。那两条错的结论我原样留在文里因为它们比对的那几条更有信息量。更重要的是我把裁剪后的拓扑真跑了一遍。这很关键——读代码得出的三条结论里有两条被实跑推翻了。如果只写纸面推论你照着做会得到一个看起来能起、实际界面打不开的栈。那两条错误我原样留在文里因为它们比正确结论更有信息量。先看结论服务能不能砍砍掉的代价postgres❌ 不能readiness 硬门槛挂了直接 503miniominio-init❌不能详见第二节纸面上能换成本地文件系统实测在官方镜像里跑不通migrate/bootstrap❌ 不能一次性任务建表和建管理员账号api/web❌ 不能就是被演示的东西milvusetcd✅ 能成组但有副作用向量检索调用时报错且 readiness 变慢导致 api 被判 unhealthyvault✅ 能密钥换成进程内实现重启即失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里三个后端被区别对待try:db.execute(text(SELECT 1))db_statusconnectedexceptException:raiseHTTPException(status_code503,detailDatabase is unavailable)try:awaitstorage.ensure_ready()storage_statusconnectedexceptException:raiseHTTPException(status_code503,detailObject storage is unavailable)try:awaitvector.check_ready()vector_statusconnectedexceptException:vector_statusunavailable数据库和对象存储挂了返 503向量库挂了只把状态记成unavailable接口照样 200。docstring 把理由写清楚了向量库故障时平台优雅降级非向量接口继续服务所以不该把整个实例踢出轮转。于是硬门槛是两个能连上的 Postgres和一个能写的存储根目录。第二条说的是「存储根目录」而不是「MinIO」——我原本以为这个区别意味着 MinIO 可以砍。这就是被实测推翻的第一条。二、被推翻的第一条本地文件系统存储在容器里用不了存储适配器走 fsspecserver/app/adapters/storage/fsspec.pybase URL 的取值顺序是self.base_urlbase_urlorsettings.storage_urlorself._default_local_base_url()_default_local_base_url()返回仓库根下.soit/storage的file://URI。看起来只要把STORAGE_URL留空或指向一个本地目录存储就落到本地磁盘MinIO 可以不起。我这么配了然后 api 起来就是 503{success:false,code:SERVICE_UNAVAILABLE,message:Object storage is unavailable}进容器直接构造那个适配器报错是这个PermissionError: [Errno 13] Permission denied: /app/home/app/home这个路径很奇怪——我给的明明是/home/appuser/soit-storage。根因在这个函数staticmethoddef_normalize_root_path(root_path:str)-str:returnroot_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 appuseruid 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.pydef_create_vector_port(self)-VectorPort:importosifos.getenv(PYTEST_CURRENT_TEST)oros.getenv(SOIT_TESTING)1:fromapp.adapters.vector.memoryimportInMemoryVectorPortreturnInMemoryVectorPort()fromapp.adapters.vector.milvusimportMilvusVectorPortreturnMilvusVectorPort()内存实现只在测试运行时启用不像密钥那一块会看ENVIRONMENT。所以真实效果是平台正常启动、非向量功能正常、readiness 诚实报告vector: unavailable而一旦点到知识库检索就会在调用时报错。实跑出来的 readiness 正是这样{status:ready,database:connected,storage:connected,vector:unavailable}这一条结论对上了。但同一次实跑里冒出来一件代码上看不出来的事就是下一节。四、被推翻的第二条砍掉 Milvus 之后web 起不来上面那个 readiness 响应我这边实测耗时 34 秒。原因不难想vector.check_ready()要先解析milvus这个主机名再建连接而那个容器根本不存在于是每次请求都得等 DNS 和连接超时走完。代码里向量探测是 fail-soft 的但它不是 fail-fast 的——没给这次探测设超时。这就撞上了 compose 自己的健康检查{Test:[CMD-SHELL,python -c \...urlopen(http://localhost:9200/health/ready, timeout3)\],Interval:10s,Timeout:5s,Retries:5}探针自己 3 秒超时、compose 再给 5 秒而接口要 34 秒——必然失败。实跑里 api 容器稳定处在unhealthysoit-api-1 Up 3 minutes (unhealthy)服务本身完全正常我用它登录成功了只是健康检查过不去。而web的depends_on写的是api: condition: service_healthy所以你按常规up -d webweb 永远不会启动——你会得到一个 API 好好的、界面打不开的栈还很难看出为什么。绕法很简单web也加--no-deps它是个静态前端只要浏览器能访问到 API 就行不需要等 compose 认定 api 健康。dockercompose... up-d--no-deps web实测这样起来的 web 是healthy的首页 HTTP 200。这一条是整篇文章里唯一必须实跑才能发现的。只读代码你会得到一份「看起来对、照做打不开界面」的指南。五、Vault这个是真能降级的密钥这块的装配逻辑就不一样了同一个container.pyifnotsettings.vault_urlornotsettings.vault_token:ifself._allows_in_memory_adapters():fromapp.adapters.secrets.memoryimportInMemorySecretValueStorereturnInMemorySecretValueStore()raiseRuntimeError(Production requires Vault URL and token for the secrets adapter)_allows_in_memory_adapters()认的ENVIRONMENT值是dev / development / local / test / testingcompose 默认值正是development。所以把VAULT_URL和VAULT_TOKEN留空密钥存储自动换成进程内实现Vault 容器可以不起。实跑验证migrate、bootstrap、api 全程没有 Vault正常工作。代价写在名字里进程内意味着不持久。演示里配的模型 API key容器一重启就没了。六、Redis三条路径三种脾气Redis 有意思的地方在于它不是「有或没有」的问题——用到它的三处代码对缺失的态度完全不同。事件总线——可以切。默认后端本来就是memory是 compose 把它改成了redisbackend(settings.event_bus_backendormemory).lower()ifbackendredis:returnRedisEventBus(...)ifbackendmemoryandself._allows_in_memory_adapters():returnInMemoryEventBus()同样受ENVIRONMENT约束生产走到memory分支会直接抛错。内存总线只在单个进程内送达——这也是它和下一节「把后台任务折进 API 进程」配套的原因。权限缓存——优雅降级。server/app/kernel/identity/permissions.py里拿 Redis 客户端的函数连不上就返回None调用方当作缓存未命中回数据库再判一次。少一层缓存不影响正确性。速率限制——硬依赖但通常不触发。RateLimiterserver/app/kernel/ports/common/rate_limiter.py是基于 Redis 的滑动窗口没有内存实现。不过调用点是有条件的server/app/kernel/ports/tools/policy.pyrate_limitkwargs.get(rate_limit_per_minute)orself.rate_limit_per_minuteifrate_limit:awaitself.rate_limiter.check_rate_limit(...)ifself.daily_quota:awaitself.rate_limiter.check_rate_limit(...)没配限额就不会走到 Redis。所以演示拓扑里砍掉 Redis 是安全的——前提是别在演示里给工具配速率限制或日配额。这是全文唯一一条要你自己拿捏场景的。七、有一个服务不用砍折进 API 就行outbox-dispatcher是事务性发件箱的派发进程。它对应的设置项注释写得很直白server/app/settings/settings.pyoutbox_dispatcher_enabled:boolFalseEnable background outbox dispatcher in the API process.这个开关的语义不是「要不要派发」而是「在不在 API 进程里派发」。compose 把它显式设成false再单起一个容器跑同一份逻辑。演示时反过来给 api 加OUTBOX_DISPATCHER_ENABLEDtrue然后不起那个容器——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用的是单独的镜像 targetserver/Dockerfile构建时多装一个 extraFROM base AS knowledge-worker RUN --mounttypecache,target/root/.cache/uv \ /bin/uv sync --frozen --no-dev --extra knowledge-worker这个 extra 是docling[rapidocr]——文档解析和 OCR。顺带澄清一个常见误会torch那一套torch/torchvision/torchaudio在pyproject.toml里属于local-embeddingextra不在knowledge-worker里也不在 API 镜像里。这个 worker 没有想象中那么重但演示不涉及「上传文档建知识库」的话它没有存在的必要。九、完整命令实跑过的那一套先说一个 compose 的坑api的depends_on里列着 milvus、vault你命令行不写它们compose 也会替你拉起来。所以每一步都要显式--no-deps一次性任务的顺序自己排。env 文件都在覆盖 compose 的默认值printf%s\n\ENVIRONMENTdevelopment\VAULT_URL\VAULT_TOKEN\EVENT_BUS_BACKENDmemory\OUTBOX_DISPATCHER_ENABLEDtrue\.env.minimal用发布好的镜像起叠加docker-compose.images.yml并加--no-build否则 compose 会从源码构建COMPOSEdocker compose --env-file .env.minimal -f docker/docker-compose.yml -f docker/docker-compose.images.yml$COMPOSEup-d--no-build postgres minio minio-init$COMPOSErun--rm--no-deps migrate$COMPOSErun--rm--no-deps bootstrap$COMPOSEup-d--no-build --no-deps api webmigrate会打出一串 alembic 升级日志bootstrap会打出Bootstrap completed.和管理员的 id。然后验证。注意 readiness要等 30 秒以上第四节说的向量探测超时curl 记得放宽超时curl-s-m60http://localhost:9200/health/ready实跑输出{status:ready,database:connected,storage:connected,vector:unavailable}vector: unavailable而整体ready正是第一节那段代码的直接结果——这个输出本身就是结论的自证。再验一次真业务路径别只信健康检查curl-s-XPOST http://localhost:9200/api/v1/login\-HContent-Type: application/json\-d{email:adminexample.com,password:changeme123}返回里带access_token就说明数据库、鉴权这条链路都通了。界面在http://localhost:5000用同一组账号登录。docker ps里 api 会显示unhealthy这是预期的第四节服务本身是好的。坦白局按惯例说清楚这套裁剪不适用于什么这不是受支持的部署形态是演示用的裁剪。真上生产ENVIRONMENTproduction会把上面这些捷径逐条堵死validate_runtime_requirements()会要求 Redis 事件总线、独立 outbox 进程、Vault、OpenTelemetry、插件签名与摘要校验缺一个直接启动失败。这是刻意的 fail-closed。api 常驻 unhealthy意味着你不能拿这套拓扑去接任何依赖容器健康状态的编排k8s 探针、按 healthcheck 排启动顺序都会挂。内存实现的东西重启就没密钥、内存事件总线里在途的事件。砍掉 Milvus 之后向量功能是报错而不是空结果要演示知识库milvus 和 etcd 得加回来。速率限制那条要自己拿捏砍 Redis 的前提是演示里不配限额。默认SECRET_KEYchange-mebootstrap 会打一条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/soit完整 quickstart12 个服务那条路仓库里的docs/quickstart.md有中文版docs/quickstart.zh-CN.md治理演示docs/governance-demo.md——20 分钟本地跑通把权限、密钥、调用审计、成本归集、重放、回归挨个演示一遍如果你按上面的最小拓扑跑起来了或者在某一步卡住了欢迎来 issue 区说一声。这套裁剪目前只是文章形态如果反馈说有用我们会把它做成 compose profile让--profile minimal一条命令搞定——顺便把上面那两个问题修掉。利益相关我是 SOIT 的维护者。
返回列表