<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ajinkya</title>
    <description>The latest articles on DEV Community by Ajinkya (@ajinkya_a3).</description>
    <link>https://dev.to/ajinkya_a3</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3522740%2F46f689f7-963d-46d2-856a-54425776a0aa.jpg</url>
      <title>DEV Community: Ajinkya</title>
      <link>https://dev.to/ajinkya_a3</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ajinkya_a3"/>
    <language>en</language>
    <item>
      <title>The Anatomy of Elastic CI: GitHub Actions ARC EKS Karpenter</title>
      <dc:creator>Ajinkya</dc:creator>
      <pubDate>Mon, 07 Sep 2026 07:38:47 +0000</pubDate>
      <link>https://dev.to/ajinkya_a3/the-anatomy-of-elastic-ci-github-actions-arc-eks-karpenter-4608</link>
      <guid>https://dev.to/ajinkya_a3/the-anatomy-of-elastic-ci-github-actions-arc-eks-karpenter-4608</guid>
      <description>&lt;p&gt;&lt;strong&gt;Zero idle CI runners. That's the point.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No jobs = zero runners = zero wasted spend.&lt;br&gt;
Traffic shows up. Infrastructure follows.&lt;/p&gt;




&lt;h2&gt;
  
  
  The flow
&lt;/h2&gt;

&lt;p&gt;Workflow triggers → ARC creates a runner pod → pod lands on a Karpenter NodePool → if capacity is missing, it goes &lt;code&gt;Pending&lt;/code&gt; → Karpenter provisions exactly what's needed → job runs → pod disappears → node consolidates.&lt;/p&gt;

&lt;p&gt;No standing fleet. No idle EC2 between builds. No capacity planning spreadsheet. Just compute that exists for exactly the minutes a job needs it.&lt;/p&gt;


&lt;h2&gt;
  
  
  What makes the cost math real
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Guaranteed QoS
&lt;/h3&gt;

&lt;p&gt;CPU requests = limits. Memory requests = limits.&lt;/p&gt;

&lt;p&gt;This gives Karpenter a clean bin-packing target instead of over-allocating — Karpenter sizes the node off what you &lt;em&gt;ask&lt;/em&gt; for, so a sloppy request means a bigger, pricier instance than the job actually needs.&lt;/p&gt;
&lt;h3&gt;
  
  
  Storage reclaim policy matters
&lt;/h3&gt;

&lt;p&gt;Reclaim policy should be &lt;code&gt;Delete&lt;/code&gt;, not &lt;code&gt;Retain&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;Retain&lt;/code&gt;, per-job PVCs and their backing EBS volumes can remain after ephemeral runners disappear. The runners scale to zero. The storage bill doesn't.&lt;/p&gt;

&lt;p&gt;This is the single easiest detail to overlook when evaluating the true cost of an ephemeral CI architecture — check it before you trust the "zero idle spend" pitch.&lt;/p&gt;
&lt;h3&gt;
  
  
  Spot for most jobs, On-Demand for critical ones
&lt;/h3&gt;

&lt;p&gt;It doesn't have to be a cluster-wide decision. Use different Karpenter NodePools and target the appropriate runner/workload based on the job.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Spot → cost optimization&lt;/li&gt;
&lt;li&gt;On-Demand → workloads that can't tolerate interruption&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same elasticity. Different reliability and cost trade-off — decided per job, not once for the whole cluster.&lt;/p&gt;
&lt;h3&gt;
  
  
  Cold starts are the trade-off
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;minRunners: 0&lt;/code&gt;, there are no warm runners waiting for jobs. The first job after an idle period needs to wait for:&lt;/p&gt;

&lt;p&gt;Pod scheduling → Karpenter provisioning → EC2 startup → runner initialization.&lt;/p&gt;

&lt;p&gt;And in &lt;code&gt;kubernetes&lt;/code&gt; mode, a PVC may also need to be provisioned before the job even starts.&lt;/p&gt;

&lt;p&gt;You're trading idle compute cost for startup latency. Fine for most CI workloads. Not ideal when you need sub-minute pipeline startup times.&lt;/p&gt;


&lt;h2&gt;
  
  
  Not a drop-in swap for &lt;code&gt;ubuntu-latest&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The container mode you choose determines how much your existing workflow needs to change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;dind&lt;/code&gt; mode&lt;/strong&gt; — The runner gets a real Docker daemon sidecar. Most existing workflows can remain largely unchanged. Trade-off: a privileged pod, plus additional startup overhead on every job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;kubernetes&lt;/code&gt; mode&lt;/strong&gt; — Every job/action runs as its own unprivileged Kubernetes pod. More isolation, but also more constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No Docker daemon anywhere&lt;/li&gt;
&lt;li&gt;Every job needs an explicit &lt;code&gt;container:&lt;/code&gt; configuration&lt;/li&gt;
&lt;li&gt;Image builds and pushes require daemonless tooling&lt;/li&gt;
&lt;li&gt;Bash-specific steps can break if the container's default shell isn't Bash&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  Why this architecture works
&lt;/h2&gt;

&lt;p&gt;Each layer has one responsibility:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ARC&lt;/strong&gt; → Runner lifecycle&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes&lt;/strong&gt; → Scheduling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Karpenter&lt;/strong&gt; → Provisioning + consolidation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spot / On-Demand&lt;/strong&gt; → Cost vs. reliability&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stack them together and the infrastructure behaves almost like a function:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Input → Compute appears → Work executes → Compute disappears&lt;/strong&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  How to actually build it
&lt;/h2&gt;

&lt;p&gt;The architecture above isn't theoretical — here's the exact install path, end to end, for a Karpenter-backed, spot-first, &lt;code&gt;kubernetes&lt;/code&gt;-mode runner scale set on EKS.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Namespaces + controller
&lt;/h3&gt;

&lt;p&gt;One controller per cluster, installed once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create namespace arc-systems
kubectl create namespace arc-runners

helm &lt;span class="nb"&gt;install &lt;/span&gt;arc &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; arc-systems &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; arc-systems
&lt;span class="c"&gt;# expect: arc-gha-runner-scale-set-controller-xxxx   1/1   Running&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. A PAT scoped to exactly one repo
&lt;/h3&gt;

&lt;p&gt;Fine-grained token → &lt;strong&gt;Repository access: Only select repositories&lt;/strong&gt; → the one target repo, nothing org-wide:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Permission&lt;/th&gt;
&lt;th&gt;Access level&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Actions&lt;/td&gt;
&lt;td&gt;Read and write&lt;/td&gt;
&lt;td&gt;Register/deregister runners, read the job queue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Administration&lt;/td&gt;
&lt;td&gt;Read and write&lt;/td&gt;
&lt;td&gt;Create/remove self-hosted runners on the repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metadata&lt;/td&gt;
&lt;td&gt;Read-only&lt;/td&gt;
&lt;td&gt;Required baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create secret generic eks-spot-amd-gh-secret &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; arc-runners &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;github_token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;YOUR_PAT&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. The values file — where the cost math actually lives
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# values-eks-spot-amd.yaml&lt;/span&gt;
&lt;span class="na"&gt;githubConfigUrl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://github.com/&amp;lt;your-username&amp;gt;/&amp;lt;your-repo&amp;gt;"&lt;/span&gt;
&lt;span class="na"&gt;githubConfigSecret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eks-spot-amd-gh-secret&lt;/span&gt;
&lt;span class="na"&gt;runnerScaleSetName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eks-spot-amd"&lt;/span&gt;

&lt;span class="na"&gt;minRunners&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;     &lt;span class="c1"&gt;# zero idle runners — this is the whole point&lt;/span&gt;
&lt;span class="na"&gt;maxRunners&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;     &lt;span class="c1"&gt;# keep tight for a single repo/workflow&lt;/span&gt;

&lt;span class="na"&gt;containerMode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kubernetes"&lt;/span&gt;
  &lt;span class="na"&gt;kubernetesModeWorkVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;ReadWriteOnce&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gp3&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5Gi&lt;/span&gt;

&lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;fsGroup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1001&lt;/span&gt;
    &lt;span class="na"&gt;nodeSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;node-pool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spot-amd64&lt;/span&gt;
      &lt;span class="na"&gt;capacity-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spot&lt;/span&gt;
      &lt;span class="na"&gt;arch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;amd64&lt;/span&gt;
    &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;runner&lt;/span&gt;
        &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/actions/actions-runner:latest&lt;/span&gt;
        &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/home/runner/run.sh"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4Gi"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;# requests == limits → Guaranteed QoS&lt;/span&gt;
          &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4Gi"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;requests == limits&lt;/code&gt; is not stylistic — it's the line that actually produces Guaranteed QoS and gives Karpenter a clean bin-packing target instead of an over-provisioned guess.&lt;/p&gt;

&lt;p&gt;Install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm &lt;span class="nb"&gt;install &lt;/span&gt;eks-spot-amd &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; arc-runners &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; values-eks-spot-amd.yaml &lt;span class="se"&gt;\&lt;/span&gt;
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; arc-runners
&lt;span class="c"&gt;# expect: eks-spot-amd-xxxx-listener   1/1   Running   (idle, long-poll)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Confirm the reclaim policy before you trust the cost story
&lt;/h3&gt;

&lt;p&gt;This is the one-line check that separates "zero idle spend" from "zero idle spend, plus a slowly growing EBS bill nobody notices":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get storageclass gp3 &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.reclaimPolicy}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it says &lt;code&gt;Retain&lt;/code&gt; instead of &lt;code&gt;Delete&lt;/code&gt;, fix it before going further — otherwise every ephemeral job leaves a volume behind.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Route jobs to Spot or On-Demand per NodePool
&lt;/h3&gt;

&lt;p&gt;Rather than a cluster-wide choice, tag NodePools separately and target them with &lt;code&gt;nodeSelector&lt;/code&gt; per runner scale set — Spot for the bulk of jobs, a second On-Demand scale set for anything interruption-sensitive (release builds, deploy gates). Same controller, same chart, two values files, two &lt;code&gt;nodeSelector&lt;/code&gt; blocks.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Adjust the workflow for your container mode
&lt;/h3&gt;

&lt;p&gt;If you're on &lt;code&gt;kubernetes&lt;/code&gt; mode, every job needs an explicit &lt;code&gt;container:&lt;/code&gt;, and anything touching Docker (builds, registry logins, local-scan patterns) needs daemonless tooling instead — no &lt;code&gt;/var/run/docker.sock&lt;/code&gt; exists anywhere in this mode by design.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eks-spot-amd&lt;/span&gt;
    &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/catthehacker/ubuntu:act-22.04&lt;/span&gt;   &lt;span class="c1"&gt;# glibc-based — musl breaks JS actions&lt;/span&gt;
    &lt;span class="na"&gt;defaults&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;   &lt;span class="c1"&gt;# confirm the image's default shell isn't /bin/sh&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;make build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If most of your existing workflows assume a live daemon and rewriting them isn't practical yet, start on &lt;code&gt;dind&lt;/code&gt; mode instead — same cost model, none of the container-mode constraints, at the cost of a privileged pod.&lt;/p&gt;

&lt;p&gt;You also don't have to pick one mode for the whole cluster. Run two runner scale sets side by side — one on &lt;code&gt;kubernetes&lt;/code&gt; mode for the bulk of your jobs, one on &lt;code&gt;dind&lt;/code&gt; mode scoped to just the jobs that genuinely need a Docker daemon (image builds, registry pushes, container actions). Same controller, same chart, two &lt;code&gt;values-*.yaml&lt;/code&gt; files, two &lt;code&gt;runnerScaleSetName&lt;/code&gt;s. Point the daemon-dependent jobs at the &lt;code&gt;dind&lt;/code&gt; scale set's &lt;code&gt;runs-on:&lt;/code&gt; and leave everything else on &lt;code&gt;kubernetes&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eks-spot-amd&lt;/span&gt;            &lt;span class="c1"&gt;# kubernetes mode — no daemon needed&lt;/span&gt;
    &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/catthehacker/ubuntu:act-22.04&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;make test&lt;/span&gt;

  &lt;span class="na"&gt;build-and-push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eks-spot-amd-dind&lt;/span&gt;       &lt;span class="c1"&gt;# dind mode — needs a real daemon&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;registry/image:${{ github.sha }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get the smaller, unprivileged blast radius everywhere it's free to have it, and you contain the privileged trade-off to exactly the jobs that can't avoid it — instead of either rewriting your entire Docker-based pipeline around daemonless tooling, or running every job privileged just because one of them needs to be.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Validate the full elastic loop
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; arc-runners &lt;span class="nt"&gt;-w&lt;/span&gt;                                           &lt;span class="c"&gt;# ephemeral pod appears on trigger&lt;/span&gt;
kubectl get pod &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; arc-runners &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.spec.nodeName}'&lt;/span&gt;           &lt;span class="c"&gt;# landed on the intended NodePool?&lt;/span&gt;
kubectl get node &amp;lt;node&amp;gt; &lt;span class="nt"&gt;--show-labels&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'capacity-type=[a-z]*'&lt;/span&gt;        &lt;span class="c"&gt;# spot or on-demand, as intended?&lt;/span&gt;
kubectl get pod &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; arc-runners &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.status.qosClass}'&lt;/span&gt;         &lt;span class="c"&gt;# actually Guaranteed?&lt;/span&gt;
kubectl get pvc &lt;span class="nt"&gt;-n&lt;/span&gt; arc-runners                                                &lt;span class="c"&gt;# gone after the job completes?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trigger a real workflow and watch the whole function execute: &lt;strong&gt;Input → Compute appears → Work executes → Compute disappears.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the PVC and node are both gone a few minutes after the job finishes, the cost math in this post is real for your cluster, not just in theory.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're running ARC on EKS, I'd love to hear what NodePool split or container mode you landed on — drop a comment below.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>aws</category>
      <category>devops</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>Enabling Karpenter on EKS with Terraform: What It Is, Why It's Worth It, and How to Set It Up</title>
      <dc:creator>Ajinkya</dc:creator>
      <pubDate>Sun, 06 Sep 2026 05:51:35 +0000</pubDate>
      <link>https://dev.to/ajinkya_a3/enabling-karpenter-on-eks-with-terraform-what-it-is-why-its-worth-it-and-how-to-set-it-up-5aj8</link>
      <guid>https://dev.to/ajinkya_a3/enabling-karpenter-on-eks-with-terraform-what-it-is-why-its-worth-it-and-how-to-set-it-up-5aj8</guid>
      <description>&lt;p&gt;Karpenter replaces the old "guess an instance type, set a min/max, wait for Cluster Autoscaler" model with something simpler: watch for pods that can't schedule, and launch exactly the EC2 capacity they need. This guide covers how it actually works, why teams adopt it, and the Terraform you need to wire up to run it on EKS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;Before you begin, make sure you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A running Amazon EKS cluster (created through either Terraform, eksctl, or the console)&lt;/li&gt;
&lt;li&gt;Terraform (v1.11+ recommended)&lt;/li&gt;
&lt;li&gt;An existing VPC and subnets&lt;/li&gt;
&lt;li&gt;Existing security groups&lt;/li&gt;
&lt;li&gt;Helm (v3+)&lt;/li&gt;
&lt;li&gt;kubectl configured to access your EKS cluster&lt;/li&gt;
&lt;li&gt;AWS CLI with credentials configured&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How Karpenter Actually Works
&lt;/h2&gt;

&lt;p&gt;Traditional autoscaling works in two disconnected layers: the Kubernetes scheduler decides where pods &lt;em&gt;should&lt;/em&gt; go, and a separate Cluster Autoscaler watches Auto Scaling Groups and adds nodes when they're full. The ASG only knows one instance type per group, so it's constantly a step behind what the scheduler actually needs.&lt;/p&gt;

&lt;p&gt;Karpenter collapses those two layers into one loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A pod goes &lt;strong&gt;Pending&lt;/strong&gt; because nothing can schedule it.&lt;/li&gt;
&lt;li&gt;Karpenter looks at that pod's actual requirements CPU, memory, architecture, zone, GPU, whatever's in its spec not a pre-defined group.&lt;/li&gt;
&lt;li&gt;It picks the cheapest EC2 instance type across the &lt;em&gt;entire&lt;/em&gt; fleet that satisfies those requirements, and calls the EC2 Fleet API directly to launch it.&lt;/li&gt;
&lt;li&gt;The node joins the cluster, the pod schedules, done typically in under a minute.&lt;/li&gt;
&lt;li&gt;On the way back down, Karpenter continuously watches for nodes that are empty or underutilized and consolidates workloads to shut them down.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There's no ASG in this loop at all. Karpenter talks to EC2 directly, which is what makes it fast and lets it pick from hundreds of instance types instead of the two or three you'd hand-pick for a node group.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Is Beneficial
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Right-sized capacity, every time.&lt;/strong&gt; Nodes are chosen per-pod's actual needs instead of forcing every workload into whatever instance type a node group happened to use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Faster scaling.&lt;/strong&gt; Direct EC2 Fleet calls skip the ASG polling loop, so new capacity shows up in seconds to a couple of minutes instead of several minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Less to manage.&lt;/strong&gt; One &lt;code&gt;NodePool&lt;/code&gt; and &lt;code&gt;EC2NodeClass&lt;/code&gt; replace a sprawl of hand-tuned node groups, one per instance-type-and-AZ combination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safer use of Spot.&lt;/strong&gt; Built-in interruption handling means Spot stops being "risky" and becomes just cheaper capacity with a warning light.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-healing infrastructure.&lt;/strong&gt; Nodes expire and get replaced automatically, so patching and drift correction happen on a schedule instead of a ticket.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How the Cost-Effective Strategies Are Implemented
&lt;/h2&gt;

&lt;p&gt;Karpenter's cost savings aren't a side effect they come from specific, configurable behaviors:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spot-first, mixed with On-Demand.&lt;/strong&gt; The &lt;code&gt;NodePool&lt;/code&gt; allows both capacity types, and Karpenter defaults to the cheapest option that satisfies the pod's constraints usually Spot, at up to 90% off On-Demand pricing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;requirements&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;karpenter.sh/capacity-type&lt;/span&gt;
    &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&lt;/span&gt;
    &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spot"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on-demand"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Consolidation.&lt;/strong&gt; Karpenter continuously re-evaluates the cluster. If pods on an underused node could be bin-packed onto existing capacity, it moves them and terminates the now-empty node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;disruption&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;consolidationPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;WhenEmptyOrUnderutilized&lt;/span&gt;
  &lt;span class="na"&gt;consolidateAfter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Broad instance selection.&lt;/strong&gt; By allowing whole instance &lt;em&gt;categories&lt;/em&gt; (&lt;code&gt;c&lt;/code&gt;, &lt;code&gt;m&lt;/code&gt;, &lt;code&gt;r&lt;/code&gt;) instead of a single type, Karpenter can pick whichever specific instance is cheapest and most available in that family at that moment rather than you guessing one in advance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scheduled node expiry.&lt;/strong&gt; Every node is replaced after a fixed lifetime, which doubles as automatic AMI patching no cost from stale, oversized, or drifted long-lived nodes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;expireAfter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;720h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Graceful Spot interruption handling.&lt;/strong&gt; Losing a Spot instance mid-job is what makes teams avoid Spot in the first place. Karpenter listens for the 2-minute interruption warning via SQS and EventBridge, drains the node in time, and replaces it so you get the discount without the risk of a workload dying uncleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting It Up
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. IAM Roles
&lt;/h3&gt;

&lt;p&gt;Karpenter needs two separate roles because two separate things assume them: the controller pod, and the EC2 instances it creates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Controller role&lt;/strong&gt;: trusted by your OIDC provider (IRSA), scoped to the &lt;code&gt;karpenter&lt;/code&gt; service account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role"&lt;/span&gt; &lt;span class="s2"&gt;"karpenter_controller"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"KarpenterControllerRole-${var.cluster_name}"&lt;/span&gt;

  &lt;span class="nx"&gt;assume_role_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="nx"&gt;Effect&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
      &lt;span class="nx"&gt;Principal&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Federated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_openid_connect_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;Action&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts:AssumeRoleWithWebIdentity"&lt;/span&gt;
      &lt;span class="nx"&gt;Condition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;StringEquals&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="s2"&gt;"${local.oidc_issuer}:sub"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"system:serviceaccount:karpenter:karpenter"&lt;/span&gt;
          &lt;span class="s2"&gt;"${local.oidc_issuer}:aud"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts.amazonaws.com"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Node role&lt;/strong&gt;: trusted by EC2, attached to every node Karpenter launches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role"&lt;/span&gt; &lt;span class="s2"&gt;"karpenter_node"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"KarpenterNodeRole-${var.cluster_name}"&lt;/span&gt;

  &lt;span class="nx"&gt;assume_role_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="nx"&gt;Effect&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;Principal&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ec2.amazonaws.com"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="nx"&gt;Action&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts:AssumeRole"&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role_policy_attachment"&lt;/span&gt; &lt;span class="s2"&gt;"node_policies"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;toset&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s2"&gt;"arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPullOnly"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter_node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;policy_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. The Controller Policy
&lt;/h3&gt;

&lt;p&gt;The controller role above only has a trust policy so far it says &lt;em&gt;who&lt;/em&gt; can assume it, not &lt;em&gt;what it's allowed to do&lt;/em&gt;. That comes from a separate inline policy granting &lt;code&gt;ec2:RunInstances&lt;/code&gt;, &lt;code&gt;ec2:CreateFleet&lt;/code&gt;, &lt;code&gt;ec2:TerminateInstances&lt;/code&gt;, instance-profile management, and &lt;code&gt;iam:PassRole&lt;/code&gt; on the node role.&lt;/p&gt;

&lt;p&gt;Don't hand-write this from scratch Karpenter's maintainers publish and maintain the canonical version as part of the official Getting Started CloudFormation template:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Official reference:&lt;/strong&gt; &lt;a href="https://karpenter.sh/docs/reference/cloudformation/" rel="noopener noreferrer"&gt;karpenter.sh/docs/reference/cloudformation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw policy source:&lt;/strong&gt; &lt;code&gt;https://raw.githubusercontent.com/aws/karpenter-provider-aws/v${KARPENTER_VERSION}/website/content/en/preview/getting-started/getting-started-with-karpenter/cloudformation.yaml&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pull the &lt;code&gt;KarpenterControllerPolicy&lt;/code&gt; statements out of that template into a local JSON file, templatize the account-specific values, and render it with &lt;code&gt;templatefile()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role_policy"&lt;/span&gt; &lt;span class="s2"&gt;"karpenter_controller_policy"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"KarpenterControllerPolicy-${var.cluster_name}"&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter_controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;templatefile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"${path.module}/policies/karpenter-controller-policy.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cluster_name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster_name&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;
    &lt;span class="nx"&gt;karpenter_node_role_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter_node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
    &lt;span class="nx"&gt;eks_cluster_arn&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_eks_cluster&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
    &lt;span class="nx"&gt;sqs_queue_arn&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_sqs_queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter_interruption&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Templating it this way means the policy JSON stays identical to the upstream version — only the account, region, cluster, role, and queue ARNs are substituted per environment so pulling in a future upstream policy update is a diff, not a rewrite.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Let the Nodes Actually Join the Cluster
&lt;/h3&gt;

&lt;p&gt;If your cluster uses the older &lt;code&gt;aws-auth&lt;/code&gt; ConfigMap, you'd map the node role there. On newer clusters with &lt;code&gt;authentication_mode = "API"&lt;/code&gt;, you skip the ConfigMap entirely and create an &lt;strong&gt;access entry&lt;/strong&gt; instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_eks_access_entry"&lt;/span&gt; &lt;span class="s2"&gt;"karpenter_node"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cluster_name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster_name&lt;/span&gt;
  &lt;span class="nx"&gt;principal_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter_node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"EC2_LINUX"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire equivalent of the old &lt;code&gt;mapRoles&lt;/code&gt; block — one resource, no YAML.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Make Subnets and Security Groups Discoverable
&lt;/h3&gt;

&lt;p&gt;Karpenter doesn't take a list of subnet IDs. It finds them by tag. Tag only your &lt;strong&gt;private&lt;/strong&gt; subnets, and Karpenter will never place a node in a public one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"karpenter.sh/discovery"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster_name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put the same tag on the node security group. Two tags, and Karpenter's entire networking config is done.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. SQS + EventBridge for Spot Interruptions
&lt;/h3&gt;

&lt;p&gt;This is the piece that makes Spot safe to use. AWS gives a 2-minute warning before reclaiming a Spot instance — but only if something is listening. EventBridge catches that warning and drops it on a queue; Karpenter polls the queue and drains the node before AWS pulls it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_sqs_queue"&lt;/span&gt; &lt;span class="s2"&gt;"karpenter_interrupt"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.cluster_name}-karpenter-spot-events"&lt;/span&gt;
  &lt;span class="nx"&gt;message_retention_seconds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_event_rule"&lt;/span&gt; &lt;span class="s2"&gt;"spot_interruption"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.cluster_name}-spot-interruption"&lt;/span&gt;
  &lt;span class="nx"&gt;event_pattern&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;source&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"aws.ec2"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;detail-type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"EC2 Spot Instance Interruption Warning"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_event_target"&lt;/span&gt; &lt;span class="s2"&gt;"spot_interruption_to_sqs"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_cloudwatch_event_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spot_interruption&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;arn&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_sqs_queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter_interrupt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repeat the rule/target pair for &lt;strong&gt;Rebalance Recommendation&lt;/strong&gt;, &lt;strong&gt;Instance State-change Notification&lt;/strong&gt;, and &lt;strong&gt;AWS Health Event&lt;/strong&gt; — four small rules, all feeding the same queue. Karpenter needs &lt;code&gt;sqs:ReceiveMessage&lt;/code&gt;, &lt;code&gt;DeleteMessage&lt;/code&gt;, and &lt;code&gt;GetQueueAttributes&lt;/code&gt; on it, granted through the controller policy above.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Install Karpenter with Helm
&lt;/h3&gt;

&lt;p&gt;Install the CRDs and the controller as &lt;strong&gt;two separate releases&lt;/strong&gt; — this keeps future CRD upgrades from breaking against an immutable-field error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"kubernetes_namespace_v1"&lt;/span&gt; &lt;span class="s2"&gt;"karpenter"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter_namespace&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"helm_release"&lt;/span&gt; &lt;span class="s2"&gt;"karpenter_crds"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"karpenter-crd"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter_namespace&lt;/span&gt;
  &lt;span class="nx"&gt;repository&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"oci://public.ecr.aws/karpenter"&lt;/span&gt;
  &lt;span class="nx"&gt;chart&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"karpenter-crd"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter_version&lt;/span&gt;
  &lt;span class="nx"&gt;create_namespace&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;kubernetes_namespace_v1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the controller itself, wired up to the queue, the IRSA role, and — critically — pinned to your &lt;strong&gt;existing managed node group&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"helm_release"&lt;/span&gt; &lt;span class="s2"&gt;"karpenter"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"karpenter"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter_namespace&lt;/span&gt;
  &lt;span class="nx"&gt;repository&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"oci://public.ecr.aws/karpenter"&lt;/span&gt;
  &lt;span class="nx"&gt;chart&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"karpenter"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter_version&lt;/span&gt;
  &lt;span class="nx"&gt;create_namespace&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="nx"&gt;wait&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;cleanup_on_fail&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;timeout&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;
  &lt;span class="nx"&gt;set&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"settings.clusterName"&lt;/span&gt;
      &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster_name&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"settings.interruptionQueue"&lt;/span&gt;
      &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter_queue_name&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"serviceAccount.annotations.eks&lt;/span&gt;&lt;span class="err"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.amazonaws&lt;/span&gt;&lt;span class="err"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.com/role-arn"&lt;/span&gt;
      &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter_controller_role_arn&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"controller.resources.requests.cpu"&lt;/span&gt;
      &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"controller.resources.requests.memory"&lt;/span&gt;
      &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1Gi"&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"controller.resources.limits.cpu"&lt;/span&gt;
      &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"controller.resources.limits.memory"&lt;/span&gt;
      &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1Gi"&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;# Pin Karpenter pods to the existing managed node group&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[0].matchExpressions[0].key"&lt;/span&gt;
      &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eks.amazonaws.com/nodegroup"&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[0].matchExpressions[0].operator"&lt;/span&gt;
      &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"In"&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[0].matchExpressions[0].values[0]"&lt;/span&gt;
      &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;node_group_name&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;kubernetes_namespace_v1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;helm_release&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;karpenter_crds&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why the node affinity block matters:&lt;/strong&gt; Karpenter's job is to launch nodes for pods that can't schedule — but Karpenter itself runs as a pod. If Karpenter is allowed to schedule onto nodes &lt;em&gt;it&lt;/em&gt; provisions, you get a chicken-and-egg deadlock: no Karpenter-provisioned nodes exist yet, so Karpenter can't start, so no nodes ever get created. The fix is to explicitly schedule Karpenter onto your &lt;strong&gt;base, pre-existing managed node group&lt;/strong&gt; — the one created in the EKS/Terraform bootstrap, not anything Karpenter itself spins up. That node group's only job is to keep a stable home for cluster-critical controllers; everything else in the cluster is fair game for Karpenter to provision, resize, and recycle.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Tell It What to Launch: NodePool + EC2NodeClass
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;EC2NodeClass&lt;/strong&gt;: the instance blueprint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;karpenter.k8s.aws/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EC2NodeClass&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;KarpenterNodeRole-my-cluster&lt;/span&gt;
  &lt;span class="na"&gt;amiSelectorTerms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;al2023@latest&lt;/span&gt;
  &lt;span class="na"&gt;subnetSelectorTerms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;karpenter.sh/discovery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;my-cluster&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;securityGroupSelectorTerms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;karpenter.sh/discovery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;my-cluster&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;NodePool&lt;/strong&gt;: the rules for when and what:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;karpenter.sh/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NodePool&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;requirements&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;karpenter.sh/capacity-type&lt;/span&gt;
          &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&lt;/span&gt;
          &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spot"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on-demand"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;karpenter.k8s.aws/instance-category&lt;/span&gt;
          &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&lt;/span&gt;
          &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;m"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;r"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;nodeClassRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;karpenter.k8s.aws&lt;/span&gt;
        &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EC2NodeClass&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
      &lt;span class="na"&gt;expireAfter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;720h&lt;/span&gt;
  &lt;span class="na"&gt;disruption&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;consolidationPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;WhenEmptyOrUnderutilized&lt;/span&gt;
    &lt;span class="na"&gt;consolidateAfter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Two roles plus one templated controller policy, one access entry, two tags, one queue with four EventBridge rules, two Helm releases pinned to your base node group, and two YAML manifests. Nothing here is complicated on its own — it's just a lot of small pieces that all have to point at each other correctly. Once they do, scaling stops being something you configure per-workload and becomes something the cluster just does, at whatever price the market currently offers.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://medium.com/@ajinkyaa3xd/enabling-karpenter-on-eks-with-terraform-what-it-is-why-its-worth-it-and-how-to-set-it-up-326467a2ce44" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>kubernetes</category>
      <category>terraform</category>
      <category>karpenter</category>
    </item>
    <item>
      <title>Deploying Envoy Gateway on AWS EKS: The Right Way</title>
      <dc:creator>Ajinkya</dc:creator>
      <pubDate>Thu, 18 Jun 2026 16:33:49 +0000</pubDate>
      <link>https://dev.to/ajinkya_a3/deploying-envoy-gateway-on-aws-eks-the-right-way-24dc</link>
      <guid>https://dev.to/ajinkya_a3/deploying-envoy-gateway-on-aws-eks-the-right-way-24dc</guid>
      <description>&lt;p&gt;Some context first: we were running on GKE Autopilot, where the Gateway API just works out of the box. Google manages the CRDs and the underlying load balancer controller for you create a Gateway, and it gets an external IP without you ever thinking about CRD lifecycle.&lt;/p&gt;

&lt;p&gt;Moving that same ingress layer to EKS meant none of that was ready to use anymore. The first real decision wasn't about Envoy Gateway's configuration at all it was about how to install its CRDs without them colliding with the Gateway API CRDs, or with each other, during future migrations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing the Gateway API CRDs
&lt;/h2&gt;

&lt;p&gt;We start by installing the Gateway API CRDs first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/standard-install.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this installs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GatewayClass&lt;/code&gt; CRD&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Gateway&lt;/code&gt; CRD&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;HTTPRoute&lt;/code&gt; CRD&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ReferenceGrant&lt;/code&gt; CRD&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Verify with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get crd | &lt;span class="nb"&gt;grep &lt;/span&gt;gateway.networking.k8s.io
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Installing Envoy Gateway's CRDs and Controller via ArgoCD
&lt;/h2&gt;

&lt;p&gt;Next, install Envoy Gateway's own CRDs and the controller itself as two separate ArgoCD Applications, on two separate sync waves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Application&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-gateway-crds&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;argocd.argoproj.io/sync-wave&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-1"&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oci://docker.io/envoyproxy&lt;/span&gt;
    &lt;span class="na"&gt;chart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway-crds-helm&lt;/span&gt;
    &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1.8.0&lt;/span&gt;
    &lt;span class="na"&gt;helm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;crds:&lt;/span&gt;
          &lt;span class="s"&gt;gatewayAPI:&lt;/span&gt;
            &lt;span class="s"&gt;enabled: false       # Gateway API CRDs managed separately&lt;/span&gt;
            &lt;span class="s"&gt;channel: experimental&lt;/span&gt;
          &lt;span class="s"&gt;envoyGateway:&lt;/span&gt;
            &lt;span class="s"&gt;enabled: true        # Only Envoy-specific CRDs&lt;/span&gt;
  &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://kubernetes.default.svc&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
  &lt;span class="na"&gt;syncPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;automated&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;prune&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;selfHeal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;syncOptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ServerSideApply=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CreateNamespace=false&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Application&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-gateway&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;argocd.argoproj.io/sync-wave&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0"&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oci://docker.io/envoyproxy&lt;/span&gt;
    &lt;span class="na"&gt;chart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway-helm&lt;/span&gt;
    &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1.8.0&lt;/span&gt;
    &lt;span class="na"&gt;helm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;skipCrds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;             &lt;span class="c1"&gt;# CRDs managed by envoy-gateway-crds app&lt;/span&gt;
  &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://kubernetes.default.svc&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-gateway-system&lt;/span&gt;
  &lt;span class="na"&gt;syncPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;automated&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;prune&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;selfHeal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;syncOptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ServerSideApply=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CreateNamespace=true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're not using ArgoCD, the equivalent Helm commands are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm &lt;span class="nb"&gt;install &lt;/span&gt;envoy-gateway-crds oci://docker.io/envoyproxy/gateway-crds-helm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version&lt;/span&gt; v1.8.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; default &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--server-side&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; crds.gatewayAPI.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; crds.gatewayAPI.channel&lt;span class="o"&gt;=&lt;/span&gt;experimental &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; crds.envoyGateway.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true

&lt;/span&gt;helm &lt;span class="nb"&gt;install &lt;/span&gt;envoy-gateway oci://docker.io/envoyproxy/gateway-helm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version&lt;/span&gt; v1.8.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; envoy-gateway-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--skip-crds&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--server-side&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;gatewayAPI.enabled: false&lt;/code&gt;&lt;/strong&gt; the shared Gateway API CRDs (&lt;code&gt;GatewayClass&lt;/code&gt;, &lt;code&gt;Gateway&lt;/code&gt;, &lt;code&gt;HTTPRoute&lt;/code&gt;, &lt;code&gt;ReferenceGrant&lt;/code&gt;) aren't installed by this chart. They're installed once, separately, by their own Application, independent of any controller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;envoyGateway.enabled: true&lt;/code&gt;&lt;/strong&gt; this chart installs only Envoy Gateway's own CRDs, including &lt;code&gt;EnvoyProxy&lt;/code&gt;, on sync-wave &lt;code&gt;-1&lt;/code&gt;, before the controller exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;skipCrds: true&lt;/code&gt;&lt;/strong&gt; on the envoy-gateway chart (wave &lt;code&gt;0&lt;/code&gt;) the controller deployment goes in after its CRDs already exist, and never touches CRD lifecycle itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ServerSideApply=true&lt;/code&gt;&lt;/strong&gt; on both field-level ownership instead of whole-object ownership, so multiple Applications can touch overlapping CRDs without one overwriting the other.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both Applications are templated as part of our cluster-bootstrap ApplicationSet, so every environment gets Envoy Gateway's CRDs-then-controller order automatically no manual sequencing per cluster for this part of the stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture at a Glance
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internet
   │
   ▼
AWS NLB  (provisioned by AWS Load Balancer Controller)
   │
   ▼
Envoy Proxy Pods  (managed by Envoy Gateway, autoscaled by HPA)
   │
   ▼
Application Services  (via HTTPRoute rules)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The LoadBalancer Pending Trap
&lt;/h2&gt;

&lt;p&gt;With the CRDs and controller in place, the next thing that breaks on a fresh EKS setup is the Service Envoy Gateway generates for its proxy deployment. By default, it's type &lt;code&gt;LoadBalancer&lt;/code&gt;, and Kubernetes' in-tree cloud controller tries to provision a Classic Load Balancer for it.&lt;/p&gt;

&lt;p&gt;On modern EKS clusters, that fails silently. No CLB gets created, no useful error appears in events, and the Service just sits at &lt;code&gt;&amp;lt;pending&amp;gt;&lt;/code&gt; indefinitely.&lt;/p&gt;

&lt;p&gt;The fix doesn't live on the Gateway object — Envoy Gateway generates its own Service internally, so there's nothing on &lt;code&gt;Gateway.metadata&lt;/code&gt; to annotate. The fix has to go into the &lt;code&gt;EnvoyProxy&lt;/code&gt; CRD, the same CRD installed separately in the &lt;code&gt;-1&lt;/code&gt; sync wave above, via &lt;code&gt;envoyService.annotations&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;envoyService&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Stops the in-tree CLB provisioner - AWS Load Balancer Controller&lt;/span&gt;
    &lt;span class="c1"&gt;# takes over and creates an NLB instead.&lt;/span&gt;
    &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;external"&lt;/span&gt;

    &lt;span class="c1"&gt;# Public-facing NLB. Use "internal" for private traffic only.&lt;/span&gt;
    &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-scheme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;internet-facing"&lt;/span&gt;

    &lt;span class="c1"&gt;# Route NLB traffic directly to Pod IPs via VPC CNI -&lt;/span&gt;
    &lt;span class="c1"&gt;# bypasses kube-proxy and NodePort.&lt;/span&gt;
    &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-nlb-target-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ip&lt;/span&gt;

    &lt;span class="c1"&gt;# NLB health check hits Envoy's admin port. /healthz returns 200&lt;/span&gt;
    &lt;span class="c1"&gt;# only once Envoy is fully ready, so the NLB never routes to a&lt;/span&gt;
    &lt;span class="c1"&gt;# pod that's still starting or draining.&lt;/span&gt;
    &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-healthcheck-protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTP&lt;/span&gt;
    &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-healthcheck-port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;19002"&lt;/span&gt;
    &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-healthcheck-path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/healthz"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The single annotation that actually breaks the deadlock is &lt;code&gt;aws-load-balancer-type: "external"&lt;/code&gt; it tells the in-tree controller to back off, and hands the Service to the AWS Load Balancer Controller, which then provisions a real NLB and writes its hostname back to &lt;code&gt;gateway.status.addresses&lt;/code&gt;. The rest of the block (scheme, target type, health check) is what makes that NLB actually production-ready rather than just "not pending."&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It Together: GatewayClass, Gateway, and EnvoyProxy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GatewayClass&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eg&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;controllerName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io/gatewayclass-controller&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Gateway&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;external-gateway&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gatewayClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eg&lt;/span&gt;
  &lt;span class="na"&gt;infrastructure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;parametersRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EnvoyProxy&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;external-proxy-config&lt;/span&gt;
  &lt;span class="na"&gt;listeners&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
      &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTP&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the &lt;code&gt;EnvoyProxy&lt;/code&gt; CRD that ties resources, autoscaling, and the LB fix together in one place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EnvoyProxy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;external-proxy-config&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Kubernetes&lt;/span&gt;
    &lt;span class="na"&gt;kubernetes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;envoyDeployment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;patch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;StrategicMerge&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shutdown-manager&lt;/span&gt;
                      &lt;span class="na"&gt;lifecycle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                        &lt;span class="na"&gt;preStop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                          &lt;span class="na"&gt;exec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                            &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/bin/sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sleep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;120"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;250m&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512Mi&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Gi&lt;/span&gt;

      &lt;span class="na"&gt;envoyHpa&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;minReplicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
        &lt;span class="na"&gt;maxReplicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
        &lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Resource&lt;/span&gt;
            &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cpu&lt;/span&gt;
              &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Utilization&lt;/span&gt;
                &lt;span class="na"&gt;averageUtilization&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;

      &lt;span class="na"&gt;envoyService&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;external"&lt;/span&gt;
          &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-scheme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;internet-facing"&lt;/span&gt;
          &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-nlb-target-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ip&lt;/span&gt;
          &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-healthcheck-protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTP&lt;/span&gt;
          &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-healthcheck-port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;19002"&lt;/span&gt;
          &lt;span class="na"&gt;service.beta.kubernetes.io/aws-load-balancer-healthcheck-path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/healthz"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Applying the Gateway triggers the chain reaction: Envoy Gateway creates the proxy deployment, the LoadBalancer Service with the annotations above, and an HPA — then AWS LBC provisions the NLB. HTTPRoute objects attach to the Gateway afterward and define per-service routing, owned by app teams.&lt;/p&gt;

&lt;h2&gt;
  
  
  Autoscaling Explained
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;envoyHpa&lt;/code&gt; block creates a standard Kubernetes HPA against the proxy deployment. &lt;code&gt;minReplicas: 1&lt;/code&gt; keeps cost down during idle periods, at the cost of zero redundancy for ~15-30s if that pod dies. &lt;code&gt;averageUtilization: 60&lt;/code&gt; (150m of the 250m request) triggers scale-out early enough that new pods are healthy before latency degrades. For zero-downtime guarantees, &lt;code&gt;minReplicas: 2&lt;/code&gt; or &lt;code&gt;3&lt;/code&gt; is the move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Running Envoy Gateway on Amazon EKS isn't just about deploying another ingress controller — it's about understanding where the responsibilities are split.&lt;/p&gt;

&lt;p&gt;Unlike managed Kubernetes offerings where the Gateway API experience is largely invisible, EKS gives you the flexibility to control every layer. That also means you own the lifecycle of the Gateway API CRDs, the Envoy Gateway CRDs, the controller installation, and the integration with the AWS Load Balancer Controller.&lt;/p&gt;

&lt;p&gt;Separating CRDs from the controller, using ArgoCD sync waves to guarantee deployment order, and configuring the EnvoyProxy resource as the single place for infrastructure concerns makes the setup predictable and GitOps-friendly. It also avoids one of the most common migration issues: LoadBalancer Services remaining in a perpetual Pending state because the wrong controller is trying to provision them.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>aws</category>
      <category>apigateway</category>
      <category>gitops</category>
    </item>
    <item>
      <title>Why We Moved from GKE to EKS</title>
      <dc:creator>Ajinkya</dc:creator>
      <pubDate>Sat, 25 Apr 2026 20:47:53 +0000</pubDate>
      <link>https://dev.to/ajinkya_a3/why-we-moved-from-gke-to-eks-1m96</link>
      <guid>https://dev.to/ajinkya_a3/why-we-moved-from-gke-to-eks-1m96</guid>
      <description>&lt;h2&gt;
  
  
  Why We Moved from GKE to EKS
&lt;/h2&gt;

&lt;p&gt;When we initially adopted Kubernetes, &lt;strong&gt;Google Kubernetes Engine (GKE) Autopilot&lt;/strong&gt; seemed like the perfect choice — fully managed, minimal operational overhead, and quick to get started.&lt;/p&gt;

&lt;p&gt;But as our workloads matured, three major challenges started to surface:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rising and unpredictable costs&lt;/li&gt;
&lt;li&gt;Compliance constraints&lt;/li&gt;
&lt;li&gt;The need for deeper infrastructure control&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This blog walks through &lt;strong&gt;why we migrated&lt;/strong&gt; to &lt;strong&gt;Amazon Elastic Kubernetes Service (EKS)&lt;/strong&gt; with Karpenter, the architectural changes we made, and the lessons we learned running production workloads post-migration.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚠️ Why GKE Autopilot Started Falling Short
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Cost Inefficiencies at Scale
&lt;/h3&gt;

&lt;p&gt;GKE Autopilot pricing is based on &lt;strong&gt;requested resources, not actual usage&lt;/strong&gt;. This sounds fine at small scale — but as traffic grows, the gaps between requested and actual usage start to compound.&lt;/p&gt;

&lt;p&gt;Problems we observed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Over-provisioned workloads leading to higher bills&lt;/li&gt;
&lt;li&gt;No access to Spot/Preemptible node strategies with the same level of flexibility&lt;/li&gt;
&lt;li&gt;Very few cost optimization knobs to tune&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As traffic grew, costs increased almost linearly with no meaningful way to optimize without restructuring our entire workload configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Compliance and Governance Constraints
&lt;/h3&gt;

&lt;p&gt;Operating in a regulated environment required:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fine-grained IAM control at the workload level&lt;/li&gt;
&lt;li&gt;Strict network isolation between services&lt;/li&gt;
&lt;li&gt;Audit-level visibility into infrastructure activity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With GKE Autopilot, several configurations are abstracted away or restricted by design. This made it harder to enforce organization-wide security policies and satisfy compliance requirements from auditors. Specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enforcing per-pod IAM permissions cleanly was non-trivial&lt;/li&gt;
&lt;li&gt;Network policy enforcement had gaps in our specific setup&lt;/li&gt;
&lt;li&gt;Generating audit-ready logs tied to individual workload actions required workarounds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We needed something that gave us &lt;strong&gt;first-class integration with cloud-native IAM and security tooling&lt;/strong&gt; — without layering on custom solutions.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Limited Infrastructure Control
&lt;/h3&gt;

&lt;p&gt;When performance-sensitive services started hitting bottlenecks, the inability to choose instance types became a real blocker. We had no control over:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CPU vs. memory-optimized instance selection&lt;/li&gt;
&lt;li&gt;ARM-based workloads on Graviton processors&lt;/li&gt;
&lt;li&gt;Custom AMIs or low-level networking tuning&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For teams running general-purpose workloads, this abstraction is a feature. For us, it was a ceiling.&lt;/p&gt;




&lt;h2&gt;
  
  
  🎯 Why We Chose EKS + Karpenter
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Full Infrastructure Control
&lt;/h3&gt;

&lt;p&gt;Moving to EKS gave us direct control over:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Instance families&lt;/strong&gt; — CPU-optimized, memory-optimized, ARM (Graviton)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom AMIs&lt;/strong&gt; — hardened images meeting our internal security baseline&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Networking&lt;/strong&gt; — VPC-native networking with fine-grained subnet and security group control&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This unlocked workload-specific performance tuning that simply wasn't possible before.&lt;/p&gt;

&lt;h3&gt;
  
  
  Advanced Cost Optimization with Karpenter
&lt;/h3&gt;

&lt;p&gt;Karpenter is not your traditional cluster autoscaler. Instead of scaling pre-defined node groups, it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Watches for unschedulable pods in real time&lt;/li&gt;
&lt;li&gt;Selects the &lt;strong&gt;right-sized instance&lt;/strong&gt; based on actual pod requirements&lt;/li&gt;
&lt;li&gt;Prioritizes &lt;strong&gt;Spot instances&lt;/strong&gt; where workloads allow, falling back to On-Demand seamlessly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bin-packs nodes efficiently&lt;/strong&gt;, reducing idle capacity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result: faster scaling reactions and a dramatically lower compute bill — without sacrificing reliability.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compliance Alignment
&lt;/h3&gt;

&lt;p&gt;AWS gave us the compliance story we needed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;IRSA (IAM Roles for Service Accounts)&lt;/strong&gt; — precise, per-pod IAM permissions with no shared credentials&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VPC-level isolation&lt;/strong&gt; — full control over ingress, egress, and inter-service communication&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudTrail integration&lt;/strong&gt; — every API call, every node action, fully auditable out of the box&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS Config + Security Hub&lt;/strong&gt; — continuous compliance checks against CIS benchmarks and custom rules&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This made our next compliance audit significantly smoother. Auditors got clear, traceable logs without us having to build custom instrumentation.&lt;/p&gt;




&lt;h2&gt;
  
  
  🏗️ Target Architecture
&lt;/h2&gt;

&lt;p&gt;Here's what the high-level migration looked like architecturally:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Before (GKE)&lt;/th&gt;
&lt;th&gt;After (EKS)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cluster&lt;/td&gt;
&lt;td&gt;GKE Autopilot&lt;/td&gt;
&lt;td&gt;EKS (Managed Node Groups + Karpenter)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Autoscaling&lt;/td&gt;
&lt;td&gt;Built-in Autopilot scaling&lt;/td&gt;
&lt;td&gt;Karpenter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spot Strategy&lt;/td&gt;
&lt;td&gt;NO Control&lt;/td&gt;
&lt;td&gt;Karpenter Spot-first provisioning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IAM&lt;/td&gt;
&lt;td&gt;GCP Workload Identity&lt;/td&gt;
&lt;td&gt;AWS IRSA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audit Logging&lt;/td&gt;
&lt;td&gt;Cloud Audit Logs&lt;/td&gt;
&lt;td&gt;CloudTrail + CloudWatch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Networking&lt;/td&gt;
&lt;td&gt;GKE VPC-native&lt;/td&gt;
&lt;td&gt;AWS VPC with custom subnets&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  ⚙️ Karpenter Setup — The Game Changer
&lt;/h2&gt;

&lt;p&gt;Karpenter replaced our traditional Cluster Autoscaler, and the difference was immediately visible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How we configured it:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt; &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;karpenter.sh/v1&lt;/span&gt;
    &lt;span class="s"&gt;kind&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NodePool&lt;/span&gt;
    &lt;span class="s"&gt;metadata&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spot-arm64&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# -----------------------------------------------&lt;/span&gt;
            &lt;span class="c1"&gt;# These labels land on the EC2 node.&lt;/span&gt;
            &lt;span class="c1"&gt;# Your pod affinity rules match against these.&lt;/span&gt;
            &lt;span class="c1"&gt;# -----------------------------------------------&lt;/span&gt;
            &lt;span class="na"&gt;node-pool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spot-arm64&lt;/span&gt;
            &lt;span class="na"&gt;capacity-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spot&lt;/span&gt;
            &lt;span class="na"&gt;arch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arm64&lt;/span&gt;
            &lt;span class="na"&gt;workload-class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;standard&lt;/span&gt;
        &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;requirements&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes.io/arch&lt;/span&gt;
              &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&lt;/span&gt;
              &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arm64"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes.io/os&lt;/span&gt;
              &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&lt;/span&gt;
              &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;linux"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;karpenter.sh/capacity-type&lt;/span&gt;
              &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&lt;/span&gt;
              &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spot"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;karpenter.k8s.aws/instance-category&lt;/span&gt;
              &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&lt;/span&gt;
              &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;m"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;r"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
              &lt;span class="c1"&gt;# c6g, c7g, c8g — compute optimized Graviton&lt;/span&gt;
              &lt;span class="c1"&gt;# m6g, m7g, m8g — general purpose Graviton&lt;/span&gt;
              &lt;span class="c1"&gt;# r6g, r7g, r8g — memory optimized Graviton&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;karpenter.k8s.aws/instance-generation&lt;/span&gt;
              &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Gt&lt;/span&gt;
              &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;             &lt;span class="c1"&gt;# Graviton2+ only (gen 6,7,8)&lt;/span&gt;
          &lt;span class="na"&gt;nodeClassRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;karpenter.k8s.aws&lt;/span&gt;
            &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EC2NodeClass&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
          &lt;span class="na"&gt;expireAfter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;168h&lt;/span&gt;             &lt;span class="c1"&gt;# 7 days — shorter for spot nodes&lt;/span&gt;


      &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;500&lt;/span&gt;
        &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2000Gi&lt;/span&gt;
      &lt;span class="na"&gt;disruption&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;consolidationPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;WhenEmptyOrUnderutilized&lt;/span&gt;
        &lt;span class="na"&gt;consolidateAfter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2m&lt;/span&gt;
      &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;100&lt;/span&gt; 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key decisions we made:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Spot-first provisioning&lt;/strong&gt; — workloads that tolerate interruptions run on Spot; stateful services stay on On-Demand&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple instance families&lt;/strong&gt; — Karpenter picks the cheapest right-sized option across families&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interruption handling&lt;/strong&gt; — we use the Karpenter interruption queue (SQS) to gracefully drain Spot nodes before AWS reclaims them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;consolidateAfter: 2m&lt;/code&gt;&lt;/strong&gt; — nodes deprovision 2m seconds after going idle, eliminating ghost capacity&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  📊 Results After Migration
&lt;/h2&gt;

&lt;h3&gt;
  
  
  💰 Cost
&lt;/h3&gt;

&lt;p&gt;Compute costs dropped significantly. The main drivers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Spot instances covering the majority of our non-critical workloads&lt;/li&gt;
&lt;li&gt;Karpenter's bin-packing eliminating idle node waste&lt;/li&gt;
&lt;li&gt;Right-sized instances instead of over-provisioned static node groups&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ⚡ Performance
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Faster pod scheduling — Karpenter provisions new nodes in under 60 seconds in most cases&lt;/li&gt;
&lt;li&gt;Better workload isolation through custom node selectors and taints&lt;/li&gt;
&lt;li&gt;Graviton (ARM) instances for compatible workloads gave us a meaningful price-performance improvement&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🔐 Compliance
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Audit reports now generated directly from CloudTrail without custom tooling&lt;/li&gt;
&lt;li&gt;IRSA eliminated shared IAM credential risks&lt;/li&gt;
&lt;li&gt;Security Hub provides continuous posture monitoring against our compliance framework&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  ⚠️ Challenges We Faced
&lt;/h2&gt;

&lt;p&gt;Being honest here — this is what makes a migration story actually useful.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Karpenter Learning Curve
&lt;/h3&gt;

&lt;p&gt;Karpenter's provisioning model is fundamentally different from Cluster Autoscaler. Debugging why a node wasn't provisioned — or why Karpenter chose a specific instance type — required understanding its internal decision logic. The logs are verbose but not always immediately readable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What helped:&lt;/strong&gt; Running Karpenter in dry-run mode first, and adding structured logging to correlate provisioning decisions with pod events.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Networking Model Differences
&lt;/h3&gt;

&lt;p&gt;GKE's VPC-native networking and AWS VPC behave differently in non-obvious ways — especially around CIDR planning, secondary IP ranges, and how pod IPs are allocated. We had to redesign our subnet layout and revisit some service-to-service communication assumptions.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. IAM Complexity
&lt;/h3&gt;

&lt;p&gt;IRSA is powerful but requires careful role design. Mapping GCP Workload Identity bindings to AWS IRSA role assumptions took time, especially for services that assumed broad IAM permissions under GCP that needed to be tightened properly.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 Key Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Managed ≠ always optimal at scale.&lt;/strong&gt; Autopilot is excellent for getting started, but production-grade platforms eventually need control surfaces that fully managed offerings deliberately hide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost optimization requires infrastructure access.&lt;/strong&gt; You can't tune what you can't see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autoscaling strategy matters more than cluster size.&lt;/strong&gt; Karpenter's approach of provisioning for the pod rather than scaling a group changed how we think about capacity planning entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance is easier when the platform is designed for it.&lt;/strong&gt; AWS's native compliance tooling removed a category of work that we were previously solving with custom scripts and log forwarding pipelines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migration should always be incremental.&lt;/strong&gt; Parallel environment, gradual DNS cutover, canary deployments — this approach meant we caught issues in staging before they became production incidents.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🏁 Conclusion
&lt;/h2&gt;

&lt;p&gt;GKE Autopilot is an excellent choice for teams that want Kubernetes without the operational overhead — and we'd still recommend it for that use case.&lt;/p&gt;

&lt;p&gt;But for production environments that require &lt;strong&gt;cost control at scale&lt;/strong&gt;, &lt;strong&gt;fine-grained compliance posture&lt;/strong&gt;, and &lt;strong&gt;workload-specific infrastructure decisions&lt;/strong&gt;, EKS with Karpenter provided a more flexible and efficient platform.&lt;/p&gt;

&lt;p&gt;The migration wasn't trivial, but the control, visibility, and cost profile on the other side made it worth it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Have you gone through a similar migration? Or are you evaluating EKS vs GKE for your stack? Drop your questions in the comments — happy to dig into specifics.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; &lt;code&gt;kubernetes&lt;/code&gt; &lt;code&gt;aws&lt;/code&gt; &lt;code&gt;devops&lt;/code&gt; &lt;code&gt;cloud&lt;/code&gt; &lt;code&gt;karpenter&lt;/code&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>aws</category>
      <category>devops</category>
      <category>karpenter</category>
    </item>
  </channel>
</rss>
