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.
When EKS for AI: the EKS vs SageMaker vs Bedrock decision
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.
| 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
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).
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)
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.
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
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.
| 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
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.
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
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.
🪜 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?