搞离线部署的朋友应该都体会过这种滋味外面环境里明明跑得好好的服务一到内网就变得无比拧巴。依赖拉不下来、模型权重动辄几个 G、Docker 镜像在网络不好的时候传到怀疑人生。MonkeyOCRv2 这个项目当初就卡在这一环——一个内部封装的光学字符识别推理引擎模型权重加推理框架加 CUDA 底座最终打包出 15GB 的 Docker 镜像要越过互联网边界完整搬到机房内网里。听起来挺吓人但把它拆开看无非就是“构建、导出、传输、加载、调优”五件事每件事都有对应的绕坑方法。这篇文章会围绕 MonkeyOCRv2 的实际部署过程重点讲三块内容第一15GB 这么大的镜像到底是怎么一层一层搭起来的每层该装什么第二从有网环境到内网环境的离线搬运方案包括镜像压缩、拆分、Docker Registry 中转这些土办法第三镜像进了内网之后GPU 显存和推理延迟怎么调NVIDIA 容器运行时那些参数怎么配。适合正在做模型服务容器化、内网安全区部署或者只是想把 Docker 镜像折腾得更小更快的人参考。1. 内容整体设计与思路拆解1.1 先拆开看MonkeyOCRv2 这个镜像里到底装了什么很多人拿到项目就急着写 Dockerfile结果构建出来镜像跟气球一样传也传不动跑起来还各种缺库。其实正确的做法是先盘点内容。MonkeyOCRv2 从功能上拆包含五部分模型权重文件OCR 识别模型、检测模型、方向分类模型加起来大概 5 到 6GBPython 推理环境Python 3.10 解释器、PyTorch、ONNX Runtime、OpenCV、NumPy 等科学计算库装完之后 Python 环境目录能到 4GB 左右CUDA 与 cuDNN 运行时库这是 GPU 推理的基础NVIDIA 官方基础镜像本身就占了几个 GB预处理和后处理代码图像缩放、裁剪、文本行拼接、坐标转换等脚本代码量不多但依赖不少服务入口FastAPI 或 gRPC 服务、监控接口、日志收集配置。把这五块加起来15GB 并不夸张。而且这里面真正“有价值”的其实只有模型权重和你写的服务代码其余全是环境依赖。理解这一点非常重要因为后面所有优化动作都围绕一个目标让环境和权重分开管理构建的时候环境层只做一次模型权重层可以单独替换。1.2 15GB 是怎么凑出来的权重、依赖和底层库的比例我习惯用 du 命令把所有目录扫一遍搞清楚空间都花在哪了。以下是我实测的一个典型比例虽然不是绝对精确但足以说明问题内容安装后大小备注CUDA 12.1 runtime cuDNN约 5.2GB使用 nvidia/cuda:12.1-runtime 作为基础镜像Python 环境与 pip 依赖约 3.8GBPyTorch 的 CUDA 扩展占了三分之二模型权重目录约 5.5GB包括检测、识别、方向分类模型应用代码及静态资源约 0.8GBFastAPI、配置、字体文件、自定义算子系统基础工具与缓存约 0.5GBapt 缓存、pip 缓存、临时文件这里有个容易踩的坑很多人构建完镜像后发现比这个还大多出来的基本都是缓存。apt 装完的 /var/lib/apt/lists、pip 的 wheel 缓存、PyTorch 的扩展编译临时文件每一块看着不大叠加起来轻松超过 1GB。后续构建阶段里把这些清理掉镜像体积能肉眼可见地缩水。1.3 为什么选择 Docker 而非裸机部署或 conda 环境MonkeyOCRv2 一开始在开发机上用的是 conda 环境团队协作时每个人装的东西还不太一样这个版本能跑那个版本不能跑后来干脆切换到 Docker。理由很现实第一模型依赖的 CUDA 版本和宿主机的驱动不一定匹配Docker 容器里封装好 CUDA 运行库就可以避免“改一个环境把另一套服务带崩”的问题。第二内网部署往往有多个业务系统共用 GPU 服务器裸机安装一个 Python 环境可能和现有服务抢依赖Docker 的隔离机制对运维更友好。第三从开发环境到测试环境再到生产环境镜像的一致性是最容易保证的。虽然 Docker 本身也有平台差异但比在一堆裸机里手动敲命令要可靠得多。2. 15GB Docker 镜像的构建顺序与制作细节2.1 构建机上先做准备把网络环境一次性搞定构建 MonkeyOCRv2 镜像之前我建议先不要急着写 Dockerfile先去解决网络下载问题。这里说的网络问题不是“网速慢”那么简单而是 pip、apt 和模型权重下载源各自有各自的脾气。我当时在构建机上做了三件事配好 pip 国内镜像源配好 apt 的国内软件源再用环境变量把 Hugging Face 的模型下载地址改到本地缓存目录。pip 配置写在 ~/.pip/pip.conf内容无非就是指向清华或者阿里云的 PyPI 镜像apt 源写在 /etc/apt/sources.list 里。模型权重这块如果从 Hugging Face 拉不动就用镜像站或者直接在能访问外网的时候先把权重下载到本地目录构建时直接 COPY 进去。做完这三步构建过程会顺畅非常多。否则你会在同一个 Dockerfile 里反复遭遇“连接超时”“无法解析域名”“校验和不匹配”每次失败都会有部分缓存残留让人很崩溃。2.2 Dockerfile 分层策略每一层都有它该待的位置Docker 镜像的本质是一层层叠加的文件系统每一层都会占空间。MonkeyOCRv2 的 Dockerfile 我按“底层不变、中间稳定、上层易变”的原则来组织# 阶段一基础运行时层 FROM nvidia/cuda:12.1-runtime-ubuntu22.04 AS runtime ENV DEBIAN_FRONTENDnoninteractive \ TZAsia/Shanghai \ LANGC.UTF-8 RUN sed -i s//.*archive.ubuntu.com//mirrors.aliyun.comg /etc/apt/sources.list \ apt-get update apt-get install -y --no-install-recommends \ libgl1 libglib2.0-0 libgomp1 libgomp-12-dev libsm6 libxext6 libxrender1 \ libgomp1 libtinfo5 libncurses5 \ fonts-dejavu-core \ rm -rf /var/lib/apt/lists/* # 阶段二Python 推理环境层 RUN apt-get update apt-get install -y --no-install-recommends python3.10 python3-pip \ rm -rf /var/lib/apt/lists/* COPY requirements.txt /tmp/requirements.txt RUN pip3 install --no-cache-dir -r /tmp/requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple \ rm -rf /root/.cache/pip # 阶段三模型权重与应用代码层 WORKDIR /app COPY ./models /app/models COPY ./monkey_ocr /app/monkey_ocr COPY ./configs /app/configs ENV HF_HUB_OFFLINE1 \ TRANSFORMERS_OFFLINE1 \ CUDA_VISIBLE_DEVICES0 \ MKL_NUM_THREADS1 \ OMP_NUM_THREADS4 EXPOSE 8080 CMD [python3, -u, -m, monkey_ocr.server, --config, /app/configs/prod.yaml]把这个文件拆开看三层逻辑很清晰第一层 CUDA 基础镜像基本不会变构建一次可以长期复用第二层 Python 依赖除非升级版本否则也不用动第三层模型权重和服务代码是更新最频繁的。这样分层的好处是日常改代码重新构建镜像时前两层直接命中缓存只需要重新打包第三层构建速度能快很多。2.3 多阶段构建与清理秘方把 19GB 压回 15GB如果你还是觉得 15GB 太大想进一步瘦身多阶段构建是必须的。MonkeyOCRv2 里有一类依赖只需要在构建期使用比如编译一些自定义算子的 gcc、cmake、ninja这些工具一旦进了最终镜像就是白白占空间。多阶段构建的思路是从基础镜像开始装好编译工具链把 C 扩展模块编译完然后把这些编译产物复制到第二阶段干净的 runtime 镜像里。此外清理动作也要做到位我总结了一个“构建三清”清 apt 列表、清 pip 缓存、清临时文件。# 阶段一编译构建阶段 FROM nvidia/cuda:12.1-devel-ubuntu22.04 AS builder RUN apt-get update apt-get install -y build-essential cmake ninja-build WORKDIR /build COPY ./custom_ops /build/custom_ops RUN python3 setup.py bdist_wheel # 阶段二瘦身运行阶段 FROM runtime AS final COPY --frombuilder /build/dist/*.whl /tmp/ RUN pip3 install /tmp/*.whl rm /tmp/*.whl批处理流程跑完之后再进入镜像里 executedocker image inspect查看历史层记录你会发现配置文件层、缓存层的体积明显变小。实测从默认裸装 19GB 左右降到 15GB 出头传输和内网加载时间都能省不少。2.4 构建时特别要注意的固化依赖清单MonkeyOCRv2 的 requirements.txt 必须在首次构建时固定好版本。镜像构建时候看到“”这种写法就头大因为它可能会在半年后拉到一个新的小版本而新版本可能直接改掉 API。我的做法是torch2.2.2cpu # 仅用于导出GPU 环境安装时替换为对应 CUDA 版本 torchvision0.17.2 numpy1.24,2.0 opencv-python-headless4.9.0.80 onnxruntime-gpu1.17.1 fastapi0.110.0 uvicorn0.29.0这样写的好处是构建的时候不会因为依赖解析冲突而报错。尤其 numpy 这个库不同版本对 OCR 后处理里一些矩阵运算的结果有细微影响版本漂移后可能出现同样的输入不同输出的情况。3. 从构建机到内网离线传输与镜像仓库中转3.1 docker save 与 load 的坑15GB 镜像怎么挪才靠谱镜像构建完成接下来就是从外部环境往内网搬运。第一个想到的就是 docker save 加 docker load。命令很简单但坑不少。docker save monkeyocr:v2.0 | gzip monkeyocr_v2.tar.gz15GB 的镜像压缩后一般是 7 到 9GB但 gzip 压缩需要时间而且压缩过程中一旦断掉前面的计算全部白费。更稳妥的做法是直接边压缩边校验docker save monkeyocr:v2.0 -o monkeyocr_v2.tar pigz -c monkeyocr_v2.tar monkeyocr_v2.tar.gz sha256sum monkeyocr_v2.tar.gz monkeyocr_v2.tar.gz.sha256传输完成后在内网机器上先检查 sha256再解压sha256sum -c monkeyocr_v2.tar.gz.sha256 gzip -dc monkeyocr_v2.tar.gz | docker load docker images | grep monkeyocr如果单个文件太大不好传还可以用 split 拆成多块split -b 2000m monkeyocr_v2.tar.gz monkeyocr_v2_part_ cat monkeyocr_v2_part_* | gzip -dc | docker load内网环境如果是 Windows 机器加移动硬盘拆分成 2GB 左右的文件能大幅降低拷贝失败概率。不要为了省事直接往 NTFS 分区拷一个 8GB 的压缩包很容易遇到“磁盘空间不足但实际还有空间”的假报错。3.2 内网 Docker Registry 的搭建比 U 盘搬运更优雅的方案如果内网有多台 GPU 服务器U 盘或移动硬盘一个一个拷镜像显然不现实。更科学的做法是在内网找一台服务器搭 Docker Registry作为所有节点共同拉取镜像的源头。搭建本身不复杂docker run -d -p 5000:5000 --restartalways \ --name registry \ -v /data/registry:/var/lib/registry \ registry:2然后构建机上给镜像打上内网仓库标签推送docker tag monkeyocr:v2.0 10.20.30.40:5000/monkeyocr:v2.0 docker push 10.20.30.40:5000/monkeyocr:v2.0内网节点拉取时需要注意 Docker 默认只允许 HTTPS 访问 registry内网一般是 HTTP 或自签证书需要在每台节点的 Docker daemon.json 里配置 insecure-registries{ insecure-registries: [10.20.30.40:5000] }改完 daemon.json 记得重启 Docker 再拉取。这个方法对外网带宽需求不高只要能完成一次推送内网节点之间走交换机速度飞快。3.3 不依赖 docker daemon 的镜像库工具skopeo 与 regctl有些内网环境甚至不允许跑 Docker daemon只允许你放一个 registry 二进制和客户端工具。这时候 skopeo 和 regctl 这类镜像管理工具就派上用场了。skopeo 可以只拷贝镜像分层不需要本地 Docker 环境skopeo copy docker://monkeyocr:v2.0 \ docker://10.20.30.40:5000/monkeyocr:v2.0 \ --dest-tls-verifyfalseregctl 更灵活能直接操作 registry API比对镜像清单甚至可以不用下载整个镜像就获取它的元数据。对于构建验收、镜像签名校验这些场景这两个工具比 docker pull 更轻量。如果外网构建机内存不够 16GB用 skopeo 直接跨 registry 推送到内网仓库会比 docker save 再 load 更省空间因为它是流式复制层不经过本地磁盘的中转。3.4 离线加载后的完整性检查清单docker load 之后镜像亮出来并不代表万事大吉。我每次都会花五分钟做一套完整性检查能避免后面调试浪费半天检查 image 大小和构建时记录是否一致误差超过 100MB 就要警惕进入容器跑一次python -c import torch; print(torch.__version__)确认 PyTorch 是否可用跑一次torch.cuda.is_available()确认 GPU 驱动通没通加载一个最小测试图片跑一遍 OCR 推理对比输出文本是否和预期一致最后确认服务端口、日志目录、模型路径权限正常。docker run --rm --gpus all monkeyocr:v2.0 python -c \ import torch; print(cuda:, torch.cuda.is_available(), device:, torch.cuda.get_device_name(0))输出里能看到 CUDA 设备名字才说明容器能正常访问 GPU。这一步如果失败后面所有推理都是空谈。4. GPU 调优从能用变成好用4.1 摸清 GPU 家底驱动、CUDA 版本和 PyTorch 的三角关系镜像能跑起来之后才到了真正花时间的环节GPU 调优。先说驱动层。MonkeyOCRv2 镜像里的 CUDA 运行库是 12.1这要求宿主机 GPU 驱动版本足够新。NVIDIA 驱动有一个向下兼容关系驱动 525 以上才支持 CUDA 12.1。如果机器上驱动停留在 470 甚至更低容器里的 CUDA 调用会直接报“CUDA driver version is insufficient”。所以在做任何调优之前先执行 nvidia-smi 看驱动版本和显卡型号。同时用nvidia-smi -q | grep Compute Capability看显卡计算能力。计算能力决定 PyTorch 和 cuDNN 是否能发挥完整算力比如老一点的 P4 只支持到 6.1跑现在的 Transformer 类 OCR 模型会比较吃力。我的建议是如果是 30 系、40 系显卡直接上 CUDA 12.1 镜像没问题如果机房还有更老的 10 系显卡得换用 CUDA 11.x 或 10.2 的基础镜像否则就算驱动勉强支持算子性能和显存利用率也会很差。4.2 容器启动参数给 PyTorch 准备好合理的运行环境GPU 容器启动不是简单加一个--gpus all就完事。MonkeyOCRv2 里用到了多进程数据预处理和 PyTorch 的 DataLoader有一点必须处理共享内存。默认 Docker 容器的 /dev/shm 只有 64MBDataLoader 的 num_workers 稍微设大一点就会报 “Bus error” 或者 “Cannot allocate memory”。我当时用的启动命令是这样的docker run -d \ --name monkeyocr-gpu \ --gpus device0,1 \ --ipchost \ --shm-size8g \ --ulimit memlock-1 \ --ulimit stack67108864 \ -e NVIDIA_VISIBLE_DEVICES0,1 \ -e NVIDIA_DRIVER_CAPABILITIEScompute,utility \ -e CUDA_VISIBLE_DEVICES0,1 \ -e OMP_NUM_THREADS8 \ -p 8080:8080 \ -v /data/logs:/app/logs \ -v /data/model_cache:/app/.cache \ monkeyocr:v2.0这里面几个参数值得解释一下。--ipchost把宿主机的内存进程通信空间共享给容器PyTorch 多进程在 CPU 端做 tensor 收集时不会因为共享内存不够而挂掉。--shm-size8g是给容器单独挂一块共享内存如果不开 host 模式这个参数要调大。--ulimit memlock-1允许进程锁住内存避免某些 CUDA 操作因为内存换页变得奇慢无比。--gpus device0,1比--gpus all更精细可以确保只分配指定的两张卡。4.3 显存抑制让 OCR 推理不至于把整张卡占满很多 OCR 模型默认会抢占大量显存尤其是 PyTorch 的缓存分配器。默认情况下PyTorch 会把能拿到的显存全部分给自己的缓存导致同一张卡上无法再跑第二个服务。MonkeyOCRv2 在经过几个版本迭代后把显存占用从原来的 9GB 左右压到了 4.5GB 左右主要靠三个手段第一个是环境变量显式限制 PyTorch 缓存。在 Python 代码里设置import torch torch.cuda.set_per_process_memory_fraction(0.45)这会限制当前进程最多使用卡上显存的 45%虽然高并发时可能触发一次缓存整理但单路 OCR 推理的延迟完全没问题。第二个是模型精度用 FP16。OCR 检测模型对精度损失不敏感转换 FP16 之后显存占用直接减半。在推理代码里加载权重后加一行model.half()同时注意把输入图像也转成.half()。如果你的 GPU 支持 Tensor Core20 系以上基本都支持FP16 的推理速度还会明显快于 FP32。第三个是控制批大小。OCR 服务如果按业务请求单张调用把 batch_size 设置为 1 最省显存如果是批量文档识别场景可以开 batch 4 但要注意监控显存的峰值。4.4 推理延迟优化从一次 400ms 到 120ms 的调整记录我先说一下基准数据MonkeyOCRv2 在 V100 上处理一张 1080p 的文档图像全流程包括检测、方向分类、识别FP32 模式下平均耗时 400ms 左右。后来做了一轮专项调优把耗时压到了 120ms 上下。调优动作按收益排序检测和识别模型都切换到 FP16 推理耗时降约 30%固定输入尺寸。之前检测阶段会根据原图宽高比动态缩放导致每次算子形状不一致CUDA kernel 每次重新编译。设置最小边长为 960 并保持宽高比不变之后耗时再降 20%开启 cuDNN 的 benchmark。PyTorch 里设置torch.backends.cudnn.benchmark True让 cuDNN 根据输入形状选择最优卷积算法能在连续推理场景省下不少时间将识别模型的 beam search 改成贪心解码。OCR 任务里 beam search 的收益有限但开销不小贪心解码延迟低且结果几乎一致对 CPU 预处理做多线程优化。OpenCV 的 resize 和 normalize 操作默认是单线程设置cv2.setNumThreads(4)后预处理段耗时从 35ms 降到 15ms。经过这轮调整V100 单卡可以扛住每秒 8 到 10 次完整的文档识别请求显存占用不超过 6GB。如果是 A100 或者 4090还能再往上提一截。4.5 并发与多卡分配不要让卡闲着也不要让卡过载内网环境不可能只有一种业务在跑GPU 资源往往是多服务共享的。MonkeyOCRv2 的部署我建议把模型加载常数和显存占用做一次完整的压测然后据此决定并发启动几个容器实例。一个比较稳妥的分配方案是每张卡跑一个容器实例每个容器最多占用该卡 45% 的显存两个以上实例同时跑时不会互相挤爆显存。如果一台机器有四张卡那就起四个容器外部用 Nginx 做负载均衡。Nginx 里做简单的轮询就够了upstream monkey_backend { server 127.0.0.1:8081; server 127.0.0.1:8082; server 127.0.0.1:8083; server 127.0.0.1:8084; }这样好处是单容器崩溃只需要重启一个实例不影响整体服务。坏处是模型权重会同时加载四份显存占用乘以实例数。如果显存紧张可以只起两个实例每个指向不同卡权重加载两份其他资源留作他用。5. 常见问题与排查技巧实录5.1 容器启动时的 GPU 报错速查表内网部署 GPU 容器时最常见的无非下面这几类报错我整理了一张速查表基本上照着排查都能解决报错信息根本原因解决办法could not select device driver with capabilities: gpu宿主机没装 NVIDIA Container Toolkitapt install nvidia-container-toolkit重启 dockerCUDA driver version is insufficient宿主机驱动版本太低不满足容器内 CUDA 版本nvidia-smi查看驱动升级驱动或降低基础镜像 CUDA 版本CUDA error: device not yet available部分进程占用了 GPU或 CUDA_VISIBLE_DEVICES 配置了不存在的设备号清理僵尸进程确认设备编号Bus error / Cannot allocate memory容器的共享内存太小启动命令加--shm-size8g或--ipchostImportError: libcudnn.so.8: cannot open shared object filecuDNN 缺失或版本不匹配确认基础镜像是否包含 cuDNN或在最终镜像安装对应的nvidia-cudnn-cu12NVIDIA-SMI has failed because it couldnt communicate with NVIDIA driver宿主机的 nvidia kernel module 未加载执行modprobe nvidia或重启宿主机5.2 磁盘空间告警docker 镜像堆积怎么破15GB 镜像多传几次磁盘再大也会吃紧。dn层执行 docker images 看镜像列表会发现很多占空间的残余镜像有时候同一个服务不同版本堆积了上百 GB。排查思路是先跑docker system df看空间去了哪里。针对 MonkeyOCRv2 这类大镜像服务我的做法是建立镜像版本清理规则线上保留最近两个稳定版本其余镜像一律删除。容器日志改用 json-file 的 max-size 限制{ log-driver: json-file, log-opts: { max-size: 100m, max-file: 3 } }内网服务器一旦磁盘写满Docker 会因为无法写日志而强制停止容器那个状态非常难受所以磁盘水位监控一定要提前做好。5.3 内网推理结果与外网不一致的排查思路有一类问题很容易被忽视镜像明明没变内网跑出来的 OCR 文本却和外部环境不完全一致。这种问题十有八九出在依赖版本差异上尤其是 NumPy 和 OpenCV。我遇到过一次内网机器因为基础镜像时间更早装到了一个旧版 NumPymask 处理时坐标矩阵的数据类型变了导致输出框有 1 个像素的偏移结果整体识别精度下降。排查这类问题的思路是先在容器里锁定依赖版本重新导出一份新的镜像对比内外网容器中pip freeze的输出找出差异包用同一张测试图分别跑内外网容器输出 JSON 做 diff定位是哪一步开始出现偏差检查浮点精度。如果是 FP16 导致的误差调整为 FP32 后对比确认是否开启了 cuDNN benchmark某些机器上 benchmark 会启用不同的卷积实现尾数误差导致结果不同。这个过程中有个实用技巧把输入图像的前几帧保存下来输出结果也保存到文件每次镜像变更以后都跑一遍同样的样本形成回归记录。内网部署初期不太会有业务压力这时候建立回归测试最划算。5.4 构建机没联网时怎么解决 pip 离线安装有些场景更极端连构建机都只能短暂联网或者压根只有一台开发机能上网。这时需要先把所有依赖下载到本地再通过 pip 的离线安装模式装上。pip download -r requirements.txt -d /data/pip_wheels \ --only-binary:all: --platform manylinux2014_x86_64 \ --python-version 3.10 --implementation cp然后内网机器安装时使用pip install --no-index --find-links/data/pip_wheels -r requirements.txt注意--platform和--python-version一定要指定不能只写简单版。因为有些包在不同 Python 版本里编译产物不同不指明的话下载出来的 wheel 可能装不上。OpenCV、PyTorch 这类大包的 wheel 文件本身也很大下载目录建议放在一个单独的磁盘分区。6. 一点额外的部署心得MonkeyOCRv2 的这次内网部署让我最深的体会是大镜像不可怕可怕的是没有把镜像内容、构建顺序、搬运方式当成一个整体来规划。你单独去优化 Dockerfile压缩体积却忽略传输环节结果是镜像瘦了但 load 依旧失败你把重点放在 GPU 调优却忘了先验证基础依赖版本结果是服务怎么调都差口气。如果你也在搞类似的项目我建议按这个顺序自查先把模型的权重路径和依赖固定下来再做镜像分层然后设计离线导出和加载流程最后才去调 GPU 参数。每一步都靠验证往前推不要凭感觉跳步。镜像构建是项谁都能上手的工作但把它做扎实、做可复现才是能真正省下后续大量时间的地方。最后分享一个小技巧内网部署验收时别只盯着“服务起来了”不妨写一个脚本连续跑 1000 次最小推理记录延迟的 P50 和 P99 值顺便观察显存是否持续增长。显存泄漏这类问题往往不会在第一次调用时暴露但跑个几百次之后内存曲线就很诚实了。有了这套基线后续升级模型版本时你自己心里就有底了。