4.1 训练任务沙箱化:多卡协同、数据卷与日志留存 把一次大模型训练塞进 Docker 沙箱,最容易让人翻车的不是"怎么装上 CUDA"——那是第二章已经解决的环境问题——而是三个不起眼但会致命的点:多卡之间说不了话、训练数据把容器文件系统撑爆、跑了一整晚的进度在容器一关就全没了。这一节我们不堆概念,而是围绕这三个真实痛点,把一个朴素训练命令一步步改造成生产可用的沙箱写法,并进一步扩展到多机、监控、compose 固化等工程现场才会遇到的问题。 起点:一个朴素但会出事的训练命令 假设你有一个 PyTorch 训练脚本,最朴素的跑法是在宿主直接执行: 你想要的是"环境隔离、换机器也能跑",于是把它塞进容器: 这条命令能跑起来,但埋了三颗雷,下面逐个拆。先给你一张总览,省得读到后面忘了主线。
把一次大模型训练塞进 Docker 沙箱,最容易让人翻车的不是"怎么装上 CUDA"——那是第二章已经解决的环境问题——而是三个不起眼但会致命的点:多卡之间说不了话、训练数据把容器文件系统撑爆、跑了一整晚的进度在容器一关就全没了。这一节我们不堆概念,而是围绕这三个真实痛点,把一个朴素训练命令一步步改造成生产可用的沙箱写法,并进一步扩展到多机、监控、compose 固化等工程现场才会遇到的问题。
假设你有一个 PyTorch 训练脚本,最朴素的跑法是在宿主直接执行:
python train.py --data /data/imagenet --epochs 50 --output /models/ckpt
你想要的是"环境隔离、换机器也能跑",于是把它塞进容器:
docker run --gpus all \ -v /data/imagenet:/data \ -v /models:/models \ my-training-image \ python train.py --data /data --epochs 50 --output /models/ckpt
这条命令能跑起来,但埋了三颗雷,下面逐个拆。先给你一张总览,省得读到后面忘了主线。
单卡训练没问题。一旦 --gpus all 给了两张以上 GPU,多卡训练(DDP / torchrun)依赖 GPU 间通信,最常见的是 NCCL 通过共享内存(shared memory / /dev/shm)做进程间通信。而 Docker 默认给容器的 /dev/shm 只有 64 MB,多卡 NCCL 一上来就会报 bus error 或卡死在初始化,这是新手被卡得最久的一个坑。
真实可行的写法有两种,按场景选:
--shm-size=16g(或更大的值,以你机器内存为准)。这是最常见的"一键解"。--ipc=host,让容器直接复用宿主的 /dev/shm。代价是隔离性下降(进程间可见性增强),但在纯训练节点上通常可接受。此外 NCCL 还需要 GPU 间能建立通信,容器里要能解析主机名、能互相连接。用 torchrun 起多卡时,常常需要把 NCCL_SOCKET_IFNAME 指到正确的网卡,否则它会选错网卡导致超时。以下写法把常见坑一次性规避(具体网卡名、阈值以你环境为准):
docker run --gpus all \ --shm-size=16g \ --ipc=host \ -e NCCL_SOCKET_IFNAME=eth0 \ -v /data/imagenet:/data \ -v /models:/models \ my-training-image \ torchrun --nproc_per_node=2 train.py --data /data --output /models/ckpt
提醒:上面
eth0、16g是示例值,请以你宿主ip addr实际网卡名和机器内存为准;NCCL 相关环境变量与默认值随版本变化,建议结合所用框架官方文档核实。
单机多卡只是开始。当模型大到一张机器放不下、或数据大到单机算力不够,就要上多机多卡(multi-node)。这时容器不只是"本机内的隔离单位",还成了"跨机通信的一个端点",几个新坑浮出水面:
bridge 模式下,容器有独立 IP 和端口映射,多机之间要靠宿主机端口转发才能互通,NCCL 的 socket 建连会变得很绕。很多团队在多机场景直接改用 --network=host,让容器共享宿主网络栈,省去一层 NAT,NCCL 建连最顺。代价同样是隔离性下降,但在训练集群里通常可接受。torchrun 的 rdzv_endpoint( rendezvous 地址)必须能被所有机器解析。容器里要能互相解析主机名,否则初始化永远超时。常见做法是用 host 网络 + 各机真实 IP,或由编排系统(如 K8s)注入彼此可达的地址。NCCL_IB_DISABLE、NCCL_NET 等开关),具体支持与默认行为请以你所用 NCCL 版本官方说明为准,本文不编造默认值。一句话总结多机多卡:单机多卡调的是 /dev/shm 和 IPC,多机多卡调的是网络和 rendezvous 可达性。两者都解决"让 NCCL 能顺利握手",只是层级不同。
新手第二个坑:把数据集 COPY 进镜像,或者让训练脚本把预处理缓存、checkpoint 写进容器可写层。镜像是给"环境"用的,不是给"数据"用的——一个 200 GB 数据集塞进镜像,不仅镜像巨大、推送拉取都废,而且容器一删数据全没。
正确做法是数据永远走挂载卷,不进镜像层:
-v /data/imagenet:/data:ro,加 :ro 防止训练脚本误改原始数据。-v /models:/models,挂载到宿主持久目录。/tmp/cache,避免和根分区抢 IO。这样容器的可写层只装"运行时产生的少量状态",数据全部落在宿主,容器删了数据还在,换机器只要把卷一起搬走即可。
把数据挂进去了,但 GPU 利用率(看 nvidia-smi 里的 Volatile GPU-Util)常年只有 30%~50%,第一反应通常是"卡太差"。其实更常见的原因是数据管线喂不饱 GPU:
沙箱化时的应对:把数据集预处理的产物缓存到本地高速盘卷(前面那个 /tmp/cache),用独立的预处理进程或数据加载 worker 把样本提前准备好;若框架支持 GPU 直解码(如 DALI 一类方案),可在容器内启用,把解码也搬到 GPU 侧。具体是否启用、如何配置请以你所用数据加载方案官方文档为准,本文只给出"缓存下沉到本地卷 + 解耦预处理"的思路,不编造基准数字。
训练跑了一整晚,机器重启或容器被误删——如果 checkpoint 没外溢到卷上,等于白跑。沙箱化的核心纪律就是:训练的任何"有价值产出"都必须落到挂载卷,而不是容器内部。
具体要做的三件事:
/models/ckpt/epoch_XX.pt(即挂载卷),而不是 /tmp。tee 到 /models/logs/ 下的文件,方便离线排查,而不是只看终端。/models/ckpt 是否有最新 checkpoint,有则从它 resume,没有才从头训练。这样容器被 kill 后,重新 docker run 同一条命令就能接着跑,而不是从头来。docker run --gpus all --shm-size=16g --ipc=host \ -v /data/imagenet:/data:ro \ -v /models:/models \ -v /nvme/cache:/tmp/cache \ my-training-image \ torchrun --nproc_per_node=2 train.py \ --data /data --output /models/ckpt --resume
--resume 让脚本自己决定是否续跑。这条命令即便被中断重跑,也只损失最后一个未保存区间的进度,而不是全部。
当模型到了十亿、百亿参数,checkpoint 本身成了工程问题:一个 7B 模型的全量权重就是几十 GB,频繁落盘既占 IO 又占空间。几个值得提前知道的策略方向(具体实现与参数请以你所用框架/并行方案官方文档为准):
ckpt_epoch_30 到底是用哪份数据、哪版代码训出来的——这正是沙箱化要解决的"可复现"痛点的延伸。训练跑起来后,建议你进容器看一眼真实状态,而不是只看终端 loss:
docker exec -it train-llm nvidia-smi
nvidia-smi 在容器内(GPU 已透传时)能看到被授权的那几张卡。重点看两项:Volatile GPU-Util(瞬时利用率)和显存占用。如果显存吃得满、利用率却低,多半是数据管线或通信在拖后腿(回看上一节"数据加载瓶颈");如果利用率高但训练慢,才是算力本身到顶了。沙箱化让这些诊断命令可以"原样在容器内复现",这是它相比"裸机装环境"最大的工程红利——任何人拿同一镜像都能得到同一个可诊断环境。
把前面几点收敛成一份 docker run 模板(参数按需替换),它已经能覆盖绝大多数单/多卡训练场景:
docker run -d --name train-llm \ --gpus all \ --shm-size=16g \ --ipc=host \ -e NCCL_SOCKET_IFNAME=eth0 \ -v /data/imagenet:/data:ro \ -v /models:/models \ -v /nvme/cache:/tmp/cache \ my-training-image \ torchrun --nproc_per_node=2 train.py --data /data --output /models/ckpt --resume
注意这里用了 -d(detach,后台跑)和 --name:训练是长任务,你不应把终端绑死在 docker run 上,而是后台跑、用 docker logs -f train-llm 看输出、docker exec 进去排查。这也引出一个工程习惯——训练任务更适合放进 docker compose 文件统一描述,把上面这些 -v、-e、--shm-size 固化成文本,避免每次手敲出错、也更利于团队复用。
当训练参数越来越多,单条 docker run 既难读又难重现。把它写进 compose 文件,等于给团队一份"训练服务说明书":谁都能 docker compose up 拉起完全一致的训练环境。下面是一份训练 compose 的骨架(字段名随 compose 版本略有差异,请以官方规范为准):
services.train.image:训练镜像;deploy.resources.reservations.devices:声明使用的 GPU(等价于 --gpus);shm_size:共享内存大小(等价于 --shm-size);ipc: host:共享 IPC 命名空间;volumes:挂数据集(只读)、模型卷、缓存卷;environment:注入 NCCL_* 等通信变量;command:torchrun ... --resume 启动命令。把这份文件纳入 git,训练就从一个"我在某台机器敲的命令"变成"团队可审计、可复现的资产"。这恰恰是体系化教程想传递的核心:沙箱化不是为了让命令更短,而是为了让工程知识可沉淀。
把上面的坑收敛成一张速查表,方便你排错时对照(具体报错文案随版本不同,句意一致即可):
bus error / NCCL 初始化卡死:九成是 /dev/shm 太小,先加 --shm-size 或 --ipc=host,再看 NCCL_SOCKET_IFNAME 是否指对网卡。timeout waiting for rendezvous:通常是容器网络不通或主机名互相解析不了,单机先验证 --network=host 是否解决,再查各机 rdzv_endpoint 可达性。-v 挂载是否真的生效、路径是否和脚本里一致。--resume 分支,先 docker exec 进去看 /models/ckpt 有没有产出。这张表的价值不在「背答案」,而在帮你建立排错顺序:先看通信、再看数据落盘、最后看资源。顺序对了,绝大多数训练沙箱问题都能在半小时内定位。
训练任务沙箱化,核心就四句话:多卡先解决 IPC 共享内存(必要时 host 网络解决多机握手),数据永远走挂载卷不进镜像,产出(checkpoint/日志)必须外溢出容器并带续跑逻辑,慢了先查数据管线而非先怪算力。把这几件事做对,你得到的就是一个"删了容器也不丢数据、换机器一条命令续上、多卡多机能真正协同、且可被 compose 固化沉淀"的训练沙箱。下一节我们看更"娇气"的另一类负载——推理服务,它对稳定性和对外暴露提出了完全不同的要求。