EKS
AWS Intermediate/Advanced
Chapter 7 · Kubernetes on AWS: EKS
AWS Intermediate/Advanced 6 ran containers on ECS and Fargate. Amazon EKS (Elastic Kubernetes Service) runs Kubernetes instead: AWS operates the Kubernetes control plane, and you decide how the worker nodes are provided. This chapter covers only what is AWS-specific. How Kubernetes itself works (pods, deployments, services, probes, disruption budgets) is in Kubernetes Fundamentals and Kubernetes Intermediate/Advanced, and is assumed.
- Run here (level 1):
eksctl0.231.0 (its configuration schema and its failure without credentials), the CDK (aws-cdk2.1145.0,aws-cdk-lib2.273.0, reusing the chapter 5 install) synthesising an L1-only and an L2 EKS stack, cfn-lint 1.57.2, Jest 30.5.2, and four small Python models: pod limits per node, version support, trust-policy conditions, and manifest checks. The first two models were cross-checked against published data. - Planned but not completed: I created a local Kubernetes cluster with
kind0.31.0 (Kubernetes v1.35.0, first one node, then three), to run the manifests against a real API server. Docker Desktop's engine stopped before the experiments could run and would not come back (its log said "no connected VM"; I trieddocker desktop startandrestart), so nothing from that cluster is reported. Where the chapter says what a real API server would do, it is my expectation and is marked as not tested. - From the documentation, not run (level 3): what EKS, Auto Mode, Pod Identity, IRSA, the VPC CNI and the Load Balancer Controller do. Read on 2026-10-13 through a page-fetch tool that summarises pages; each such statement says so.
eksctl and kind. I checked each file's SHA-256 against the checksum published next to it (both matched), which only shows the download was not corrupted, because the checksum comes from the same place as the file.
EKS or ECS?
I did not read a page that compares the two, so this table sticks to what the chapter shows or the pages I read state. The last row is my judgement and is not tested.
| Topic | ECS on Fargate (chapter 6) | EKS |
|---|---|---|
| What you describe | A task definition and a service | Kubernetes objects (Deployment, Service, Ingress, PodDisruptionBudget...), so the tools and manifests you already know |
| Control plane fee | Not read; no claim made | Per cluster per hour: $0.10 in standard version support, $0.60 in extended support (pricing page, 2026-10-13, no Region stated). About $73 or $438 for a 730-hour month |
| Pod addressing | Not covered here | Each pod gets its own VPC IP address with the Amazon VPC CNI, so subnet size and instance type limit pod counts (below) |
| Version lifecycle | Not applicable | A Kubernetes minor version gets 14 months of standard support plus 12 of extended; nodes are not upgraded for you (except in Auto Mode) |
| Identity for pods | Task role (chapter 6) | IRSA or EKS Pod Identity (below) |
| When EKS earns its cost (judgement) | When you already run Kubernetes, need its ecosystem or portability, or have many teams sharing a platform. For one small service, the extra concepts and the fixed fee are the price. Not tested. | |
What EKS Runs, and What You Choose
From the documentation (level 3): AWS manages the Kubernetes API server and the etcd database. You choose how pods get compute. Two choices appear on the pages I read: managed node groups (EC2 instances in your account, created and updated through the EKS API) and EKS Auto Mode. Pods can also run on Fargate, with restrictions: the Pod Identity page says Linux and Windows pods on Fargate are not supported for Pod Identity. I did not read the Fargate profile pages, and hybrid nodes were only seen in the pricing page.
The Auto Mode page says AWS "takes over the configuration, management, security, and scaling of the EC2 instances launched as well as cluster capabilities for load balancing, IP address management, networking policy, and block storage". Specific statements: nodes use Bottlerocket variants with a read-only root file system and no SSH or SSM access; nodes have a maximum lifetime of 21 days (which you can reduce) and are then replaced; scaling is Karpenter-based; upgrades respect your Pod Disruption Budgets; "you do not have to install the EKS Pod Identity Agent on EKS Auto Mode clusters". It limits pods per node to "the lower of" 110 and ENIs × (IPs per ENI − 1). The pricing page says you pay "based on the duration and type of Amazon EC2 instances launched and managed by EKS Auto Mode" and lists per-instance Auto Mode fees only in an example (US West (Oregon)); I did not work out a total.
Managed node groups are not upgraded when the control plane is. The version page says so for managed node groups, self-managed nodes, Fargate pods and hybrid nodes alike: "These instances aren't automatically upgraded when you or Amazon EKS update your control plane." The page adds that Kubernetes tests compatibility between control plane and nodes for up to three minor versions, and recommends the same version for both.
Defining a Cluster
eksctl: one YAML file, checked against its own schema
eksctl takes a ClusterConfig file. Run here: eksctl create cluster -f cluster.yaml --dry-run needs AWS access even for a dry run: it exited with status 1 and "checking AWS STS access – cannot get role ARN for current session". What does work offline is eksctl utils schema, which prints a JSON schema of the configuration. I validated the file against it with the jsonschema package, then changed one thing at a time.
The schema caught 7 of the 10 changes: typos, wrong types and unknown fields, including the YAML number trap: an unquoted version: 1.35 is a number, and the unquoted 1.30 becomes 1.3, a different version. Quote versions. It did not catch a minSize above maxSize, an instance type that does not exist, or a Region that does not exist: those need AWS (or a list of valid values you maintain).
The CDK, two ways
Run here: the same cluster as an L1-only stack (every property is one you write) and with the L2 eks.Cluster construct. Both synthesised offline. The L1 stack:
The L1 template has 22 resources; the L2 stack has 43, plus 24 more in two nested stacks (67 in all). The L2 construct does not use AWS::EKS::Cluster at all: it creates the cluster through a custom resource (Custom::AWSCDK-EKS-Cluster) backed by Lambda functions and a Step Functions state machine, plus a kubectl handler, an OIDC provider custom resource and a NAT gateway. A test in the exercise pins this behaviour. Reasons to prefer one or the other are yours to weigh; the facts are the size, the extra Lambda code that runs in your account during deployment, and that the cluster is not a plain CloudFormation resource. Two more things surfaced:
- The L2 construct requires a
kubectlLayer(a Lambda layer containingkubectl). I passed a placeholder ARN so that the stack would synthesise; it could never deploy as written. - The library lags the service.
aws-cdk-lib2.273.0 lists Kubernetes versions up toV1_36, while the lifecycle page lists 1.37 (released on EKS on 2026-10-01). The construct has a way to name another version, which I did not try. - Deprecation warning:
CfnResource#addDependencyprinted "is deprecated. UseaddResourceDependencyinstead" at synth. I used the deprecated name in the L1 stack and left it, so the chapter shows the warning; synthesis still exited 0.
The managed policies are a useful cross-check. I wrote the L1 role policy names from memory; the library's own roles carry the same ones:
The cluster role has AmazonEKSClusterPolicy; the node role has AmazonEKSWorkerNodePolicy, AmazonEKS_CNI_Policy and AmazonEC2ContainerRegistryReadOnly. That shows my names match the library's, not that they are the right ones for your cluster (I did not read the node role page).
What cfn-lint says
The L1 template lints clean (exit status 0); the L2 templates produce only W3005 warnings (a dependency that is already implied), exit status 4. cfn-lint validates against the AWS resource schemas it bundles, so it knows the allowed values. I changed one property at a time in the L1 template:
cfn-lint caught 10 of the 15 changes and missed 5: a public-access CIDR that is not a CIDR, a node group whose minimum exceeds its maximum, an instance type that does not exist, an add-on name that does not exist, and a service account name that is not a valid Kubernetes name. Types and enumerations are checked; meaning is not. The version pattern it enforces (1\.\d\d) is a format check, not a check that the version is available.
Tests on the synthesised L1 stack check what the chapter argues for: a pinned version string and STANDARD upgrade policy, a public endpoint limited to listed CIDRs (never 0.0.0.0/0), API-only authentication mode, audit and authenticator logs, exactly the three worker policies on the node role, a pod role that trusts only pods.eks.amazonaws.com, one node replaced at a time, the Pod Identity agent add-on, and no NAT gateway. All 10 passed the first time, which proves little on its own, so I ran a control: changing the allowed CIDR to 0.0.0.0/0 made the endpoint test fail (1 failed, 9 skipped), and rebuilding made it pass again.
Pod Networking: the VPC CNI and How Many Pods Fit
From the documentation: "Since each Pod is assigned its own IP address, the number of IP addresses supported by an instance type is a factor in determining the number of Pods that can run on the instance." The default maximum is (number of ENIs × (IPs per ENI − 1)) + 2, where "the +2 accounts for the Amazon VPC CNI and kube-proxy running on every node, which don't consume a Pod IP address". A managed node group without a custom AMI also caps it at 110 for instances with fewer than 30 vCPUs and 250 above, and that cap "takes precedence over any other maxPods configuration". Prefix delegation (VPC CNI 1.9.0 or later) assigns more addresses; I did not read that page.
Run here: a model of the formula with data from two published files in the VPC CNI repository (the limits file the CNI itself uses, and a published list of max pods per instance type), both fetched on 2026-10-13:
The formula and the published list agree for all 1,392 instance types. They come from the same repository, so that shows internal consistency, not that they match what AWS runs today. The practical reading: a t3.medium holds 17 pods, an m5.large holds 29, and many small pods per node need a bigger instance or prefix delegation. The 952 types whose formula exceeds 110 are subject to the managed node group cap (I do not have their vCPU counts here).
The other side is the subnet. Every pod takes an address from a subnet in your VPC. AWS Fundamentals 5 states that AWS reserves 5 addresses in every subnet, so a /24 offers 251, and these must also cover the nodes and the load balancer. I did not read how many addresses the CNI keeps in reserve per node, so the number of pods a subnet really holds is lower than a naive count suggests, and I made no estimate.
Identity: IRSA and EKS Pod Identity
Both give pods AWS permissions by tying an IAM role to a Kubernetes service account instead of using the node's instance role. From the documentation:
| IRSA (IAM roles for service accounts) | EKS Pod Identity | |
|---|---|---|
| Mechanism | An OIDC provider for the cluster; the pod's projected token is exchanged with AssumeRoleWithWebIdentity | An association (role to service account to namespace, per cluster) made in EKS; the Pod Identity Agent on each node issues credentials |
| Trust policy | A principal per cluster (the OIDC provider) | One principal for all clusters: "Service": "pods.eks.amazonaws.com" |
| Setup | Create the OIDC provider once per cluster; annotate the service account | Install the agent add-on (not needed on Auto Mode) and create the association |
| Limits and exclusions | Not for local clusters on Outposts | Up to 5,000 associations per cluster; role must be in the cluster's account; Linux EC2 nodes only (not Fargate, not Windows) |
| Consistency | Immediate (not stated) | Associations are eventually consistent, "potential delays of several seconds" |
Both pages also say that without restricting access to the instance metadata service, a pod can reach the node's role, and that "containers are not a security boundary". The Pod Identity page says its association environment variables configure the AWS SDKs, so the workload must use a supported SDK version and the default credential chain.
Run here: the IRSA trust policy is where mistakes hide. The condition on the token's sub claim is what limits which service account may use the role. I wrote a small evaluator on top of the chapter 2 condition model and tried the cases. (The JSON shape is the commonly documented web-identity format; the EKS page I read did not print it.)
The strict policy (StringEquals on the full system:serviceaccount:apps:reports and on the audience) allowed only that service account and refused a wrong namespace, a wrong name, a wrong audience and a token from another cluster's provider. A StringLike with * in the namespace let any namespace's reports account in, including kube-system; and a policy with no sub condition let every service account in the cluster assume the role. With Pod Identity the trust policy has no such condition, because the association carries the restriction, so who may create associations matters; I did not read the permissions required to create one (iam:PassRole is the thing to check).
The CDK's L2 addServiceAccount hides this. The trust policy it generated has a StringEquals whose value is Fn::GetAtt of a custom resource (Custom::AWSCDKCfnJson): the actual sub and aud condition is built when the stack deploys, so it cannot be read or tested in the synthesised template (a test in the exercise pins that). The L1 route with a Pod Identity association is plain and inspectable.
Manifests, and What Validates Them Without a Cluster
Run here: a set of manifests for the service from chapter 6 (service account with the IRSA annotation, a three-replica Deployment with probes, a read-only root file system and a zone spread, a PodDisruptionBudget, a Service and an Ingress for the AWS Load Balancer Controller):
The Load Balancer Controller, from the documentation: it "watches for Kubernetes Ingress or Service resources" and creates load balancers: an Application Load Balancer for an Ingress, a Network Load Balancer for a Service of type LoadBalancer, and (from version 2.14.0) an ALB for a Gateway. If you do not install it, Kubernetes uses a legacy cloud provider that creates Classic Load Balancers. The annotations page gives these defaults and values: scheme defaults to internal (values internal or internet-facing), target-type defaults to instance (values instance or ip), and listen-ports defaults to HTTP 80, or HTTPS 443 if a certificate ARN is set. Not read: the subnet tags the controller needs, its IAM policy, installation, and the Gateway configuration.
I validated the manifests offline in two layers: JSON schemas for Kubernetes v1.35.0 from a third-party project that publishes them (yannh/kubernetes-json-schema, strict variant), and checks I wrote from the annotation values above plus one consistency rule (the ALB health check path must be a path the readiness probe serves). Then I changed one thing at a time:
The two layers together caught 7 of the 12 changes and missed 5: a selector that matches no pod label, a PodDisruptionBudget asking for more pods than exist, a PDB with both minAvailable and maxUnavailable, a memory limit below the request, and a misspelt annotation key (which the controller would simply ignore, leaving the default internal scheme, so the ALB would silently not be public). My expectation is that a real API server rejects the selector mismatch, the double PDB field and the limit below the request; I did not test it because the cluster was not available. The PDB with minAvailable: 5 and three replicas is accepted by the schema and, in my understanding, would block every voluntary eviction, which is exactly what stops a node drain. A real API server (kubectl apply --dry-run=server) is the check to add, and it needs a cluster.
Versions, Upgrades and the Bill
From the documentation: a minor version is under standard support for 14 months after its EKS release, then extended support for 12 more (26 in all); extended support is enabled by default (upgrade policy EXTENDED); a cluster still on a version at the end of extended support is automatically upgraded (the control plane only). In-place upgrades can be rolled back within 7 days. I encoded the rule and tested it on every row of the page's release calendar:
The rule reproduced all 7 printed pairs of dates. On 2026-10-13, 1.34 had 50 days of standard support left and 1.33 was already in extended support: the cluster.yaml I first drafted for this chapter used 1.33 and would have started on the $0.60 rate. That is the kind of surprise to check before you create a cluster, and the reason the L1 stack sets the upgrade policy to STANDARD (the cluster is then upgraded at the end of standard support, which has its own risk: test upgrades before they are forced).
Upgrades, from the documentation: control plane and nodes are separate. After a control plane upgrade you must update managed node groups, self-managed nodes, add-ons and Fargate pods yourself; Auto Mode nodes follow automatically and respect Pod Disruption Budgets, with intervention needed if a blocking PDB prevents replacement within the 21-day lifetime. I did not read the node group update procedure or what maxUnavailable does when a PDB blocks eviction.
Cost and Clean-Up
Nothing in this chapter created anything in AWS. On this machine, outside the site: the scratch folder with its downloaded tools (about 196 MB of binaries and a 211 MB Python environment), a junction to chapter 5's node_modules, and whatever kind left in Docker (its containers and the local images from chapter 6 vanished from my view when the engine stopped, so I cannot say what remains). If you create a cluster for real (level 2, not run): the control plane fee runs from creation (about $73 for a 730-hour month in standard support, $438 in extended), plus nodes, volumes, NAT gateways and load balancers, which I did not price here. Delete the node groups and then the cluster, and set a budget alert first (AWS Fundamentals 9).
eksctl schema validation with 10 changes and its dry run without credentials; the CDK L1 and L2 stacks synthesised, linted (15 changes) and tested (10 tests plus a control); the max-pods model against two published files (1,392 instance types); the version rule against 7 calendar rows; the IRSA trust evaluator on 9 cases; the manifests against Kubernetes v1.35.0 schemas and annotation rules (12 changes); checksums of the two downloaded tools.
From the documentation, not run: EKS Auto Mode, Pod Identity, IRSA, the max-pods rules, the Load Balancer Controller, the version lifecycle and prices (Region not stated for the control plane fee).
Not read or not run: every command that needs an account; a Kubernetes API server (kind was created but unusable for the experiments); node group update behaviour; Fargate profiles; prefix delegation; the controller's IAM policy and subnet tags; access entries; add-on versions; networking policy; cluster logging costs; EC2 node prices; Karpenter. Not checked at all: a real EKS cluster.
Practice
Write an eksctl ClusterConfig, print eksctl's own JSON schema and validate the file against it, then make ten one-change mutations (including a number-typed version and an unquoted 1.30) and report which the schema catches. Run eksctl create cluster --dry-run without credentials and report what happens. Compare the version you chose with the support calendar using a small model of the 14 + 12 month rule, tested on every row of the calendar.
Define the cluster in the CDK twice: with L1 constructs only and with the L2 Cluster. Synthesise both, count resources and types, find what the L2 version creates that you did not write, and lint every template. Mutate the L1 template fifteen times and report which cfn-lint catches. Write Jest tests for the L1 stack (include a control that proves one test can fail) and tests that pin the L2 surprises.
Model the maximum pods per node from the VPC CNI's published limits and cross-check it against the published list. Write an IRSA trust policy and evaluate nine cases, including a wildcard namespace and a missing sub condition. Write the manifests for a service, validate them against Kubernetes schemas plus the Load Balancer Controller's documented annotation values, make twelve one-change mutations and report what each layer misses, and say what only a real API server could check.
Chapter 7 Quick Reference
- EKS runs the Kubernetes API server and etcd; you choose compute: managed node groups, Auto Mode (Bottlerocket nodes, 21-day maximum lifetime, Karpenter scaling), or Fargate pods with restrictions. Control plane fee $0.10 per cluster-hour in standard support, $0.60 in extended (no Region stated)
- Versions: 14 months standard + 12 extended; extended support is on by default; node groups, add-ons and Fargate pods are not upgraded with the control plane (Auto Mode nodes are). Check a version's support status before creating a cluster
- Quote Kubernetes versions in YAML: unquoted
1.35is a number and1.30becomes1.3 - Each pod gets a VPC IP address: max pods = ENIs × (IPs per ENI − 1) + 2, capped at 110 or 250 on managed node groups without a custom AMI. A
t3.mediumholds 17 pods, anm5.large29 - IRSA: OIDC provider per cluster, trust policy with
subandaudconditions (useStringEqualson the exact service account; a*namespace or nosubopens the role wider). Pod Identity: one trust principal (pods.eks.amazonaws.com), an association per service account, the agent add-on (not on Auto Mode), Linux EC2 nodes only - The Load Balancer Controller makes an ALB for an Ingress and an NLB for a
LoadBalancerService; theschemedefault isinternal, so an Ingress meant to be public needsinternet-facing, and a misspelt annotation is silently ignored - The L2 CDK
Clusterdeployed as a custom resource with Lambda and Step Functions (67 resources against 22 for L1) and needs a kubectl layer; its IRSA condition is not visible in the template - Offline checks catch types, enumerations and typos (cfn-lint caught 10 of 15 changes, the eksctl schema 7 of 10, schemas plus annotation rules 7 of 12); they do not catch nonexistent instance types or Regions, min above max, or relationships between objects. Add a real API server and the AWS service to your checks
docker info answers) before chapter 8. Start with AWS Intermediate/Advanced 8.