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.
| Concept | Azure / AKS | AWS / EKS |
|---|---|---|
| Managed control plane | AKS (free standard tier, paid uptime SLA tier) | EKS - $0.10/hr per cluster (~$73/month, always) |
| Cluster identity / workload identity | AKS managed identity + Azure AD Workload Identity | IRSA (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 volumes | Azure CSI - Managed Disks, Azure Files | EBS CSI driver (block), EFS CSI driver (shared NFS) |
| Ingress / Layer 7 LB | Application Gateway Ingress Controller (AGIC) or NGINX | AWS Load Balancer Controller (ALBs via Ingress, NLBs via Service) |
| Workload identity for k8s | Azure AD Workload Identity | IRSA / EKS Pod Identity |
| Spot capacity | Spot node pools | Spot instances (managed node groups or Karpenter) |
| Auto-scaling nodes | Cluster Autoscaler (and AKS-managed autoscaler) | Karpenter (preferred, 2021+) or Cluster Autoscaler |
| Container registry | ACR | ECR (also IAM-gated, supports immutable tags, scan-on-push) |
| RBAC binding to cloud identity | Azure RBAC integration (assign Entra groups to k8s RBAC) | aws-auth ConfigMap (legacy) or EKS access entries (2023+) |
| Cluster upgrade window | Roughly N-2 minor versions supported | Only 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) |
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 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.
| Component | Who runs it | You pay? |
|---|---|---|
| API server / etcd / scheduler | AWS | $0.10/hr per cluster (~$73/month) |
| Worker nodes (EC2) | You | Yes - regular EC2 pricing + EBS + ENI usage |
| Fargate pods | AWS-managed micro-VMs | Yes - per vCPU-hr + per GB-hr (~2x EC2) |
| NAT Gateway egress | AWS-managed | $0.045/GB out - sneaky at scale |
| Control-plane logging to CloudWatch | AWS-managed | $0.50/GB ingest + $0.03/GB storage |
| EKS managed add-ons (CNI, kube-proxy, CoreDNS) | AWS-managed | Free - just runs as pods on your nodes |
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
}
}
# 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 }
}
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).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.
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.
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.
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.
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.
| Question | Choose |
|---|---|
| "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) |
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?
$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.
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.
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 type | vCPU / RAM | Max ENIs | IPs / ENI | Max pods (default) | With prefix delegation |
|---|---|---|---|---|---|
t3.medium | 2 / 4 GiB | 3 | 6 | 17 | ~110 |
t3.large | 2 / 8 GiB | 3 | 12 | 35 | ~110 |
m6i.large | 2 / 8 GiB | 3 | 10 | 29 | ~110 |
m6i.xlarge | 4 / 16 GiB | 4 | 15 | 58 | ~250 |
m6i.4xlarge | 16 / 64 GiB | 8 | 30 | 234 | ~250 |
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.
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.
/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.
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"
}
})
}
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.
To get a pod talking to (say) DynamoDB via IRSA, you need:
sts:AssumeRoleWithWebIdentity for the specific ServiceAccount.# 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"
}
}
}]
})
}
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 }
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?
{
"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?
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.
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".
# 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.
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
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.
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.
| k8s object | AWS resource | Use for |
|---|---|---|
Service type: LoadBalancer with service.beta.kubernetes.io/aws-load-balancer-type: nlb | NLB (Layer 4, TCP/UDP) | gRPC, raw TCP, low-latency, static-IP needs |
Ingress with kubernetes.io/ingress.class: alb | ALB (Layer 7, HTTP/HTTPS) | HTTP routing, WAF integration, SSL termination, host/path rules |
Service type: ClusterIP | None (cluster-internal only) | Internal pod-to-pod traffic |
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 }
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.
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.
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.
# 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
Two final EKS-specific bits an Azure dev will hit on day one: persistent volumes and dynamic node provisioning.
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.
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
}
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
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 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 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.
# 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
# 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
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
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.
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.
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.
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).
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.
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.
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.
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.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?
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?
DynamoDBFullAccess to the node IAM role.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.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...
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.
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.
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.
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.
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" }
}
# 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
}
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.
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.
certificate-arn drives HTTPS, target-type: ip is the standard.WaitForFirstConsumer); Karpenter beats Cluster Autoscaler on cost via continuous consolidation.| Trap | Fix |
|---|---|
| "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. |