Skip to content

필사 모드: 三行配置文件让 4 张 GPU 死了一周 — containerd drop-in 合并的真相

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

开篇 — 文件在,配置却不在

上一篇里,我把 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。

小结

项目
containerd1.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...

작성 글자: 0원문 글자: 7,538작성 단락: 0/148