Split View: 세 줄짜리 설정 파일이 GPU 4장을 일주일 동안 죽였다 — containerd 드롭인 병합의 진실
세 줄짜리 설정 파일이 GPU 4장을 일주일 동안 죽였다 — containerd 드롭인 병합의 진실
- 들어가며 — 파일은 있는데 설정은 없다
- 첫 번째 오진 — "메인 설정이 드롭인을 덮는다"
- 두 번째 오진 — "version = 2 가 빠졌다"
- 실험 — 드롭인을 하나씩 뺀다
- 규칙 — 병합은 필드 단위가 아니다
- 왜 일주일 동안 아무도 몰랐나
- 고치기 — 몇 번을 돌려도 같은 결과로
- 여진 — containerd는 살아 있는데 노드가 죽는다
- 검증 — 숫자가 아니라 파드로
- 그래서 무엇을 하면 좋은가
- 정리
- 🧠 이해도 체크 퀴즈
- 참고 자료
들어가며 — 파일은 있는데 설정은 없다
앞선 글에서 GPU Operator를 올리고, 설정이 config.toml 이 아니라 conf.d/99-nvidia.toml 이라는 드롭인 파일에 심긴다는 것을 확인했습니다. 그 글을 이렇게 맺었습니다.
이 설계는 의도적입니다. 호스트가 원래 갖고 있던 설정과 섞이지 않으므로, Operator를 제거하면 드롭인 파일만 지우면 되고 호스트는 원래 상태로 돌아갑니다.
맞는 말입니다. 다만 한 가지 조건이 빠져 있었습니다. 드롭인이 그 하나뿐일 때 그렇습니다.
일주일 뒤, 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
실습 파드가 뜨지 못했습니다. 그런데 파일을 보면 아무 문제가 없습니다.
$ ssh omen 'grep -c "runtimes.nvidia" /etc/containerd/conf.d/99-nvidia.toml'
6
nvidia, nvidia-cdi, nvidia-legacy 세 런타임이 BinaryName 까지 정확하게 적혀 있습니다. 파일은 있습니다.
첫 번째 오진 — "메인 설정이 드롭인을 덮는다"
로드된 설정을 봤습니다.
$ 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 병합은 테이블 단위이고, 메인 파일이 그 테이블을 이미 들고 있으니 드롭인의 같은 테이블이 버려지는 것이다. 그래서 메인 파일에 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 은 세 줄짜리입니다. 사설 레지스트리가 평문 HTTP라서 인증서 디렉터리를 가리키려고 제가 일주일 전에 넣은 것입니다.
# LabHub: Harbor(HTTP) 레지스트리 신뢰
[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
또 틀렸습니다.
두 번 다 그럴듯했고 두 번 다 틀렸습니다. 여기서 방식을 바꿨습니다. 설정 파일을 읽고 추론하는 것을 그만두고, 하나씩 빼면서 결과가 달라지는지 보기로 했습니다.
실험 — 드롭인을 하나씩 뺀다
containerd --config <파일> config dump 는 살아 있는 프로세스를 건드리지 않고 임의의 설정 파일을 읽혀 볼 수 있습니다. 운영 노드에서 아무것도 바꾸지 않고 측정할 수 있다는 뜻입니다.
$ 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 "$f 만 import → "
containerd --config /tmp/t.toml config dump 2>/dev/null | grep -c "runtimes.nvidia"
done
99-nvidia 만 import → 6
zz-labhub-registry 만 import → 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 런타임이 여섯 번 나옵니다. 그런데 레지스트리 세 줄을 함께 읽히면 0이 됩니다. 런타임 이야기는 한 글자도 없는 파일이 런타임 셋을 지웠습니다.
규칙 — 병합은 필드 단위가 아니다
containerd의 imports 병합은 플러그인 단위 통째 교체입니다.
드롭인이 어떤 플러그인의 설정을 건드리면, 그 플러그인의 설정 전체가 그 파일의 내용으로 바뀝니다. 드롭인이 적지 않은 항목은 앞 파일의 값으로 돌아가는 것이 아니라 기본값이 됩니다.
드롭인은 이름순으로 읽힙니다. 그래서 결국 그 플러그인을 언급한 마지막 파일이 전부 가져갑니다.
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 는 어차피 기본 런타임입니다.
그리고 런타임 핸들러는 컨테이너를 만들 때만 조회됩니다. 이미 돌고 있던 GPU 파드는 설정이 사라져도 멀쩡히 계속 돕니다. 그래서 고장이 즉시 보이지 않고, 노드를 재부팅하거나 파드가 재생성되는 순간에야 한꺼번에 터집니다.
이 두 가지가 겹쳐서 일주일이 지났습니다.
고치기 — 몇 번을 돌려도 같은 결과로
원인을 알았으니 고치는 것은 간단합니다. 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("레지스트리 설정이 사라집니다. 원본은 그대로 둡니다.")
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가 먼저 터졌을 뿐, 나머지도 운이 좋았던 것뿐입니다. 쿠버네티스 노드라면 미리 올려 두는 편이 낫습니다.
검증 — 숫자가 아니라 파드로
$ 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
여기서 멈추면 안 됩니다. 광고는 되는데 파드가 안 뜨는 상태가 있습니다. 실제로 돌려 봅니다.
$ kubectl -n gpu-operator logs gputest
GPU 0: NVIDIA GeForce RTX 4070 Laptop GPU (UUID: GPU-53be3556-a942-531c-a9a5-20e92af45279)
파드가 스케줄되고, nvidia 런타임으로 샌드박스가 만들어지고, 컨테이너 안에서 nvidia-smi 가 카드를 봅니다. 여기까지 와야 끝입니다.
그래서 무엇을 하면 좋은가
하나. 파일을 읽어서 결론 내리지 않습니다. cat 이 보여 주는 것은 누군가 쓴 의도이고, config dump 가 보여 주는 것은 실제로 로드된 것입니다. GPU 설정 사고의 대부분은 이 둘이 다른데 앞의 것만 본 데서 옵니다.
둘. 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. 드롭인 파일에 nvidia 런타임이 분명히 적혀 있는데 config dump 에는 없습니다. 무엇을 의심해야 하나요?
같은 플러그인을 언급하면서 이름순으로 더 뒤에 오는 드롭인이 있는지 봅니다. containerd의 imports 병합은 필드 단위가 아니라 플러그인 단위 통째 교체라서, 그 플러그인을 언급한 마지막 파일이 설정 전체를 가져갑니다. 그 파일이 런타임을 적지 않았다면 런타임은 기본값으로 돌아갑니다.
2. config dump 에 runc와 sandbox_image가 정상으로 보이는데도 설정이 날아갔을 수 있는 이유는 무엇인가요?
남아 있는 값이 우리가 쓴 값이 아니라 기본값이기 때문입니다. containerd의 기본 런타임은 runc이고 기본 sandbox_image는 kubeadm이 쓰는 것과 같아서, 설정이 통째로 사라진 상태와 정상 상태의 dump가 비슷하게 보입니다. 구분하려면 드롭인을 하나씩 빼면서 dump가 달라지는지 봐야 합니다.
3. 런타임 설정이 사라졌는데도 GPU 파드가 한동안 멀쩡히 도는 이유는 무엇인가요?
런타임 핸들러는 컨테이너를 만들 때만 조회됩니다. 이미 실행 중인 컨테이너는 설정이 바뀌어도 영향을 받지 않습니다. 그래서 고장이 즉시 드러나지 않고, 노드 재부팅이나 파드 재생성 시점에 한꺼번에 터집니다. 이 잠복이 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 는 정상으로 보이므로 반드시 로그를 봐야 합니다.
참고 자료
A Three-Line Config File Killed Four GPUs for a Week — How containerd Drop-in Merging Really Works
- Opening — the file is there, the config is not
- First misdiagnosis — "the main config overrides the drop-in"
- Second misdiagnosis — "version = 2 is missing"
- The experiment — take the drop-ins away one at a time
- The rule — merging is not field by field
- Why nobody noticed for a week
- The fix — same result no matter how many times it runs
- Aftershock — containerd is alive but the node is dead
- Verification — with a pod, not a number
- So what should you actually do
- Summary
- 🧠 Comprehension Check Quiz
- References
Opening — the file is there, the config is not
In the previous post I brought up the GPU Operator and confirmed that its configuration lands not in config.toml but in a drop-in file, conf.d/99-nvidia.toml. I closed that post like this.
This design is deliberate. Since it never mixes with the configuration the host already had, removing the Operator only means deleting the drop-in file, and the host goes back to its original state.
That is correct. One condition was missing, though. It holds only when that drop-in is the only one.
A week later, all four GPU nodes stopped advertising GPUs.
$ 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
Lab pods could not start. And yet the file looks perfectly fine.
$ ssh omen 'grep -c "runtimes.nvidia" /etc/containerd/conf.d/99-nvidia.toml'
6
All three runtimes — nvidia, nvidia-cdi, nvidia-legacy — are spelled out correctly, down to BinaryName. The file is there.
First misdiagnosis — "the main config overrides the drop-in"
I looked at the loaded configuration.
$ 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]
Only runc. And line 111 caught my eye. The main config.toml was defining the runtimes table directly.
A plausible hypothesis fell into place immediately. TOML merging happens at the table level, and since the main file already holds that table, the drop-in's copy of the same table gets thrown away. So I told people to put the nvidia block directly into the main file.
Wrong. And that instruction took a node down.
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.
I had handed out a command that appends with >>, and if you paste it twice the same table gets declared twice. TOML treats that as a syntax error, and containerd refuses to start. The whole node went down.
The first lesson arrived before the diagnosis did. A command you hand someone to run on a production node has to produce the same result when it runs twice. People paste things twice.
Second misdiagnosis — "version = 2 is missing"
Even after clearing out the duplicate, nvidia still would not show up. Going back through conf.d, a second file caught my attention.
$ ssh omen 'ls /etc/containerd/conf.d/'
99-nvidia.toml
zz-labhub-registry.toml
zz-labhub-registry.toml is three lines long. I added it a week earlier to point at a certificate directory, because the private registry serves plain HTTP.
# LabHub: trust the Harbor (HTTP) registry
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
What stood out was the missing version = 2 line. There is a well-known story that when the version marker is absent from a containerd config, it gets treated as v1 and plugin keys are interpreted differently. A second hypothesis formed, and this time I measured before applying it.
$ # dump using a copy of the zz file with version = 2 added
$ containerd --config /tmp/t2.toml config dump 2>/dev/null | grep -c "runtimes.nvidia"
0
Wrong again.
Both hypotheses were plausible and both were wrong. That is where I changed my approach. I stopped reading config files and reasoning about them, and decided to remove them one at a time and watch whether the result changed.
The experiment — take the drop-ins away one at a time
containerd --config FILE config dump lets you feed containerd an arbitrary config file without touching the running process. Which means you can measure on a production node while changing nothing.
$ 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 only → "
containerd --config /tmp/t.toml config dump 2>/dev/null | grep -c "runtimes.nvidia"
done
import 99-nvidia only → 6
import zz-labhub-registry only → 0
$ # both
$ 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
The third line is the answer.
Load 99-nvidia.toml alone and the nvidia runtime shows up six times. Load the three-line registry file alongside it and you get 0. A file that does not say a single word about runtimes wiped out three runtimes.
The rule — merging is not field by field
containerd's imports merging is wholesale per-plugin replacement.
If a drop-in touches any part of a plugin's configuration, that plugin's entire configuration is replaced with the contents of that file. Anything the drop-in does not spell out does not fall back to the value from the earlier file — it becomes the default.
Drop-ins are read in name order. So in the end, the last file that mentions a plugin takes all of it.
zz- sorts after 99-. Which is how these three lines replaced the entire CRI plugin configuration.
load order CRI plugin config
───────────────── ────────────────────────────────────
config.toml runc, sandbox_image, cgroup settings …
99-nvidia.toml runc + nvidia ×3, certs.d … ← replaces the above wholesale
zz-registry.toml config_path, nothing else ← replaces it wholesale again
───────────────── ────────────────────────────────────
final config_path + everything else default
Put the two files side by side and merge them in your head and you count four runtimes. What actually got loaded was one.
Why nobody noticed for a week
The genuinely frightening part of this failure is not the merge rule. It is that there are no symptoms.
Here is the config dump from the broken state again.
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
runtime_type = "io.containerd.runc.v2"
sandbox_image = "registry.k8s.io/pause:3.8"
runc is there. sandbox_image looks plausible. The configuration looks alive.
All of it is defaults. Not one of the values we wrote survived, but the defaults look close enough to what we would have written that nothing stands out. pause:3.8 is exactly what kubeadm uses, and runc is the default runtime anyway.
And the runtime handler is looked up only when a container is created. GPU pods that were already running keep running fine even after the configuration disappears. So the breakage is not visible right away — it all detonates at once the moment you reboot a node or the pods get recreated.
Those two things combined bought a week.
The fix — same result no matter how many times it runs
Once you know the cause the fix is simple. Move zz-labhub-registry.toml out of conf.d. The config_path value that file was holding is already in 99-nvidia.toml.
This time, though, I did not hand out a one-line command. I had already taken a node down that way. I wrote a script instead, and gave it two properties.
First, it does not append. It strips out everything that is there and then writes exactly one copy back. Zero previous appends, one, or two — the result is the same.
def strip_nvidia(lines):
"""Strip out the nvidia table and all of its sub-tables."""
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
Second, it touches the live file last. It builds the candidate configuration as a temporary file, has containerd read that file to confirm nvidia and certs.d actually come through, and only then swaps it in. If verification fails, the original is not changed by a single character.
rc, out, err = dump(candidate) # containerd --config <candidate> config dump
if rc != 0 or out.count("runtimes.nvidia") == 0:
print("The candidate config does not pick up the nvidia runtime. Leaving the original alone.")
return 1
if "/etc/containerd/certs.d" not in out:
print("The registry config would disappear. Leaving the original alone.")
return 1
# from here on we actually change things
The second check is the important one. It deletes the file only after confirming that some other file carries the configuration the doomed file was holding. Delete without that check and I would have revived the GPUs and cut off image pulls.
Aftershock — containerd is alive but the node is dead
I applied it to all four nodes and restarted. Three came straight back, and one stayed NotReady.
$ ssh omen2 'systemctl is-active containerd'
active
$ kubectl describe node omen2 | grep -A1 "Ready "
Ready False KubeletNotReady container runtime is down
containerd is active but kubelet says the runtime is dead. The logs have the answer.
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"
The process came up, and only the CRI plugin failed to load. The default fs.inotify.max_user_instances of 128 had been exhausted. Look at systemctl status alone and the state appears healthy.
$ 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
All five nodes were on the default of 128 with no config file at all. omen2 just blew up first; the rest were only lucky. On a Kubernetes node you are better off raising this ahead of time.
Verification — with a pod, not a number
$ 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
Do not stop here. There is a state where the advertisement works but pods still will not start. Actually run one.
$ kubectl -n gpu-operator logs gputest
GPU 0: NVIDIA GeForce RTX 4070 Laptop GPU (UUID: GPU-53be3556-a942-531c-a9a5-20e92af45279)
The pod schedules, the sandbox is created with the nvidia runtime, and nvidia-smi inside the container sees the card. You are only done once you get this far.
So what should you actually do
One. Do not draw conclusions from reading files. What cat shows you is what somebody intended to write; what config dump shows you is what actually got loaded. Most GPU configuration incidents come from those two differing while only the former gets looked at.
Two. Even when reading the dump, tell your own values apart from defaults. That is why this incident stayed hidden for a week. The only way to tell them apart is to remove things one at a time and see whether the result changes.
Three. Look at the name before you drop one more file into conf.d. If that file sorts last and mentions any plugin, it becomes responsible for that plugin's entire configuration. Write only three lines and everything else becomes a default.
Four. Commands you hand out for production nodes have to be the same when run twice. People paste things twice. I handed out a command that could not survive that, and it took a node down.
Five. For irreversible changes, touch the live target last. Build a candidate, verify it, and swap only if verification passes. This ordering makes the script a few lines longer and drives the cost of being wrong to zero.
Summary
| Item | Value |
|---|---|
| containerd | 1.7.27 |
| Symptom | 4 nodes stopped advertising nvidia.com/gpu |
| File state | Fine (3 runtimes in 99-nvidia.toml) |
| Actual cause | Three lines of zz-labhub-registry.toml replaced the entire CRI config |
| Merge rule | Wholesale per-plugin replacement, last file in name order wins |
| Why it hid | Everything left was a default, and the defaults look plausible |
| Aftershock | inotify limit exhausted, only the CRI plugin failed to load |
| Misdiagnoses | 2 |
The lesson that sticks longest is about the diagnostic method. Both wrong diagnoses were hypotheses built by reading config files, and what gave me the answer was an experiment that removed things one at a time and measured the result. Confidence gained by reading is not verification.
🧠 Comprehension Check Quiz
1. The nvidia runtime is clearly written in a drop-in file, but it is not in config dump. What should you suspect?
Look for another drop-in that mentions the same plugin and sorts later by name. containerd's imports merging is not field by field but wholesale per-plugin replacement, so the last file that mentions a plugin takes the whole configuration. If that file did not write out the runtimes, the runtimes revert to defaults.
2. Why can the configuration be gone even though runc and sandbox_image look normal in config dump?
Because the values that remain are not the ones we wrote — they are defaults. containerd's default runtime is runc and its default sandbox_image is the same one kubeadm uses, so a dump from a completely wiped configuration looks a lot like a healthy one. To tell them apart you have to remove the drop-ins one at a time and see whether the dump changes.
3. Why do GPU pods keep running fine for a while even after the runtime configuration is gone?
The runtime handler is looked up only at container creation time. Containers that are already running are unaffected by a configuration change. So the breakage does not surface immediately, and it all detonates at once on a node reboot or when pods get recreated. That latency is what makes GPU configuration incidents unusually expensive.
4. Why write a script that fixes production node configuration as "strip and rewrite" instead of "append"?
So that running it twice produces the same result. Run an appending command twice and the same TOML table gets declared twice, and containerd treats that as a duplicated tables syntax error and refuses to start. The whole node goes down. People paste things twice, so the command has to survive it.
5. containerd is active, but the node is NotReady and kubelet says "container runtime is down". What should you look at?
Look for a plugin load failure in the containerd logs. The process may have come up with only the CRI plugin failing. In this case the default fs.inotify.max_user_instances of 128 was exhausted, so it could not create the CNI config watcher. systemctl status looks healthy, so you have to read the logs.