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.tomlandcrictlgets 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 = truerequires cgroup v2 on the host (or at least systemd-managed cgroups). Check withcat /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_imagefor your region’s ECR address.us-east-1is602401143452; other regions differ. The AWSamazon-k8s-cnidocs 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
| Approach | Fidelity | Cost | Setup Time | Best For |
|---|---|---|---|---|
| Local containerd CRI hand-aligned (this article) | High (full control) | Near zero | 1–2 hours | Config validation, CRI behavior debugging, P2P logic POC |
| kind / k3s single node | Medium (non-EKS defaults) | Low | 10 minutes | App-level testing, node behavior irrelevant |
| Real EKS + single-node managed group | 100% | High (100+/mo) | 30 minutes | Final acceptance, performance baselines |
| EKS Hybrid Nodes | High (official bridge) | Medium | Half a day | Hybrid cloud, edge node unification |
| Boot EKS AMI on EC2 and self-manage | Very high (it is the original) | Medium-high | 2–3 hours | AMI-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
- Amazon EKS AMI build repo — Want to know exactly what EKS changes in containerd? Read the bootstrap scripts here. More authoritative than any blog post.
- containerd CRI plugin config docs — Full field reference for
SystemdCgroup,sandbox_image, and friends. - Kubernetes container runtime docs — Explains why CRI-compatible runtimes are mandatory post-1.24 and how to pick a cgroup driver.
- r/kubernetes thread on CFS throttling and p99 spikes — The real-world data (500m limit, 50ms quota, burst spikes) behind Step 4’s verification.
- Spegel P2P image distribution project — If you’re building image-acceleration POCs, its issue tracker will save you from a bunch of EKS-specific traps.
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.
