OCR服务离线部署实战:15GB镜像瘦身与GPU调优全记录
上个月客户那边来消息说新到的一批OCR业务服务器全部放在隔离机房里网络策略很严不允许出网访问任何外部资源要我们把 MonkeyOCRv2 这套识别服务以离线方式部署进去。当时我的第一反应是镜像一共15GB构建和部署本身不复杂真正麻烦的是这条链路要跨过三道坎——镜像怎么稳定搬进内网容器怎么在隔离环境里识别GPU以及GPU识别速度能不能压到业务能接受的线。下面就是我把三道坎一一踩平的完整记录。1. 一个OCR服务为什么会把镜像撑到15GB1.1 MonkeyOCRv2 到底是一个什么样的服务MonkeyOCRv2 是我们基于多模态视觉语言模型封装的一套文档理解服务。它和传统OCR最大的区别是不再走先检测文字框、再逐个识别的两段式路线而是把整张图片直接交给模型进行端到端理解。输入一张带标题、表格、手写批注的混合版面A4图输出的是结构化文本版面的相对顺序能基本保持住。这个特性在做合同抽取、票据归档、企业知识库批量录入的时候非常有用因为传统OCR遇到复杂版面往往要先费很大力气做版面分析而我们只需要把图丢进去、拿结果。但代价也很直接模型权重大了不少。MonkeyOCRv2 底层包含视觉编码器和语言模型两部分光 BF16 精度的模型权重就有 6GB 以上再加上推理时要用到的 PyTorch、CUDA 依赖、tokenizer 和配置文件整个镜像体积被推到了 15GB。这里先给一个结论15GB不是乱堆出来的每一个GB都有明确归属。如果你也打算做类似的离线OCR服务最好先搞清楚体积构成否则后面做离线搬运时会很被动。1.2 镜像体积拆解15GB从哪里来我在构建机上执行过docker images之后把镜像里的层逐个分析了一遍体积构成大致如下组成部分体积说明基础镜像 CUDA 12.1 runtime Ubuntu 22.04约2.8GB提供GPU运行库和操作系统基底Python依赖 PyTorch 相关库约4.2GB包括torch、torchvision、transformers、opencv等模型权重与tokenizer约7.5GBBF16精度权重包含视觉编码器和语言模型推理服务代码与资源文件约1.0GB服务入口、配置文件、字库、临时处理脚本其他杂项约0.5GB系统证书、locale、时区等这里最关键的一点是基础镜像我没有选择 CPU 版 Python 镜像而是直接用 CUDA runtime 镜像。原因很简单MonkeyOCRv2 的推理是强依赖 GPU 的虽然 CPU 也能跑但在真实业务图片上单张耗时动辄几十秒根本没法用。与其到内网再折腾 GPU 运行库不如在构建阶段就把 CUDA 相关依赖全部打进镜像里运行机上只需要有 NVIDIA 驱动和容器运行时就够了。1.3 哪些东西不参与构建镜像瘦身的第一步很多人在构建镜像时容易忽略一类东西不参与构建的资源却因为没被排除而被一并打进了镜像。我第一次构建 MonkeyOCRv2 镜像时镜像体积直接到了 21GB查了半天发现是构建上下文里带了测试图片集、标注JSON、README文档和git历史目录。这些文件对服务运行没有任何用处却全部被 Docker 打包进了镜像。解决方式是两件事一是在项目根目录放.dockerignore把tests/、docs/、.git/、*.pth.bak这类内容全部排除二是用多阶段构建在构建阶段安装编译工具链最终运行镜像里只拷贝编译好的产物和运行依赖。这一步做完镜像从21GB降回15GB。别小看这6GB在内网用移动硬盘拷贝时少6GB意味着少跑将近一个小时传输时间也意味着更不容易在搬运过程中出错。2. 从外网构建机到内网机房镜像搬运的完整链路2.1 构建机上的环境准备要离线交付首先需要一台有外网权限的构建机。我这里为了方便操作直接用了一台Linux服务器磁盘剩余空间留了80GB以上内存32GB。这里提醒一句构建15GB级别的镜像磁盘空间不能只按15GB算。Docker构建缓存、临时层、基础镜像解压、pip下载缓存都会占用额外空间我实际构建时峰值占用接近40GB。构建机上需要安装 Docker 20.10 以上的版本并启用 BuildKit。对大镜像构建来说BuildKit 的并发层处理和缓存复用比传统 builder 强很多。另外如果构建机能访问公网建议在docker build时加上--networkhost这样 apt 和 pip 下载速度会快不少。不过要注意这个参数只在构建阶段生效不会把宿主网络模式写进镜像内网运行不受影响。还要确认一点构建机不需要安装 NVIDIA Container Toolkit因为我们只是构建镜像不做GPU验证。但如果你的构建机刚好也有 NVIDIA 显卡并且想在交付前快速验证镜像能跑通那就需要装。我们这次是先在构建机上用一张测试图验证通过再开始打包的。2.2 Dockerfile 写法把变化最慢的层放前面权重单独一层镜像体积大不代表没有优化空间。Dockerfile 的层顺序会直接影响后续的更新和搬运效率我的核心思路是变化频率越低的内容越往前面放变化最频繁的模型权重单独做一层。这样可以最大限度复用缓存。当时用的 Dockerfile 结构大致如下FROM nvidia/cuda:12.1.1-runtime-ubuntu22.04 # 系统库这一层几乎不需要变动 RUN apt-get update apt-get install -y --no-install-recommends \ libgl1 libglib2.0-0 libgomp1 libssl3 curl \ rm -rf /var/lib/apt/lists/* # Python 依赖层只有 requirements.txt 变化时才重新构建 COPY requirements.txt /opt/monkeyocr/ RUN pip install --no-cache-dir -r /opt/monkeyocr/requirements.txt # 推理代码层 COPY src/ /opt/monkeyocr/src/ COPY entrypoint.sh /opt/monkeyocr/ # 模型权重单独一层后续模型迭代时只覆盖这一层 COPY models/ /opt/monkeyocr/models/ WORKDIR /opt/monkeyocr CMD [bash, entrypoint.sh]把模型权重单独 COPY 的好处是当模型从 v2.0 迭代到 v2.1只需要重新构建权重这一层前面系统层、Python依赖层都可以复用缓存。后面在离线环境做镜像更新时这个分层还能配合内网 Registry 做增量推送不会每次更新都传15GB。2.3 镜像导出、压缩与校验镜像构建好之后离线搬运最常见的方案是docker save导出tar包。这里有一个细节值得专门说docker save出来的是未压缩的 tar 文件15GB镜像导出就是15GB直接拿去内网非常吃亏。我做了一步 gzip 压缩整个包能压到 8GB 左右。docker save registry.internal/monkeyocr:v2.1 | gzip -1 monkeyocr_v2.1.tar.gz sha256sum monkeyocr_v2.1.tar.gz monkeyocr_v2.1.sha256关于压缩等级我建议用gzip -1而不是-9。因为镜像里的大头是模型权重权重文件本身已经是接近压缩状态用-9压缩率高不了多少但构建机要多等很久。如果镜像包超过单文件传输限制可以用split命令分卷split -b 2000m monkeyocr_v2.1.tar.gz monkeyocr_part_生成的分卷文件会带上固定前缀传到内网后再合并。2.4 内网导入后的第一道坑Docker版本与GPU支持镜像包搬到内网之后第一次docker load还算顺利但紧接着运行容器就报错了。这里把完整的排查链路写出来方便你直接对照。执行导入cat monkeyocr_part_* monkeyocr_v2.1.tar.gz sha256sum -c monkeyocr_v2.1.sha256 docker load -i monkeyocr_v2.1.tar.gz启动容器时用了--gpus all结果报错unknown flag: --gpus。问题很明确内网机器的 Docker 版本太老不支持 GPU 参数。这个坑如果不在导入前发现部署进度会被拖很久。解决思路是先看版本再决定是升级 Docker 还是用旧方案。docker version20.10 之前的版本对--gpus支持不完整最简单的处理是把 Docker 升级到 20.10 以上。如果内网不能随便升级系统组件也可以用 nvidia-container-runtime 启动但配置上会更绕。我和客户确认后直接在内网机器上离线安装了新版 Docker顺利解决了。3. GPU 调优从能识别到压得起并发3.1 先用最简参数摸清基线镜像能启动之后我先用一组保守参数把服务跑起来做了一次基线测量。这里给出的启动命令基本可以作为参考模板docker run -d --name monkeyocr \ --gpus all \ --shm-size8g \ --restartalways \ -p 8080:8080 \ -e PYTORCH_CUDA_ALLOC_CONFmax_split_size_mb:128 \ -v /data/monkeyocr/logs:/opt/monkeyocr/logs \ registry.internal/monkeyocr:v2.1这里解释两个关键参数。--shm-size8g是给容器共享内存扩容PyTorch 的 DataLoader 在数据预处理时会用到共享内存默认64MB很容易爆。PYTORCH_CUDA_ALLOC_CONFmax_split_size_mb:128是设置 PyTorch 显存分配器的拆分粒度对大模型推理特别有用后面会展开说。基线测试我用了同一张标准测试图A4混合版面包含标题、表格、手写批注分辨率为 1240×1754。在三种卡上分别测了单张端到端延迟GPU型号显存单张延迟GPU利用率备注NVIDIA A1024GB2.1s68%算力够用但带宽偏弱RTX 409024GB1.35s55%性价比高但机房供电和散热要确认A100 40GB40GB0.82s72%高带宽优势明显延迟最低这里先说明数字不是万能标尺但趋势可以参考MonkeyOCRv2 这类视觉语言模型对显存带宽非常敏感所以 A100 和 4090 的差距主要来自带宽而不是单纯算力。如果业务并发不高、追求单张速度4090 够用如果后面要做较高并发A100 这类卡优势会更大。3.2 显存分配策略碎片化和并发限制第一次压测就遇到一个比较隐蔽的问题单张推理时显存占用正常但连续几张之后显存峰值越来越高最终 CUDA OOM。原因是多模态模型在处理不同分辨率图片时会动态申请大块显存频繁申请释放后出现碎片PyTorch 默认分配器对这种情况处理得不够好。我的处理是在启动命令里加了PYTORCH_CUDA_ALLOC_CONFmax_split_size_mb:128之后的实测效果很明显单并发峰值显存从 17GB 降到 14.8GB连续推理几十张都没有再出现 OOM。另外如果内网机器显存只有 24GB一定要在服务层控制并发数。我建议在接口代码里加一个信号量限制同时推理的请求数量。例如服务配置里max_infer2表示最多允许两个请求同时在 GPU 上跑其他请求排队。这样做的好处是避免多个请求同时申请显存导致 OOM也避免容器崩溃后需要重新加载模型反而更慢。3.3 推理参数和请求合并把小图拼成大BatchMonkeyOCRv2 的模型默认是单图推理但实际业务里有很多是小图比如身份证照片、票据局部图。把这些小图在服务层合并成一个 batch可以显著提升吞吐。我用三种配置对比过配置平均单张耗时吞吐量张/分钟显存峰值batch11.35s4414.8GBbatch41.02s5818.2GBbatch80.95s6322GBbatch8 时显存已经逼近 24GB 上限所以生产环境我推荐 batch4兼顾吞吐和稳定性。另外把几个环境变量也一并设置了效果比默认配置要好OMP_NUM_THREADS4需要提醒的是这个变量不是越大越快。我特意对比过OMP_NUM_THREADS 设置成 CPU 核数时CPU预处理和GPU推理会互相抢资源端到端延迟反而比4线程高了约20%。所以如果内网机器是 32 核甚至 64 核不要想当然全开先试 4、8、16 三档选最稳的。精度方面BF16 是我日常使用的配置。对比 FP32BF16 的端到端延迟能降低 40% 左右识别精度在表格抽取上几乎没有肉眼可见的差异。INT8 量化我没有上生产因为多模态模型的量化对表格线、手写体的细节影响比较大出现过框线漂移问题所以不推荐一上来就动量化。3.4 进一步压榨TensorRT 与显存监控如果 1 秒级别的延迟还不够下一步可以考虑 TensorRT。我把 MonkeyOCRv2 的视觉编码器部分导成 TensorRT 试过但整个模型端到端的算子回退太多实际收益不稳定后来就没有在生产用。如果你有这个需求建议先只加速视觉编码器语言模型部分维持 PyTorch改动风险小很多。监控方面推荐在宿主机上用nvidia-smi dmon记录 GPU 利用率、显存和温度不要只盯着 dockerd。我在容器内部还加了一个小技巧启动一个后台线程定期调用torch.cuda.reset_peak_memory_stats()然后把峰值显存和请求ID一起打到日志里。这样压测时能快速定位是哪条请求触发了显存尖峰。4. 生产落地不能漏的几件事4.1 健康检查与自愈镜像能跑、速度也达标之后还不能马上交付因为生产环境里容器随时可能挂。我给 Dockerfile 加了一段 HEALTHCHECKHEALTHCHECK --interval30s --timeout5s --start-period60s --retries3 \ CMD curl -f http://localhost:8080/healthz || exit 1这里特别要注意--start-period60s。MonkeyOCRv2 启动时要把 7GB 多的模型权重加载进显存这个阶段即使进程活着HTTP 接口也可能还没就绪。如果 start-period 太短容器会被反复标记为 unhealthy 然后被编排系统杀掉造成启动循环。配合--restartalways进程意外退出后 Docker 会自动拉起。不过要提醒一句如果容器是因为 CUDA OOM 崩溃的自动重启后显存可能没有立刻释放第二次启动可能继续失败。所以服务层的并发限制比容器重启机制更前置、更重要。4.2 日志与监控默认的docker logs是 json-file 驱动不限制大小的话一个长期运行的服务能把磁盘塞满。我在内网机器的/etc/docker/daemon.json里加了{ log-driver: json-file, log-opts: { max-size: 100m, max-file: 3 } }业务日志也要打全。我在每个请求里都记录了请求ID、图片尺寸、模型版本、推理耗时和显存峰值。内网出问题时没有这些字段会非常被动。GPU 监控不需要装太复杂的东西我直接用宿主机定时任务跑nvidia-smi --query-gpuutilization.gpu,memory.used,temperature.gpu --formatcsv把结果追加到一个文件里足够定位绝大多数问题。4.3 离线环境的镜像更新策略如果内网有多台机器一个一个docker load会很痛苦。我建议在内网先搭一个私有 Registry比如跑一个registry:2容器然后把镜像推到内网 Registry各节点从 Registry 拉取。这样做的最大好处是镜像层是去重的。后续模型更新时只有模型权重那层变化了各节点从内网 Registry 拉取时只需要传输增量层不需要每次都搬运完整15GB。命令可以这样docker tag registry.internal/monkeyocr:v2.1 192.168.10.20:5000/monkeyocr:v2.1 docker push 192.168.10.20:5000/monkeyocr:v2.1节点上执行docker pull 192.168.10.20:5000/monkeyocr:v2.1这里有一条原则不要在运行中的容器上docker commit来打补丁。commit 会把容器运行期间的临时文件、日志、脏数据全部打进新镜像镜像会越来越肿而且层结构不可控。正确的做法是改代码或权重后重新构建。4.4 多机分发的几种方式对比最后把几种方式放在一起对比一下内网场景选型会更清楚分发方式适用场景缺点tar包逐台load机器少、网络隔离严格每台都要手动操作大包传输时间长内网Registry机器多、后续会更新需要额外搭一个Registry容器共享存储挂模型权重权重特别大、不想重新发镜像对存储网络延迟有要求第一次加载可能变慢我们最后还是选择了内网 Registry 加 tar 包混合的方案第一台机器用 tar 包导入同时把镜像 push 到内网 Registry其余机器直接从 Registry 拉。这样既保证第一台能快速跑通也方便后面节点同步。5. 一些绕不开的小坑和至今还在用的经验5.1 内网机加载镜像失败的常见原因清单内网环境千奇百怪我这次遇到和听同行提到最多的几个问题整理成了一张清单现象原因操作docker load报no space left on device磁盘空间或inode不足先清理/var/lib/docker和日志文件tar包传输后sha256不匹配移动介质传输损坏分卷传输后务必做校验docker load成功但run报manifest unknownDocker版本太旧升级Docker或在构建时兼容旧版manifestU盘无法拷入4GB以上文件FAT32文件系统限制用exFAT格式或split分卷容器内nvidia-smi已装但识别不到GPU容器运行时配置问题检查daemon.json里的runtimes配置最后一个问题特别容易被忽略镜像里自带 NVIDIA 相关文件但 Docker 本身没有配置 nvidia runtime所以--gpus all会静默失败或报错。我建议在跑业务容器之前先用以下命令验证 GPU 容器链路docker run --rm --gpus all nvidia/cuda:12.1.1-runtime-ubuntu22.04 nvidia-smi如果这步能正常输出 GPU 信息再启动 MonkeyOCRv2 也不迟。5.2 我还是建议保留一台带外构建机内网离线部署做一次并不是终点。模型精度迭代、镜像安全补丁、依赖库更新这些需求大概率在部署后两三个月内就会出现。如果每次都要重新摸索一遍离线链路成本太高。我的做法是在能访问公网的办公区保留一台专用构建机所有镜像都在这台机器上构建、打tag、导出内网机只负责运行。版本号一定固定不要用latest标签。我们早期用latest导致内网两台机器的镜像内容不一致排查了整整一个下午。现在所有交付镜像都是类似registry.internal/monkeyocr:v2.1.3这种完整版本号内网 Registry 里也存一份后续回滚也方便。如果下次再做一次完整部署我会优先把模型权重单独作为一个数据卷镜像推理镜像只保留代码和依赖。这样 MonKeyOCRv2 的可执行镜像能从15GB降到5GB以内权重更新也只需要在内网 Registry 里同步一个新层。这个优化思路这次没有全部落地主要是时间紧张但确实是内网大镜像交付值得认真做的一步。