1. 项目概述当模型走出Jupyter真正开始呼吸真实世界空气“From Notebook to Production: Running ML in the Real World (Part 4)”——这个标题里藏着一个被无数数据科学家反复咀嚼、又悄悄咽下的苦涩真相我们花了80%的时间调参、画图、在Jupyter里把准确率从92.3%刷到92.7%却只留20%的精力甚至更少去思考——这串漂亮的数字明天早上八点能不能准时在生产环境里跑通能不能扛住用户突然涌进来的1000个并发请求能不能在数据库字段悄悄变更后不直接崩溃而是安静地报错并通知运维Part 4不是系列的收尾恰恰是真正硬仗的开场。它不讲模型结构不推公式只聚焦一件事让那个在本地笔记本上跑得飞起的model.predict()变成公司API网关背后一个稳定、可观测、可回滚、能被业务方写进SLO里的服务端点。核心关键词——ML productionization机器学习工程化、model serving模型服务化、CI/CD for ML机器学习持续集成/持续部署、observability可观测性、real-world data drift真实世界数据漂移——每一个词背后都对应着一次深夜告警、一次线上事故复盘会、或一份被业务方质疑“你们模型是不是又不准了”的邮件。适合谁不是刚学完Scikit-learn的新人而是已经把模型训练流程跑通、正被“上线难”卡住脖子的中级算法工程师、MLOps实践者或是技术负责人——你不需要再听“为什么需要工程化”你需要的是“今天下午三点前怎么让v2.1模型替掉线上v2.0”。我试过用Flask裸写API结果第一个高峰就OOM也试过直接把Notebook转成Docker镜像扔进K8s结果发现日志全堆在容器stdout里排查问题像在大海捞针。这篇就是把那些踩过的坑、抄过的作业、压箱底的checklist掰开揉碎给你摆到台面上。2. 内容整体设计与思路拆解为什么“部署”不是“复制粘贴”而是一场系统性重构2.1 从Notebook到Production本质是范式迁移不是路径平移很多人误以为“部署”就是把.ipynb文件里的model load_model(best.h5)和pred model.predict(X_test)两行代码复制进一个Python脚本再用flask run启动。这是最危险的认知陷阱。Jupyter Notebook是一个探索性、交互式、状态驱动的环境变量全局可见、内存随单元格执行动态增长、错误信息直接打印在输出区、数据和代码混杂在同一文档里。而生产服务是一个确定性、无状态、资源受限、高可用的系统组件它必须在固定内存限制下运行、不能依赖全局变量、错误必须结构化记录到日志中心、输入输出必须严格定义Schema、任何单点故障都可能引发业务雪崩。把Notebook直接搬过去就像把实验室里用玻璃烧杯和酒精灯做出来的化学反应直接塞进化工厂的万吨级反应釜——反应原理没变但温度控制、压力释放、杂质过滤、安全联锁全得重来。Part 4的设计起点就是彻底放弃“移植”思维拥抱“重构”思维以服务契约Service Contract为唯一输入反向驱动所有技术选型与架构决策。这个契约明确写着输入是JSON格式的{user_id: U123, features: [0.1, 0.8, ...]}输出是{prediction: 0.92, confidence: 0.87, model_version: v2.1}SLA要求99.95%的请求在200ms内返回日均处理50万请求。所有后续工作——模型序列化方式、API框架、容器配置、监控指标——都必须服务于满足这个契约。2.2 方案选型的核心逻辑平衡“可控性”、“成熟度”与“团队能力”面对几十种模型服务方案Triton、KServe、Seldon Core、BentoML、FastAPIUvicorn、自研C服务……我们最终锁定BentoML FastAPI Docker Kubernetes组合并非因为它最炫酷而是它在三个关键维度上取得了我们团队能接受的平衡点可控性ControlBentoML的核心价值在于它把“模型服务”这件事抽象成了一个可版本化、可测试、可打包的Bento一个包含模型、代码、依赖、配置的完整包。它不强制你用它的运行时而是生成一个标准的Dockerfile你可以自由修改基础镜像、添加健康检查探针、调整Gunicorn worker数。这种“封装但不绑架”的设计让我们既能享受标准化带来的效率又保有对底层基础设施的完全掌控权。对比Triton后者在GPU推理优化上确实极致但它的模型注册、版本管理、预处理逻辑耦合度极高一旦业务需要在预测前加一层实时特征计算比如查Redis获取用户实时行为分就得绕很大弯子。成熟度MaturityFastAPI的异步支持、自动生成OpenAPI文档、Pydantic Schema验证是经过百万级API服务验证的工业级方案。它不像某些新兴框架那样存在“文档落后于代码”或“社区插件生态薄弱”的风险。我们曾用一个周末就基于FastAPI写出了带JWT鉴权、请求限流、结构化日志的完整服务骨架而如果换成一个需要自己手写路由解析、参数校验、错误码映射的框架光基础建设就得耗掉两周。团队能力Team Fit团队里算法工程师Python功底扎实但对Go/Rust等系统语言、K8s Operator开发经验为零。BentoML的CLI命令bentoml build和bentoml serve极其友好bentoml models list能清晰看到所有已注册模型版本。更重要的是它的Python APIbentoml.Service和我们熟悉的Scikit-learn/TensorFlow/PyTorch原生API无缝衔接工程师无需学习一套全新的“模型服务DSL”就能快速上手。这种低学习成本带来的生产力提升在项目攻坚期至关重要。提示选型没有银弹。如果你的场景是纯GPU密集型CV推理且团队有强C背景Triton可能是更优解如果你的模型极小10MB且QPS不高100一个轻量级FlaskGunicornNGINX组合反而更简单可靠。关键不是追新而是让技术栈成为团队能力的放大器而非绊脚石。2.3 架构分层为什么必须把“模型”、“服务”、“基础设施”切成三块我们的最终架构严格遵循三层分离原则这是保障长期可维护性的基石模型层Model Layer仅包含模型权重文件.pkl,.h5,.pt、模型加载/预测逻辑model.py、以及定义输入输出Schema的pydantic.BaseModel类。这一层完全独立于任何框架可以被BentoML、Triton、甚至离线批处理脚本复用。我们强制要求模型层代码中禁止出现任何import flask、import bentoml、import kubernetes。它只回答一个问题“给定X如何算出Y”服务层Serving Layer由BentoML构建的Bento包实现负责将模型层包装成HTTP/gRPC服务。它处理请求路由、参数解析自动将JSON映射到Pydantic模型、调用模型层预测、格式化响应、记录基础指标如请求耗时、成功率。这一层是“胶水”它知道如何与外部世界API网关、监控系统对话但对模型内部细节一无所知。基础设施层Infrastructure Layer即Kubernetes集群负责Bento服务的部署、扩缩容、健康检查、日志收集、网络策略。它只关心“这个容器镜像是否健康”、“CPU使用率是否超阈值”对里面跑的是TensorFlow还是PyTorch模型毫不关心。这种解耦意味着当我们要升级K8s版本时只需更新基础设施层服务层和模型层完全不受影响当要更换模型时只需重新构建Bento并更新Deployment的镜像Tag基础设施层配置纹丝不动。这种分层不是教条主义而是血泪教训。早期我们曾把模型加载逻辑、Flask路由、K8s健康检查探针curl http://localhost:/healthz全写在一个app.py里。结果一次模型迭代需要同时修改算法逻辑、API接口、K8s配置一次提交引发三处故障回滚时更是灾难——根本分不清是模型错了还是探针路径写错了还是Flask路由注册失败了。3. 核心细节解析与实操要点从代码到容器每一步都藏着魔鬼3.1 模型层序列化不是终点而是服务化的起点模型序列化Serialization常被简单理解为“保存模型文件”。但在生产环境中它直接决定了服务的启动速度、内存占用、跨环境兼容性。我们针对不同框架制定了严格的序列化规范Scikit-learn模型禁用joblib.dump()强制使用pickle并指定protocol4Python 3.6默认兼容性好。# ✅ 正确显式指定protocol避免因Python版本差异导致加载失败 import pickle with open(model.pkl, wb) as f: pickle.dump(model, f, protocolpickle.HIGHEST_PROTOCOL) # ❌ 错误joblib.dump()在不同环境如conda vs pip下可能产生不兼容二进制 # joblib.dump(model, model.pkl)原因joblib为了加速大型NumPy数组的保存会使用特定的二进制格式该格式在不同Python发行版或NumPy版本间存在细微差异极易导致“模型在训练机上能加载部署到生产容器里就报ModuleNotFoundError”。TensorFlow/Keras模型优先使用SavedModel格式.pb而非HDF5.h5。# ✅ 正确SavedModel是TensorFlow官方推荐的生产格式包含计算图、权重、签名Signature model.save(saved_model_dir, save_formattf) # ❌ 错误HDF5只保存权重和部分架构缺失签名无法保证输入输出一致性 # model.save(model.h5)原因SavedModel是一个目录里面包含saved_model.pb计算图定义和variables/权重更重要的是它通过signatures明确声明了“这个模型接受什么输入输出什么”。BentoML在构建Bento时会自动读取这些签名生成精确的Pydantic Schema避免人工定义Schema时出现类型错误如把float32误写成float64。PyTorch模型必须使用torch.jit.script()或torch.jit.trace()导出为TorchScript而非直接torch.save()。# ✅ 正确TorchScript是PyTorch的生产就绪格式可在无Python环境的C后端运行 traced_model torch.jit.trace(model, example_input) traced_model.save(model.pt) # ❌ 错误torch.save()保存的是Python对象依赖训练时的完整代码环境 # torch.save(model.state_dict(), model.pth)原因torch.save()保存的是state_dict权重字典和模型类的__class__引用。这意味着部署时容器里必须安装完全相同的Python包版本并且model.py文件路径、类名必须和训练时一模一样否则torch.load()会因找不到类而失败。TorchScript则将模型编译成独立的中间表示IR彻底解耦了运行时依赖。注意所有序列化操作必须在与生产环境完全一致的Python和库版本下进行。我们使用Docker构建训练环境镜像python:3.9-slimtensorflow2.12.0确保训练和序列化都在同一镜像内完成。绝不在本地Mac上训练然后把模型文件拷贝到Linux服务器——这是数据科学家最容易犯的“环境不一致”错误。3.2 服务层BentoML构建Bento的黄金配置BentoML的bentofile.yaml是服务层的“宪法”其配置直接影响服务的健壮性。以下是我们在Part 4中验证过的最小可行配置# bentofile.yaml service: service.py:svc labels: owner: ml-team part: 4 python: packages: - scikit-learn1.2.2 - numpy1.23.5 # ✅ 关键显式指定pip_index_url避免构建时因网络波动拉取到损坏包 pip_index_url: https://pypi.org/simple # ✅ 关键启用pip_trusted_host解决私有仓库证书问题 pip_trusted_host: - pypi.org - files.pythonhosted.org docker: # ✅ 关键基础镜像必须与训练环境一致且选择slim版本减少攻击面 base_image: python:3.9-slim # ✅ 关键设置非root用户满足安全审计要求 user: 1001 # ✅ 关键暴露正确端口BentoML默认用3000需与K8s Service匹配 ports: - 3000 # ✅ 关键定义健康检查端点K8s liveness/readiness探针依赖于此 endpoints: /healthz: method: GET input: {} output: {}对应的service.py核心代码# service.py from pydantic import BaseModel from bentoml.io import JSON, NumpyNdarray import bentoml # ✅ 定义严格输入Schema强制类型、范围、长度拦截非法请求 class PredictionRequest(BaseModel): user_id: str features: list[float] # 显式声明为float避免int传入导致类型转换错误 # 可添加更多业务约束 # validator(features) # def features_length_must_be_10(cls, v): # if len(v) ! 10: # raise ValueError(features must have exactly 10 elements) # return v # ✅ 定义输出Schema确保API响应结构稳定 class PredictionResponse(BaseModel): prediction: float confidence: float model_version: str # ✅ 使用BentoML的Service装饰器清晰声明服务入口 svc bentoml.Service(fraud-detection-service, runners[]) # ✅ 加载模型时使用BentoML内置的Model API自动处理版本、缓存、并发 svc.api(inputJSON(pydantic_modelPredictionRequest), outputJSON(pydantic_modelPredictionResponse)) def predict(request: PredictionRequest) - PredictionResponse: # ✅ 从BentoML Model Store加载模型而非硬编码路径 model bentoml.models.get(fraud_model:latest).to_runner() # ✅ 输入预处理严格按Schema转换避免Numpy类型混乱 import numpy as np X np.array(request.features).reshape(1, -1) # 确保是2D array # ✅ 调用模型预测此处model.predict()是BentoML Runner的异步方法 pred_result model.predict.run(X) # .run() 是同步调用.async_run() 是异步 # ✅ 构造结构化响应包含元数据 return PredictionResponse( predictionfloat(pred_result[0][0]), # 强制转float避免np.float32序列化问题 confidence0.95, # 实际项目中这里应来自模型输出 model_versionv2.1 )实操心得bentoml build命令执行时BentoML会扫描service.py自动识别svc.api装饰器并将所有依赖包括model.pkl打包进Bento。我们发现一个关键技巧在bentoml build前先运行bentoml models export fraud_model:latest ./models/把模型文件单独导出到./models/目录再在bentofile.yaml中通过python: {packages: [...]}引入。这样做的好处是模型文件不会被BentoML的自动依赖分析误判为“Python代码”从而避免因模型文件过大导致构建超时或镜像臃肿。3.3 基础设施层Kubernetes Deployment的生存指南一个能活过一周的K8s Deployment绝不是kubectl create deployment一条命令能搞定的。以下是我们在Part 4中为Bento服务定制的deployment.yaml核心片段每一行都是线上事故换来的经验apiVersion: apps/v1 kind: Deployment metadata: name: fraud-detection-v21 labels: app: fraud-detection version: v2.1 spec: replicas: 3 # ✅ 至少3副本避免单点故障 selector: matchLabels: app: fraud-detection version: v2.1 template: metadata: labels: app: fraud-detection version: v2.1 # ✅ 关键添加注解让Prometheus自动抓取指标 annotations: prometheus.io/scrape: true prometheus.io/port: 3000 prometheus.io/path: /metrics spec: # ✅ 关键强制使用非root用户安全基线要求 securityContext: runAsNonRoot: true runAsUser: 1001 containers: - name: predictor image: registry.example.com/ml/fraud-detection:v2.1 # ✅ 镜像Tag必须与Bento版本严格一致 ports: - containerPort: 3000 name: http # ✅ 关键资源限制Limits必须设置否则容器可能被OOMKilled resources: requests: memory: 512Mi cpu: 250m limits: memory: 1Gi # ✅ 内存Limit设为Request的2倍给GC留空间 cpu: 500m # ✅ 关键Liveness探针检测服务是否“活着” livenessProbe: httpGet: path: /healthz port: 3000 initialDelaySeconds: 60 # ✅ 给模型加载留足时间大模型加载可能需30s periodSeconds: 30 timeoutSeconds: 5 failureThreshold: 3 # ✅ 关键Readiness探针检测服务是否“准备好接收流量” readinessProbe: httpGet: path: /readyz port: 3000 initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 3 # ✅ 关键failureThreshold设为1确保流量在服务未就绪时绝不打入 failureThreshold: 1 # ✅ 关键环境变量注入解耦配置与代码 env: - name: MODEL_VERSION value: v2.1 - name: LOG_LEVEL value: INFO # ✅ 关键挂载ConfigMap管理非敏感配置如特征工程参数 volumeMounts: - name: config mountPath: /app/config volumes: - name: config configMap: name: fraud-detection-config-v21注意initialDelaySeconds的设置是生死线。我们曾因将livenessProbe.initialDelaySeconds设为10秒而模型加载实际耗时45秒导致K8s在服务还没启动完成时就判定其“不健康”反复重启Pod形成“启动-探测失败-重启”死循环。解决方案是在模型加载函数中加入print(Model loaded successfully)并在bentoml serve启动日志中观察真实加载时间再将initialDelaySeconds设为该时间的1.5倍。4. 实操过程与核心环节实现从本地验证到灰度发布全流程拆解4.1 本地验证在笔记本上模拟生产环境的终极手段在把代码推送到Git前必须完成本地端到端验证。这不是简单的bentoml serve而是模拟整个生产链路构建Bento# 在项目根目录执行生成Bento包 bentoml build # 输出类似Bento fraud-detection-service:20231015142233_F4F3A2 built successfully本地容器化运行模拟K8s Pod# 使用BentoML内置命令一键构建并运行Docker容器 bentoml containerize fraud-detection-service:20231015142233_F4F3A2 # 或手动构建更可控 cd /path/to/bento docker build -t fraud-detection-local:v2.1 . docker run -p 3000:3000 --rm -it fraud-detection-local:v2.1发送真实请求验证全链路# 使用curl发送符合Schema的JSON请求 curl -X POST http://localhost:3000/predict \ -H Content-Type: application/json \ -d { user_id: U123, features: [0.1, 0.8, 0.3, 0.9, 0.2, 0.7, 0.4, 0.6, 0.5, 0.1] } # 期望响应{prediction: 0.92, confidence: 0.95, model_version: v2.1}验证健康检查与指标端点# 检查liveness curl http://localhost:3000/healthz # 应返回200 OK # 检查readiness首次调用可能返回503等待几秒再试 curl http://localhost:3000/readyz # 应返回200 OK # 检查Prometheus指标BentoML自动提供 curl http://localhost:3000/metrics | grep bentoml_service_request # 应看到类似bentoml_service_request_duration_seconds_count{endpoint/predict,methodPOST,status200} 1.0实操心得我们编写了一个local-test.sh脚本自动执行以上4步并在最后一步失败时退出set -e作为CI流水线的第一道门禁。这个脚本比任何单元测试都更能暴露环境不一致问题——比如如果本地Python版本是3.10而bentofile.yaml指定python:3.9-slimdocker build会失败脚本立即报错阻止错误代码进入主干。4.2 CI/CD流水线GitOps驱动的自动化发布我们使用GitHub Actions构建CI/CD流水线核心思想是每一次git push到main分支都触发一次从代码到生产环境的全自动、可审计、可回滚的发布。流水线分为四个阶段阶段触发条件关键任务成功标志CI: Build Testpushtomain1. 运行local-test.sh2. 执行单元测试pytest tests/3. 静态代码检查pylint所有步骤Exit Code 0Build: Create Bento ImageCI成功后1.bentoml build2.bentoml containerize3.docker push到私有Registry镜像在Registry中存在且可拉取CD: Deploy to StagingBuild成功后1. 更新Staging环境的deployment.yaml中的image字段2.kubectl apply -f staging-deployment.yamlK8s中fraud-detection-stagingPod状态为Running且ReadyCD: Promote to Production (Manual)人工审批后1. 将Staging的deployment.yaml复制为prod-deployment.yaml2. 修改version标签和replicas3.kubectl apply -f prod-deployment.yaml生产环境新Pod就绪旧Pod被优雅终止关键配置.github/workflows/ml-deploy.yml节选name: ML Model Deployment on: push: branches: [main] jobs: build-and-test: runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 - name: Set up Python uses: actions/setup-pythonv4 with: python-version: 3.9 - name: Install dependencies run: | pip install bentoml pytest pylint - name: Run local test run: ./local-test.sh # 我们的端到端验证脚本 - name: Run unit tests run: pytest tests/ -v build-bento: needs: build-and-test runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 - name: Set up Python uses: actions/setup-pythonv4 with: python-version: 3.9 - name: Build Bento run: bentoml build - name: Containerize and Push env: DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} run: | # 登录私有Registry echo $DOCKER_PASSWORD | docker login $DOCKER_REGISTRY -u $DOCKER_USERNAME --password-stdin # 构建并推送镜像 bentoml containerize fraud-detection-service:latest -t $DOCKER_REGISTRY/ml/fraud-detection:${{ github.sha }} docker push $DOCKER_REGISTRY/ml/fraud-detection:${{ github.sha }}注意staging环境与production环境完全隔离不同K8s Namespace但共享同一套CI/CD流水线。这确保了“在Staging上能跑通的99%概率在Production上也能跑通”。我们坚持“Staging Production的缩小镜像”原则绝不允许Staging用python:3.9-slim而Production用python:3.8-alpine。4.3 灰度发布用金丝雀Canary策略把风险降到最低直接将v2.1模型100%切流到所有用户是生产环境的大忌。Part 4采用基于Istio的金丝雀发布将流量按比例分发到新旧版本部署两个Deploymentfraud-detection-v20运行旧模型v2.0标签version: v2.0fraud-detection-v21运行新模型v2.1标签version: v2.1创建Istio VirtualService定义流量分割# virtualservice-canary.yaml apiVersion: networking.istio.io/v1beta1 kind: VirtualService metadata: name: fraud-detection-canary spec: hosts: - fraud-detection.example.com http: - route: - destination: host: fraud-detection subset: v20 weight: 90 # 90%流量到v2.0 - destination: host: fraud-detection subset: v21 weight: 10 # 10%流量到v2.1监控关键指标在Prometheus中创建Dashboard实时对比两个版本的bentoml_service_request_duration_seconds_p95{versionv2.0}vs...{versionv2.1}bentoml_service_request_total{status5xx, versionv2.1}v2.1的5xx错误率业务指标fraud_prediction_rate{versionv2.1}新模型预测为欺诈的比例渐进式放量如果v2.1在10%流量下表现完美P95延迟200ms5xx0业务指标无异常则将权重逐步调整为30% → 50% → 100%。每次调整后至少观察15分钟。实操心得金丝雀发布最大的陷阱是“指标盲区”。我们曾发现v2.1在10%流量下一切正常但当切到50%时P95延迟突然飙升。排查发现v2.1模型的内存占用比v2.0高30%在K8s资源限制下当并发请求增多时频繁的GC导致延迟抖动。解决方案是在灰度发布前必须对新Bento进行压力测试locust或k6模拟目标QPS下的内存/CPU消耗并据此调整deployment.yaml中的resources.limits。我们现在的SOP是任何新模型上线前必须提供一份《压力测试报告》包含峰值QPS、平均延迟、P95延迟、内存占用曲线图。5. 常见问题与排查技巧实录那些凌晨三点的告警我们帮你挡下了5.1 典型问题速查表问题现象可能原因排查命令/步骤解决方案kubectl get pods显示CrashLoopBackOff1. 模型加载失败路径错误、版本不兼容2. 容器启动时内存不足OOMKilled3.livenessProbe初始延迟太短kubectl logs pod-name --previouskubectl describe pod pod-name查看Events1. 检查bentoml models list确认模型存在2.kubectl top pod pod-name看内存峰值3. 增大livenessProbe.initialDelaySecondsAPI返回503 Service Unavailable1.readinessProbe失败Pod未进入Ready状态2. K8s Service的selector与Pod标签不匹配kubectl get endpoints service-namekubectl get pods -l appfraud-detection,versionv2.11. 检查readinessProbe配置和/readyz端点日志2. 确认Deployment和Service的labels/selector完全一致预测结果与本地Notebook不一致1. 特征工程代码在服务层和Notebook中不一致2. 模型序列化/反序列化精度损失如float32vsfloat64在服务容器内执行python -c import numpy as np; print(np.array([0.1]).dtype)对比Notebook中相同代码输出1. 将特征工程逻辑抽离为独立Python包服务层和Notebook共用2. 在模型加载后用model.dtype检查并在预测前显式X X.astype(np.float32)Prometheus无指标数据1.prometheus.io/scrape注解未添加2.metrics端点路径或端口配置错误3. Prometheus未配置正确的ServiceMonitorkubectl get servicemonitor -n monitoringcurl http://pod-ip:3000/metrics1. 检查Pod的annotations2. 确认bentoml serve默认暴露/metrics在3000端口3. 创建ServiceMonitor指向fraud-detectionService5.2 独家避坑技巧来自生产一线的“血泪笔记”技巧1用bentoml models list --show-tags代替bentoml models list默认bentoml models list只显示模型名和创建时间而--show-tags会显示你为模型打的tag如fraud_model:v2.1。这是定位“哪个模型版本被打包进了哪个Bento”的最快方法。我们曾因多个团队共用一个BentoML Repository导致bentoml build时拉取了错误的模型版本--show-tags救了我们。技巧2在/healthz端点里加入模型加载状态检查不要让/healthz只是简单返回{status: ok}。应该在其中检查模型是否已成功加载到内存# 在service.py中 _model_loaded False _model None svc.api(input..., output...) def predict(...): global _model_loaded, _model if not _model_loaded: _model bentoml.models.get(fraud_model:latest).to_runner()
机器学习模型服务化实战:从Notebook到K8s生产部署
1. 项目概述当模型走出Jupyter真正开始呼吸真实世界空气“From Notebook to Production: Running ML in the Real World (Part 4)”——这个标题里藏着一个被无数数据科学家反复咀嚼、又悄悄咽下的苦涩真相我们花了80%的时间调参、画图、在Jupyter里把准确率从92.3%刷到92.7%却只留20%的精力甚至更少去思考——这串漂亮的数字明天早上八点能不能准时在生产环境里跑通能不能扛住用户突然涌进来的1000个并发请求能不能在数据库字段悄悄变更后不直接崩溃而是安静地报错并通知运维Part 4不是系列的收尾恰恰是真正硬仗的开场。它不讲模型结构不推公式只聚焦一件事让那个在本地笔记本上跑得飞起的model.predict()变成公司API网关背后一个稳定、可观测、可回滚、能被业务方写进SLO里的服务端点。核心关键词——ML productionization机器学习工程化、model serving模型服务化、CI/CD for ML机器学习持续集成/持续部署、observability可观测性、real-world data drift真实世界数据漂移——每一个词背后都对应着一次深夜告警、一次线上事故复盘会、或一份被业务方质疑“你们模型是不是又不准了”的邮件。适合谁不是刚学完Scikit-learn的新人而是已经把模型训练流程跑通、正被“上线难”卡住脖子的中级算法工程师、MLOps实践者或是技术负责人——你不需要再听“为什么需要工程化”你需要的是“今天下午三点前怎么让v2.1模型替掉线上v2.0”。我试过用Flask裸写API结果第一个高峰就OOM也试过直接把Notebook转成Docker镜像扔进K8s结果发现日志全堆在容器stdout里排查问题像在大海捞针。这篇就是把那些踩过的坑、抄过的作业、压箱底的checklist掰开揉碎给你摆到台面上。2. 内容整体设计与思路拆解为什么“部署”不是“复制粘贴”而是一场系统性重构2.1 从Notebook到Production本质是范式迁移不是路径平移很多人误以为“部署”就是把.ipynb文件里的model load_model(best.h5)和pred model.predict(X_test)两行代码复制进一个Python脚本再用flask run启动。这是最危险的认知陷阱。Jupyter Notebook是一个探索性、交互式、状态驱动的环境变量全局可见、内存随单元格执行动态增长、错误信息直接打印在输出区、数据和代码混杂在同一文档里。而生产服务是一个确定性、无状态、资源受限、高可用的系统组件它必须在固定内存限制下运行、不能依赖全局变量、错误必须结构化记录到日志中心、输入输出必须严格定义Schema、任何单点故障都可能引发业务雪崩。把Notebook直接搬过去就像把实验室里用玻璃烧杯和酒精灯做出来的化学反应直接塞进化工厂的万吨级反应釜——反应原理没变但温度控制、压力释放、杂质过滤、安全联锁全得重来。Part 4的设计起点就是彻底放弃“移植”思维拥抱“重构”思维以服务契约Service Contract为唯一输入反向驱动所有技术选型与架构决策。这个契约明确写着输入是JSON格式的{user_id: U123, features: [0.1, 0.8, ...]}输出是{prediction: 0.92, confidence: 0.87, model_version: v2.1}SLA要求99.95%的请求在200ms内返回日均处理50万请求。所有后续工作——模型序列化方式、API框架、容器配置、监控指标——都必须服务于满足这个契约。2.2 方案选型的核心逻辑平衡“可控性”、“成熟度”与“团队能力”面对几十种模型服务方案Triton、KServe、Seldon Core、BentoML、FastAPIUvicorn、自研C服务……我们最终锁定BentoML FastAPI Docker Kubernetes组合并非因为它最炫酷而是它在三个关键维度上取得了我们团队能接受的平衡点可控性ControlBentoML的核心价值在于它把“模型服务”这件事抽象成了一个可版本化、可测试、可打包的Bento一个包含模型、代码、依赖、配置的完整包。它不强制你用它的运行时而是生成一个标准的Dockerfile你可以自由修改基础镜像、添加健康检查探针、调整Gunicorn worker数。这种“封装但不绑架”的设计让我们既能享受标准化带来的效率又保有对底层基础设施的完全掌控权。对比Triton后者在GPU推理优化上确实极致但它的模型注册、版本管理、预处理逻辑耦合度极高一旦业务需要在预测前加一层实时特征计算比如查Redis获取用户实时行为分就得绕很大弯子。成熟度MaturityFastAPI的异步支持、自动生成OpenAPI文档、Pydantic Schema验证是经过百万级API服务验证的工业级方案。它不像某些新兴框架那样存在“文档落后于代码”或“社区插件生态薄弱”的风险。我们曾用一个周末就基于FastAPI写出了带JWT鉴权、请求限流、结构化日志的完整服务骨架而如果换成一个需要自己手写路由解析、参数校验、错误码映射的框架光基础建设就得耗掉两周。团队能力Team Fit团队里算法工程师Python功底扎实但对Go/Rust等系统语言、K8s Operator开发经验为零。BentoML的CLI命令bentoml build和bentoml serve极其友好bentoml models list能清晰看到所有已注册模型版本。更重要的是它的Python APIbentoml.Service和我们熟悉的Scikit-learn/TensorFlow/PyTorch原生API无缝衔接工程师无需学习一套全新的“模型服务DSL”就能快速上手。这种低学习成本带来的生产力提升在项目攻坚期至关重要。提示选型没有银弹。如果你的场景是纯GPU密集型CV推理且团队有强C背景Triton可能是更优解如果你的模型极小10MB且QPS不高100一个轻量级FlaskGunicornNGINX组合反而更简单可靠。关键不是追新而是让技术栈成为团队能力的放大器而非绊脚石。2.3 架构分层为什么必须把“模型”、“服务”、“基础设施”切成三块我们的最终架构严格遵循三层分离原则这是保障长期可维护性的基石模型层Model Layer仅包含模型权重文件.pkl,.h5,.pt、模型加载/预测逻辑model.py、以及定义输入输出Schema的pydantic.BaseModel类。这一层完全独立于任何框架可以被BentoML、Triton、甚至离线批处理脚本复用。我们强制要求模型层代码中禁止出现任何import flask、import bentoml、import kubernetes。它只回答一个问题“给定X如何算出Y”服务层Serving Layer由BentoML构建的Bento包实现负责将模型层包装成HTTP/gRPC服务。它处理请求路由、参数解析自动将JSON映射到Pydantic模型、调用模型层预测、格式化响应、记录基础指标如请求耗时、成功率。这一层是“胶水”它知道如何与外部世界API网关、监控系统对话但对模型内部细节一无所知。基础设施层Infrastructure Layer即Kubernetes集群负责Bento服务的部署、扩缩容、健康检查、日志收集、网络策略。它只关心“这个容器镜像是否健康”、“CPU使用率是否超阈值”对里面跑的是TensorFlow还是PyTorch模型毫不关心。这种解耦意味着当我们要升级K8s版本时只需更新基础设施层服务层和模型层完全不受影响当要更换模型时只需重新构建Bento并更新Deployment的镜像Tag基础设施层配置纹丝不动。这种分层不是教条主义而是血泪教训。早期我们曾把模型加载逻辑、Flask路由、K8s健康检查探针curl http://localhost:/healthz全写在一个app.py里。结果一次模型迭代需要同时修改算法逻辑、API接口、K8s配置一次提交引发三处故障回滚时更是灾难——根本分不清是模型错了还是探针路径写错了还是Flask路由注册失败了。3. 核心细节解析与实操要点从代码到容器每一步都藏着魔鬼3.1 模型层序列化不是终点而是服务化的起点模型序列化Serialization常被简单理解为“保存模型文件”。但在生产环境中它直接决定了服务的启动速度、内存占用、跨环境兼容性。我们针对不同框架制定了严格的序列化规范Scikit-learn模型禁用joblib.dump()强制使用pickle并指定protocol4Python 3.6默认兼容性好。# ✅ 正确显式指定protocol避免因Python版本差异导致加载失败 import pickle with open(model.pkl, wb) as f: pickle.dump(model, f, protocolpickle.HIGHEST_PROTOCOL) # ❌ 错误joblib.dump()在不同环境如conda vs pip下可能产生不兼容二进制 # joblib.dump(model, model.pkl)原因joblib为了加速大型NumPy数组的保存会使用特定的二进制格式该格式在不同Python发行版或NumPy版本间存在细微差异极易导致“模型在训练机上能加载部署到生产容器里就报ModuleNotFoundError”。TensorFlow/Keras模型优先使用SavedModel格式.pb而非HDF5.h5。# ✅ 正确SavedModel是TensorFlow官方推荐的生产格式包含计算图、权重、签名Signature model.save(saved_model_dir, save_formattf) # ❌ 错误HDF5只保存权重和部分架构缺失签名无法保证输入输出一致性 # model.save(model.h5)原因SavedModel是一个目录里面包含saved_model.pb计算图定义和variables/权重更重要的是它通过signatures明确声明了“这个模型接受什么输入输出什么”。BentoML在构建Bento时会自动读取这些签名生成精确的Pydantic Schema避免人工定义Schema时出现类型错误如把float32误写成float64。PyTorch模型必须使用torch.jit.script()或torch.jit.trace()导出为TorchScript而非直接torch.save()。# ✅ 正确TorchScript是PyTorch的生产就绪格式可在无Python环境的C后端运行 traced_model torch.jit.trace(model, example_input) traced_model.save(model.pt) # ❌ 错误torch.save()保存的是Python对象依赖训练时的完整代码环境 # torch.save(model.state_dict(), model.pth)原因torch.save()保存的是state_dict权重字典和模型类的__class__引用。这意味着部署时容器里必须安装完全相同的Python包版本并且model.py文件路径、类名必须和训练时一模一样否则torch.load()会因找不到类而失败。TorchScript则将模型编译成独立的中间表示IR彻底解耦了运行时依赖。注意所有序列化操作必须在与生产环境完全一致的Python和库版本下进行。我们使用Docker构建训练环境镜像python:3.9-slimtensorflow2.12.0确保训练和序列化都在同一镜像内完成。绝不在本地Mac上训练然后把模型文件拷贝到Linux服务器——这是数据科学家最容易犯的“环境不一致”错误。3.2 服务层BentoML构建Bento的黄金配置BentoML的bentofile.yaml是服务层的“宪法”其配置直接影响服务的健壮性。以下是我们在Part 4中验证过的最小可行配置# bentofile.yaml service: service.py:svc labels: owner: ml-team part: 4 python: packages: - scikit-learn1.2.2 - numpy1.23.5 # ✅ 关键显式指定pip_index_url避免构建时因网络波动拉取到损坏包 pip_index_url: https://pypi.org/simple # ✅ 关键启用pip_trusted_host解决私有仓库证书问题 pip_trusted_host: - pypi.org - files.pythonhosted.org docker: # ✅ 关键基础镜像必须与训练环境一致且选择slim版本减少攻击面 base_image: python:3.9-slim # ✅ 关键设置非root用户满足安全审计要求 user: 1001 # ✅ 关键暴露正确端口BentoML默认用3000需与K8s Service匹配 ports: - 3000 # ✅ 关键定义健康检查端点K8s liveness/readiness探针依赖于此 endpoints: /healthz: method: GET input: {} output: {}对应的service.py核心代码# service.py from pydantic import BaseModel from bentoml.io import JSON, NumpyNdarray import bentoml # ✅ 定义严格输入Schema强制类型、范围、长度拦截非法请求 class PredictionRequest(BaseModel): user_id: str features: list[float] # 显式声明为float避免int传入导致类型转换错误 # 可添加更多业务约束 # validator(features) # def features_length_must_be_10(cls, v): # if len(v) ! 10: # raise ValueError(features must have exactly 10 elements) # return v # ✅ 定义输出Schema确保API响应结构稳定 class PredictionResponse(BaseModel): prediction: float confidence: float model_version: str # ✅ 使用BentoML的Service装饰器清晰声明服务入口 svc bentoml.Service(fraud-detection-service, runners[]) # ✅ 加载模型时使用BentoML内置的Model API自动处理版本、缓存、并发 svc.api(inputJSON(pydantic_modelPredictionRequest), outputJSON(pydantic_modelPredictionResponse)) def predict(request: PredictionRequest) - PredictionResponse: # ✅ 从BentoML Model Store加载模型而非硬编码路径 model bentoml.models.get(fraud_model:latest).to_runner() # ✅ 输入预处理严格按Schema转换避免Numpy类型混乱 import numpy as np X np.array(request.features).reshape(1, -1) # 确保是2D array # ✅ 调用模型预测此处model.predict()是BentoML Runner的异步方法 pred_result model.predict.run(X) # .run() 是同步调用.async_run() 是异步 # ✅ 构造结构化响应包含元数据 return PredictionResponse( predictionfloat(pred_result[0][0]), # 强制转float避免np.float32序列化问题 confidence0.95, # 实际项目中这里应来自模型输出 model_versionv2.1 )实操心得bentoml build命令执行时BentoML会扫描service.py自动识别svc.api装饰器并将所有依赖包括model.pkl打包进Bento。我们发现一个关键技巧在bentoml build前先运行bentoml models export fraud_model:latest ./models/把模型文件单独导出到./models/目录再在bentofile.yaml中通过python: {packages: [...]}引入。这样做的好处是模型文件不会被BentoML的自动依赖分析误判为“Python代码”从而避免因模型文件过大导致构建超时或镜像臃肿。3.3 基础设施层Kubernetes Deployment的生存指南一个能活过一周的K8s Deployment绝不是kubectl create deployment一条命令能搞定的。以下是我们在Part 4中为Bento服务定制的deployment.yaml核心片段每一行都是线上事故换来的经验apiVersion: apps/v1 kind: Deployment metadata: name: fraud-detection-v21 labels: app: fraud-detection version: v2.1 spec: replicas: 3 # ✅ 至少3副本避免单点故障 selector: matchLabels: app: fraud-detection version: v2.1 template: metadata: labels: app: fraud-detection version: v2.1 # ✅ 关键添加注解让Prometheus自动抓取指标 annotations: prometheus.io/scrape: true prometheus.io/port: 3000 prometheus.io/path: /metrics spec: # ✅ 关键强制使用非root用户安全基线要求 securityContext: runAsNonRoot: true runAsUser: 1001 containers: - name: predictor image: registry.example.com/ml/fraud-detection:v2.1 # ✅ 镜像Tag必须与Bento版本严格一致 ports: - containerPort: 3000 name: http # ✅ 关键资源限制Limits必须设置否则容器可能被OOMKilled resources: requests: memory: 512Mi cpu: 250m limits: memory: 1Gi # ✅ 内存Limit设为Request的2倍给GC留空间 cpu: 500m # ✅ 关键Liveness探针检测服务是否“活着” livenessProbe: httpGet: path: /healthz port: 3000 initialDelaySeconds: 60 # ✅ 给模型加载留足时间大模型加载可能需30s periodSeconds: 30 timeoutSeconds: 5 failureThreshold: 3 # ✅ 关键Readiness探针检测服务是否“准备好接收流量” readinessProbe: httpGet: path: /readyz port: 3000 initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 3 # ✅ 关键failureThreshold设为1确保流量在服务未就绪时绝不打入 failureThreshold: 1 # ✅ 关键环境变量注入解耦配置与代码 env: - name: MODEL_VERSION value: v2.1 - name: LOG_LEVEL value: INFO # ✅ 关键挂载ConfigMap管理非敏感配置如特征工程参数 volumeMounts: - name: config mountPath: /app/config volumes: - name: config configMap: name: fraud-detection-config-v21注意initialDelaySeconds的设置是生死线。我们曾因将livenessProbe.initialDelaySeconds设为10秒而模型加载实际耗时45秒导致K8s在服务还没启动完成时就判定其“不健康”反复重启Pod形成“启动-探测失败-重启”死循环。解决方案是在模型加载函数中加入print(Model loaded successfully)并在bentoml serve启动日志中观察真实加载时间再将initialDelaySeconds设为该时间的1.5倍。4. 实操过程与核心环节实现从本地验证到灰度发布全流程拆解4.1 本地验证在笔记本上模拟生产环境的终极手段在把代码推送到Git前必须完成本地端到端验证。这不是简单的bentoml serve而是模拟整个生产链路构建Bento# 在项目根目录执行生成Bento包 bentoml build # 输出类似Bento fraud-detection-service:20231015142233_F4F3A2 built successfully本地容器化运行模拟K8s Pod# 使用BentoML内置命令一键构建并运行Docker容器 bentoml containerize fraud-detection-service:20231015142233_F4F3A2 # 或手动构建更可控 cd /path/to/bento docker build -t fraud-detection-local:v2.1 . docker run -p 3000:3000 --rm -it fraud-detection-local:v2.1发送真实请求验证全链路# 使用curl发送符合Schema的JSON请求 curl -X POST http://localhost:3000/predict \ -H Content-Type: application/json \ -d { user_id: U123, features: [0.1, 0.8, 0.3, 0.9, 0.2, 0.7, 0.4, 0.6, 0.5, 0.1] } # 期望响应{prediction: 0.92, confidence: 0.95, model_version: v2.1}验证健康检查与指标端点# 检查liveness curl http://localhost:3000/healthz # 应返回200 OK # 检查readiness首次调用可能返回503等待几秒再试 curl http://localhost:3000/readyz # 应返回200 OK # 检查Prometheus指标BentoML自动提供 curl http://localhost:3000/metrics | grep bentoml_service_request # 应看到类似bentoml_service_request_duration_seconds_count{endpoint/predict,methodPOST,status200} 1.0实操心得我们编写了一个local-test.sh脚本自动执行以上4步并在最后一步失败时退出set -e作为CI流水线的第一道门禁。这个脚本比任何单元测试都更能暴露环境不一致问题——比如如果本地Python版本是3.10而bentofile.yaml指定python:3.9-slimdocker build会失败脚本立即报错阻止错误代码进入主干。4.2 CI/CD流水线GitOps驱动的自动化发布我们使用GitHub Actions构建CI/CD流水线核心思想是每一次git push到main分支都触发一次从代码到生产环境的全自动、可审计、可回滚的发布。流水线分为四个阶段阶段触发条件关键任务成功标志CI: Build Testpushtomain1. 运行local-test.sh2. 执行单元测试pytest tests/3. 静态代码检查pylint所有步骤Exit Code 0Build: Create Bento ImageCI成功后1.bentoml build2.bentoml containerize3.docker push到私有Registry镜像在Registry中存在且可拉取CD: Deploy to StagingBuild成功后1. 更新Staging环境的deployment.yaml中的image字段2.kubectl apply -f staging-deployment.yamlK8s中fraud-detection-stagingPod状态为Running且ReadyCD: Promote to Production (Manual)人工审批后1. 将Staging的deployment.yaml复制为prod-deployment.yaml2. 修改version标签和replicas3.kubectl apply -f prod-deployment.yaml生产环境新Pod就绪旧Pod被优雅终止关键配置.github/workflows/ml-deploy.yml节选name: ML Model Deployment on: push: branches: [main] jobs: build-and-test: runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 - name: Set up Python uses: actions/setup-pythonv4 with: python-version: 3.9 - name: Install dependencies run: | pip install bentoml pytest pylint - name: Run local test run: ./local-test.sh # 我们的端到端验证脚本 - name: Run unit tests run: pytest tests/ -v build-bento: needs: build-and-test runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 - name: Set up Python uses: actions/setup-pythonv4 with: python-version: 3.9 - name: Build Bento run: bentoml build - name: Containerize and Push env: DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} run: | # 登录私有Registry echo $DOCKER_PASSWORD | docker login $DOCKER_REGISTRY -u $DOCKER_USERNAME --password-stdin # 构建并推送镜像 bentoml containerize fraud-detection-service:latest -t $DOCKER_REGISTRY/ml/fraud-detection:${{ github.sha }} docker push $DOCKER_REGISTRY/ml/fraud-detection:${{ github.sha }}注意staging环境与production环境完全隔离不同K8s Namespace但共享同一套CI/CD流水线。这确保了“在Staging上能跑通的99%概率在Production上也能跑通”。我们坚持“Staging Production的缩小镜像”原则绝不允许Staging用python:3.9-slim而Production用python:3.8-alpine。4.3 灰度发布用金丝雀Canary策略把风险降到最低直接将v2.1模型100%切流到所有用户是生产环境的大忌。Part 4采用基于Istio的金丝雀发布将流量按比例分发到新旧版本部署两个Deploymentfraud-detection-v20运行旧模型v2.0标签version: v2.0fraud-detection-v21运行新模型v2.1标签version: v2.1创建Istio VirtualService定义流量分割# virtualservice-canary.yaml apiVersion: networking.istio.io/v1beta1 kind: VirtualService metadata: name: fraud-detection-canary spec: hosts: - fraud-detection.example.com http: - route: - destination: host: fraud-detection subset: v20 weight: 90 # 90%流量到v2.0 - destination: host: fraud-detection subset: v21 weight: 10 # 10%流量到v2.1监控关键指标在Prometheus中创建Dashboard实时对比两个版本的bentoml_service_request_duration_seconds_p95{versionv2.0}vs...{versionv2.1}bentoml_service_request_total{status5xx, versionv2.1}v2.1的5xx错误率业务指标fraud_prediction_rate{versionv2.1}新模型预测为欺诈的比例渐进式放量如果v2.1在10%流量下表现完美P95延迟200ms5xx0业务指标无异常则将权重逐步调整为30% → 50% → 100%。每次调整后至少观察15分钟。实操心得金丝雀发布最大的陷阱是“指标盲区”。我们曾发现v2.1在10%流量下一切正常但当切到50%时P95延迟突然飙升。排查发现v2.1模型的内存占用比v2.0高30%在K8s资源限制下当并发请求增多时频繁的GC导致延迟抖动。解决方案是在灰度发布前必须对新Bento进行压力测试locust或k6模拟目标QPS下的内存/CPU消耗并据此调整deployment.yaml中的resources.limits。我们现在的SOP是任何新模型上线前必须提供一份《压力测试报告》包含峰值QPS、平均延迟、P95延迟、内存占用曲线图。5. 常见问题与排查技巧实录那些凌晨三点的告警我们帮你挡下了5.1 典型问题速查表问题现象可能原因排查命令/步骤解决方案kubectl get pods显示CrashLoopBackOff1. 模型加载失败路径错误、版本不兼容2. 容器启动时内存不足OOMKilled3.livenessProbe初始延迟太短kubectl logs pod-name --previouskubectl describe pod pod-name查看Events1. 检查bentoml models list确认模型存在2.kubectl top pod pod-name看内存峰值3. 增大livenessProbe.initialDelaySecondsAPI返回503 Service Unavailable1.readinessProbe失败Pod未进入Ready状态2. K8s Service的selector与Pod标签不匹配kubectl get endpoints service-namekubectl get pods -l appfraud-detection,versionv2.11. 检查readinessProbe配置和/readyz端点日志2. 确认Deployment和Service的labels/selector完全一致预测结果与本地Notebook不一致1. 特征工程代码在服务层和Notebook中不一致2. 模型序列化/反序列化精度损失如float32vsfloat64在服务容器内执行python -c import numpy as np; print(np.array([0.1]).dtype)对比Notebook中相同代码输出1. 将特征工程逻辑抽离为独立Python包服务层和Notebook共用2. 在模型加载后用model.dtype检查并在预测前显式X X.astype(np.float32)Prometheus无指标数据1.prometheus.io/scrape注解未添加2.metrics端点路径或端口配置错误3. Prometheus未配置正确的ServiceMonitorkubectl get servicemonitor -n monitoringcurl http://pod-ip:3000/metrics1. 检查Pod的annotations2. 确认bentoml serve默认暴露/metrics在3000端口3. 创建ServiceMonitor指向fraud-detectionService5.2 独家避坑技巧来自生产一线的“血泪笔记”技巧1用bentoml models list --show-tags代替bentoml models list默认bentoml models list只显示模型名和创建时间而--show-tags会显示你为模型打的tag如fraud_model:v2.1。这是定位“哪个模型版本被打包进了哪个Bento”的最快方法。我们曾因多个团队共用一个BentoML Repository导致bentoml build时拉取了错误的模型版本--show-tags救了我们。技巧2在/healthz端点里加入模型加载状态检查不要让/healthz只是简单返回{status: ok}。应该在其中检查模型是否已成功加载到内存# 在service.py中 _model_loaded False _model None svc.api(input..., output...) def predict(...): global _model_loaded, _model if not _model_loaded: _model bentoml.models.get(fraud_model:latest).to_runner()