《AI Agent 工程实战》16.3 配置注入、健康检查与优雅退出

16.2 把服务编排起来了,但容器编排还有三件「看不见却致命」的事:配置怎么注入、健康检查怎么区分 liveness 与 readiness、收到 SIGTERM 后怎么优雅退出。本节讲清用环境变量注入配置、两条健康线各自的取舍、以及「停止接新请求→等在途 run 收尾→关连接池→退出」四步。用 uvicorn 0.54.0 + lifespan 真发 SIGTERM,贴出完整日志序列与参数表。

16.3 配置注入、健康检查与优雅退出

16.2 的 compose 让四个服务能一起起来了,但那只是「起得来」。容器编排真正难的是三件平时看不见、出事才要命的事:配置怎么进容器、怎么判断服务该不该接流量、服务被要求停止时怎么把在途的活儿收干净。这三件事做不好,表现为随机崩溃、滚动更新时丢请求、以及数据库里一堆永远停在 running 的僵尸 run。

本节把 ResearchCopilot 推进到「可被编排系统管理」的形态:配置走环境变量注入、健康检查分清 liveness 与 readiness、收到 SIGTERM 后按四步优雅退出。用一个带 lifespan 的 uvicorn 应用真发 SIGTERM,把整个退出序列的日志贴出来。

16.3.1 配置注入:环境变量,不打进镜像

一个铁律:镜像里不许有环境相关的配置。数据库地址、Redis 端口、API 密钥、日志级别——这些都会随环境变,如果打进镜像,你就得为每个环境构建一个镜像,而镜像本该是「一次构建、到处运行」的不可变产物。

正确做法是配置在运行时注入,容器里最自然的载体是环境变量。ResearchCopilot 的 api 服务在 compose 里这样拿配置:

    environment:
      DATABASE_URL: postgresql://postgres:postgres@postgres:5432/research
      REDIS_URL: redis://redis:6379/0
      LOG_LEVEL: info

应用侧用 pydantic-settings 读取(第 2 章建立的 Settings 模型),环境变量名和字段名按约定映射。真跑一下注入:

import os

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="", extra="ignore")
    database_url: str = "postgresql://localhost/research"
    redis_url: str = "redis://localhost:6379/0"
    log_level: str = "info"


os.environ["DATABASE_URL"] = "postgresql://postgres:postgres@postgres:5432/research"
os.environ["REDIS_URL"] = "redis://redis:6379/0"
s = Settings()
print("database_url =", s.database_url)
print("redis_url    =", s.redis_url)
print("log_level    =", s.log_level)
database_url = postgresql://postgres:postgres@postgres:5432/research
redis_url    = redis://redis:6379/0
log_level    = info

环境变量里的 DATABASE_URL 覆盖了代码默认值,LOG_LEVEL 没设就落到默认的 info——「环境变量 > 默认值」的分层顺序被实测验证。这样带来三个好处:同一个镜像能在 dev / staging / prod 跑;密钥不进镜像层(密钥在镜像里等于公开,因为谁 docker history 都能翻出来);配置变更不用重新构建,改环境变量重启即可。

分层顺序要明确:环境变量 > .env 文件 > 代码里的默认值。.env 只用于本地开发(进 .gitignore),生产一律走环境变量,由编排系统或密钥管理注入。这里和第 2 章的「配置分层」是同一套口径,只是载体的最后一段从「本机 shell」换成了「容器环境变量」。

16.3.2 健康检查的两条线:liveness 与 readiness

很多人把「健康检查」当成一个东西,其实是两条语义完全不同的线,混用会出大事故:

探测问的问题失败后编排系统做什么依赖下游吗
liveness进程还活着、没死锁吗重启容器不依赖
readiness现在能接流量吗摘掉,不转发新请求可以依赖

区别的关键在失败后果。liveness 失败 = 重启容器,所以它必须只探本进程——/livez 里绝不能去连数据库。否则数据库抖动一下,所有 API 容器 liveness 全失败、集体重启,而重启并不能修好数据库,反而制造一场风暴。

readiness 失败 = 从负载均衡里摘掉,所以它可以探依赖——「数据库连不上时,先别给我派活」。但这里有个取舍:如果 readiness 直接探 DB,DB 一抖动,全部副本会同时被摘流量,服务对外表现为 100% 不可用,比「降级返回部分错误」更糟。所以实践中常见的分法:

  • /livez(liveness):只返回进程自身状态,永远 200。
  • /readyz(readiness):探关键依赖,但加一个「抖动容忍」——连续几次失败才判不健康,避免单次超时误摘。

回到 16.2 的 compose,api 的 healthcheck 打的是 /healthz、探的是 HTTP 通不通——那更接近 readiness。真正生产里要拆成 /livez 与 /readyz 两个端点,compose 的 healthcheck 只对应其中一个(本地编排够用,K8s 才需要两条分开的 probe)。

16.3.3 优雅退出:SIGTERM 之后要做四件事

容器被停止时,编排系统发的信号是 SIGTERM,不是 SIGKILL。这是一个「请你体面地收尾」的请求,语义上给你一段时间;超时后才 SIGKILL 强杀。收到 SIGTERM 后,一个 Agent 服务应该按顺序做四件事:

  1. 停止接新请求:关掉 listener,不再 accept 新连接。新来的请求被拒绝,而不是排进一个永远处理不完的队列。
  2. 等在途 run 收尾:正在跑的 Agent run 可能已经调了模型、写了一半数据库,不能中途硬砍。给它一个上限时间等它跑完。
  3. 关闭连接池 / 取消订阅:释放 Postgres 连接、退订 Redis 频道,让下游知道这个副本走了。
  4. 退出:进程结束。

为什么要给 terminationGracePeriodSeconds 留足时间(K8s 概念,compose 里对应 stop_grace_period):从「发 SIGTERM」到「SIGKILL 强杀」之间的窗口就是它。如果这个窗口比「最长的在途 run」还短,第 2 步根本等不完——编排系统会在 run 跑完前把进程砍掉。

不优雅退出会丢什么,这是本节最该记住的一点:一个正在跑的 Agent run,其状态在数据库里是 running。如果进程被 SIGKILL 硬杀,这个 run 没有机会把自己的状态改成 failed 或 cancelled,于是它就永远停在 running——变成僵尸 run。前端会一直转圈,配额统计会算错,重试逻辑不知道该不该重跑。优雅退出就是把「run 的生命周期」和「进程的生命周期」对齐:进程要死,先让 run 死得体面。

16.3.4 真跑:uvicorn + lifespan 的 SIGTERM 日志序列

把上面的四步落到代码。uvicorn 提供了两处挂载点:lifespan(应用启动/关闭时的钩子,用来开/关连接池)和 timeout_graceful_shutdown(等连接关闭的上限)。ResearchCopilot 的 app 长这样:

import asyncio
import contextlib

from fastapi import FastAPI


@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
    print("[lifespan] startup: 打开连接池 / 订阅队列", flush=True)
    app.state.pool = "pg-pool"
    yield
    print("[lifespan] shutdown: 停止接新 run,等待在途 run 收尾", flush=True)
    await asyncio.sleep(0.3)
    print("[lifespan] shutdown: 关闭连接池 / 取消订阅", flush=True)


app = FastAPI(lifespan=lifespan)


@app.get("/healthz")
def healthz() -> dict[str, str]:
    return {"status": "ok"}


@app.get("/slow")
async def slow() -> dict[str, str]:
    print("[req] /slow 开始(模拟一个跑了 2s 的 run)", flush=True)
    await asyncio.sleep(2.0)
    print("[req] /slow 结束,返回结果", flush=True)
    return {"ok": "done"}

要验证的关键是:SIGTERM 到来时,一个正在跑的 /slow 请求会不会被砍掉。写个驱动脚本,先起一个 /slow 请求让它进入在途状态,再发 SIGTERM,然后立刻试一个新请求看是否被拒:

import os
import signal
import threading
import time
import urllib.request

import uvicorn


def driver() -> None:
    time.sleep(1.0)
    in_flight: list = []

    def fire() -> None:
        body = urllib.request.urlopen("http://127.0.0.1:18001/slow", timeout=10).read()
        in_flight.append(("ok", body.decode()))

    t_req = threading.Thread(target=fire)
    t_req.start()
    time.sleep(0.5)  # 让 /slow 进入在途状态

    print(">>> 发送 SIGTERM", flush=True)
    os.kill(os.getpid(), signal.SIGTERM)
    time.sleep(0.3)

    print(">>> SIGTERM 后尝试发新请求(应被拒绝)", flush=True)
    try:
        urllib.request.urlopen("http://127.0.0.1:18001/healthz", timeout=3).read()
        print(">>> 新请求竟然成功了", flush=True)
    except Exception as exc:
        print(f">>> 新请求被拒绝: {type(exc).__name__}", flush=True)

    t_req.join()
    print(f">>> 在途请求结果: {in_flight[0]}", flush=True)


config = uvicorn.Config(app, host="127.0.0.1", port=18001, log_level="info",
                        timeout_graceful_shutdown=5)
server = uvicorn.Server(config)
threading.Thread(target=driver).start()
server.run()  # 必须在主线程跑,uvicorn 才会安装 SIGTERM 处理器

注意最后一行 server.run() 必须在主线程执行——uvicorn 只从主线程安装信号处理器(signal.signal 的硬约束),如果放到子线程里跑,SIGTERM 会走默认行为直接把进程杀掉,优雅退出根本不会发生。真跑(Python 3.14.6 / uvicorn 0.54.0):

INFO:     Started server process [24810]
INFO:     Waiting for application startup.
[lifespan] startup: 打开连接池 / 订阅队列
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:18001 (Press CTRL+C to quit)
[req] /slow 开始(模拟一个跑了 2s 的 run)
>>> 发送 SIGTERM
INFO:     Shutting down
INFO:     Waiting for connections to close. (CTRL+C to force quit)
>>> SIGTERM 后尝试发新请求(应被拒绝)
>>> 新请求被拒绝: URLError
[req] /slow 结束,返回结果
INFO:     127.0.0.1:60041 - "GET /slow HTTP/1.1" 200 OK
>>> 在途请求结果: ('ok', '{"ok":"done"}')
INFO:     Waiting for application shutdown.
[lifespan] shutdown: 停止接新 run,等待在途 run 收尾
[lifespan] shutdown: 关闭连接池 / 取消订阅
INFO:     Application shutdown complete.
INFO:     Finished server process [24810]

逐行读,四步清清楚楚:

  • 停止接新请求:INFO: Shutting down + Waiting for connections to close 之后,新请求 >>> 新请求被拒绝: URLError——listener 已关,连接被拒。
  • 等在途 run 收尾:老的 /slow 没被砍,继续跑到 [req] /slow 结束,返回 200 OK,驱动侧拿到 ('ok', '{"ok":"done"}')。
  • 释放资源:Waiting for application shutdown 后进入 lifespan 的 yield 之后,打印「停止接新 run」→ 等 0.3s →「关闭连接池 / 取消订阅」。
  • 退出:Application shutdown complete → Finished server process。

还有两个实测出来的真实细节值得记:

其一,进程最终以退出码 143 结束,这是正常的。 143 = 128 + 15(SIGTERM 的编号)。查 uvicorn 源码(server.py 的 capture_signals)会发现,它优雅退出完成后会重新抛出收到的信号:signal.raise_signal(captured_signal)。所以「优雅退出」不等于「退出码 0」——它保证的是「收尾做完了」,而不是「假装没被终止」。编排系统据此判断容器是被 SIGTERM 正常停的。

其二,timeout_graceful_shutdown 是硬上限。 上面设的是 5 秒;如果 /slow 要跑 30 秒,uvicorn 等满 5 秒后就会强杀在途请求,第 2 步就失效了。所以这个值必须 大于你最长的在途 run,同时小于编排系统的 terminationGracePeriodSeconds——留出几百毫秒给第 3、4 步。三者关系是:timeout_graceful_shutdown < terminationGracePeriodSeconds,否则 SIGKILL 会先到。

16.3.5 反例:SIGKILL 会丢掉在途 run

为了看清「优雅」二字值多少钱,把同样的场景换成 SIGKILL 强杀——这正是编排系统在宽限期超时后干的事。这次把服务作为子进程跑,在途请求发起后直接 proc.kill():

import subprocess
import sys
import threading
import time
import urllib.request

proc = subprocess.Popen([sys.executable, "hard_server.py"])
time.sleep(1.5)
result: list = []


def fire() -> None:
    try:
        body = urllib.request.urlopen("http://127.0.0.1:18002/slow", timeout=10).read()
        result.append(("ok", body.decode()))
    except Exception as exc:
        result.append(("err", type(exc).__name__))


t = threading.Thread(target=fire)
t.start()
time.sleep(0.5)
print(">>> 用 SIGKILL 强杀进程", flush=True)
proc.kill()  # 等价于 kill -9,不给任何收尾机会
t.join()
print(">>> 在途请求结果:", result[0], flush=True)
proc.wait()
print(">>> 进程退出码:", proc.returncode, flush=True)

真跑输出:

INFO:     Started server process [25665]
INFO:     Waiting for application startup.
[lifespan] startup: 打开连接池
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:18002 (Press CTRL+C to quit)
[req] /slow 开始(在途 run)
>>> 用 SIGKILL 强杀进程
>>> 在途请求结果: ('err', 'RemoteDisconnected')
>>> 进程退出码: -9

对比鲜明:在途请求拿到 RemoteDisconnected(连接被硬断),退出码是 -9(被 SIGKILL 杀掉),而且 [lifespan] shutdown 那一行根本没有出现——lifespan 的关闭钩子完全没执行。映射回 Agent 场景:如果此刻这个 run 在数据库里的状态是 running,它再也没机会被改成 failed 或 cancelled,就成了永远转圈的僵尸 run。SIGTERM 与 SIGKILL 的差别,就是「run 死得体面」与「run 永远卡在 running」的差别。

16.3.6 参数表:健康检查与优雅退出

把本节涉及的所有参数集中成一张表,调参时对照:

参数位置建议值说明
intervalcompose healthcheck5s探测间隔;太密费资源,太疏反应慢
timeoutcompose healthcheck3s单次探测超时;要小于 interval
retriescompose healthcheck5–10连续失败几次判不健康;DB 首启慢就调大
start_periodcompose healthcheck10–30s启动宽限期,期间失败不计入 retries
timeout_graceful_shutdownuvicorn10–30s等在途请求的上限,要 > 最长 run
stop_grace_periodcompose≥ uvicorn 上限 + 5scompose 版「宽限窗口」
terminationGracePeriodSecondsK8s≥ uvicorn 上限 + 5sK8s 版「宽限窗口」
--timeout-keep-aliveuvicorn5s空闲长连接保持时间

这张表里最容易被忽略的是 start_period:数据库、模型连接池首次初始化可能要十几秒,没有它,retries 会被启动期的失败消耗光,服务还没起来就被判不健康。

16.3.7 验收清单

  • 配置全部走环境变量注入,镜像里没有环境相关的配置文件或密钥。
  • liveness 只探本进程,绝不连数据库;readiness 才探依赖。
  • readiness 对依赖抖动有容忍(连续失败才摘流量)。
  • 应用挂了 lifespan / shutdown hook,收到 SIGTERM 后停止接新请求、等在途收尾、关连接池。
  • timeout_graceful_shutdown < terminationGracePeriodSeconds(或 stop_grace_period)。
  • 真发一次 SIGTERM,确认在途请求跑完、新请求被拒、退出码 143。

真实环境里会怎样:本节的 /slow 是 asyncio.sleep 假装的在途 run,真实 run 要调模型、写数据库,可能跑几十秒——所以真实环境里 terminationGracePeriodSeconds 往往要给到 30–60 秒。还有一个坑:如果 shutdown 钩子里去关连接池,而此时仍有 run 在用连接,就会报错——正确顺序是「先等在途 run 结束,再关池」,本节 lifespan 里的 await asyncio.sleep(0.3) 就是那个「等在途」的占位。更严谨的做法是维护一个在途 run 的计数/事件,shutdown 时 await 它归零。这三节把 ResearchCopilot 装进了容器、编排起来、并做成了可被编排系统安全重启的服务形态。下一章进入交付的最后一公里:CI/CD 流水线、灰度发布与不中断的 schema 迁移。

阅读导航:上一节:16.2 本地编排:compose 起 API/worker/postgres/redis · 下一节:17.1 流水线:lint/test/评测/构建串联 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距