AI/ML on EKS
The AWS-specific angle on running AI/ML yourself: when EKS beats SageMaker or Bedrock, GPU-node autoscaling with Karpenter, the Inferentia/Trainium (Neuron) accelerators, Spot GPUs and cost governance, and serving models the AWS way with S3 / ECR / IRSA / ALB. Assumes EKS basics (K5) and generic AI-on-K8s (K7) — this lesson does not repeat them.
- An EKS cluster + AWS credentials (
aws configure), pluseksctl/kubectland (for Karpenter) a Karpenter install — all of this costs money (GPU nodes especially). - The YAML/CLI is illustrative and correct in shape, but Karpenter CRDs, Neuron device-plugin manifests, instance types and prices change — verify every one against the current AWS / Karpenter / Neuron docs before relying on it.
Learning objectives
- Decide between EKS, SageMaker, and Bedrock for a given AI workload — and mix them.
- Autoscale GPU nodes with Karpenter (vs Cluster Autoscaler): consolidation and instance flexibility.
- Know when Inferentia/Trainium (Neuron) beats GPU — and the compatibility tax you pay.
- Use Spot GPUs for interruptible AI and make GPU cost on EKS visible and bounded.
- Serve a model the AWS way: pull from S3, images from ECR, access via IRSA, expose via ALB/NLB.
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.
When EKS for AI: the EKS vs SageMaker vs Bedrock decision
Start with the meeting you've sat in. Someone says "let's just run our own model on Kubernetes — we already have a cluster, and it'll be cheaper than paying Bedrock per token." It sounds obviously right. Six weeks later a GPU node has been sitting at 8% utilisation, billing $4/hour around the clock, and the "cheaper" option has quietly out-spent the managed one it replaced — while also consuming an engineer's week to keep the cluster patched. The decision that felt like a no-brainer was actually the most expensive of the three options, chosen for the wrong reason. This section exists so you never make that call by reflex.
Here's the mental model that prevents it: think of the three services as a staircase of control, not a menu of rivals. Renting a hotel room (Bedrock) means you never see the plumbing — you pay per night and walk away. Renting a serviced apartment (SageMaker) gives you your own kitchen but the building still handles maintenance. Buying the house (EKS) gives you total freedom to knock down walls — and total responsibility for the roof, the boiler, and the property tax. Each step down trades less operational burden for more control, and — this is the part people miss — every step down also adds a bill that never sleeps.
So the rule for choosing is deliberately conservative: stay as high on the staircase as your hardest requirement allows. You only descend when the layer above genuinely cannot do the job — not because the lower layer feels more "real" or more in your control. The mistake to name out loud is descending for cost alone: a managed API that scales to zero is almost always cheaper than a GPU you rent by the hour and forget to turn off. The legitimate reasons to drop to EKS are control (a custom serving stack, sidecars, a specific runtime), consolidation (AI sharing one control plane, RBAC, and GitOps with services you already run on k8s), and portability (plain Kubernetes runs anywhere; the managed layers lock you to AWS). Cost only joins that list at genuinely high, sustained utilisation — and even then you have to prove it, not assume it. Keep the staircase in your head as you read the table below.
AWS gives you three very different ways to run AI, and the mistake is treating them as competitors when they are layers. Bedrock is a managed model API — you call a foundation model (W2) and never see a server. SageMaker (W8/W9) is a managed ML platform — you own the model but AWS owns the serving/ training plumbing. EKS is self-run Kubernetes — you own everything down to the node, in exchange for maximum control and portability. You move down that list only when the layer above genuinely cannot meet a hard requirement.
Read it left→right: each box hands off more control (and more ops) to you than the one before it.
- The left box, "Bedrock — managed API", is the least you run: you call a foundation model over an API and AWS owns everything behind it.
- The arrows between the boxes mark the escalation of control — moving right, you take on more of the platform yourself, from the middle "SageMaker — managed platform" box to the right.
- The right box, "EKS — self-run k8s", is the payoff of full control: you run the Kubernetes cluster and the serving stack, trading convenience for portability and customisation.
In short: Bedrock → SageMaker → EKS is a slider from "managed for you" to "self-run", with more control and more ops the further right you go.
| Signal in the requirement | Lands on | Why |
|---|---|---|
| Call an FM (Claude, Titan…) by API; no infra | Bedrock | Pay-per-token, scales to zero, no servers (W2) |
| Host/fine-tune your own model, want managed endpoints | SageMaker | Owns serving, autoscaling, pipelines (W8/W9) |
| Already run everything on Kubernetes; want one control plane | EKS | AI shares the same cluster, RBAC, GitOps, tooling |
| Need custom serving stack (vLLM, KServe, sidecars, Neuron) | EKS | Full control of the runtime, not a fixed endpoint contract |
| Multi-cloud / portability / avoid platform lock-in | EKS | Plain k8s runs anywhere; SageMaker/Bedrock do not |
| Small team, no k8s expertise, wants fastest path | Bedrock/SageMaker | EKS is a lot of undifferentiated ops to carry |
GPU node autoscaling with Karpenter
Picture the failure this solves. A traffic spike hits, ten vLLM pods go Pending because they each want a GPU, and… nothing happens. Your cluster has no spare GPU capacity, and the old autoscaler is staring at a fixed "GPU node group" you sized for average load, not peak. Users wait, pods sit unscheduled, and someone scrambles to bump the group by hand. The opposite failure is just as painful: you over-provision that group "to be safe," the spike never comes, and a rack of idle GPUs bills you all night. The whole point of GPU autoscaling is to escape both traps — capacity that appears when pods need it and vanishes when they don't.
The older tool, Cluster Autoscaler, thinks in terms of node groups you defined in advance — an Auto Scaling Group of, say, "g5.xlarge nodes." It can only add or remove copies of shapes you already chose, like a car park where every space is pre-marked "compact only." That's fine until a pod needs a shape you didn't pre-mark, or until you're paying for half-empty groups. Karpenter flips the model: instead of scaling pre-cut groups, it reads the pending pods directly, works out the cheapest EC2 instance type from a set you allow that actually fits what they asked for, and launches exactly that — a valet who parks each car in the perfect-sized bay rather than forcing everyone into "compact." When load falls, it consolidates: it repacks the surviving pods onto fewer nodes and terminates the ones left under-used, so you stop paying for slack.
Why this matters so much for AI specifically: GPU instances are the most expensive things you'll ever schedule, so the cost of a wrong-sized or idle node is brutal, and GPU demand is spiky (a burst of inference, a training job that ends). Karpenter's just-in-time provisioning and aggressive consolidation are therefore not just a convenience — they are the mechanism that keeps GPU spend bounded. You describe the rules with two custom resources: a NodePool (the Kubernetes-facing constraints — which instance families are allowed, Spot vs on-demand, a hard GPU cap, how eagerly to consolidate) and an EC2NodeClass (the AWS-level facts — which AMI, which subnets, which node IAM role). Keep that split in mind reading the YAML: NodePool = what to schedule, EC2NodeClass = how to build the box.
K7 covered scheduling a pod onto a GPU node; the AWS question is where do the GPU nodes come from. The classic answer is Cluster Autoscaler, which scales pre-defined node groups (Auto Scaling Groups) up and down. Karpenter is the AWS-native alternative: instead of fixed groups, it looks at pending pods and provisions right-sized EC2 instances directly, picking instance types from a flexible set — then consolidates (repacks and terminates under-used nodes) as load falls.
| Cluster Autoscaler | Karpenter | |
|---|---|---|
| Unit it scales | Pre-defined node groups (ASGs) | Individual EC2 instances, chosen just-in-time |
| Instance-type flexibility | Fixed per group | A wide set you allow; picks per pending pod |
| Consolidation | Limited | Actively repacks & removes under-used nodes |
| GPU fit | Must pre-size GPU groups | Provisions the GPU shape the pending pod needs |
| Spot handling | Via ASG mixed instances | First-class: Spot + on-demand in one NodePool |
For GPU AI that means: a burst of pending vLLM pods requesting nvidia.com/gpu can cause Karpenter to launch, say, a g5 instance in seconds; when the burst clears, consolidation tears it back down so you are not paying for an idle GPU. You express what is allowed with two CRDs — a NodePool (scheduling constraints/limits) and an EC2NodeClass (the AWS-level details: AMI, subnets, IAM role).
Follow the arrows left→right for the scale-up, then the curved arrow back down for the scale-down.
- The red boxes on the left are unscheduled
Pendingpods, each asking for a GPU — the cluster has no free capacity, so they sit and wait. - The middle Karpenter box reads those pending pods directly and picks the cheapest EC2 instance from your allowed set that actually fits their request — no pre-defined node group needed.
- The green node on the right is that right-sized GPU instance, launched just-in-time so the pods schedule.
- The amber curved arrow back to "repack + terminate" is the payoff: when load falls, Karpenter consolidates — repacking surviving pods onto fewer nodes and killing the under-used one, so idle GPU spend stops.
In short: Pending pods in → right-sized GPU node out → idle node consolidated away. Capacity appears when pods need it and vanishes when they don't.
gpu-nodepool.yamlapiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: gpu
spec:
template:
spec:
# only launch GPU instance families; let Karpenter pick the cheapest that fits
requirements:
- key: karpenter.k8s.aws/instance-family
operator: In
values: ["g5", "g6", "p4d", "p5"] # verify families/availability in your region
- key: karpenter.sh/capacity-type
operator: In
values: ["spot", "on-demand"] # allow both; Spot preferred when available
taints:
- key: nvidia.com/gpu # repel non-GPU pods (K7)
effect: NoSchedule
nodeClassRef:
group: karpenter.k8s.aws
kind: EC2NodeClass
name: gpu
limits:
nvidia.com/gpu: 16 # hard cap on total GPUs this pool may create (cost guardrail)
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 2m # reclaim idle/under-used GPU nodes quickly
---
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
name: gpu
spec:
amiFamily: AL2023 # verify the current GPU-capable AMI family/alias
role: "KarpenterNodeRole-my-cluster" # the node IAM role (placeholder)
subnetSelectorTerms: [{ tags: { "karpenter.sh/discovery": "my-cluster" } }]
securityGroupSelectorTerms: [{ tags: { "karpenter.sh/discovery": "my-cluster" } }]
karpenter.sh/v1, karpenter.k8s.aws/v1), field names, and which GPU families (g5/g6/p4d/p5) exist and are available in your region have all changed across Karpenter releases. Treat the YAML above as shape, not gospel — check the current Karpenter docs and your region's instance availability. The portable idea is: constrain to GPU families, allow Spot, cap total GPUs, consolidate aggressively.limits: nvidia.com/gpu: 16 is the cheapest insurance on this page: it hard-caps how many GPUs the pool can ever spin up, so a runaway HPA or a bad manifest can't provision 200 GPUs overnight. Pair it with consolidation and you bound both the count and the idle time.AWS AI accelerators: Inferentia & Trainium (Neuron)
Here's the pitch that gets a finance director excited and an engineer nervous. "We can cut our inference bill by a third by moving off NVIDIA GPUs onto Amazon's own chips." It can be true — and it can also be a two-week detour that ends in "our model doesn't compile." The tension in this section is exactly that gap between a real cost win and a real compatibility tax, and the skill is knowing which one you're walking into before you commit a platform to it.
The analogy that keeps it straight: a GPU (NVIDIA, via CUDA) is a Swiss Army knife — it runs almost any model you throw at it because the whole ML world has spent a decade building for CUDA. Amazon's custom silicon — Inferentia (the inf2 instances, tuned for inference) and Trainium (trn1 and newer, tuned for training) — is more like a specialised power tool: cheaper and faster per unit of work for the jobs it was built for, useless for the ones it wasn't. You program these chips through the Neuron SDK, and the critical difference from a GPU is that you don't just load your model and run — you must compile it with the Neuron compiler first, ahead of time, into a form the chip understands.
That compile step is where the tax lives, and it's the mistake to name loudly: not every model or operator is supported. If your architecture and its ops are on the supported list, compilation is a one-time build step and you're rewarded with better tokens-per-dollar. If it isn't — an exotic layer, a newer architecture the compiler hasn't caught up to — you can hit a wall that no amount of config fixes, and the "savings" evaporate into engineering time. On EKS the scheduling side is reassuringly familiar: just as K7 used a device plugin to advertise nvidia.com/gpu, a Neuron device plugin (a DaemonSet) advertises the chips as aws.amazon.com/neuron, and pods request that resource instead. Same Kubernetes pattern, different resource name. The honest posture: treat Neuron as a genuine lever for high-volume, steady inference on a supported model — and always prototype the compile-and-load-test step on your actual model before you build anything around it. The table and lab below show the trade-off and the scheduling glue.
GPUs are not the only silicon on AWS. Amazon builds custom AI chips: Inferentia (inf2 instances) for inference and Trainium (trn1/newer) for training. You program them through the Neuron SDK, and on EKS a Neuron device plugin advertises the chips as a schedulable resource — the same idea as the NVIDIA device plugin in K7, but the resource is aws.amazon.com/neuron instead of nvidia.com/gpu.
| Dimension | GPU (g5/p4/p5) | Neuron (inf2/trn1) |
|---|---|---|
| Schedulable resource | nvidia.com/gpu | aws.amazon.com/neuron |
| Best at | General; broad model/framework support | Cost/throughput on supported models |
| Toolchain | CUDA — huge ecosystem | Neuron SDK — you compile the model first |
| Model compatibility | Runs almost anything | Must be supported/compilable for Neuron |
| Portability | Runs on any GPU cloud | AWS-only silicon |
The pitch is cost and throughput: for a model that Neuron supports well, inf2 can serve more tokens per dollar than a comparable GPU. The tax is real, though: you must compile the model with the Neuron compiler ahead of time, and not every model/operator is supported — the ecosystem is narrower and younger than CUDA's. It is a genuine win for high-volume inference on a supported architecture, and a frustrating dead end if your model isn't supported. Prototype the compile step before committing.
Read the two lanes top and bottom: the top is the GPU path, the bottom is the Neuron path for the same model.
- The top lane shows a model flowing straight into a green "runs directly" box — CUDA's decade-old ecosystem means almost anything just runs, scheduled as
nvidia.com/gpu. - The bottom lane inserts a purple compile step (the Neuron compiler) that every model must pass through before it can run — there is no "just load and go".
- The branch after compile is the whole risk: a green "supported → serves" path (better tokens-per-dollar) or a red "unsupported → wall" path where an exotic op or newer architecture the compiler hasn't caught up to simply fails.
In short: GPU = run anything now; Neuron = compile first, then win on cost if your model is supported. Prototype that compile step before you commit a platform to it.
neuron-pod.yaml# The Neuron device plugin (a DaemonSet, like K7's NVIDIA one) must be installed first;
# it advertises aws.amazon.com/neuron as a schedulable resource.
apiVersion: v1
kind: Pod
metadata:
name: neuron-inference
spec:
nodeSelector:
node.kubernetes.io/instance-type: inf2.xlarge # verify the instance type exists in your region
containers:
- name: server
image: <account>.dkr.ecr.<region>.amazonaws.com/my-neuron-model:pinned # compiled for Neuron
resources:
limits:
aws.amazon.com/neuron: 1 # request 1 Neuron device (NOT nvidia.com/gpu)
Spot GPUs & cost governance on EKS
Two stories sit behind this section, and every EKS-AI team lives one of them. The first: an engineer discovers Spot GPUs, moves the customer-facing chat endpoint onto them to save 70%, and three days later AWS reclaims the single node with a two-minute warning — the endpoint goes dark during business hours and the "saving" becomes an incident review. The second, quieter and more common: nobody looks at utilisation for a month, and the invoice reveals a GPU node that ran at 5% the entire time, burning thousands of dollars doing almost nothing. One story is about using the discount recklessly; the other is about not watching the meter at all. This section is the antidote to both.
Start with the discount itself. Spot capacity is EC2's spare inventory sold at a steep discount off on-demand prices — the catch is that AWS can take it back whenever it needs the hardware, giving you only about a two-minute warning before the instance disappears. Think of it as flying standby: dramatically cheaper, but you might get bumped, so it's perfect when being bumped is merely annoying and catastrophic when it isn't. That single property — reclaimable on short notice — cleanly sorts every AI workload into "fine on Spot" or "not." Batch and offline inference, experimentation, and training that checkpoints are all interruptible: a reclaim just reschedules the work or resumes from the last checkpoint, costing minutes. A single-replica, latency-critical endpoint is the opposite: one reclaim is one outage. The professional move for anything in between is a base-plus-burst split — enough on-demand replicas to serve minimum traffic no matter what, with Spot layered on only for the peaks.
But cheap capacity is only half the discipline; the other half is seeing where the money goes, because the deadliest EKS-AI cost isn't a dramatic mistake — it's the idle GPU nobody noticed. You cannot govern what you cannot see, so the order of operations is: make GPU spend visible first (cost-allocation tags so spend is attributable per team and model, CloudWatch or Container Insights for node utilisation, Kubecost for per-namespace Kubernetes cost), then bound it (the Karpenter GPU cap and consolidation from section 2), and only then argue about which service is cheapest. This is why Karpenter's consolidation is a cost feature, not just a scheduling one — it directly attacks idle time, the single biggest silent bill. Keep that hierarchy — survive interruptions, then watch the meter, then compare — as you read the workload table and the offline cost math below.
The single biggest EKS-AI cost lever is Spot capacity: EC2 spare capacity at a large discount off on-demand, in exchange for the fact that AWS can reclaim the instance with a short (~2-minute) warning. That reclaim risk is exactly why Spot fits interruptible AI work — batch/offline inference, experimentation, and fault-tolerant training with checkpointing — but is a poor fit for a single-replica, latency-critical endpoint that can't absorb a sudden node loss.
Start at the top box — the one question that decides everything — then follow the two branches down.
- The top box is the single test: when AWS reclaims the Spot node on ~2 minutes' notice, can the workload absorb it?
- The left green branch ("yes, retryable") lands on pure Spot — batch/offline inference, checkpointed training, and dev, where a reclaim just reschedules or resumes and costs minutes.
- The right red branch ("no, latency-critical") lands on an on-demand base that alone serves minimum traffic, with Spot added only for burst.
- The amber box below is the survival kit that makes even the risky side safe: enough replicas, a PodDisruptionBudget, and draining the node on the 2-minute warning.
In short: One question — "can it survive a 2-minute reclaim?" — routes a workload to pure Spot or to an on-demand base with Spot burst.
| Workload | Spot? | Why |
|---|---|---|
| Batch / offline inference | ✅ yes | Retryable; a lost node just reschedules the job |
| Training with checkpointing | ✅ yes | Resume from the last checkpoint after a reclaim |
| Dev / experimentation | ✅ yes | Interruptions are cheap here |
| User-facing low-latency endpoint | ⚠️ mixed | Keep on-demand base capacity; Spot only for burst |
| Single-replica critical service | ❌ no | One reclaim = an outage |
Handle interruptions rather than hope: run enough replicas across instances so losing one is survivable, set a PodDisruptionBudget, and let a node-interruption handler (Karpenter consumes the interruption/rebalance signals natively) drain the node gracefully on the 2-minute warning. For training, checkpoint frequently so a reclaim costs minutes, not the run.
How does GPU-on-EKS cost compare to a SageMaker endpoint or Bedrock tokens? Qualitatively: Bedrock bills per token and scales to zero — cheapest when traffic is spiky or low, no idle cost at all. SageMaker endpoints bill per instance-hour (like a reserved GPU) but AWS runs the serving. EKS is also per instance-hour but you own utilisation — it only beats the others when you keep the GPUs genuinely busy and amortise the ops effort. Idle capacity flips EKS from cheapest to most expensive. The offline calculator in the exercises below makes this concrete; verify current prices before trusting any absolute number.
Serving LLMs on EKS the AWS way
You already know how to serve a model on Kubernetes — K7 taught the mechanics. So why a whole section on doing it "the AWS way"? Because the moment you're on EKS, three mundane-sounding questions turn into security and cost decisions that sink teams: where do the multi-gigabyte weights come from, where does the container image come from, and how does the pod get permission to fetch them? Get the last one wrong and you end up with an AWS access key hard-coded into a Dockerfile — the single most common way cloud credentials leak, and a finding that fails every security audit. This section is the AWS glue around the generic serving you already understand.
The picture to hold is a supply chain with a security checkpoint. The weights — often tens of gigabytes — live in S3, AWS's object store, because baking them into a container image would make it enormous and slow to pull. The serving image (your vLLM or KServe runtime) lives in ECR, AWS's private container registry. At start-up an init container syncs the weights from S3 onto a local volume, then the real server container boots and reads them. Finally the endpoint is exposed to the world through the AWS Load Balancer Controller — an ALB for ordinary HTTP traffic, or an NLB when you need raw TCP or gRPC. That's the flow the diagram traces: S3 → init container → pod → load balancer.
The checkpoint in that chain — and the reason this section earns its place — is IRSA, IAM Roles for Service Accounts. Here's the analogy: instead of handing the pod a permanent master key it carries in its pocket (a static access key), IRSA is a valet system. You annotate the pod's Kubernetes ServiceAccount with an IAM role's ARN; when the pod starts, it presents its identity to the cluster's OIDC provider and is handed a temporary, automatically-rotated credential scoped to exactly that one role — nothing more. If the pod is compromised, the attacker inherits only what that narrow role allows (say, read-only on one S3 prefix), and the credential expires on its own. This is the exact AWS mirror of the least-privilege principle K7 enforced with RBAC: RBAC bounds what a pod can do to the cluster; IRSA bounds what it can do to AWS. The same mechanism is what lets an EKS pod call Bedrock in the hybrid pattern from section 1 without ever storing a key. The labs below wire it up: a ServiceAccount, a pod that pulls from S3, and the tight IAM policy that makes the role nearly powerless.
K7 showed the generic serving mechanics (weights on a PVC, vLLM, KServe, autoscale on queue depth). The AWS-specific glue is where the bytes and permissions come from: model weights live in S3, container images in ECR, the inference endpoint is exposed through the AWS Load Balancer Controller (ALB for HTTP, NLB for raw TCP/gRPC), and — critically — pods get AWS permissions via IRSA (IAM Roles for Service Accounts), not baked-in keys.
Follow the single arrow chain left→right — it traces one model's journey from storage to a public endpoint.
- The left box, "S3 — weights", is where the model files live at rest, outside the pod.
- The middle two boxes are the pod's startup path: the "initContainer — pull via IRSA" box fetches the weights using its IRSA role, then hands them to the "Pod (vLLM) — serves model" box that actually runs inference.
- The right box, "ALB/NLB — ingress", is the payoff: the load balancer exposes the running vLLM pod so callers can reach it.
In short: weights in S3 → init container pulls them via IRSA → vLLM pod serves the model → ALB/NLB exposes it.
IRSA is the AWS-native way to give a pod least-privilege AWS access. You annotate the pod's ServiceAccount with an IAM role ARN; the pod then assumes that role via the cluster's OIDC provider and gets temporary credentials scoped to exactly that role — no long-lived keys in the image or env. This is the mechanism a pod uses to read model weights from S3, or to call Bedrock in the hybrid pattern from section 1.
irsa-s3-serve.yaml# 1) A ServiceAccount annotated with the IAM role it should assume (IRSA).
apiVersion: v1
kind: ServiceAccount
metadata:
name: model-loader
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/eks-model-reader # placeholder
---
# 2) A pod that uses that SA; an initContainer syncs weights from S3 before vLLM starts.
apiVersion: v1
kind: Pod
metadata:
name: llm
spec:
serviceAccountName: model-loader # -> pod gets the role's temporary creds via IRSA
initContainers:
- name: fetch-weights
image: amazon/aws-cli:latest # pin a version in prod
command: ["aws", "s3", "sync", "s3://my-models/my-llm/", "/models/my-llm/"]
volumeMounts: [ { name: models, mountPath: /models } ]
containers:
- name: vllm
image: <account>.dkr.ecr.<region>.amazonaws.com/vllm:pinned # image from ECR
args: ["--model", "/models/my-llm"]
resources: { limits: { nvidia.com/gpu: 1 } }
volumeMounts: [ { name: models, mountPath: /models } ]
volumes:
- name: models
emptyDir: {}
model-reader-policy.json{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadOneModelPrefix",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::my-models",
"arn:aws:s3:::my-models/my-llm/*"
]
}
]
}
AI operating EKS on AWS
Imagine the incident you'd never live down. You give an operating agent read-only access to diagnose cluster problems, feeling safe because its Kubernetes RBAC is tightly scoped — it can't delete a Deployment, can't touch a secret. Then, during an incident, a poisoned log line or a hallucinated command nudges it, and it terminates three EC2 nodes and starts listing every S3 bucket in the account. Nothing it did violated its RBAC. How? Because on EKS the agent had a second identity you forgot to lock down. That blind spot is the entire subject of this section.
The mental shift is this: on a plain Kubernetes cluster, an agent has one boundary — its RBAC, which governs what it can do to cluster objects. On EKS, the same pod also carries an AWS identity via IRSA (the mechanism from section 5), and that identity governs what it can do to the AWS account — EC2, S3, Bedrock, even the EKS control-plane API itself. So an "in-cluster" agent has two locks on two different doors: RBAC on the cluster door, and its IAM role on the AWS door. The dangerous, invisible mistake is scoping the first door carefully and leaving the second wide open — because RBAC has nothing to say about whether the pod can terminate a node or read a bucket. Those live entirely on the AWS side, outside Kubernetes' view.
The AWS-specific surface an operating agent touches is worth naming so you can bound each part: alongside the Kubernetes API there's the EKS control-plane API (node groups, add-ons), CloudWatch and Container Insights as the evidence source it reads to diagnose, and — the sharp edge — that IAM boundary. The safety recipe is the ch08 / K7 pattern with one extra AWS lock bolted on: keep the agent diagnosis-first (read cluster state and CloudWatch, explain the incident, propose a fix rather than apply it), gate every mutation behind dry-run plus human approval or a GitOps PR, and scope both boundaries narrowly — a tight RBAC role and a tight IRSA policy. The rule to carry away: on EKS, "least privilege" is never one setting. It's two, and the AWS one is the boundary teams forget until the blast radius escapes the cluster.
K7 built the generic AIOps agent (read-only kubectl diagnosis, RBAC/dry-run/approval as the enforced safety layers) and it ties back to the ch08 DevOps-agent capstone. The AWS-specific surface an agent touches on EKS is worth naming, because it widens the blast radius beyond the cluster: alongside the Kubernetes API there is the EKS control-plane API (managing node groups, add-ons), CloudWatch / Container Insights as the evidence source, and — the sharp edge — the pod's IAM boundary.
Start at the single agent at the top, then follow the two arrows down to the two independent locks it must pass.
- The top box is the one operating agent — but note the subtitle: it carries two identities at once.
- The left green lock is Kubernetes RBAC, the "cluster door": it governs what the agent can do to cluster objects (Deployments, secrets).
- The right green lock is the IRSA/IAM role, the "AWS door": it governs what the same pod can do to the AWS account — terminate EC2 nodes, read S3, call Bedrock, even hit the EKS control-plane API.
- The red line under the AWS door is the trap: RBAC says nothing about the AWS side, so a tightly-scoped cluster role with an over-broad IAM role lets an "in-cluster" agent escape into the whole account.
In short: Two doors, two locks — scope RBAC and IRSA. Locking only the cluster door leaves the AWS account exposed.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Teams reach for EKS out of habit when Bedrock or SageMaker would be simpler and cheaper. The first skill is matching the workload to the layer.
Your task: For four workloads, choose Bedrock, SageMaker, or EKS and justify each in one line.
Requirements:
- Workload A: a chat feature that calls Claude, tiny team, spiky traffic
- Workload B: a fine-tuned open model to serve behind a managed endpoint, no k8s expertise
- Workload C: LLM inference that must share one cluster/RBAC/GitOps with 30 existing microservices
- Workload D: a self-hosted model that must also run on-prem later (portability)
- Give a one-line reason per choice tied to control, cost, or portability
💡 Hint: Only go down the Bedrock → SageMaker → EKS ladder when the layer above cannot meet a hard requirement.
Show solution
A → Bedrock. Pay-per-token, scales to zero, no servers — ideal for a small team and spiky traffic; owning infra would be pure overhead.
B → SageMaker. You own the model but want a managed endpoint and no cluster ops — SageMaker's hosted serving/autoscaling fits, and the team has no k8s skills to justify EKS.
C → EKS. The deciding requirement is one control plane — sharing the existing cluster, RBAC, and GitOps with 30 services makes EKS the natural home even though Bedrock/SageMaker could serve the model.
D → EKS. Portability is the hard requirement: plain Kubernetes runs on-prem later; SageMaker and Bedrock are AWS-only and would lock you in.
Context: Karpenter provisions GPU nodes just-in-time from pending pods, but an unbounded pool can spin up a fortune in GPUs. The professional version always caps and consolidates.
Your task: Write a Karpenter NodePool that launches only GPU instance families, allows Spot, hard-caps total GPUs, and consolidates idle nodes.
Requirements:
- Constrain
instance-familyto GPU families only - Allow both
spotandon-demandcapacity types - Taint the nodes so non-GPU pods stay off (per K7)
- Set a
limits.nvidia.com/gpucap as a cost guardrail - Enable consolidation so idle GPU nodes are reclaimed
- Label it as needing a cluster + Karpenter and hedge the CRD versions
💡 Hint: The limits block and consolidation are the two lines that turn a scaling feature into a cost-control feature.
Show solution
A GPU NodePool that is flexible on instance type, Spot-friendly, capped, and self-consolidating:
gpu-nodepool.yamlapiVersion: karpenter.sh/v1
kind: NodePool
metadata: { name: gpu }
spec:
template:
spec:
requirements:
- { key: karpenter.k8s.aws/instance-family, operator: In, values: ["g5","g6","p4d","p5"] }
- { key: karpenter.sh/capacity-type, operator: In, values: ["spot","on-demand"] }
taints:
- { key: nvidia.com/gpu, effect: NoSchedule }
nodeClassRef: { group: karpenter.k8s.aws, kind: EC2NodeClass, name: gpu }
limits: { nvidia.com/gpu: 16 } # hard cap = cost guardrail
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 2m
Context: Spot GPUs are the biggest cost lever on EKS, but a 2-minute reclaim warning will take down anything that can't absorb a node loss. The design differs sharply by workload.
Your task: Design how to safely use Spot GPUs for (a) a batch inference job and (b) a user-facing latency-critical endpoint, or explain why Spot is wrong for one of them.
Requirements:
- State the ~2-minute reclaim reality and what consumes the warning signal
- For the batch job: how a reclaim is made harmless
- For the endpoint: the base/burst split (what stays on-demand)
- Name at least two k8s mechanisms (replicas, PDB, interruption handling) you rely on
- Be explicit about which workload should NOT be pure-Spot
💡 Hint: Interruptible + retryable ⇒ Spot; single-replica + latency-critical ⇒ on-demand base with Spot only for burst.
Show solution
The reality: AWS can reclaim a Spot instance with ~2 minutes' warning. Karpenter (or a node-termination handler) consumes the interruption/rebalance signal and cordons + drains the node so pods reschedule gracefully instead of being killed cold.
(a) Batch inference → pure Spot is fine. The job is retryable: if a node is reclaimed mid-batch, the work item simply reschedules onto another node. Run it as a Job with retries; a reclaim costs a few minutes, never correctness.
(b) User-facing endpoint → NOT pure Spot. Keep an on-demand base capacity that alone can serve minimum traffic, and add Spot only for burst replicas. A single-replica Spot endpoint is an outage waiting for a reclaim.
Mechanisms: (1) enough replicas spread across instances/AZs so losing one is survivable; (2) a PodDisruptionBudget so draining never drops below the safe replica count; (3) interruption handling (Karpenter-native) to drain on the warning; and for training, frequent checkpointing so a reclaim resumes rather than restarts.
Context: "Self-hosting on EKS is cheaper" is only true above a utilisation break-even. Modelling it offline stops the argument being a vibe.
Your task: Write an offline Python function that, given an hourly GPU node price, hours run, utilisation, and a SageMaker/Bedrock alternative cost, reports the effective cost per useful hour and which option wins. Runs offline.
Requirements:
- EKS cost = node_hourly × hours_run, regardless of utilisation (you pay for idle)
- Effective cost per useful hour = EKS cost / (hours_run × utilisation)
- Compare against a provided managed-alternative cost for the same useful work
- Return both the effective unit cost and the cheaper option
- Demonstrate a low-utilisation case (managed wins) and a high-utilisation case (EKS wins), offline
💡 Hint: Idle time is the whole story: divide the fixed node bill by only the useful hours to expose what low utilisation really costs.
Show solution
The key move is dividing the fixed node bill by only the useful hours — that is where idle GPUs hurt.
break_even.pydef eks_vs_managed(node_hourly, hours_run, utilisation, managed_total):
"""utilisation in (0,1]; managed_total = cost of the same useful work on SageMaker/Bedrock."""
eks_total = node_hourly * hours_run # you pay for idle time too
useful_hours = hours_run * utilisation
eks_per_useful = eks_total / useful_hours # true unit cost of useful work
winner = "EKS" if eks_total < managed_total else "managed"
return {
"eks_total": round(eks_total, 2),
"eks_per_useful_hour": round(eks_per_useful, 2),
"winner": winner,
}
# Low utilisation: an idle-heavy EKS GPU node loses to the managed option
print(eks_vs_managed(node_hourly=4.0, hours_run=730, utilisation=0.10, managed_total=1200))
# -> {'eks_total': 2920.0, 'eks_per_useful_hour': 40.0, 'winner': 'managed'}
# High utilisation: a well-packed EKS node wins
print(eks_vs_managed(node_hourly=4.0, hours_run=730, utilisation=0.85, managed_total=3500))
# -> {'eks_total': 2920.0, 'eks_per_useful_hour': 4.71, 'winner': 'EKS'}
Context: A serving pod needs to read weights from S3 but should be able to do nothing else. IRSA + a tight IAM policy is the AWS-native least-privilege answer — no static keys.
Your task: Produce the IRSA ServiceAccount annotation and a least-privilege IAM policy that lets a pod read exactly one model prefix in one bucket, and explain why this beats env-var keys.
Requirements:
- Annotate the ServiceAccount with
eks.amazonaws.com/role-arn - IAM policy allows only
s3:GetObject/s3:ListBucket - Scope the resource to one bucket + one prefix, not
* - Explain why IRSA (temporary, rotated, role-scoped) beats baked-in keys
- Note the boundary: a leaked pod can read only that one prefix
💡 Hint: Scope the S3 Resource ARNs to the single prefix; the SA annotation is what wires the pod to the role via the cluster OIDC provider.
Show solution
The ServiceAccount annotation ties the pod to an IAM role via the cluster's OIDC provider; the policy makes that role near-powerless:
irsa-sa.yaml# ServiceAccount -> role (IRSA)
apiVersion: v1
kind: ServiceAccount
metadata:
name: model-loader
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/eks-model-reader
# IAM policy attached to that role — read-only, one prefix:
# {
# "Version": "2012-10-17",
# "Statement": [{
# "Effect": "Allow",
# "Action": ["s3:GetObject", "s3:ListBucket"],
# "Resource": ["arn:aws:s3:::my-models",
# "arn:aws:s3:::my-models/my-llm/*"]
# }]
# }
Why IRSA beats env-var keys: the pod assumes the role and receives temporary, auto-rotated credentials scoped to exactly this role — there is no long-lived secret to leak, commit, or forget to rotate. A compromised pod inherits only read on my-models/my-llm/*; it cannot touch other buckets, write, or delete. That bounded blast radius is the whole point, and it is the AWS mirror of the K7 RBAC principle.
Context: Representative scenario: leadership wants your fine-tuned open model served to production on EKS with a hard monthly GPU budget — and an on-call agent to help operate it. Design it end to end, AWS-specifically.
Your task: Produce a design covering node provisioning, Spot strategy, weights/permissions, cost guardrails against the ceiling, and how an operating agent is bounded on EKS — naming the failure mode each choice defends against.
Requirements:
- Karpenter GPU NodePool with a GPU cap tied to the budget + consolidation
- Spot strategy: on-demand base for the endpoint, Spot for burst/batch, with interruption handling
- Weights from S3 via IRSA (least-privilege), images from ECR, ALB/NLB ingress
- Cost visibility: tags + CloudWatch/Kubecost, alarm on idle GPU utilisation
- An operating agent bounded by both RBAC and IRSA, diagnosis-first, mutations gated
- Name the top failure mode each choice defends against
💡 Hint: Two boundaries, not one: on EKS the agent is capped by its k8s RBAC AND its IAM/IRSA role — scope both, and let Karpenter's GPU limit enforce the budget mechanically.
Show solution
Provisioning & budget. A Karpenter GPU NodePool constrained to GPU families with a hard limits.nvidia.com/gpu derived from the monthly ceiling (GPUs × hourly × 730 ≤ budget), plus aggressive consolidation so idle GPU nodes are reclaimed in minutes. Defends against: a runaway autoscale or idle GPUs blowing the budget — the cap enforces the ceiling mechanically, not by hope.
Spot strategy. An on-demand base of endpoint replicas that alone serves minimum traffic, with Spot burst replicas and any offline/batch inference on pure Spot. Karpenter consumes the interruption signal to drain gracefully; a PodDisruptionBudget protects the safe replica count. Defends against: a Spot reclaim turning into an outage.
Weights & permissions. Weights synced from S3 by an init container using an IRSA ServiceAccount scoped read-only to one prefix; the serving image comes from ECR; the endpoint is exposed via the AWS Load Balancer Controller (ALB for HTTP, NLB for gRPC). Defends against: leaked static keys and per-replica cold re-download / over-broad S3 access.
Cost visibility. Cost-allocation tags per team/model, CloudWatch/Container Insights + Kubecost for per-namespace spend, and an alarm on low GPU utilisation (the silent bill). Defends against: discovering the overspend on the invoice instead of on a dashboard.
Operating agent — two boundaries. Diagnosis-first: read-only kubectl + CloudWatch evidence, the agent explains incidents and proposes fixes (the ch08/K7 pattern). Any mutation is gated by dry-run + human approval / GitOps PR, and the agent is capped by both narrow k8s RBAC and a narrow IRSA IAM role — so it can neither delete cluster resources nor terminate nodes / read other buckets via AWS. Defends against: an injected or hallucinated command escaping the cluster boundary into the AWS account.
✓ Checkpoint — you can move on when you can…
- State when EKS beats SageMaker and Bedrock — and describe a hybrid (Bedrock + EKS via IRSA).
- Explain how Karpenter provisions and consolidates GPU nodes, and where the cost cap lives.
- Say when Inferentia/Trainium (Neuron) beats GPU and the compatibility tax it charges.
- Choose Spot vs on-demand for a given AI workload and describe interruption handling.
- Write an IRSA ServiceAccount + least-privilege IAM policy for a pod pulling weights from S3.
- Name the two boundaries (RBAC + IRSA) that bound an operating agent on EKS.
Knowledge check
check yourselfA team says "we'll move our Claude calls off Bedrock onto a self-hosted model on EKS to save money." What is the flaw in that reasoning, and when would EKS actually be the right call?
Show answer
An LLM-serving pod on EKS needs to read weights from S3, and later an ops agent will run in the same cluster. Why is it not enough to secure these with Kubernetes RBAC alone, and what is the AWS-specific control?