返回

解决Docker中FastAPI lifespan不执行:uv run -m命令详解

python

解决Docker中FastAPI lifespan不执行的问题:uv run -m的陷阱与对策

咱们在用 FastAPI 开发应用的时候,lifespan 参数是个挺方便的东西,可以在应用启动和关闭时执行一些初始化或清理操作,比如连接数据库、加载模型、释放资源等等。通常情况下,这玩意儿用起来挺顺手的。但有时把它扔进 Docker 里跑,就可能碰到 lifespan “罢工” 的情况,启动和关闭时定义的那些函数好像凭空消失了,控制台也看不到预期的打印信息。

这篇博客就来聊聊这个有点诡异的问题,特别是当你用 uv run -m your_module 命令在 Docker 里启动应用时,为啥 lifespan 会失效,以及怎么搞定它。

问题浮现:本地好好的,Docker 里就不行了?

我们先来看看典型的问题场景。假设你的 FastAPI 应用结构类似这样:

项目主入口文件 retrofun/__main__.py

import contextlib
import argparse
import uvicorn
import fastapi as fa
from retrofun import test # 假设有个 test 模块,包含 API 路由

@contextlib.asynccontextmanager
async def lifespan(app: fa.FastAPI):
    """定义应用的生命周期事件"""
    print("LIFE SPAN: 应用启动啦!")
    # 这里可以放启动时要执行的代码,比如初始化数据库连接池
    yield # 应用运行期间会卡在这里
    # yield 后面的代码在应用关闭时执行
    print("LIFE SPAN: 应用关闭咯!")
    # 这里可以放关闭时要执行的代码,比如断开数据库连接

# 创建 FastAPI 应用实例,并传入 lifespan
app_api = fa.FastAPI(lifespan=lifespan)

# 包含其他路由
app_api.include_router(test.router)

def main():
    """应用主函数,负责解析参数和启动 Uvicorn"""
    parser = argparse.ArgumentParser(
        description="运行 RetroFun 应用。"
    )
    parser.add_argument(
        "-p", "--host-port", type=str, default="localhost:8000",
        help="指定主机和端口,格式 host:port (默认: localhost:8000)"
    )
    args = parser.parse_args()

    # 解析主机和端口
    try:
        host, port_str = args.host_port.split(':')
        port = int(port_str)
    except ValueError:
        print(f"错误:无效的 host:port 格式 '{args.host_port}'")
        return

    print(f"准备在 {host}:{port} 启动 retrofun ...")

    # 使用 uvicorn 运行 FastAPI 应用
    # 注意这里的 app 参数是字符串 "retrofun.__main__:app_api"
    # 这告诉 uvicorn 去哪里找那个 FastAPI 实例
    uvicorn.run(
        "retrofun.__main__:app_api",
        host=host,
        port=port,
        reload=True # 开发时开启热重载,生产环境建议关闭
    )

if __name__ == "__main__":
    # 如果这个脚本是直接被 Python 执行的(比如 python -m retrofun)
    # 那么就调用 main 函数
    main()

现在看三种运行情况:

  1. 本地直接运行(Case 1):
    在项目根目录下执行 python -m retrofun
    结果:一切正常!FastAPI 应用启动,API 可以访问,控制台能看到 "LIFE SPAN: 应用启动啦!" 的打印。关闭应用时(比如按 Ctrl+C),也能看到 "LIFE SPAN: 应用关闭咯!" 的信息。完美!

  2. Docker 中使用 uv run -m (Case 2 - 问题所在):
    DockerfileCMD 指令如下:

    # Dockerfile
    FROM python:3.11-slim
    
    WORKDIR /app
    
    # (这里省略了拷贝代码、安装依赖等步骤...)
    # COPY ./src /app/src
    # RUN pip install uv fastapi uvicorn your-other-dependencies
    
    # 使用 uv run -m 来启动模块
    CMD ["uv", "run", "-m", "retrofun", "--host", "0.0.0.0", "--port", "5555"]
    

    构建镜像并运行容器。
    结果:应用跑起来了,API 也能正常访问,但 lifespan 里定义的 print 语句却没在容器日志里出现。启动和关闭的事件好像被跳过了?这就奇怪了。

  3. Docker 中换种 uv run 方式(Case 3):
    如果把 DockerfileCMD 指令改成这样:

    # Dockerfile (修改后的 CMD)
    CMD ["uv", "run", "fastapi", "run", "./src/retrofun/__main__.py", "--host", "0.0.0.0", "--port", "5555"]
    

    或者直接用 Python 运行:

    # Dockerfile (另一种修改后的 CMD)
    CMD ["python", "-m", "retrofun", "-p", "0.0.0.0:5555"]
    

    构建并运行。
    结果:lifespan 又恢复正常了!启动和关闭的打印信息都出现了。

核心疑问来了:为啥 Case 2 中那个看似简洁的 uv run -m retrofun 命令就让 lifespan 失效了呢?

问题根源分析:uv run -mif __name__ == "__main__": 的“误会”

要理解这个问题,关键在于搞清楚 python -m retrofunuv run -m retrofun 这两种命令在执行 retrofun/__main__.py 这个文件时的细微差别。

  • 当执行 python -m retrofun 时:
    Python 解释器会把 retrofun 包(或者说目录)当作一个脚本来运行。它会去寻找 retrofun 目录下的 __main__.py 文件,并把它作为顶级脚本执行。重点是,此时 __main__.py 文件中的 if __name__ == "__main__": 这个条件判断是 成立 的。因此,main() 函数会被调用。
    main() 函数内部,我们显式地调用了 uvicorn.run("retrofun.__main__:app_api", ...)。这个调用非常明确:它告诉 Uvicorn 去 retrofun.__main__ 模块里找到名为 app_api 的那个 FastAPI 应用实例,并且使用这个实例(以及它身上绑定的 lifespan 配置)来启动服务。Uvicorn 拿到了正确的、配置了 lifespanapp_api 实例,自然就能正确处理生命周期事件。

  • 当执行 uv run -m retrofun --host ... --port ... 时:
    uv run 是一个工具,它尝试去运行一个 ASGI 应用。当你给它 -m retrofun 参数时,uv (或者它内部调用的 Uvicorn) 会尝试去 导入 retrofun 模块,然后在这个模块里 寻找 一个符合 ASGI 规范的应用实例(通常是名为 appapplication 的变量,或者通过其他约定)。
    关键点来了: 在这种模式下,uv run 通常不会python -m 那样去执行 __main__.py 里的 if __name__ == "__main__": 代码块!也就是说,你的 main() 函数根本没被调用!既然 main() 没运行,里面那句至关重要的 uvicorn.run("retrofun.__main__:app_api", ...) 自然也没执行。
    uv run -m retrofun 只是加载了 retrofun/__main__.py 模块的代码,发现了顶层定义的 app_api 变量。它可能会找到这个 app_api 实例并尝试运行它,但因为它没有通过我们 main() 函数里那种明确的方式启动,Uvicorn 可能没有正确地识别或初始化附加在 app_api 上的 lifespan 协议处理。就像是你把零件(app_api)给了它,但没给它完整的说明书(通过 uvicorn.run 正确调用)。

  • 为啥 Case 3 (uv run fastapi run ...python -m ...) 又可以了?

    • CMD ["python", "-m", "retrofun", "-p", "0.0.0.0:5555"]: 这个和本地运行 Case 1 逻辑一样,执行了 __main__.pymain() 函数,所以 lifespan 被正确处理。
    • CMD ["uv", "run", "fastapi", "run", "./src/retrofun/__main__.py", ...]: fastapi run 这个子命令是 fastapi-cli (现在通常集成在 uv 或单独安装) 提供的,它专门设计用来运行 FastAPI 应用。它有更智能的应用发现机制,可能会直接执行脚本文件,或者通过其他方式确保 app_api 实例及其 lifespan 被正确加载和处理。它不依赖于 -m 的模块导入方式,而是更倾向于把指定的 .py 文件当作入口点来执行。

说白了,Case 2 的问题在于 uv run -m 的启动方式“绕过”了你精心准备的 main() 函数,导致 Uvicorn 没有得到运行带 lifespan 应用的“正确指令”。

解决方案:让 Lifespan 在 Docker 里重获新生

既然知道了原因,解决起来就顺理成章了。目标是确保 Uvicorn(或者 uv run)能够明确知道要运行哪个 FastAPI 实例,并且是以正确的方式启动,使其能够识别并执行 lifespan

方案一:修改 Docker CMD,直接执行启动脚本(推荐)

这是最直观也通常是最好的方法:让 Docker 容器里的启动命令跟你本地测试成功的方式保持一致。

原理:
直接用 python -m 来执行你的模块,这样就能确保 __main__.py 文件里的 if __name__ == "__main__": 块被执行,从而调用 main() 函数,进而正确地通过 uvicorn.run() 启动应用。

操作步骤:
修改 Dockerfile 中的 CMD 指令。

# Dockerfile

FROM python:3.11-slim
WORKDIR /app

# (假设代码在 src 目录下,并且 __main__.py 在 src/retrofun/ 内)
COPY ./src /app/src
# 安装依赖,注意包含了 uv, uvicorn, fastapi 等
RUN pip install uv uvicorn fastapi # ...以及你的其他依赖

# 设置 PYTHONPATH,让 python -m 能找到 retrofun 包
ENV PYTHONPATH=/app/src

# 修改 CMD 指令
# 使用 python -m 执行模块,并传入参数给 main() 函数解析
CMD ["python", "-m", "retrofun", "-p", "0.0.0.0:5555"]

代码示例:
上面 Dockerfile 中的 CMD 就是示例。

注意事项:

  1. 确保 PYTHONPATH 设置正确,或者你的项目结构使得 Python 能找到 retrofun 模块。如果你的代码根目录是 /appretrofun/app/retrofun,则不需要设置 PYTHONPATH,可以直接 CMD ["python", "-m", "retrofun", ...]. 如果代码在 /app/src 下面,像例子中那样设置 PYTHONPATH 是比较常见的做法。
  2. CMD 命令里的 -p 0.0.0.0:5555 参数会被 retrofun/__main__.py 里的 argparse 解析,所以格式要跟你脚本里定义的一致。
  3. 这种方式下,应用进程就是 Python 解释器进程。

进阶技巧:
这种方式的好处是启动逻辑完全由你的 main() 函数控制,包括参数解析、日志配置等,都跟你本地开发调试时一致,减少了环境差异带来的意外。如果你在 uvicorn.run 中使用了 reload=True,那么在开发镜像中也能工作(尽管在生产环境强烈不建议开启 reload)。

方案二:修改 Docker CMD,明确告诉 uv run 应用实例位置

如果你仍然想用 uv run 作为启动命令(比如想利用它的一些特性,或者只是偏好),可以不使用 -m 参数,而是直接告诉它 FastAPI 应用实例的确切位置。

原理:
模仿 uvicorn.run() 第一个参数的格式("module:instance"),直接提供给 uv run 命令。这样 uv run 就能直接定位到那个配置了 lifespanapp_api 实例。

操作步骤:
修改 Dockerfile 中的 CMD 指令。

# Dockerfile

FROM python:3.11-slim
WORKDIR /app

# (假设代码在 src 目录下,__main__.py 在 src/retrofun/ 内)
COPY ./src /app/src
RUN pip install uv uvicorn fastapi # ...以及你的其他依赖
ENV PYTHONPATH=/app/src

# 修改 CMD 指令
# 直接告诉 uv run 应用实例的位置
# 注意参数格式变成了 uv run 的标准格式 --host 和 --port
CMD ["uv", "run", "retrofun.__main__:app_api", "--host", "0.0.0.0", "--port", "5555"]

代码示例:
上面 Dockerfile 中的 CMD 就是示例。

注意事项:

  1. 这种方式直接指定了 retrofun.__main__:app_api,所以 uv run 会直接导入并使用这个实例。
  2. 不会 执行 retrofun/__main__.py 里面的 if __name__ == "__main__": 代码块,所以你的 main() 函数不会被调用。这意味着:
    • 你在 main() 函数里做的任何事情(比如参数解析、额外的日志配置等)都不会生效。
    • 启动参数(如 host 和 port)需要直接传给 uv run 命令(使用 --host--port,而不是你自定义的 -p host:port)。
  3. 如果你的 main() 函数里除了启动 Uvicorn 外还有其他重要逻辑,这种方法就不太合适了,或者你需要把那些逻辑移到模块加载时执行(但这通常不推荐)。

进阶技巧:
这种方式可能启动更快一点点,因为它省去了执行 main() 函数的步骤。如果你应用启动很简单,没有复杂的初始化逻辑放在 main() 里,这种方式也是可行的。但要小心它绕过了 main() 函数带来的潜在影响。如果需要传递配置,可以考虑使用环境变量,并在 lifespan 函数或者模块顶层读取环境变量。

方案三:调整项目结构或 App 定义(作为参考)

虽然不是直接解决 uv run -m 的问题,但有时调整项目结构也能避免这类困惑。

原理:
把 FastAPI 的 app 实例定义在一个更“纯粹”的模块里,而不是混在 __main__.py 这种既是模块又是执行入口的文件里。

操作步骤:

  1. 创建一个新文件,比如 retrofun/app.py
  2. lifespan 函数和 app_api = fa.FastAPI(lifespan=lifespan) 的定义移到 retrofun/app.py 中。
  3. 修改 retrofun/__main__.py,让它从 retrofun.app 导入 app_apimain() 函数里的 uvicorn.run() 调用需要相应修改为 "retrofun.app:app_api"
  4. 修改你的 Dockerfile CMD
    • 如果用方案一(执行脚本),CMD ["python", "-m", "retrofun", ...] 仍然有效,因为它会执行 __main__.py,里面导入并运行了正确的 app 实例。
    • 如果用方案二(直接指定实例),CMD 应改为 CMD ["uv", "run", "retrofun.app:app_api", "--host", "0.0.0.0", "--port", "5555"]。这种方式下,uv run -m retrofun.app 或许也能工作,因为 retrofun.app 模块比较简单,uv 可能更容易找到顶层的 app_api 实例,但具体行为还是要看 uv 的实现细节。不过,直接指定实例路径总是最稳妥的。

代码示例(示意):

retrofun/app.py:

import contextlib
import fastapi as fa
# ... 其他 import

@contextlib.asynccontextmanager
async def lifespan(app: fa.FastAPI):
    print("LIFE SPAN (from app.py): 应用启动啦!")
    yield
    print("LIFE SPAN (from app.py): 应用关闭咯!")

app_api = fa.FastAPI(lifespan=lifespan)

# 可以把路由包含也放在这里
# from . import test
# app_api.include_router(test.router)

retrofun/__main__.py (修改后):

import argparse
import uvicorn
from retrofun.app import app_api # 从 app.py 导入实例
from retrofun import test # 假设路由包含在这里处理

# 可以在这里再包含路由,或者在 app.py 包含
app_api.include_router(test.router)

def main():
    # ... (parser 部分不变) ...
    args = parser.parse_args()
    host, port = args.host_port.split(':')
    port = int(port)
    print(f"准备在 {host}:{port} 启动 retrofun (using app from app.py)...")

    # uvicorn.run 的目标字符串变了
    uvicorn.run(
        "retrofun.app:app_api", # 指向 app.py 里的实例
        host=host,
        port=port,
        reload=True
    )

if __name__=="__main__":
    main()

进阶技巧:
将 App 实例定义和业务逻辑(路由等)与启动脚本 (__main__.py) 分离,是更清晰的工程实践。这样每个文件的职责更单一。app.py 负责定义应用和它的核心配置,__main__.py 只负责启动服务(读取配置、调用 uvicorn.run)。

好了,关于 FastAPI lifespan 在 Docker 中使用 uv run -m 失效的问题以及如何解决,就聊这么多。核心在于理解不同启动命令对 __main__.py 执行方式的影响。选择哪种解决方案取决于你的项目结构和偏好,但方案一(直接执行脚本)通常是最不容易出错且与本地开发行为最一致的选择。