1. 项目概述:这不是一次模型训练,而是一场工程交付
“From Notebook to Production: Running ML in the Real World (Part 4)”——这个标题里藏着一个被太多人轻描淡写、却让无数团队在临门一脚时彻底卡死的真相:Notebook 是思考的草稿纸,Production 是交付的合同书。它不讲怎么调参、不教怎么画 loss 曲线,它直指那个没人愿意多说但每天都在吞噬工程师时间的核心问题:当你在 Jupyter 里跑通了 accuracy 92.3% 的模型,下一步该把这串代码交给谁?用什么方式交?交过去之后,它会不会在凌晨三点因为一条脏数据崩掉,而你手机没响、告警没触发、业务方已经打电话来问“为什么推荐页全黑了”?
我做过 7 个从零到上线的机器学习服务,其中 4 个在模型准确率达标后,花了比训练周期长 2.3 倍的时间才真正稳定跑进生产环境。Part 4 这个编号很关键——它不是入门篇,不是原理篇,而是压轴的“交付实战篇”。它默认你已掌握模型开发(Part 1)、特征工程落地(Part 2)、模型监控基线(Part 3),现在要解决的是:如何让一个“能跑”的模型,变成一个“敢签 SLA”的服务。
核心关键词“Notebook to Production”背后,实际覆盖三个不可妥协的硬性要求:可复现性(Reproducibility)——今天在你本地跑的结果,和三个月后运维同事在 k8s 集群里拉起的镜像结果必须完全一致;可观测性(Observability)——不是只看 CPU 和内存,而是要实时知道特征分布是否漂移、预测置信度是否集体下滑、某类样本的延迟是否异常升高;可演进性(Maintainability)——当业务方下周突然要求增加“用户最近 30 分钟行为加权”,你能不能在不重启服务、不影响线上流量的前提下完成热更新?这三个词,就是 Part 4 的全部分量。它适合两类人:一类是刚把模型跑通、正对着部署文档发愁的算法工程师;另一类是被算法同学反复喊“再给我两天就能上线”、但已经等了三周的后端或 SRE 同事。这篇文章,就是给你们共同写的交接清单。
2. 整体设计思路:为什么放弃“一键部署”,选择“分层解耦”
很多团队在 Part 4 阶段会本能地走向两个极端:要么用 MLflow 或 Kubeflow 搞一套“全自动流水线”,结果半年过去 pipeline 跑得比模型还复杂,出了问题连日志都找不到在哪;要么干脆手写 Flask API + Gunicorn,模型 load 一次、全局变量存着,美其名曰“轻量”,实则成了线上最脆弱的单点故障。这两种方案,本质上都错在试图用“一个工具”解决“三层矛盾”:开发态与运行态的矛盾、模型逻辑与基础设施的矛盾、快速迭代与系统稳定的矛盾。
我们最终采用的方案是“四层解耦架构”,它不是炫技,而是从血泪教训里长出来的:
第一层:Notebook → Script(可执行脚本化)
不是简单把 .ipynb 导出为 .py,而是重构整个代码结构:把数据加载、预处理、模型加载、推理封装成独立函数,每个函数有明确输入输出契约(例如def predict(user_id: str, item_ids: List[str]) -> Dict[str, float]),并强制添加类型注解和 docstring。我试过直接导出的脚本,里面混着plt.show()、df.head()、%timeit这类调试代码,上线前漏删一行,服务就卡死在 matplotlib 后端初始化上。这一层的目标只有一个:让模型代码脱离 Jupyter 环境后,仍能通过python model_inference.py --user_id=123 --item_ids=456,789这种命令行方式干净运行。第二层:Script → Container(容器标准化)
用 Dockerfile 显式声明所有依赖:Python 版本、PyTorch 版本、CUDA 版本、甚至pip install的源地址(国内必须指定清华源,否则 CI/CD 流水线会因网络超时失败)。关键细节在于:模型权重文件不打包进镜像,而是通过挂载 volume 或对象存储 URL 加载。原因很现实——一个 BERT 微调模型权重动辄 1.2GB,每次模型微调都重打镜像,镜像仓库会迅速膨胀到 TB 级,且版本回滚成本极高。我们约定:镜像只含代码和轻量依赖,模型权重存 OSS,启动时由容器内脚本下载到/model/weights/目录。这样,同一镜像可服务多个模型版本,只需改一个环境变量MODEL_VERSION=v2.1.3。第三层:Container → Service(服务化抽象)
不直接暴露容器端口,而是套一层轻量 API 网关(我们选的是 Envoy,而非 Nginx,因为 Envoy 原生支持 gRPC-JSON 转换、熔断、重试策略)。重点在于定义清晰的接口契约:HTTP POST/v1/predict接收 JSON,返回标准格式{ "status": "success", "data": { "scores": [0.92, 0.15, ...] }, "meta": { "latency_ms": 42, "model_version": "v2.1.3" } }。这里埋了一个关键经验:所有响应必须包含meta字段,且meta中必须有model_version和latency_ms。前者用于灰度发布时精准定位问题版本,后者是后续做 P99 延迟监控的原始数据源。没有这个字段,你后期想加监控就得改所有客户端代码。第四层:Service → Platform(平台级治理)
这才是 Part 4 的真正战场。我们用 Kubernetes 的 Custom Resource Definition(CRD)定义了MLModel这个资源类型,它的 spec 包含:image: registry.example.com/ml-recommender:v1.2,modelUrl: oss://models/recommender/v1.2/weights.pt,trafficSplit: { stable: 80, canary: 20 },autoscaling: { minReplicas: 2, maxReplicas: 10, targetCPUUtilization: 60 }。运维同学不再需要 SSH 登服务器改配置,只需kubectl apply -f recommender-canary.yaml,K8s Operator 就会自动拉起新 Pod、注入模型、切流、扩缩容。这个设计让算法同学获得了“自助发布权”,而平台团队守住了“稳定性底线”。
这个四层结构的价值,在于它把“谁该对什么负责”划得清清楚楚:算法工程师只管第一层(代码契约)和第二层(Dockerfile 正确性);SRE 团队专注第三、四层(网关策略、CRD 运维);当线上出问题时,大家不用在群里互相甩锅,而是按层排查:先看 CRD 是否生效(第四层),再查 Envoy 日志是否有 503(第三层),然后进容器 exec 进去跑python model_inference.py --debug(第二层),最后回到 Notebook 检查数据预处理逻辑(第一层)。这种责任隔离,是项目能持续交付的根本保障。
3. 核心细节解析:从模型加载到请求路由的 7 个生死关
3.1 模型加载:别让torch.load()成为启动瓶颈
很多人以为模型加载就是model = torch.load('model.pt')一行的事。实测下来,一个 800MB 的 PyTorch 模型,在 16 核 CPU 上torch.load()平均耗时 3.2 秒,且会阻塞主线程,导致 K8s readiness probe 失败,Pod 一直卡在ContainerCreating状态。我们踩过的坑是:在__init__里直接 load,结果服务启动时间从 5 秒飙升到 12 秒,K8s 默认 10 秒超时,大量 Pod 反复重启。
解决方案是异步懒加载 + 预热机制。具体实现分三步:
构造函数中只初始化模型结构,不加载权重:
class RecommenderModel: def __init__(self, config_path: str): self.config = load_config(config_path) # 快,<10ms self.model = None # 占位,不实例化 self._is_loaded = False提供显式
load_weights()方法,并用 threading.Lock 防并发:def load_weights(self, weights_url: str): if self._is_loaded: return with self._load_lock: # 防止多线程重复加载 if self._is_loaded: return # 下载权重(带进度条和断点续传) local_path = download_with_resume(weights_url, "/tmp/model.pt") # 异步加载,避免阻塞 threading.Thread(target=self._do_load, args=(local_path,)).start() def _do_load(self, path: str): self.model = torch.load(path, map_location='cpu') # 先 CPU 加载 self.model.to('cuda') # 再 GPU 转移,避免 OOM self._is_loaded = True在 HTTP handler 中加入预热检查:
@app.route('/v1/predict', methods=['POST']) def predict(): if not model_instance._is_loaded: return jsonify({"error": "model not ready"}), 503 # 正常推理...同时,K8s readiness probe 设置为
GET /healthz,该 endpoint 仅检查model._is_loaded和 CUDA 可用性,响应时间稳定在 2ms 内。
提示:
torch.load()的map_location参数必须显式指定'cpu'或'cuda',否则在无 GPU 环境下会报错;若模型含自定义 layer,需在torch.load()前sys.path.append('/path/to/model/code'),否则反序列化失败。
3.2 特征预处理:为什么不能在请求里做pandas.read_csv()
一个典型错误是:API 接收原始用户 ID 和商品 ID 列表,然后在predict()函数里现场查数据库、拼接特征、用 pandas 做归一化。实测一个含 50 个特征的样本,这种流程平均耗时 180ms,P99 达到 420ms,远超业务要求的 <100ms。更致命的是,pandas 在多线程环境下存在 GIL 争用,QPS 上不去。
我们的解法是特征服务化 + 向量化预处理:
特征服务(Feature Store):用 Feast 搭建统一特征仓库,所有特征(用户画像、商品属性、实时行为)预先计算好,存入 Redis Cluster。API 层通过
feast_client.get_online_features(...)以毫秒级延迟获取特征向量,而非自己拼 SQL。向量化预处理:将 sklearn 的
StandardScaler、OneHotEncoder等转换器,用sklearn-onnx导出为 ONNX 模型,与主模型一起加载。推理时,原始输入(如[user_age=28, item_category="electronics"])先经 ONNX runtime 执行预处理,输出标准化后的 float32 数组,再喂给主模型。ONNX runtime 的 CPU 推理速度是原生 sklearn 的 3.7 倍,且线程安全。
注意:ONNX 导出时务必用
opset_version=15(兼容性最好),且对OneHotEncoder的handle_unknown='ignore'参数要显式设置,否则线上遇到未见过的 category 会直接 crash。
3.3 请求路由:gRPC vs HTTP,选哪个不是看性能,而是看生态
团队曾为用 gRPC 还是 HTTP 争论两周。gRPC 确实快(二进制协议、HTTP/2 多路复用),但真实场景中,90% 的延迟来自模型计算本身,而非序列化。我们压测对比:相同模型下,gRPC P99 延迟 89ms,HTTP/1.1 为 94ms,差距仅 5ms,但代价是:前端 Web 应用需引入 gRPC-web 代理,iOS 客户端要集成 C++ gRPC 库,运维要额外维护 Envoy 的 gRPC 转换配置。
最终我们选 HTTP/1.1,但做了关键优化:
- 启用 HTTP Keep-Alive:在 Flask/Gunicorn 配置中设置
keepalive = 30,避免短连接频繁握手; - 请求体用 MessagePack 替代 JSON:MessagePack 是二进制序列化,体积比 JSON 小 40%,解析快 2.1 倍。客户端用
msgpack.packb(data)发送,服务端用msgpack.unpackb(request.data)解析; - 响应压缩:Nginx 层开启
gzip on; gzip_types application/json;,对 10KB 的 JSON 响应压缩后仅 2.3KB,节省带宽且降低传输延迟。
实操心得:不要迷信“新技术一定更好”。gRPC 在内部微服务间调用(如特征服务调用模型服务)非常合适,但对外暴露 API,HTTP 的普适性和调试便利性无可替代。我们最终架构是:外部 HTTP → 内部 gRPC(特征服务 ↔ 模型服务)。
3.4 错误处理:为什么try...except Exception:是线上毒药
新手常写:
try: result = model.predict(input_data) except Exception as e: logger.error(f"Predict failed: {e}") return {"error": "internal error"}这会导致两个灾难:一是所有错误(从CUDA out of memory到KeyError: 'user_id')都被抹平为同一个模糊错误码,无法区分是数据问题还是模型问题;二是Exception会捕获KeyboardInterrupt、SystemExit,导致服务无法被kill -15正常终止。
正确做法是分层捕获 + 结构化错误码:
@app.errorhandler(400) def bad_request(error): return jsonify({"code": "INVALID_INPUT", "message": str(error)}), 400 @app.errorhandler(500) def internal_error(error): return jsonify({"code": "MODEL_ERROR", "message": "model execution failed"}), 500 # 在 predict handler 中 def predict(): try: input_data = validate_input(request.json) # 自定义校验,抛 ValidationError except ValidationError as e: raise BadRequest(str(e)) # 触发 400 handler try: result = model.predict(input_data) except torch.cuda.OutOfMemoryError: logger.critical("CUDA OOM, scaling down batch size") raise InternalServerError("GPU memory exhausted") # 触发 500 handler except Exception as e: logger.exception("Unexpected error in predict") raise InternalServerError("unexpected error")这样,前端可根据code字段精准处理:INVALID_INPUT提示用户检查参数,MODEL_ERROR显示“服务暂时不可用,请稍后再试”,而CUDA OOM这类严重错误会触发告警,SRE 立即收到企业微信通知。
3.5 日志规范:别让print()毁掉你的可观测性
在 Notebook 里print("Start inference")很自然,但上线后,这些 print 会混在容器 stdout 里,被 Loki 当作普通日志采集,无法关联请求 ID,无法做聚合分析。我们强制推行结构化日志 + 请求上下文透传:
使用
structlog替代logging,每条日志自动注入request_id、model_version、latency_ms:import structlog log = structlog.get_logger() @app.before_request def before_request(): request_id = request.headers.get('X-Request-ID', str(uuid.uuid4())) # 将 request_id 注入当前线程上下文 structlog.contextvars.bind_contextvars(request_id=request_id) @app.after_request def after_request(response): latency = (time.time() - request.start_time) * 1000 structlog.contextvars.unbind_contextvars("request_id") log.info("request_finished", status_code=response.status_code, latency_ms=round(latency, 2)) return response关键路径打点:模型加载完成、特征获取耗时、推理耗时、后处理耗时,全部用
log.debug()记录,级别设为DEBUG,生产环境日志级别设为INFO,但通过 Loki 的level=debug查询可随时拉取。
经验:日志不是越多越好,而是要“可关联、可过滤、可聚合”。我们禁止任何
log.info("something happened")这种无字段日志,必须带至少两个 key-value 对,如log.info("feature_fetched", feature_name="user_age", duration_ms=12.3)。
3.6 健康检查:/healthz和/readyz必须是两套逻辑
K8s 的 liveness probe 和 readiness probe 用途不同,但很多人用同一个/healthzendpoint。这会导致灾难:当模型加载未完成时,readiness probe 失败,K8s 不会把流量打过来,这是对的;但如果此时 liveness probe 也指向/healthz,K8s 会认为 Pod 已死,直接 kill 重建,而重建后又要重新加载模型,陷入“加载-失败-重建”死循环。
我们的实践是:
/readyz:只检查模型是否加载完毕、Redis 连接是否正常、特征服务是否可达。不检查模型推理能力,因为推理可能因单条脏数据失败,但服务本身是健康的。/healthz:检查进程存活、磁盘空间 >10%、CPU 负载 <80%,不检查任何外部依赖。这是纯粹的“进程健康”,确保 K8s 不会误杀。
@app.route('/readyz') def readyz(): if not model._is_loaded: return "model not loaded", 503 if not redis_client.ping(): return "redis unavailable", 503 return "ok" @app.route('/healthz') def healthz(): # 检查本地资源,不依赖外部服务 if psutil.disk_usage("/").percent > 90: return "disk full", 503 return "ok"K8s 配置中,readinessProbe 指向/readyz,livenessProbe 指向/healthz,两者 timeoutSeconds 和 periodSeconds 独立设置。
3.7 配置管理:为什么.env文件绝不能出现在生产镜像里
开发时用python-dotenv读.env很方便,但上线后,把密钥、OSS AK/SK、数据库密码写在.env里,等于把钥匙挂在门把手上。我们强制所有配置通过K8s ConfigMap + Secret注入:
- ConfigMap 存放非敏感配置:
MODEL_URL,FEATURE_STORE_ENDPOINT,DEFAULT_BATCH_SIZE; - Secret 存放敏感信息:
OSS_ACCESS_KEY_ID,OSS_ACCESS_KEY_SECRET,REDIS_PASSWORD; - 容器启动时,通过环境变量或挂载文件方式注入,代码中统一用
os.getenv("MODEL_URL")读取。
关键技巧:Secret 的 value 必须 base64 编码,但 K8s 会自动解码,所以你在 YAML 里写的是编码后的字符串,代码里拿到的是明文。为防误操作,我们写了 pre-commit hook,禁止 git commit 中出现.env、config.py等含敏感字样的文件。
血泪教训:曾有同事在 debug 时把
.env临时提交到 dev 分支,CI 流水线自动构建镜像并推送到生产仓库,导致 AK/SK 泄露。从此我们所有 CI job 第一步就是git grep -q ".env\|password\|secret" && exit 1 || true。
4. 实操过程:从本地验证到灰度发布的完整流水线
4.1 本地验证:用docker-compose模拟生产环境
在 push 代码前,必须在本地用 docker-compose 拉起一个微型生产环境,验证端到端流程。我们的docker-compose.yml包含四个 service:
version: '3.8' services: model-service: build: . environment: - MODEL_URL=file:///model/weights.pt - FEATURE_STORE_ENDPOINT=http://feature-store:8000 volumes: - ./models:/model:ro depends_on: - feature-store - redis feature-store: image: feastdev/feast-feature-server:0.24.0 ports: ["8000:8000"] environment: - REDIS_URL=redis://redis:6379 redis: image: redis:7-alpine command: redis-server --save 60 1 --loglevel warning nginx: image: nginx:alpine ports: ["8080:80"] volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro验证步骤严格按上线顺序执行:
docker-compose up -d redis feature-store—— 启动依赖服务;curl -X POST http://localhost:8000/feature-store/ingest注入测试特征数据;docker-compose up -d model-service—— 启动模型服务;curl -H "Content-Type: application/msgpack" --data-binary "@test_input.mpk" http://localhost:8080/v1/predict发送 MessagePack 请求;- 检查响应状态码、
meta.latency_ms是否 <100ms、meta.model_version是否匹配。
这一步卡住,绝不允许代码合并。我们把它做成 GitLab CI 的test:localjob,失败则阻断 MR。
4.2 CI/CD 流水线:GitLab CI 的 5 个必过阶段
我们的.gitlab-ci.yml设计为五阶段流水线,每个阶段失败即中断:
| 阶段 | 任务 | 关键检查点 | 耗时 |
|---|---|---|---|
test:unit | 运行 pytest,覆盖模型加载、预处理、推理函数 | 覆盖率 ≥85%,无 flaky test | 2m15s |
test:integration | 启动 mock feature store + redis,测试端到端 | 所有 API endpoint 返回 200,P95 latency <80ms | 4m30s |
build:docker | 构建 Docker 镜像,docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG . | 镜像大小 ≤1.2GB,docker scan无 CRITICAL 漏洞 | 6m40s |
deploy:staging | kubectl apply -f staging.yaml部署到预发集群 | 所有 Pod Ready,curl -I staging-endpoint/readyz返回 200 | 1m20s |
test:e2e | 在预发环境运行真实业务流量回放(用 goreplay 录制的 1000 条请求) | 错误率 <0.1%,P99 latency 符合 SLA | 3m50s |
关键设计点:deploy:staging阶段使用staging.yaml,其中replicas: 1,resources.limits.memory: 2Gi,完全模拟生产资源配置;test:e2e不用新写测试用例,而是用线上录制的真实流量,确保测试场景 100% 覆盖。
4.3 灰度发布:用 Istio 的 VirtualService 实现 5% 流量切分
生产发布绝不允许“一刀切”。我们用 Istio 的VirtualService实现渐进式灰度:
apiVersion: networking.istio.io/v1beta1 kind: VirtualService metadata: name: ml-recommender spec: hosts: - ml-recommender.example.com http: - route: - destination: host: ml-recommender subset: stable weight: 95 - destination: host: ml-recommender subset: canary weight: 5 --- apiVersion: networking.istio.io/v1beta1 kind: DestinationRule metadata: name: ml-recommender spec: host: ml-recommender subsets: - name: stable labels: version: v1.2.0 - name: canary labels: version: v1.2.1发布流程是:
kubectl set image deploy/ml-recommender-canary ml-recommender=registry.example.com/ml-recommender:v1.2.1—— 更新 canary deployment 的镜像;kubectl apply -f virtualservice-canary.yaml—— 应用 5% 流量切分;- 观察 Grafana 看板:
canary_p99_latency、canary_error_rate、canary_feature_drift_score(用 Evidently 计算); - 若 15 分钟内所有指标正常,则
weight: 20→weight: 50→weight: 100,最终删除 canary subset。
实操心得:灰度不是“技术动作”,而是“决策流程”。我们规定:任何灰度发布,必须由算法负责人、SRE 负责人、业务 PM 共同在钉钉群确认“指标达标”,才能执行下一步切流。技术只是工具,人依然是决策主体。
4.4 监控告警:用 Prometheus + Grafana 搭建 4 层黄金指标
监控不是“加几个图表”,而是建立分层指标体系,确保问题能被快速定位到具体层级:
| 层级 | 指标名称 | 数据来源 | 告警阈值 | 作用 |
|---|---|---|---|---|
| 基础设施层 | container_cpu_usage_seconds_total{container="model-service"} | cAdvisor | > 80% for 5m | 容器资源过载 |
| 服务层 | http_request_duration_seconds_bucket{handler="predict", le="0.1"} | Prometheus client lib | < 95% for 10m | P95 延迟超标 |
| 模型层 | model_prediction_latency_seconds{model="recommender", quantile="0.99"} | 自定义 metrics | > 150ms for 5m | 模型推理变慢 |
| 业务层 | feature_distribution_drift{feature="user_age", metric="ks_test"} | Evidently + Prometheus exporter | > 0.2 for 30m | 用户年龄分布漂移 |
Grafana 看板按层级组织:基础设施看板(CPU/Mem/Disk)、服务看板(QPS/延迟/错误率)、模型看板(各版本 P99 延迟对比、特征漂移热力图)、业务看板(推荐点击率、GMV 影响)。告警规则全部配置在 Prometheus,通过 Alertmanager 推送到企业微信,消息模板包含:[ALERT] {{ $labels.job }} {{ $labels.instance }} {{ $labels.metric }} = {{ $value }}。
注意:
feature_distribution_drift指标需每日定时任务计算,我们用 Airflow 调度 Evidently 的Report生成 HTML 报告,并用 Python 脚本解析报告中的 KS 值,通过prometheus_client.Gauge暴露给 Prometheus。这一步自动化程度决定模型监控是否真正可用。
4.5 回滚机制:kubectl rollout undo不是万能的
很多人以为kubectl rollout undo deployment/ml-recommender就能一键回滚,但实际场景中,这招常失效:如果新版本镜像有 bug 导致 Pod 启动失败,K8s 会不断重启,rollout undo会回滚到上一个失败的 revision,而非真正的稳定版本。
我们的回滚流程是双保险:
- 自动回滚:在
Deployment中配置spec.progressDeadlineSeconds: 600(10 分钟),若新版本在 10 分钟内无法达到availableReplicas == replicas,K8s 自动触发回滚; - 手动回滚:当自动回滚失败时,执行
kubectl rollout history deployment/ml-recommender查看所有 revision,找到REVISION列显示1的稳定版本(通常是最老的那个),然后kubectl rollout undo deployment/ml-recommender --to-revision=1。
为防万一,我们每天凌晨 2 点用 CronJob 备份当前稳定版本的 Deployment YAML:kubectl get deploy ml-recommender -o yaml > /backup/ml-recommender-stable-$(date +%Y%m%d).yaml。哪怕集群崩溃,也能从备份中快速恢复。
5. 常见问题与排查技巧实录:那些文档里不会写的坑
5.1 问题:模型在本地 GPU 上推理正常,但 K8s Pod 里报CUDA error: no kernel image is available for execution on the device
现象:Pod 日志显示RuntimeError: CUDA error: no kernel image is available for execution on the device,nvidia-smi显示 GPU 正常,torch.cuda.is_available()返回True。
根因:PyTorch 编译时的 CUDA compute capability 与 K8s 节点 GPU 的 compute capability 不匹配。例如,你在 A100(compute capability 8.0)上训练的模型,用torch==1.12.1+cu113构建的镜像,但 K8s 节点是 V100(compute capability 7.0),cu113 的 wheel 不包含 7.0 的 kernel。
排查步骤:
- 进入 Pod:
kubectl exec -it <pod-name> -- bash; - 查看 GPU 信息:
nvidia-smi --query-gpu=name,compute_cap --format=csv; - 查看 PyTorch CUDA 版本:
python -c "import torch; print(torch.version.cuda)"; - 查看 PyTorch 支持的 compute capability:
python -c "import torch; print(torch.cuda.get_arch_list())"。
解决方案:
- 在构建镜像时,显式指定与目标 GPU 匹配的 PyTorch 版本。例如,节点是 V100,就用
pip install torch==1.12.1+cu116 -f https://download.pytorch.org/whl/torch_stable.html(cu116 支持 compute capability 7.x); - 或者,统一用
pip install torch==1.12.1 --index-url https://download.pytorch.org/whl/cpu安装 CPU 版本,推理时用model.to('cuda')动态加载,PyTorch 会自动选择兼容的 kernel。
经验:永远不要假设“我的 GPU 和集群 GPU 一样”。上线前,必须用
nvidia-smi确认集群所有节点的 GPU 型号,并在 CI 流水线中增加check-gpu-compatjob,自动验证 PyTorch wheel 是否支持目标 compute capability。
5.2 问题:/readyz返回 200,但实际请求全部超时,kubectl logs显示Connection reset by peer
现象:K8s 显示 Pod Ready,但所有/v1/predict请求在 30 秒后返回504 Gateway Timeout,Envoy 日志显示upstream reset: connection termination。
根因:Gunicorn 的timeout参数(默认 30 秒)与 Nginx 的proxy_read_timeout(默认 60 秒)不匹配,且模型推理耗时超过 Gunicorn timeout,Gunicorn 主动 kill worker 进程,导致连接重置。
排查步骤:
- 查看 Gunicorn 配置:
cat gunicorn.conf.py | grep timeout; - 查看 Nginx 配置:
kubectl exec nginx-pod -- cat /etc/nginx/conf.d/default.conf | grep timeout; - 在 Pod 内手动测试:
curl -v http://localhost:8000/readyz(确认服务进程存活),再curl -v http://localhost:8000/v1/predict(确认本地能通)。
解决方案:
- 统一超时时间:Gunicorn
timeout = 120,Nginxproxy_read_timeout 120,K8sreadinessProbe.timeoutSeconds: 120; - 关键:Gunicorn 必须启用
preload = True,否则每个 worker 进程都会重复加载模型,内存翻倍且启动慢; - 添加
--keep-alive 5参数,避免短连接风暴。
实操心得:超时设置不是拍脑袋。我们用压测工具(k6)模拟 100 QPS,记录 P99 延迟,然后设
timeout = P99 * 3。例如 P99 是 35ms,timeout 就设 105 秒,留足缓冲。
5.3 问题:特征漂移告警频繁触发,但业务反馈“效果没变差”
现象:Evidently 的feature_distribution_drift指标连续 3 天告警,KS 值 > 0.3,但 AB 测试显示新