RAPID INCIDENT TRIAGE & ACTION PLAN•Performance
01. Error Signature
Kubelet Garbage Collection Device Lock
02. Root Cause Vector

Kubelet fails to garbage collect dead containers because a process or mount holds an active file descriptor lock on the container's overl...

03. Rapid Prescription

Sort lock acquisition order deterministically, or configure explicit session timeouts.

Zero-Trust Client IDE/ Performance

Kubelet Garbage Collection Device Lock

Kubelet fails to garbage collect dead containers because a process or mount holds an active file descriptor lock on the container's overlay filesystem, blocking cleanup and exhausting disk/inode resources.

SYSTEM STATUS
CREDITS: 0 / 3
source.yaml(96L)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
---
# legacy-kubelet-config.yaml
# TODO: This config is a mess, but it's been running in prod for 2 years. Don't touch unless you have to.
# PCI-Secure-Zone Cluster Config v1.2 (DO NOT MODIFY WITHOUT CHANGE BOARD)
# Original author left the company, good luck.

apiVersion: v1
kind: ConfigMap
metadata:
  name: legacy-kubelet-gc-config
  namespace: kube-system
  labels:
    env: pci-secure-zone
    tier: legacy-backend
    # TODO: Clean up these old labels after migration
    # app: deprecated-api-gateway

data:
  # Garbage collection thresholds - tuned for old HDD nodes, might be too aggressive now.
  kubelet-gc-thresholds: |
    --image-gc-high-threshold=90
    --image-gc-low-threshold=85
    --eviction-hard=memory.available<100Mi,nodefs.available<10%,nodefs.inodesFree<5%
    # VULNERABILITY: Device lock contention due to mixed container runtime and local volume GC intervals
    --container-gc-threshold=50
    --maximum-dead-containers=5
    --minimum-container-ttl-duration=30s
    # This next line is critical for PCI compliance but causes race conditions with the local volume manager
    --local-storage-capacity-isolation=false  # DO NOT ENABLE - breaks legacy PVC attachments

  # Old workaround for a Docker bug from 2018, but we switched to containerd last year...
  container-runtime-flags: |
    --container-runtime=remote
    --container-runtime-endpoint=unix:///var/run/containerd/containerd.sock
    --runtime-request-timeout=15m
    # TODO: Remove this docker-specific flag after Q3 migration
    --docker-disable-shared-pid=true  # This flag is ignored by containerd but left for "compatibility"

  # Secrets injected via "secure" env vars (rotated quarterly by the legacy vault sync)
  aws-credentials: |
    AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
    AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
    # TODO: Migrate to IRSA but blocked by networking team

  stripe-api-token: |
    STRIPE_SECRET_KEY=sk_live_4eC39HqLyjWDarjtT1zdp7dc
    # This is for the old billing microservice, still active in EU region

  database-uri: |
    DB_URI="postgresql://app_user:SuperSecret123!@pci-db.internal:5432/legacy_payments?sslmode=require"
    # TODO: Move to RDS Proxy, but the VPC peering is broken

  # Red herring: Old NFS mount config that's commented out but still referenced elsewhere
  # nfs-mounts: |
  #   - server: nfs-old.internal
  #     path: /exports/legacy
  #     mountPath: /mnt/legacy

  # Critical misconfiguration: Two GC processes fighting over the same device
  # The volume manager GC runs every 30s, but the container GC also tries to clean mounts
  volume-gc-schedule: |
    */30 * * * * /opt/legacy-scripts/cleanup-old-mounts.sh  # Runs via cron on the node
    # This script does 'find /var/lib/kubelet/pods -mtime +1 -exec umount {} \;'
    # VULNERABILITY: This cron job runs concurrently with kubelet's own GC, causing "device busy"

---
# This is a DaemonSet fragment that applies the config
# It's in the same file because the original Helm chart couldn't handle separate files
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: kubelet-gc-tuner
  namespace: kube-system
spec:
  template:
    spec:
      hostPID: true  # Needed for the legacy mount cleanup script
      containers:
      - name: gc-tuner
        image: my-registry.internal/legacy-k8s-utils:v0.12.3  # Image from 2020
        command: ["/bin/sh"]
        args: ["-c", "while true; do echo 'GC Tuner running...'; sleep 300; done"]
        envFrom:
        - configMapRef:
            name: legacy-kubelet-gc-config
        # This volume mounts the same hostPath that kubelet uses for pods
        volumeMounts:
        - name: kubelet-pods
          mountPath: /var/lib/kubelet/pods
          readOnly: false  # Big mistake, but required for the cleanup script
      volumes:
      - name: kubelet-pods
        hostPath:
          path: /var/lib/kubelet/pods
          type: DirectoryOrCreate  # This allows the DaemonSet to create locks
AI Audit Console
> Waiting for pre-flight audit...
HybridRedactor Engine: Active
TECHNICAL WHITEPAPER & ARCHITECTURE SPEC•Updated 2026

How to Fix Kubelet Garbage Collection Failures Caused by Device or Resource Busy Locks

Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 15–30 mins

TL;DR

  • What broke: Kubelet's garbage collection loop cannot remove stopped/dead containers because the overlay or devicemapper mount is still held open by a leaked process, a stale bind mount, or a zombie containerd/docker shim.
  • How to fix it: Identify the blocking PID with lsof or fuser, kill or unmount it, then force-trigger GC or restart the container runtime.
  • Shortcut: Use our Client-Side Sandbox above to auto-refactor your kubelet config and generate the exact crictl/nsenter remediation commands for your specific runtime.

▼ 🔍 Root Cause Breakdown & Kernel Diagnostics [Click to Expand Deep Dive]

The Incident (What Does the Error Mean?)

Raw kubelet log output:

E0612 03:14:22.871042    1423 image_gc_manager.go:305] Failed to garbage collect required amount of images. Wanted to free 5368709120 bytes, but only found 0 bytes eligible to free.
E0612 03:14:22.871198    1423 container_gc.go:85]  Failed to garbage collect containers: device or resource busy
E0612 03:14:22.871301    1423 kubelet.go:1302] Image garbage collection failed multiple times in a row: failed to garbage collect required amount of images

Immediate consequence: Kubelet's GC loop is stuck. Dead containers accumulate. Overlay mounts pile up under /var/lib/containerd or /var/lib/docker/overlay2. The node's disk fills, inode table exhausts, and new pod scheduling fails with FailedMount or Evicted events. In severe cases the node goes NotReady.


▼ 🔍 Detailed Impact & Blast Radius Analysis [Click to Expand Deep Dive]

The Attack Vector / Blast Radius

This is a cascading disk exhaustion failure, not a one-pod problem.

  1. Overlay FS leak: Each stopped container leaves an overlay mount. If a process (log forwarder, sidecar shim, nsenter session left open by an operator) holds a file descriptor into that overlay directory, the kernel returns EBUSY on umount2(). Kubelet cannot proceed.

  2. Blast radius escalates fast:

    • Disk fills → new image pulls fail → ImagePullBackOff cluster-wide on that node.
    • Inode exhaustion hits before raw disk in environments with many small files (log-heavy workloads). df -i will show 100% inode usage while df -h shows free space — a notoriously confusing failure mode.
    • Kubelet's eviction manager cannot evict pods fast enough if GC is blocked, triggering a hard node pressure condition.
    • If this node is a control plane node, etcd or API server pods may fail to restart after eviction.
  3. Common culprits:

    • Fluent Bit / Fluentd tailing logs inside a dead container's overlay directory.
    • A kubectl exec or kubectl debug session left open by an operator.
    • Stale containerd-shim or docker-containerd-shim processes not reaped after container exit.
    • auditd or a security agent (Falco, Sysdig) holding an inotify watch on the overlay mount.
    • NFS or CSI volume not unmounted cleanly before container termination.

How to Fix It

Step 1: Identify the blocking mount and PID

# Find all overlay mounts for dead containers
grep overlay /proc/mounts | awk '{print $2}'

# Find what is holding each busy mount
# Replace <mount_path> with the path from above
sudo lsof +D <mount_path> 2>/dev/null
sudo fuser -vm <mount_path> 2>/dev/null

# For containerd: list dead containers
sudo crictl ps -a --state Exited
sudo crictl ps -a --state Created

# For docker runtime:
sudo docker ps -a --filter status=exited --filter status=dead

Step 2: Kill the blocking process or force-unmount

# Kill the offending PID (replace <PID>)
sudo kill -9 <PID>

# If the process is gone but mount is still busy (kernel ref count):
sudo umount -l <mount_path>   # lazy unmount — detaches from namespace immediately

# Force remove dead containers via crictl
sudo crictl rm $(sudo crictl ps -a --state Exited -q)

Basic Fix — Restart the container runtime to reap all stale shims

# containerd
sudo systemctl restart containerd
sudo systemctl restart kubelet

# docker (if using dockershim — EOL, migrate off this)
sudo systemctl restart docker
sudo systemctl restart kubelet

⚠️ Restarting containerd briefly interrupts running pods on the node. Cordon first in production: kubectl cordon <node>

Enterprise Best Practice — Tune kubelet GC thresholds and add eviction safeguards

The default kubelet GC config is too permissive for high-churn workloads. Patch your kubelet config:

# /var/lib/kubelet/config.yaml
  imageGCHighThresholdPercent: 85
- imageGCLowThresholdPercent: 80
+ imageGCLowThresholdPercent: 70

- containerLogMaxSize: "10Mi"
+ containerLogMaxSize: "5Mi"
- containerLogMaxFiles: 5
+ containerLogMaxFiles: 3

+ evictionHard:
+   nodefs.available: "10%"
+   nodefs.inodesFree: "5%"
+   imagefs.available: "15%"

For containerd, ensure shim reaping is not disabled:

# /etc/containerd/config.toml
  [plugins."io.containerd.runtime.v1.linux"]
-   shim_debug = true
+   shim_debug = false
+   no_shim = false

For Fluent Bit (common culprit) — ensure it does not tail into overlay paths directly:

  [INPUT]
      Name              tail
-     Path              /var/lib/containerd/io.containerd.runtime.v2.task/*/log
+     Path              /var/log/containers/*.log
+     DB                /var/log/flb_kube.db
+     Rotate_Wait       5

💡 Tired of pasting proprietary infrastructure configs into public LLMs? Generic AI interfaces log your company's ARNs, connection strings, and private keys. StackEngine enforces client-side zero-trust boundaries—the sandbox above executes 100% locally in your browser. Drop your broken manifest above to redact secrets locally and synthesize deterministic patch drafts with your own API key.

(For CI/CD pipeline automation, install via npm i -g stackengine).


▼ 🔍 Continuous Pipeline Governance & CI/CD Guardrails [Click to Expand Deep Dive]

Prevention in CI/CD

1. Node-level monitoring — alert before disk fills

# Prometheus alerting rule
- alert: NodeDiskPressureImminent
  expr: |
    (node_filesystem_avail_bytes{mountpoint="/var/lib/containerd"} /
     node_filesystem_size_bytes{mountpoint="/var/lib/containerd"}) < 0.15
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "Node {{ $labels.instance }} containerd disk < 15%"

- alert: NodeInodeExhaustion
  expr: |
    node_filesystem_files_free{mountpoint="/"} /
    node_filesystem_files{mountpoint="/"} < 0.05
  for: 2m
  labels:
    severity: critical

2. OPA/Gatekeeper — enforce log size limits on all pods

package k8srequiredloglimits

violation[{"msg": msg}] {
  container := input.review.object.spec.containers[_]
  not container.resources.limits["ephemeral-storage"]
  msg := sprintf("Container '%v' must set ephemeral-storage limit", [container.name])
}

3. Checkov / kube-linter in CI pipeline

# Add to your GitHub Actions / GitLab CI
kube-linter lint ./k8s-manifests/ \
  --checks container-resources,no-read-only-root-fs

checkov -d ./k8s-manifests \
  --check CKV_K8S_11,CKV_K8S_13  # CPU/memory limits enforced

4. Periodic node hygiene CronJob

apiVersion: batch/v1
kind: CronJob
metadata:
  name: containerd-gc-assist
  namespace: kube-system
spec:
  schedule: "0 */4 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          hostPID: true
          tolerations:
          - operator: Exists
          containers:
          - name: gc
            image: alpine:3.19
            securityContext:
              privileged: true
            command:
            - /bin/sh
            - -c
            - |
              crictl rmp --force $(crictl pods --state NotReady -q) 2>/dev/null || true
              crictl rm $(crictl ps -a --state Exited -q) 2>/dev/null || true
          restartPolicy: OnFailure
          nodeSelector:
            kubernetes.io/os: linux

Long-term: If this node runs high-churn batch workloads, dedicate a separate imagefs disk mounted at /var/lib/containerd to isolate container storage from root filesystem pressure entirely.

Official Grounding & Normative Specifications

The technical diagnostics, error recovery protocols, and prevention patterns in this specification are grounded in official vendor documentation and standards:

Diagnostic & Architectural FAQ

Q1.Why does 'device or resource busy' appear even after the container has exited?
The container process is gone, but the kernel still has an active reference count on the overlay mount. This happens when a second process — a log shipper, a debug session, a security agent, or a stale containerd-shim — holds an open file descriptor into the container's overlay upper/work directory. The kernel will not allow umount() until all references are released, returning EBUSY to kubelet's GC loop. This is a fundamental kernel-level resource lock, not a simple cleanup issue.
Q2.Will restarting containerd kill my running pods?
With containerd v1.6+ and the default 'live-restore' equivalent behavior, a brief restart may not kill running containers, but it's a high-risk gamble. Pod networking and exec sessions will be interrupted, and any misconfiguration or version mismatch can cascade into a full node outage. Manual remediation like this has a massive blast radius and opens security gaps by forcing privileged restarts. Drop the broken manifest into the StackEngine Local Sandbox above to automatically compile a zero-trust fix that isolates and resolves the lock without touching the container runtime.
Q3.How do I tell if inode exhaustion is the real cause rather than raw disk space?
Run 'df -i' on the affected node. If the 'IUse%' column shows 100% for the root or containerd filesystem while 'df -h' shows available space remaining, you have inode exhaustion. This is common on nodes running thousands of short-lived containers that each create many small log and metadata files. Manually fixing this across large mono-repos or at scale is a compliance nightmare, requiring invasive node surgery and inconsistent config management. Deploy the StackEngine CI/CD Automator (Team Edition) for on-prem, automated pull request generation with Maker-Checker review that enforces policy and prevents recurrence across the entire fleet.