先看一个常见现象:一个训练好的 scikit-learn 模型磁盘上可能只有 80MB,但把它封装成 BentoML 服务并构建成镜像后,体积往往超过 2GB。多出来的空间并不是模型膨胀,而是构建阶段把训练依赖、编译器、包管理缓存和大量不会在推理路径上执行的代码一起打进了最终镜像。要解决这个问题,单靠 pip autoremove 或删缓存作用有限,更有效的是使用 Docker 多阶段构建,把构建环境和运行环境从镜像层面彻底分开。

一、BentoML镜像为什么容易膨胀
BentoML 的核心框架为了兼容多种模型格式与部署场景,安装时往往会带入一组基础依赖,例如 NumPy、Pandas、PyArrow、Protobuf、FastAPI 等。如果项目同时使用了 scikit-learn、PyTorch 或 TensorFlow,镜像体积会从几百 MB 快速上升到 2GB 以上。这还只是 Python 层面的开销;操作系统镜像中的编译工具链、头文件和包管理缓存同样占据大量空间。
Docker 的分层机制也会让体积只增不减。很多 Dockerfile 先安装依赖,再拷贝代码,最后删除缓存,但删除动作发生在新的镜像层里,前一层仍然保留着那些被删除的文件,所以最终镜像体积并没有真正下降。可以通过 docker history --human my-bentoml-image:latest 逐层查看,通常最大的层就是 pip install 和 apt-get install 产生的。
因此瘦身的第一步不是立刻改运行代码,而是先厘清依赖关系。安装 BentoML 后可以使用 pipdeptree 查看二级依赖,检查哪些包只在训练阶段需要。区分构建依赖和运行依赖,是把多阶段构建落地的前提。
docker history --human my-bentoml-image:latest pipdeptree -p bentoml
二、多阶段构建如何拆分BentoML镜像
多阶段构建的核心思想是:在一个完整的构建镜像里完成依赖安装、编译和打包,然后只把运行时真正需要的产物复制到最终镜像。BentoML 自身的 bentoml build 命令可以生成独立的 Bento 包,里面包含模型、服务代码、依赖声明和元数据,天然适合作为多阶段构建的中间产物。
典型做法是把 Dockerfile 分成两个阶段。构建阶段使用 python:3.11-slim 安装完整依赖并执行 bentoml build;运行阶段使用更小的基础镜像,只安装 Bento 包运行所需依赖,再复制 Bento 包。注意不要把整个项目目录拷贝到运行阶段,否则测试脚本、训练数据、检查点文件都会一起进入最终镜像。
# syntax=docker/dockerfile:1 FROM python:3.11-slim AS builder WORKDIR /repo COPY requirements-build.txt . RUN pip install --no-cache-dir -r requirements-build.txt COPY . . RUN bentoml build FROM python:3.11-slim AS runtime WORKDIR /app COPY requirements-runtime.txt . RUN pip install --no-cache-dir -r requirements-runtime.txt COPY --from=builder /repo/bento /app/bento EXPOSE 3000 CMD ["bentoml", "serve", "/app/bento"]
这里 requirements-runtime.txt 并不是把训练依赖直接复制一遍,而是按照推理路径重新整理。例如只保留 bentoml、numpy、scikit-learn、fastapi 等必要包,去掉 torchvision、albumentations、jupyter 这类训练阶段专用依赖。更激进的做法是在构建阶段把运行依赖打成 wheel,运行阶段直接安装 wheel,避免重复解析依赖关系。
三、基础镜像选择、依赖裁剪和 .dockerignore
基础镜像对最终体积影响明显。python:3.11 完整镜像通常超过 900MB,python:3.11-slim 约 120MB,而 python:3.11-alpine 可以做到 50MB 左右。但 BentoML 场景不建议盲目切换 alpine,因为 NumPy、OpenCV 等二进制包在 musl libc 上经常需要从源码编译,构建耗时长且容易失败。多数情况下 slim 是体积和兼容性之间的平衡点;如果服务不依赖 OpenCV、Pandas,再考虑尝试 alpine。
依赖裁剪可以从 pip freeze 开始,按用途将包标记为训练专用、预处理专用和推理必需。推理阶段用不到的包应移出运行阶段,或者放进 requirements-build.txt。同时使用 --no-cache-dir 避免 pip 缓存进入镜像,再用 .dockerignore 排除 .git、data/、notebooks/、tests/ 等目录。
.git *.md __pycache__ *.pyc .venv data/ notebooks/ tests/
BentoML 的 bentofile.yaml 同样可以控制打包内容。通过 include 和 exclude 明确打包范围,避免把原始训练数据打进 Bento 包。例如可以设置 exclude: ["data/raw/**", "notebooks/**"]。如果模型权重从 S3 或模型仓库加载,本地 Bento 包只保留配置和推理代码即可。不同版本 BentoML 的配置字段略有差异,升级前需要先核对官方说明。
四、验证瘦身效果与常见陷阱
每次调整 Dockerfile 后,都应使用 docker build 记录镜像大小,并用 docker history --human 复查最大的层。更直观的方式是使用 dive 工具逐层查看文件系统,确认到底是 /usr/local/lib/python3.11/site-packages、/root/.cache 还是模型权重占用了最多空间。如果体积大头在模型权重本身,删除 Python 包不会有明显效果,应该把模型移到镜像外,通过挂载卷或模型仓库加载。
常见陷阱之一是运行阶段缺少系统动态库。某些 Python 包虽然 pip 安装成功,但运行时依赖 libgomp.so.1、libglib2.0-0 等库。使用 slim 基础镜像时,可以通过 apt-get install --no-install-recommends 只安装必要库,并且一定要把 apt-get update、install 和 rm -rf /var/lib/apt/lists/* 放在同一个 RUN 层里,避免包管理缓存被保留在镜像中。
RUN apt-get update && apt-get install -y --no-install-recommends \
libgomp1 libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
另一个容易忽略的问题是 BentoML 的 Runner 模型。如果服务同时加载多个模型,不同模型可能依赖不同版本的 NumPy 或 Protobuf,裁剪依赖时需要一起检查版本冲突。体积优化也不是一次性动作,建议把镜像体积纳入 CI 检查流程,每次构建都输出最终镜像大小和层数变化,这样瘦身效果才能持续保持。

