跳至正文

Docker部署FastAPI项目,差点让我把键盘吃了

事情是这样的,我有个FastAPI写的内部工具,平时在本机跑得挺欢实,想着部署到服务器上用Docker一劳永逸。结果从拉镜像到跑起来,整整折腾了两个晚上,中间一度怀疑自己是不是不会写Dockerfile了。

先说环境:服务器是Ubuntu 20.04,Docker版本20.10.12,docker-compose版本1.29.2。项目用的是Python 3.9,FastAPI 0.78.0,依赖里有个特殊的东西——onnxruntime,做模型推理用的。

第一次构建,Dockerfile写得很简单,就是常规的多阶段构建,先拉python:3.9-slim,装依赖,然后拷贝代码,最后CMD跑uvicorn。docker build过得很顺利,docker run也启动了,端口映射是8888:8000。我心想,这不挺好吗,三分钟搞定。

结果浏览器一打开,502 Bad Gateway。嗯?服务没起来?docker logs一看,报错信息是:ModuleNotFoundError: No module named ‘onnxruntime’。我明明在requirements.txt里写了onnxruntime啊。查了下,发现python:3.9-slim这个镜像是基于Debian的,缺了很多底层库,而onnxruntime的安装需要一些系统级别的依赖,比如libgomp1。说白了就是slim镜像太干净了,干净到连编译好的wheel都没法跑。

解决办法其实很简单:换用python:3.9-bullseye这个镜像,或者自己装那些系统依赖。我选了后者,在Dockerfile里加了RUN apt-get update && apt-get install -y libgomp1。重新build,run,这回报错变了:ImportError: libssl.so.1.1: cannot open shared object file。这次是openssl版本不对,python:3.9-slim里自带的libssl版本是1.1,但onnxruntime依赖的某个动态库链接到了更高版本。搞了半天,最后是把基础镜像换成了python:3.9,也就是完整的Debian镜像,问题才解决。

你以为这就完了?天真。

服务起来了,但CPU直接飙到95%。查了下,onnxruntime默认会调用所有CPU核心做推理,而我这个服务是单线程处理的,每个请求进来都会创建一个新的推理会话,加载模型,然后推理,再销毁。每个会话的初始化开销巨大,而且模型文件有200多MB,频繁加载等于在打自己的脸。这个其实不是Docker的问题,是我代码设计的问题,但Docker的部署环境放大了这个问题——本机跑的时候因为内存和CPU都充沛,没感觉到,但在容器里资源是受限的,一下子就暴露了。

解决方案是加一个全局的模型池,或者直接用单例模式,把模型加载到内存里复用。我选了个更偷懒的方式:用functools.lru_cache装饰器缓存模型的加载函数。具体代码大概是这样的:

“`python
from functools import lru_cache

@lru_cache(maxsize=1)
def load_model():
return onnxruntime.InferenceSession(“model.onnx”)
“`

这样一来,模型只加载一次,所有请求共享同一个会话。CPU占用从95%降到了15%左右。

然后又遇到一个新问题:模型文件怎么放进镜像里?一开始我是直接COPY model.onnx /app/,但构建镜像的时候发现镜像体积直接飙到了1.2GB,太可怕了。后来改成挂载卷的方式,在docker-compose.yml里用volumes把宿主机上的模型目录挂进去。这样镜像体积控制在400MB左右,而且模型更新的时候不用重新build镜像,直接替换宿主机上的文件再重启容器就行。

还有个坑是关于日志的。容器跑了一天后,我登录服务器发现磁盘快满了。排查后发现是容器里的日志文件没有轮转,uvicorn默认把访问日志和错误日志都打到标准输出,而Docker会把这些输出收集到json.log文件里,默认不限制大小。一个容器跑了几天,日志文件直接3个GB。解决方法是在docker run的时候加上–log-opt max-size=10m –log-opt max-file=3,或者在docker-compose里配置logging的driver和options。

最后说一个跟网络相关的坑。这个FastAPI服务还需要调用另一个内部API,那个API部署在同一台服务器的另一个容器里,端口是5000。我在代码里写的是http://localhost:5000,结果调用一直超时。后来才反应过来,Docker容器里的localhost指向的是容器本身,不是宿主机。要访问宿主机的服务,得用host.docker.internal(Windows/Mac)或者直接写宿主机IP(Linux)。在Linux上更简单的做法是把两个服务放到同一个docker network里,用服务名互相访问。我改成了docker-compose统一管理两个服务,然后在代码里用http://api-service:5000,问题解决。

总结一下踩坑经验:

第一,别盲目用slim基础镜像,尤其是项目里依赖了C扩展库的时候。省那几十MB的镜像体积,换来一堆诡异的报错,不值得。第二,容器里的资源限制不是开玩笑,代码里任何不合理的资源使用都会被放大。第三,日志轮转和磁盘监控一定要在部署初期就考虑进去,等磁盘满了再去清理,项目已经挂了。第四,容器间的网络通信不要写localhost,用Docker网络或者宿主机IP才靠谱。第五,模型文件这种大体积且会频繁更新的东西,别塞进镜像里,用挂载卷才是正道。

现在这个服务已经稳定跑了快两周了,再没出什么幺蛾子。不过说实话,每次部署新服务我都得重新经历一遍这些坑,只不过踩过的坑不会再踩第二次罢了。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注