Managed K8s on EKS
Everything so far runs on any cluster. This lesson goes to Amazon EKS — managed Kubernetes on AWS, and the bridge to the AWS AI Automation track. You'll stand up a cluster with eksctl, choose node groups (managed EC2 vs Fargate), give Pods AWS permissions the right way with IRSA (IAM Roles for Service Accounts), push images to ECR, expose apps through the AWS Load Balancer Controller, and face the production concerns EKS makes real: logging, cost, and upgrades. Then we go past "it runs" to enterprise grade — private nodes behind a locked-down API, KMS-encrypted secrets, IMDSv2, audit logging, and Karpenter — and finish by replacing EC2 nodes with Fargate, step by step.
eksctl delete cluster). Commands and IAM/annotation details drift; verify every one against the current EKS and eksctl docs. This ties directly into the AWS track (W1–W14).Learning objectives
- Explain what EKS manages for you (the control plane) versus what you still own (nodes, add-ons).
- Create a cluster and node group with eksctl, and choose managed node groups vs Fargate.
- Grant Pods least-privilege AWS access with IRSA instead of node-wide credentials.
- Push a container image to ECR and pull it into the cluster.
- Expose a service via the AWS Load Balancer Controller, and reason about logging and cost.
- Harden a cluster to enterprise grade: private nodes, a private/restricted API endpoint, and NAT egress.
- Lock down the data plane with KMS secret encryption, IMDSv2, and SSM instead of SSH.
- Turn on the signals that make a cluster defensible — audit logging, GuardDuty, per-Pod security groups, and Pod Security Standards.
- Scale and survive with Karpenter, multi-AZ spread, Spot, and safe version upgrades.
- Replace EC2 nodes with Fargate via a production migration runbook — audit, size, canary cutover, rollback.
- Give critical data durable, encrypted storage (EBS/EFS, StatefulSets) and a tested backup/DR plan (Velero, snapshots, RPO/RTO).
- Keep it up 24/7 with probes, PDBs, topology spread, and graceful shutdown — and make it reproducible with IaC + GitOps.
- Run a production go-live checklist before real traffic touches the cluster.
- Follow one end-to-end runbook — empty AWS account → hardened cluster → live app → teardown — from start to finish.
30-second recap
- Pods, Deployments & Services: A Pod runs containers, a Deployment keeps N identical Pods alive and rolls out updates, a Service gives them one stable address.
- AWS foundations: IAM controls who can do what, regions decide where things run, and every AWS service is called through an authenticated API.
1 · What EKS manages — and what you still own
Here's the mistake that turns "managed Kubernetes" into a 3am incident. Someone reads the word managed, assumes AWS is handling the whole cluster, and skips patching their nodes, sizing their instances, or watching their bill. Six weeks later a node is out of disk, an add-on is three versions behind, and the monthly invoice has a forgotten load balancer on it. "Managed" is a real gift — but it's a gift with a precise, published boundary, and your entire job on EKS is knowing exactly where that boundary falls.
The cleanest way to hold it in your head is a rented apartment. The landlord (AWS) guarantees the building stands up: the foundation, the plumbing risers, the electrical service to your door. You never think about whether the building will collapse — that's the whole point of renting. But inside your unit, you own the furniture, you pay the electricity bill, you decide what to plug in, and if you leave the tap running the water bill is yours. EKS draws the same line: AWS runs the parts that are catastrophic and boring to operate, and hands you a clean door to everything above it.
The single most important word in that boundary is control plane — the "brain" of a Kubernetes cluster. It's two pieces: the api-server (the front desk that every kubectl command and every controller talks to) and etcd (the database that stores the desired state of every object in the cluster). Running etcd yourself is genuinely frightening — it's a distributed database where a bad backup or a botched upgrade can lose the entire cluster's memory — and that is precisely the burden AWS lifts. Why this matters so much: it's the difference between "Kubernetes is a full-time platform team" and "Kubernetes is something a small team can actually run."
On EKS, AWS runs the control plane — api-server and etcd, replicated across Availability Zones, patched and backed up for you. That removes the scariest operational burden (running etcd). But EKS is not fully managed: you still own the worker nodes, the add-ons (CNI, CoreDNS, controllers), upgrades of your workloads, and cost.
Read it left-to-right, following the two arrows: the boundary of what AWS runs sits at the far left, and everything to the right of it is yours.
- The left box is the AWS-managed control plane — api-server and etcd that AWS keeps alive for you; you never touch it directly.
- The arrows mark where responsibility hands off: from the control plane to your node groups (EC2 / Fargate) in the middle box, then on to your Pods in the right box.
- The payoff is those two right-hand boxes — nodes and Pods — both labelled "Your": EKS being managed removes etcd, but node types, patching, add-ons, and cost stay on your side of the line.
In short: AWS keeps the brain (control plane) alive; you own the muscle — the nodes and Pods to the right of the handoff.
2 · Create a cluster with eksctl
Imagine building an EKS cluster by hand. You'd create a VPC, carve out subnets across three Availability Zones, wire up route tables and NAT gateways, stand up the control plane, attach an OIDC provider, create IAM roles for the nodes, launch an auto-scaling group of EC2 instances, and register them with the cluster — dozens of interdependent resources, each with a chance to be misconfigured in a way you won't discover until something breaks. People do this once, get it slightly wrong, and spend a day debugging why their nodes won't join. eksctl exists so you never have to.
The mental model is a house-building kit versus raw lumber. Raw AWS APIs are the lumber — total control, and total responsibility for every joint. eksctl is the kit: you fill in a short form (how big, which region, how many nodes) and it produces the whole structure, correctly assembled, in one step. Under the hood it isn't magic — it writes CloudFormation (AWS's infrastructure-as-code service) and lets AWS build the stack — so everything it creates is tracked, repeatable, and deletable as a unit. That last part is why the teardown at the end of the lab is a single command: eksctl remembers everything it made.
The common mistake here is treating cluster creation as instant. It is not — spinning up a control plane and a node group takes roughly 15–20 minutes, because AWS is genuinely provisioning networking, a highly-available control plane, and EC2 capacity behind the scenes. Beginners often Ctrl-C in a panic when nothing seems to happen for ten minutes, leaving a half-built CloudFormation stack that then has to be cleaned up manually. Start the command, and let it finish. One more thing to internalize before you run anything: the moment those nodes are Ready, the meter is running — so the very first thing to learn alongside "create" is "delete."
eksctl is the quickest path to a working cluster: one config file describes the cluster and its node groups, and eksctl provisions the VPC, control plane, and nodes via CloudFormation.
Read it left-to-right: your form on the left becomes a stack of real AWS resources on the right.
- The left blue box is
cluster.yaml— the short form you fill in (name, region, node counts). - The middle boxes are the machinery: eksctl writes CloudFormation, so you never call raw AWS APIs by hand.
- The green row is what actually gets built — VPC and subnets (with the OIDC provider IRSA needs), the HA control plane, and your managed node group. Because it's one CloudFormation stack,
eksctl delete clusterremoves every piece.
In short: Fill in one form; eksctl builds (and later deletes) the entire cluster as a single CloudFormation stack — which is why teardown is one command.
cluster.yamlapiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
name: ai-platform
region: us-east-1
version: "1.30" # pick a currently-supported version
managedNodeGroups:
- name: general
instanceType: m6i.large
minSize: 2
maxSize: 6
desiredCapacity: 3
volumeSize: 40
# NOTE: with no privateNetworking flag, eksctl places these nodes in PUBLIC
# subnets (they get public IPs). Fine for a first learning cluster — but for
# anything real, set privateNetworking: true and lock down the API (section 6).
iam:
withOIDC: true # REQUIRED to use IRSA (section 3)
create.sheksctl create cluster -f cluster.yaml # ~15-20 min; provisions everything
aws eks update-kubeconfig --name ai-platform --region us-east-1
kubectl get nodes # your managed nodes, Ready
# when finished, to STOP being billed:
eksctl delete cluster --name ai-platform
NAME STATUS ROLES AGE VERSION
ip-10-0-1-23.ec2.internal Ready <none> 3m v1.30.x
ip-10-0-2-41.ec2.internal Ready <none> 3m v1.30.x
ip-10-0-3-88.ec2.internal Ready <none> 3m v1.30.x
privateNetworking flag, eksctl places these managed nodes in public subnets with public IPs and leaves the API endpoint public. That's the fastest way to a working cluster, which is why we start here — but it's also the default that quietly ships to production and gets flagged in the first security review. Section 6 fixes exactly this: private-subnet nodes behind NAT and a locked-down API endpoint. Learn on this; harden before anything real runs on it.3 · Node groups: managed EC2 vs Fargate
Your control plane is alive, but it has nowhere to run your Pods yet. The api-server can accept a Deployment all day long, but a Pod is just a scheduled intention until there's an actual machine with CPU and memory to place it on. Those machines are your nodes, and choosing how you get them is the decision that quietly sets both your bill and your operational workload for the life of the cluster. Get it wrong and you either overpay for idle servers or find yourself hand-patching operating systems you thought AWS was handling.
The choice is really a rent-vs-own question, and a good analogy is transport. A managed node group (EC2) is like owning a car: you pick the model, you keep it fuelled and serviced (with EKS assisting on the OS-image patching), and you pay for it whether it's parked or driving. That fixed cost is fine — even efficient — when you drive every day, because you can pack many Pods onto one node and amortize it. Fargate is like taking a taxi per trip: no vehicle to maintain, you pay only for the exact ride (each Pod's requested vCPU and memory), and each rider gets their own isolated car. Perfect for occasional or unpredictable trips; wasteful and clunky if you're commuting all day.
The mistake to avoid is thinking one model is simply "better." They solve different problems, and the professional move is to mix them on purpose — which is exactly why the table below exists. The other trap, and the one that ends projects abruptly, is assuming Fargate can do everything: it cannot run GPUs. If you're serving an LLM (the whole point of K7) you need GPU instance types, and those live only on EC2 node groups. Read the comparison below not as "pick a winner" but as "learn which tool each workload is asking for" — steady baseline, bursty spikes, and GPU inference each point at a different row.
Where do your Pods actually run? Two models, and you can mix them:
Compare the two panels split by the dashed line: the left is "own the car" (EC2), the right is "taxi per trip" (Fargate).
- The left panel is a single EC2 node with many Pod boxes packed inside it — you pay for that node whether it's busy or idle, which is efficient only when it's kept full. The amber
GPUbox sits here because GPUs exist only on EC2. - The right panel shows separate amber boxes, each its own isolated micro-VM — one per Pod, billed for exactly that Pod's requested vCPU and memory, with no shared node and no GPU option.
- The bottom line is the payoff: the professional move is to run both — EC2 for steady baseline and GPU inference, Fargate for bursty or isolation-sensitive work.
In short: EC2 = one shared, always-paid machine that can hold GPUs; Fargate = a private per-Pod micro-VM paid by the trip; mix them by workload.
| Managed node group (EC2) | Fargate | |
|---|---|---|
| You manage | Instance type, scaling, AMI patching (assisted) | Nothing — AWS runs each Pod |
| Granularity | Per node (pack many Pods) | Per Pod (one micro-VM each) |
| Cost shape | Pay per node, even partly idle | Pay per Pod's requested vCPU/memory |
| Good for | Steady workloads, GPUs, DaemonSets | Bursty, spiky, or isolation-sensitive workloads |
| GPUs | Yes (GPU instance types) | No GPU support |
4 · IRSA — give Pods AWS permissions the right way
This is the section that shows up in security post-mortems. A Pod needs to read one S3 bucket. The fast fix — the one that "just works" and gets copied into a hundred tutorials — is to attach an S3 policy to the node's instance role. It works instantly, so it ships. What nobody notices until an audit (or a breach) is that every Pod on that node now shares those credentials, including the third-party sidecar, the noisy neighbor from another team, and any container an attacker manages to compromise. One Pod's need became the whole node's power. IRSA is how you refuse that trade.
Think of it as the difference between a building's master key and a hotel keycard. Putting permissions on the node role is handing every guest on the floor a master key because one of them needed into the supply closet — convenient, and a catastrophe waiting for the one bad guest. IRSA — IAM Roles for Service Accounts — issues each workload its own keycard instead: a credential scoped to exactly the doors that one Pod needs, and useless to anyone else. The principle underneath is least privilege: give each thing the minimum access it needs and nothing more, so a compromise stays small.
The machinery is worth understanding rather than memorizing, because it explains why it's trustworthy. Kubernetes already gives each Pod a ServiceAccount — an in-cluster identity. IRSA connects that identity to AWS's identity system (IAM) through an OIDC provider, a standard trust bridge that lets AWS verify "yes, this really is the bedrock-caller ServiceAccount in your cluster, asking for its role." When it checks out, the Pod receives short-lived, auto-rotating credentials for that one IAM role — no long-lived access keys baked into the image, no secrets in environment variables, nothing to leak. That's why enabling withOIDC back in the cluster config wasn't optional boilerplate: it's the foundation this entire mechanism stands on. Keep the master-key-versus-keycard image in mind as you read the wrong way below.
Your app needs to call AWS (read S3, invoke Bedrock, write to DynamoDB). The wrong way is to attach broad permissions to the node's instance role — then every Pod on that node inherits them. The right way is IRSA: map a Kubernetes ServiceAccount to a specific IAM role via OIDC, so only Pods using that ServiceAccount get exactly those permissions.
Two approaches split by the dashed line — the red left is the trap, the green/blue right is the fix. Follow the arrows downward on each side.
- On the left, a broad policy sits on the node role and the red arrows fan out to every Pod on that node — including an attacker's compromised container. One Pod's need became the whole node's power.
- On the right, a narrowly-scoped IAM role flows down through the OIDC trust bridge to a single ServiceAccount (
bedrock-caller) — and only the Pod that names that ServiceAccount receives short-lived credentials. - The payoff is the bottom-right box: the permission stops at only this Pod, so a neighbor or a breach can't reach it — that's least privilege.
In short: Node-role permissions leak to everything on the node; IRSA delivers a scoped role, via OIDC, to exactly one ServiceAccount and its Pod.
irsa.sh# eksctl creates the IAM role, the trust policy, and the annotated ServiceAccount:
eksctl create iamserviceaccount \
--cluster ai-platform \
--namespace ai \
--name bedrock-caller \
--attach-policy-arn arn:aws:iam::aws:policy/AmazonBedrockReadOnly \
--approve
# the resulting ServiceAccount carries an annotation like:
# eks.amazonaws.com/role-arn: arn:aws:iam::<acct>:role/<generated-role>
# then a Pod uses it:
# spec.serviceAccountName: bedrock-caller
AmazonS3FullAccess to the node instance role, every Pod on that node — including a compromised one — can use it. IRSA scopes credentials to a single ServiceAccount so each workload gets least privilege. This is the single most important EKS security practice. (Verify whether EKS Pod Identity — a newer alternative — better fits your case against the current docs.)5 · ECR, the AWS Load Balancer Controller, and prod concerns
A cluster that runs your code isn't the same as a cluster in production. The gap between them is unglamorous plumbing: where your images live, how traffic from the real internet reaches a Pod, where the logs go when something breaks at 2am, and who's watching the bill. Skip these and you get the classic pattern — a demo that works beautifully on your laptop and falls apart the first week it faces real users, real traffic, and a real invoice. This section is the difference between "it runs" and "it runs in production."
Take the pieces one at a time. ECR (Elastic Container Registry) is AWS's private image registry — think of it as your cluster's warehouse. Your nodes can't run an image they can't fetch, and pulling from a public registry for production is like having every delivery routed through a stranger's shop: slow, rate-limited, and outside your control. ECR sits inside your AWS account, next to your cluster, so pulls are fast and access is yours to govern. The AWS Load Balancer Controller is the front door: an Ingress object (from K4) is just a wish for routing until something actually provisions a load balancer to fulfil it, and on AWS that something is this controller, translating your Ingress into a real Application Load Balancer that the internet can reach.
Then come the "prod concerns" — logging, metrics, cost, upgrades, security — and here's the mindset shift that separates a hobby cluster from a real one: these are not optional extras you bolt on later, they're the tax you owe for running anything people depend on. The most expensive lesson in this whole lesson is that the cluster bills you whether or not anyone is using it. An idle control plane still charges by the hour; a forgotten load balancer from a test you ran last month is still quietly billing. Nobody forgets to turn off a light that's costing them visibly — the danger is precisely that this cost is invisible until the invoice arrives. Read the tables below as a pre-flight checklist: each row is a question production will ask you, and it's cheaper to answer it now than during an incident.
Two more AWS-native pieces, then the operational realities.
Read the two rows separately, both flowing left-to-right into the Pod on the right: the top row is how your code arrives, the bottom row is how users arrive.
- The top row is the image supply line: the versioned image lives in ECR (a private registry in your account), the node pulls it, and it runs as the Pod.
- The bottom row is the traffic path: requests from the internet hit the ALB that the AWS Load Balancer Controller provisioned to fulfil your Ingress, which routes through the Service to the Pod.
- Both arrows converge on the same Pod — the payoff is that "runs in production" means both supply lines are wired: private images in, real load-balanced traffic in.
In short: ECR delivers the image to the node; the controller's ALB delivers user traffic through the Service — two paths, one Pod.
ecr.shaws ecr create-repository --repository-name web
aws ecr get-login-password --region us-east-1 \
| docker login --username AWS --password-stdin <acct>.dkr.ecr.us-east-1.amazonaws.com
docker build -t <acct>.dkr.ecr.us-east-1.amazonaws.com/web:1.0.0 .
docker push <acct>.dkr.ecr.us-east-1.amazonaws.com/web:1.0.0
# then in your Deployment: image: <acct>.dkr.ecr.us-east-1.amazonaws.com/web:1.0.0
The AWS Load Balancer Controller lets an Ingress (K4) provision a real AWS Application Load Balancer, and a LoadBalancer Service provision an NLB. Install it (via Helm, K6) and it fulfills Ingress objects natively on AWS.
alb-ingress.yamlapiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web
annotations:
kubernetes.io/ingress.class: alb
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
spec:
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: web, port: { number: 80 } }
| Prod concern | On EKS you handle it with |
|---|---|
| Logging | Ship container logs to CloudWatch (Fluent Bit / the CloudWatch add-on) |
| Metrics | Container Insights, or Prometheus + Grafana |
| Cost | Right-size nodes, use Spot for tolerant workloads, Cluster Autoscaler/Karpenter, watch idle nodes |
| Upgrades | Bump the control-plane version, then node groups; respect PDBs (K3) during rolls |
| Security | IRSA for Pods, private API endpoint, least-privilege RBAC, image scanning in ECR |
6 · Enterprise networking: private nodes, a locked-down API
Here's the single most common way a "production" EKS cluster is quietly insecure: the nodes have public IPs and the Kubernetes API is open to the whole internet. It happens because the quickstart config — the one in section 2 — does exactly that by default, and it works, so nobody revisits it. Then a scanner finds the public API endpoint, or a compromised container reaches out from a node that has a public route, and what should have been an internal blast radius becomes an internet-facing one. Professional EKS starts by moving the whole data plane off the public internet.
The mental model is a corporate office versus a market stall. The quickstart cluster is a stall in the open square: anyone walking past can see it and walk up to it. An enterprise cluster is an office inside a secured building — the workers (your nodes) sit in interior rooms with no door to the street, and the reception desk (the API server) either faces only the internal corridor or, at most, has a guard checking IDs against a short list. Deliveries still go out (nodes need to pull images and reach AWS APIs), but nothing unsolicited comes in.
Two independent controls make this real, and it's worth separating them because people conflate them. First, private node networking: your worker nodes live in private subnets — subnets with no route to an internet gateway — so they have no public IP at all. They still reach out through a NAT gateway for image pulls and AWS API calls, but the internet cannot originate a connection to them. Second, the cluster API endpoint: by default it's public. You can flip it to private-only (reachable just from inside the VPC / your VPN) or keep it public but restrict it to an allow-list of CIDRs (your office and CI IP ranges). A subtle detail that explains why private access works: when you enable private access, EKS places the control-plane network interfaces (ENIs) in your own subnets, so kubectl from inside the VPC talks to the API over private address space, never the internet.
Compare the two halves across the dashed line: the red left is the default you inherit, the green/blue right is the hardened target. Follow the arrows to see who can reach what.
- On the left, red arrows come from the internet into both a public API and a public node — unsolicited inbound reach to your control plane and your workers.
- On the right, the only inbound path is from an allow-listed set of office IPs (or nothing, if the API is private-only) into a restricted API endpoint; the nodes sit in a private subnet and reach the internet only outward through NAT.
- The payoff is directionality: enterprise networking makes every arrow that touches your nodes point outward, so a scanner or attacker has nothing to connect to.
In short: private subnets remove the nodes' front door; a private/allow-listed API removes the control plane's. Egress still works via NAT.
cluster-hardened.yamlapiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
name: ai-platform
region: us-east-1
version: "1.30"
vpc:
nat:
gateway: HighlyAvailable # one NAT per AZ (Single = cheaper, less resilient)
clusterEndpoints:
publicAccess: true # set false for a fully private API endpoint
privateAccess: true # REQUIRED so in-VPC kubectl uses private ENIs
publicAccessCIDRs: # only these can reach the API (drop when public=false)
- 203.0.113.10/32 # your office / VPN egress IP
managedNodeGroups:
- name: general
instanceType: m6i.large
minSize: 2
maxSize: 6
desiredCapacity: 3
privateNetworking: true # nodes in PRIVATE subnets — no public IP
iam:
withOIDC: true
publicAccess: false) means you can only run kubectl from inside the VPC — via a bastion, VPN, or Direct Connect — and your CI runners must sit in-network too. It's the strongest posture, but decide it up front. If you must keep public access, always pair it with a tight publicAccessCIDRs list; a public endpoint open to 0.0.0.0/0 is the finding auditors flag first. Verify current endpoint and CIDR behavior against the EKS docs.7 · Lock down the data plane: KMS, IMDSv2, and no SSH
A confession most teams make only after an incident: their Kubernetes "secrets" were never encrypted, and their node's credentials were one HTTP request away from being stolen. Both are default behaviors, both are invisible until someone looks, and both have clean fixes that cost almost nothing to apply — if you apply them before you need them.
Start with secrets. A Kubernetes Secret is famously only base64-encoded, not encrypted — base64 is a costume, not a lock, and anyone who can read the object (or the etcd backing store) reads the value. EKS lets you fix this with KMS envelope encryption: you point the cluster at a KMS key, and EKS encrypts secret data with a data key that is itself encrypted by your KMS key before it ever lands in etcd. Think of it as putting the documents in a safe and keeping the safe's key in a bank vault you control — losing a copy of etcd no longer means losing your secrets.
Then the node's own identity. Every EC2 instance exposes the Instance Metadata Service (IMDS) at a fixed link-local address, and it hands out the node's IAM credentials to anything that asks. The classic breach chain is short and brutal: an app on the node has a server-side request forgery (SSRF) bug, an attacker tricks it into fetching that metadata URL, and now they hold the node role's credentials. IMDSv2 closes this by requiring a session token (a PUT before the GET), and setting the hop limit to 1 means the response can't travel out of the instance into a container that shouldn't see it. Pair that with IRSA (section 4) so pods get their own scoped credentials and never need the node's at all.
Last, stop using SSH. A bastion host with SSH keys is a standing door, a key to lose, and a thing to patch. AWS Systems Manager (SSM) Session Manager gives you an interactive shell to a node with no open inbound port, no key pair, and a full audit log of who connected and when — you just attach the SSM permission to the node role. The right posture is: no SSH, no bastion, break-glass shell through SSM only.
cluster-encrypt.yamlapiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
name: ai-platform
region: us-east-1
# Envelope-encrypt Kubernetes Secrets with your KMS key (create the key first):
secretsEncryption:
keyARN: arn:aws:kms:us-east-1:<acct>:key/<key-id>
managedNodeGroups:
- name: general
instanceType: m6i.large
privateNetworking: true
disableIMDSv1: true # enforce IMDSv2 (token-required) on the nodes
# hop limit / metadata options are set on the node launch template;
# confirm the exact eksctl field vs your version, or manage via a launch template.
ssm.sh# give the node role SSM access (once), then reach a node with NO open port and NO key:
# attach policy: arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore (to the node role)
aws ssm start-session --target i-0abc123def456 # audited interactive shell
# there is no inbound SSH port to expose, no key pair to lose, and every session is logged.
8 · See it and guard it: audit logs, GuardDuty, per-Pod SGs
The worst moment in a security incident is realizing you can't answer "what happened?" because nothing was recorded. A cluster that runs fine can still be undefendable — not because it lacks locks, but because it lacks cameras. EKS ships with most of its logging and threat detection off by default, and the time to turn them on is long before you need them, because you can't retroactively log an event that already happened.
The foundation is control-plane logging. EKS can stream the API server's audit log (every request: who did what, to which object, and whether it was allowed) and the authenticator log (how identities mapped to cluster access) to CloudWatch. This is the flight recorder — without the audit log, "which ServiceAccount deleted that Deployment at 2am?" is unanswerable. Turn it on per cluster; it's a one-line config.
On top of that come active guards. GuardDuty EKS Protection watches control-plane activity and node runtime behavior for known-bad patterns (a pod suddenly crypto-mining, an anonymous API call that succeeded) and raises findings without you writing detection rules. Security groups for pods let you attach an EC2 security group to individual pods rather than only the node — so your database-client pod can be the only thing in the cluster allowed to reach the database's port, enforced at the VPC layer, not just by NetworkPolicy. And Pod Security Standards (the successor to PodSecurityPolicy) enforce, at admission, that pods can't run privileged, as root, or with host access — set a namespace to restricted and the API server rejects a pod that violates it. These tie back to the in-cluster controls in K9; here they get their AWS-native teeth.
observe.yamlapiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata: { name: ai-platform, region: us-east-1 }
cloudWatch:
clusterLogging:
enableTypes: ["audit","authenticator","api","controllerManager","scheduler"]
pod-identity.sh# install the add-on, then associate a role with a namespace/ServiceAccount:
eksctl create addon --cluster ai-platform --name eks-pod-identity-agent
eksctl create podidentityassociation \
--cluster ai-platform --namespace ai --service-account-name bedrock-caller \
--permission-policy-arns arn:aws:iam::aws:policy/AmazonBedrockReadOnly
# same least-privilege goal as IRSA (section 4), often simpler to manage — pick per current docs.
| You want to… | Turn on |
|---|---|
| Answer "who did what" after an incident | Control-plane audit + authenticator logging → CloudWatch |
| Detect known-bad behavior without writing rules | GuardDuty EKS Protection (control-plane + runtime) |
| Firewall a single Pod at the VPC layer | Security groups for pods (per-Pod SG, not just the node SG) |
| Refuse privileged / root / host-access Pods | Pod Security Standards at admission (restricted) |
| Give add-ons/Pods scoped AWS access | IRSA or EKS Pod Identity — never the node role |
audit and authenticator on any cluster that matters. Verify current log types and pricing against the EKS/CloudWatch docs.9 · Scale and survive: Karpenter, multi-AZ, Spot, upgrades
A cluster that can't grow fast enough drops traffic; one that can't shrink burns money; one that can't survive an AZ blip pages you at 3am. Sections 6–8 made the cluster secure — this one makes it elastic and resilient, which at enterprise scale is just as much a production requirement. The tools here decide how quickly you get capacity, how little you overpay for it, and whether a single failure takes you down.
First, how nodes appear. The older answer is the Cluster Autoscaler, which adds or removes nodes from fixed Auto Scaling Groups you pre-define — it works, but you're stuck with whatever instance shapes those groups contain. Karpenter is the modern answer: instead of scaling pre-baked groups, it looks at the pending pods and provisions right-sized nodes just-in-time — the exact instance type and size those pods need — then consolidates them as load drops. The analogy is a taxi dispatcher who sends the right-sized vehicle for each group of riders rather than running a fixed fleet of identical vans. Faster to scale up, tighter bin-packing, less waste.
Second, don't put all your eggs in one Availability Zone. Spreading replicas across AZs with topology spread constraints means one zone going dark degrades you instead of downing you. Third, Spot instances cut compute cost dramatically for interruption-tolerant workloads — the catch is they can be reclaimed with two minutes' notice, so you pair them with graceful interruption handling (Karpenter does this natively; on managed groups, a Node Termination Handler drains the node first) and keep anything stateful or latency-critical on On-Demand. Finally, upgrades: the order is non-negotiable — bump the control plane first, then roll the node groups, respecting PodDisruptionBudgets (from K3) so draining a node for the upgrade never takes more replicas offline than you can afford. Kubernetes tolerates only a small version skew between control plane and nodes, which is exactly why the control plane leads.
Read left-to-right, then read the two lines beneath as what happens over time.
- The red box is the trigger: pods the scheduler can't place because no existing node fits them.
- Karpenter (middle) reads those pods' actual CPU/memory/GPU needs and provisions a right-sized node (green) — not a pre-baked group's guess, but the shape the pods asked for.
- The bottom lines are the payoff over time: when load falls, Karpenter consolidates onto fewer nodes to cut cost, and you steer tolerant workloads to Spot while keeping stateful/latency-critical ones on On-Demand.
In short: Karpenter turns "which pre-made node group fits?" into "build the node these pods need," then packs down as they leave.
| Workload | Node strategy |
|---|---|
| Steady baseline services, system add-ons | Small On-Demand managed group (or Karpenter On-Demand pool) |
| Bursty / batch / stateless, interruption-tolerant | Spot (Karpenter native, or managed group + Node Termination Handler) |
| GPU LLM inference (K7) | GPU instance types on EC2 — On-Demand or Capacity Reservations |
| Anything that must survive one AZ failing | ≥3 AZs + topologySpreadConstraints across zones |
10 · Replace EC2 nodes with Fargate — step by step
You've seen the EC2-vs-Fargate table in section 3; this is where you actually make the switch. The promise is real — no nodes to patch, no OS to secure, no idle capacity to pay for, and each pod in its own micro-VM — but a naïve migration hits three predictable walls in the first hour: CoreDNS won't move, your logging DaemonSet vanishes, and the load balancer can't find your pods. Every one of them has a known fix, and the difference between a smooth migration and a frustrating afternoon is knowing them before you start. Work through the steps in order.
The core mechanism to understand first: on Fargate you never create nodes. Instead you create a Fargate profile that says "any pod in these namespaces (optionally matching these labels) runs on Fargate." When a matching pod is scheduled, AWS spins up a right-sized micro-VM for it, runs it, and bills you for exactly its requested vCPU and memory. Your job is to point the workloads you want at a profile, then remove the EC2 capacity underneath them.
Read it left-to-right as a before/after: the shared node on the left becomes per-pod micro-VMs on the right, and the arrow is the Fargate profile doing the matching.
- The left box is today: multiple pods packed onto one EC2 node that you keep patched and scaled.
- The arrow labelled "profile · ns+labels" is the selector — the Fargate profile matches pods by their namespace and optional labels, which is what decides a pod runs on Fargate at all.
- The right side is the result: each matched pod runs in its own micro-VM, so there's no shared node to own — you pay per pod and patch nothing.
In short: you don't move pods to a node — you declare a namespace/label match, and Fargate gives each matching pod its own tiny machine.
What follows is a production migration runbook, not a toy demo: pre-flight audit → profile → system pods → right-size every Pod → a phased canary cutover that never drops traffic → validate → remove EC2 → and a rollback you decide before you start. Do them in order; each step earns the next.
Step 0 · Pre-flight audit — will these workloads even run on Fargate?
The most expensive Fargate migration is the one you start and then discover, halfway through, that a workload can't run there. Fargate silently never schedules a Pod it can't honor (it sits Pending), so audit first. Before creating anything, walk every workload in the target namespace against the constraints: no GPU, no DaemonSets, no privileged/hostNetwork/hostPort, no EBS ReadWriteOnce volumes, and each Pod's requests must fit under the per-Pod size ceiling. Anything that fails stays on EC2 — and that's a normal outcome, not a failure.
fargate-0-preflight.sh# DaemonSets can't run on Fargate — list any that target this namespace's nodes:
kubectl get daemonsets -A
# Pods that need host access, privileged mode, or hostNetwork will never schedule:
kubectl get pods -n ai -o json \
| jq -r '.items[] | select(.spec.hostNetwork==true or (.spec.containers[].securityContext.privileged==true)) | .metadata.name'
# Volumes: Fargate supports EFS (RWX), NOT EBS (RWO). Find EBS-backed PVCs:
kubectl get pvc -n ai -o custom-columns=NAME:.metadata.name,SC:.spec.storageClassName
# Every Pod MUST set resource requests (Fargate sizes the micro-VM from them):
kubectl get pods -n ai -o json \
| jq -r '.items[] | select(.spec.containers[].resources.requests==null) | .metadata.name'
# GPU workloads (nvidia.com/gpu) stay on EC2 — they cannot move.
--labels compute=fargate): label only the Pods that pass the audit, leave GPU/DaemonSet/EBS workloads unlabelled on EC2, and run the two side by side. A Fargate profile and an EC2 node group in the same cluster is the normal end state, not a transitional hack.Step 1 · Create the Fargate profile
The profile is the routing rule: it declares which Pods (by namespace, optionally narrowed by labels) Fargate will host. In production, create it from a version-controlled config file, not a one-off command (see §14) — and select the private subnets from §6, because Fargate Pods should sit behind NAT exactly like your EC2 nodes did.
fargate-1-profile.sheksctl create fargateprofile \
--cluster ai-platform \
--name ai-workloads \
--namespace ai \
--labels compute=fargate # match ONLY labelled Pods — leave EBS/GPU/DaemonSet Pods on EC2
# Fargate places Pods in the cluster's PRIVATE subnets by default (good) — confirm the
# profile's subnets are the private ones, never public. Verify vs current eksctl docs.
Step 2 · Let system Pods (CoreDNS) run on Fargate
If you're going all-Fargate (no EC2 nodes left at all), the cluster's own system Pods have to move too — and CoreDNS is the one that bites people, because EKS ships it pinned to EC2 with an annotation. Left as-is on a node-less cluster, CoreDNS sits Pending, DNS stops resolving, and every Pod's outbound call fails in a way that looks like a network outage. Patch the annotation off and restart it. (Skip this step if you're keeping a small EC2 node group for system add-ons — a common, defensible choice.)
fargate-2-coredns.sh# CoreDNS ships pinned to EC2 via an annotation; on an all-Fargate cluster it stays Pending.
# Remove the compute-type annotation, then restart it so it reschedules onto Fargate:
kubectl patch deployment coredns -n kube-system --type json \
-p='[{"op":"remove","path":"/spec/template/metadata/annotations/eks.amazonaws.com~1compute-type"}]'
kubectl rollout restart deployment coredns -n kube-system
# You also need a Fargate profile covering kube-system for this to schedule.
# (only needed if kube-system itself is covered by a Fargate profile / has no EC2 nodes.)
Step 3 · Right-size every Pod — this is your Fargate bill
On EC2 you paid per node; on Fargate you pay per Pod's requests — so a sloppy request line is now a direct, permanent line item on your invoice. Fargate reads each Pod's CPU and memory requests, rounds them up to the nearest supported Fargate size (there's a fixed menu of vCPU/memory combinations), adds a small overhead for the kubelet, and bills you for that shape for the Pod's whole life. Two consequences most people learn the hard way: a Pod requesting 300m CPU and a Pod requesting 480m CPU may cost exactly the same (both round to the 0.5 vCPU tier), and a Pod with no requests at all gets the minimum size and then gets OOM-killed under load. Set requests deliberately: measure real usage, then request just above the rounding boundary that fits.
fargate-3-sizing.yamlspec:
template:
metadata:
labels: { compute: fargate } # so the profile from Step 1 matches this Pod
spec:
containers:
- name: web
image: <acct>.dkr.ecr.us-east-1.amazonaws.com/web:1.0.0
resources:
requests: { cpu: "500m", memory: "1Gi" } # lands on the 0.5 vCPU / 1GB tier
limits: { cpu: "500m", memory: "1Gi" } # on Fargate, set limits == requests
# Fargate rounds UP to its size menu: asking 300m still bills the 0.5 vCPU tier, so
# requesting 500m is "free" headroom. Verify the current size table + pricing vs AWS docs.
Step 4 · Phased canary cutover — never drop traffic
Do not flip the whole service at once. Fargate Pods have slower cold starts than landing on a warm EC2 node, so a big-bang re-roll can leave you briefly under-capacity while the ALB waits for new targets to pass health checks. The safe pattern is a canary: run a few Fargate Pods alongside the EC2 ones, confirm they're healthy and serving, then shift the balance over. A PodDisruptionBudget (from K3) guarantees the cutover never takes more replicas offline at once than you can afford, and correct readiness probes ensure the ALB only sends traffic to Pods that are actually ready.
fargate-4-cutover.sh# 0) Protect availability during the shuffle: never let more than 1 replica be down.
kubectl -n ai apply -f - <<'EOF'
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: web-pdb, namespace: ai }
spec: { minAvailable: 2, selector: { matchLabels: { app: web } } }
EOF
# 1) Add the Fargate label to the Deployment (Step 3 YAML) but scale replicas UP first,
# so new Fargate Pods come online BEFORE old EC2 Pods drain:
kubectl -n ai scale deployment/web --replicas=6
kubectl -n ai rollout status deployment/web --timeout=300s
# 2) Confirm the new Pods are on Fargate AND passing readiness before removing EC2:
kubectl -n ai get pods -o wide # NODE column shows fargate-ip-... for new Pods
kubectl -n ai get endpoints web # only READY Pods appear here (ALB targets these)
# 3) Return to steady replica count once Fargate Pods are serving:
kubectl -n ai scale deployment/web --replicas=3
Step 5 · Fix the ALB target type and logging
Two things silently break on Fargate unless you change them. The ALB (§5) routes to node ports by default, and Fargate Pods have no node — so the Ingress must use target-type: ip to hit Pod IPs directly, or the load balancer finds no targets. And your logging: a Fluent Bit DaemonSet can't run on Fargate, so container logs vanish unless you enable Fargate's built-in log router — a special aws-logging ConfigMap that ships every Pod's stdout/stderr to CloudWatch with no DaemonSet at all.
fargate-5-obs.yaml# (a) Fargate has no node ports, so ALB Ingress MUST use target-type: ip (see section 5):
# alb.ingress.kubernetes.io/target-type: ip
# (b) no DaemonSets on Fargate, so a Fluent Bit DaemonSet won't work.
# Use Fargate's built-in log router: create the namespace + aws-logging ConfigMap.
apiVersion: v1
kind: Namespace
metadata:
name: aws-observability
labels: { aws-observability: enabled }
---
apiVersion: v1
kind: ConfigMap
metadata:
name: aws-logging
namespace: aws-observability
data:
output.conf: |
[OUTPUT]
Name cloudwatch_logs
Match *
region us-east-1
log_group_name /aws/eks/ai-platform/fargate
auto_create_group true
Step 6 · Validate, then remove the EC2 capacity — with a rollback ready
Only now — after Fargate Pods are serving real traffic and you've watched the metrics — do you remove the EC2 nodes. Removing them is the point of no easy return, so decide your rollback first: keep the EC2 node group definition in your IaC (§14) so re-creating it is one command, and don't delete it until a full business cycle (a peak, a deploy, a scale event) has passed on Fargate. Drain gracefully so in-flight requests finish.
fargate-6-finish.sh# Validate on Fargate under real conditions BEFORE deleting EC2:
kubectl -n ai get pods -o wide # all serving Pods on fargate-*
kubectl -n ai get endpoints web # ready targets present
# ...watch dashboards/SLOs (K8) across a peak + a deploy before proceeding.
# Drain gracefully (finishes in-flight work) then delete the EC2 node group:
eksctl delete nodegroup --cluster ai-platform --name general --drain
# ROLLBACK: if Fargate misbehaves, recreate the node group from your version-controlled
# config and re-roll — this is why you keep the definition in git and don't hand-delete it:
eksctl create nodegroup -f cluster-hardened.yaml
11 · Durable data: EBS, EFS, StatefulSets, encryption at rest
Here's the sentence that ends careers: "the Pod restarted and the data was gone." A Pod's own filesystem is ephemeral — it dies with the Pod, every time, by design. That's fine for stateless web tiers, but the moment you process critical data you need storage that outlives the Pod, survives a reschedule to another node or AZ, and is encrypted at rest. On EKS that means wiring up the AWS storage that Kubernetes doesn't ship with by default.
Two AWS storage types cover almost everything, and choosing wrong causes outages. EBS is a fast block volume attached to one node at a time — ReadWriteOnce (RWO) — perfect for a single database Pod that wants low-latency disk, but it's tied to one Availability Zone, so a Pod using it can only reschedule within that AZ. EFS is a shared network filesystem — ReadWriteMany (RWX), multi-AZ — slower, but many Pods across zones can mount it at once, and (as §10 noted) it's the only option on Fargate. The analogy: EBS is an external drive you physically plug into one laptop; EFS is a shared network drive the whole office mounts. Each is installed as a CSI driver add-on (the standard interface between Kubernetes and a storage backend), and you request storage through a StorageClass + PersistentVolumeClaim rather than provisioning disks by hand.
For stateful workloads themselves, a plain Deployment is the wrong shape — replicas are interchangeable and share nothing, which is meaningless for a database. A StatefulSet gives each replica a stable identity and its own persistent volume that follows it across reschedules, which is what a database or queue actually needs. And critical-data hygiene: turn on encryption at rest in the StorageClass (EBS/EFS both support KMS encryption) so the data on disk is unreadable if the volume is ever exposed.
Two halves across the dashed line: the red left is ephemeral storage, the green/blue right is durable storage via a claim.
- On the left, data lives on the Pod's local disk; when the Pod restarts, the red arrow leads to an empty disk — the data is gone.
- On the right, the Pod mounts a PVC bound to a KMS-encrypted EBS volume; the volume is a separate object, so restarts and reschedules keep the data.
- The payoff is the bottom-right box: durable storage survives the Pod, which is the whole point for anything holding critical data.
In short: never keep critical data on a Pod's local disk — bind it to an EBS (RWO) or EFS (RWX) volume through a PVC, encrypted with KMS.
ebs-csi.sh# The EBS CSI driver needs its own scoped IAM role (IRSA, section 4) to create volumes:
eksctl create iamserviceaccount --cluster ai-platform --namespace kube-system \
--name ebs-csi-controller-sa \
--attach-policy-arn arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy --approve
eksctl create addon --cluster ai-platform --name aws-ebs-csi-driver
# (EFS is analogous: AmazonEFSCSIDriverPolicy + aws-efs-csi-driver add-on.)
stateful.yamlapiVersion: storage.k8s.io/v1
kind: StorageClass
metadata: { name: gp3-encrypted }
provisioner: ebs.csi.aws.com
parameters:
type: gp3
encrypted: "true" # KMS encryption at rest (uses the EBS default key)
# kmsKeyId: arn:aws:kms:... # or your own CMK
volumeBindingMode: WaitForFirstConsumer # bind in the AZ where the Pod lands
reclaimPolicy: Retain # keep the volume if the claim is deleted (data safety)
---
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: db, namespace: ai }
spec:
serviceName: db
replicas: 3
selector: { matchLabels: { app: db } }
template:
metadata: { labels: { app: db } }
spec:
containers:
- name: db
image: <acct>.dkr.ecr.us-east-1.amazonaws.com/db:1.0.0
volumeMounts: [{ name: data, mountPath: /var/lib/db }]
volumeClaimTemplates: # each replica gets its OWN persistent volume
- metadata: { name: data }
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: gp3-encrypted
resources: { requests: { storage: 50Gi } }
reclaimPolicy: Retain for anything holding real data: with the default Delete, removing a PersistentVolumeClaim destroys the underlying volume and its data. Verify current CSI, StorageClass, and reclaim behavior against the EKS/Kubernetes docs.12 · Backup and disaster recovery: assume it will fail
Multi-AZ is not a backup, and a running cluster is not a recovery plan. Spreading Pods across zones (§9) protects you from a zone failing — it does nothing against the things that actually destroy data day-to-day: a bad migration that drops a table, an operator who kubectl deletes the wrong namespace, ransomware, or a region-wide event. Production means you can restore, and you know how long that takes, because you've practiced it. A backup you have never restored is a hope, not a backup.
Two independent things need protecting, and people forget the first. Your persistent volumes (the data from §11) are backed up with EBS/EFS snapshots — point-in-time copies stored in S3, ideally automated on a schedule via AWS Backup and copied to a second region. But your cluster state — the Deployments, ConfigMaps, Services, RBAC, the shape of the whole system — also needs capturing, and that's what Velero does: it backs up Kubernetes objects (and can orchestrate volume snapshots at the same time) to an S3 bucket, so you can restore a namespace, or an entire cluster, into a fresh EKS cluster after a disaster.
The two numbers that turn "we have backups" into an actual plan are RPO and RTO. RPO (Recovery Point Objective) is how much data you can afford to lose — it dictates backup frequency (hourly snapshots = up to an hour of loss). RTO (Recovery Time Objective) is how long you can afford to be down — it dictates your restore strategy (restoring 2TB from snapshots takes real time; a warm standby in another region is faster but costs more). Pick these numbers with the business before an incident, then design backups to meet them — and rehearse the restore on a schedule so RTO is a measured fact, not a guess.
velero.sh# Install Velero with an S3 backup target (needs an IAM role scoped to that bucket + snapshots):
velero install --provider aws --bucket my-eks-backups --backup-location-config region=us-east-1 \
--snapshot-location-config region=us-east-1 --use-node-agent
# Scheduled daily backup of the "ai" namespace, kept 30 days:
velero schedule create ai-daily --schedule="0 3 * * *" --include-namespaces ai --ttl 720h
# On disaster, restore into a fresh cluster:
velero backup create ai-now --include-namespaces ai # ad-hoc backup
velero restore create --from-backup ai-daily-20260924030000
# TEST restores on a schedule — an untested backup is not a backup.
| Protect against | Mechanism |
|---|---|
| Volume data loss / corruption | EBS/EFS snapshots via AWS Backup, on a schedule, copied cross-region |
| Deleted/broken cluster objects | Velero backups of Kubernetes state to S3 (namespace or whole-cluster) |
| Whole-region outage | Cross-region snapshot copies + IaC (§14) to rebuild the cluster elsewhere |
| "How much can we lose / be down?" | Agree RPO (backup frequency) and RTO (restore strategy) with the business |
13 · Stay up 24/7: the reliability primitives
A cluster that's secure and has durable data can still page you at 3am — because "always on" is its own engineering problem. Between routine deploys, node upgrades, autoscaling, and the occasional AZ blip, your Pods are constantly being stopped and rescheduled. Whether users notice depends entirely on a handful of Kubernetes primitives that most tutorials mention once and never wire up. For a workload processing critical data around the clock, these are not optional polish — they are the difference between a rolling deploy nobody notices and a five-minute outage.
Five settings carry most of the weight. Readiness and liveness probes (from K3) tell Kubernetes when a Pod can take traffic and when it's wedged and needs restarting — without them, the ALB sends requests to Pods that aren't ready and traffic to a hung process. Resource requests and limits let the scheduler place Pods safely and stop one greedy Pod from starving its neighbors (on Fargate, §10, requests are your bill). PodDisruptionBudgets cap how many replicas can be voluntarily taken down at once, so a node drain during an upgrade can't take your whole service offline. Anti-affinity / topology spread stops all your replicas from landing on one node or in one AZ, so a single failure degrades instead of downs you. And graceful shutdown — a preStop hook plus honoring SIGTERM — lets a Pod finish in-flight requests before it dies, so a deploy doesn't drop the requests that were mid-flight.
One EKS-specific trap deserves its own callout because it causes real, confusing outages: the VPC CNI assigns every Pod a real VPC IP address, and each instance type can only attach a limited number. On a busy cluster you can run out of Pod IPs — not CPU, not memory, IP addresses — and new Pods sit Pending with an error that doesn't obviously say "out of IPs." Plan subnet sizing and prefix delegation for the Pod density you expect.
reliable-deploy.yamlspec:
replicas: 3
template:
spec:
terminationGracePeriodSeconds: 45 # give in-flight work time to finish
containers:
- name: web
readinessProbe: # only READY Pods get ALB traffic
httpGet: { path: /healthz, port: 8080 }
periodSeconds: 5
livenessProbe: # restart a wedged Pod
httpGet: { path: /livez, port: 8080 }
periodSeconds: 10
lifecycle:
preStop: # stop taking new traffic, drain, then exit
exec: { command: ["sh","-c","sleep 10"] }
resources:
requests: { cpu: "500m", memory: "512Mi" }
limits: { cpu: "1", memory: "1Gi" }
topologySpreadConstraints: # spread replicas across AZs
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector: { matchLabels: { app: web } }
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: web-pdb }
spec: { minAvailable: 2, selector: { matchLabels: { app: web } } }
Pending with a networking error, not an obvious "out of capacity" — a genuinely confusing 3am page. Size your subnets for peak Pod count and enable prefix delegation to pack more IPs per node. Verify current CNI limits and prefix-delegation setup against the EKS docs.14 · No click-ops: Infrastructure as Code and GitOps
Every command in this lesson so far has been imperative — you typed it, it happened, and nothing recorded why. That's fine for learning, and fatal for production. A cluster built by hand is a cluster nobody can rebuild, audit, or safely change: the knowledge lives in someone's shell history, drift accumulates silently, and disaster recovery (§12) is impossible because there's no source of truth to recreate from. The final step to production-grade is making the cluster reproducible — defined in version-controlled files, applied by automation, with every change reviewed like code.
Two layers, two tools. For the cluster itself — the VPC, the control plane, node groups, add-ons, IAM — you use Infrastructure as Code: an eksctl config file (the declarative form you've seen throughout), or Terraform for richer, multi-service estates. The whole cluster becomes a file in git; a code review gates every change; and rebuilding in another region for DR is terraform apply, not a week of archaeology. For the workloads on the cluster — Deployments, Services, ConfigMaps — you use GitOps: a controller like Argo CD (covered in K6) continuously reconciles the cluster to match a git repo, so git is the single source of truth. You don't kubectl apply to production; you merge a PR, and the controller rolls it out — and reverts anything that drifts.
The payoff compounds: environment separation (dev/staging/prod as separate configs, promoted by PR), an audit trail (git history answers "who changed what, when, and why" — the same question §8's audit log answers for runtime), instant rollback (revert the commit), and drift detection (the controller flags anything changed out-of-band). This is also what made the §10 Fargate rollback trivial: because the node group lived in a config file, re-creating it was one command, not a scramble.
gitops.sh# The whole cluster is a file in git — apply changes declaratively, never click-ops:
eksctl create cluster -f cluster-hardened.yaml # or: terraform apply
eksctl upgrade cluster -f cluster-hardened.yaml # version bumps go through the file + a PR
# Workloads: don't kubectl apply to prod. Point Argo CD (K6) at the git repo and let it
# reconcile — git is the source of truth, drift is reverted, rollback is 'git revert':
argocd app create ai-platform --repo https://git.example/ai-platform.git \
--path manifests/prod --dest-server https://kubernetes.default.svc --dest-namespace ai
argocd app set ai-platform --sync-policy automated --self-heal --auto-prune
kubectl/eksctl changes to prod are break-glass exceptions, logged and then codified. This is what makes a cluster auditable, rebuildable, and recoverable — the foundation the backup/DR plan (§12) stands on. Verify current eksctl/Terraform/Argo CD workflows against their docs.15 · The production go-live checklist
Before a cluster carries real, critical traffic, walk this list. It's the whole lesson compressed into pass/fail gates — if you can't tick a row, you have a known gap, not a surprise. Nothing here is exotic; every item is one of the sections above. Print it, and don't go live with a red row you haven't consciously accepted.
| Area | Gate — you're production-ready when… | § |
|---|---|---|
| Networking | Nodes are in private subnets; API endpoint is private or CIDR-restricted (never 0.0.0.0/0) | 6 |
| Data-plane security | KMS secret encryption on; IMDSv2 enforced (hop limit 1); no SSH (SSM only) | 7 |
| Identity | Pods get AWS access via IRSA/Pod Identity, never the node role; least-privilege RBAC | 4, 8 |
| Observability | Control-plane audit + authenticator logs on; metrics + alerts wired (K8) | 8 |
| Threat detection | GuardDuty EKS Protection on; image scanning in ECR; Pod Security Standards enforced | 8 |
| Scaling | Autoscaling (Karpenter/CA) configured; workloads spread across ≥3 AZs | 9 |
| Durable data | Critical data on encrypted EBS/EFS via PVCs; StatefulSets where needed; Retain reclaim | 11 |
| Backup & DR | Velero + volume snapshots scheduled & cross-region; restore tested; RPO/RTO agreed | 12 |
| Reliability | Probes, requests/limits, PDBs, topology spread, graceful shutdown on every critical workload | 13 |
| Reproducibility | Cluster in IaC; workloads via GitOps; no manual prod changes; env separation | 14 |
| Upgrades | Control plane first, then nodes; add-on version compatibility checked; PDBs respected | 9 |
| Cost | Right-sized requests; Spot where tolerant; idle-resource & anomaly alarms set | 5, 9 |
16 · End-to-end: from empty AWS account to a live, hardened app
Everything above taught one concept at a time. This section is the opposite: a single, ordered path you follow top to bottom to actually ship. Sections 1–15 are the reference — the "why" and the alternatives for each piece. This is the runbook — the exact sequence, with pointers back to the section that explains each step. Do them in order; each one assumes the previous succeeded. Where a step has a "basic vs hardened" choice, this path takes the production option and links back to the trade-off.
It's a snake: read the top row left→right to build the platform, then the bottom row right→left to run and retire it.
- The top row (blue/purple) is one-time setup: prerequisites, the hardened cluster, cluster add-ons + IRSA, and pushing your image to ECR.
- The bottom row (green/amber/red) is the app and its life: deploy it, expose it through the ALB, operate it (observability + backups), and finally tear it down.
- The payoff: every box is a step below with the exact commands and a link back to the section that explains why — so this is a checklist you can execute, not just read.
In short: build left-to-right across the top, operate and tear down along the bottom — one continuous path from empty account to live app and back.
Step 0 · Prerequisites (once per workstation)
Before anything, install and authenticate the tools. This path assumes an AWS account you can create resources in and a Docker image to deploy (any small web app works).
wt-0-prereqs.sh# Install: awscli, eksctl, kubectl, helm, docker (see each tool's current install docs).
aws configure # set an IAM user/role with permissions to create EKS/VPC/IAM
aws sts get-caller-identity # confirm you're authenticated as who you expect
# Pick and export the basics you'll reuse:
export AWS_REGION=us-east-1 CLUSTER=ai-platform
Step 1 · Create the hardened cluster (§6, §7, §8)
Don't start from the §2 quickstart for production — start from the hardened config: private nodes, a restricted API, KMS secret encryption, and control-plane audit logging, all in one file. This is the §6/§7/§8 configs combined.
wt-1-cluster.yamlapiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata: { name: ai-platform, region: us-east-1, version: "1.30" }
vpc:
nat: { gateway: HighlyAvailable }
clusterEndpoints: { publicAccess: true, privateAccess: true }
publicAccessCIDRs: [ "203.0.113.10/32" ] # your office/VPN egress only
secretsEncryption: { keyARN: "arn:aws:kms:us-east-1::key/" }
cloudWatch:
clusterLogging: { enableTypes: ["audit","authenticator","api","controllerManager","scheduler"] }
managedNodeGroups:
- name: general
instanceType: m6i.large
minSize: 2
maxSize: 6
desiredCapacity: 3
privateNetworking: true # nodes in PRIVATE subnets (§6)
disableIMDSv1: true # enforce IMDSv2 (§7)
iam: { withOIDC: true } # required for IRSA (§4)
wt-1-create.sheksctl create cluster -f wt-1-cluster.yaml # ~15-20 min
aws eks update-kubeconfig --name $CLUSTER --region $AWS_REGION
kubectl get nodes # nodes Ready, in private subnets
Step 2 · Cluster add-ons and Pod identity (§4, §8, §11, §5)
A bare cluster can't yet pull private images cleanly, give Pods AWS access, provision storage, or serve internet traffic. Install the pieces the app will need.
wt-2-addons.sh# Durable storage (§11): EBS CSI driver with its own IRSA role
eksctl create iamserviceaccount --cluster $CLUSTER --namespace kube-system \
--name ebs-csi-controller-sa \
--attach-policy-arn arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy --approve
eksctl create addon --cluster $CLUSTER --name aws-ebs-csi-driver
# AWS Load Balancer Controller (§5) — install via Helm, grant it IRSA to manage ELBs (see its docs)
helm repo add eks https://aws.github.io/eks-charts && helm repo update
# helm install aws-load-balancer-controller eks/aws-load-balancer-controller -n kube-system --set clusterName=$CLUSTER ...
# App's own AWS access (§4): a least-privilege ServiceAccount for the workload
eksctl create iamserviceaccount --cluster $CLUSTER --namespace ai --name web-sa \
--attach-policy-arn arn:aws:iam::aws:policy/AmazonBedrockReadOnly --approve
kubectl create namespace ai 2>/dev/null || true
Step 3 · Build and push your image to ECR (§5)
wt-3-ecr.shACCT=$(aws sts get-caller-identity --query Account --output text)
REPO=$ACCT.dkr.ecr.$AWS_REGION.amazonaws.com/web
aws ecr create-repository --repository-name web 2>/dev/null || true
aws ecr get-login-password --region $AWS_REGION \
| docker login --username AWS --password-stdin $ACCT.dkr.ecr.$AWS_REGION.amazonaws.com
docker build -t $REPO:1.0.0 .
docker push $REPO:1.0.0 # never :latest in production
Step 4 · Deploy the app with storage + reliability (§11, §13)
Deploy with the reliability block from §13 (probes, requests/limits, PDB, topology spread, graceful shutdown) and the ServiceAccount from Step 2. Add a PersistentVolumeClaim only if the app is stateful (§11).
wt-4-app.yamlapiVersion: apps/v1
kind: Deployment
metadata: { name: web, namespace: ai }
spec:
replicas: 3
selector: { matchLabels: { app: web } }
template:
metadata: { labels: { app: web } }
spec:
serviceAccountName: web-sa # IRSA-scoped AWS access (§4)
terminationGracePeriodSeconds: 45 # graceful shutdown (§13)
containers:
- name: web
image: .dkr.ecr.us-east-1.amazonaws.com/web:1.0.0
ports: [{ containerPort: 8080 }]
readinessProbe: { httpGet: { path: /healthz, port: 8080 }, periodSeconds: 5 }
livenessProbe: { httpGet: { path: /livez, port: 8080 }, periodSeconds: 10 }
resources:
requests: { cpu: "500m", memory: "512Mi" }
limits: { cpu: "1", memory: "1Gi" }
topologySpreadConstraints: # spread across AZs (§13)
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector: { matchLabels: { app: web } }
---
apiVersion: v1
kind: Service
metadata: { name: web, namespace: ai }
spec:
selector: { app: web }
ports: [{ port: 80, targetPort: 8080 }]
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: web-pdb, namespace: ai }
spec: { minAvailable: 2, selector: { matchLabels: { app: web } } }
wt-4-apply.shkubectl apply -f wt-4-app.yaml
kubectl -n ai rollout status deployment/web --timeout=180s
kubectl -n ai get pods -o wide # 3 Pods Running, spread across AZs
kubectl -n ai get endpoints web # only READY Pods listed
Step 5 · Expose it to the internet via an ALB (§5)
wt-5-ingress.yamlapiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web
namespace: ai
annotations:
kubernetes.io/ingress.class: alb
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
spec:
rules:
- http: { paths: [ { path: /, pathType: Prefix, backend: { service: { name: web, port: { number: 80 } } } } ] }
wt-5-check.shkubectl apply -f wt-5-ingress.yaml
kubectl -n ai get ingress web # ADDRESS = the ALB DNS name (allow 1-2 min)
# add TLS with ACM/cert-manager and a Route 53 record for a real domain.
Step 6 · Operate it: observability + backups (§8, §12)
Control-plane audit logging is already on from Step 1. Add workload metrics/logs (K8) and schedule backups (§12) so the cluster is debuggable and recoverable before it carries real load.
wt-6-operate.sh# Metrics: enable CloudWatch Container Insights (or install Prometheus+Grafana) — see K8.
# Backups (§12): install Velero to S3, then schedule the namespace daily:
velero schedule create ai-daily --schedule="0 3 * * *" --include-namespaces ai --ttl 720h
velero backup create ai-now --include-namespaces ai # take one now and TEST a restore
# Cost: set a budget alarm and watch for idle nodes/forgotten load balancers (§5, §9).
target-type: ip (already set above) and logging, then drain the EC2 node group with a rollback ready. The GPU/stateful tiers stay on EC2. This slots in between Steps 6 and 7 here.Step 7 · Tear down (stop the billing)
The meter runs until you delete. When you're done — especially with a learning cluster — remove everything in one command; because eksctl built it as one CloudFormation stack, it deletes as one.
wt-7-teardown.sh# Remove app-created AWS resources first if they won't be cascade-deleted (ELBs from Ingress):
kubectl delete -f wt-5-ingress.yaml # lets the controller delete the ALB
# Then the whole cluster (control plane, nodes, VPC eksctl made) in one stack:
eksctl delete cluster --name $CLUSTER --region $AWS_REGION
# Check the console for anything left behind: EBS volumes (Retain!), snapshots, ECR images, KMS key.
reclaimPolicy: Retain (§11), deleting the cluster does not delete your EBS volumes or snapshots — that's the point, so a mistaken teardown never destroys data. Clean them up separately when you're truly finished, and don't forget cross-region snapshot copies, ECR images, and the KMS key. Verify current teardown behavior and residual-resource billing against the AWS docs.✓ Checkpoint — you can move on when you can…
- State what EKS manages (control plane) and what you still own (nodes, add-ons, cost, upgrades).
- Create a cluster with an eksctl config and point
kubectlat it. - Choose managed node groups vs Fargate for a given workload, including the GPU constraint.
- Explain IRSA and why node-role permissions are the wrong approach.
- Push an image to ECR and expose a service via the AWS Load Balancer Controller.
- Put nodes in private subnets and lock the API endpoint to a private or allow-listed access.
- Encrypt secrets with KMS, enforce IMDSv2, and reach nodes via SSM instead of SSH.
- Turn on control-plane audit logging and name the AWS-native guards (GuardDuty, per-Pod SGs, PSS).
- Choose a scaling strategy (Karpenter, Spot, multi-AZ) and state the correct upgrade order.
- Migrate a workload from EC2 to Fargate with a real runbook — audit, right-size, canary cutover, and rollback.
- Give stateful workloads durable, encrypted storage (EBS/EFS via PVCs, StatefulSets) with a safe reclaim policy.
- Back up cluster state and volumes (Velero + snapshots), and state your RPO/RTO and why a tested restore matters.
- Name the reliability primitives (probes, PDBs, topology spread, graceful shutdown) and the VPC-CNI IP-exhaustion trap.
- Explain why production clusters are defined in IaC and reconciled by GitOps rather than built by hand.
- Walk the go-live checklist and identify any row your cluster can't yet pass.
- Execute the end-to-end runbook (§16) start to finish — create, deploy, expose, operate, and tear down.
A teammate grants their Pod access to S3 by attaching AmazonS3FullAccess to the EKS node group's instance role. It works. Why is this a serious mistake, and what's the correct approach on EKS?
Show answer
serviceAccountName on only the Pods that need it. Now credentials are least-privilege and per-workload. (EKS Pod Identity is a newer alternative — check current docs.)You need to run GPU-based LLM inference (K7) and also have a bursty webhook handler. Which node model fits each on EKS, and why?
Show answer
An auditor flags that your EKS nodes have public IPs and the cluster API endpoint is reachable from 0.0.0.0/0. What's the exposure, and what's the professional fix?
Show answer
privateNetworking: true) so they have no public IP and reach the internet only outbound through a NAT gateway; (2) make the API endpoint private-only, or if it must stay public, restrict it with a tight publicAccessCIDRs allow-list of your office/VPN/CI ranges. Enabling private access places the control-plane ENIs in your VPC so in-cluster kubectl uses private address space. Verify current endpoint behavior against the EKS docs.You created a Fargate profile for your namespace and deleted the EC2 node group, but CoreDNS is stuck Pending, your logging DaemonSet disappeared, and the ALB can't reach your Pods. What did you miss?
Show answer
eks.amazonaws.com/compute-type: ec2 annotation — remove that annotation and restart it so it reschedules onto Fargate. (2) Fargate runs no DaemonSets, so a Fluent Bit DaemonSet won't run — use Fargate's built-in log router (the aws-logging ConfigMap in an aws-observability namespace) instead. (3) Fargate has no node ports, so the ALB Ingress must use alb.ingress.kubernetes.io/target-type: ip to target Pod IPs directly. Also remember Fargate has no GPUs, no privileged containers, and rounds each Pod's vCPU/memory up to the nearest size. Verify current Fargate limits against the EKS docs.🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Getting from zero to a reachable managed cluster is the first EKS milestone — and knowing how to tear it down is the second.
Your task: Write the eksctl config for a small cluster and the commands to create it, point kubectl at it, and delete it when done.
Requirements:
- A managed node group with 2–4 nodes
- Enable OIDC (needed for IRSA later)
- Show update-kubeconfig and a get nodes check
- Show the delete command and say why it matters
💡 Hint: Enabling withOIDC now saves you re-provisioning later for IRSA.
Show solution
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata: { name: demo, region: us-east-1, version: "1.30" }
iam: { withOIDC: true }
managedNodeGroups:
- { name: general, instanceType: m6i.large, minSize: 2, maxSize: 4, desiredCapacity: 2 }eksctl create cluster -f cluster.yaml
aws eks update-kubeconfig --name demo --region us-east-1
kubectl get nodes
eksctl delete cluster --name demo # stops the billingWhy delete matters: the control plane bills hourly and nodes bill continuously, so a forgotten demo cluster quietly runs up a bill. Enabling withOIDC up front means the cluster is ready for IRSA without re-provisioning. Verify the supported version against current EKS docs.
Note — this is a learning cluster: with no privateNetworking flag these nodes land in public subnets and the API is public. That's deliberate here (fewest moving parts), but a production cluster must add privateNetworking: true and a locked-down API endpoint — see sections 6–13.
Context: Your registry is where deploys pull from; wiring ECR correctly is a daily EKS task.
Your task: Give the commands to create an ECR repo, authenticate, build and push an image, then the Deployment snippet that references it.
Requirements:
- Create the repo and log docker in to ECR
- Build and push a version-tagged image (never :latest)
- Reference the full ECR image URI in a Deployment
💡 Hint: The image URI is <acct>.dkr.ecr.<region>.amazonaws.com/<repo>:<tag>.
Show solution
aws ecr create-repository --repository-name web
aws ecr get-login-password --region us-east-1 \
| docker login --username AWS --password-stdin .dkr.ecr.us-east-1.amazonaws.com
docker build -t .dkr.ecr.us-east-1.amazonaws.com/web:1.0.0 .
docker push .dkr.ecr.us-east-1.amazonaws.com/web:1.0.0 Reference the pushed image by its full URI, pinned to the version tag:
containers:
- name: app
image: .dkr.ecr.us-east-1.amazonaws.com/web:1.0.0 EKS nodes can pull from ECR in the same account by default via their instance role; cross-account or private setups need an image-pull configuration — verify against current ECR/EKS docs.
Context: IRSA is the correct, least-privilege way to give a Pod AWS access; setting it up is a core EKS competency.
Your task: Give the steps to let only a specific Pod read from Bedrock via IRSA, and explain why this beats node-role permissions.
Requirements:
- Ensure the cluster has OIDC enabled
- Create an IAM-backed ServiceAccount scoped to the needed policy
- Attach it to the Pod and explain the credential path
- Contrast with node-role permissions
💡 Hint: eksctl can create the role, trust policy, and annotated ServiceAccount in one command.
Show solution
eksctl create iamserviceaccount \
--cluster ai-platform --namespace ai --name bedrock-caller \
--attach-policy-arn arn:aws:iam::aws:policy/AmazonBedrockReadOnly --approveAttach it to the Pod:
spec:
serviceAccountName: bedrock-callerHow it works: the ServiceAccount is annotated with an IAM role ARN; via the cluster's OIDC provider, Pods using it receive short-lived, automatically-rotated credentials for exactly that role — the AWS SDK picks them up with no keys in the image or env.
Why it beats node-role permissions: node-role grants apply to every Pod on the node, violating least privilege; IRSA scopes credentials to a single ServiceAccount, so only the intended workload can call Bedrock and a neighbor Pod can't. Verify whether EKS Pod Identity is a better fit against current docs.
Context: On AWS, Ingress is fulfilled by the AWS Load Balancer Controller provisioning a real ALB; using it correctly is how EKS apps face the internet.
Your task: Describe how to expose a web Service through an internet-facing ALB via an Ingress, and name the prerequisite and the annotation caveat.
Requirements:
- State the controller prerequisite
- Write an ALB Ingress with the key annotations
- Explain target-type ip vs instance briefly
- Note that annotations are version-specific
💡 Hint: The controller must be installed (Helm) and have IRSA permissions to manage ELBs.
Show solution
Prerequisite: install the AWS Load Balancer Controller (via Helm, K6) and grant it IRSA permissions to create/manage load balancers. Without it, the Ingress is inert.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web
annotations:
kubernetes.io/ingress.class: alb
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
spec:
rules:
- http:
paths:
- { path: /, pathType: Prefix, backend: { service: { name: web, port: { number: 80 } } } }target-type: ip routes the ALB straight to Pod IPs (works with Fargate and is the common modern choice); instance routes to node ports. Caveat: these alb.ingress.* annotations are controller-version-specific — verify each against the controller's current docs rather than copying blindly.
Context: On EKS, node choices and observability decisions directly drive the bill and your ability to debug; a professional plans both up front.
Your task: Design the node-group topology, logging, and cost controls for a platform with steady services, bursty jobs, and one GPU inference workload.
Requirements:
- Assign each workload class to managed nodes or Fargate (with the GPU constraint)
- State the logging/metrics pipeline
- Name at least three concrete cost controls
- Note the upgrade approach and its interaction with PDBs
💡 Hint: GPUs force EC2; bursty stateless favors Fargate; steady baseline favors a small managed group.
Show solution
Node topology: a small managed node group for steady baseline services and system add-ons; Fargate for bursty jobs (pay per Pod, no idle cost, isolation); a GPU managed node group for the inference workload, since Fargate has no GPUs (K7). Autoscale nodes with the Cluster Autoscaler or Karpenter.
Logging/metrics: Fluent Bit (or the CloudWatch add-on) ships container logs to CloudWatch; Container Insights or Prometheus+Grafana for metrics — so you can actually debug what the cluster does.
Cost controls: (1) right-size requests so the scheduler packs nodes tightly; (2) use Spot instances for interruption-tolerant workloads; (3) autoscale to reclaim idle nodes; (4) alarm on idle/forgotten clusters and cap max node counts. Idle nodes and forgotten load balancers are the biggest silent costs.
Upgrades: bump the control-plane version first, then roll node groups; respect PodDisruptionBudgets (K3) so draining nodes for the roll never breaches availability. Verify current pricing, autoscaler, and upgrade steps against AWS docs.
Context: Representative scenario: an app runs on kind locally and must go to EKS for production, with proper AWS integration, exposure, and cost discipline.
Your task: Write the migration plan: what changes from the local manifests, how AWS access and image pulls are handled, how it's exposed, and how it's kept observable and affordable.
Requirements:
- State what's identical and what changes moving local → EKS
- Handle images (ECR) and AWS permissions (IRSA)
- Expose it via the AWS Load Balancer Controller
- Include logging, cost controls, and teardown discipline
💡 Hint: The workload YAML barely changes; what changes is registry, IAM, ingress, and cost.
Show solution
What stays the same: the Deployment, Service, ConfigMap/Secret, probes, resources, HPA, and PDB manifests (K2–K4) are portable — that's the payoff of standard Kubernetes. The workload definition barely changes.
What changes for EKS:
- Cluster: create with eksctl (OIDC enabled), point kubectl at it.
- Images: push to ECR and update image URIs to the ECR path (versioned tags).
- AWS access: replace any local fake credentials with IRSA — a scoped ServiceAccount per workload that needs AWS (S3/Bedrock/etc.), never node-role permissions.
- Exposure: swap the local NodePort/port-forward for an ALB Ingress via the AWS Load Balancer Controller, with TLS (ACM/cert-manager).
- Secrets: move real values into AWS Secrets Manager (via a CSI driver) instead of plain manifests; keep only placeholders in git.
Operate it: ship logs to CloudWatch, add Container Insights/Prometheus metrics, right-size requests, use Spot for tolerant workloads, autoscale nodes, and respect PDBs on upgrades. Put a cost alarm on the cluster and tear down anything non-prod when idle. Verify every AWS-specific command and annotation against the current EKS docs — this is the layer that drifts most.