docker compose ps 里 api 显示 unhealthy,前端容器因此一直不启动,日志里什么都没有。 这是我们上个月写最小部署拓扑时撞上的,当时的结论是「readiness 慢了点,服务本身是好的, 加个 --no-deps 绕过去」。
这次我回头把整套健康检查从端点到 compose 逐条拆了一遍,发现那句「服务本身是好的」 只说对了一半,顺带又查出三个问题:一道只在启动时检查一次的存储门、一个在生产配置里 永远打到 404 的探针、以及一份只生效了一半的 .env。每一条都附了本机复现和文件行号, 文末有一张排障速查表。
先看结论
问题:api 容器显示 unhealthy 的时候,它说的对吗?它到底在检查什么?
一句话:我们的健康检查有一处查得太重,有一处查得太轻,有一处查错了地方,还有一处 根本没收到你以为你给它的配置。
- 太重 :向量库的 readiness 探针是
async def,里面却做了一次同步的 gRPC 连接。 向量库不在时,每次探针都会把整个 API 进程的事件循环冻住 10 秒以上 (本机实测 10.1 秒;容器里之前实测整个请求 34 秒)。 - 太轻 :对象存储是 readiness 的硬门,但它只在第一次成功之前 真正检查,之后结果被 永久缓存。MinIO 在启动后挂掉,readiness 照样报
connected。 - 查错了地方 :生产版 compose 的探针打的是
/api/v1/health/ready,而这个路径不存在, 返回 404。真正的端点在/health/ready。 - 没收到配置 :不加
--env-file .env启动时,.env里凡是 compose 的environment:块也列了的键,都会被 compose 里写死的默认值静默覆盖;没列的键却照常生效。
后面是逐层拆解,最后一节是一张排障速查表。
一、先把三个端点分清
健康相关的路由在 server/app/api/v1/health/router.py,挂载时没有加前缀 (server/app/main.py:429:app.include_router(health_router, tags=["health"])), 所以路径就是字面上的这几个:
| 端点 | 查什么 | 失败时 |
|---|---|---|
/health |
什么都不查,恒返回 healthy |
--- |
/health/live |
同上,恒返回 healthy |
--- |
/health/ready |
数据库、对象存储、向量库 | 数据库或存储失败 ⇒ 503;向量库失败 ⇒ 仍 200,只把 vector 标成 unavailable |
liveness 什么都不查是对的:它只回答「进程还活着吗」,查依赖反而会让依赖抖一下就把 进程重启掉。
readiness 那三道检查写得很清楚(router.py:82--106):
plaintext
try:
await 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"
数据库和存储是硬门,向量库是软门------docstring 给的理由是「向量库挂了,非向量接口照样能用, 没必要把实例从流量里摘掉」(router.py:74--77)。这个设计本身是对的。问题出在每道门的 实现细节上。
二、compose 里谁在检查谁
开发版 docker/docker-compose.yml 里,各容器的健康检查是这样的:
| 服务 | 健康检查 | 间隔 / 超时 / 重试 |
|---|---|---|
| postgres | pg_isready |
10s / 5s / 10 |
| redis | redis-cli ping |
10s / 5s / 10 |
| minio | curl -f .../minio/health/live |
10s / 5s / 10 |
| etcd | etcdctl endpoint health |
10s / 5s / 10 |
| milvus | curl -f .../healthz(9091 端口) |
15s / 5s / 10 |
| vault | vault status |
10s / 5s / 10 |
| api | Python urlopen('/health/ready', timeout=3) |
10s / 5s / 5 |
| web | wget 首页 |
10s / 5s / 5 |
| outbox-dispatcher | Python urlopen('/metrics', timeout=3)(9201 端口) |
10s / 5s / 5 |
| knowledge-ingest-worker | 无 | --- |
| scheduler | 无 | --- |
另有三个一次性容器(minio-init、migrate、bootstrap),都是 restart: "no", 下游用 condition: service_completed_successfully 等它们以 0 退出。
依赖是这样串起来的:api 等 postgres / redis / minio / milvus / vault 全部 healthy、 等三个一次性容器成功退出(docker-compose.yml:264--280);web 只等一件事------ api healthy (:298--300)。
所以 api 的探针是整条链的咽喉:它不过,前端就永远不启动。
三、症状:api 一直 unhealthy,web 永远起不来
这一段是上个月写最小部署拓扑时撞上的,这里只简述:把 milvus 和 etcd 从栈里拿掉, /health/ready 仍然返回正确的 "vector":"unavailable",但要等 34 秒 才返回。 而 api 的探针自己只给 3 秒(urlopen(..., timeout=3)),compose 再给 5 秒 (docker-compose.yml:282--284)。34 秒对 3 秒,必然失败,于是 api 恒为 unhealthy, web 因为 depends_on: api: service_healthy 永远不启动。当时给的绕法是 docker compose up -d --no-deps web,现在仍然有效。
当时我们还写了一句:「api 显示 unhealthy 是预期的,服务本身是好的。」
这句话只对了一半。
四、那十几秒里,整个 API 是冻住的
看向量库探针的实现(server/app/adapters/vector/milvus.py:112--115):
plaintext
async def check_ready(self) -> None:
"""Probe vector-store connectivity; raise if unreachable (for readiness checks)."""
self._ensure_connected()
utility.get_server_version()
check_ready 是 async def,但它调用的 _ensure_connected()(:89--99)里是 connections.connect(...),一个同步 的 pymilvus 调用。在 async def 里直接做同步 网络 I/O,意味着这段时间事件循环什么都干不了。
我在本机把这段单独拎出来跑了一遍(soit/server/.venv,pymilvus 2.5.11), 连一个没有服务监听的端口,同时在同一个事件循环上挂一个每 0.5 秒记一次时间的任务:
plaintext
probe failed: MilvusException
probe 10.1s, max gap between 0.5s ticks 10.3s, ticks during probe 0
10.1 秒里,计时任务一次都没跑。 这 10.1 秒是 pymilvus 自己的默认连接超时; 容器里那 34 秒还叠加了 Docker 网络里对一个不存在的服务名做 DNS 解析的时间。
再连续探两次,看失败会不会被记住:
plaintext
probe 1 failed: MilvusException
probe 1 10.2s
probe 2 failed: MilvusException
probe 2 10.1s
不会。连接失败时不会留下任何连接,connections.has_connection("default") 下次还是假, 每一次探针都要重新付满全价。
把这个放回 compose 里:开发版的 api 是单进程 uvicorn(docker-compose.yml:261, 没有 --workers),健康检查每 10 秒一次。
🔍 以下是推论,不是在容器里观测到的 :每次探针都会把这唯一的事件循环冻住 10 秒以上 (容器里按 34 秒算),而 compose 的探针 3 秒就放弃、10 秒后再来一次------服务端那边 上一次还没卡完,下一次已经排上了。也就是说,在没有向量库的拓扑里,这个 API 进程 大部分时间都卡在健康检查里。上个月那次登录能成功,是因为登录请求要么落在两次探针 之间,要么排队等到了探针结束------「服务本身是好的」,但它是在探针的间隙里好的。
对照一下,同一个仓库里另外两个适配器都做对了:
- pgvector 后端的
check_ready是await asyncio.to_thread(self._check_ready)(adapters/vector/pgvector.py:100--102),阻塞调用丢进线程,事件循环不受影响。 - 存储适配器的每个同步操作都经过
_run_sync_operation:asyncio.to_thread外面再包一层asyncio.wait_for(adapters/storage/fsspec.py:23--35),默认超时 10 秒 (settings.py:178,storage_operation_timeout_seconds)。
所以修法也很清楚:Milvus 的探针照存储那样包一层 to_thread + wait_for, 超时给一两秒就够。issue #44 里建议的正是这个写法,只是 issue 当时只看到了「慢」, 没看到「冻」。
五、反过来:存储那道门只检查一次
存储是硬门,按说应该是最严的一道。看 ensure_ready(adapters/storage/fsspec.py:120--158):
plaintext
async def ensure_ready(self) -> None:
if self._ready:
return
async with self._ready_lock:
if self._ready:
return
...
exists = await _run_sync_operation("storage_ready", ..., self.fs.exists, readiness_root)
...
if not exists:
raise KernelError("STORAGE_NOT_READY", "Storage root is not ready", ...)
self._ready = True
第一次成功之后,_ready 被置为 True,此后每次调用在第一行就直接返回。
这个缓存能活多久,取决于适配器实例活多久。readiness 用的实例来自依赖注入容器的 get("storage_port")(router.py:44--47),而容器的 get() 会把工厂造出来的实例缓存成 单例 (wiring/container.py:350--372)。所以:一个 API 进程的生命周期里, 存储这道门只真正检查到第一次成功为止。
本机复现(fsspec 的 memory:// 后端,不依赖任何外部服务):
plaintext
1st probe: ok
root removed, exists = False
2nd probe on same instance: ok (cached)
fresh instance raised: KernelError STORAGE_NOT_READY Storage root is not ready
根目录已经不存在了,同一个实例仍然报就绪;换一个新实例才报错。
这对启动阶段是有用的:MinIO 没起来时,api 不会被判为 ready。但启动之后 MinIO 挂了, /health/ready 依然会返回 "storage":"connected",而真正读写对象的请求会各自失败。 ensure_ready 这个名字其实说得很准------它是「确保初始化过」,不是「探测现在通不通」; 问题在于 readiness 把它当成了后者来用。
现有的单测(tests/unit/test_health_readiness.py:52--57)用一个直接抛异常的假存储 验证了 503 分支,没有覆盖「先成功、后失联」这种情况。
六、生产版 compose 的探针,打的是一个不存在的路径
开发版的探针打 /health/ready。生产版 docker/docker-compose.production.yml:151--156:
plaintext
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request;urllib.request.urlopen('http://localhost:9200/api/v1/health/ready')\""]
interval: 15s
timeout: 10s
retries: 5
start_period: 30s
多了一个 /api/v1 前缀。第一节说过,health 路由挂载时没有前缀。我用 Starlette 的 TestClient 直接打当前主干上的应用:
plaintext
/api/v1/health/ready 404 {"success":false,"code":"NOT_FOUND","message":"Not Found",...
/health/live 200 {"success":true,"code":"OK","message":"OK","data":{"status":...
urlopen 碰到 404 会抛 HTTPError,探针进程以非零退出------生产栈里的 api 容器会一直是 unhealthy。
它之所以没把生产栈搞挂,是因为生产版里没有任何服务用 condition: service_healthy 等 api: 网关和前端写的都是短格式的 depends_on: - api(docker-compose.production.yml:116--118、 :166--167),只等容器启动、不看健康。于是这个探针失败得悄无声息,只在 docker ps 里挂一个 unhealthy,以及让任何依赖容器健康状态的外部监控永远报警。
两个附带问题:
- 生产探针的
urlopen没有给超时 (开发版给了timeout=3),只能靠 compose 的 10 秒兜底。 - 从外面也打不到 readiness:生产网关 Caddy 只把
/api/*转给 api,其余一律转给前端 (docker/production/Caddyfile:18--19)。按这个路由规则,外部负载均衡器访问/health/ready会落到前端容器上。
仓库里还有两份文档也写的是带前缀的路径(docs/QUALITY_GATE.md:199、 docs/operations/database-connections.md:74),而快速开始文档写的是对的 (docs/quickstart.md:76)。打算提一个 issue,一并改掉。
七、.env 只生效了一半
这是 issue #27 里点到的第一个坑,值得单独讲清楚,因为它的症状不是「启动失败」, 而是「启动成功了,但用的不是你的配置」。
开发版 compose 里,每个服务端容器同时有两处配置来源(docker-compose.yml:159--215):
plaintext
env_file:
- path: ../.env
required: false
environment:
DATABASE_PASS: ${DATABASE_PASS:-soit}
SECRET_KEY: ${SECRET_KEY:-change-me}
...
两条 compose 规则叠在一起就出事了:
- 同一个键在
environment:和env_file:里都有时,environment:赢。 environment:里的${DATABASE_PASS:-soit}是插值 ,插值读的是项目目录下的.env(也就是 compose 文件所在的docker/目录),或者命令行--env-file指定的文件------ 不是env_file:指向的那个../.env。
所以在仓库根目录放一份 .env、不加 --env-file 直接启动,${DATABASE_PASS:-soit} 找不到值,落回默认的 soit,然后覆盖掉 env_file 读进来的那一份。
用 docker compose config 就能验证,不需要起任何容器。根目录 .env 里放两行:
plaintext
DATABASE_PASS=from-root-env
MY_ONLY_KEY=only-in-env-file
plaintext
--- without --env-file:
DATABASE_PASS: soit
MY_ONLY_KEY: only-in-env-file
--- with --env-file .env:
DATABASE_PASS: from-root-env
MY_ONLY_KEY: only-in-env-file
同一份文件,一个键生效、一个键被静默换成了默认值。 environment: 块里列了四十多个键 (数据库、Redis、MinIO、Vault、SECRET_KEY、各家模型的 API key......),它们全都有这个问题; 没列在里面的键(比如 MILVUS_MODE、VECTOR_BACKEND)反而照常透传。
最麻烦的是它看起来一切正常:postgres 容器的 POSTGRES_PASSWORD 也走同一个默认值, 两边都是 soit,于是数据库连得上、服务起得来,只是你设的密码和 SECRET_KEY 根本没用上。
快速开始文档的命令里写了 --env-file .env(docs/quickstart.md:13),照着敲不会踩; 自己改命令、或者从 docker/ 目录里直接 docker compose up 的人会踩。
八、别拿 Milvus Lite 当容器里的绕法
九月初仓库加了一个 MILVUS_MODE=lite:用嵌入式的 Milvus Lite 读写本地文件, 不需要 milvus / etcd 容器。看起来正好能解决第三节的问题,但它不适合用在这套 compose 里:
- 它是单进程的文件库。提交说明写得很直白:一个进程独占这个数据库文件,别的进程读不到它。
- 在 compose 里,写向量的是
knowledge-ingest-worker,查向量的是api,两个独立容器, 之间没有共享卷。按这个结构,两边会各自打开自己容器里的那个文件。 - 生产环境直接拒绝 lite(
settings.py:550--554)。
它的定位是「本机调试知识库」(docs/development.md 的 Milvus Lite 一节), 不是「砍掉 Milvus 的部署方式」。
九、排障速查表
| 症状 | 跑这条 | 看什么 | 怎么办 |
|---|---|---|---|
docker compose ps 里 api 是 unhealthy |
docker inspect --format '{{json .State.Health}}' soit-api-1 |
Log 里最近 5 次探针的 Output:timed out ⇒ 探针超时;HTTP Error 503 ⇒ 数据库或存储没过;HTTP Error 404 ⇒ 探针路径错了 |
分别看下面三行 |
| 探针超时 | curl -s -m 60 -o /dev/null -w '%{http_code} %{time_total}s\n' http://localhost:9200/health/ready |
200 但远超 3 秒 ⇒ 向量库不可达(第四节) | 起 milvus + etcd;或接受 unhealthy,用 --no-deps 起 web |
| 503 | 同上,去掉 -o /dev/null 看返回体 |
Database is unavailable 或 Object storage is unavailable |
查 postgres / minio 容器与连接配置;注意存储这道门只在启动时有效(第五节) |
| 404 | 看 compose 文件里的探针路径 | 带了 /api/v1 前缀 |
改成 /health/ready(第六节) |
| web 一直不启动 | docker compose -f docker/docker-compose.yml ps -a |
api 不是 healthy | 先解决 api;临时可 docker compose -f docker/docker-compose.yml up -d --no-deps web |
| api 根本没起来 | docker compose -f docker/docker-compose.yml ps -a migrate bootstrap minio-init |
一次性容器是不是 Exited (0) |
非 0 就看 docker compose -f docker/docker-compose.yml logs migrate bootstrap |
| 配置好像没生效 | docker compose --env-file .env -f docker/docker-compose.yml config api 与去掉 --env-file 的输出对比 |
你改的键在两份输出里是否一致 | 启动命令一律带 --env-file .env(第七节) |
worker 显示 Up 但不干活 |
docker compose -f docker/docker-compose.yml logs --tail 50 knowledge-ingest-worker scheduler |
这两个容器没有健康检查 ,Up 只代表进程在 |
看日志;目前没有更好的信号 |
十、现在还对不上的地方
按老规矩,自己查出来的先自己列。
① 向量库探针在事件循环里做同步连接,且没有超时。 每次探针冻住整个进程 10 秒以上。 issue #44 已经记了「慢」,打算在 #44 下补上「冻」的复现 。修法:to_thread + wait_for, 与存储适配器同构。
② 存储这道硬门只检查到第一次成功为止。 启动后存储失联,readiness 不会反映。 影响:编排系统不会把一个存储已经断开的实例摘掉。绕法:监控对象存储本身,别只看 api 的 readiness。打算提一个 issue。
③ 生产版 compose 的 api 探针打到 404,且没有超时。 影响:生产栈的 api 恒 unhealthy, 外部监控恒报警。打算提一个 issue。
④ 两份文档里的 readiness 路径带了不存在的前缀 (QUALITY_GATE.md:199、 operations/database-connections.md:74)。与③一起改。
⑤ 生产网关没有把 readiness 暴露出去。 按 Caddyfile 的路由规则,/health/ready 会落到前端。影响:外部负载均衡器没有可用的就绪探测地址。
⑥ ReadyResponse 的 docstring 说 status 可能是 not_ready (router.py:31--32), 但代码从不返回它:不就绪时直接抛 503。小问题,但写客户端的人会按 docstring 去判断字段。
⑦ knowledge-ingest-worker 和 scheduler 没有健康检查。 进程卡死时 compose 仍显示 Up。
⑧ 不加 --env-file 时,.env 里与 environment: 块重名的键被静默覆盖。 这是 compose 的既定语义,不是 bug,但我们的 compose 写法让它变得很容易踩, 而且踩了之后没有任何报错。打算在 issue #27 的排障文档里把它列为第一条。
另有一处没有核实、只是没看到 的:数据库引擎没有显式配置连接超时或连接池等待超时 (infra/db/session.py:65--73 只传了 pool_pre_ping、pool_size、max_overflow), 所以数据库那道门在「数据库主机不可达」时要等多久,取决于驱动的默认值。这次没有测, 不下结论。
坦白局
- 这次有实跑,但没有起容器。 实跑的四件事都在本机:pymilvus 的连接耗时与事件循环 阻塞、存储适配器的缓存(
memory://后端)、TestClient打两个路径、docker compose config验证插值。代码对应soit/主干提交8a24af4。 - 容器里那 34 秒是上个月的实测,「API 在容器里大部分时间是冻住的」是推论。 依据是单进程 uvicorn 加上本机实测的事件循环阻塞,我没有在容器里测请求排队的时长。
- 本机的 10.1 秒不等于你那里的数字。 它是 pymilvus 的默认连接超时; 服务名解析不到、端口被防火墙丢包、主机在但端口没开,耗时都不一样。
- 我只读了社区版。
- 这篇更正了我们自己上一篇里的一句话。 「服务本身是好的」写的时候是基于「登录成功了」 这个观测,观测本身没错,推论少走了一步。
- 利益相关:我是 SOIT 的维护者。
一句话结论
健康检查不是样板代码。一个探针该查什么、查几次、花多久、会不会拖慢被查的服务, 每一条都要单独核对;而 healthy 只是说「上一次探针的进程以 0 退出了」。
来试试,也来挑刺
仓库在 github.com/soit-ai/soit。三个最快的核对入口:
server/app/adapters/vector/milvus.py:112--115,对照adapters/vector/pgvector.py:100--102, 看一个async def里有没有to_thread;server/app/adapters/storage/fsspec.py:120--158,从if self._ready:那一行读下去;- 把
docker/docker-compose.production.yml:152的探针路径和server/app/main.py:429的挂载方式放在一起看。
如果你在自己的部署里碰到了速查表之外的症状,欢迎开 issue------issue #27 那份排障文档 还没人认领,你遇到的坑可能正好是下一条。