- 开篇 — 文件在,配置却不在
- 第一次误诊 —「主配置盖掉了 drop-in」
- 第二次误诊 —「少了 version = 2」
- 实验 — 把 drop-in 一个一个拿掉
- 规则 — 合并不是按字段来的
- 为什么一周里没人发现
- 修复 — 跑多少次都是同一个结果
- 余震 — containerd 活着,节点却死了
- 验证 — 不看数字,看 Pod
- 那么该怎么做
- 小结
- 🧠 理解度自测
- 参考资料
开篇 — 文件在,配置却不在
在上一篇里,我把 GPU Operator 装了起来,并确认配置不是落在 config.toml,而是落在 conf.d/99-nvidia.toml 这个 drop-in 文件里。那篇文章我是这样收尾的。
这个设计是有意为之的。它不会和主机原有的配置混在一起,所以卸掉 Operator 时只要删掉 drop-in 文件,主机就回到原来的状态。
这话没错。只是少了一个前提:只有当 drop-in 就这一个的时候 才成立。
一周后,四台 GPU 节点全都不广告 GPU 了。
$ kubectl get nodes -o custom-columns="NODE:.metadata.name,GPU:.status.allocatable.nvidia\.com/gpu"
NODE GPU
nuc1 0
nuc2 0
omen 0
omen2 0
实验 Pod 起不来了。可是打开文件看,什么问题都没有。
$ ssh omen 'grep -c "runtimes.nvidia" /etc/containerd/conf.d/99-nvidia.toml'
6
nvidia、nvidia-cdi、nvidia-legacy 三个运行时连 BinaryName 都写得一字不差。文件是在的。
第一次误诊 —「主配置盖掉了 drop-in」
我看了实际被加载的配置。
$ ssh omen 'containerd config dump | grep -n "runtimes"'
111: [plugins."io.containerd.grpc.v1.cri".containerd.runtimes]
113: [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
128: [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
只有 runc 一个。而且第 111 行有个东西撞进了我的视线。主 config.toml 自己就在直接定义 runtimes 表。
一个听起来很像回事的假设马上立住了:TOML 合并是以表为单位的,主文件已经握着那张表,所以 drop-in 里同名的表就被丢掉了。于是我建议把 nvidia 块直接写进主文件。
错了。而且这个建议弄倒了一台节点。
containerd: failed to load TOML: /etc/containerd/config.toml: (300, 2): duplicated tables
Job for containerd.service failed because the control process exited with error code.
我给的是用 >> 追加的命令,可人只要粘贴两次,同一张表就会被声明两次。TOML 把这看成语法错误,containerd 拒绝启动。整台节点就这么下线了。
第一个教训在诊断之前就出来了:给生产节点的命令,跑两次也得给出同样的结果。人是会粘贴两次的。
第二次误诊 —「少了 version = 2」
清掉重复之后,nvidia 还是没被抓到。我又把 conf.d 翻了一遍,第二个文件撞进了视线。
$ ssh omen 'ls /etc/containerd/conf.d/'
99-nvidia.toml
zz-labhub-registry.toml
zz-labhub-registry.toml 只有三行。私有 registry 走的是明文 HTTP,为了指向证书目录,一周前是我自己加进去的。
# LabHub: 信任 Harbor(HTTP) registry
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
我注意到没有 version = 2 这一行。有说法是 containerd 配置里少了版本标记就会被当成 v1,插件键的解析方式会跟着变。第二个假设立住了,这次我 在动手之前先量了一遍。
$ # 给 zz 文件加上 version = 2 的副本,拿去 dump
$ containerd --config /tmp/t2.toml config dump 2>/dev/null | grep -c "runtimes.nvidia"
0
又错了。
两次都听起来像回事,两次都错了。到这里我换了做法。我不再靠读配置文件做推理,改成一个一个地拿掉,看结果会不会变。
实验 — 把 drop-in 一个一个拿掉
containerd --config FILE config dump 可以在不碰活着的进程的前提下,让 containerd 去读任意一个配置文件。也就是说,我可以在生产节点上什么都不改就把数量测出来。
$ for f in 99-nvidia zz-labhub-registry; do
printf 'version = 2\nimports = ["/etc/containerd/conf.d/%s.toml"]\n' $f > /tmp/t.toml
echo -n "只 import $f → "
containerd --config /tmp/t.toml config dump 2>/dev/null | grep -c "runtimes.nvidia"
done
只 import 99-nvidia → 6
只 import zz-labhub-registry → 0
$ # 两个都要
$ printf 'version = 2\nimports = ["/etc/containerd/conf.d/*.toml"]\n' > /tmp/t3.toml
$ containerd --config /tmp/t3.toml config dump 2>/dev/null | grep -c "runtimes.nvidia"
0
第三行就是答案。
只读 99-nvidia.toml 的时候,nvidia 运行时出现六次。可是把那三行 registry 一起 读进来,就变成 0。一个字都没提运行时的文件,抹掉了三个运行时。
规则 — 合并不是按字段来的
containerd 的 imports 合并是按插件整块替换。
只要某个 drop-in 碰了某个插件的配置,那个插件的 整份配置 就会被换成这个文件的内容。drop-in 里没写的项目,不是回到前一个文件的值,而是变成 默认值。
drop-in 是按名字顺序读进来的。所以最后的结果是,提到那个插件的 最后一个文件把一切都拿走。
zz- 排在 99- 后面。所以这三行把整份 CRI 插件配置换掉了。
读取顺序 CRI 插件配置
───────────────── ────────────────────────────────────
config.toml runc、sandbox_image、cgroup 设置 …
99-nvidia.toml runc + nvidia ×3、certs.d … ← 把上面的整块替换掉
zz-registry.toml 只有一个 config_path ← 又一次整块替换
───────────────── ────────────────────────────────────
最终 config_path + 其余全部默认值
把两个文件并排放着,人在脑子里合一合,会数出四个运行时。实际被加载的只有一个。
为什么一周里没人发现
这次故障真正吓人的地方不是合并规则,而是 它没有症状。
再看一遍坏掉状态下的 config dump。
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
runtime_type = "io.containerd.runc.v2"
sandbox_image = "registry.k8s.io/pause:3.8"
runc 在。sandbox_image 也像模像样。看上去配置是活着的。
全都是默认值。我们写的值一个都没剩下,可默认值又和我们可能会写的值很像,所以看不出来。pause:3.8 和 kubeadm 用的那个一样,runc 本来就是默认运行时。
而且运行时 handler 只有 在创建容器的时候 才会被查。已经在跑的 GPU Pod,就算配置没了也照样好好地跑下去。所以故障不会立刻显形,要等到节点重启或者 Pod 被重建的那一刻,才一口气全炸开。
这两件事叠在一起,一周就过去了。
修复 — 跑多少次都是同一个结果
知道了原因,修起来很简单。把 zz-labhub-registry.toml 挪到 conf.d 外面就行。那个文件握着的 config_path 值,99-nvidia.toml 里本来就有。
只是这一次我没有用一行命令去指挥。因为前面我已经弄倒过一台节点。我写了一个脚本,给它塞了两个性质。
第一,不追加。它先把已经在的东西全部扒掉,再原样写回正好一份。之前粘过 0 次、1 次还是 2 次,结果都一样。
def strip_nvidia(lines):
"""把 nvidia 表和它的所有子表全部扒掉。"""
out, cur, dropped = [], "", 0
for line in lines:
h = header_of(line)
if h is not None:
cur = h
if cur == NV or cur.startswith(NV + "."):
dropped += 1
continue
out.append(line)
return out, dropped
第二,活着的文件放到最后再碰。先把候选配置做成临时文件,让 containerd 去读那个文件,确认 nvidia 和 certs.d 真的被抓到了,然后才做替换。验证不过,原文件一个字都不会变。
rc, out, err = dump(candidate) # containerd --config <候选> config dump
if rc != 0 or out.count("runtimes.nvidia") == 0:
print("候选配置里没有抓到 nvidia 运行时。原文件保持不动。")
return 1
if "/etc/containerd/certs.d" not in out:
print("registry 配置会消失。原文件保持不动。")
return 1
# 从这里开始才真的动手
第二个确认才是关键。要删的那个文件握着的配置,只有 确认了另一个文件替它拿着 之后才删。不确认就删,我就会救活 GPU 却切断镜像拉取。
余震 — containerd 活着,节点却死了
四台节点都应用完并重启。三台马上活了过来,有一台停在 NotReady。
$ ssh omen2 'systemctl is-active containerd'
active
$ kubectl describe node omen2 | grep -A1 "Ready "
Ready False KubeletNotReady container runtime is down
containerd 是 active,kubelet 却说运行时挂了。看日志就有答案。
level=warning msg="failed to load plugin io.containerd.grpc.v1.cri"
error="failed to create CRI service: failed to create cni conf monitor for default:
failed to create fsnotify watcher: too many open files"
level=info msg="containerd successfully booted in 0.417476s"
进程起来了,只有 CRI 插件加载失败。fs.inotify.max_user_instances 的默认值 128 被耗尽了。这是只看 systemctl status 会觉得一切正常的状态。
$ sudo sh -c 'printf "fs.inotify.max_user_instances = 8192\nfs.inotify.max_user_watches = 1048576\n" \
> /etc/sysctl.d/99-inotify.conf && sysctl -p /etc/sysctl.d/99-inotify.conf'
$ sudo systemctl restart containerd
五台节点全都是默认的 128,也都没有配置文件。只是 omen2 先炸了,其余的不过是运气好而已。既然是 Kubernetes 节点,还是提前调高比较好。
验证 — 不看数字,看 Pod
$ kubectl get nodes -o custom-columns="NODE:.metadata.name,GPU:.status.allocatable.nvidia\.com/gpu"
NODE GPU
nuc1 1
nuc2 1
omen 1
omen2 1
不能停在这里。有一种状态是广告出来了,Pod 却起不来。真的跑一个看看。
$ kubectl -n gpu-operator logs gputest
GPU 0: NVIDIA GeForce RTX 4070 Laptop GPU (UUID: GPU-53be3556-a942-531c-a9a5-20e92af45279)
Pod 被调度,用 nvidia 运行时建起沙箱,容器里的 nvidia-smi 看得见显卡。走到这一步才算完。
那么该怎么做
一、不靠读文件下结论。cat 给你看的是某个人写下的意图,config dump 给你看的才是实际被加载的东西。GPU 配置事故大多数都出在这两者不一样,而人只看了前面那个。
二、读 dump 的时候也要分清是我写的值还是默认值。这次事故藏了一周就是因为这个。唯一能分清的办法,是一个一个地拿掉 drop-in,看 dump 会不会变。
三、往 conf.d 里再放一个文件之前,先看它的名字。如果那个文件按名字顺序排在最后,而且提到了某个插件,它就要为那个插件的整份配置负责。只写三行,剩下的就全变成默认值。
四、给生产节点的命令,跑两次也必须一样。人是会粘贴两次的。我给出的命令扛不住这一点,弄倒了一台节点。
五、不可逆的变更,把活着的对象放到最后再碰。先做候选,再验证,只有验证通过才替换。这个顺序只会让脚本长几行,却能把出错时的代价压到 0。
小结
| 项目 | 值 |
|---|---|
| containerd | 1.7.27 |
| 症状 | 4 台节点不广告 nvidia.com/gpu |
| 文件状态 | 正常(99-nvidia.toml 里有 3 种运行时) |
| 真正原因 | zz-labhub-registry.toml 的三行替换掉了整份 CRI 配置 |
| 合并规则 | 按插件整块替换,名字顺序最后的文件获胜 |
| 藏住的原因 | 剩下的全是默认值,而默认值看着像模像样 |
| 余震 | inotify 上限耗尽,只有 CRI 插件加载失败 |
| 误诊次数 | 2 |
留得最久的教训在诊断方法这一边。两次误诊都是 读配置文件立起来的假设,而给出答案的是 一个一个拿掉、把结果量出来的实验。读出来的确信不是验证。
🧠 理解度自测
1. drop-in 文件里明明写着 nvidia 运行时,config dump 里却没有。该怀疑什么?
看看有没有别的 drop-in 也提到了同一个插件,而且按名字顺序排在更后面。containerd 的 imports 合并不是按字段,而是按插件整块替换,所以提到那个插件的最后一个文件会把整份配置拿走。如果那个文件没写运行时,运行时就回到默认值。
2. config dump 里 runc 和 sandbox_image 看起来都正常,为什么配置仍然可能已经飞了?
因为剩下的值不是我们写的值,而是默认值。containerd 的默认运行时就是 runc,默认 sandbox_image 又和 kubeadm 用的一样,所以配置整块消失的状态和正常状态,dump 出来长得很像。要分清,只能一个一个拿掉 drop-in,看 dump 会不会变。
3. 运行时配置都没了,为什么 GPU Pod 还能好好地跑上一段时间?
运行时 handler 只有 在创建容器的时候 才会被查。已经在运行的容器,配置变了也不受影响。所以故障不会立刻暴露,而是在节点重启或者 Pod 重建的时刻一口气炸开。这段潜伏期让 GPU 配置事故格外昂贵。
4. 修生产节点配置的脚本,为什么要做成「扒掉重写」而不是「追加」?
为了让它执行两次也给出同样的结果。追加型命令跑两次,同一张 TOML 表就会被声明两次,containerd 把这看成 duplicated tables 语法错误,拒绝启动。整台节点会下线。人是会粘贴两次的,所以得由命令这一侧来扛住。
5. containerd 是 active,节点却 NotReady,kubelet 说「container runtime is down」。该看哪里?
去 containerd 日志里找插件加载失败。可能是进程起来了、只有 CRI 插件失败的状态。这次的情况是 fs.inotify.max_user_instances 的默认值 128 被耗尽,做不出 CNI 配置的监视器。systemctl status 看着是正常的,所以必须去看日志。
参考资料
현재 단락 (1/148)
在[上一篇](/blog/kubernetes/gpu-operator-containerd-runtime-toolkit)里,我把 GPU Operator 装了起来,并确认配置不是落在 `co...