Amazon EKS

DiskPressure evicted your database to reclaim a few gigabytes of logs — here's how to stop it

When a worker node's root disk fills with container logs and image layers, the kubelet flips DiskPressure, fails image garbage collection, and starts killing pods to save itself. Left to defaults, it can kill your most critical stateful pod. Here is how to hard-code filesystem isolation into the node.

Deep dive·platform & cloud engineers·

Scope & versions. Kubelet node-pressure eviction and image garbage collection as documented for Kubernetes 1.29–1.31 (the behavior is stable across recent EKS versions). Default hard thresholds and the 85%/80% image-GC defaults are Kubernetes defaults inherited by EKS-optimized AMIs. AL2023 node bootstrapping is via nodeadm / NodeConfig; Bottlerocket references its two-volume design. Re-verify the numbers against the linked primary docs before you bet a cluster on them.

The page goes off at 3 a.m. Your primary Postgres pod is Terminating, then gone, and a fresh one is stuck ContainerCreating on a different node. Nothing OOM-killed. CPU was fine. When you describe the pod you find the real story in one line:

Status:   Failed
Reason:   Evicted
Message:  The node was low on resource: ephemeral-storage.
          Container db was using 1.4Gi, which exceeds its
          request of 0.

Events:
  Warning  EvictionThresholded   node/ip-10-0-3-88   attempting
           to reclaim ephemeral-storage
  Warning  ImageGCFailed         node/ip-10-0-3-88   failed to
           garbage collect required amount of images
  Normal   NodeHasDiskPressure   node/ip-10-0-3-88   status is
           now: NodeHasDiskPressure

The chain is brutal and completely mechanical. Some noisy neighbor — a chatty sidecar, a log shipper that stopped shipping, an image the node kept pulling — filled the node's root filesystem. The kubelet noticed free space cross a threshold, raised DiskPressure, tried to reclaim space by deleting images, couldn't free enough, and then did the only other thing it knows how to do: it started evicting pods. Your database happened to sit near the front of that line, because you never told the kubelet it was special.

This is a host-level systems problem, and it wants a host-level fix. You cannot YAML your way out of a full disk from inside a pod. Below: exactly how the kubelet computes the eviction decision, then how to make the node's volatile directories physically incapable of taking your workloads down with them, and finally how to make sure that if something is going to die, it is never the pod that matters.

TL;DR

  • Eviction is a disk-space signal, not a memory event. The kubelet watches nodefs.available, imagefs.available, and their inodesFree counterparts; the default hard thresholds are nodefs.available<10%, imagefs.available<15%, and 5% free inodes. Cross one and the node goes DiskPressure=True.
  • Before it evicts, the kubelet tries to reclaim images. Image garbage collection fires when image-disk usage passes imageGCHighThresholdPercent (default 85%) and deletes least-recently-used images down to 80%. When it can't free enough, you get ImageGCFailed — and eviction begins.
  • Eviction order is not random, and by default it is not on your side. The kubelet ranks victims by whether usage exceeds requests, then by Pod Priority, then by how far over request they are. A critical pod with no ephemeral-storage request and no PriorityClass looks exactly as disposable as a broken log shipper.
  • The durable fix is at the node image, not the manifest. Put the volatile directories — /var/lib/containerd and /var/lib/kubelet — on a dedicated EBS volume so runaway logs and image bloat can fill that disk without ever touching the OS root. Bottlerocket does this by design with a separate data volume; on AL2023 you mount a second volume in user data.
  • Then buy insurance in the workload spec: a high-value PriorityClass on critical pods so they are evicted last, and ephemeral-storage requests and limits on everything so a leaky logger gets killed for its own sin instead of taking a neighbor with it. Rotate logs at the sidecar; never let a container write unbounded to disk.
One idea: a full node disk is a host-engineering failure, and the kubelet's response is self-preservation. You win by (1) making the disk that fills a disk you don't care about, and (2) telling the kubelet which pods it may never touch.

How the kubelet decides who dies

The kubelet's eviction manager is a small control loop, evaluated every housekeeping-interval (default 10s). On each pass it reads a set of eviction signals from the node's cAdvisor/CRI stats and compares them against configured thresholds. For disk, the signals that matter are computed straight from the filesystem:

SignalHow it's computedDefault hard threshold
nodefs.availablefree bytes on the filesystem holding /var/lib/kubelet (emptyDir, logs, the writable layer when imagefs isn't split out)<10%
nodefs.inodesFreefree inodes on that same filesystem<5%
imagefs.availablefree bytes on the filesystem holding container images and layers (the containerd content store)<15%
imagefs.inodesFreefree inodes on the image filesystem<5%
The disk eviction signals and their default Linux hard thresholds. On a stock EKS node nodefs and imagefs are the same root volume, so one runaway directory trips both.

Two things about that table decide most incidents. First, on a default EKS node there is one EBS volume: /, holding the OS, /var/lib/containerd (images), and /var/lib/kubelet (emptyDir + logs) all together. So nodefs and imagefs are the same physical disk — anything that fills it trips everything at once. Second, these are percentages of free space, so a small root volume trips far sooner than operators expect: on a 20 GiB root, nodefs.available<10% means eviction starts with 2 GiB still free.

Hard vs soft: the grace period you probably don't have

Thresholds come in two flavors. A hard threshold (the ones above) triggers immediate eviction with a 0s grace period — the kubelet does not honor terminationGracePeriodSeconds and does not consult PodDisruptionBudgets. A soft threshold adds an eviction-soft-grace-period: the signal must stay below the line for that whole window before the kubelet acts, and it then honors up to eviction-max-pod-grace-period for a clean shutdown. Out of the box, EKS ships hard thresholds only. That means the first time you learn about disk pressure, a stateful pod is already being killed without the graceful-shutdown window your application assumes it has. Adding a soft threshold slightly above the hard one buys you a warning band and a chance to drain cleanly.

Image garbage collection runs first — until it can't

Before evicting user pods, the kubelet tries the cheap fix: delete images nobody is using. Image GC is governed by two knobs, imageGCHighThresholdPercent (default 85) and imageGCLowThresholdPercent (default 80). When image-disk usage climbs above 85%, the kubelet deletes images ordered by last-used time, oldest first, until usage drops back to 80%. It also sweeps dead containers and images on a schedule — unused images every five minutes, unused containers every minute.

The trap is ImageGCFailed. If every remaining image is currently in use (running pods reference them), the kubelet has nothing safe to delete and cannot reach the low threshold. It records ImageGCFailed and, because disk is still over the eviction line, escalates to killing pods. So ImageGCFailed in your events is rarely the disease — it is the kubelet announcing that the polite option failed and the impolite one is next. Note the ordering interaction: if imageGCHighThresholdPercent is set higher than the point where imagefs.available eviction triggers, eviction can start before GC ever runs, which is its own footgun. Keep the GC high threshold below the eviction line.

The ordering: usage over request, then Priority

When the kubelet must evict, it does not pick at random. It builds a ranked list of pods and kills from the top. The ranking is, in order:

  1. Whether the pod's usage of the starved resource exceeds its request. Pods using more ephemeral storage than they requested are sorted ahead of pods using less than (or within) their request. A pod with no request has an effective request of zero, so any usage counts as "over request."
  2. Pod Priority. Among pods that are over request, lower-spec.priority pods are ranked to die first; higher-priority pods are evicted last.
  3. How far over request the pod is — the biggest overspender in the group goes first.

Read that carefully against the default state of most manifests: no ephemeral-storage request (so everything is "over request") and no PriorityClass (so everything is priority 0). In that world the tiebreaker collapses to raw usage, and a busy database writing WAL and temp files to an emptyDir can easily be the single largest disk user on the node — which puts it at the front of the kill list. The two levers you actually control are exactly the two the kubelet sorts on: set a request so the pod isn't automatically "over," and set a priority so it sorts to the back. We do both in the last section.

Eviction is not OOMKill — don't confuse the two

One clarification that saves hours of misdiagnosis. Node-pressure eviction is the kubelet reacting to a node-level resource signal (disk here, or memory.available) by terminating whole pods; the pod shows Reason: Evicted. OOMKill is the Linux kernel killing a single process when a container blows past its memory limit (or the node truly runs out of RAM before the kubelet can act); you see OOMKilled and exit code 137. For memory pressure the kernel also weighs in via oom_score_adj — Guaranteed pods get -997 (near-invincible), BestEffort get 1000 (first to die), Burstable get a value scaled by how small their memory request is relative to node capacity. Disk pressure has no kernel equivalent: there is no "OOM killer for disk." The kubelet is the only actor, which is precisely why tuning it — and the disk underneath it — is the whole game.

The platform fix: give the volatile directories their own disk

Every manifest-level mitigation is downstream of one physical fact: on a stock node the OS root and the churning container directories share a single volume. The moment you split them, a runaway logger or image storm can fill its disk to 100% and the kubelet never raises DiskPressure against the volume the OS and system daemons live on. You have converted a node-killing event into a localized, self-correcting one. This is a node-image / launch-template change, and it is the highest-leverage thing in this article.

The directories to isolate are the two that actually churn:

  • /var/lib/containerd — the container image content store and snapshots. This is your imagefs. Image bloat lives here.
  • /var/lib/kubelet — emptyDir volumes, pod logs, and (when imagefs isn't separate) the writable container layer. This is your nodefs. Runaway application writes live here.

Bottlerocket does this by default — that's the point

Bottlerocket ships a two-volume design out of the box: a small, immutable OS volume (active/passive partitions, dm-verity, the API datastore) and a separate data volume that holds container images, orchestration state, and ephemeral storage. On boot Bottlerocket grows the data partition to fill the device, and if you enlarge the EBS volume you reboot to extend it. You don't script anything — you just size the data volume in the launch template. That is a large part of why Bottlerocket nodes are calmer under disk pressure: the OS physically cannot be starved by container churn.

## Launch template block device mapping: root + a dedicated data EBS
##   /dev/xvda -> 20 GiB   (OS root)
##   /dev/xvdb -> 200 GiB gp3, 500 MB/s, 6000 IOPS  (containerd + kubelet)

MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="//"

--//
Content-Type: text/x-shellscript; charset="us-ascii"
#!/bin/bash
set -euo pipefail
DEV=/dev/nvme1n1                      # the second EBS (xvdb) as NVMe

# Stop the writers before we move their state
systemctl stop kubelet containerd || true

mkfs.xfs -f "$DEV"
mkdir -p /mnt/data
mount "$DEV" /mnt/data

# IMPORTANT (AL2023): copy, don't rm. Newer AL2023 AMIs pre-cache the
# pause image under /var/lib/containerd; deleting it makes nodes fail
# to join. rsync preserves the cached content.
rsync -aXS /var/lib/containerd/ /mnt/data/containerd/
rsync -aXS /var/lib/kubelet/    /mnt/data/kubelet/
umount /mnt/data

# Mount both volatile dirs onto the data volume via bind-friendly layout
mount "$DEV" /mnt/data
mkdir -p /mnt/data/containerd /mnt/data/kubelet
mount --bind /mnt/data/containerd /var/lib/containerd
mount --bind /mnt/data/kubelet    /var/lib/kubelet

# Persist across reboots
echo "$DEV /mnt/data xfs defaults,noatime 0 2" >> /etc/fstab
echo "/mnt/data/containerd /var/lib/containerd none bind 0 0" >> /etc/fstab
echo "/mnt/data/kubelet    /var/lib/kubelet    none bind 0 0" >> /etc/fstab

systemctl start containerd kubelet
--//--
On AL2023, do not rm -rf /var/lib/containerd/* — that deletes the pre-cached pause image and the node fails to join. Copy with rsync instead. Device names appear as /dev/nvme1n1 on Nitro even when the launch template says /dev/xvdb.
# Bottlerocket: no script needed. Attach a data volume in the launch
# template and Bottlerocket uses it for /var/lib automatically.

# --- Launch template blockDeviceMappings (Terraform) ---
block_device_mappings {
  device_name = "/dev/xvda"           # OS volume (keep small)
  ebs { volume_size = 4  volume_type = "gp3" }
}
block_device_mappings {
  device_name = "/dev/xvdb"           # DATA volume (containers live here)
  ebs {
    volume_size = 200
    volume_type = "gp3"
    throughput  = 500
    iops        = 6000
  }
}

# --- Bottlerocket user data (TOML): tune the kubelet in one place ---
[settings.kubernetes]
"image-gc-high-threshold-percent" = "80"
"image-gc-low-threshold-percent"  = "70"

[settings.kubernetes.eviction-hard]
"nodefs.available"  = "12%"
"imagefs.available" = "15%"

[settings.kubernetes.eviction-soft]
"nodefs.available"  = "20%"

[settings.kubernetes.eviction-soft-grace-period]
"nodefs.available"  = "2m"
Bottlerocket grows the data partition to fill /dev/xvdb on first boot. Enlarge the EBS volume and reboot to extend it — no mkfs, no fstab editing.
Split-imagefs caveat. If you mount only /var/lib/containerd onto a second volume (leaving /var/lib/kubelet on root), you create a true split filesystem: imagefs is the data volume, nodefs is the root. That is valid and supported, but now emptyDir and logs still fill the OS root. Move both directories if your goal is to protect the OS from application writes, not just from image bloat.

Workload insurance: make sure the right pod dies last

Filesystem isolation stops most incidents, but disks can still fill — a data volume is smaller than infinity. So set the two levers the eviction ranker actually sorts on, and cap the leaky writers before they matter.

1. PriorityClass so critical pods are evicted last

A PriorityClass is a cluster-scoped object mapping a name to an integer value (user-defined range up to 1,000,000,000; the built-ins system-cluster-critical and system-node-critical sit at 2000000000 and 2000001000 and are reserved for control-plane components). Give your critical stateful pods a high value and the kubelet sorts them to the back of the eviction list within the "over request" tier. Don't overreach — reserve the system-critical classes for what they're for; a high app-level value is plenty.

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: critical-stateful
value: 1000000                 # high, but below the reserved system tiers
globalDefault: false
description: "Primary datastores. Evicted only after everything else."
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: orders-db
spec:
  replicas: 1
  selector: { matchLabels: { app: orders-db } }
  template:
    metadata:
      labels: { app: orders-db }
    spec:
      priorityClassName: critical-stateful      # <-- the insurance
      containers:
        - name: db
          image: postgres:16
          resources:
            requests:
              cpu: "1"
              memory: 2Gi
              ephemeral-storage: 2Gi            # not "over request" for 2Gi
            limits:
              memory: 2Gi                        # Guaranteed-ish for memory
              ephemeral-storage: 4Gi             # hard cap on local disk

2. ephemeral-storage requests and limits on everything

Ephemeral storage is the writable container layer, container logs, and emptyDir volumes combined. Two reasons to declare it on every pod. A request keeps the pod from being auto-classified as "over request" (the first sort key) the instant it writes a byte, and it lets the scheduler avoid packing a node past its ephemeral capacity. A limit is a scalpel: if a single container exceeds its own ephemeral-storage limit, the kubelet evicts that pod specifically — for its own overspend — rather than waiting for the whole node to hit DiskPressure and taking a bystander with it. Cap emptyDir volumes independently with sizeLimit; a pod whose emptyDir exceeds its sizeLimit is evicted on the spot.

apiVersion: v1
kind: Pod
metadata:
  name: log-shipper
spec:
  priorityClassName: low-priority         # define separately, e.g. value: 100
  containers:
    - name: shipper
      image: fluent/fluent-bit:3
      resources:
        requests:
          ephemeral-storage: 256Mi
        limits:
          ephemeral-storage: 1Gi          # blows past 1Gi -> only THIS pod dies
      volumeMounts:
        - name: scratch
          mountPath: /var/log/buffer
  volumes:
    - name: scratch
      emptyDir:
        sizeLimit: 512Mi                   # emptyDir cap, enforced independently

3. Tune the kubelet: soft band + sane image GC

On AL2023 you deliver kubelet configuration through nodeadm NodeConfig (a drop-in under /etc/eks/nodeadm.d/ or the managed node group's NodeConfig). Add a soft threshold above the hard one for a graceful-shutdown window, and keep image GC's high threshold below the eviction line so GC always runs before eviction.

apiVersion: node.eks.aws/v1alpha1
kind: NodeConfig
spec:
  kubelet:
    config:
      # Image GC fires before eviction (85/80 defaults -> tighten a little)
      imageGCHighThresholdPercent: 80
      imageGCLowThresholdPercent: 70
      # Hard thresholds: immediate, 0s grace (the last line of defense)
      evictionHard:
        nodefs.available: "10%"
        nodefs.inodesFree: "5%"
        imagefs.available: "15%"
      # Soft thresholds: a warning band with a real grace period
      evictionSoft:
        nodefs.available: "15%"
        imagefs.available: "20%"
      evictionSoftGracePeriod:
        nodefs.available: "2m"
        imagefs.available: "2m"
      evictionMaxPodGracePeriod: 60
      # Reserve headroom so system daemons never compete with pods
      systemReserved:
        ephemeral-storage: "2Gi"
      kubeReserved:
        ephemeral-storage: "2Gi"

4. Rotate logs at the source

None of the above helps if a container writes an unbounded log to its own filesystem. Container stdout/stderr is rotated by the runtime (containerd caps and rotates via the kubelet's containerLogMaxSize / containerLogMaxFiles, defaulting to 10Mi × 5), but application logs written to files inside the container — or to an emptyDir — are not. Rotate them at the sidecar (logrotate, or the log agent's own buffer caps) and give that emptyDir a sizeLimit. A log shipper that stops shipping is the single most common trigger for the whole cascade in Figure 1; treat its buffer as hostile by default.

Checkpoint: on a hardened node you should be able to dd a 100 GiB file into an emptyDir and watch only that pod get evicted, while kubectl get node never reports DiskPressure and every other pod — especially the database — keeps running.

Pitfalls that bite in production

Watch out

  • Percent thresholds on a tiny root. nodefs.available<10% on a 20 GiB disk starts evicting with 2 GiB free. Either size the volume for real churn or set absolute thresholds (e.g. 2Gi).
  • rm -rf /var/lib/containerd/* on AL2023. Newer AMIs pre-cache the pause image there; deleting it makes nodes fail to join with a localhost pull error. Copy with rsync, never wipe.
  • Image GC high threshold above the eviction line. If GC only fires at 85% but imagefs.available<15% evicts at 85% used too, eviction can win the race. Keep GC's high threshold safely below the eviction trigger.
  • Assuming a graceful shutdown. Hard eviction is 0s — it ignores terminationGracePeriodSeconds and PodDisruptionBudgets. Only a soft threshold gives you a drain window.

Do this

  • Move both volatile dirs (/var/lib/containerd and /var/lib/kubelet) to a dedicated volume — or just run Bottlerocket, which does it for you.
  • Set ephemeral-storage requests and limits on every pod, plus sizeLimit on every emptyDir. Requests move you off the "over request" front of the kill list.
  • Give critical pods a high PriorityClass (well below the reserved system tiers) so they sort to the back of the eviction queue.
  • Add a soft threshold + kube/systemReserved ephemeral headroom so image GC and a drain window happen before any hard kill.
  • Alarm on the leading indicator: alert on DiskPressure=True and rising kubelet_evictions, not on the pods that already died.

References

  1. Node-pressure Eviction (signals, computations, default hard thresholds, soft vs hard, pod selection ordering, node OOM behavior) — Kubernetes Documentation. kubernetes.io/docs/concepts/scheduling-eviction/node-pressure-eviction
  2. Garbage Collection — image GC HighThresholdPercent 85 / LowThresholdPercent 80, imageMaximumGCAge, container GC, and the 5-minute / 1-minute cadence — Kubernetes Documentation. kubernetes.io/docs/concepts/architecture/garbage-collection
  3. Local ephemeral storage (what counts, requests/limits, emptyDir sizeLimit, eviction on exceeding a limit) — Kubernetes Documentation. kubernetes.io/docs/concepts/storage/ephemeral-storage
  4. Resource Management for Pods and Containers (ephemeral-storage resource, QoS classes) — Kubernetes Documentation. kubernetes.io/docs/concepts/configuration/manage-resources-containers
  5. Pod Priority and Preemption (PriorityClass fields, system-node-critical 2000001000 / system-cluster-critical 2000000000, user range to 1e9) — Kubernetes Documentation. kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption
  6. Pod Quality of Service Classes and oom_score_adj (Guaranteed −997, BestEffort 1000, Burstable scaled) — Kubernetes Documentation. kubernetes.io/docs/concepts/workloads/pods/pod-qos
  7. Local Storage Capacity Isolation reaches GA (Kubernetes 1.25) — Kubernetes Blog. kubernetes.io/blog/2022/09/19/local-storage-capacity-isolation-ga
  8. Reduce container startup time on Amazon EKS with Bottlerocket data volume — AWS Containers Blog. aws.amazon.com/blogs/containers/reduce-container-startup-time-on-amazon-eks-with-bottlerocket-data-volume
  9. Create nodes with optimized Bottlerocket AMIs — Amazon EKS User Guide. docs.aws.amazon.com/eks/latest/userguide/eks-optimized-ami-bottlerocket.html
  10. Bottlerocket storage / two-volume design (OS volume vs data volume, partition growth) — bottlerocket-os/bottlerocket README. github.com/bottlerocket-os/bottlerocket/blob/develop/README.md
  11. AL2023 second volume for /var/lib/containerd and the pre-cached pause-image caveat — awslabs/amazon-eks-ami issue #2122. github.com/awslabs/amazon-eks-ami/issues/2122
  12. EKS Best Practices Guide — Reliability / Data plane (kube-reserved, system-reserved, node stability). aws.github.io/aws-eks-best-practices/reliability/docs/dataplane