- 들어가며 — 파일은 있는데 설정은 없다
- 첫 번째 오진 — "메인 설정이 드롭인을 덮는다"
- 두 번째 오진 — "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 는 정상으로 보이므로 반드시 로그를 봐야 합니다.
참고 자료
현재 단락 (1/148)
[앞선 글](/blog/kubernetes/gpu-operator-containerd-runtime-toolkit)에서 GPU Operator를 올리고, 설정이 `config.to...