Symptoms
- Inference pods show
EvictedwithThe node was low on resource: ephemeral-storage. - New pods stay
Pendingon GPU nodes with the taintnode.kubernetes.io/disk-pressure. - Model downloads fail part way with
No space left on device(OSError: [Errno 28]). - Image pulls fail or take forever because kubelet keeps garbage-collecting and re-pulling the same 15 GB image.
GPU nodes fill up faster than normal nodes. Each model revision is tens of GB, serving images with CUDA libraries are often 10 GB or more, and every pod that downloads weights into emptyDir writes them again.
Step 1: Confirm it's disk and find the node
kubectl get nodes -o custom-columns=NAME:.metadata.name,DISK:.status.conditions[?(@.type==\"DiskPressure\")].status
kubectl get pods -A --field-selector=status.phase=Failed -o wide | grep -i evicted
kubectl describe node <gpu-node> | grep -A6 -E "Conditions|Taints|ephemeral-storage"
Check what kubelet thinks is left:
kubectl get --raw "/api/v1/nodes/<gpu-node>/proxy/stats/summary" \
| jq '{nodefs: .node.fs | {availableBytes, capacityBytes},
imagefs: .node.runtime.imageFs | {availableBytes, capacityBytes}}'
nodefs holds pod logs, emptyDir volumes and writable layers. imagefs holds container images. On many nodes they are the same disk.
Step 2: Find what uses the space
Get a shell on the node (kubectl debug node/<gpu-node> -it --image=ubuntu, then chroot /host), or use SSH or SSM:
df -h / /var/lib/containerd /var/lib/kubelet
du -xh --max-depth=2 /var/lib/kubelet/pods 2>/dev/null | sort -rh | head -15
du -xh --max-depth=1 /var/lib/containerd 2>/dev/null | sort -rh | head
crictl images | sort -k4 -h | tail -10
Then list per-pod ephemeral usage from the cluster side:
kubectl get --raw "/api/v1/nodes/<gpu-node>/proxy/stats/summary" \
| jq -r '.pods[] | [.podRef.namespace, .podRef.name, (."ephemeral-storage".usedBytes/1e9|floor)] | @tsv' \
| sort -k3 -n -r | head
| What you find | Likely cause | Go to |
|---|---|---|
Big /var/lib/kubelet/pods/<uid>/volumes/kubernetes.io~empty-dir | Model downloaded into emptyDir per pod | Step 3 and Step 5 |
| Hugging Face cache on a hostPath with many revisions | Old snapshots never cleaned | Step 4 |
Many large images in crictl images | Old serving image versions left behind | Step 3 |
Large /var/log/pods | Model server logging every request at debug level | Lower the log level, then restart the pod |
Step 3: Free space quickly
Restore service first, then clean up properly.
Delete evicted pod objects. They don't hold disk, but they clutter triage:
kubectl delete pods -A --field-selector=status.phase=FailedRemove unused images. This only removes images that no container is using:
crictl rmi --pruneRestart the worst
emptyDirconsumer if it is a replica you can lose. ItsemptyDiris deleted with the pod. Check the PodDisruptionBudget and remaining capacity first:kubectl get pdb -n <ns> kubectl delete pod <pod> -n <ns>
Caution
Don't delete files under
/var/lib/containerdor/var/lib/kubeletby hand. You can corrupt the runtime's metadata and lose the node. Usecrictl, delete pods, or drain and replace the node.
If space doesn't come back, cordon and drain the node and let the autoscaler or node pool replace it. On managed node groups a fresh node is often faster than cleaning one up:
kubectl cordon <gpu-node>
kubectl drain <gpu-node> --ignore-daemonsets --delete-emptydir-data --timeout=10m
Step 4: Clean a shared Hugging Face cache
If models are cached on a hostPath or a shared volume (HF_HOME or HF_HUB_CACHE), old revisions pile up every time someone changes --revision or a repo is updated.
Recent huggingface_hub releases ship the hf CLI. Older releases use huggingface-cli scan-cache and delete-cache, so check hf --help on your version.
export HF_HUB_CACHE=/models/hf/hub # wherever your servers point
hf cache ls # repos and their size
hf cache prune # remove detached revisions no branch or tag points to
hf cache rm model/<org>/<old-model> -y
A version-independent option is the Python API, which also gives you a dry run:
from huggingface_hub import scan_cache_dir
info = scan_cache_dir("/models/hf/hub")
print(f"total {info.size_on_disk/1e9:.1f} GB")
for repo in sorted(info.repos, key=lambda r: r.size_on_disk, reverse=True)[:10]:
print(f"{repo.size_on_disk/1e9:7.1f} GB {repo.repo_id} revisions={len(repo.revisions)}")
# Keep only revisions that a ref (e.g. main) points to
old = [rev.commit_hash for repo in info.repos for rev in repo.revisions if not rev.refs]
strategy = info.delete_revisions(*old)
print("would free", strategy.expected_freed_size_str)
# strategy.execute() # uncomment after checking nothing running uses these revisions
Warning
A model server that pinned a commit hash (not a branch) is using a revision with no ref. Check the
--revisionof every running deployment before you delete detached revisions, or the next restart will download the model again or fail if the node is offline.
Step 5: Fix the pod spec
Most repeat incidents come from pods that write models to node disk with no limit.
spec:
containers:
- name: vllm
image: vllm/vllm-openai:<pinned-tag>
env:
- name: HF_HOME
value: /models/hf
resources:
requests:
ephemeral-storage: "20Gi" # logs, temp files, compile caches
limits:
ephemeral-storage: "40Gi"
volumeMounts:
- name: models
mountPath: /models
volumes:
- name: models
persistentVolumeClaim:
claimName: model-cache # ReadOnlyMany or ReadWriteMany if shared
| Option | Good for | Watch out for |
|---|---|---|
emptyDir with sizeLimit | Small models, few restarts | Downloads again on every pod start |
| PVC / shared file system (Azure Files, EFS, FSS) pre-filled by a Job | Several replicas, same model | Throughput of the file system affects load time |
| Node-local NVMe with a cache DaemonSet | Fastest load | You own cleanup of that disk |
| Weights baked into the image | Air-gapped clusters | Huge images make imagefs pressure worse |
With ephemeral-storage limits set, kubelet evicts the single pod that goes over its limit instead of putting the whole node under DiskPressure.
Step 6: Tune node disk and image garbage collection
- Make GPU node pools' OS disks larger than general-purpose pools. Size them for at least two serving images plus the largest model you cache locally, with room to spare.
- If you can configure kubelet, check
imageGCHighThresholdPercentandimageGCLowThresholdPercent, and theevictionHardvaluenodefs.available. Managed services expose these differently or not at all; check your platform's node configuration options. - Pin and reuse serving image tags across deployments. Five teams using five patch versions of the same 12 GB image means 60 GB of images per node.
Prevent it next time
Alert on node disk before kubelet does. In Prometheus with node-exporter:
(node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay"}) < 0.20Pair it with an alert on
kube_node_status_condition{condition="DiskPressure",status="true"} == 1from kube-state-metrics.Run a scheduled Job that prunes detached Hugging Face revisions on shared caches and reports the size of the cache.
Require
ephemeral-storagerequests and limits on GPU workloads through admission policy (Kyverno, Gatekeeper or Azure Policy for AKS).Pre-pull the standard serving image onto new GPU nodes with a DaemonSet so the first scale-out doesn't start with a 10-minute pull.