{"id":"IL-0071","org":"Onkar Raghunath Shelke","date":"2026-09-17","docs":["https://dev.to/onkar_s/kubelet-watches-inodes-just-not-until-its-an-emergency-351c"],"title":{"en":"Worker node running out of inodes while disk space looked fine, because containerd kept a full node_modules tree in each of several image layer snapshots","ko":"작은 파일이 4만 개 넘게 든 단일 스테이지 이미지의 containerd 스냅샷이 쌓여 워커 노드의 inode 가 디스크보다 빠르게 소진되고 있었다"},"domain":["kubernetes","observability"],"impact":{"scope":"one worker node trending towards inode exhaustion; no outage reported","users":"unknown","region":"unknown"},"review":{"written_by":"session","reviewed_at":null,"reviewed_by":null},"lessons":{"en":"kubelet's background cleanup works on bytes only. A workload heavy in small files gets no early intervention for inodes, and the first thing kubelet does at 5% free inodes is evict pods. Watch inode use on nodes separately from disk use, and keep dependency trees out of runtime images.","ko":"kubelet 의 백그라운드 정리는 바이트를 기준으로 돈다. 용량보다 파일 수가 먼저 차는 워크로드에는 미리 손쓸 장치가 없다. kubelet 은 inode 가 5% 남았을 때 파드 축출로 처음 개입한다. 파드 축출은 노드를 지키는 마지막 수단일 뿐 해결책이 아니다.\n\next4 의 inode 수는 포맷할 때 정해진다. 작은 파일이 많으면 디스크가 넉넉해도 inode 가 먼저 바닥난다.\n\n추세 알림은 증가가 잠시 멈추면 조용해진다. kubelet 의 95% eviction 지점보다 한참 앞에서 울리는 고정 기준 알림을 함께 둔다.\n\nnode_modules 뿐 아니라 Python virtualenv, 벤더링한 PHP 패키지, Ruby gem 처럼 작은 파일이 많은 의존성 트리를 담은 이미지는 모두 같은 문제를 일으킨다."},"related":[],"sources":[{"url":"https://www.cncf.io/blog/2026/10/05/kubelet-watches-inodes-just-not-until-its-an-emergency/","kind":"blog","license":"cc-by","archived":null}],"summary":{"en":"The application image was built in one stage, so it carried the source, the build output and the whole node_modules tree including development dependencies: more than 40,000 files, over half of them in the @mui/icons-material package. containerd's content store deduplicates compressed layers by digest, but the overlayfs snapshotter unpacks every distinct layer into its own directory, so each new build with a different layer digest added another full copy of those files to every node that pulled it. The snapshots stay until the image is removed.\n\nkubelet removes unused images only through image garbage collection, which starts when the image filesystem passes 85% of bytes and stops at 80%. It never looks at file counts. kubelet does have inode thresholds on Linux, nodefs.inodesFree and imagefs.inodesFree at 5% free, but those are hard eviction thresholds: the first action kubelet takes when inodes run low is to evict pods. Because the files were small, inodes were being used up much faster than bytes. The author works out from the df figures that the root filesystem holds about 16.3 million inodes, more than the stock mkfs ratio of one inode per 16,384 bytes allows for its size, and that it was probably formatted at about 8,192 bytes per inode; this is arithmetic, not a capture, because the author no longer has access to the cluster. Kubelet, containerd and the node image all ran on defaults.\n\nThe alert caught the trend at 67% inode use, before kubelet's eviction threshold was reached. The author's fixes are a multi-stage Dockerfile that ships only the compiled output, a flat inode usage alert alongside the predictive one, and pruning unused images on the node.","ko":"글쓴이가 운영하던 클러스터에서 워커 노드 한 대의 inode 가 바닥날 추세라는 알림이 왔다. 디스크 사용률은 83%, inode 사용률은 67% 였다. 알림이 장애로 이어지기 전에 문제를 잡았다. 글쓴이는 지금 그 클러스터에 접근할 수 없어서, 일부 수치는 장애 기록에서 가져왔고 나머지는 계산으로 되짚었다고 밝혔다.\n\nkubelet 은 이미지가 노드를 채우는 것을 이미지 가비지 컬렉션으로 막는다. 이미지 파일 시스템 사용률이 imageGCHighThresholdPercent 의 기본값인 85 를 넘으면, kubelet 은 쓰지 않는 이미지를 오래된 것부터 지워 imageGCLowThresholdPercent 의 기본값인 80 까지 내린다. 두 값 모두 바이트 기준이고 파일 수는 보지 않는다. 리눅스의 기본 하드 eviction 설정에는 nodefs.inodesFree 와 imagefs.inodesFree 가 모두 5% 로 들어 있다. 그러나 이는 노드를 지키려고 파드를 축출하는 마지막 단계다. 바이트에는 85% 에서 도는 이미지 가비지 컬렉션과 하드 eviction 두 장치가 있지만, inode 에는 하드 eviction 하나뿐이다. inode 가 모자랄 때 처음 일어나는 일이 가장 큰 영향을 주는 일이다.\n\next4 는 파일 시스템을 만들 때 inode_ratio 로 inode 수를 정하고 그 뒤로 바꾸지 않는다. 기본 /etc/mke2fs.conf 값은 16384 다. 비어 있지 않은 파일은 아무리 작아도 4 KiB 블록 하나를 쓰므로, 작은 파일만 있으면 디스크의 25% 를 쓴 시점에 inode 가 바닥난다. 이 노드는 inode 가 모두 약 1,630만 개로, 계산상 8192 정도의 비율로 포맷된 것으로 보였다. 이 비율이면 작은 파일만 있을 때 디스크의 50% 를 쓴 시점에 inode 가 바닥난다.\n\n문제는 클러스터가 아니라 이미지였다. kubelet, containerd, 노드 이미지는 모두 기본값으로 돌고 있었다. 이미지는 단일 스테이지 빌드여서 소스, 빌드 결과물, 개발 의존성을 포함한 node_modules 가 모두 들어갔다. containerd 의 콘텐츠 저장소는 압축된 레이어를 다이제스트로 중복 제거하지만, overlayfs 스냅샷터는 레이어마다 따로 풀고 서로 공유하지 않는다. .dockerignore 에서 node_modules 를 빼지 않았거나 CI 가 레이어 캐시 없이 매번 npm install 을 다시 돌리면, 내용이 같아도 다이제스트가 달라져 새 스냅샷이 생긴다. 이 스냅샷은 이미지가 지워질 때까지 남고, kubelet 은 바이트 기준 85% 에 닿아야 이미지를 지운다. 사람들이 주로 보는 디스크 사용률이 기준선을 넘지 않았기 때문에 아무도 알아채지 못했다."},"trigger":{"en":"Several versions of a single-stage Node.js image, each shipping more than 40,000 files, were held on the node; containerd unpacked every layer with a distinct digest into its own snapshot, each with a full copy of node_modules.","ko":"단일 스테이지로 빌드한 이미지에 소스, 빌드 결과물, 개발 의존성까지 포함한 node_modules 전체가 들어갔고, 새 레이어 다이제스트가 생길 때마다 노드에 새 스냅샷이 풀렸다."},"severity":"unknown","symptoms":["node-disk-full"],"timeline":{"detected_after":null,"resolved_after":null},"date_kind":"published","detection":{"en":"The NodeFilesystemFilesFillingUp alert from the node_exporter mixin fired on the predicted inode trend while inode use was at 67%.","ko":"node_exporter mixin 에 들어 있는 NodeFilesystemFilesFillingUp 알림이 inode 사용률 67% 에서 울렸다. predict_linear 로 inode 가 바닥날 시점을 예측하는 추세 알림이다."},"components":["node-disk","kubelet","container-runtime"],"root_cause":"capacity","remediation":[],"action_items":[{"text":{"en":"Build the image in multiple stages so the final image contains only the compiled assets","ko":"멀티 스테이지 Dockerfile 로 바꿔, 한 스테이지에서 의존성을 설치하고 빌드한 뒤 최종 이미지에는 nginx:alpine 위에 컴파일된 결과물만 넣는다."}},{"text":{"en":"Add a flat alert on root filesystem inode use above 80% for 30 minutes alongside the predictive alert","ko":"추세 알림과 별도로 루트 파일 시스템의 inode 사용률이 0.80 을 넘으면 울리는 고정 기준 알림을 for: 30m 으로 둔다."}},{"text":{"en":"Put a Prometheus query on a dashboard that shows, per node, inode usage minus disk usage, sorted from the highest","ko":"노드마다 inode 사용률에서 디스크 사용률을 뺀 값을 큰 순서로 보여 주는 Prometheus 쿼리를 대시보드에 둔다."}},{"text":{"en":"Remove unused images from nodes with crictl rmi --prune to free old snapshots","ko":"노드에 이미 쌓인 오래된 스냅샷은 crictl rmi --prune 으로 어떤 컨테이너도 쓰지 않는 이미지를 지워 정리한다."}}],"contributing":["default-config-unchanged","monitoring-blind-spot","limit-unknown"],"recurrence_of":null,"symptoms_text":{"en":"A Kubernetes worker node paged with NodeFilesystemFilesFillingUp, the node_exporter mixin alert that predicts a filesystem will run out of inodes. The engineer checked disk space first: df -h showed the root filesystem 83% used, 103G used and 22G available. df -i showed inodes 67% used, 11M used and 5.3M free.\n\nThe disk was fuller than the inode table, yet the inode table was what paged. The alert is built on predict_linear, so it fires on how fast inodes are being consumed, not on the current level; 67% was not a problem in itself, the rate of growth was.\n\ndu --inodes -xS /var showed where the files were: not in logs or pod volumes but in containerd's snapshot store, /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/. Several snapshot directories each held the same path, app/node_modules/@mui/icons-material, with 21,553 files apiece.","ko":"워커 노드 한 대에서 NodeFilesystemFilesFillingUp 알림이 울려 담당자가 호출됐다. inode 가 바닥날 추세라는 알림이었다.\n\n담당자는 누구나 그렇듯 디스크부터 봤다. 디스크는 83% 사용 중으로 103G 를 쓰고 22G 가 남아 있었다. inode 는 67% 사용 중으로 11M 을 쓰고 5.3M 이 남아 있었다. 더 찬 쪽은 디스크였지만 알림은 inode 쪽에서 왔다. NodeFilesystemFilesFillingUp 은 predict_linear 로 만든 추세 알림이라, 현재 수준이 아니라 늘어나는 기울기를 보고 울린다.\n\ndu --inodes -xS /var | sort -rh | head -n 20 으로 파일이 많은 디렉터리를 찾았다. 맨 위는 로그도 파드 볼륨도 아니었다. containerd 의 스냅샷 저장소인 /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/ 였다. 여러 스냅샷에 app/node_modules/@mui/icons-material 이라는 같은 경로가 들어 있었고, 스냅샷마다 파일이 21,553개였다. 이미지 하나에 든 파일은 모두 4만 개가 넘었다."},"updated_at":"2026-11-10T00:00:39.461752Z","url":"https://benchlet.com/incidentlib/app/cases/IL-0071","org_slug":"onkar-raghunath-shelke"}