容器化部署:Docker打包LLM应用的最佳实践
从一次凌晨三点的事故说起
凌晨三点,手机震得我直接从床上弹起来。线上LLM推理服务挂了,日志里只有一行“OOM Killer terminated process”。我盯着屏幕骂了句脏话——明明本地测试好好的,怎么一上生产就崩?
后来排查发现,问题出在Docker镜像里。我图省事,把整个conda环境连同几十个G的模型权重一股脑塞进镜像,结果容器启动时内存直接爆了。更蠢的是,我连模型文件都没做分层处理,每次重新部署都要重新下载整个镜像,团队同事看我的眼神都不对了。
从那以后,我花了整整两周重构了LLM应用的Docker化方案。今天这篇笔记,就是那次事故后沉淀下来的血泪经验。
镜像瘦身:别把整个家当都装进去
很多新手打包LLM应用时,习惯性用pip freeze > requirements.txt然后COPY整个项目目录。这做法在普通Web应用上勉强能跑,但LLM应用动辄几个G的模型文件,这么搞就是找死。
正确的做法是分层构建。模型权重这种几乎不变的大文件,单独放在一个基础镜像层里。代码和依赖这种频繁更新的小文件,放在上层。这样每次更新代码时,Docker只需要重新构建上层,模型层直接复用缓存。
看个实际例子,我现在的Dockerfile长这样:
# 基础镜像选轻量的,别用python:3.10-slim这种带一堆工具链的 FROM python:3.10-slim AS base # 先装系统依赖,这里踩过坑——apt-get update和install要写在同一行 # 否则Docker会缓存update层,导致后续安装失败 RUN apt-get update && apt-get install -y \ libgomp1 \ && rm -rf /var/lib/apt/lists/* # 单独一层放模型文件,注意这里用COPY而不是ADD # ADD会自动解压tar.gz,但模型文件不需要解压,反而可能出问题 FROM base AS model-layer COPY ./models /app/models # 代码和依赖放上层,这样改代码不用重新下载模型 FROM model-layer AS app-layer WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY ./src /app/src # 最后设置非root用户运行,别问为什么,安全 RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app USER appuser CMD ["python", "src/serve.py"]这个Dockerfile构建出来的镜像,从原来的12G瘦身到3.5G。更关键的是,改代码时构建时间从15分钟缩短到30秒。
内存管理:别让模型把容器撑爆
LLM推理最头疼的就是内存。模型加载时,显存和内存的消耗是动态的,Docker默认的--memory限制如果设得太死,模型加载到一半直接OOM;设得太松,又可能把宿主机资源吃光。
我的经验是分两步走。
第一步,在Dockerfile里设置环境变量,让模型框架自己控制内存:
# 这里别这样写:ENV OMP_NUM_THREADS=4 # 应该根据容器CPU核数动态设置,否则换机器就崩 ENV OMP_NUM_THREADS=${NPROC:-4} ENV MALLOC_TRIM_THRESHOLD_=131072 ENV PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128第二步,在docker-compose或K8s配置里,给容器设置合理的资源限制。我一般这样配:
services:llm-service:image:llm-app:latestdeploy:resources:limits:memory:8Gcpus:'4'reservations:memory:4Gcpus:'2'environment:-CUDA_VISIBLE_DEVICES=0-MODEL_CACHE_DIR=/app/modelsvolumes:-model-cache:/app/models注意那个reservations字段,它告诉调度器这个容器至少需要4G内存,但允许它用到8G。这样既保证了模型加载时有足够的缓冲,又不会无限制地吃资源。
模型加载优化:别每次都重新加载
LLM应用启动慢,很大一部分原因是模型加载。一个7B的模型,从磁盘加载到显存,少说也要30秒。如果每次容器重启都来一遍,用户体验直接爆炸。
解决方案是使用卷挂载。把模型文件放在宿主机的一个目录里,通过Docker volume挂载到容器内。这样容器重启时,模型文件还在,不需要重新下载。
但这里有个坑——模型文件权限。我之前遇到过,容器内用户是appuser(UID 1000),但宿主机上的模型文件是root所有,导致容器启动时权限不足,模型加载失败。
解决办法是在docker-compose里指定用户ID:
services:llm-service:user:"1000:1000"volumes:-/data/models:/app/models:roro表示只读挂载,防止容器意外修改模型文件。
日志和监控:别等出事了才看
LLM推理服务的日志量巨大,每个请求的输入输出、推理时间、显存占用,如果不加控制,几天就能把磁盘撑爆。
我的做法是使用Docker的日志驱动,限制日志文件大小和数量:
services:llm-service:logging:driver:"json-file"options:max-size:"10m"max-file:"3"同时,在应用代码里,只记录关键信息。比如推理时间超过5秒的请求才打日志,正常请求只记录一个计数器。
另外,强烈建议在容器内暴露健康检查接口。Docker的HEALTHCHECK指令可以定期检查服务是否存活:
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ CMD curl -f http://localhost:8080/health || exit 1--start-period=60s这个参数很重要,它告诉Docker给模型加载留出60秒的缓冲时间,避免启动阶段误报。
多阶段构建:把调试和生产分开
开发环境和生产环境的需求完全不同。开发时你需要调试工具、测试数据、甚至Jupyter notebook;生产环境只需要最小化的运行环境。
多阶段构建就是干这个的。看这个例子:
# 第一阶段:开发环境 FROM python:3.10-slim AS dev WORKDIR /app COPY requirements-dev.txt . RUN pip install -r requirements-dev.txt COPY . . CMD ["python", "-m", "debugpy", "--listen", "0.0.0.0:5678", "src/serve.py"] # 第二阶段:生产环境 FROM python:3.10-slim AS prod WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY --from=dev /app/src /app/src COPY --from=dev /app/models /app/models USER appuser CMD ["python", "src/serve.py"]构建时指定目标阶段:
# 开发用dockerbuild--targetdev-tllm-app:dev.# 生产用dockerbuild--targetprod-tllm-app:prod.这样开发镜像里可以装debugpy、pytest、ipython,生产镜像里干干净净,只保留运行所需的最小依赖。
网络配置:别让API请求超时
LLM推理通常需要GPU,而GPU设备在Docker里需要通过--gpus参数暴露。但很多人忽略了一个问题——容器内的CUDA版本和宿主机驱动版本必须匹配。
我的经验是使用NVIDIA官方的基础镜像,比如nvidia/cuda:12.1-runtime-ubuntu22.04,这样CUDA版本和驱动版本都帮你配好了。
另外,LLM推理的API响应时间通常较长,Docker默认的请求超时时间可能不够。在docker-compose里显式设置:
services:llm-service:ports:-"8080:8080"environment:-REQUEST_TIMEOUT=120deploy:resources:reservations:devices:-driver:nvidiacount:1capabilities:[gpu]那个deploy.resources.reservations.devices字段,是Docker Compose v3.8之后支持的新语法,比--gpus更灵活。
个人经验性建议
永远不要在生产环境用
:latest标签。我见过太多人图省事,结果某天拉了个新镜像,模型格式变了,服务直接挂掉。用语义化版本号,比如v1.2.3,每次发布都打tag。模型文件单独管理。别把模型文件放在镜像里,用对象存储或者NFS挂载。这样换模型版本时,只需要改挂载路径,不用重新构建镜像。
容器内不要跑多进程。LLM推理本身就很吃资源,再跑个监控进程、日志收集进程,很容易互相干扰。每个容器只跑一个进程,其他功能交给K8s的sidecar模式。
启动脚本里加个重试机制。模型加载偶尔会因为显存碎片化失败,写个简单的重试逻辑,失败后等5秒再试,能省去很多半夜被叫醒的痛苦。
最后一条,也是最重要的——Docker化不是银弹。如果你的LLM应用需要频繁更新模型、或者需要动态调整推理参数,考虑用K8s的StatefulSet配合持久化存储,别硬塞进Docker里。
那次凌晨三点的事故之后,我把这套方案写成了内部文档,团队里再没人因为Docker部署LLM出过问题。希望这篇笔记也能帮你少踩几个坑。