Notes · Learn AWS · CHAPTER 10

Kubernetes on AWS (EKS)

The chapter most likely to map to your day job. If you're an Azure-savvy DevOps engineer who's been living in AKS, this is where you swap clusters without swapping mental models. EKS is the same Kubernetes - the differences are in the IAM glue, the CNI, the load balancer story, and the bill.

AKS hides a lot of the AWS pain points behind defaults: control plane is free, the CNI does sane things, ingress is "tick a box". EKS is the opposite philosophy - everything is composable, almost nothing is automatic. The control plane bills monthly. Networking demands you understand ENIs. Workload identity is a deliberate setup, not a flag. The payoff is more knobs and a tighter integration with IAM than AKS has with Entra. This chapter walks the deltas in the order you'll hit them when migrating a workload.

In this chapter
  1. The cheat table
  2. EKS control plane
  3. Node groups: managed, self-managed, Fargate
  4. VPC CNI: pods get real IPs
  5. IRSA and EKS Pod Identity
  6. AWS Load Balancer Controller
  7. EBS CSI, EFS CSI, and Karpenter
  8. Try it: read-only cluster inspection
  9. Quick check (quiz)
  10. Gotchas for Azure devs
  11. Project Compass: Lambda to EKS
  12. Recap & next

The cheat table: AKS → EKS

ConceptAzure / AKSAWS / EKS
Managed control planeAKS (free standard tier, paid uptime SLA tier)EKS - $0.10/hr per cluster (~$73/month, always)
Cluster identity / workload identityAKS managed identity + Azure AD Workload IdentityIRSA (older, OIDC-based) or EKS Pod Identity (2023, simpler)
Pod networking (CNI)Azure CNI (real VNet IPs) or kubenet (overlay)VPC CNI (real VPC IPs, default). Alternatives: Calico, Cilium overlays
Persistent volumesAzure CSI - Managed Disks, Azure FilesEBS CSI driver (block), EFS CSI driver (shared NFS)
Ingress / Layer 7 LBApplication Gateway Ingress Controller (AGIC) or NGINXAWS Load Balancer Controller (ALBs via Ingress, NLBs via Service)
Workload identity for k8sAzure AD Workload IdentityIRSA / EKS Pod Identity
Spot capacitySpot node poolsSpot instances (managed node groups or Karpenter)
Auto-scaling nodesCluster Autoscaler (and AKS-managed autoscaler)Karpenter (preferred, 2021+) or Cluster Autoscaler
Container registryACRECR (also IAM-gated, supports immutable tags, scan-on-push)
RBAC binding to cloud identityAzure RBAC integration (assign Entra groups to k8s RBAC)aws-auth ConfigMap (legacy) or EKS access entries (2023+)
Cluster upgrade windowRoughly N-2 minor versions supportedOnly 4 minor versions in standard support - upgrade cadence is non-optional
"Run pods without managing nodes"ACI virtual nodes (kubelet to ACI)Fargate profiles (per-pod billing, no nodes)
The row to internalize: EKS control plane is always $73/month, per cluster. AKS' default tier is free. This single line item is the #1 cause of "wait, why does our dev environment cost $1,000/month?" - because someone spun up 14 clusters and never tore them down.
What's in a name? - the EKS glossary
EKS
Elastic Kubernetes Service. Launched June 2018, three years after AKS. The "Elastic" prefix is shared with EC2, EBS, EFS, ELB - it's AWS' brand verb for "we scale this for you".
IRSA
IAM Roles for Service Accounts. Announced September 2019. Pronounced "ER-suh". The pod's Kubernetes ServiceAccount JWT is exchanged at AWS STS for temporary IAM credentials. Same family as Workload Identity on AKS, different plumbing.
ENI
Elastic Network Interface. A virtual NIC attached to an EC2 instance. Each instance type has a limit on ENIs and on IPs-per-ENI. The VPC CNI puts pod IPs on these ENIs - so the per-instance pod cap comes from this hardware ceiling, not from Kubernetes.
CNI
Container Network Interface. A vendor-neutral spec (CNCF) for how container runtimes wire up pod networking. AWS' implementation is called "VPC CNI" or "amazon-vpc-cni-k8s". It's a daemonset that talks to the EC2 API to attach ENIs and assign IPs.
Karpenter
A pun on "carpenter" - the tool that builds the cluster. Open-sourced by AWS in late 2021. Provisions nodes directly via EC2 APIs (skipping Auto Scaling Groups) and consolidates them aggressively when load drops. Watches pending pods and picks the cheapest instance type that fits.
Fargate
Named for the gate at a cargo terminal - your code rolls through, AWS handles everything behind it. Fargate was launched for ECS in 2017, extended to EKS in late 2019. Per-pod billing, no nodes to manage, but ~2x more expensive than equivalent EC2 for steady workloads.
IMDS
Instance Metadata Service. The link-local endpoint 169.254.169.254 that EC2 instances query for credentials, region, AMI ID. IMDSv1 was unauthenticated GET. IMDSv2 requires a token (PUT then GET), mitigating SSRF. EKS nodes should always use IMDSv2 with a low hop-limit.

EKS control plane

ELI5: what the control plane is
The control plane is the brain of the cluster - the API server, the scheduler, the controller manager, and etcd. In AKS, Azure runs it for free (mostly) and you don't see it. In EKS, AWS also runs it for you, but they charge $0.10 per hour per cluster, forever, whether you're using it or not. The control plane runs in AWS' own VPC; your nodes connect to it across an ENI bridge in your VPC.

EKS is a managed Kubernetes control plane - same flavor as upstream Kubernetes, plus a few AWS-specific add-ons (the VPC CNI, kube-proxy, CoreDNS, EBS CSI). AWS runs the API server, scheduler, and etcd in their own account. You bring the worker nodes (or use Fargate). The boundary is the same as AKS conceptually; the bill is not.

EKS architecture: AWS-owned control plane, your-VPC data plane AWS-managed VPC ($73/mo) API server multi-AZ etcd backed up by AWS Scheduler + controllers OIDC issuer for IRSA tokens Your VPC (you pay for nodes, ENIs, NAT) Managed Node Group EC2, your AMI/ours kubelet, kube-proxy Self-managed Nodes you patch AMIs + Karpenter (recommended) Fargate profiles no nodes, per-pod billing, slow start, no DaemonSets VPC CNI, kube-proxy, CoreDNS (add-ons) EKS-managed by default; you can take over versions ENI cross-account
The orange box is AWS' account; the green box is yours. The ENI bridge is the only path between them. This boundary explains why EKS API access is gated by IAM (the request enters AWS' VPC) but pod-to-pod traffic stays purely in your VPC.

What you pay for, and what you don't

ComponentWho runs itYou pay?
API server / etcd / schedulerAWS$0.10/hr per cluster (~$73/month)
Worker nodes (EC2)YouYes - regular EC2 pricing + EBS + ENI usage
Fargate podsAWS-managed micro-VMsYes - per vCPU-hr + per GB-hr (~2x EC2)
NAT Gateway egressAWS-managed$0.045/GB out - sneaky at scale
Control-plane logging to CloudWatchAWS-managed$0.50/GB ingest + $0.03/GB storage
EKS managed add-ons (CNI, kube-proxy, CoreDNS)AWS-managedFree - just runs as pods on your nodes

Provision a cluster in Terraform

Terraform · azurerm (AKS)
resource "azurerm_kubernetes_cluster" "aks" {
  name                = "compass-aks"
  location            = "eastus"
  resource_group_name = azurerm_resource_group.rg.name
  dns_prefix          = "compass"
  kubernetes_version  = "1.30"

  default_node_pool {
    name       = "system"
    node_count = 2
    vm_size    = "Standard_D2s_v5"
  }

  identity { type = "SystemAssigned" }

  network_profile {
    network_plugin = "azure"   # Azure CNI
  }
}
Terraform · aws (EKS)
# Real-world: use the terraform-aws-modules/eks module.
# Bare resource shown for clarity.
resource "aws_eks_cluster" "eks" {
  name     = "compass-eks"
  role_arn = aws_iam_role.cluster.arn
  version  = "1.30"

  vpc_config {
    subnet_ids = module.vpc.private_subnet_ids
    endpoint_private_access = true
    endpoint_public_access  = true
  }

  enabled_cluster_log_types = [
    "api", "audit", "authenticator",
  ]
}

resource "aws_eks_node_group" "ng" {
  cluster_name    = aws_eks_cluster.eks.name
  node_group_name = "default"
  node_role_arn   = aws_iam_role.nodes.arn
  subnet_ids      = module.vpc.private_subnet_ids
  instance_types  = ["m6i.large"]
  scaling_config { desired_size = 2  min_size = 2  max_size = 6 }
}
The cluster IAM role is mandatory. EKS won't create a cluster without a service-linked role that trusts eks.amazonaws.com. The role needs AmazonEKSClusterPolicy attached. The node role is separate and needs AmazonEKSWorkerNodePolicy, AmazonEKS_CNI_Policy, and AmazonEC2ContainerRegistryReadOnly. AKS hides all this; in EKS you wire it yourself (or use the official Terraform module).
Control plane logging: enable at least audit and authenticator log types from day one. The audit log is your only record of "who called the k8s API and got what answer" - it's the equivalent of the AKS diagnostic settings. It does cost money (CloudWatch ingest), but you cannot retroactively enable it for a past incident.

Node groups: managed, self-managed, Fargate

AKS has node pools. EKS has three patterns for "where do my pods run?" - and the trade-offs are unique enough that picking the wrong one bites for the lifetime of the cluster.

Managed node groups

What: EKS provisions EC2 instances behind an Auto Scaling Group, joins them to the cluster, and rolls updates for you when you bump the AMI.

When: default choice. Closest to "AKS node pool" in feel.

Trade-off: EKS picks the AMI; your custom AMI options are limited. Scaling is via ASG, which is slower to react than Karpenter.

Self-managed nodes

What: you create the ASG (or use Karpenter), join nodes via the bootstrap script, and patch AMIs yourself.

When: custom kernels, GPU AMIs, bottlerocket variants, or Karpenter-driven dynamic provisioning.

Trade-off: more ops; in return, full control over AMI/userdata/instance shape.

Fargate profiles

What: pod runs in an AWS-managed micro-VM. No node visible to kubectl get nodes at all (each pod gets its own one-pod "node").

When: bursty workloads, untrusted code, "I just don't want nodes".

Trade-off: ~2x cost of EC2 for steady load. Slower pod start (45-60s). No DaemonSets, no privileged pods, no hostPath volumes, no GPU.

Fun fact Fargate pods don't share a kernel with each other. Each pod boots its own Firecracker microVM - the same lightweight hypervisor that powers AWS Lambda. This is why Fargate's blast radius is small (strong tenant isolation) but pod start is slow (you're waiting on VM boot, not container start) and you cannot run DaemonSets (there's nothing to daemon-on).

Picking between them

QuestionChoose
"I want AKS-like managed node pools with minimum surprises"Managed node groups
"Workload runs 24/7, steady load, cost matters"Managed (or self-managed) + Savings Plans / Reserved Instances
"Spiky load, mixed instance types, I want to pack tightly"Self-managed + Karpenter
"GPU nodes for ML, custom AMI"Self-managed + bottlerocket-nvidia AMI
"CI jobs, one-off pods, untrusted code"Fargate profiles
"I want zero node management and don't care about cost"Fargate profiles (but you will care about cost soon)
Cost trap: "we picked Fargate for no-node-management" ~$1,800 / month

A platform team running a steady backend on EKS picks Fargate "because nobody wants to manage nodes". They have 50 pods running 24/7, each sized at 1 vCPU and 2 GB. The first month's bill arrives and shows ~$1,800 for Fargate alone, before any storage, networking, or load balancers. Their finance partner asks why a 50-pod workload costs more than their previous Lambda fleet. What's going on, and how should they restructure?

Click to reveal the math
Fargate pricing (us-east-1, May 2026 reference): $0.04048 / vCPU-hr + $0.004445 / GB-hr.

Per pod, per hour: (1 × 0.04048) + (2 × 0.004445) = $0.04937. Per pod per month (730 hours): $36. 50 pods: $1,800/month.

Equivalent on EC2: the 50 pods need 50 vCPU + 100 GB total. A c6i.4xlarge (16 vCPU / 32 GB, ~$0.68/hr) fits 12-13 of these pods. 4 nodes covers it. On-demand: 4 × $0.68 × 730 = $1,985. Already roughly the same, but with a 3-year Compute Savings Plan at ~57% off, it drops to ~$850/month. Spot would be cheaper still for fault-tolerant workloads.

The fix: use Fargate selectively. Good fits: CI jobs, build pods, infrequent batch, untrusted code, very bursty admission webhooks. Bad fits: steady 24/7 services, anything that benefits from bin-packing. The "no node management" benefit evaporates when Karpenter is in the picture - it manages nodes for you and gets you Spot pricing.

Counter-intuitive but true: managed node groups with Karpenter + Savings Plans is often both cheaper and lower ops than blanket-Fargate, because the automation does what Fargate's price premium is supposed to buy you.

VPC CNI: pods get real IPs

ELI5: VPC CNI
In most Kubernetes clusters, pods live on an "overlay" network - a fake network layered on top of the real one, with NAT in between. AWS' default CNI throws that out: every pod gets a real VPC IP, drawn from your subnets, attached to a real network card on the node. Pod traffic looks like EC2 traffic to AWS. Faster (no encapsulation) and cleaner for security groups, but it caps how many pods fit on a node - because each pod consumes a real IP and the EC2 instance has a hardware limit on IPs.

The VPC CNI is the most-AWS-specific piece of EKS. AKS' Azure CNI does the same trick (real VNet IPs), so you may already be used to this style - but AWS' per-instance pod limit is much stricter, and the workarounds are unique enough to be worth understanding.

Pods on real VPC IPs vs pods on an overlay Overlay CNI (Calico, Flannel) Node IP: 10.0.1.42 (single ENI, single VPC IP) pod 100.96.1.5 pod 100.96.1.6 pod 100.96.1.7 Pod IPs are NATed/encapsulated. Pods can pack densely (250+ per node). VPC can't see pod IPs directly. VPC CNI (AWS default) Node IP: 10.0.1.42 (multiple ENIs, each with N secondary IPs) pod 10.0.1.78 pod 10.0.1.91 pod 10.0.1.104 Pod IPs are real VPC IPs. SGs apply to pods directly. Capped by ENIs × IPs-per-ENI per instance.
Same node, two CNI worldviews. AWS picks the right side because security groups + route tables + flow logs apply uniformly to pods - but you pay with pod-density caps.

The pod-density ceiling

Every EC2 instance type has a fixed max ENIs and max IPs-per-ENI. Pod count per node = (max ENIs × max IPs-per-ENI) - 1 (the primary IP). Some real numbers:

Instance typevCPU / RAMMax ENIsIPs / ENIMax pods (default)With prefix delegation
t3.medium2 / 4 GiB3617~110
t3.large2 / 8 GiB31235~110
m6i.large2 / 8 GiB31029~110
m6i.xlarge4 / 16 GiB41558~250
m6i.4xlarge16 / 64 GiB830234~250
Fun fact The t3.medium cap of ~17 pods led to a long-running complaint: "why can my AKS dev node run 100 pods but my EKS dev node runs out at 17?" In August 2021 AWS shipped prefix delegation for the VPC CNI - instead of allocating individual /32 IPs to each ENI, the CNI assigns /28 prefixes (16 IPs each). The same t3.medium can now host roughly 110 pods. Enable it via the aws-node DaemonSet env var ENABLE_PREFIX_DELEGATION=true.

Subnet sizing trap

Because every pod consumes a real VPC IP, the cluster's pod-network subnets need to be big. A common pattern from VNet thinking is /24 subnets (~250 usable IPs). Three /24 subnets across AZs gives you maybe 750 IPs - which is fine for a handful of nodes, but means your cluster maxes out at ~700 pods total before you're out of IPs.

Rule of thumb: for any EKS cluster you expect to grow, use /19 or larger pod subnets (~8,190 IPs each). If you can't (because someone burned the VPC CIDR space), the answer is to add secondary CIDRs to the VPC for pods - the VPC CNI supports a custom-networking mode where pods come from a separate CIDR than nodes.

Configure prefix delegation in Terraform

eks-addons.tf · vpc-cni add-on with prefix delegation
resource "aws_eks_addon" "vpc_cni" {
  cluster_name = aws_eks_cluster.eks.name
  addon_name   = "vpc-cni"
  addon_version = "v1.18.1-eksbuild.3"

  configuration_values = jsonencode({
    env = {
      ENABLE_PREFIX_DELEGATION = "true"
      WARM_PREFIX_TARGET       = "1"
    }
  })
}

IRSA and EKS Pod Identity

ELI5: workload identity for pods
You don't want AWS keys baked into a container image. So Kubernetes gives the pod a signed JSON Web Token (a "projected token"). AWS already trusts your cluster's OIDC issuer (you registered it once). AWS STS reads the token, checks an IAM role's trust policy to see if the token's subject matches, and mints temporary credentials. Same outcome as AKS Workload Identity, slightly more wiring.

There are now two ways to give pods AWS credentials. The older path (IRSA) ships through OIDC and STS. The newer path (EKS Pod Identity, GA late 2023) uses a dedicated agent on the node. They achieve the same end state. For new EKS clusters, EKS Pod Identity is the preferred approach - it's simpler, doesn't need OIDC provider registration, and supports role chaining. IRSA is still the right tool for self-managed Kubernetes or any non-EKS cluster with OIDC.

IRSA: token-exchange flow, step by step Pod SA: gfn-reports ns: gfn Projected JWT sub: system: serviceaccount:gfn:gfn-reports AWS STS AssumeRoleWith- WebIdentity EKS OIDC issuer signs the JWT IAM Role gfn-reports-role trust: ns=gfn, sa=gfn-reports Temp creds ~1 hour, auto-rotated → DynamoDB, S3, ... 1 2 3 (verify) 4 5 1: pod's SA projects token. 2: pod calls STS. 3: STS asks EKS OIDC to verify. 4: STS matches sub against trust policy. 5: STS mints creds.
No long-lived secrets. The IAM role is the only durable artifact. Tokens rotate every hour (or sooner, on demand).

The three pieces of IRSA

To get a pod talking to (say) DynamoDB via IRSA, you need:

  1. An OIDC provider registered in IAM, pointed at the cluster's OIDC issuer URL.
  2. An IAM role with a trust policy that allows sts:AssumeRoleWithWebIdentity for the specific ServiceAccount.
  3. A Kubernetes ServiceAccount annotated with the role ARN.

IRSA in Terraform

irsa.tf · OIDC provider + IAM role + trust policy
# 1. Tell IAM about the cluster's OIDC issuer (once per cluster)
data "tls_certificate" "eks_oidc" {
  url = aws_eks_cluster.eks.identity[0].oidc[0].issuer
}

resource "aws_iam_openid_connect_provider" "eks" {
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = [data.tls_certificate.eks_oidc.certificates[0].sha1_fingerprint]
  url             = aws_eks_cluster.eks.identity[0].oidc[0].issuer
}

# 2. Role with trust policy keyed to ns/sa
resource "aws_iam_role" "gfn_reports" {
  name = "gfn-reports-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = { Federated = aws_iam_openid_connect_provider.eks.arn }
      Action = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringEquals = {
          "${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:sub" =
            "system:serviceaccount:gfn:gfn-reports"
          "${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:aud" =
            "sts.amazonaws.com"
        }
      }
    }]
  })
}
serviceaccount.yaml · the pod side
apiVersion: v1
kind: ServiceAccount
metadata:
  name: gfn-reports
  namespace: gfn
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/gfn-reports-role
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: gfn-reports
  namespace: gfn
spec:
  replicas: 2
  selector: { matchLabels: { app: gfn-reports } }
  template:
    metadata: { labels: { app: gfn-reports } }
    spec:
      serviceAccountName: gfn-reports   # wired via annotation above
      containers:
        - name: app
          image: 123456789012.dkr.ecr.us-east-1.amazonaws.com/gfn-reports:0.1
          env:
            - { name: AWS_REGION, value: us-east-1 }
Bug hunt: pod can't assume its role

A teammate set up IRSA. The pod has the annotated ServiceAccount, the IAM role exists with the right DynamoDB permissions, the OIDC provider is registered. But every PutItem call returns WebIdentityErr: not authorized to perform sts:AssumeRoleWithWebIdentity. They paste the role's trust policy below. What's wrong?

role-trust-policy.json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/ABCD1234"
    },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "oidc.eks.us-east-1.amazonaws.com/id/ABCD1234:sub": "system:serviceaccount:default:my-app"
      }
    }
  }]
}

The team checked: the deployment's serviceAccountName is my-app. The ServiceAccount has the role-arn annotation. The cluster has the OIDC provider registered. Why is STS still saying no?

Click to reveal the bug
Namespace mismatch. The trust policy condition says system:serviceaccount:default:my-app, but the deployment actually lives in the kube-system namespace (you can see this if you run kubectl get pod -n kube-system -l app=my-app). The full ServiceAccount subject is system:serviceaccount:<NAMESPACE>:<SA-NAME> - both must match exactly, and the namespace is the most commonly overlooked half.

Fix the condition to "system:serviceaccount:kube-system:my-app" (or move the deployment to default, but namespace-by-purpose is the better discipline).

Two further traps in the same family: (1) trailing whitespace in the annotation value (YAML is forgiving, STS is not), (2) wrong audience - the condition must include aud = sts.amazonaws.com as well, otherwise STS sometimes refuses depending on SDK version. Make both conditions explicit in the trust policy.

EKS Pod Identity (the newer, simpler path)

In late 2023 AWS shipped EKS Pod Identity, which is functionally equivalent to IRSA but trades the OIDC dance for a per-cluster agent. You install the eks-pod-identity-agent add-on, then bind a ServiceAccount to a role via an aws_eks_pod_identity_association resource. No OIDC provider registration, no thumbprint, no trust-policy mini-DSL. Trust policy is just "Service": "pods.eks.amazonaws.com".

IRSA (older)
# Register OIDC, write trust policy
# with namespace+SA condition,
# annotate the ServiceAccount,
# deploy.

# Works on any k8s with OIDC.
# Cross-cluster reusable.
# Slightly more wiring.
EKS Pod Identity (2023+)
resource "aws_eks_pod_identity_association" "gfn" {
  cluster_name    = aws_eks_cluster.eks.name
  namespace       = "gfn"
  service_account = "gfn-reports"
  role_arn        = aws_iam_role.gfn_reports.arn
}
# Trust policy is just:
#   Principal: { Service: "pods.eks.amazonaws.com" }
#   Action: sts:AssumeRole + sts:TagSession
Migration recommendation: use EKS Pod Identity for any new EKS cluster. Keep IRSA for self-managed Kubernetes, GitLab Runners, or anything outside EKS that has an OIDC issuer. The two coexist on the same cluster - you can move workloads one ServiceAccount at a time.
Fun fact EKS Pod Identity routes credential requests through a node-local agent at 169.254.170.23. That's a different link-local IP than ECS task IAM (169.254.170.2) and different again from EC2 IMDS (169.254.169.254). All three are link-local 169.254/16 endpoints, and pods sometimes get all three answers if the network policy allows. If you ever see surprising credentials, check the SDK's credential-provider chain - it tries them in a specific order.

AWS Load Balancer Controller

ELI5: AWS Load Balancer Controller
In Kubernetes, you write an Ingress or Service type: LoadBalancer resource. Something in the cluster has to turn that into an actual cloud load balancer. AKS has the AGIC for App Gateway. EKS has the AWS Load Balancer Controller - a pod that watches your Ingress objects and creates ALBs in AWS to match. The mapping is driven entirely by annotations on the Ingress/Service.

The AWS LB Controller is an add-on pod (not bundled with EKS by default) that turns Kubernetes Ingress and Service resources into ALBs (Application Load Balancers) or NLBs (Network Load Balancers). Annotations on the Ingress/Service drive almost every decision the controller makes - SSL cert, target type (instance vs IP), scheme (internal vs internet-facing), health-check paths.

Service vs Ingress, ALB vs NLB

k8s objectAWS resourceUse for
Service type: LoadBalancer with service.beta.kubernetes.io/aws-load-balancer-type: nlbNLB (Layer 4, TCP/UDP)gRPC, raw TCP, low-latency, static-IP needs
Ingress with kubernetes.io/ingress.class: albALB (Layer 7, HTTP/HTTPS)HTTP routing, WAF integration, SSL termination, host/path rules
Service type: ClusterIPNone (cluster-internal only)Internal pod-to-pod traffic

An ALB Ingress with TLS

ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: gfn-reports
  namespace: gfn
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip   # required for IRSA/Pod Identity pods
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]'
    alb.ingress.kubernetes.io/ssl-redirect: '443'
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:123:certificate/abc-...
    alb.ingress.kubernetes.io/healthcheck-path: /healthz
spec:
  rules:
    - host: reports.compass.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: gfn-reports
                port: { number: 8080 }
The annotation to remember: alb.ingress.kubernetes.io/certificate-arn for HTTPS. This points at an ACM certificate ARN - ACM is AWS' built-in cert manager (chapter 8). The controller wires the ALB listener to that cert. No cert-manager required for the public-facing edge.
target-type: ip matters. The default (instance) puts the ALB target group on node IPs, then NodePort routes to the pod - extra hop, doesn't work well with Fargate or IP-based identity. ip targets the pod IP directly (works with VPC CNI's real-IP model). Standard practice: always set target-type: ip.

Installing the controller

The controller itself is a Helm chart. It needs an IAM role (IRSA or Pod Identity) with permissions to create/delete ALBs, target groups, listeners, and to read EC2 subnets/security-groups. AWS publishes the policy JSON; you attach it to a role that the controller's ServiceAccount can assume.

terminal · Helm install
# Add the EKS chart repo
helm repo add eks https://aws.github.io/eks-charts
helm repo update

# Install (assumes you've already created the SA + IAM role)
helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
  -n kube-system \
  --set clusterName=compass-eks \
  --set serviceAccount.create=false \
  --set serviceAccount.name=aws-load-balancer-controller

EBS CSI, EFS CSI, and Karpenter

Two final EKS-specific bits an Azure dev will hit on day one: persistent volumes and dynamic node provisioning.

EBS CSI driver - block storage

The EBS CSI driver lets PersistentVolumeClaims (PVCs) provision Elastic Block Store volumes. It's the same shape as Azure CSI for Managed Disks. EKS ships it as a managed add-on - one Terraform line installs it.

ebs-csi.tf
resource "aws_eks_addon" "ebs_csi" {
  cluster_name             = aws_eks_cluster.eks.name
  addon_name               = "aws-ebs-csi-driver"
  service_account_role_arn = aws_iam_role.ebs_csi.arn   # IRSA
}
storage-class.yaml · gp3 default
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: gp3
  annotations:
    storageclass.kubernetes.io/is-default-class: "true"
provisioner: ebs.csi.aws.com
parameters:
  type: gp3
  encrypted: "true"
  iops: "3000"
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Delete
WaitForFirstConsumer is important. EBS volumes are zonal - tied to a single AZ. If you bind the volume before scheduling the pod, you might end up with a PVC in us-east-1a and the pod scheduled in us-east-1b. WaitForFirstConsumer lets the scheduler pick the AZ, then provisions the volume in the right one. Azure CSI ships with this enabled by default; on EKS you write it explicitly.

EFS CSI - shared filesystems

EFS is AWS' managed NFS - the equivalent of Azure Files. Useful when you need a single volume mounted RWX (ReadWriteMany) across many pods. The EFS CSI driver is also a managed add-on. The PVC sizing field is ignored (EFS is elastic and pay-per-GB-stored, no preallocation).

Karpenter - dynamic node provisioning

ELI5: Karpenter vs Cluster Autoscaler
Cluster Autoscaler is a polite waiter: it watches the cluster, and when there's a pending pod it asks an Auto Scaling Group to add one more node from a pre-chosen instance type. Karpenter is an aggressive sommelier: it watches pending pods, looks at their actual CPU/RAM/zone requirements, and picks the cheapest EC2 instance type (often from a mix of On-Demand and Spot) that would fit them. It launches the node directly via EC2 APIs, skipping the ASG layer. When pods drain off, Karpenter consolidates - moving remaining pods onto fewer/smaller nodes and terminating the excess.

Karpenter was open-sourced by AWS in 2021 and has largely replaced Cluster Autoscaler as the recommended scaler for EKS. The key win is consolidation: not just scaling up, but proactively scaling down by repacking. Cluster Autoscaler can only scale down a node group when its instance type matches; Karpenter rebalances across instance families.

Cluster Autoscaler / AKS
# Per node pool: min/max + a chosen instance type
# Scale-up: pod pending → CA asks ASG for +1 node
#           (of the pool's instance type)
# Scale-down: node empty for 10m → CA drains it

# Pros: simple, predictable
# Cons: can't switch instance type;
#       can't consolidate across pools
Karpenter (EKS)
# Per "NodePool" CRD: instance-type filters, zones,
#                     spot/on-demand mix, taints
# Scale-up: pod pending → Karpenter picks the
#           cheapest fit, calls RunInstances directly
# Scale-down: continuous consolidation - repacks
#             pods onto fewer/smaller nodes, terminates excess

# Pros: cheaper, faster, mixes instance types
# Cons: more moving parts, eats node IAM roles for breakfast
karpenter-nodepool.yaml · a simple NodePool
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: default
spec:
  template:
    spec:
      requirements:
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64"]
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["m", "c"]            # general-purpose + compute-optimized
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]   # mix - Karpenter prefers Spot when safe
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 30s
Real-world incident "AdministratorAccess on the node role" - the EKS cryptominer week $50K crypto-mining bill

A small startup built their first EKS cluster following a hodgepodge of stack-overflow advice. To make "things just work", they attached AdministratorAccess to the node IAM role - thinking, "pods will need to do all sorts of things, just give it everything". The cluster ran public ALBs with an open ingress for testing. IMDSv1 was still enabled on the nodes; no hop-limit was set.

A cryptominer crawler indexed by Shodan found an exposed Kubernetes Dashboard pod (left default-public from a tutorial). The attacker deployed a privileged pod. The pod hit http://169.254.169.254/latest/meta-data/iam/security-credentials/ - IMDSv1 happily returned the node role's credentials. The node role had AdministratorAccess. The attacker spun up p3.8xlarge GPU instances in five regions, mined Monero for seven days, and disappeared.

Total damage at end of week: ~$50,000 in EC2 GPU charges, plus several hundred dollars of NAT-gateway egress. The startup got partial relief from AWS Support after submitting an incident report.

Lessons: (1) Never give the node role AdministratorAccess - use IRSA / EKS Pod Identity for pod permissions instead. The node role should only have the three managed policies: AmazonEKSWorkerNodePolicy, AmazonEKS_CNI_Policy, AmazonEC2ContainerRegistryReadOnly. (2) Enforce IMDSv2 with HttpPutResponseHopLimit=1 at the launch template - that breaks the IMDS-from-pod path entirely, because the extra container-network hop pushes the request past the limit. (3) Don't expose the Kubernetes Dashboard publicly. Ever. (4) Set up budget alerts and AWS GuardDuty - the cluster's GPU spike would have lit up day one.

Try it: read-only cluster inspection

Lab: poke at an existing EKS cluster (read-only) $0

Goal: list EKS clusters in your account, describe one, configure kubectl against it, and run a few read-only inspections. Important: creating an EKS cluster is not free - $73/month minimum, plus node costs. So this lab assumes you already have access to a cluster (work, lab, or shared dev account). If you don't, stop at step 2 and read the describe-cluster output to get a feel for the shape.

Step 1. List clusters in your current region.

terminal
aws eks list-clusters --region us-east-1
# {
#   "clusters": [ "compass-dev", "platform-prod" ]
# }

Step 2. Describe a cluster - look at the OIDC issuer, control-plane version, and endpoint flags.

terminal
aws eks describe-cluster --name compass-dev --region us-east-1
# Look for:
#   .cluster.version          -- the k8s minor version
#   .cluster.identity.oidc.issuer -- the IRSA OIDC issuer URL
#   .cluster.endpoint         -- the API server URL (private/public)
#   .cluster.resourcesVpcConfig.endpointPublicAccess
#   .cluster.logging.clusterLogging -- which log types are enabled

Step 3. Wire kubectl to talk to the cluster. This writes a ~/.kube/config entry that signs requests with your AWS credentials (via the aws eks get-token exec plugin).

terminal
aws eks update-kubeconfig \
  --name compass-dev \
  --region us-east-1 \
  --profile compass

# Verify the context
kubectl config current-context
# arn:aws:eks:us-east-1:123456789012:cluster/compass-dev

Step 4. Inspect nodes, pods, and CNI add-on.

terminal
kubectl get nodes -o wide
# Note the INTERNAL-IP column - those are real VPC IPs

kubectl get pods -A -o wide | head -20
# Pod IPs come from the same VPC range as the nodes (VPC CNI)

kubectl get daemonset aws-node -n kube-system
# aws-node is the VPC CNI's per-node agent

kubectl describe configmap amazon-vpc-cni -n kube-system | grep -i prefix
# Tells you whether prefix delegation is on for this cluster

Step 5. If the cluster uses IRSA, list ServiceAccounts that have role annotations.

terminal
kubectl get sa -A -o json | jq -r '.items[]
  | select(.metadata.annotations["eks.amazonaws.com/role-arn"])
  | "\(.metadata.namespace)/\(.metadata.name) -> \(.metadata.annotations["eks.amazonaws.com/role-arn"])"'

What you learned: EKS access is dual-rail. IAM gets you to the cluster API (the kubectl command itself is authenticated by AWS); Kubernetes RBAC then gates what you can do once in. The OIDC issuer is the linkage point between AWS IAM and the pod's identity - which we'll use in the Compass section.

Quick check

Test yourself - 5 questions

EKS rewards understanding the IAM-shaped seams. Sit with each before revealing.

1. Your team has 14 EKS clusters across dev, staging, and prod. What's the minimum monthly bill before any nodes or storage are added?

  • $0 - the EKS control plane is free, like AKS.
  • About $146 (one shared control plane bill).
  • About $1,022 (14 × $73/month per cluster).
  • About $360 (you pay only for active clusters).
Show answer
Answer: c. EKS charges $0.10/hr per cluster, always, regardless of activity. 14 clusters × $0.10 × 730 hr/month = $1,022/month, before a single pod runs. This is the most common surprise on AKS-to-EKS migration. The standard mitigation is to consolidate environments (one cluster per env across multiple namespaces) rather than per-team or per-app.

2. You run an EKS cluster on t3.medium nodes (3 ENIs, 6 IPs each). The VPC CNI is default (no prefix delegation). Roughly how many pods can fit on each node?

  • ~6 pods - one per IP on one ENI.
  • ~17 pods - (3 × 6) - 1 for the node primary IP.
  • ~110 pods - the Kubernetes default.
  • ~250 pods - the AKS default with kubenet.
Show answer
Answer: b. With VPC CNI default settings, pods-per-node = (max ENIs × max IPs-per-ENI) - 1 = 17 on a t3.medium. This is the famous gotcha that catches Azure devs - on AKS with kubenet you'd see ~110. The fix is prefix delegation (ENI gets /28 prefixes instead of individual IPs), which lifts t3.medium to about 110 pods. Enable it via the ENABLE_PREFIX_DELEGATION env var on the aws-node DaemonSet, or via the VPC CNI add-on configuration.

3. You're setting up workload identity on a new EKS cluster for a pod that needs DynamoDB access. Which approach should you reach for first?

  • Bake an IAM access key into the container image (rotate annually).
  • IRSA - register the OIDC provider, write a trust policy keyed to the ServiceAccount.
  • EKS Pod Identity - install the add-on, create a pod identity association.
  • Attach DynamoDBFullAccess to the node IAM role.
Show answer
Answer: c. For new EKS clusters, EKS Pod Identity (GA late 2023) is the preferred approach. It needs no OIDC provider registration, no thumbprint, no namespace-mini-DSL in the trust policy. IRSA still works and is still the right answer for non-EKS clusters or self-managed Kubernetes with OIDC. Option (a) is a security incident waiting to happen, and (d) is the literal setup from the $50K cryptominer horror story earlier in this chapter.

4. You want HTTPS on an ALB created by the AWS Load Balancer Controller. Which annotation is the one that wires the cert?

  • kubernetes.io/tls-secret pointing at a k8s Secret.
  • alb.ingress.kubernetes.io/certificate-arn pointing at an ACM cert ARN.
  • cert-manager.io/cluster-issuer on the Ingress.
  • alb.ingress.kubernetes.io/ssl-policy set to true.
Show answer
Answer: b. The controller integrates with ACM (AWS Certificate Manager). You provision the cert in ACM, then point the Ingress at it via the certificate-arn annotation. The controller wires the ALB listener to that cert. ssl-policy exists but only picks the TLS policy (cipher suites). cert-manager is the right answer in NGINX-Ingress-style setups but is not the AWS LB Controller path.

5. Compared to Cluster Autoscaler, the unique thing Karpenter does is...

  • Scale node counts up and down based on pending pods.
  • Continuously consolidate nodes - repacking pods onto fewer or smaller nodes and terminating excess.
  • Schedule pods onto nodes.
  • Run on managed control planes.
Show answer
Answer: b. Both Karpenter and Cluster Autoscaler can scale up on pending pods (a). Pod scheduling (c) is the kube-scheduler's job. Karpenter's distinguishing trait is consolidation: it watches the running set continuously and rebalances workloads across instance types and sizes to minimize cost - something CA can't do because CA is bounded to a single node-group instance type. Pair Karpenter's consolidation with Spot mixing and you typically cut node costs 30-60% over a static managed node group.

Gotchas for Azure devs

1. EKS control plane costs $73/month per cluster, always. AKS' default tier is free. The temptation to spin up "one cluster per team" is a $1,000+/month tax. Default pattern: one cluster per environment (dev / staging / prod), namespaces per team or service. If you need stronger isolation, look at Virtual Clusters (vcluster) or Capsule tenancy operators - both let you carve a single physical cluster into many logical ones without paying 14 control-plane bills.
2. The VPC CNI caps pod density per node. A t3.medium hosts ~17 pods by default, not the ~110 Kubernetes ships with. If "pods are stuck in Pending" surprises you, run kubectl describe node | grep -i pods - the allocatable count comes from the EC2 instance type's ENI math. Enable prefix delegation to fix it, or right-size nodes from the start.
3. Fargate looks cheap until it's running 24/7. Fargate's "no node management" sounds like a win, but for steady workloads it's ~2x the cost of EC2 with Savings Plans, and even worse vs Spot. Use Fargate selectively (CI jobs, burst pods, untrusted workloads); for everything else, managed node groups or Karpenter beat it on price.
4. IRSA needs an OIDC provider registered in IAM. The OIDC URL is per cluster. If you delete and recreate a cluster (or use ephemeral PR-environment clusters), you also need to register a new OIDC provider each time - and rebuild any role trust policies that reference the old URL. EKS Pod Identity sidesteps this entirely, which is one more reason to prefer it for new clusters.
5. EKS supports only 4 minor versions in standard support at a time. Each new k8s minor lands roughly every 4 months, so you have ~14 months of standard support per version before you must upgrade or move to extended support (which costs 6x the control plane price). AKS' support window is more generous. Plan for a Kubernetes upgrade every quarter, not "we'll get to it next year". Treat the upgrade itself as a continuous responsibility, not a project.
6. aws-auth ConfigMap is the legacy path; access entries are the new one. Older guides will show you how to edit a ConfigMap to map IAM roles to k8s usernames. The 2023+ pattern is EKS access entries, which are API-first and don't require kubectl access to bootstrap. If you're starting fresh, use access entries - they're more auditable and they don't lock you out of the cluster when the ConfigMap drifts.

Project Compass: migrate the gfn-reports worker to EKS

Through chapters 4-9 the gfn-reports worker ran as a Lambda. The product team wants to enable longer-running aggregation jobs (over the 15-minute Lambda ceiling), keep stateful in-memory caches between invocations, and consolidate with three other backend services. EKS is the right tool. This slice provisions the cluster, sets up workload identity, and proves a pod can read DynamoDB via the same gfn-reports-role we built up over the last nine chapters.

Project Compass · Step 10 of 12 EKS cluster + IRSA for gfn-reports

Warning: unlike previous chapters this slice is not $0. Creating the cluster costs ~$73/month for the control plane plus EC2 node costs. Do this only in a sandbox account you actively monitor. The Terraform shown is structural - the official terraform-aws-modules/eks module is what you'd use in production.

This chapter's slice (skeleton)
compass-eks.tf · cluster + 2-node managed group
module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 20.0"

  cluster_name    = "compass-eks"
  cluster_version = "1.30"

  cluster_endpoint_public_access = true

  vpc_id                   = module.vpc.vpc_id
  subnet_ids               = module.vpc.private_subnet_ids
  control_plane_subnet_ids = module.vpc.private_subnet_ids

  enable_irsa = true      # registers the OIDC provider for us

  cluster_addons = {
    coredns      = { most_recent = true }
    kube-proxy   = { most_recent = true }
    vpc-cni      = {
      most_recent = true
      configuration_values = jsonencode({
        env = { ENABLE_PREFIX_DELEGATION = "true" }
      })
    }
    eks-pod-identity-agent = { most_recent = true }
  }

  eks_managed_node_groups = {
    default = {
      desired_size   = 2
      min_size       = 2
      max_size       = 4
      instance_types = ["m6i.large"]
      # Enforce IMDSv2 with hop-limit 1 - blocks pod-from-IMDS attack
      metadata_options = {
        http_tokens                 = "required"
        http_put_response_hop_limit = 1
      }
    }
  }

  tags = { Project = "Compass", ManagedBy = "learn-aws" }
}
compass-irsa.tf · re-target gfn-reports-role's trust policy
# gfn-reports-role already exists (chapter 2 onward).
# Through chapter 9 its trust policy allowed lambda.amazonaws.com.
# Now we add an IRSA trust statement so EKS pods can assume it too.

data "aws_iam_policy_document" "gfn_reports_trust" {
  statement {
    # Keep the Lambda principal during migration (remove later)
    actions    = ["sts:AssumeRole"]
    principals { type = "Service" identifiers = ["lambda.amazonaws.com"] }
  }

  statement {
    # Allow the EKS ServiceAccount via OIDC (IRSA)
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [module.eks.oidc_provider_arn]
    }
    condition {
      test     = "StringEquals"
      variable = "${module.eks.oidc_provider}:sub"
      values   = ["system:serviceaccount:gfn:gfn-reports"]
    }
    condition {
      test     = "StringEquals"
      variable = "${module.eks.oidc_provider}:aud"
      values   = ["sts.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "gfn_reports" {
  name               = "gfn-reports-role"
  assume_role_policy = data.aws_iam_policy_document.gfn_reports_trust.json
}
gfn-reports.yaml · placeholder pod that reads DynamoDB
apiVersion: v1
kind: Namespace
metadata: { name: gfn }
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: gfn-reports
  namespace: gfn
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/gfn-reports-role
---
apiVersion: batch/v1
kind: Job
metadata:
  name: gfn-reports-irsa-check
  namespace: gfn
spec:
  template:
    spec:
      serviceAccountName: gfn-reports
      restartPolicy: Never
      containers:
        - name: probe
          image: amazon/aws-cli:2.15
          env:
            - { name: AWS_REGION, value: us-east-1 }
          command: ["sh", "-c"]
          args:
            - |
              echo "==> who am I?"
              aws sts get-caller-identity
              echo "==> can I list the table?"
              aws dynamodb describe-table --table-name gfn-reports-aggregates

Apply the Terraform, then kubectl apply -f gfn-reports.yaml. After ~30 seconds, kubectl logs -n gfn job/gfn-reports-irsa-check should show the role's session ARN and the DynamoDB table description. If it doesn't, walk the bug-hunt checklist: namespace match, SA name match, role trust condition, OIDC provider URL.

Progress
ch1 · profile ch2 · IAM role ch3 · VPC ch4 · Lambda ch5 · S3 ch6 · DynamoDB ch7 · SQS ch8 · KMS ch9 · alarms ch10 · EKS ch11 · API GW ch12 · Terraform
Paid-cost reminder: the moment you terraform apply this slice, the meter starts: ~$73/month for the control plane + 2 × m6i.large (~$140/month on-demand) + EBS gp3 volumes (~$8) + NAT gateway egress. Budget: ~$225/month while this slice is running. Use a separate sandbox account, set up an AWS Budget alert at $50, and run terraform destroy when you're done exploring. Chapter 12 will move this into the multi-account Terraform setup so cleanup is one apply away.

Recap & next

What stuck?

The mental model in one sentence

AKS is Kubernetes with Azure choices made for you. EKS is Kubernetes with AWS knobs exposed. The compute, networking, identity, and storage all hook into existing AWS primitives (EC2, VPC, IAM, EBS) rather than k8s-specific abstractions. Once you accept that EKS is a thin layer over the rest of AWS, the wiring stops feeling arbitrary.

Common pitfalls so far

TrapFix
"My pod is Pending - insufficient IPs"You hit the VPC CNI per-node IP cap. Enable prefix delegation, or move to larger instance types.
"IRSA assume-role fails with WebIdentityErr"Check the trust policy condition's namespace AND SA name. Both must match the deployed pod exactly.
"Fargate bill is exploding"You're using it for steady workloads. Move long-running pods to managed node groups + Karpenter.
"ALB has no HTTPS"Add alb.ingress.kubernetes.io/certificate-arn pointing at an ACM cert in the same region.
"Cluster won't upgrade - too many minor versions behind"EKS only supports 4 minors in standard support. Plan upgrades quarterly. Use blue-green clusters for risky bumps.
NEXT CHAPTER
11. Serverless patterns
Lambda + API Gateway + DynamoDB + EventBridge end-to-end. The whole-stack synthesis chapter, mapped against Azure Functions + APIM + Cosmos + Event Grid.