1. 项目概述:当模型走出Jupyter,真正开始呼吸真实世界空气
“From Notebook to Production: Running ML in the Real World (Part 4)”——这个标题里藏着一个被无数数据科学家反复咀嚼、又悄悄咽下的苦涩真相:我们花了80%的时间调参、画图、在Jupyter里把准确率从92.3%刷到92.7%,却只留20%的精力(甚至更少)去思考——当模型明天就要接入订单系统、要扛住每秒300次并发请求、要在没有GPU的旧服务器上跑出亚秒级响应、要自动识别出凌晨三点突然飘进来的异常数据流时,它还能不能稳稳站着?Part 4不是系列的收尾,而是真正硬仗的发令枪。它不讲怎么用PyTorch写Transformer,而讲怎么让那个在Notebook里闪闪发光的model.pkl文件,在运维同事皱着眉头递来的那台内存只有16GB、内核是Linux 3.10、连Docker都要手动编译的生产服务器上,不报错、不OOM、不超时、不静默失败。它解决的是“模型已训练完成”之后,那个没人愿意主动认领的灰色地带:部署、监控、回滚、降级、日志追踪、资源隔离、版本灰度、依赖冲突消解。适合谁?适合刚把第一个模型跑通的算法工程师,也适合被业务方天天追问“模型什么时候上线”的技术负责人,更适合那位凌晨两点收到告警、发现模型预测结果全变成NaN、而本地复现死活不复现的SRE。这不是理论推演,这是我在过去三年亲手把17个模型送进银行核心风控链路、电商实时推荐管道和IoT设备边缘节点后,用掉的第47块白板、237次线上回滚、以及11次彻夜排查换来的实操笔记。
2. 核心设计思路拆解:为什么放弃“一键部署”,选择“分层可控交付”
2.1 拒绝“Notebook即服务”的幻觉:真实世界的三重绞杀
很多团队在Part 1就栽了跟头——直接把.ipynb文件拖进Airflow或Kubeflow Pipelines,配个定时任务,美其名曰“MLOps”。我试过,也踩过。结果是:某天凌晨,模型预测服务突然返回500错误,日志里只有一行ModuleNotFoundError: No module named 'xgboost'。查环境?Docker镜像里确实没装;查Pipeline定义?Notebook里那行!pip install xgboost被当成普通cell执行了,但Kubeflow的运行时根本不会执行带!的shell命令。这就是第一重绞杀:开发环境与生产环境的不可复现性。Jupyter的交互式本质决定了它天然鼓励“临时补丁”——os.environ['CUDA_VISIBLE_DEVICES'] = '1'、sys.path.insert(0, '../src')、pd.set_option('display.max_colwidth', None)……这些在笔记本里无比顺滑的操作,在容器化、无状态、多实例的生产环境中,就是一颗颗随时引爆的雷。
第二重绞杀是资源感知的彻底缺失。你在Notebook里用model.predict(X_test)测延迟,得到12ms,喜滋滋写进SLA文档。可当流量洪峰到来,100个请求并发打进来,模型服务瞬间卡死。为什么?因为Notebook测试永远是单线程、单样本、无内存压力的“理想真空”。真实服务要面对连接池耗尽、Python GIL争抢、NumPy数组内存碎片、GPU显存未释放等一连串连锁反应。我们曾有个文本分类模型,在测试时QPS 200毫无压力,上线后峰值QPS 150就触发K8s的OOMKilled——根源是模型加载时一次性把整个词向量矩阵np.load('glove.6B.300d.npy')全塞进内存,而K8s Pod的内存limit设的是2GB,实际RSS峰值冲到2.1GB。这根本不是模型问题,是交付物对资源边界的无知。
第三重绞杀最隐蔽也最致命:可观测性的真空地带。Notebook里print(f"Accuracy: {acc:.4f}")就是全部。生产环境呢?你需要知道:当前请求的输入特征分布是否漂移?模型输出的置信度均值是否在缓慢下降?某个特定用户ID的请求,其预测路径上每个中间层的输出是什么?这些信息,不是靠logging.info()能覆盖的。它需要结构化的指标(Prometheus)、上下文关联的追踪(OpenTelemetry)、可检索的原始请求/响应快照(Elasticsearch)。Part 4的设计起点,就是承认这三重绞杀无法靠“更漂亮的Notebook”规避,必须用工程化分层来切割、隔离、控制。
2.2 四层交付模型:把混沌拆解为可验证的契约
我们最终落地的架构,是一个清晰的四层交付模型,每一层都定义了明确的输入、输出、契约和验证方式:
Layer 0:可重现的训练环境(The Reproducible Training Environment)
这不是Dockerfile,而是一份environment.yml(Conda)+requirements-lock.txt(Pip)+train.py的组合。关键在于train.py必须是纯函数式接口:def train(config: dict) -> ModelArtifact。ModelArtifact是一个自定义类,内部封装了model,preprocessor,feature_names,input_schema(Pydantic模型),以及最重要的save(path: str)方法。这个save()方法会将所有必要组件(模型权重、预处理器状态、schema定义)序列化到一个model.tar.gz包里,并生成一个MANIFEST.json,记录Python版本、PyTorch版本、git commit hash、train.py的SHA256。验证方式?docker run -v $(pwd):/workspace my-train-image python /workspace/train.py --config config.yaml,输出必须是完全一致的model.tar.gz哈希值。这层消灭了“在我机器上是好的”这种万能借口。Layer 1:标准化的模型服务接口(The Standardized Serving Interface)
所有模型,无论用XGBoost、TensorFlow还是自研C++推理引擎,对外暴露的API必须严格遵循OpenAPI 3.0规范定义的/predict端点。请求体是JSON Schema定义的{"features": {"age": 35, "income": 85000, ...}},响应体是{"prediction": 1, "confidence": 0.92, "explanation": {...}}。实现上,我们强制使用mlserver(基于FastAPI)作为统一网关。它的核心价值在于:自动加载model.tar.gz,根据MANIFEST.json校验环境兼容性,提供健康检查/livez、就绪检查/readyz、模型元数据/v2/models/{name}/versions/{version}。你不再需要为每个模型写一套Flask路由,mlserver已经为你做好了负载均衡、批处理、模型版本路由。验证方式?curl -X POST http://localhost:8080/v2/models/my-model/infer -d '{"inputs": [{"name": "features", "shape": [1, 10], "datatype": "FP32", "data": [35, 85000, ...]}]}',必须返回标准V2 Infer协议响应。Layer 2:生产就绪的基础设施契约(The Production-Ready Infrastructure Contract)
这层定义了模型服务在K8s上的“生存法则”。我们不接受裸Pod。每个模型服务必须通过Helm Chart部署,Chart中强制包含:resources.limits.memory: 2Gi(基于Layer 0的MANIFEST.json中记录的训练内存峰值+30%冗余计算得出)livenessProbe指向/livez,initialDelaySeconds: 60(给大模型加载留足时间)readinessProbe指向/readyz,failureThreshold: 3podDisruptionBudget确保滚动更新时至少1个副本在线securityContext.runAsNonRoot: true,readOnlyRootFilesystem: true验证方式?helm template my-chart | kubectl apply --dry-run=client -f -,必须通过kubeval和我们自研的infra-contract-checker(扫描YAML中是否缺失上述字段)双重校验。
Layer 3:闭环的可观测性与反馈环(The Closed-Loop Observability & Feedback Loop)
这是Part 4区别于前几部分的灵魂。每个/predict请求,mlserver会自动注入OpenTelemetry Trace ID,并将结构化日志(含Trace ID、Request ID、Input Hash、Output、Latency、Error Stack)发送到Loki。同时,Prometheus Exporter暴露mlserver_inference_latency_seconds_bucket等指标。最关键的是feedback端点:POST /v2/models/{name}/feedback,业务系统可上报{"request_id": "...", "ground_truth": 1, "is_correct": false}。这些反馈数据,经由Flink实时作业清洗后,自动触发数据漂移检测(KS检验)、概念漂移检测(ADWIN算法),并生成告警工单。验证方式?模拟一次数据漂移,看是否在30分钟内收到企业微信告警:“模型my-model-v1.2在特征user_session_duration上检测到显著漂移(p-value < 0.01),建议触发重训练”。
这个四层模型,把“运行ML”这个模糊动作,拆解成了四个可独立验证、可单独替换、可逐层审计的工程契约。它不追求一步登天,而是让每一次交付,都像拧紧一颗螺栓一样确定。
3. 核心细节解析与实操要点:那些文档里不会写的“手抖时刻”
3.1 Layer 0的魔鬼细节:save()方法里的血泪教训
ModelArtifact.save(path)看似简单,但里面全是坑。我们最初版本是这样写的:
def save(self, path: str): joblib.dump(self.model, os.path.join(path, "model.joblib")) joblib.dump(self.preprocessor, os.path.join(path, "preprocessor.joblib")) with open(os.path.join(path, "schema.json"), "w") as f: json.dump(self.input_schema.dict(), f)上线三天后崩溃。原因?joblib在不同Python版本间序列化不兼容。一个在Python 3.8训练的模型,用3.9的joblib加载,直接AttributeError: 'module' object has no attribute 'XXX'。解决方案?放弃joblib,拥抱pickle的可控版本。但pickle也有风险,所以我们做了三件事:
强制指定协议版本:
pickle.dump(obj, f, protocol=pickle.HIGHEST_PROTOCOL)→ 改为pickle.dump(obj, f, protocol=4)。Protocol 4是Python 3.4+稳定支持的最高协议,跨版本兼容性最好。我们甚至在MANIFEST.json里硬编码"pickle_protocol": 4,加载时先校验。剥离非序列化依赖:
preprocessor里如果用了lambda函数,pickle会尝试序列化整个闭包,导致体积暴增且易失败。我们要求所有预处理器必须继承自BaseEstimator和TransformerMixin,且内部逻辑必须是纯函数或可导入的模块函数。例如,把lambda x: x.strip().lower()改为定义一个独立函数def clean_text(x): return x.strip().lower(),并在preprocessor中引用self.clean_func = clean_text。输入Schema的防御性序列化:
Pydantic的dict()方法会把datetime对象转成ISO字符串,但某些老系统可能期望int时间戳。我们在save()里增加转换逻辑:schema_dict = self.input_schema.dict() # 将所有 datetime 字段转为 int timestamp for k, v in schema_dict.items(): if isinstance(v, datetime): schema_dict[k] = int(v.timestamp())并在
MANIFEST.json里记录"schema_serialization": "timestamp_int",加载时按此规则反序列化。
提示:
MANIFEST.json不是摆设。我们有一个CI步骤,git diff HEAD~1 -- requirements-lock.txt | grep -q "torch==1.12",如果检测到PyTorch版本变更,就强制触发一次全量回归测试。因为哪怕小版本升级,也可能导致CUDA kernel行为微变,影响数值稳定性。
3.2 Layer 1的网关陷阱:mlserver配置的五个致命参数
mlserver开箱即用,但默认配置在生产环境就是灾难。以下是我们在settings.json里必须显式覆盖的五个参数,以及为什么:
| 参数 | 推荐值 | 为什么必须改 | 血泪案例 |
|---|---|---|---|
parallel_workers | min(4, cpu_count()) | 默认1,意味着所有请求串行排队。高并发下延迟飙升。 | 电商大促期间,QPS 50,平均延迟从15ms飙到1200ms,用户投诉激增。 |
max_batch_size | 32(图像) /128(表格) | 默认0(禁用批处理)。开启批处理能极大提升GPU利用率,但过大导致首字节延迟(TTFB)增加。需权衡吞吐与延迟。 | NLP模型max_batch_size=256,用户感觉“卡顿”,实测P95延迟从80ms升至320ms。 |
load_timeout | 300(秒) | 默认60。大模型(如BERT-base)加载可能耗时超过2分钟。超时会导致Pod反复CrashLoopBackOff。 | 一个1.2GB的视觉模型,加载需210秒,load_timeout=60导致K8s不断重启Pod,服务不可用。 |
grpc_port | 8081 | 默认8080。必须与HTTP端口分离!否则/livez健康检查会走gRPC通道,失败。 | 运维误将Ingress的8080映射到mlserver的gRPC端口,所有健康检查失败,K8s认为服务不健康,持续驱逐Pod。 |
log_level | "INFO" | 默认"WARNING"。生产环境必须INFO,否则无法看到关键的Loading model...、Batching enabled等日志,排查问题如盲人摸象。 | 模型加载失败,日志只有WARNING:root:Failed to load model,开启INFO后才看到OSError: libcuda.so.1: cannot open shared object file,定位到CUDA驱动缺失。 |
注意:
mlserver的model-settings.json里还有一个隐藏炸弹:"implementation"字段。不要写"mlserver.xgboost.XGBoostModel",而要写"mlserver.sklearn.SKLearnModel"。因为XGBoost模型本质上是sklearn兼容的,SKLearnModel实现更成熟,对joblib/pickle兼容性更好。我们曾因写错这个字段,导致XGBoost模型加载后predict()返回全零。
3.3 Layer 2的K8s安全红线:非Root与只读根文件系统的实战妥协
securityContext.runAsNonRoot: true和readOnlyRootFilesystem: true是K8s安全基线的黄金标准。但在ML场景下,它们会制造真实的摩擦:
问题1:模型加载时需要写临时文件。
mlserver加载model.tar.gz时,会解压到/tmp目录。如果/tmp不是可写,会报错Permission denied。
解法:在Helm Chart的deployment.spec.template.spec.containers.volumeMounts里,显式挂载一个emptyDir到/tmp:volumeMounts: - name: tmp-dir mountPath: /tmp volumes: - name: tmp-dir emptyDir: {}问题2:
readOnlyRootFilesystem导致/var/log不可写,日志丢失。
解法:同样挂载emptyDir到/var/log,并配置mlserver的日志输出到该路径。在settings.json里加:"logging": { "level": "INFO", "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", "handlers": [ { "class": "logging.FileHandler", "filename": "/var/log/mlserver.log" } ] }问题3:非Root用户无法绑定
8080端口。mlserver默认监听8080。
解法:修改settings.json的http_port为8080(没问题,非Root可以监听>1024端口),但K8s Service的targetPort必须同步改为8080。或者更稳妥,统一改为8080(HTTP)和8081(gRPC),避免端口冲突。
实操心得:我们写了一个
k8s-security-audit.sh脚本,作为CI的最后一步。它会kubectl get pod -o yaml,然后用yq提取securityContext字段,校验runAsNonRoot和readOnlyRootFilesystem是否为true,并检查volumeMounts是否包含了/tmp和/var/log。任何一项失败,CI直接红灯。安全不是口号,是自动化流水线里的一道铁闸。
4. 实操过程与核心环节实现:从模型包到线上服务的完整流水线
4.1 流水线全景:GitOps驱动的端到端交付
我们的CI/CD流水线完全基于GitOps理念,所有配置即代码,所有变更可追溯。流程如下:
Developer Commit:算法工程师在
models/my-credit-risk/目录下提交:train.py(符合Layer 0契约)config.yaml(训练超参)Dockerfile.train(用于构建训练镜像)charts/my-credit-risk/(Helm Chart,符合Layer 2契约)
CI Pipeline (GitHub Actions):
Step 1: Validate Layer 0:运行python train.py --config config.yaml --dry-run,验证train.py语法、MANIFEST.json生成、model.tar.gz哈希。Step 2: Build & Test Train Image:docker build -f Dockerfile.train -t $REGISTRY/train-my-credit-risk:$COMMIT .,然后docker run $REGISTRY/train-my-credit-risk:$COMMIT,确认模型包生成成功。Step 3: Validate Helm Chart:helm lint charts/my-credit-risk/+helm template charts/my-credit-risk/ --set image.tag=$COMMIT | kubeval+infra-contract-checker。Step 4: Push Artifacts:将model.tar.gz推送到MinIO(S3兼容对象存储),将训练镜像推送到Harbor私有仓库,将Helm Chart推送到ChartMuseum。
CD Pipeline (Argo CD):
- Argo CD监听Git仓库
charts/目录和MinIO的models/前缀。 - 当检测到新
model.tar.gz或新Helm Chart版本,自动触发同步。 - 同步前,Argo CD执行
pre-sync钩子:调用/v2/models/my-credit-risk/versions/latest/readyz,确认旧版本服务健康;调用/v2/models/my-credit-risk/versions/latest/metrics,获取P95延迟基线。 - 同步中,执行
helm upgrade --install --wait --timeout 600s,K8s开始滚动更新。 - 同步后,执行
post-sync钩子:调用新版本/readyz,调用/v2/models/my-credit-risk/versions/latest/infer进行金丝雀测试(发送3个预定义样本),比对新旧版本输出差异(允许abs(diff) < 1e-5)。
- Argo CD监听Git仓库
这个流水线,把“上线”这个动作,压缩到了一次git push。没有人工kubectl apply,没有深夜值班,没有“我刚刚改了一行代码,应该没问题吧”的侥幸。
4.2 关键环节详解:金丝雀发布与自动回滚的代码级实现
金丝雀发布(Canary Release)是Part 4的核心保障。我们不用复杂的Service Mesh,而是用mlserver原生的模型版本路由+K8s的Service权重来实现。
Step 1: Helm Chart中的双版本部署
在charts/my-credit-risk/templates/deployment.yaml里,我们部署两个Deployment:my-credit-risk-v1.2:image: $REGISTRY/mlserver:1.2my-credit-risk-v1.3:image: $REGISTRY/mlserver:1.3(新版本) 两者共享同一个Service,但通过labels区分:app.kubernetes.io/version: "1.2"和"1.3"。
Step 2:
mlserver的模型版本路由mlserver的model-settings.json里,name字段必须唯一。我们约定格式:{model_name}-{version}。所以v1.2的模型设置是"name": "my-credit-risk-1.2",v1.3的是"name": "my-credit-risk-1.3"。Step 3: K8s Service的流量切分(核心!)
我们不依赖Ingress,而是用K8s原生的EndpointSlice。创建两个Service:my-credit-risk-primary:Selector匹配app.kubernetes.io/version: "1.2",承载95%流量。my-credit-risk-canary:Selector匹配app.kubernetes.io/version: "1.3",承载5%流量。 然后,业务方的客户端,通过环境变量MODEL_ENDPOINT决定调用哪个Service。金丝雀阶段,MODEL_ENDPOINT=http://my-credit-risk-canary:8080。
Step 4: 自动回滚的触发器(Python脚本)
这是真正的“无人值守”。我们有一个常驻的canary-monitor.py,它每30秒做一次检查:import requests import time from prometheus_client import Summary # 定义关键指标 canary_error_rate = Summary('canary_error_rate', 'Canary error rate') canary_latency_p95 = Summary('canary_latency_p95', 'Canary latency P95') def check_canary(): try: # 1. 调用canary endpoint 10次 latencies = [] errors = 0 for _ in range(10): start = time.time() resp = requests.post("http://my-credit-risk-canary:8080/v2/models/my-credit-risk-1.3/infer", json={"inputs": [...]}, timeout=5) latencies.append(time.time() - start) if resp.status_code != 200: errors += 1 # 2. 计算指标 error_rate = errors / 10 p95_latency = sorted(latencies)[8] # 9th element of 10 # 3. 触发条件:错误率 > 5% 或 P95延迟 > 200ms if error_rate > 0.05 or p95_latency > 0.2: # 调用Argo CD API,回滚到v1.2 requests.post("https://argocd.example.com/api/v1/applications/my-credit-risk/sync", json={"revision": "v1.2-commit-hash"}, headers={"Authorization": "Bearer ..."}) print(f"ALERT: Canary failed! ErrorRate={error_rate:.2f}, P95Latency={p95_latency:.3f}s. Rolling back.") return True except Exception as e: print(f"Monitor error: {e}") return False while True: check_canary() time.sleep(30)这个脚本部署为K8s CronJob,确保即使主服务宕机,监控依然有效。它让“回滚”从一个需要人工判断、登录服务器、敲命令的紧张操作,变成了一个后台安静执行的例行公事。
4.3 监控与告警:从“服务是否活着”到“模型是否可信”
监控不是CPU < 80%,而是回答三个问题:它在工作吗?它工作得好吗?它还在理解这个世界吗?
Question 1: It's alive? (健康性)
指标:up{job="mlserver"} == 1(Prometheus)
告警:ALERT MLServerDown,IF up{job="mlserver"} == 0,FOR 2m,LABELS {severity="critical"}。
这是最基础的,对应/livez和/readyz。Question 2: Is it good? (服务质量)
指标:rate(mlserver_inference_errors_total[5m]) / rate(mlserver_inference_requests_total[5m])(错误率)histogram_quantile(0.95, sum(rate(mlserver_inference_latency_seconds_bucket[5m])) by (le))(P95延迟)mlserver_model_load_time_seconds(模型加载耗时,突增说明磁盘IO瓶颈)
告警:ALERT MLServerErrorRateHigh,IF (rate(mlserver_inference_errors_total[5m]) / rate(mlserver_inference_requests_total[5m])) > 0.01,FOR 5m,LABELS {severity="warning"}。ALERT MLServerLatencyHigh,IF histogram_quantile(0.95, sum(rate(mlserver_inference_latency_seconds_bucket[5m])) by (le)) > 0.2,FOR 5m,LABELS {severity="warning"}。
Question 3: Is it still understanding? (模型可信度)
这是Part 4的精华。我们用Flink SQL实时计算:-- 计算每个特征的KS检验统计量(每小时窗口) CREATE TABLE feature_drift AS SELECT feature_name, ks_test( COLLECT_LIST(CAST(input_value AS DOUBLE)), 'reference_distribution' -- 来自训练数据的基准分布 ) AS ks_statistic FROM ( SELECT 'age' AS feature_name, CAST(json_extract_scalar(payload, '$.features.age') AS VARCHAR) AS input_value FROM kafka_source WHERE event_type = 'inference_request' ) t GROUP BY feature_name, TUMBLING_WINDOW(INTERVAL '1' HOUR);告警:
ALERT ModelConceptDrift,IF max_over_time(feature_drift_ks_statistic[24h]) > 0.15,FOR 1h,LABELS {severity="info"}(通知数据科学家,非紧急)。
更进一步,我们把feedback数据(is_correct: false)和input_hash关联,生成hotspot报告:哪些用户群体(如region=South且age<25)的错误率显著高于均值?这直接指导业务方做定向运营或模型重训。
实操心得:我们给每个告警都配了
runbook_url。比如MLServerErrorRateHigh的Runbook,第一步永远是:“检查/metrics端点,确认mlserver_inference_errors_total的标签reason,是model_not_found、invalid_input还是internal_error?”——把模糊的“错误率高”,立刻定位到具体故障域。这才是监控的价值。
5. 常见问题与排查技巧实录:那些让你凌晨三点还在SSH的“经典”故障
5.1 故障速查表:高频问题、现象、根因与一招毙命解法
| 现象 | 可能根因 | 快速诊断命令 | 一招毙命解法 | 经验备注 |
|---|---|---|---|---|
503 Service Unavailableon/readyz | mlserver进程启动了,但模型加载失败,卡在Loading model... | kubectl logs <pod-name> -c mlserver | tail -20 | 检查日志末尾是否有OSError: Unable to load library 'libcudnn'。若有,kubectl exec -it <pod-name> -- ldd /usr/local/lib/python3.8/site-packages/torch/lib/libtorch_cuda.so | grep cudnn,确认CUDA/cuDNN版本匹配。 | 这是GPU模型上线第一杀手。务必在Dockerfile里用nvidia/cuda:11.3.1-cudnn8-runtime-ubuntu20.04基镜,而非pytorch/pytorch:1.12.1-cuda11.3-cudnn8-runtime,后者cuDNN版本可能不一致。 |
400 Bad RequestwithInvalid input shape | 请求JSON的features字段结构与input_schema定义不符 | curl -s http://<service>/v2/models/<model>/versions/<version>/metadata | jq '.input' | 对比metadata输出的name/shape/datatype与你的请求体。常见错误:shape: [1, 10],你传了[10](少了一维),或datatype: "FP32",你传了整数。 | mlserver的V2协议要求严格。用mlserver自带的mlserver test命令生成合规请求模板:mlserver test --model-name my-model --model-version 1.0 --input-file request.json。 |
P95 Latency spikes to 5sduring traffic surge | Python GIL争抢,parallel_workers设置过低,或max_batch_size过大导致队列积压 | kubectl top pod <pod-name>查看CPU使用率;kubectl exec -it <pod-name> -- python -c "import threading; print(len(threading.enumerate()))" | 如果CPU < 50%但延迟高,增大parallel_workers;如果CPU > 90%且延迟高,减小max_batch_size并增大parallel_workers。 | 别迷信“越多越好”。我们测试过,parallel_workers=8在4核CPU上反而比4慢15%,因为线程切换开销超过了并行收益。 |
model.tar.gz加载后,predict()返回NaN | 训练时用了float64,生产环境float32,数值下溢为NaN;或预处理器fit()时用了np.nan,transform()时未处理 | kubectl exec -it <pod-name> -- python -c "import numpy as np; print(np.finfo(np.float32))";检查preprocessor代码中是否有np.nan相关逻辑 | 在train.py的fit()前,强制X_train = X_train.astype(np.float32);在preprocessor.transform()里,对np.nan做fillna(0)或dropna()。 | 这是隐形杀手。本地测试用float64一切正常,上线float32后,exp(-1000)直接变0.0,后续计算全NaN。务必在MANIFEST.json里记录"dtype": "float32"。 |
/feedback端点返回404 Not Found | mlserver默认不启用Feedback API,需显式开启 | kubectl exec -it <pod-name> -- curl -s http://localhost:8080/v2/health | jq,看输出是否含"extensions": ["kfserving.proto.v2"] | 在settings.json里添加"extensions": ["kfserving.proto.v2"],并重启Pod。 | Feedback是闭环的关键。别省这一步。 |
5.2 独家避坑技巧:来自17次线上事故的总结
技巧1:永远在
Dockerfile里RUN pip install --no-cache-dir -r requirements.txt
不要信--no-cache-dir就能清干净。pip的wheel缓存藏在/root/.cache/pip,--no-cache-dir只清/tmp。正确姿势:RUN pip install --no-cache-dir --force-reinstall -r requirements.txt && rm -rf /root/.cache/pip。我们曾因缓存里一个旧版numpy,导致scipy安装失败,CI卡死2小时。技巧2:
MANIFEST.json里加"build_timestamp",而不是"git_commit"git_commit只能告诉你代码版本,但build_timestamp能告诉你这个二进制包是何时、在何种环境(CI runner OS、Python patch version)下构建的。当两个git_commit相同的包,一个线上OK一个失败,build_timestamp能立刻指向CI环境差异(如ubuntu-20.04vsubuntu-22.04)。技巧3:为
/livez和/readyz写独立的探针脚本
不要直接用curl http://localhost:8080/livez。写一个healthcheck.sh:#!/bin/bash # /livez: 检查进程存活 if ! pgrep -f