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.

How every example in this chapter was verified
There is no AWS account and no AWS credentials on the machine that wrote this course, and an EKS cluster costs money while it exists, so this chapter is mostly level 3 and no cluster was created.
  • Run here (level 1): eksctl 0.231.0 (its configuration schema and its failure without credentials), the CDK (aws-cdk 2.1145.0, aws-cdk-lib 2.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 kind 0.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 tried docker desktop start and restart), 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.
Two tools were downloaded as binaries into the scratch folder, not installed: 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.

TopicECS on Fargate (chapter 6)EKS
What you describeA task definition and a serviceKubernetes objects (Deployment, Service, Ingress, PodDisruptionBudget...), so the tools and manifests you already know
Control plane feeNot read; no claim madePer 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 addressingNot covered hereEach pod gets its own VPC IP address with the Amazon VPC CNI, so subnet size and instance type limit pod counts (below)
Version lifecycleNot applicableA 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 podsTask 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.

apiVersion: eksctl.io/v1alpha5 kind: ClusterConfig metadata: name: demo region: eu-west-1 version: "1.35" availabilityZones: ["eu-west-1a", "eu-west-1b"] iam: withOIDC: true managedNodeGroups: - name: ng-1 instanceType: t3.medium desiredCapacity: 2 minSize: 1 maxSize: 3 privateNetworking: true
python validate_eksctl.py
yaml.safe_load('1.30') -> 1.3 | yaml.safe_load('"1.30"') -> '1.30' change schema validation the file as written valid version written as a YAML number, 1.35 1 error(s): 1.35 is not of type 'string' version 1.30 written unquoted (YAML reads it as 1.3) 1 error(s): 1.3 is not of type 'string' misspelt key managedNodeGroup 1 error(s): Additional properties are not allowed ('managedNodeGroup' was unexpected) desiredCapacity 'two' 1 error(s): 'two' is not of type 'integer' an unknown field on the node group 1 error(s): Additional properties are not allowed ('colour' was unexpected) minSize 5 above maxSize 3 valid instanceType 't3.huge' valid region 'eu-west-9' valid withOIDC 'yes' 1 error(s): 'yes' is not of type 'boolean' kind 'ClusterConfigs' 1 error(s): 'ClusterConfigs' is not one of ['ClusterConfig']

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:

import * as cdk from "aws-cdk-lib"; import { Construct } from "constructs"; import * as ec2 from "aws-cdk-lib/aws-ec2"; import * as eks from "aws-cdk-lib/aws-eks"; import * as iam from "aws-cdk-lib/aws-iam"; // Only L1 constructs for the EKS parts: every property is one you wrote, so the template is short and nothing is created behind your back. export class EksL1Stack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const vpc = new ec2.Vpc(this, "Vpc", { maxAzs: 2, natGateways: 0, subnetConfiguration: [{ name: "public", subnetType: ec2.SubnetType.PUBLIC }] }); const clusterRole = new iam.Role(this, "ClusterRole", { assumedBy: new iam.ServicePrincipal("eks.amazonaws.com"), managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonEKSClusterPolicy")], }); const nodeRole = new iam.Role(this, "NodeRole", { assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"), managedPolicies: [ iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonEKSWorkerNodePolicy"), iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonEC2ContainerRegistryReadOnly"), iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonEKS_CNI_Policy"), ], }); const cluster = new eks.CfnCluster(this, "Cluster", { name: "demo", version: "1.35", roleArn: clusterRole.roleArn, resourcesVpcConfig: { subnetIds: vpc.publicSubnets.map((s) => s.subnetId), endpointPublicAccess: true, endpointPrivateAccess: true, publicAccessCidrs: ["203.0.113.0/24"], }, accessConfig: { authenticationMode: "API" }, upgradePolicy: { supportType: "STANDARD" }, logging: { clusterLogging: { enabledTypes: [{ type: "api" }, { type: "audit" }, { type: "authenticator" }] } }, }); const nodes = new eks.CfnNodegroup(this, "Nodes", { clusterName: cluster.ref, nodeRole: nodeRole.roleArn, subnets: vpc.publicSubnets.map((s) => s.subnetId), instanceTypes: ["t3.medium"], amiType: "AL2023_x86_64_STANDARD", scalingConfig: { minSize: 1, maxSize: 3, desiredSize: 2 }, updateConfig: { maxUnavailable: 1 }, }); for (const addon of ["vpc-cni", "kube-proxy", "coredns", "eks-pod-identity-agent"]) { const a = new eks.CfnAddon(this, "Addon-" + addon, { clusterName: cluster.ref, addonName: addon }); a.addDependency(nodes); } const appRole = new iam.Role(this, "AppRole", { assumedBy: new iam.ServicePrincipal("pods.eks.amazonaws.com") }); appRole.assumeRolePolicy!.addStatements(new iam.PolicyStatement({ principals: [new iam.ServicePrincipal("pods.eks.amazonaws.com")], actions: ["sts:TagSession"] })); new eks.CfnPodIdentityAssociation(this, "Assoc", { clusterName: cluster.ref, namespace: "apps", serviceAccount: "reports", roleArn: appRole.roleArn }); } }
python analyze.py
== EksL1 (L1 only): 22 resources, 13 types 1 AWS::CDK::Metadata 1 AWS::EC2::InternetGateway 2 AWS::EC2::Route 2 AWS::EC2::RouteTable 2 AWS::EC2::Subnet 2 AWS::EC2::SubnetRouteTableAssociation 1 AWS::EC2::VPC 1 AWS::EC2::VPCGatewayAttachment 4 AWS::EKS::Addon 1 AWS::EKS::Cluster 1 AWS::EKS::Nodegroup 1 AWS::EKS::PodIdentityAssociation 3 AWS::IAM::Role == EksL2 (L2 Cluster): 43 resources, 21 types top-level types: CDK::Metadata x1, CloudFormation::Stack x2, EC2::EIP x1, EC2::InternetGateway x1, EC2::NatGateway x1, EC2::Route x4, EC2::RouteTable x4, EC2::SecurityGroup x1, EC2::Subnet x4, EC2::SubnetRouteTableAssociation x4, EC2::VPC x1, EC2::VPCGatewayAttachment x1, EKS::Nodegroup x1, IAM::Policy x2, IAM::Role x7, Lambda::Function x2, SSM::Parameter x1, Custom::AWSCDK-EKS-Cluster x1, Custom::AWSCDK-EKS-KubernetesResource x2, Custom::AWSCDKCfnJson x1, Custom::AWSCDKOpenIdConnectProvider x1 == nested template EksL2awscdkawseksClusterResourceProviderC158A3E5.nested.temp: 18 resources: CDK::Metadata x1, IAM::Policy x4, IAM::Role x6, Lambda::Function x5, Lambda::LayerVersion x1, StepFunctions::StateMachine x1 == nested template EksL2awscdkawseksKubectlProvider86078FD4.nested.template.jso: 6 resources: CDK::Metadata x1, IAM::Policy x1, IAM::Role x1, Lambda::Function x2, Lambda::LayerVersion x1 L2 stack: custom resources: ['AWS::CloudFormation::Stack', 'Custom::AWSCDK-EKS-Cluster', 'Custom::AWSCDK-EKS-KubernetesResource', 'Custom::AWSCDKCfnJson', 'Custom::AWSCDKOpenIdConnectProvider'] L2 stack: NAT gateways: 1 | Lambda functions in nested stacks: 7 managed policies, L1 (my names): ['AmazonEC2ContainerRegistryReadOnly', 'AmazonEKSClusterPolicy', 'AmazonEKSWorkerNodePolicy', 'AmazonEKS_CNI_Policy'] managed policies, L2 (library's): ['AmazonEC2ContainerRegistryPullOnly', 'AmazonEC2ContainerRegistryReadOnly', 'AmazonEKSClusterPolicy', 'AmazonEKSWorkerNodePolicy', 'AmazonEKS_CNI_Policy', 'AmazonElasticContainerRegistryPublicReadOnly', 'service-role/AWSLambdaBasicExecutionRole', 'service-role/AWSLambdaVPCAccessExecutionRole']

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 containing kubectl). I passed a placeholder ARN so that the stack would synthesise; it could never deploy as written.
  • The library lags the service. aws-cdk-lib 2.273.0 lists Kubernetes versions up to V1_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#addDependency printed "is deprecated. Use addResourceDependency instead" 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:

python analyze.py # roles
managed policies attached to each IAM role in the EksL2 stack (top-level and nested templates): ClusterKubectlHandlerRole94549F93 service-role/AWSLambdaBasicExecutionRole, service-role/AWSLambdaVPCAccessExecutionRole, AmazonEC2ContainerRegistryPullOnly, AmazonElasticContainerRegistryPublicReadOnly ClusterRoleFA261979 AmazonEKSClusterPolicy ClusterNodegroupNodesNodeGroupRole7AC14175 AmazonEKSWorkerNodePolicy, AmazonEKS_CNI_Policy, AmazonEC2ContainerRegistryReadOnly (roles that only carry AWSLambdaBasicExecutionRole are the other Lambda execution roles of the custom resource providers and are left out)

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

cfn-lint cdk.out/EksL1.template.json # first block of the lint run
$ cfn-lint EksL1.template.json (exit status 0)

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:

python mutate_lint.py
change cfn-lint the template as synthesised exit 0 - cluster version 1.35 -> 135 exit 2 E3031:'135' does not match '1\\.\\d\\d' cluster version 1.35 -> 'latest' exit 2 E3031:'latest' does not match '1\\.\\d\\d' upgradePolicy.supportType 'STANDARD' -> 'PREMIUM' exit 2 E3030:'PREMIUM' is not one of ['STANDARD', 'EXTENDED'] accessConfig.authenticationMode -> 'SAML' exit 2 E3030:'SAML' is not one of ['CONFIG_MAP', 'API_AND_CONFIG_MAP', 'API'] logging type 'api' -> 'apis' exit 2 E3030:'apis' is not one of ['api', 'audit', 'authenticator', 'controllerManager', 'scheduler'] endpointPublicAccess 'true' -> 'yes' exit 2 E3012:'yes' is not of type 'boolean' publicAccessCidrs -> 'anywhere' exit 0 - nodegroup amiType -> 'AL3' exit 2 E3030:'AL3' is not one of ['AL2023_ARM_64_NVIDIA', 'AL2023_ARM_64_STANDARD', 'AL2023_x86_64_NEU nodegroup minSize 1 -> 5 (above maxSize 3) exit 0 - nodegroup scalingConfig.desiredSize -> 'two' exit 2 E3012:'two' is not of type 'integer' nodegroup instance type 't3.medium' -> 't3.huge' exit 0 - nodegroup updateConfig.maxUnavailable -> 0 exit 2 E3034:0 is less than the minimum of 1 addon name -> 'vpc-cni2' exit 0 - pod identity namespace missing exit 2 E3003:'Namespace' is a required property pod identity serviceAccount -> 'Reports_SA' (not a DNS name) exit 0 -

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:

py max_pods.py
instance types in the limits file: 1392 | in the published list: 1392 types in both: 1392 | formula disagrees with the list for: 0 only in limits file: 0 | only in list: 0 type ENIs IPs/ENI max pods t3.small 3 4 11 t3.medium 3 6 17 t3.large 3 12 35 m5.large 3 10 29 m5.xlarge 4 15 58 m5.4xlarge 8 30 234 c5.large 3 10 29 m6g.large 3 10 29 m5.24xlarge 15 50 737 types whose formula exceeds 110: 952 of 1392 (a managed node group without a custom AMI caps maxPods at 110 below 30 vCPUs and 250 above, per the page; vCPU counts are not in these files)

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
MechanismAn OIDC provider for the cluster; the pod's projected token is exchanged with AssumeRoleWithWebIdentityAn association (role to service account to namespace, per cluster) made in EKS; the Pod Identity Agent on each node issues credentials
Trust policyA principal per cluster (the OIDC provider)One principal for all clusters: "Service": "pods.eks.amazonaws.com"
SetupCreate the OIDC provider once per cluster; annotate the service accountInstall the agent add-on (not needed on Auto Mode) and create the association
Limits and exclusionsNot for local clusters on OutpostsUp to 5,000 associations per cluster; role must be in the cluster's account; Linux EC2 nodes only (not Fargate, not Windows)
ConsistencyImmediate (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.)

python trust_eval.py
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::111122223333:oidc-provider/oidc.eks.eu-west-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "oidc.eks.eu-west-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE:sub": "system:serviceaccount:apps:reports", "oidc.eks.eu-west-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE:aud": "sts.amazonaws.com" } } } ] } ok strict policy, apps/reports -> allowed ok strict policy, wrong namespace (dev/reports) -> denied ok strict policy, wrong service account (apps/other) -> denied ok strict policy, wrong audience -> denied ok strict policy, token from another cluster's provider -> denied ok StringLike with * in the namespace, dev/reports -> allowed ok StringLike with * in the namespace, kube-system/reports -> allowed ok StringLike with * in the namespace, apps/other -> denied ok no sub condition at all, any service account -> allowed expectation mismatches: 0

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):

apiVersion: v1 kind: ServiceAccount metadata: name: reports namespace: apps annotations: # IRSA only (Pod Identity needs no annotation): the role the pods may assume through the cluster's OIDC provider eks.amazonaws.com/role-arn: arn:aws:iam::111122223333:role/reports-irsa --- apiVersion: apps/v1 kind: Deployment metadata: name: reports namespace: apps spec: replicas: 3 selector: matchLabels: {app: reports} strategy: type: RollingUpdate rollingUpdate: {maxUnavailable: 0, maxSurge: 1} template: metadata: labels: {app: reports} spec: serviceAccountName: reports terminationGracePeriodSeconds: 20 topologySpreadConstraints: - maxSkew: 1 topologyKey: topology.kubernetes.io/zone whenUnsatisfiable: ScheduleAnyway labelSelector: matchLabels: {app: reports} containers: - name: web image: 111122223333.dkr.ecr.eu-west-1.amazonaws.com/web:1.0.0 ports: - containerPort: 8080 env: - {name: HANDLE_SIGTERM, value: "1"} readinessProbe: httpGet: {path: /health, port: 8080} periodSeconds: 5 livenessProbe: httpGet: {path: /health, port: 8080} initialDelaySeconds: 10 periodSeconds: 15 resources: requests: {cpu: 100m, memory: 128Mi} limits: {memory: 256Mi} securityContext: runAsNonRoot: true runAsUser: 10001 readOnlyRootFilesystem: true allowPrivilegeEscalation: false --- apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: reports namespace: apps spec: minAvailable: 2 selector: matchLabels: {app: reports} --- apiVersion: v1 kind: Service metadata: name: reports namespace: apps spec: selector: {app: reports} ports: - port: 80 targetPort: 8080 --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: reports namespace: apps annotations: alb.ingress.kubernetes.io/scheme: internet-facing alb.ingress.kubernetes.io/target-type: ip alb.ingress.kubernetes.io/healthcheck-path: /health alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS": 443}]' alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:eu-west-1:111122223333:certificate/00000000-0000-0000-0000-000000000000 spec: ingressClassName: alb rules: - http: paths: - path: / pathType: Prefix backend: service: {name: reports, port: {number: 80}}

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:

python validate_k8s.py
change JSON schema (v1.35.0 strict) annotation/consistency checks the manifests as written valid - misspelt 'readinessprobe' on the container 1 error: Deployment: spec/template/spec/containers/0: Additional prop no readiness probe found replicas: '3' (a string) 1 error: Deployment: spec/replicas: '3' is not of type 'integer', 'nu - containerPort: 'http' 1 error: Deployment: spec/template/spec/containers/0/ports/0/containe - Deployment without a selector 1 error: Deployment: spec: 'selector' is a required property - selector that matches no pod label (app: other) valid - PDB minAvailable 5 with 3 replicas valid - PDB with both minAvailable and maxUnavailable valid - memory limit below the request (64Mi < 128Mi) valid - Ingress scheme 'public' valid scheme public Ingress target-type 'pod' valid target-type pod ALB health check path '/healthz' (readiness serves /health) valid ALB health check path /healthz is not the readiness path /health misspelt annotation key 'alb.ingress.kubernetes.io/schema' valid -

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:

py versions.py
1.37 release October 1, 2026 -> standard ends 2027-12-01 (printed 2027-12-01) extended ends 2028-12-01 (printed 2028-12-01) ok 1.36 release June 2, 2026 -> standard ends 2027-08-02 (printed 2027-08-02) extended ends 2028-08-02 (printed 2028-08-02) ok 1.35 release January 27, 2026 -> standard ends 2027-03-27 (printed 2027-03-27) extended ends 2028-03-27 (printed 2028-03-27) ok 1.34 release October 2, 2025 -> standard ends 2026-12-02 (printed 2026-12-02) extended ends 2027-12-02 (printed 2027-12-02) ok 1.33 release May 29, 2025 -> standard ends 2026-07-29 (printed 2026-07-29) extended ends 2027-07-29 (printed 2027-07-29) ok 1.32 release January 23, 2025 -> standard ends 2026-03-23 (printed 2026-03-23) extended ends 2027-03-23 (printed 2027-03-23) ok 1.31 release September 26, 2024 -> standard ends 2025-11-26 (printed 2025-11-26) extended ends 2026-11-26 (printed 2026-11-26) ok rows where the 14 + 12 month rule disagrees with the page: 0 status on 2026-10-13 1.37 standard (standard ends in 414 days) 1.36 standard (standard ends in 293 days) 1.35 standard (standard ends in 165 days) 1.34 standard (standard ends in 50 days) 1.33 extended 1.32 extended 1.31 extended control plane fee for one cluster for one month (730 h): standard $73.00, extended $438.00 the cluster.yaml first drafted for this chapter used version 1.33, which is in: extended

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).

What was and was not checked
Run here: 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

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

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.35 is a number and 1.30 becomes 1.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.medium holds 17 pods, an m5.large 29
  • IRSA: OIDC provider per cluster, trust policy with sub and aud conditions (use StringEquals on the exact service account; a * namespace or no sub opens 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 LoadBalancer Service; the scheme default is internal, so an Ingress meant to be public needs internet-facing, and a misspelt annotation is silently ignored
  • The L2 CDK Cluster deployed 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
Coming next
Chapter 8 is API Gateway and serverless APIs: REST, HTTP and WebSocket APIs, integrations, stages, authorisation, throttling and the SAM CLI. SAM runs Lambda locally in Docker, and Docker Desktop's engine was not running when this chapter ended, so please start it (and check that docker info answers) before chapter 8. Start with AWS Intermediate/Advanced 8.