Docker+K8s+TensorRT:生产环境AI推理服务部署实战
几个月前我们团队接到一个车辆外观检测模型的部署任务。开发机上模型跑得很欢单张图片推理不到30毫秒结果一到生产环境就开始翻车有的节点CUDA版本对不上有的机器缺cuDNN还有两张卡利用率死活上不去。折腾了一周才明白模型训练只是第一步真正考验人的是“把模型安全、稳定、高性能地跑在集群里”这条完整链路。今天聊的这套组合就是我在实际项目里打磨了大半年的东西Docker 管环境、K8s 管调度、TensorRT 管性能。整套工具链解决的核心问题很清楚——把“开发机能跑的模型”变成“生产环境随时能调用的高性能推理服务”。如果你正在做模型部署、推理服务开发或者需要给团队设计一套可扩展的 AI 服务底座这篇文章应该能帮你少踩不少坑。1. 容器化部署先想清楚它到底解决了什么问题很多同学一上来就急着装 Docker、搭集群、转 TensorRT但没想清楚为什么要这么干。架构师和初级工程师的差别往往就在这一步工具选型之前先把问题定义清楚。1.1 三个绕不开的部署痛点第一个痛点是环境一致性。训练时用的 PyTorch 版本、CUDA 版本、cuDNN 版本都是开发机上的固定配置生产服务器大概率不完全一样。哪怕是同一个版本的 PyTorch底层链接的 cuBLAS 版本不同推理结果都可能出现细微差异。更不用说团队里有人用 Windows有人用 Ubuntu还有人在 Mac 上写代码每次联调都像在猜谜。第二个痛点是资源调度。模型服务不是部署完就结束的业务量上来要扩容高峰过去要缩容。如果没有统一的调度层就得手动登录每台服务器改配置、起新进程半夜被叫起来扩容的滋味我试过真的不好受。GPU 这种稀缺资源更需要统一管理哪台机器还剩多少显存、谁在用哪张卡不能靠人肉记账。第三个痛点是性能利用率。很多服务“能跑”和“跑得快”是两回事。一个 ResNet 级别的模型PyTorch 默认推理可能 25 毫秒换 TensorRT 优化后可能压到 8 毫秒甚至更低。如果只部署不优化等于买了一辆跑车但一直用怠速开——GPU 的算力被白白浪费了。顺着这三个痛点往下走结论很自然环境用 Docker 隔离固化资源用 K8s 统一调度性能用 TensorRT 榨干硬件三者各管一段组合成一条完整链路。1.2 三件套的分工逻辑这三者的关系可以打个比方Docker 是“标准集装箱”把模型、依赖、环境变量全部打包进去无论运到哪个港口都能原样运行K8s 是“港口调度系统”它不关心集装箱里面装的是什么只管哪个泊位有空、什么时候装卸、需要几个工人TensorRT 是“货物压缩打包机”同样的货经过它优化之后装载效率更高、运输成本更低。所以这三件套不是互相替代的关系而是层层叠加的关系。只上 Docker 不上 K8s你只是解决了“能跑”没解决“怎么跑得稳、怎么调度”只上 K8s 不上 TensorRT你的集群调度能力很强但每台 GPU 都在低效运转只做 TensorRT 优化不上容器化模型的部署形态就非常脆弱换个环境又得重新适配。我在项目里见过不少团队上来就铺 K8s结果业务模型还是裸进程在集群外面跑GPU 调度完全没接入也有团队只研究 TensorRT 转换模型跑得是快但每次发布都要靠人肉拷贝引擎文件环境一换就崩。三条腿缺一条整个系统都走不稳。2. Docker 镜像把模型环境做成标准件容器化部署的第一步不是写 Dockerfile而是想清楚这个镜像要承担什么职责。推理服务镜像和训练镜像的关注点完全不同训练镜像要装完整的深度学习框架、各种数据增强库、分布式训练组件体积大一点没关系推理镜像追求的是体积小、启动快、依赖少、安全合规。2.1 镜像设计多阶段构建更省心我现在的做法是多阶段构建一个阶段做“编译/环境准备”另一个阶段做“运行时”。对于 TensorRT 方向的项目基础镜像通常选nvidia/cuda:12.2-devel-ubuntu22.04作为构建环境需要用到 nvcc 和 TensorRT 的头文件最终运行镜像则用更精简的nvidia/cuda:12.2-runtime-ubuntu22.04或者直接基于最小化的自定义镜像。这里有个版本对应关系要注意CUDA 版本、cuDNN 版本、TensorRT 版本、Python 版本、模型框架版本之间有兼容矩阵。网上搜“pt文件转换tensorrt”搜出一堆报错的八成是版本没对齐。我的经验是首先确定一个基准组合。打个比方CUDA 12.2 配 TensorRT 8.6全部用官方预编译包不搞源码编译这套组合是经过大量社区验证比较稳的路线。先跑通一条再想升级的事。2.2 一份可直接抄作业的 Dockerfile 示例不废话直接看我项目里一个经过线上验证的简化版本# 阶段一构建 TensorRT 相关依赖 FROM nvidia/cuda:12.2-devel-ubuntu22.04 AS builder RUN apt-get update apt-get install -y --no-install-recommends \ python3.10 python3-pip libgl1 libglib2.0-0 \ rm -rf /var/lib/apt/lists/* # TensorRT 使用 tar 包方式安装方便在最终阶段里精确拷贝 ADD TensorRT-8.6.1.6.Linux.x86_64-gnu.cuda12.0.tar.gz /opt/tensorrt RUN pip install /opt/tensorrt/python/tensorrt-8.6.1*.whl # 阶段二构建推理运行时 FROM nvidia/cuda:12.2-runtime-ubuntu22.04 COPY --frombuilder /opt/tensorrt /opt/tensorrt COPY --frombuilder /usr/local/lib/python3.10/dist-packages /usr/local/lib/python3.10/dist-packages COPY . /workspace/inference WORKDIR /workspace/inference ENV LD_LIBRARY_PATH/opt/tensorrt/lib:${LD_LIBRARY_PATH} ENV PYTHONPATH/workspace/inference:${PYTHONPATH} # 这里一定要用 exec 形式别写 shell 形式 ENTRYPOINT [python3, server.py]几个关键点展开讲一下用 ENTRYPOINT 的 exec 形式而不是 shell 形式。写CMD [python3, server.py]配合ENTRYPOINT或者干脆只写ENTRYPOINT目的是一样的让 Python 进程直接成为容器的主进程 PID 1。这样 K8s 的停止流程里SIGTERM 信号能直接送达我们的服务进程程序收到信号后可以先排空当前请求再退出。我见过不少人把启动命令写成CMD python server.py结果 K8s 滚动更新时老的 Pod 总是要等很久才能杀掉排查到最后发现是 shell 子进程没把信号传下去白白拖慢了发布速度。模型文件不要 COPY 进镜像。大模型或者 TensorRT 引擎文件动辄几百 MB 到几个 GB如果塞进镜像每次发布都要重新构建、重新推送镜像仓库压力大拉取也慢。我们的做法是把模型文件放到独立的存储目录启动时通过 Volume 挂载进来。镜像里只保留代码和依赖这样代码更新和模型更新可以互不影响各自独立发布。2.3 镜像里最隐蔽的三个坑第一个坑也是最常见的容器里打不到 GPU。Docker 本身默认不带 GPU 访问能力必须先在宿主机装好 NVIDIA Container Toolkit然后docker run的时候带上--gpus all参数。如果你发现容器内运行nvidia-smi报错“could not select device”“no such device”九成是这个工具包没装或者装了但 Docker 服务没重启。记住K8s 集群里的容器要访问 GPU也得依赖这套底层支持。第二个坑是镜像体积膨胀。如果不做多阶段、不打理依赖一个带 PyTorch、OpenCV、各种库的镜像轻轻松松到 8GB。镜像越大节点拉镜像越慢冷启动越慢出问题的概率也越高。建议是推理服务里别装训练组件torchvision如果不用也去掉OpenCV 如果只用到图像解码可以考虑用更轻的替代每次装完包记得清理 apt 缓存垃圾。第三个坑是日志和时区。容器默认没有正确的时区日志时间戳和宿主机差 8 小时排查线上问题的时候会把人绕晕日志如果没有重定向到标准输出K8s 的日志采集器就抓不到内容。这些细节看起来不起眼但到了真正出故障的时候就知道了日志不对齐会浪费好几个小时的排查时间。3. K8s 上的 GPU 调度让模型服务真正“用起来”Docker 解决的是“这一台机器上跑起来”K8s 解决的是“整个集群里跑得稳、调度得动”。这一步才是架构师和”只会写模型“的工程师拉开差距的地方。3.1 GPU 进 K8s 的第一步device plugin 机制K8s 默认不认识 NVIDIA GPU它只能调度 CPU、内存这样的标准资源。要让 K8s 能感知 GPU需要 NVIDIA 官方提供的 device plugin 组件。它的本质是一个 DaemonSet每个 GPU 节点上跑一个 Pod这个 Pod 会向 kubelet 上报节点上的 GPU 数量把nvidia.com/gpu注册成扩展资源。安装起来很简单一般来说就是通过 Helm 部署nvidia-device-plugin或者直接应用官方 YAML。装完检查两个地方看 DaemonSet Pod 全部 Running看节点资源里出现nvidia.com/gpukubectl get nodes -o json | jq .items[].status.capacity # 期望输出里有类似这样的字段 # nvidia.com/gpu: 2这一步搞不定后面写什么 YAML 都是白搭——你声明了 GPU 资源调度器不认识它Pod 就只能永远 Pending。3.2 一个最小可用的 GPU Pod 清单下面这个 Deployment 片段是我项目里的标准模板不是什么花哨配置但足够稳定apiVersion: apps/v1 kind: Deployment metadata: name: inference-server spec: replicas: 2 selector: matchLabels: app: inference-server template: metadata: labels: app: inference-server spec: containers: - name: inference-server image: registry.internal/ai/inference-server:1.4.2 resources: limits: nvidia.com/gpu: 1 memory: 8Gi cpu: 4 ports: - containerPort: 8000 env: - name: CUDA_VISIBLE_DEVICES valueFrom: fieldRef: fieldPath: metadata.annotations[nvidia.com/gpu] readinessProbe: httpGet: path: /health port: 8000 initialDelaySeconds: 30 periodSeconds: 10三个关键知识点GPU 资源只能用 limits 声明不用 requests。当前的 device plugin 实现里 GPU 不支持 requests/limits 的拆分分配调度器只看 limits。你写requests: nvidia.com/gpu: 1而没有 limitsPod 也拿不到 GPU。这是很多人踩过的问题。给容器设置物理显存上限。K8s 层面只能管“分配几张卡”管不了“卡上用多少显存”。两张卡各 24GB你只要一个 GPU程序却可能把 24GB 显存全部吃光另一个 Pod 再调上来发现显存不够直接崩溃。所以模型启动时要设置显存限制比如 TensorRT 的 workspace 上限设成显存的 80%给同卡邻居留条活路。用 CUDA_VISIBLE_DEVICES 指定设备。虽然 K8s 已经帮你指了 GPU但推理框架有时还会去遍历所有可见设备导致资源分配异常。显式设置CUDA_VISIBLE_DEVICES可以让容器内只看到分配的那张卡避免很多奇怪的行为。3.3 高可用与平滑升级的架构建议K8s 集群本身的高可用控制面至少三节点是基本门槛。如果条件允许建议用三个 control plane 节点中间通过内部负载均衡分发 apiserver 请求。工作负载层面推理服务至少 2 个副本配合 Pod AntiAffinity 让副本分散在不同节点上——一台物理机挂了流量能自动切到另一台。滚动更新也要注意安全。K8s 默认的更新策略是“先杀旧再起新”对无状态服务没问题但推理服务通常有长连接或正在处理的请求直接杀会导致客户端报错。我的做法是 service 层面加terminationGracePeriodSeconds稍微调大并在 PreStop hook 里让进程先停止接收新请求同时把当前队列里的请求处理完。这个“优雅退出”机制我在前面 Docker 部分专门说 exec 形式信号传递就是为了这里能正常生效。对外暴露服务时请求量不大可以用 NodePort 或 ExternalIP 直接暴露方便调试生产环境流量较大就上一个 LoadBalancer云环境下是 SLB自建机房就用 MetalLB 之类。这个选择取决于你的网络底座不用太纠结。4. TensorRT 优化链路把模型从“能跑”压到“跑得快”如果说完美容器化编排是骨架TensorRT 就是肌肉。这一步直接影响用户的感知延迟也直接决定一台 GPU 能扛多少路并发。4.1 从 pt 文件到 Engine 的标准转换流程目前绝大部分 PyTorch 模型拿到手都是.pt或.pth权重文件。TensorRT 不能直接吃 PyTorch 模型必须先转成 ONNXOpen Neural Network Exchange再做 ONNX 到 TensorRT Engine 的转换。标准链路四步走PyTorch 导出 ONNX在 PyTorch 里调用torch.onnx.export注意导出时指定 opset_version通常 11 到 17 都行但要和 TensorRT 版本兼容。ONNX 算子检查用onnx.checker或直接跑一次 onnxruntime 验证 ONNX 模型本身没毛病。ONNX 转 Engine用trtexec或 Python API 构建 TensorRT engine。引擎加载推理在服务代码里用 TensorRT Python/C API 反序列化 engine做实际推理。第三步是核心我习惯用 Python API比 trtexec 更好控制动态 shape 和精度策略也方便集成到自动化流程里。一个可参考的转换片段import tensorrt as trt logger trt.Logger(trt.Logger.WARNING) builder trt.Builder(logger) network builder.create_network(1 int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)) parser trt.OnnxParser(network, logger) with open(model.onnx, rb) as f: if not parser.parse(f.read()): for i in range(parser.num_errors): print(parser.get_error(i)) raise RuntimeError(ONNX parse failed) config builder.create_builder_config() config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 4 * (1 30)) # 4GB workspace config.set_flag(trt.BuilderFlag.FP16) # 动态 batch / 动态分辨率配置 profile builder.create_optimization_profile() profile.set_shape(input, min_shape(1, 3, 640, 640), opt_shape(4, 3, 640, 640), max_shape(8, 3, 640, 640)) config.add_optimization_profile(profile) engine builder.build_serialized_network(network, config) with open(model.engine, wb) as f: f.write(engine)要注意 TensorRT 各版本的 API 会有变化。比如 8.5 以前配置 workspace 用的是config.max_workspace_size8.5 之后改成了set_memory_pool_limit。你如果从网上找老代码直接贴大概率会报错“AttributeError: BuilderConfig object has no attribute max_workspace_size”。这算是转 TensorRT 时的常见坑之一。4.2 转换时值得关注的三个关键参数FP16 半精度是收益最大、成本最低的一档优化。大多数 CV 模型用 FP16 推理精度损失可以忽略速度能提升 30% 到 80%。开启方式就是上面代码里config.set_flag(trt.BuilderFlag.FP16)。注意有一类模型比如某些目标检测的 anchor 分支对精度很敏感如果发现转完 FP16 后 mAP 掉得厉害可以考虑保留 FP32 或做 INT8 量化不过那反而不适合轻量部署这里不展开。动态 shape是生产环境必须面对的。线上图片尺寸千奇百怪batch 也不固定。TensorRT 的 engine 构建时如果固定了输入 shape就只能支持那个尺寸的图像。所以优化 profile 要同时设置 min、opt、max 三个档位分别代表最低、最常见、最高的输入规模。上面代码里给的就是多 batch 的例子。动态 shape 会导致 engine 首次加载时多做一些编译优化加载时间可能比固定 shape 长一些这是正常现象。workspace 大小控制的是构建阶段 TensorRT 能用多少显存/内存去做算子融合、层规划。设置得太小TensorRT 找不到更优的算法组合性能会打折扣设置得太大构建时可能显存溢出。经验值是设为单卡显存的 40%-60%比如 24GB 卡设 8-10GB 比较稳妥。注意 workspace 是构建期占用不是运行期常驻别被字面意思误导。4.3 TensorRT 版本与老显卡的兼容性问题很多人来问“TensorRT 版本如果是 10.x 是否支持 GTX1070”这个问题它跟“我用的框架版本支不支持我的显卡”是同一种问题。我需要先把底层逻辑讲清楚TensorRT 本身是一个可执行库它不一定依赖特定架构的 Tensor Core但有些新算子和优化通道会默认硬件能力足够。官方发布说明里较新的 TensorRT 版本仍会把 Pascal 架构也就是 GTX 10 系列列入支持范围但支持方式可能偏保守新特性全部关闭或降级部分新算子无法做深度融合只能走 CUDA 兜底实现。换句话说Engine 能构建、能运转但性能提升幅度可能没有新卡那么夸张。所以判断方法很简单如果你必须在 GTX1070 上跑最新版 TensorRT 10.x先用trtexec跑一次基准实测吞吐和延迟不建议盲信官方支持列表。如果项目还没锁定版本老显卡建议用 TensorRT 8.6 或 9.x 更稳妥配套 CUDA 11.8/12.0 都是验证过的组合。如果 onnx 里有比较新的算子比如某些包装过的 GroupNorm 或 Transformer 类算子在旧显卡套路下可能直接报错这时候要么升级显卡驱动和 CUDA runtime要么把这些算子改写成兼容实现。还有一个更隐蔽的坑TensorRT engine 是和版本绑死的。同一个 ONNXTensorRT 8.6 生成的 engine 换到 TensorRT 10.x 环境里会直接报incompatible/ API version 不匹配。所以 engine 文件必须跟镜像版本一起走 CI/CD 发布不能把 engine 当成一个独立产物到处拷贝。这一步不注意线上环境分分钟出岔子。4.4 转换后的精度与性能验证转换完成后别急着宣布“优化成功”先做两项检查精度验证准备一批固定输入分别用 PyTorch 原模型和 TensorRT engine 推理对比输出。分类模型直接比对 softmax 结果差异检测模型则对比框的坐标和置信度。设定合理阈值比如输出的绝对误差小于 1e-2 或相对误差小于 1%。如果偏差超标检查有没有用错均值方差归一化、有没有把动态 shape 配错。性能验证先用trtexec做基础摸底比如trtexec --loadEnginemodel.engine --fp16 --shapesinput:1x3x640x640 --warmUp1000 --best能直接看到 Throughput 和 mean latency这个结果可以近似估算单卡的单路性能峰值。但真实服务往往有网络传输、请求排队、反序列化等额外开销所以最终以端到端 HTTP 压测结果为准。我见过不少项目 trtexec 显示性能很好一到服务端就拉胯问题都出在预处理和后处理没跟上。5. 端到端联调压测、监控、故障速查所有组件都装好、模型也优化完还不能说上线。必须做一次完整的端到端演练把所有层面的问题一次性炸出来。5.1 压测里容易忽略的两个设置压测工具很多人用 wrk 或 ghz 去打接口做压力测试。这里有两个容易被忽略的设置第一个是预热请求。TensorRT engine 首次推理时要执行 CUDA context 初始化、kernel 加载耗时可能是正常推理的几十倍。压测如果不提前预热先发的那部分请求会吃掉大量超时时间把 P99 拉得很高你以为服务有问题其实是没预热。通常在服务启动时加载 model.engine 后会主动跑 3-5 次空推理让 GPU 先“醒过来”。第二个是并发模型。推理服务往往是 batch 服务单请求进来先排队攒够 batch 后才送 GPU 推理。所以压测要区分“单路吞吐”和“多路并发吞吐”。并发拉高以后P99 延迟可能会非线性上升这时候要结合 GPU Util 和显存占用来判断瓶颈到底在网络层、预处理层还是 GPU 层。盲目的加并发只会制造一堆排队请求让整体延迟恶化并不会直接提高吞吐。5.2 常见故障速查表把我在多个项目里遇到的高频问题整理成一张表每一条都是真实踩过坑的现象可能原因解决方式Pod 一直 PendingGPU 资源不足或节点没有上报 nvidia.com/gpu检查 device plugin DaemonSetkubectl describe node看是否有资源确认节点驱动和容器运行时支持 GPU容器内 nvidia-smi 报错找不到设备NVIDIA Container Toolkit 未安装或 Docker 未重启宿主机装nvidia-container-toolkit后重启 Docker重新拉起 PodEngine 加载报版本不兼容TensorRT 版本或 CUDA 版本与构建时不匹配engine 必须重新构建或统一通过镜像版本固定环境推理时 CUDA OOM显存被其他进程占用或 workspace 配得太大限制 Pod 显存模型内部限制 workspace考虑整卡独占调度首个请求延迟特别高GPU context 初始化 / engine 加载启动预热加载后先空转几次再开流量滚动更新时老请求大量超时进程未优雅退出信号没被处理改造 ENTRYPOINT 为 exec 形式、实现 SIGTERM 处理逻辑、调大 terminationGracePeriodSeconds这套表不是全部但覆盖了我碰到过八成的生产事故。你把这些问题提前消化掉上线的时候会睡得踏实很多。5.3 踩出来的落地顺序建议刚开始接触这套工具链的同学我建议别贪心按这个顺序推进第一步先把模型用 TensorRT 在单机 Docker 里跑通直接对比优化前后的延迟和吞吐确认这一步有收益再进下一步第二步部署一台 Node 的实验 K8s 集群把 GPU device plugin 装好确认 Pod 能拿到卡第三步再把服务改成 Deployment 形态做到滚动更新不断流最后一步才考虑高可用、监控告警、自动扩容这些进阶内容。这个顺序的意义在于每一步都有一个明确的验收标准不会把多个问题搅在一起排查时无从下手。我自己就是把“先单独验证再整体打通”吃透了才没有掉进“处处都在改、处处都改不动”的死循环。最后分享一个体会这套工具链组合起来看似东西很多但真正给你带来价值的不是某个单一组件的花活而是把“部署环境”这件脏活累活变成了一条稳定流水线。有一次线上 GPU 节点故障K8s 自动把服务刷到了备用节点TensorRT engine 因为版本一致直接拉起业务无感切换。那一刻你会觉得前面花在镜像整理、配置文件打磨上的时间全都值了。