解决Docker中FastAPI lifespan不执行:uv run -m命令详解
2025-03-28 08:46:56
解决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()
现在看三种运行情况:
-
本地直接运行(Case 1):
在项目根目录下执行python -m retrofun
。
结果:一切正常!FastAPI 应用启动,API 可以访问,控制台能看到 "LIFE SPAN: 应用启动啦!" 的打印。关闭应用时(比如按 Ctrl+C),也能看到 "LIFE SPAN: 应用关闭咯!" 的信息。完美! -
Docker 中使用
uv run -m
(Case 2 - 问题所在):
Dockerfile
的CMD
指令如下:# 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
语句却没在容器日志里出现。启动和关闭的事件好像被跳过了?这就奇怪了。 -
Docker 中换种
uv run
方式(Case 3):
如果把Dockerfile
的CMD
指令改成这样:# 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 -m
和 if __name__ == "__main__":
的“误会”
要理解这个问题,关键在于搞清楚 python -m retrofun
和 uv 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 拿到了正确的、配置了lifespan
的app_api
实例,自然就能正确处理生命周期事件。 -
当执行
uv run -m retrofun --host ... --port ...
时:
uv run
是一个工具,它尝试去运行一个 ASGI 应用。当你给它-m retrofun
参数时,uv
(或者它内部调用的 Uvicorn) 会尝试去 导入retrofun
模块,然后在这个模块里 寻找 一个符合 ASGI 规范的应用实例(通常是名为app
或application
的变量,或者通过其他约定)。
关键点来了: 在这种模式下,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__.py
的main()
函数,所以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
就是示例。
注意事项:
- 确保
PYTHONPATH
设置正确,或者你的项目结构使得 Python 能找到retrofun
模块。如果你的代码根目录是/app
且retrofun
在/app/retrofun
,则不需要设置PYTHONPATH
,可以直接CMD ["python", "-m", "retrofun", ...]
. 如果代码在/app/src
下面,像例子中那样设置PYTHONPATH
是比较常见的做法。 CMD
命令里的-p 0.0.0.0:5555
参数会被retrofun/__main__.py
里的argparse
解析,所以格式要跟你脚本里定义的一致。- 这种方式下,应用进程就是 Python 解释器进程。
进阶技巧:
这种方式的好处是启动逻辑完全由你的 main()
函数控制,包括参数解析、日志配置等,都跟你本地开发调试时一致,减少了环境差异带来的意外。如果你在 uvicorn.run
中使用了 reload=True
,那么在开发镜像中也能工作(尽管在生产环境强烈不建议开启 reload
)。
方案二:修改 Docker CMD,明确告诉 uv run
应用实例位置
如果你仍然想用 uv run
作为启动命令(比如想利用它的一些特性,或者只是偏好),可以不使用 -m
参数,而是直接告诉它 FastAPI 应用实例的确切位置。
原理:
模仿 uvicorn.run()
第一个参数的格式("module:instance"
),直接提供给 uv run
命令。这样 uv run
就能直接定位到那个配置了 lifespan
的 app_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
就是示例。
注意事项:
- 这种方式直接指定了
retrofun.__main__:app_api
,所以uv run
会直接导入并使用这个实例。 - 它 不会 执行
retrofun/__main__.py
里面的if __name__ == "__main__":
代码块,所以你的main()
函数不会被调用。这意味着:- 你在
main()
函数里做的任何事情(比如参数解析、额外的日志配置等)都不会生效。 - 启动参数(如 host 和 port)需要直接传给
uv run
命令(使用--host
和--port
,而不是你自定义的-p host:port
)。
- 你在
- 如果你的
main()
函数里除了启动 Uvicorn 外还有其他重要逻辑,这种方法就不太合适了,或者你需要把那些逻辑移到模块加载时执行(但这通常不推荐)。
进阶技巧:
这种方式可能启动更快一点点,因为它省去了执行 main()
函数的步骤。如果你应用启动很简单,没有复杂的初始化逻辑放在 main()
里,这种方式也是可行的。但要小心它绕过了 main()
函数带来的潜在影响。如果需要传递配置,可以考虑使用环境变量,并在 lifespan
函数或者模块顶层读取环境变量。
方案三:调整项目结构或 App 定义(作为参考)
虽然不是直接解决 uv run -m
的问题,但有时调整项目结构也能避免这类困惑。
原理:
把 FastAPI 的 app 实例定义在一个更“纯粹”的模块里,而不是混在 __main__.py
这种既是模块又是执行入口的文件里。
操作步骤:
- 创建一个新文件,比如
retrofun/app.py
。 - 把
lifespan
函数和app_api = fa.FastAPI(lifespan=lifespan)
的定义移到retrofun/app.py
中。 - 修改
retrofun/__main__.py
,让它从retrofun.app
导入app_api
。main()
函数里的uvicorn.run()
调用需要相应修改为"retrofun.app:app_api"
。 - 修改你的
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
执行方式的影响。选择哪种解决方案取决于你的项目结构和偏好,但方案一(直接执行脚本)通常是最不容易出错且与本地开发行为最一致的选择。