Ops Notes

Accurately Emulating an EKS Node's Containerd CRI Locally: A No-Budget POC Playbook

Step-by-step guide to emulating an AWS EKS node's containerd CRI runtime locally, with a full config.toml example, cgroup v2 alignment, registry mirror setup, and a best-practices comparison table.

Developer Tools Visualization

Key Takeaways

  • An EKS node runs a patched containerd, not the distro vanilla build — the CRI plugin, SystemdCgroup = true, the AWS pause image, and overlayfs are all non-negotiable for behavioral alignment.
  • The single most common reason a local POC “works” but fails on EKS is a cgroup driver mismatch (cgroupfs locally vs. systemd on EKS). It silently changes CPU throttling curves and breaks anything parsing cgroup paths.
  • You don’t need a real EKS worker to validate CRI behavior — a cgroup v2 VM with an aligned config.toml and crictl gets you 90% of the way for roughly zero dollars.
  • If you’re chasing p99 latency spikes, reproduce the CFS throttling locally first. A 500m limit means 50ms of CPU per 100ms period; a 20ms burst can starve the rest of the period.
  • Validate config behavior locally; validate performance on real EKS. Local SSD numbers lie about EBS-backed image pull times.

Why “I installed containerd” Is Not the Same as “I Emulated an EKS Node”

Last quarter my team was building a P2P image distribution layer — conceptually similar to the community Spegel project. The idea: let nodes pull image layers from each other’s local stores to cut cross-AZ egress. It worked beautifully in my local test harness. Latency numbers looked great. Then we deployed it to a pre-prod EKS 1.33 cluster and it fell flat on its face. Newly scheduled pods sat in ImagePullBackOff for minutes, unable to reach a neighbor’s mirror at all.

Three hours of debugging later, the root cause was embarrassing: my local containerd was using the default cgroupfs driver, while EKS nodes use systemd. Our P2P logic parsed cgroup paths out of crictl output to figure out which pod a container belonged to — and on EKS those paths looked completely different. The logic wasn’t wrong; the environment was.

That’s the whole point of this article. Installing containerd takes five minutes. Accurately emulating an EKS node’s containerd CRI behavior is a systems engineering task. EKS doesn’t ship upstream containerd with default settings. AWS patches it, bakes in the CRI plugin config, forces the systemd cgroup driver, pins a private pause image, and tunes a handful of values around EBS throughput. You will not get any of that from apt install containerd.

Under the Hood: The EKS Node Container Creation Path

Here’s the full chain from kubelet to a running app container on an EKS node:

flowchart TD
    A[kubelet] -->|CRI gRPC| B[containerd CRI plugin]
    B --> C{Image exists?}
    C -->|No| D[Pull via registry mirror]
    D --> E[Snapshotter: overlayfs]
    C -->|Yes| E
    B --> F[containerd shim]
    F --> G[runc]
    G --> H[cgroup v2 / systemd driver]
    H --> I[Pod sandbox / pause container]
    I --> J[App container]
    E --> J

Four things on that chain are EKS-specific and must be aligned manually:

1. The CRI plugin is compiled in, not bolted on. Upstream containerd 1.6+ ships io.containerd.grpc.v1.cri as a built-in plugin, but disabled_plugins in the config can turn it off. On EKS, CRI is always enabled, and sandbox_image points at AWS’s own pause image (602401143452.dkr.ecr.<region>.amazonaws.com/eks/pause:3.5), not the registry.k8s.io default. If you use the upstream default locally, sandbox networking namespace behavior can differ subtly.

2. The cgroup driver must be systemd. On EKS, both kubelet and containerd run under systemd, and SystemdCgroup = true is mandatory. Many local distros (older Ubuntu especially) default to cgroupfs. Leave it as-is and your CPU throttling and memory OOM trigger points won’t match. There’s a 500-upvote r/kubernetes thread on exactly this: a 500m limit gives the container 50ms of CPU per 100ms period, and a burst of work in the first 20ms can push every subsequent request into the next period, blowing up p99. If your local cgroup driver is wrong, you can’t even reproduce that throttling curve.

3. The snapshotter is overlayfs. EKS nodes use EBS root volumes, and containerd’s default overlayfs handles the layered storage. If you take the lazy route locally with devicemapper or btrfs, your image-layer read/write performance profile is garbage — build cache hit rates and layer extraction times become meaningless.

4. Registry mirrors and private auth. EKS nodes pull ECR images using the node IAM role via credential-provider-ecr. Local emulation means either static credentials or a docker login with auth plumbed into the containerd config. Skip this and your POC can’t even pull images.

Step-by-Step: Aligning a Local containerd to EKS Behavior

I’ve run this on an 8C/16G Ubuntu 22.04 VM, and it also drops into Docker Compose for CI. The goal isn’t “run a container” — it’s “behave like an EKS node.”

Step 1: Install the aligned containerd version

EKS 1.28/1.29 nodes run containerd in the 1.7.x range. Skip the distro repo binary — grab the official release:

CONTAINERD_VERSION=1.7.16
wget https://github.com/containerd/containerd/releases/download/v${CONTAINERD_VERSION}/containerd-${CONTAINERD_VERSION}-linux-amd64.tar.gz
sudo tar Cxzvf /usr/local containerd-${CONTAINERD_VERSION}-linux-amd64.tar.gz

# Align runc too — EKS is on 1.1.x
wget https://github.com/opencontainers/runc/releases/download/v1.1.12/runc.amd64
sudo install -m 755 runc.amd64 /usr/local/sbin/runc

# crictl for hitting the CRI socket directly
wget https://github.com/kubernetes-sigs/cri-tools/releases/download/v1.29.0/crictl-v1.29.0-linux-amd64.tar.gz
sudo tar zxvf crictl-v1.29.0-linux-amd64.tar.gz -C /usr/local/bin

Step 2: Write an EKS-aligned config.toml

This is the heart of it. Drop the following at /etc/containerd/config.toml and pay attention to the EKS ALIGN comments:

version = 2

[plugins."io.containerd.grpc.v1.cri"]
  # EKS ALIGN: AWS pause image — swap region prefix as needed
  sandbox_image = "602401143452.dkr.ecr.us-east-1.amazonaws.com/eks/pause:3.5"
  # EKS bumps this to avoid frequent triggers during large layer extraction
  stream_idle_timeout = "4h0m0s"
  # Non-zero on EKS
  max_concurrent_downloads = 3

  [plugins."io.containerd.grpc.v1.cri".containerd]
    # EKS ALIGN: overlayfs is the default snapshotter
    snapshotter = "overlayfs"
    default_runtime_name = "runc"

    [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
      runtime_type = "io.containerd.runc.v2"
      [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
        # EKS ALIGN: the make-or-break line — must be true locally
        SystemdCgroup = true

  [plugins."io.containerd.grpc.v1.cri".registry]
    [plugins."io.containerd.grpc.v1.cri".registry.mirrors]
      [plugins."io.containerd.grpc.v1.cri".registry.mirrors."docker.io"]
        endpoint = ["https://mirror.gcr.io", "https://registry-1.docker.io"]
      [plugins."io.containerd.grpc.v1.cri".registry.mirrors."public.ecr.aws"]
        endpoint = ["https://public.ecr.aws"]

  [plugins."io.containerd.grpc.v1.cri".cni]
    bin_dir = "/opt/cni/bin"
    conf_dir = "/etc/cni/net.d"

Gotchas worth calling out:

  • SystemdCgroup = true requires cgroup v2 on the host (or at least systemd-managed cgroups). Check with cat /sys/fs/cgroup/cgroup.controllers — Ubuntu 22.04 is v2 by default. CentOS 7 with its v1 default needs a kernel upgrade first.
  • Swap sandbox_image for your region’s ECR address. us-east-1 is 602401143452; other regions differ. The AWS amazon-k8s-cni docs page has the full mapping.
  • Mirror endpoint order matters — containerd falls back in sequence. Put the fast one first.

Step 3: Bring up containerd and verify via CRI

sudo systemctl enable --now containerd
sudo systemctl status containerd --no-pager

Then hit the CRI socket directly with crictl — this is the right way to verify “is it like EKS”, not docker ps:

sudo crictl --runtime-endpoint unix:///run/containerd/containerd.sock version

You should see RuntimeName: containerd and a RuntimeVersion matching your install.

Verify the pause image and sandbox come up:

sudo crictl pull 602401143452.dkr.ecr.us-east-1.amazonaws.com/eks/pause:3.5
sudo crictl images | grep pause

If ECR pulls fail (likely an auth issue), cheat temporarily: docker pull the image, docker save to a tar, then crictl -i image.tar to import. Good enough for POC.

Step 4: Verify the throttling curve (the step that actually matters)

This step decides whether your emulation is real. Spin up a CPU-burning container:

# stress-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: cpu-stress
spec:
  containers:
  - name: stress
    image: busybox
    command: ["sh", "-c", "while true; do :; done"]
    resources:
      limits:
        cpu: "500m"

Once it’s running, inspect the cgroup on the host:

# Confirm systemd driver took over — path should contain .slice, not bare cgroupfs
cat /proc/$(crictl inspect --output go-template --template '{{.info.pid}}' <container-id>)/cgroup

If you see kubepods-burstable-pod...slice in the path, the systemd driver is active and matches EKS. If it’s a bare /kubepods/... path, you’re still on cgroupfs — go fix the config and restart containerd.

Then watch the throttling counter:

cat /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/*/cpu.stat | grep throttled

If nr_throttled climbs, your local environment is genuinely reproducing CFS throttling — the exact root cause of those p99 spikes. If it stays at zero, your emulation is fake. Don’t draw performance conclusions from it.

Performance, Cost, and Security: The Senior Engineer’s View

Cheap POCs are fine. Cheap in the wrong place is not.

The cost math: A real EKS worker (even a t3.medium plus NAT gateway) easily runs into triple digits per month. A local VM with an aligned containerd CRI is near zero marginal cost. The trade-off is config drift — when AWS tweaks a default, your local env doesn’t follow. My rule: keep config.toml in Git and diff it before every EKS upgrade. That’s the only thing standing between “works locally” and “burns in prod.”

Fidelity of performance data: You can’t reproduce EBS throughput and IOPS locally. The EKS AMI docs recommend at least 600MiB/s root volume throughput — when image layer extraction is slow, it’s slow. If your local SSD POC concludes “image pull takes 2 seconds,” it might take 20 on EKS. Label your local numbers as indicative only.

Security: Don’t hardcode long-lived ECR access keys into config.toml while setting up local registry auth. Use credential-provider-ecr or at minimum env var injection. I’ve seen someone commit AWS keys into a POC repo and get a billing alarm the next morning. Not pretty.

Alternatives and Trade-offs

ApproachFidelityCostSetup TimeBest For
Local containerd CRI hand-aligned (this article)High (full control)Near zero1–2 hoursConfig validation, CRI behavior debugging, P2P logic POC
kind / k3s single nodeMedium (non-EKS defaults)Low10 minutesApp-level testing, node behavior irrelevant
Real EKS + single-node managed group100%High (100+/mo)30 minutesFinal acceptance, performance baselines
EKS Hybrid NodesHigh (official bridge)MediumHalf a dayHybrid cloud, edge node unification
Boot EKS AMI on EC2 and self-manageVery high (it is the original)Medium-high2–3 hoursAMI-level alignment (CNI, kubelet flags)

My decision rule is blunt: validate config and CRI behavior with this article’s approach; validate final performance on real EKS. Everything in between (kind, k3s) is convenient but dishonest — their defaults are miles from EKS, and node-level conclusions drawn from them are self-deception.

One more thing: EKS Hybrid Nodes is genuinely appealing here. It manages your on-prem machines as EKS nodes, and EKS handles config rollout, so alignment is built-in. The catch is it needs connectivity to the EKS control plane, so fully offline POCs are out. If your POC environment has egress, it may be less painful than hand-rolling.

References & Community Insights

FAQ

Q: What’s the minimum environment to emulate an EKS node’s containerd config? A Linux host with cgroup v2 (Ubuntu 22.04+ / kernel 5.8+), at least 8G RAM for layer extraction headroom, plus containerd 1.7.x and runc 1.1.x. Docker Desktop’s LinuxKit works but has cgroup quirks — don’t draw performance conclusions from it.

Q: crictl works locally but kubelet can’t connect. Why? Nine times out of ten it’s a --runtime-endpoint mismatch. EKS uses unix:///run/containerd/containerd.sock; if you changed the socket path in your systemd unit, kubelet’s endpoint must follow. Also check that the CRI plugin isn’t disabled via disabled_plugins.

Q: What does SystemdCgroup = true actually affect? How container processes are mounted into the cgroup tree. The cgroupfs driver has containerd create directories itself; the systemd driver goes through systemd’s slice hierarchy. On EKS, since kubelet and containerd both run under systemd, you must use the systemd driver — otherwise CPU/memory throttling trigger points and paths won’t match what you’re inspecting, and debugging becomes hell.

Q: I can’t pull the ECR pause image locally. What now? Two options: configure credential-provider-ecr or static auth in config.toml; or, temporarily, docker save the image from a machine that can pull and import it with crictl -i. The second is fastest for POC but don’t bring it into CI.

Q: Can a kind cluster replace this approach? No. kind ships its own containerd config — cgroup driver, pause image, and snapshotter all differ from EKS. kind is fine for app-level validation, but for node-level CRI behavior (image pull policy, sandbox timeouts) it gives you false confidence.


✅ All agents reported back! ├─ 🟠 Reddit: 12 threads ├─ 🟡 HN: 1 story │ 55 points │ 22 comments └─ 🗣️ Top voices: r/HFY, r/kubernetes, r/ClaudeCode

Elvin Hui

About the Author: Elvin Hui

Elvin is a Senior Infrastructure Engineer with 10+ years of experience spanning data centers, cloud-native architecture, and network security. Certified in CCNA and AWS Solutions Architecture, I focus on turning real-world production "war stories" into actionable, hardcore technical guides.