ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FastAPI+Docker:机器学习模型服务化部署的完整指南

FastAPI+Docker:机器学习模型服务化部署的完整指南 我见过太多训练得不错的模型最后卡在“别人没法用”这一步。Notebook 里准确率刷到 95%模型文件也存好了但同事想调个接口试试得把整个项目跑起来、再装一堆依赖最后往往不了了之。这里的关键并不是再调一轮参而是把模型真正包装成一个可以被外部调用的服务。把机器学习模型用 FastAPI 和 Docker 部署是我目前觉得最省心的一条路线FastAPI 负责把模型变成 HTTP 接口Docker 负责把接口连同运行环境一起打包扔到哪台机器上都能跑。这篇文章按我的实际操作顺序完整走一遍包含项目目录怎么建、依赖怎么锁版本、模型怎么加载、接口怎么写、Dockerfile 怎么优化、容器怎么启动验证以及我在生产部署时踩过的一些坑每一步都给可直接抄的代码和配置。适合已经会训练模型、但没接触过服务化部署的读者也适合想规范一下自己部署流程的开发者对照检查。1. 为什么是 FastAPI Docker 这个组合而不是老办法1.1 FastAPI 比 Flask 赢在哪儿先说机器学习服务化最常用的两个 Web 框架Flask 和 FastAPI。Flask 的好处是简单网上教程多很多人第一个模型接口就是拿 Flask 写的几行代码就能跑起来。但用久了你会发现接口一多请求参数校验、返回结构定义、接口文档维护全都得自己来而且 Flask 对异步的支持是后来补上的用起来总有点别扭。FastAPI 的核心优势我的体感有四点。第一它用 Python 类型注解直接定义请求和响应Pydantic 在背后做校验参数不对直接返回 422不用自己写一堆 if 判断。第二接口文档是自动生成的FastAPI 基于 OpenAPI 规范服务跑起来后访问/docs就能看到全部接口其他同事点开页面就能直接试调联调阶段省很多口舌。第三它原生支持异步虽然这个在模型推理场景里不一定起决定作用但一旦接口需要同时调用数据库、缓存或别的外部服务asyncio 就派上用场了。第四路由、异常处理、依赖注入这些结构是长好的项目一扩大不会变成一团乱麻。有同学问过性能FastAPI 社区测试的性能确实比 Flask 高不少但对一个单模型推理服务来说通常每秒几百次请求就到头了框架本身那点差距在这个量级下并不明显。我选 FastAPI 更主要是因为它减少样板代码、自带校验和文档开发效率的提升是实打实的。1.2 Docker 解决的是“环境一致性”这个老大难Docker 的价值别只记成“环境一致”这四个字它具体解决这么几件事。第一镜像里把系统依赖、Python 依赖、模型文件、启动命令全固定下来本地能跑服务器上就一定能跑不会再出现“我这儿是好的啊”这种经典甩锅场景。第二不同项目之间不会再互相踩 Python 依赖老项目要 numpy 1.24新项目要 numpy 2.0各自容器里互不干扰。第三回滚非常容易镜像就是那个时间点的完整快照哪个版本出问题了把之前打好的镜像再跑起来就行。还有一个很多人忽略的点Docker 让“模型部署”这个动作变得可重复、可审计。今天部署的到底是不是昨天验证过的那份代码和模型docker image 的 digest 能回答这个问题。这种确定性在多人协作、模型频繁迭代的时候尤其重要。1.3 模型到底该不该打进镜像里对“模型是否打进镜像”我的建议分两种情况。模型文件不大几百 MB 以内且改动不频繁直接 COPY 进镜像部署最简单一个容器就是一份完整可用的服务测试、回滚都好办。如果模型很大或者你经常要更新模型而不想重建镜像那就把模型放在专门目录或对象存储上容器启动时通过环境变量指定读取路径用挂载卷把模型带进来。我见过不少团队上来就追求“模型放外部存储”结果引入一堆权限、网络、挂载的麻烦。我的建议是团队初期图上一种省事模型迭代频繁之后再切到第二种配合下面的版本管理机制一起做。2. 动手前的准备版本选择和项目目录2.1 Python 版本和依赖管理Python 版本我建议用 3.10 或 3.11。很多机器学习库对最新 Python 版本的支持会有滞后比如某些算子库在 3.12 刚发布时只有预编译版本装起来一堆坑。3.10/3.11 目前兼容性最好scikit-learn、PyTorch、LightGBM 这些主流库都稳定支持。你本地环境如果用的 3.12 也没关系但 Docker 基础镜像那一层我强烈建议锁定到python:3.10-slim或python:3.11-slim省掉大量兼容性折腾。依赖管理用 venv 就够如果你愿意尝鲜uv 会更快但原则都一样项目里必须有一份锁定版本的 requirements.txt而不是靠 import 报错来反推缺什么包。还有个小原则requirements.txt 要写精确版本号比如numpy1.26.4不要写numpy1.26。前者保证任何人在任何时间构建得到的环境一致后者则可能因为一个月后某个子依赖发布了新版本线上构建出一个你没验证过的环境。2.2 目录结构一开始就为迭代留好位置项目目录不要随便堆我常用的结构是这样ml-api/ ├── app/ │ ├── __init__.py │ ├── main.py # FastAPI 实例和路由 │ ├── schemas.py # Pydantic 请求/响应模型 │ └── model.py # 模型加载与推理逻辑 ├── models/ │ ├── classifier.joblib │ └── scaler.joblib ├── tests/ │ └── test_api.py ├── requirements.txt ├── Dockerfile ├── .dockerignore ├── docker-compose.yml └── README.mdapp/main.py是入口只管路由和 FastAPI 实例app/model.py把加载和推理逻辑单独放后续要加新模型或改预处理不用动路由层models/放模型产物虽然 joblib 文件不算代码但本地调试方便进 Docker 时通过条件控制在构建上下文里是否包含。这样分层的意义在于路由层、业务层、模型层之间互不纠缠改任何一层都不需要重写其他层。2.3 requirements.txt 的正确写法根据上面的结构一份典型的 requirements.txt 是这样fastapi0.115.0 uvicorn[standard]0.30.6 scikit-learn1.5.1 numpy1.26.4 joblib1.4.2 pydantic2.9.2这里uvicorn[standard]不是装饰standard这个 extra 会带上 uvloop、httptools 等加速组件对并发性能有一点改善。pydantic 不用单独在意版本FastAPI 会带但锁上更稳妥。如果你模型是 PyTorch 的那就需要额外加 torch注意 torch 版本和 CUDA 版本的对应关系这个放在第六节讲 GPU 时细说。3. 用 FastAPI 把模型包成可调用服务3.1 模型加载永远不要放在请求处理函数里最常见的错误写法是把joblib.load写在每个请求的处理函数里请求一进来就重新读一遍磁盘上的模型文件。这在 QPS 低的时候看不出问题但一旦并发上来磁盘 IO 和反序列化的开销会直接把服务拖垮。模型应该只在服务启动时加载一次之后常驻内存。正确的做法是使用 lifespan这是 FastAPI 推荐的启动/关闭钩子# app/main.py from contextlib import asynccontextmanager import joblib from fastapi import FastAPI model None scaler None asynccontextmanager async def lifespan(app: FastAPI): global model, scaler model joblib.load(models/classifier.joblib) scaler joblib.load(models/scaler.joblib) print(model loaded, flushTrue) yield # 如果有连接池、句柄之类的资源在这里释放 app FastAPI(lifespanlifespan)用lifespan而不是旧的app.on_event(startup)是因为后者已经标记为弃用语义上也不如 context manager 清晰。模型加载失败时lifespan 里抛出的异常会让服务启动失败这比“服务起来了但请求全报 500”要更容易发现问题。还有一个细节模型路径尽量不要写相对当前工作目录的路径。因为容器里和你本地的工作目录可能不一样。我习惯基于pathlib.Path(__file__).resolve().parent.parent来拼绝对路径这样不管在哪个目录启动都不会找错文件。3.2 用 Pydantic 定义请求与响应请求体和响应体用 Pydantic 模型定义结构清晰还能自动校验。假设这个分类模型训练时用了 5 个特征# app/schemas.py from pydantic import BaseModel, Field class PredictRequest(BaseModel): # min_length / max_length 可以直接挡住特征个数不对的请求 features: list[float] Field(..., min_length5, max_length5) class PredictResponse(BaseModel): prediction: int probability: floatField(..., min_length5, max_length5)是在告诉 FastAPI这个字段必须有且只能有 5 个元素。前端传了 4 个或 6 个接口直接返回 422根本走不到你的模型推理逻辑。对深度学习模型这个校验还能顺便挡住“特征数量都对不上”的脏数据算是第一道防线。3.3 预测接口与异常处理接下来写预测接口。我把推理逻辑单独放进app/model.pymain 里只做路由和异常包装# app/model.py import numpy as np from app.schemas import PredictRequest def run_inference(model, scaler, req: PredictRequest): feature_array np.array(req.features).reshape(1, -1) feature_array_scaled scaler.transform(feature_array) proba model.predict_proba(feature_array_scaled)[0] pred int(model.predict(feature_array_scaled)[0]) return pred, float(max(proba))# app/main.py 追加部分 import numpy as np from fastapi import HTTPException from app.schemas import PredictRequest, PredictResponse from app.model import run_inference app.get(/health) def health(): return {status: ok} app.post(/v1/predict, response_modelPredictResponse) def predict(req: PredictRequest) - PredictResponse: if model is None: raise HTTPException(status_code503, detailmodel not loaded) try: pred, prob run_inference(model, scaler, req) except Exception as e: # 生产环境记得把详细异常打到日志里这里只返回给客户端精简信息 raise HTTPException(status_code400, detailfprediction failed: {type(e).__name__}) return PredictResponse(predictionpred, probabilityprob)/health是给 Docker 健康检查用的不要和业务接口混在一起。/v1/predict带上版本前缀是给后面模型升级留的余地第六节再展开。3.4 同步接口还是异步接口这里有个容易搞混的点。你在 FastAPI 里写def predict(...)而不是async def predict(...)并不是说接口就变成阻塞了。FastAPI 对同步函数有特殊处理它会把同步函数丢到线程池里执行不会阻塞事件循环。所以对 CPU 密集的模型推理写普通def反而是正确的选择。反过来如果你的接口主要是 IO 密集比如要调用外部数据库、读缓存、请求另一个服务那用async def更合适因为可以同时发起多个 IO 等待。模型推理这种活儿让async def去做并不会更快因为进程内 GIL 对纯 CPU 计算并不是什么助力。一句话总结推理服务用同步函数 线程池IO 聚合服务用异步函数别盲目全都async def。4. Dockerfile多阶段构建让镜像又小又稳4.1 为什么需要多阶段构建很多人写 Dockerfile 就是FROM python:3.10COPY完直接RUN pip installCMD一跑就完事了。这在小项目里确实能跑但镜像里会残留 pip 缓存、编译工具、临时文件体积动辄几个 GB传输和启动都慢还不安全。多阶段构建的思路是第一个阶段专门负责安装依赖第二个阶段只把“运行必须的东西”带进去。构建过程中用到的工具比如 gcc、缓存文件一概不进最终镜像。这样镜像会小很多而且攻击面也小。scikit-learn、numpy 这些主流包都有预编译 wheel严格说不需要 gcc但你一旦装了某个需要从源码编译的依赖builder 阶段就显得必要了。4.2 一个可以直接用的 Dockerfile下面这个 Dockerfile 我直接在项目里用过可以拿来改# ---------- 第一阶段安装依赖 ---------- FROM python:3.10-slim AS builder WORKDIR /build COPY requirements.txt . # --prefix 会把包装到指定目录方便后面整体复制 RUN pip install --no-cache-dir --prefix/install -r requirements.txt # ---------- 第二阶段运行 ---------- FROM python:3.10-slim AS runtime WORKDIR /app # 把第一阶段安装好的依赖整体复制到系统路径 COPY --frombuilder /install /usr/local # 只复制代码和模型不把整个构建上下文带进来 COPY app ./app COPY models ./models # 非 root 运行降低容器被攻破后的影响 RUN useradd -m appuser chown -R appuser /app USER appuser EXPOSE 8000 # 健康检查slim 镜像没有 curl用 urllib 代替 HEALTHCHECK --interval30s --timeout5s --start-period15s --retries3 \ CMD python -c import urllib.request; urllib.request.urlopen(http://127.0.0.1:8000/health) || exit 1 # 用 python -m 调用避免 PATH 问题这一点后面会详细说 CMD [python, -m, uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000]几个关键点解释一下pip install --prefix/install是把包装到指定前缀然后在 runtime 阶段COPY --frombuilder /install /usr/local依赖就进入了系统 Python 的搜索路径。这是多阶段构建里常见的依赖搬运方式。基础镜像用python:3.10-slim而不是alpine。alpine 用 musl libc很多机器学习相关的 manylinux wheel 在 alpine 上装不了或者需要现场编译坑很大。slim 是 Debian 系的兼容性最稳。最后用python -m uvicorn而不是直接uvicorn。原因在下一节展开这里先记住这个更稳。4.3 健康检查、非 root 用户和 .dockerignoreHEALTHCHECK是 Docker 原生的健康检查机制。镜像里带了它docker ps能看到容器状态是 healthy 还是 unhealthy编排工具也能据此决定是否重启。slim 镜像里没有 curl 也没有 wget所以我用 Python 自带的 urllib 去请求/health一次轻量检查不经过业务逻辑。USER appuser换成非 root 用户是一个很多人忽略的安全习惯。应用进程不需要 root 权限用 root 跑一旦容器被利用宿主机风险会大很多。再补一个容易被忽略的文件.dockerignore。__pycache__/ *.pyc .git .venv venv tests/ *.md它的作用是告诉 Docker 构建时不要把哪些文件发送给 daemon。如果你没有它本地几百 MB 甚至几个 GB 的 venv 目录会被一起打包进构建上下文构建速度慢到让你怀疑人生。我的项目里.dockerignore是永远存在的。5. 从构建到运行这次踩过的坑5.1 构建镜像时最容易翻车的几个点先跑构建命令docker build -t ml-api:latest .我第一次构建时踩的第一个坑是忘记写.dockerignore构建上下文里带了个 2 GB 左右的虚拟环境目录光上传上下文就花了十几分钟。现在我的习惯是新建项目第一件事就把.dockerignore写好。第二个坑是依赖下载速度。pip install在容器里默认走官方 PyPI网络不好的时候等得人发慌。我一般直接在构建命令里加--build-arg或者把 pip 源换成速度快、能访问的镜像源。网络环境各不相同这里只需要记住依赖慢、装不上优先检查 pip 源而不是怀疑代码。第三个坑和依赖版本有关。某个依赖在一个月后发了新版本原来锁定的版本号解析变了构建出来的环境和你本地验证过的对不上。这就是我在第二节强调精确锁版本的原因。构建遇到“本地好好的容器里报错”时先pip freeze对比一下两边的包版本。5.2 容器起来就退出的典型原因镜像构建成功docker run之后容器几秒就退出了这种情况几乎每个人都遇到过。排查顺序固定是这样docker ps -a docker logs container_id常见的三个原因启动命令找不到模块app.main:app这个路径写错了或者app目录没有按照工作目录要求放对位置。容器里的工作目录是 Dockerfile 里WORKDIR /app指定的路径代码复制后结构没对上就会报 ModuleNotFoundError。uvicorn不在 PATH 里如果你用CMD [uvicorn, ...]但安装方式导致/usr/local/bin不在 PATH容器会直接报exec: uvicorn: executable file not found。所以我在 Dockerfile 里统一用python -m uvicorn不依赖 PATH这个问题就消失了。模型文件路径不对joblib.load(models/classifier.joblib)在你的容器里的实际路径是app/models/classifier.joblib吗不是WORKDIR 是/app所以只有./models/classifier.joblib存在才能加载成功。建议把模型加载写进 lifespan加载失败时容器会启动失败报错信息会直接打到日志里。5.3 用 docker compose 管理整个服务单条docker run命令参数一多就难维护我建议用 docker-compose.yml 把服务定义固化下来services: ml-api: build: . image: ml-api:latest ports: - 8000:8000 restart: unless-stopped environment: - MODEL_PATHmodels/classifier.joblib这里restart: unless-stopped很关键它保证容器因为意外退出时会被自动拉起来。在 Windows 上开发的话Docker Desktop 配合 WSL2 后端上面的 compose 文件直接能用唯一要记住的是容器里永远是 Linux 路径不要拿C:\xxx这种本地路径去映射。部署和更新的命令就两三条docker compose up -d --build # 构建并启动 docker compose logs -f # 看实时日志 docker compose down # 停掉服务5.4 部署完成后如何验证服务起来后先看健康状态docker compose ps然后模拟一个真实请求curl -X POST http://localhost:8000/v1/predict \ -H Content-Type: application/json \ -d {features:[5.1, 3.5, 1.4, 0.2, 1.0]}正常情况下会返回类似{prediction:1,probability:0.95}再打开http://localhost:8000/docs会看到 FastAPI 自动生成的 Swagger 页面每个接口都能点开直接试用。验证这一步不要只求“能通”建议拿几组有代表性的输入测一遍正常输入、特征数量不对的输入、空 body 的输入确认 422/400 的返回格式符合预期。这样接口交付给前端同事的时候他们对错误处理心里才有底。6. 上生产之前我还会再做的几件事6.1 模型版本和接口地址/v1/predict的v1不只是装饰。模型一定会迭代你不想每次更新模型都把旧的调用方打断。两个务实做法URL 版本化/v1/predict、/v2/predict新模型走新接口老接口保留一段时间做兼容。镜像 tag 同步构建时打上ml-api:20250115、ml-api:v2.3之类的 tag部署时能明确知道线上是哪个版本。我从不用latest部署生产因为过两周你根本不知道latest指向哪里。如果模型经常更新建议把模型挂载到容器外部用环境变量MODEL_PATH指定路径这样更新模型只需要替换文件并重启容器不需要重新构建镜像。镜像和模型文件分离是模型迭代频繁的项目里最舒服的一种状态。6.2 并发、限流和资源控制默认情况下 uvicorn 只起一个 worker。想提高并发可以在命令里加--workers 4或者干脆用 compose 起多个副本。这里有个必须提前算清楚的账每个 worker 都会独立加载一份模型到内存。如果模型占 2 GB4 个 worker 就是 8 GB稍不注意就把服务器内存打满触发 OOM然后整机卡死。所以加 worker 前先确认内存预算。资源限制建议在 compose 里显式声明deploy: resources: limits: memory: 4g cpus: 2.0限流则可以在 FastAPI 层面做比如用 slowapi 或者你所在网关平台的限流能力。模型推理服务最怕的不是慢而是被一个失控的调用方打爆。请求量上来之后优先在入口层把不可控流量挡住。6.3 日志与可观测性容器里的日志默认打到 stdoutdocker logs能看到这点不用额外配置。我踩过的坑是自己配了一套logging配置之后uvicorn 的 access log 突然消失了容器日志里只剩业务日志请求量、响应码全看不到了。原因是自定义 logging 配置覆盖了 uvicorn 的 logger。解决方法是显式添加uvicorn.access这个 logger或者在配置里保留 uvicorn 默认的 handler。另外强烈建议生产环境的日志用 JSON 格式输出比如通过python-json-logger每条日志带上时间戳、请求 ID、模型版本、响应时长。这样后续接日志平台做检索、告警都会非常方便。别小看这一步出问题的时候你能快速定位到“哪一个请求、用了哪个模型版本、花了多久”排查效率翻倍。6.4 GPU 模型怎么处理如果你的模型是 PyTorch、TensorFlow 这类需要 GPU 推理的部署有两个额外要点。第一基础镜像要换掉python:3.10-slim里没有 CUDA 运行库需要基于nvidia/cuda的镜像或者使用带 CUDA 依赖的官方镜像构建时把 torch 的 CUDA 版本和基础镜像的 CUDA 版本对齐。第二宿主机需要装 nvidia-container-toolkit容器启动时加--gpus allcompose 里写成deploy: resources: reservations: devices: - driver: nvidia count: all capabilities: [gpu]GPU 容器的镜像通常比 CPU 版大好几个数量级这是正常的。代码里做推理时建议把设备选择写成可配置的环境变量控制用 CPU 还是 CUDA这样同一套镜像逻辑既能部署到 CPU 机器测试也能部署到 GPU 机器上跑。最后说一个我长期养成的习惯镜像构建完、容器跑通之后立刻打一个带日期和版本的 tag并推到私有镜像仓库而不是永远停在 latest。机器学习项目的迭代频率比普通 Web 项目高得多今天线上跑的好版本被一次手滑的重建覆盖掉是常有的事。tagged image 就是一次可恢复的快照我回滚过太多次这个习惯每次都能救命。
返回列表