Docker容器GPU不可见:设备、驱动、运行时与镜像排查指南
上周帮人看一个训练任务宿主机上显卡状态查询工具跑得漂漂亮亮四张卡稳稳停在四十来度风扇慢悠悠转着一点毛病没有。结果一进容器就翻脸要么提示命令不存在要么直接报No devices were found要么更干脆/dev下面那几个设备节点压根就是空的。这类 “Docker 找不到 N 卡” 的问题我这些年少说碰过几十次每次的根子都不太一样但排查路径其实是高度收敛的。先把话说在前头这篇不聊大模型怎么训、参数怎么调只解决一件很具体的事——为什么容器里看不见那张卡以及怎么把它找回来。适合两类人看一类是刚把模型环境搬进容器、被各种could not select device driver报错拦住的新手另一类是把 GPU 容器化部署到生产、被“本地好好的、服务器上不行”折磨过的老手。文中所有命令和配置都可以直接抄但顺序很重要顺序错了你会浪费大半天。说明不同显卡厂商的驱动工具、运行时组件命名各不相同为了行文顺畅下文的gpu-smi统一代指你手上那张卡对应的官方设备状态查询命令gpu-container-toolkit代指厂商提供的容器运行时接入工具包。你按自己机器上实际安装的名字替换即可逻辑完全一致。1. 找不到 N 卡其实是三种病别一上来就改配置我见过太多人一遇到报错就去翻daemon.json改完重启 Docker重启完还是不行再改再重启最后把配置文件改成一锅粥。问题的关键在于“找不到”这三个字描述的是现象不是原因。同样是容器里看不到卡至少对应三个完全不同的故障层用错药不仅没用还会掩盖真正的问题。我的习惯是先花三十秒做症状分层再决定往哪个方向挖。1.1 症状一容器里连设备节点都没有这一类最典型。进容器执行ls -l /dev/你会发现宿主上那一批跟显卡相关的设备节点在容器里一个都不存在。这时候容器内的任何计算库都无从谈起——它连硬件入口都没有就像你把一台打印机锁在隔壁房间隔间里的人再怎么装驱动也打不出纸。病灶位置基本锁定在容器启动参数或者运行时注册这两层。也就是说要么你没加--gpus之类的参数要么运行时的钩子没生效。1.2 症状二设备节点在工具命令找不到ls /dev/能看到设备节点了说明映射成功。但执行状态查询命令时提示command not found。这种情况很多人会误判成“显卡没接上”其实是两回事设备映射和镜像内容是两个独立维度。宿主机的驱动工具装在宿主机上容器是另一套文件系统它自然看不到宿主机/usr/bin下的那堆二进制。要么你在镜像里装一份要么你换个方式验证——比如直接跑一个带计算库的小程序看能不能枚举到设备。1.3 症状三设备在、命令在一跑就报错这是最磨人的一类也是最有技术含量的。典型报错包括初始化失败、驱动版本不匹配、CUDA driver version is insufficient、权限拒绝、显存分配失败等等。设备节点映射没问题工具也能跑但底层初始化和宿主驱动对不上或者权限不对。把这三类症状对照一下你会发现排查方向完全不同症状表现一句话判断命令病灶所在层典型修复方向容器内/dev下无设备节点ls -l /dev/对比宿主启动参数 / 运行时注册加--gpus、检查运行时是否注册成功有设备节点无查询工具which gpu-smi返回空镜像内容镜像内装运行时、或用计算库自检有节点有工具初始化失败跑最小用例看报错驱动版本 / 权限 / cgroup对齐版本、加权限组、检查资源限制提示别跳过这一步。我见过有人把驱动卸载重装了三遍最后发现只是启动命令里少了个参数。症状分层能帮你省下的时间远超这三十秒的投入。2. 容器为什么天生看不见显卡设备节点、驱动库、运行时这三道门要真正把这类问题一次性解决干净得先明白容器为什么“看不见”。这不是 Docker 的 bug而是隔离机制的正常表现。理解了机制你就能预判哪一步可能断而不是靠试。2.1 显卡不是“插上就能用”它由内核态和用户态两块拼起来一张卡要被程序用起来需要两套东西配合。第一套在内核里驱动以内核模块的形式加载负责和硬件打交道同时在/dev下创建一批设备节点作为用户态访问硬件的入口。第二套在用户态一批共享库文件程序通过它们调用内核模块提供的接口。这两套东西版本必须对应。内核模块的版本、用户态库的版本、程序编译时依赖的计算运行时版本三者构成一条链。宿主上这条链是完整的问题往往出在容器把后半截切断了。2.2 容器本质是“被裁剪过的文件系统和命名空间”容器启动时它看到的是镜像提供的根文件系统加上若干被显式挂载进去的目录。宿主的/dev默认不会完整暴露给容器只有极少数基础设备空设备、随机数、终端等会被带进去。这就解释了为什么容器里看不到显卡设备节点不在用户态库不在环境变量也不在。默认状态就是“什么都不知道”。想让它知道必须有人主动把这些东西搬进去。2.3 运行时钩子那个负责“搬东西”的角色搬运工作不是 Docker 自己干的而是交给一个专门的运行时组件。这个组件在容器创建阶段介入负责三件事解析你传的--gpus参数决定映射哪些设备在容器启动前把宿主上的设备节点挂载进容器的/dev把宿主的驱动用户态库、相关配置文件一并挂载进去并设置好环境变量。所以完整的链路是宿主驱动装好 → 运行时组件装好 → 运行时注册到 Docker → 容器启动时带--gpus参数。这四步任何一环断掉结果都是“找不到卡”但断的位置不同修法完全不同。一个很常见但容易被忽略的点装完运行时组件后必须重启 Docker 守护进程。因为运行时列表是在守护进程启动时读取的不重启新装的运行时对它来说压根不存在。这一步坑过的人不计其数后面还会展开讲。3. 宿主机侧的体检从内核模块到运行时注册排查我一直坚持自上而下——先确认底下是好的再看上面的封装。否则你在容器层折腾半天可能底下本来就是坏的。3.1 第一层物理与内核层是否正常先确认卡本身被系统识别。用总线和设备枚举工具看一眼能看到对应的显示设备条目说明硬件和总线上没问题。接着确认内核模块已经加载查看已加载模块列表里有没有对应厂商的模块。如果模块没加载手动加载一次看看报什么错。常见原因是内核版本升级后模块没跟着重新编译或者安全启动策略拦住了未签名的模块。这种情况下容器里当然找不到卡——宿主自己都还没搞定。再确认设备节点确实生成了。/dev下应该能看到一组以厂商前缀命名的节点通常包括若干编号的卡节点、一个控制节点以及一些辅助设备节点。数量应该和你实际的卡数对应。如果卡有三张但只出现一个节点说明枚举阶段就出问题了先去查硬件和内核日志。3.2 第二层用户态驱动和工具是否可用设备节点在接下来确认用户态能不能通。执行设备状态查询命令正常情况下会输出一张表格包含卡型号、驱动版本、显存、当前占用进程等信息。这一步能过说明宿主的驱动链路是完整的。如果这一步就失败别往容器方向找了先把宿主驱动修好。同时记下两件事驱动版本号和支持的最高计算运行时版本。这两个数字后面比对镜像兼容性时要用。很多人到后面报版本不匹配就是因为从来没记过这两个数。3.3 第三层运行时组件是否真的注册进了 Docker这是最容易被误判的一层。很多人确认自己能跑gpu-smi就觉得“环境好了”其实那只证明宿主驱动好不证明 Docker 认识这个运行时。检查方式很简单docker info | grep -A5 -i runtime正常的输出里应该能看到你安装的那个运行时名字。如果只有默认的runc说明注册没生效。另一个更直接的验证docker run --rm --gpus all 你的镜像 gpu-smi如果报could not select device driver with capabilities: [[gpu]]基本可以确定是运行时没注册成功或者注册了但守护进程没重启。注册的标准做法是用工具包自带的配置命令去改daemon.json而不是手写。手写的问题在于格式、字段名、路径都很容易错一个字符然后你就要花一小时找那个字符。工具包的命令大致长这样sudo gpu-ctk runtime configure --runtimedocker sudo systemctl restart docker执行完再跑一次docker info确认运行时出现在列表里。这一步过了宿主侧的体检才算完。4. 容器启动参数八成人卡在这几行上宿主全绿运行时注册成功接下来就剩启动参数了。这块看似简单实际是最容易出岔子的地方因为写法有好几种而且不同版本的 Docker、不同的编排工具写法不一样。4.1 命令行方式的正确姿势现代版本的 Docker 支持直接传设备参数最宽松的写法是docker run --rm -it --gpus all 镜像 bash只给某几张卡docker run --rm -it --gpus device0,2 镜像 bash注意那个引号的嵌套这是 shell 层面的坑——外层单引号里面还要有双引号很多人直接被 shell 吃掉参数然后 Docker 收到一个奇怪的字符串报错信息还很含糊。还有一种老写法是--runtime运行时名配合环境变量控制可见卡。这种写法在新版本里仍然能用但语义上不如--gpus清晰--runtime只是指定用哪个运行时来启动容器具体映射哪些设备还得靠环境变量。如果你只想映射部分卡又用老写法很容易出现“映射了但没完全映射”的怪现象。提示优先用--gpus。它的语义更明确而且能自动处理设备节点和驱动库的挂载。老写法在没有明确理由时不要用。4.2 编排工具里的写法差异要特别注意直接写docker run没问题一旦上编排写法就变了。用 Compose 的话标准写法是在服务下用资源预留的方式声明设备services: train: image: your-image:tag deploy: resources: reservations: devices: - driver: gpu count: all capabilities: [gpu]这里有两个坑。第一deploy段在早期版本里只在集群模式下生效单机跑需要较新版本的 Compose 才认。第二capabilities里的gpu是必须写的不写或者写成别的值设备不会被映射。如果版本较老认不了deploy段可以退回用运行时字段services: train: image: your-image:tag runtime: gpu但这种写法只能全量映射没法精细控制卡号需要精细控制就配合环境变量指定可见设备。4.3 参数写对了还是不行回头查这三个开关我有一次调了半天最后发现是三个开关里的第二个没开。这三个位置建议逐项核对第一守护进程的运行时列表里有没有你那个运行时。前面查过了。第二有没有把运行时设成默认。默认运行时是可选配置不设也能用--gpus但如果你用的是老写法--runtime名字必须对得上。第三容器内用户对设备节点有没有读权限。设备节点默认属主和权限是受限的容器里如果用非 root 用户跑极容易撞上权限拒绝。相关处理在下一节展开。5. 镜像里面的世界设备映射成功不等于能算很多人到这里会松一口气——容器里能看到设备节点了状态查询命令也出结果了。但只要真跑起计算任务问题又会冒出来。这一层是纯粹的文件系统和版本问题比前面更细。5.1 镜像里到底要不要装驱动这是个高频误解。答案是内核态的驱动绝对不能在镜像里装用户态的库通常也不需要你手动装。内核模块和硬件绑定只能在宿主上加载容器共享宿主内核装进去也没用反而可能把镜像搞崩。用户态的驱动库由运行时组件在启动时从宿主挂载进来你镜像里再装一份两边版本不一致时反而会打架——容器里优先加载了自带的那份版本和宿主内核模块对不上直接初始化失败。所以正确的做法是镜像里只装计算运行时你代码依赖的那套库不装驱动。让运行时组件去挂宿主的那份。如果你用的运行时版本比较老可能需要额外声明要挂载哪些能力。新版组件默认会把这些库带进来老版需要显式配置。这是个很容易踩的暗坑——同一份镜像在 A 机器上好好的换到 B 机器就报找不到库往往就是两边运行时组件版本不一样。5.2 版本兼容宿主驱动和镜像内计算库的对应关系这条链是这样的镜像里的计算库版本对宿主驱动版本有最低要求。宿主驱动太老容器里的库就启动不了。排查方法很直接在容器里跑一个最小验证程序打印出运行时版本和驱动版本两个都记下来然后去查官方兼容性表格。有一个实用技巧如果发现是宿主驱动偏低而升级宿主驱动成本很高比如是多人在用的共享服务器升级要协调停机窗口可以优先考虑把镜像里的计算库版本降下来比动宿主安全得多。反过来如果镜像生态锁死了不能降那就只能推动宿主升级。另外镜像和宿主驱动之间存在主版本兼容的概念——同一个主版本线内的驱动通常能向下兼容若干个小版本。跨主版本就不行了。所以升级宿主驱动时尽量整条线一起升别只升一半。5.3 权限与设备节点的属主那点事容器用 root 跑基本不会有权限问题。但只要切到普通用户麻烦就来了。设备节点在宿主上的属主和所属组是驱动安装时定下来的普通用户需要加入对应组才能访问。容器里的用户 ID 和宿主的组 ID 是两套体系如果不额外处理容器内的普通用户就访问不了设备节点。处理方式有两种。第一种是在启动时把宿主的组加进容器docker run --rm -it --gpus all --group-add 宿主上的设备组ID 镜像 bash第二种是干脆让镜像里的用户 UID 和宿主上那个能访问设备的用户对齐。我更推荐第一种侵入性小也不用改镜像。提示查宿主上的设备组 ID 很简单ls -l /dev/看一眼设备节点的组名再getent group 组名拿到数字 ID 就行。这个数字写进启动脚本一次配好长期有效。6. 几个真实踩过的坑文档上基本不会提上面讲的是标准路径。但现实里标准路径走通之后还会有一堆边角问题。这一节全是硬碰硬踩出来的纯经验。6.1 重启之后又不行了最气人的情况昨天配好能跑今天开机一测又报错。原因通常是运行时的注册没持久化或者某个服务启动顺序有问题。我遇到过两次。一次是配置写在了临时目录而不是守护进程实际读取的路径重启后配置丢了。另一次是运行时组件依赖的一个服务在开机时没有自动启动导致守护进程读配置时运行时还没就绪。排查办法重启后第一件事就是跑docker info看运行时还在不在。不在就说明持久化没做好回去检查配置文件路径和服务开机自启状态。6.2 多卡机器只认到一张八卡机只认到一张或者认到八张但程序只能用一张是两类不同的问题。前者通常是设备映射的范围写错了或者运行时的设备发现逻辑有问题。先看容器内/dev下节点数量和宿主是否一致不一致就是映射没做全。后者往往是环境变量在作怪。计算运行时默认会读一个可见设备列表的环境变量如果容器启动时这个变量被设成了单卡程序自然只认一张。查一下容器内的环境变量env | grep -i visible之类的看看有没有被意外设置。我见过一个镜像的启动脚本里写死了单卡所有人都以为机器有问题其实是脚本的问题。6.3 嵌套环境和桌面版环境的特殊性跑在虚拟机里的机器显卡是否可用取决于虚拟机配置有没有做直通。这一点在容器层完全看不出来——你在容器里怎么折腾都没用得回到虚拟机管理器那一层去配。桌面版容器环境更特殊。它跑在一个轻量虚拟机里设备访问路径和纯 Linux 服务器不一样很多在服务器上直接可用的命令在这类环境里不适用。如果你在公司笔记本上配环境最好一开始就确认清楚自己用的是哪种模式别拿服务器文档往桌面环境上套。另外一个容易被忽略的点某些云主机的实例类型不提供设备直通能力即使规格表上写着有卡容器里也可能拿不到。这种情况找平台侧确认别在自己机器上耗。6.4 资源限制带来的怪现象容器可以通过资源限制参数控制能访问哪些设备。如果你只映射了设备节点但资源限制层面没有放行会出现“设备节点在、一用就报错”的现象。新版 Docker 在处理--gpus时会自动同步资源限制一般不用担心。但如果是手工挂载设备节点、绕过运行时的做法就必须自己处理设备控制规则否则就会撞上这个坑。顺便说一句手工挂载设备节点这个做法我不推荐。看起来灵活实际上要处理的东西特别多设备节点、控制节点、驱动库、环境变量、资源规则少一样都不行。用运行时组件把这些一次性包圆稳定性高得多。7. 一套可以直接抄的自检清单上面讲了原理和坑最后给一套可以照着走的流程。这套流程我用了很久基本能在十分钟内定位到问题层。7.1 五步定位脚本按顺序执行哪一步断了就停在哪一步# 第 1 步宿主上设备是否被识别 lspci | grep -i vga # 第 2 步内核模块是否加载 lsmod | grep -i gpu # 第 3 步设备节点是否生成 ls -l /dev/ | grep -i gpu # 第 4 步宿主侧工具是否正常 gpu-smi # 第 5 步Docker 是否认识运行时 docker info | grep -i runtime五步全过说明宿主侧没问题问题一定在容器启动参数或镜像内部。这时候再跑最小验证docker run --rm --gpus all 镜像 gpu-smi能出结果就说明链路通了。出不了结果按报错内容对照前面的症状分层往回找。7.2 报错信息与病因的对照表我把这几年遇到的高频报错整理成一张表遇到时可以直接对号入座报错关键词最可能的病因优先检查项could not select device driver运行时未注册或未重启守护进程docker info里有无该运行时No devices were found设备节点未映射进容器启动参数是否带--gpuscommand not found镜像内无工具与驱动无关换用计算库自检或镜像内装工具permission denied容器内用户无设备访问权限加--group-add宿主设备组driver version is insufficient镜像内计算库版本高于宿主驱动降镜像内库版本或升宿主驱动程序只能看到一张卡可见设备环境变量被写死检查容器内环境变量重启后失效配置未持久化或服务未开机自启查配置路径与服务状态这张表不是万能的但覆盖了我碰到的八成情况。剩下两成基本都要靠日志——容器内计算库的详细日志、宿主内核日志、守护进程日志三处一起看基本都能定位。我个人在实际操作中的体会是这类问题最忌讳的就是“猜”。看到报错就凭直觉改配置改完重启重启完再猜下一轮一上午就没了。真正高效的做法是把“设备层、驱动层、运行时层、镜像层”四个层级的验证命令固定成一套脚本每次出问题先跑一遍三十秒内就能锁定是哪一层剩下的只是针对性的修补。还有个小技巧值得记一下如果你经常在几台机器之间倒腾环境把上面那套五步检查写成一个check-gpu.sh放在每台机器的家目录里出问题直接bash check-gpu.sh。看起来是个很土的办法但真到了着急跑实验的时候它比翻文档快得多。最后补一句关于版本管理的建议。这类问题有相当比例是版本错位造成的而版本错位往往是“随手装了个最新版”埋下的。我现在的做法是宿主的驱动版本、运行时组件版本、镜像里的计算库版本三个数字记在一个文本文件里跟着项目走换机器先对这个表。这个习惯帮我省下的调试时间比任何技巧都多。