第4章:AI容器化技术
2025.11.29在第一篇中,我们完成了智算中心“地基”的建设。我们深入了AI芯片的内核,铺设了RDMA的高速网络,并构建了并行的存储系统。现在,我们拥有了强大的、但却原始的裸金属算力。这就像我们拥有了一座装备精良的工厂,但里面还没有标准化的生产线。直接在裸金属服务器上部署AI应用,会让我们迅速陷入“依赖地狱”——不同项目的Python版本冲突、系统库不兼容、环境难以迁移和复现。这在追求快速迭代的AI时代是不可接受的。
答案是容器化。容器技术(以Docker为代表)通过将应用及其所有依赖打包到一个轻量、可移植的“集装箱”中,实现了良好的隔离和环境一致性。它正是我们为AI工厂构建标准化生产线所需要的核心技术。
然而,让AI应用“住进”容器并非易事。标准的容器与底层的GPU/NPU硬件之间存在一道天然的鸿沟。本章,我们将手把手地为你打通这条路。我们将首先解决最基础的问题:如何构建一个能“看见”并使用GPU/NPU的容器环境。接着,我们将把这个能力接入到Kubernetes的宏大叙事中,让K8s能够像调度CPU一样智能地调度和管理AI算力。最后,我们将传授“镜像工程”的独门秘籍,教你如何为大模型打造轻量、高效、可跨平台部署的标准化镜像。掌握本章内容,你将能构建起云原生AI平台的第一个、也是最核心的支柱。
4.1 基础环境构建:NVIDIA Container Toolkit 与 Ascend Docker Runtime
4.1.1 问题的根源:容器的“视而不见”
让我们从一个简单的实验开始。在一台已经正确安装了NVIDIA驱动的主机上,打开终端,输入nvidia-smi,你会看到GPU的详细信息。现在,我们启动一个标准的Ubuntu容器,并尝试在容器内执行同样的命令:
# 在主机上执行,成功显示GPU信息
$ nvidia-smi
# 启动一个标准Ubuntu容器
$ docker run --rm -it ubuntu:20.04 /bin/bash
# 在容器内执行,命令不存在或报错
root@container:/# nvidia-smi
bash: nvidia-smi: command not found
为什么会这样?原因在于容器的核心隔离机制:Linux命名空间(Namespaces)。容器拥有自己独立的文件系统、进程空间和网络栈。它与主机(Host)是隔离的,默认情况下,它“看不见”主机上的设备文件(如/dev/nvidia0)和驱动库(如libcuda.so)。
为了打破这层隔离,让容器能够访问到GPU/NPU,我们需要一个“中间人”或“翻译官”。这个角色,就由NVIDIA的Container Toolkit和华为的Ascend Docker Runtime来扮演。
4.1.2 NVIDIA Container Toolkit:为容器注入CUDA之力
NVIDIA Container Toolkit是一套组件,它扩展了标准的容器运行时(如Docker的runc),使其能够感知并利用NVIDIA GPU。
核心组件剖析
libnvidia-container: 这是一个核心库,提供了与NVIDIA驱动交互的底层API,负责查询GPU信息、配置容器环境等。nvidia-container-cli: 一个命令行工具,供容器运行时调用,它会使用libnvidia-container来准备GPU环境。nvidia-container-runtime: 这才是关键的“翻译官”。它是一个自定义的OCI(Open Container Initiative)运行时,它会拦截标准的容器创建请求。当它发现请求需要GPU时,它会调用nvidia-container-cli,将必要的NVIDIA驱动文件、设备节点和库文件动态地挂载(mount)到容器的命名空间内,然后再调用标准的运行时(runc)来启动容器。
工作流程
- 用户执行
docker run --gpus all ...。 - Docker Daemon收到请求,看到
--gpus参数,知道需要GPU。 - Docker Daemon不直接调用默认的
runc,而是调用nvidia-container-runtime。 nvidia-container-runtime接管请求,它调用nvidia-container-cli去查询主机上有哪些GPU设备,以及驱动库在哪里。nvidia-container-cli将这些设备文件(如/dev/nvidia0,/dev/nvidia-uvm)和库文件自动添加到容器的配置中。- 最后,
nvidia-container-runtime带着这份“增强版”的配置,调用runc来创建和启动容器。 - 最终,容器启动时,内部就已经有了访问GPU所需的一切。
安装与配置实战
- 确保NVIDIA驱动已安装。 这是所有工作的前提。
- 添加NVIDIA的软件源
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
- 安装Toolkit
sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit
- 配置Docker Daemon并重启
安装脚本通常会自动配置Docker。你可以检查/etc/docker/daemon.json文件,确保nvidia-container-runtime被设置为了默认运行时。然后重启Docker服务:
sudo systemctl restart docker
- 验证安装
docker run --rm --gpus all nvidia/cuda:12.1.0-base-ubuntu22.04 nvidia-smi
如果能成功打印出GPU信息,说明你的基础环境已经构建完成!
环境变量的妙用:
Toolkit还会向容器内注入一些关键的环境变量,用于精细化控制GPU的使用:
NVIDIA_VISIBLE_DEVICES: 这是最重要的一个。它的值决定了容器内“可见”的GPU。可以是all,也可以是GPU的索引(如0,1)或UUID。docker run --gpus '"device=0,1"'最终就会体现为容器内的NVIDIA_VISIBLE_DEVICES=0,1。NVIDIA_DRIVER_CAPABILITIES: 控制挂载到容器内的驱动库。默认是compute,utility,对于图形应用可能需要graphics。
4.1.3 Ascend Docker Runtime:昇腾平台的容器化基石
与NVIDIA的思路类似,华为也提供了一套机制来让容器使用昇腾NPU。这套机制通常作为CANN(异构计算架构)软件包的一部分提供。
核心组件与原理
- Ascend Docker Runtime: 同样是一个自定义的Docker运行时。它的作用是在启动容器时,将主机的CANN驱动、固件、必要的库文件以及NPU设备文件(如
/dev/davinci0,/dev/devmm_svm)挂载到容器内部。 - 与NVIDIA的不同: 相比NVIDIA Toolkit的独立安装,Ascend Docker Runtime的安装和配置通常与CANN Toolkit的安装过程更紧密地绑定在一起。
安装与配置实战
- 确保CANN软件包已正确安装。 这包括驱动、固件和工具包。
- 安装Ascend Docker Runtime:CANN的安装包中通常会包含一个名为
ascend-docker-runtime的deb或rpm包。
# 假设在CANN的安装目录下
sudo dpkg -i ascend-docker-runtime_*.deb
- 配置Docker Daemon
编辑/etc/docker/daemon.json文件,添加Ascend运行时,并可能将其设为默认。
{
"runtimes": {
"ascend": {
"path": "/usr/local/bin/ascend-docker-runtime",
"runtimeArgs": []
}
},
"default-runtime": "ascend"
}
- 重启Docker服务
sudo systemctl restart docker
- 验证安装
启动容器时,需要使用--device参数来手动指定要映射的NPU设备。
docker run -it --device=/dev/davinci0 --device=/dev/davinci_manager --device=/dev/devmm_svm --device=/dev/hisi_hdc ascendhub.huawei.com/public-ascendhub/mindspore-ascend:2.2.11-cann7.0.1-py39-euleros2.10-aarch64 npu-smi info
如果能看到NPU 0的信息,则说明配置成功。
环境变量:昇腾容器同样依赖环境变量来指定使用的设备,最核心的是ASCEND_VISIBLE_DEVICES,其作用与NVIDIA_VISIBLE_DEVICES完全相同。
小结: 无论NVIDIA还是华为昇腾,其容器化方案的核心思想都是一致的:提供一个特权的、了解底层硬件的自定义运行时,在标准容器启动流程中“做手脚”,将主机上的驱动和设备“偷渡”到容器的隔离环境中。 完成这一步,我们就为在K8s中管理AI算力铺平了道路。
4.2 K8s Device Plugin原理:如何让K8s“看见”并分配GPU/NPU
在单机上通过--gpus或--device参数运行容器只是第一步。在真实的智算中心,我们面对的是由成百上千台服务器组成的庞大集群。我们不可能手动ssh到每台机器去执行docker run。我们需要一个“中央大脑”——Kubernetes——来统一调度和管理这些宝贵的AI算力。
问题来了:K8s天生只认识CPU和Memory这两种资源。它如何知道node-01上有8张H800,而node-02上有8张910B?答案就是 K8s Device Plugin(设备插件)框架。
4.2.1 Device Plugin:K8s的“硬件翻译官”
Device Plugin是K8s提供的一套标准的、开放的扩展机制,允许第三方硬件厂商将自己的专有硬件(如GPU、NPU、FPGA、高性能网卡等)注册为K8s集群中的一等公民资源,使其可以被调度、被申请。
工作模式:
- Device Plugin本身通常是一个Pod,以DaemonSet的形式运行在集群中的每个(或指定标签的)计算节点上。
- 它通过一个定义好的gRPC接口,与该节点上的Kubelet进行通信。通信使用的Unix Socket文件通常位于
/var/lib/kubelet/device-plugins/kubelet.sock。
4.2.2 Device Plugin的生命周期(核心原理)
理解Device Plugin的工作流程,是理解K8s如何管理AI算力的关键。整个过程可以分为三步:
第一步:发现与注册 (Registration)
- 当Device Plugin的Pod在某个节点上启动后,它首先会扫描该节点,通过调用
nvidia-smi或npu-smi等原生工具,发现节点上存在哪些AI加速卡及其ID。 - 然后,它会连接到Kubelet的gRPC服务,并调用
Register方法。 - 在这次调用中,它会告诉Kubelet:“你好,我是NVIDIA的插件,我提供一种名为
nvidia.com/gpu的新资源。请记录一下。”(华为的插件则会注册如huawei.com/npu的资源)。 - Kubelet收到注册后,就知道了这种新资源的存在。
第二步:上报与监控 (ListAndWatch)
- 注册成功后,Kubelet会反过来调用Device Plugin的
ListAndWatch方法。 - Device Plugin会立即返回一个当前节点上所有可用设备ID的列表,例如
[GPU-UUID-1, GPU-UUID-2, ...]。 - Kubelet收到这个列表后,会更新该Node对象的
status.capacity和status.allocatable字段,写入nvidia.com/gpu: 8这样的信息。 - 就是在这个瞬间,K8s的“中央大脑”(API Server和Scheduler)才真正“看见”了这8张GPU!
ListAndWatch是一个流式RPC。Device Plugin会持续监控硬件状态。如果一张GPU卡因为故障离线了,它会立刻通过这个流告诉Kubelet,Kubelet会相应地更新节点的可分配资源,避免调度器将任务调度到一张已经坏掉的卡上。
第三步:申请与分配 (Allocate)
现在,用户可以提交一个Pod的YAML文件,在resources.limits中申请GPU资源了:
resources:
limits:
nvidia.com/gpu: 2
- Kube-scheduler在调度时,会遍历所有节点,查找
status.allocatable中nvidia.com/gpu数量大于等于2的节点,然后将Pod调度过去。 - Pod到达目标节点后,Kubelet在创建容器前,会再次调用Device Plugin的
Allocate方法,并告诉它:“这个Pod需要2张GPU,请把分配结果告诉我。” - Device Plugin会从自己维护的空闲设备列表中选出2张卡(例如ID为
GPU-UUID-3和GPU-UUID-5的卡),然后返回一个ContainerAllocateResponse。这个响应中包含了启动容器所必需的信息:- 设备(Devices): 需要挂载到容器的设备文件路径,如
/dev/nvidia3,/dev/nvidia5。 - 环境变量(Envs): 需要注入到容器的环境变量,最重要的就是
NVIDIA_VISIBLE_DEVICES=3,5。
- 设备(Devices): 需要挂载到容器的设备文件路径,如
- Kubelet拿到这些信息后,将其传递给底层的容器运行时(Docker + NVIDIA Container Toolkit)。Toolkit根据
NVIDIA_VISIBLE_DEVICES=3,5,精确地只将第3和第5张GPU暴露给容器。 - 至此,一个申请了特定GPU的Pod就成功运行起来了。
4.2.3 实战部署
部署NVIDIA Device Plugin:
NVIDIA官方提供了Helm Chart和YAML文件,部署非常简单。
# 使用Helm安装
helm repo add nvdp https://nvidia.github.io/k8s-device-plugin
helm repo update
helm install \
--generate-name \
--set-string nodeSelector.accelerator=nvidia-h800 \
nvdp/nvidia-device-plugin
注意这里的nodeSelector,它确保了NVIDIA的插件只运行在装有NVIDIA GPU的节点上。
部署Ascend Device Plugin:
华为同样提供了插件的YAML部署文件,通常包含在CANN的软件包或其开源社区中。
# 示例DaemonSet片段
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: ascend-device-plugin-daemonset
spec:
template:
spec:
nodeSelector:
accelerator: ascend-910b # 确保只在昇腾节点运行
containers:
- name: ascend-device-plugin
image: ascend-device-plugin:latest
# ... 挂载必要的socket和设备目录
小结: Device Plugin是Kubernetes设计哲学中“开放与扩展”的典范。它通过一套优雅的gRPC协议,将五花八门的硬件抽象成了统一的、可声明的资源,是实现异构算力统一调度的基石。作为AI Infra工程师,你不仅要会部署它,更要深刻理解Register -> ListAndWatch -> Allocate这一核心流程,这在你排查“Pod为什么调度不上去”、“GPU为什么分配错了”这类问题时,将给予你清晰的思路。
4.3 镜像工程:大模型各异构环境下的Docker镜像瘦身与分层构建
我们已经能在K8s上调度需要GPU/NPU的Pod了。现在,我们面临一个新的、同样棘手的问题:这些Pod运行的容器镜像从何而来?
对于大模型应用,其依赖环境极其复杂:特定版本的CUDA或CANN、特定版本的PyTorch、海量的Python依赖包,再加上模型本身的权重文件。一个不经优化的“朴素”镜像,体积轻松超过20GB。在一个需要频繁更新、快速部署的LLMOps流程中,这样的“巨无霸”镜像是灾难性的:
- 拉取缓慢: 集群中一个新节点启动时,拉取20GB的镜像可能需要几十分钟,严重影响弹性伸缩的效率。
- 存储昂贵: 在镜像仓库中存储成百上千个版本,每个都20GB,是一笔巨大的开销。
- 安全性差: 镜像中包含了大量不必要的编译工具和库,增加了攻击面。
镜像工程(Image Engineering),就是一门将臃肿的AI应用镜像,打造成轻量、高效、安全、可维护的“艺术”。
4.3.1 核心原则一:选择正确的“地基”——基础镜像
选择一个好的基础镜像,是镜像瘦身的第一步,也是最重要的一步。
避免使用
-devel镜像生产:- NVIDIA的
nvidia/cuda镜像标签中,通常有-base、-runtime和-devel三种。 -devel包含了完整的CUDA编译器(nvcc)、头文件和调试工具,体积最大,仅用于编译阶段。-runtime只包含运行CUDA程序所必需的运行时库,体积小得多。-base则更精简,甚至不包含cuDNN等常用库。- 原则: 生产环境的最终镜像,必须基于
-runtime或-base镜像构建。
- NVIDIA的
昇腾生态的对应选择: 华为的基础镜像(如
mindspore-ascend)同样有不同版本,需要选择只包含CANN运行环境,而不包含完整开发工具链的版本作为生产基础。
4.3.2 核心原则二:“两阶段施工”——多阶段构建(Multi-Stage Builds)
这是镜像瘦身的“核武器”。其思想是:在一个Dockerfile中使用多个FROM指令,将构建过程分为多个阶段。只有最后一个阶段的产物,会成为最终的镜像。
场景: 我们有一个需要编译的自定义PyTorch C++扩展。
糟糕的Dockerfile(单阶段):
# 镜像体积巨大 (e.g., 15GB)
FROM nvidia/cuda:12.1.0-devel-ubuntu22.04
# 安装编译工具和Python
RUN apt-get update && apt-get install -y g++ python3-pip
# 安装Python依赖
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
# 编译和安装自定义扩展
COPY . .
RUN python3 setup.py install
CMD ["python3", "main.py"]
这个镜像包含了g++编译器、完整的CUDA SDK、pip的缓存等所有“建筑垃圾”。
优秀的Dockerfile(多阶段):
# --- Stage 1: Builder ---
# 使用开发镜像进行编译
FROM nvidia/cuda:12.1.0-devel-ubuntu22.04 AS builder
RUN apt-get update && apt-get install -y g++ python3-pip python3.10-venv
WORKDIR /app
# 在虚拟环境中安装依赖,方便打包
RUN python3 -m venv /app/venv
ENV PATH="/app/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
RUN python3 setup.py install
# --- Stage 2: Final ---
# 使用轻量的运行时镜像
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04
# 只拷贝必要的运行时文件和编译好的产物
COPY --from=builder /app/venv /app/venv
COPY --from=builder /app/main.py /app/main.py
ENV PATH="/app/venv/bin:$PATH"
WORKDIR /app
CMD ["python3", "main.py"]
这个最终生成的镜像,体积可能只有5GB。它不包含g++、CUDA SDK等任何编译时依赖,只包含了干净的Python虚拟环境和最终的运行脚本。
4.3.3 核心原则三:“精打细算”——优化Dockerfile指令
合并RUN指令: Dockerfile中的每一个RUN, COPY, ADD指令都会创建一个新的镜像层。过多的层会增加镜像体积和构建时间。
# 不好:创建了多个层
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# 好:合并为一层,并及时清理
RUN apt-get update && \
apt-get install -y curl && \
rm -rf /var/lib/apt/lists/*
利用构建缓存: Docker会缓存每一层的构建结果。将不常变化的指令(如安装系统包)放在前面,将经常变化的指令(如COPY源代码)放在后面,可以最大化地利用缓存,加快后续构建速度。
使用.dockerignore: 在项目根目录下创建.dockerignore文件,忽略掉不需要拷贝到镜像中的文件(如.git目录、测试数据、本地配置文件),这可以减小构建上下文(Build Context)的大小,并避免敏感信息泄露。
4.3.4 挑战:异构环境下的镜像策略
当你的集群中同时有NVIDIA和昇腾时,如何管理应用的镜像?
策略一(推荐):专镜专用 (Separate Images)
为同一个应用,构建两个不同的镜像,并打上清晰的tag:
myapp:1.2.0-cuda12.1myapp:1.2.0-cann7.0
在K8s的部署文件(如Deployment或Job)中,通过nodeSelector来决定使用哪个镜像:
# 部署到NVIDIA节点的Pod模板
spec:
nodeSelector:
accelerator: nvidia-h800
containers:
- name: main
image: my-registry/myapp:1.2.0-cuda12.1
# 部署到Ascend节点的Pod模板
spec:
nodeSelector:
accelerator: ascend-910b
containers:
- name: main
image: my-registry/myapp:1.2.0-cann7.0
- 优点: 镜像清晰、最小化、无冗余。这是最干净、最符合云原生思想的做法。
- 缺点: 需要维护两条CI/CD流水线。
策略二(高级,慎用):胖镜像与运行时判断 (Fat Image)
构建一个同时包含CUDA和CANN依赖的“胖镜像”。
修改容器的入口脚本(ENTRYPOINT),让它在启动时检测当前环境,然后选择正确的执行路径。
#!/bin/bash
if [ -d "/usr/local/cuda" ]; then
echo "CUDA environment detected. Starting application for NVIDIA."
# 执行NVIDIA版本的启动命令
exec python3 main_cuda.py "$@"
elif [ -d "/usr/local/ascend" ]; then
echo "Ascend/CANN environment detected. Starting application for Huawei."
# 执行Ascend版本的启动命令
exec python3 main_ascend.py "$@"
else
echo "Error: No supported AI accelerator environment found."
exit 1
fi
- 优点: 只需要管理一个镜像tag。
- 缺点: 镜像极度臃肿,违反了单一职责原则,管理和调试更复杂。通常不推荐,除非有非常特殊的统一交付要求。
总结: 本章,我们从零开始,成功地将AI应用封装进了标准化的、可在K8s中被统一调度的容器中。我们掌握了连接容器与硬件的“运行时”技术,理解了让K8s感知硬件的“设备插件”原理,并学习了打造轻量高效镜像的“镜像工程”艺术。至此,我们已经拥有了云原生AI平台的坚实“底座”。接下来,我们将在这个底座之上,构建更智能、更高效的调度与资源管理系统。