api 容器一直 unhealthy?我把一个 Agent 运行时的健康检查逐条拆了,查出四个问题

docker compose ps 里 api 显示 unhealthy,前端容器因此一直不启动,日志里什么都没有。 这是我们上个月写最小部署拓扑时撞上的,当时的结论是「readiness 慢了点,服务本身是好的, 加个 --no-deps 绕过去」。

这次我回头把整套健康检查从端点到 compose 逐条拆了一遍,发现那句「服务本身是好的」 只说对了一半,顺带又查出三个问题:一道只在启动时检查一次的存储门、一个在生产配置里 永远打到 404 的探针、以及一份只生效了一半的 .env。每一条都附了本机复现和文件行号, 文末有一张排障速查表。

先看结论

问题:api 容器显示 unhealthy 的时候,它说的对吗?它到底在检查什么?

一句话:我们的健康检查有一处查得太重,有一处查得太轻,有一处查错了地方,还有一处 根本没收到你以为你给它的配置。

  1. 太重 :向量库的 readiness 探针是 async def,里面却做了一次同步的 gRPC 连接。 向量库不在时,每次探针都会把整个 API 进程的事件循环冻住 10 秒以上 (本机实测 10.1 秒;容器里之前实测整个请求 34 秒)。
  2. 太轻 :对象存储是 readiness 的硬门,但它只在第一次成功之前 真正检查,之后结果被 永久缓存。MinIO 在启动后挂掉,readiness 照样报 connected。
  3. 查错了地方 :生产版 compose 的探针打的是 /api/v1/health/ready,而这个路径不存在, 返回 404。真正的端点在 /health/ready。
  4. 没收到配置 :不加 --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 规则叠在一起就出事了:

  1. 同一个键在 environment: 和 env_file: 里都有时,environment: 赢。
  2. 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。三个最快的核对入口:

  1. server/app/adapters/vector/milvus.py:112--115,对照 adapters/vector/pgvector.py:100--102, 看一个 async def 里有没有 to_thread;
  2. server/app/adapters/storage/fsspec.py:120--158,从 if self._ready: 那一行读下去;
  3. 把 docker/docker-compose.production.yml:152 的探针路径和 server/app/main.py:429 的挂载方式放在一起看。

如果你在自己的部署里碰到了速查表之外的症状,欢迎开 issue------issue #27 那份排障文档 还没人认领,你遇到的坑可能正好是下一条。

相关推荐
暗不需求1 小时前
Docker 入门:从「光盘与 DVD」到全栈项目容器化实战
docker·容器·面试
春天花会开1311 小时前
内网 HTTPS 部署实战:私有 CA + Nginx 反向代理全流程复盘
运维·nginx·https
AllFiles1 小时前
K8s Node Exporter 异常 Write 超时排查实录
运维·后端·kubernetes
探索云原生1 小时前
一个 Deployment 就能跑 vLLM,为什么还需要 KServe?
docker·ai·云原生·kubernetes·go
虎头金猫5 天前
4K 视频总卡在公网带宽?用 N1 + OpenList 把网盘播放链路重新理顺
运维·服务器·网络·python·容器·beautifulsoup·pandas
分布式存储与RustFS5 天前
MinIO 官方 Docker 镜像被移除:依赖它的项目该怎么办
docker·云原生·devops·对象存储·minio·分布式存储
AI职业加油站6 天前
AI智能体应用工程师证书:政策红利下的职业新风口
大数据·运维·人工智能·学习·职场发展
此冬歌咏6 天前
K8s 节点故障实战:优雅驱逐 31 秒,硬故障 331 秒,以及那个永远 Pending 的 Pod
运维·k8s
玉&心6 天前
通过Arthas在线诊断K8S中的内存及JVM等使用情况
docker·k8s·arthas