<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[AditModi's Blog]]></title><description><![CDATA[Senior Cloud Engineer at Digital-Alpha]]></description><link>https://aditmodi.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Tue, 10 Nov 2026 01:26:39 GMT</lastBuildDate><atom:link href="https://aditmodi.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Behind the Scenes: Planning the 5th Edition of AWS Community Day Vadodara]]></title><description><![CDATA[On 12th September, we hosted the fifth edition of AWS Community Day Vadodara. Five editions. When you look at the final day, the packed hall, the…]]></description><link>https://aditmodi.hashnode.dev/behind-the-scenes-planning-the-5th-edition-of-aws-community-day-vadodara</link><guid isPermaLink="true">https://aditmodi.hashnode.dev/behind-the-scenes-planning-the-5th-edition-of-aws-community-day-vadodara</guid><dc:creator><![CDATA[Adit Modi]]></dc:creator><pubDate>Mon, 14 Sep 2026 06:00:38 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/151ca590-092c-45b0-8dee-eaedcd8ae7c6.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>On 12th September, we hosted the fifth edition of AWS Community Day Vadodara.</p>
<p>Five editions.</p>
<p>When you look at the final day, the packed hall, the conversations, the talks, the panel discussion, the activities, the networking, the volunteers running around, and the smiles at the end of the day, it can look like everything came together naturally.</p>
<p>It didn't.</p>
<p>An event like AWS Community Day Vadodara is the result of weeks and months of planning, conversations, follow-ups, spreadsheets, calls, last-minute changes, problem-solving, and a lot of people quietly doing things behind the scenes.</p>
<p>This is the story of how we planned this year's edition.</p>
<h2>It Starts With a Simple Question: Should We Do It Again?</h2>
<p>Every edition starts with the same question.</p>
<p>Can we make the next one better than the previous one?</p>
<p>By the time you reach the fifth edition, expectations are different. The community knows what AWS Community Day Vadodara feels like. Speakers have expectations. Sponsors want to understand the value of participating. Volunteers know the scale of the work involved. And attendees expect an experience that is worth giving up their Saturday for.</p>
<p>So planning doesn't really begin with picking a date or finding a venue. It begins with defining what we want the event to be.</p>
<p>For this edition, the goal was simple: build a day where people could learn from practitioners, meet others from the community, have meaningful conversations, and walk away with something useful.</p>
<h2>Building the Organizing Team</h2>
<p>The first major piece of the puzzle is the team behind the event.</p>
<p>Community events are never built by one person. Over the years, we've built a core team, and each person on it has taken on a role that lets us pull off an event at this scale: sponsorship, speaker management, activities, volunteer coordination, and everything in between. This year, that core team came together for our first planning call about three months before the event, and from there each of us just ran with our piece of it.</p>
<p>A large part of the planning involves breaking a huge event into hundreds of small tasks and making sure someone owns each one. The organizing team needs to constantly ask:</p>
<p>What needs to happen? Who owns it? What is the deadline? What depends on this? What happens if something goes wrong? And, inevitably: what did we forget?</p>
<h2>Finding the Venue</h2>
<p>We've hosted AWS Community Day Vadodara at Sun City Club and Resort for the last few years now, and it's become home for this event. But "same venue" doesn't mean "same plan." Every year we try to innovate around it, sometimes more tracks, sometimes fewer, more activities one year, a different food stall lineup the next.</p>
<p>There's also a constant we plan around every single year: rain. September in Vadodara is unpredictable, and a good chunk of the venue and layout planning is really about building in enough flexibility that an uncertain sky doesn't derail the day.</p>
<p>The venue determines how attendees move through the event and how the entire experience feels. We wanted enough space for people to comfortably attend sessions, interact with sponsors, network during breaks, and take part in the activities happening throughout the day.</p>
<h2>Sponsorship: Making the Event Possible</h2>
<p>One of the most important, and often least visible, parts of planning a community event is sponsorship.</p>
<p>Events cost money. Venue, food, branding, printing, AV, photography, logistics, attendee materials, badges, merchandise, and countless smaller expenses all add up.</p>
<p>Nirmal put in an enormous amount of effort coordinating sponsorships this year, with strong support from Jay, Dhaval, and Manan. Between conversations, proposals, follow-ups, requirements, branding, booth arrangements, deliverables, and coordination that ran right up until, and sometimes during, the event, this is a genuinely hard job, and Nirmal and team carried it well.</p>
<p>This year, we were fortunate to have the support of: phonon, automating outcomes, Rezoomex, Jeavio, SUDO Consultants, AVDEVS, iOS Development, APPMATTIC, CloudKida Pvt Ltd, Nishchay Labs, and KonfHub.</p>
<p>Every sponsor played a role in making this edition possible. Without sponsors supporting the initiative, an event at this scale simply wouldn't exist.</p>
<h2>Building the Speaker Lineup</h2>
<p>Then comes one of the most exciting parts: speakers.</p>
<p>This year we ran the entire speaker selection process through Sessionize, and it was a genuinely smooth experience end to end. A big thank you to Nirmal and Chirag for helping me with speaker selection, sifting through submissions and helping shape a lineup that felt right for this edition.</p>
<p>This year's speakers included: Dhaval Nagar, Praful Bagai, Darko Mesaroš, Srushith Repakula, Aric Pandya, Rucha Chormunge, Ancelia Patrao, Vishnu Rachapudi, Ashish Gajjar, Krutarth Rindani, Satyanarayan Sahoo, Kushal Nagrani, Ishan Jain, Sankalp, Sandeep Paranjpe, Pritesh Kiri, Dhruvesh Patel, Israr Mahedi, and M Lokhandwala.</p>
<p>Once speakers were confirmed, the harder part started for me: designing the agenda. Fitting topics, formats, and timings together so the day flows well is never trivial, and I have to thank Nilesh for the thinking that led us to placing Darko in the main track, a call that made the agenda click into place.</p>
<h2>The Panel Discussion</h2>
<p>The panel this year was Manan's idea, and it turned into one of the highlights of the day.</p>
<p><strong>The AI Shift: Business, Talent &amp; Future</strong></p>
<p>Panel discussion featuring Vadodara's tech founders and CXOs.</p>
<p>AI is changing how businesses operate, how teams work, and what it actually means to be ready for the workforce ahead, but most of that conversation happens in hype cycles rather than in the room with people actually building through it.</p>
<p>This panel brought together Vadodara's tech founders and CXOs to cut through that noise. The conversation covered where AI is genuinely driving business value versus where it's just adding noise, what leading teams and careers looks like in an AI-first era, how students and early professionals should actually be preparing for this shift, and what it takes to build a business today that's still built to scale tomorrow. It was a grounded, city-local take on questions every builder in the room is already asking.</p>
<p>It was brilliantly executed by Chirag Darji as moderator, who kept the conversation sharp and made sure every voice on that stage got room to speak.</p>
<h2>Activities</h2>
<p>A community day shouldn't only be about sitting in a chair and listening, so this year we built out a set of activities:</p>
<ul>
<li><p>Ask Us Anything: AI and Career Growth</p>
</li>
<li><p>Show Me Your Architecture</p>
</li>
<li><p>Show Me Your Project</p>
</li>
</ul>
<p>These came from my own experience attending events around the world and noticing how much value sits in these smaller, more interactive formats. I'm grateful to everyone who backed the idea early on and to the team that actually executed it:</p>
<p>Ravi Soni led AI in the Industry session, Nilesh Vaghela led career growth, Vrajesh Jayswal led the project track, and Udit Parikh led architecture. And Jay Patel was behind putting this whole plan into execution with all the logistics it needed to actually run on the day.</p>
<h2>The Volunteer Army</h2>
<p>If speakers are the people attendees see on stage, volunteers are the people who make sure the stage, and everything around it, works.</p>
<p>Registration. Badge distribution. Speaker assistance. Room coordination. Timekeeping. Microphones. Audience questions. Photography. Speaker movement. Food coordination. Sponsor support. Troubleshooting. And the hundred tiny things that nobody notices when they're done correctly.</p>
<p>To our volunteers: thank you for supporting the community, it was truly an amazing experience working with each and every one of you.</p>
<p>What stood out to me most was how everyone just stepped up, took ownership, and delivered without needing to be asked twice. That's rare, and it's exactly what made this edition special.</p>
<p>Always here if you need anything from my side.</p>
<h2>Registration and Attendee Experience</h2>
<p>Another major part of planning was the attendee journey.</p>
<p>From the moment someone registers, they begin forming an expectation of the event. What information do they receive? How easy is check-in? Where do they go? How do they know which session is happening where? Where do they get food? Where can they network? Where are the sponsors? Who can they ask for help?</p>
<p>The attendee experience isn't one thing. It is the sum of hundreds of small interactions throughout the day.</p>
<h2>Communication</h2>
<p>Communication becomes its own project as the event gets closer, and this year our social media team made sure that whatever we wanted to reach the audience, reached them.</p>
<p>Srujal Shah led this effort brilliantly, with amazing support from Malhar and help from Maitry. Whether it was speaker announcements, schedule updates, or last-minute changes, the social media team made sure nothing got lost along the way.</p>
<p>A lot of the graphics this year were appreciated by everyone too, and that's thanks to Shivang and Sara, who put in a huge amount of effort into small details across social media creatives, event day signage, banners, lanyards, and our five-year journey wall. Everything came together beautifully.</p>
<p>The website was also excellent this year, all thanks to Parth for putting it together, and as always, Shivang for the UI/UX design work behind it.</p>
<h2>The Final Week</h2>
<p>The final week always feels different. The long-term planning suddenly becomes very real.</p>
<p>Names are finalized. Badges are prepared. Schedules are checked again. Signage is reviewed. Speaker communication is reconfirmed. Volunteer responsibilities are assigned. Sponsor requirements are verified. Equipment is checked. Registration lists are reviewed.</p>
<p>That final week was largely carried by Jay Patel and Nirmal Pathak, who put in an enormous amount of effort to make sure everything was actually ready when the day arrived.</p>
<p>And then someone inevitably asks: "Are we sure everything is ready?"</p>
<p>The honest answer is never completely. There is always something. But eventually you reach the point where planning has to stop and execution has to begin.</p>
<h2>Event Day</h2>
<p>Then came 12th September.</p>
<p>The hall started filling up. Volunteers moved into their positions. Speakers arrived. Sponsors set up. Attendees started networking. Sessions began.</p>
<p>Behind the scenes, the AV and production setup was managed by Parth, with support from Dharmin, Nisarg, and Shivang. These folks did an amazing job keeping everything running smoothly without ever being visible from the audience's side of things.</p>
<p>A few people also went well above and beyond on the day itself: Ayush with videography, and Neel, Jenish, Preet, and Srujal helping out across a lot of behind-the-scenes work and support wherever it was needed.</p>
<p>A big thank you to Dhaval Pancholi as well, for an amazing opening note and for coaching and helping our MC team through the day. And to the MC folks themselves, Nidhi, Paree, and Drishti, who did a great job keeping the stage moving.</p>
<p>And suddenly, all those spreadsheets, messages, meetings, calls, and checklists turned into a real community event.</p>
<h2>Five Editions In</h2>
<p>Five editions have taught us something important.</p>
<p>Community isn't created by an event. An event simply gives the community a place to come together. The real community exists in the people who show up, contribute, share knowledge, introduce themselves to strangers, help someone solve a problem, volunteer their time, speak on stage, sponsor an initiative, organize behind the scenes, or simply attend and participate.</p>
<p>That's what makes AWS Community Day Vadodara special. It isn't the venue. It isn't the stage. It isn't the branding. It's the people.</p>
<h2>Thank You</h2>
<p>To our sponsors, phonon, automating outcomes, Rezoomex, Jeavio, SUDO Consultants, AVDEVS, iOS Development, APPMATTIC, CloudKida Pvt Ltd, Nishchay Labs, and KonfHub, thank you for believing in the community and helping make this year's edition possible.</p>
<p>To our speakers, thank you for sharing your time, experience, and knowledge with us.</p>
<p>To Chirag Darji, thank you for moderating our panel with such energy and for making sure every perspective on that stage came through.</p>
<p>To our volunteers and core contributors, thank you for everything you did behind the scenes. Much of your work may never be visible to attendees, but the event would not happen without you.</p>
<p>To our organizing team, thank you for the countless hours, discussions, decisions, and compromises that went into bringing everything together.</p>
<p>And finally, to everyone who attended, whether this was your first AWS Community Day Vadodara or your fifth, thank you for showing up. You didn't have to come. You chose to. And that's what makes all of this worth doing.</p>
<p><strong>A special note, from Nirmal Pathak and me:</strong> this was Jay's event through and through. From the smallest details to the biggest plans, everything was carefully curated and executed by him and the team of volunteers behind him. The swags, the audio, every bit of coordination with the venue, all of it has Jay at the core. Thank you, Jay, for everything you pour into this community.</p>
<p>And a note for Nirmal Pathak, from me: it is difficult to imagine something and then put in the continuous effort it takes to actually get there. Thank you for that effort, Nirmal Pathak, as we fondly call him, the supreme leader of AWS User Group Vadodara.</p>
<h2>What's Next?</h2>
<p>The fifth edition is now a memory. But the work doesn't really end when the event does.</p>
<p>We want to know what worked, what didn't, which sessions people loved, which activities they want more of, and what the community wants to see next.</p>
<p>That's why feedback matters. Every response helps us understand how we can make the next edition better.</p>
<p>Because if there is one thing we've learned after five editions, it's this: the next AWS Community Day Vadodara starts the moment the current one ends.</p>
<p>And we'll see you there.</p>
<p>Thank you, Vadodara. ❤️</p>
<p>Five editions down. More to come.</p>
<p><em>AWS Community Day Vadodara is a community-driven event made possible by the collective efforts of organizers, volunteers, speakers, sponsors, partners, and attendees.</em></p>
<hr />
<p><em>PS: I was unwell over the last couple of days leading up to and around the event, so I couldn't give this edition my full self the way I normally would. Thank you to everyone who stepped up and supported through it, it meant a lot.</em></p>
]]></content:encoded></item><item><title><![CDATA[👋 Everything about EKS & AI Infrastructure Newsletter "#75" ☁️❤👨‍💻]]></title><description><![CDATA[GPU scheduling matures, agent governance gets teeth, and the boring reliability work that decides whether any of this survives contact with production.]]></description><link>https://aditmodi.hashnode.dev/everything-about-eks-ai-infrastructure-newsletter-75</link><guid isPermaLink="true">https://aditmodi.hashnode.dev/everything-about-eks-ai-infrastructure-newsletter-75</guid><dc:creator><![CDATA[Adit Modi]]></dc:creator><pubDate>Sat, 04 Jul 2026 07:37:36 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/5a423dd4-047b-449d-985c-4833d69ae040.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>Dear EKS &amp; AI Infrastructure enthusiasts,</strong></p>
<p>Welcome to Everything about EKS &amp; AI Infrastructure #75.</p>
<p>There’s a thread running under this edition, and it’s not about models — it’s about the layer underneath them finally catching up. DRA reaching GA and HAMi hitting CNCF Incubating in the same window means Kubernetes finally has a real, open answer for fractional GPU scheduling instead of vendor-specific hacks. The EKS control plane stories tell the same story from the reliability side: the etcd-journal rework that made the control plane compute-bound instead of disk-bound, version rollbacks that finally give clusters an undo button Kubernetes never had, and AWS DevOps Agent tracing a 66,000-requests-per-minute controller back to an invisible APF seat exhaustion in seven minutes flat.</p>
<p>Governance is the other half. Agentgateway’s CEL-based tool masking and LLMKube’s fail-closed <code>ModelRouter</code> are solving the same problem from opposite directions — one keeps an agent from touching a tool it shouldn’t, the other keeps regulated data from ever reaching a cloud model by construction, not by convention. Neither of these is glamorous work. Both are the difference between a platform you can actually trust with production traffic and one you’re hoping doesn’t get audited.</p>
<p>The scheduling primitive matures. The control plane stops surprising you. The agent stops being trusted by default. None of that shows up in a keynote. All of it is the actual job.</p>
<ol>
<li>Performance Engineering in Modern AI Systems 🌩️</li>
</ol>
<p>🌩️<strong>Dynamic Resource Allocation reaches GA in Kubernetes 1.35</strong></p>
<p>DRA replaces the device-plugin era’s opaque, all-or-nothing GPU allocation with structured claims and device classes — the difference between “this pod gets a GPU” and “this pod gets exactly the slice of GPU it asked for, expressed as an API object instead of a vendor-specific hack.” For EKS clusters running mixed training and inference workloads on the same fleet, that’s the primitive fractional scheduling has been missing since Kubernetes started treating accelerators as indivisible bricks. It’s also the layer HAMi (below) builds directly on top of, so the two ship in the same window for a reason.</p>
<p>If you’re still running device-plugin-era GPU scheduling, DRA going stable is the trigger to start planning the migration. Worth watching whether AWS ships DRA support into EKS-managed add-ons or leaves it to cluster operators to wire up themselves — that’s the detail that decides how fast this actually lands in production versus staying a v1.35 changelog line.</p>
<p><strong>🌩️NVIDIA’s TwoTower gets a 30B model to 2.4x faster generation</strong></p>
<p>Nemotron-Labs-TwoTower splits generation across a frozen autoregressive tower that causally reads context and a trainable diffusion tower that denoises token blocks in parallel via cross-attention to that context — adapting an existing model to diffusion decoding rather than training one from scratch. Built on the open Nemotron-3-Nano-30B hybrid Mamba-Transformer MoE, it needed only ~2.1T training tokens against the backbone’s original 25T, which is what makes this a realistic thing to try rather than a full retrain.</p>
<p>The number that matters: 98.7% of the original model’s quality retained at 2.42x wall-clock throughput, tested against a real 30B backbone. Weights and code are released, so this is testable against your own serving stack this week — the same diffusion-decoding idea showed up in DiffusionGemma and DSpark, but this is the first version proven by adapting an off-the-shelf AR model instead of training one purpose-built for it.</p>
<p><strong>🌩️Gary A. Stafford: Running LiteLLM as a Proxy in Front of Multiple Model Providers</strong></p>
<p>The distinction most LiteLLM writeups blur: the library and the proxy are different things. <code>litellm.completion()</code> runs in-process straight to the upstream provider — nothing shows up in an admin UI, no virtual key gets checked. The proxy is the actual gateway: one HTTP surface in front of Bedrock, Anthropic, OpenAI, and in-cluster vLLM/TGI, with provider credentials living only on the gateway host while every caller authenticates with a scoped, revocable virtual key instead of a shared provider secret — and AWS’s own reference architecture deploys that identical proxy config on ECS Fargate or EKS behind an ALB.</p>
<p>Two gotchas worth carrying forward: reasoning models like Qwen3 and Gemma4 stream chain-of-thought into a separate <code>reasoning_content</code> channel that eats into <code>max_tokens</code> before user-facing text appears, so token budgets need headroom you wouldn’t expect from a non-reasoning model; and as of June 2026, Bedrock Mantle’s Anthropic Responses-API route breaks through LiteLLM because the gateway downconverts to chat-completions, which Mantle’s Anthropic models reject — route Claude through native <code>bedrock/anthropic.*</code> instead. Worth reading alongside the token-monitoring piece from edition #74, which flagged LiteLLM CVE-2026-42271 — pin to ≥1.83.7 if you’re standing this up net-new.</p>
<p><strong>🌩️Scaling video generation across a GB200 NVL72 rack with TensorRT-LLM</strong></p>
<p>Video diffusion models are a different scaling problem than LLM serving: no KV cache to reuse, one request generating one video, and the model re-running full attention over the entire frame sequence on every one of 30-50 denoising steps. NVIDIA’s VisualGen runtime handles this by combining four separate ways of splitting the work across GPUs — halving the guidance computation, splitting the sequence itself, splitting attention across a 2D grid of GPUs instead of a single line of them, and splitting the final decode step — all as config settings, with no changes to the model code required.</p>
<p>The headline number: one specific combination of those settings takes the core generation loop from 371 seconds on a single GPU down to 7 seconds across a 72-GPU rack — about 53x faster — and the identical settings work unchanged on a second, much larger model, which is the real proof this generalizes rather than being hand-tuned to one case. The sharp detail is why splitting attention across a 2D grid beats the older single-line approach at this scale: the single-line approach’s GPU-to-GPU communication cost stays roughly fixed no matter how many GPUs you throw at it, so it stops paying off past a certain point — at 72 GPUs it’s already 83% slower than the 2D-grid version. There’s a second bottleneck worth knowing too: once the generation loop is sped up 53x, the final decode step back into pixels becomes a quarter of total request time if left unsplit on one GPU — splitting that step as well shaves it down further and adds another 20% speedup end to end.</p>
<ol>
<li>Starred Content ⭐</li>
</ol>
<p><strong>⭐Mark Vinciguerra, Daniel Schoonover &amp; Tomonori Shimomura: SageMaker HyperPod EKS in a fully private VPC</strong></p>
<p>A closed-network HyperPod cluster has no internet gateway, no NAT gateway, and no public subnets — every AWS API call a node makes (assuming a role, pulling an image, writing a log) has to traverse a VPC endpoint instead, and the failure mode when one is missing is silent: a pod just hangs in <code>ContainerCreating</code> with no error pointing at the actual cause. The post treats the eleven interface endpoints plus the S3 gateway endpoint as a dependency chain rather than a checklist: the VPC CNI plugin needs the <code>ec2</code> endpoint before a pod can even get an IP, Pod Identity needs <code>eks-auth</code> and <code>sts</code> before that identity resolves to credentials, and only then can <code>ecr.api</code>/<code>ecr.dkr</code> pull the image — so a missing endpoint early in that chain surfaces as a confusing timeout several steps downstream, which is exactly why 9 times out of 10 a stuck <code>ContainerCreating</code> traces back to the missing <code>ec2</code> endpoint specifically.</p>
<p>The three-phase structure is the part worth copying regardless of your compliance requirements: mirror exactly three images (NVIDIA’s device plugin, the MPI operator, the Kubeflow training operator — everything else pulls from AWS-owned ECR accounts directly) on a build host with internet access, run a single <code>terraform apply</code> against the empty private VPC, then verify connectivity with a two-layer check (raw TCP/443 plus an actual boto3 call, since the endpoint can exist while an IAM or endpoint policy still blocks the call silently) before flipping the EKS API to private and closing the air gap. Real numbers worth budgeting around: a single <code>ml.p5.48xlarge</code> consumes up to 81 IPs across its ENIs, which is why subnets need to be <code>/18</code> or larger, and the eleven interface endpoints run about $290/month in endpoint-hours alone across 4 AZs before any data-processing charges. Worth reading alongside CommBank’s HyperPod-on-Kubernetes piece from edition #74 — that one covers the enterprise-constraints side (IRSA, MIG partitioning, NCCL debugging) on a no-outbound-internet cluster; this one is the missing piece showing exactly how you build the closed network those constraints run inside.</p>
<p><strong>⭐Shyam Kulkarni, Kuntal Shah, and Vasanth Raju: Diagnosing EKS control plane performance issues with AWS DevOps Agent</strong></p>
<p>API server overload is one of the harder EKS failure modes to catch because API Priority and Fairness makes it partially invisible to the workload causing it: when a seat is exhausted, the API server returns a 429, client-go retries transparently, and the request eventually succeeds — so the offending controller’s own logs show nothing wrong even as the EKS audit logs fill with hundreds of throttled requests. The post’s repro scenario makes the mechanism concrete: a controller mixing LIST/GET (fast, high-volume), WATCH (holds connections open, disproportionately expensive on seats), and MUTATE calls at 50 replicas × 80-100 req/s produces 1,600-2,000 req/s total, pushing basic <code>kubectl</code> latency from ~100ms to 1.5s+ while nodes and pods themselves stay healthy — which is exactly the trap, since standard troubleshooting instinct is to check node/pod health first.</p>
<p>The root cause AWS DevOps Agent surfaced by correlating CloudWatch metrics, EKS audit logs, CloudTrail, and cluster state: one controller deployment generating ~66,000 API requests per minute, consuming 99%+ of API server capacity — identified in about 7 minutes end to end. The detail worth keeping regardless of tooling: the audit logs are the only place this shows up cleanly, because they capture the request that got throttled, not just the retried response the application eventually saw. Any team running high-replica controllers or aggressive polling tools (CI/CD pipelines, monitoring agents) on EKS should know to check APF seat metrics and audit logs directly rather than trusting application-level error rates during a control-plane slowdown.</p>
<p><strong>⭐Miro Nikolov &amp; Barak Epstein: Multi-node KV cache offloading with GKE and Managed Lustre</strong></p>
<p>Long-context and agentic workloads push KV caches past what local CPU RAM and host SSD cache tiers can hold, and the usual fix — pooling node-local NVMe into a distributed layer — pushes the complexity onto the cluster itself, which now has to handle its own cross-node data distribution and replication. This approach sidesteps that entirely: it points <code>llm-d</code>‘s open-source <code>OffloadingConnector</code> at Google Cloud Managed Lustre instead of local disk, turning a shared parallel filesystem into one cluster-wide KV cache tier that any GPU node can read from, with no host-capacity ceiling and no custom replication logic to maintain.</p>
<p>The numbers, on a six-node A3 Mega cluster serving Llama-3.3-70B with a 50,000-token prompt: over 50% TCO savings and nearly 60% fewer GPU-hours, driven by a 95% cache hit rate on offloaded prefilled KV caches — meaning nearly all repeat-prefix requests skip recomputation entirely. Layering CPU RAM offload in front of the Lustre tier as a hybrid (rather than Lustre alone) adds another ~40% TTFT improvement and ~30% lower end-to-end latency. The part worth knowing before you copy this: the offloading connector writes cache blocks to the filesystem but never deletes them, so this only works in production with a separate garbage-collection service running alongside it — their PVC Evictor sizes at roughly 1 replica per 72TB of Lustre capacity and shards the cache namespace across replicas to avoid redundant metadata scans.</p>
<p>This is GKE-native as published, but nothing in the mechanism is GCP-specific — <code>llm-d</code>‘s connector is the portable part, and AWS’s equivalent path is FSx for Lustre with its CSI driver in front of an EKS GPU node pool, running the identical <code>OffloadingConnector</code> config. Worth prototyping if you’re running long-context serving on EKS and hitting local-SSD cache limits; the manifest structure in the post (StorageClass, PVC, the vLLM <code>kv-transfer-config</code> block) maps over almost directly, swap Lustre CSI for FSx for Lustre CSI.</p>
<p><strong>⭐Guidance for Accelerator-Optimized Agentic Bidding on AWS</strong></p>
<p>Programmatic ad bidding runs on a hard timeout budget, which is exactly why GPU inference inside the bid path has been rare — you can’t add a model call if it risks missing the auction window. This guidance implements the IAB’s Agentic RTB Framework (ARTF) directly on EKS: an OpenRTB bid request fans out in parallel to four containers — a DLRM bid-shading model, a Wide &amp; Deep segment activator, an NCF deal matcher, and a rule-based metrics enricher — each serving inference off NVIDIA Triton on GPU nodes, with the orchestrator merging their proposed bidstream “mutations” into one response before the auction continues. Bedrock AgentCore optionally exposes the whole pipeline as an MCP tool (<code>extend_rtb</code>), so an external agent can call into the bidding decision directly rather than only reading its output.</p>
<p>The infra is fully worked out, not a sketch: IRSA for Triton’s S3 model access, HPA and Cluster Autoscaler on both the GPU (<code>g5.xlarge</code>/A10G, or G7e for more throughput) and CPU node groups, a scheduled GPU shutdown to control cost, and a documented estimate of ~\(592/month for the demo config versus ~\)1,080/month running the GPU node 24/7. The catch that matters before you take this anywhere near production: the DLRM/Wide&amp;Deep/NCF models are exported with seeded random weights specifically to exercise the GPU inference path — they don’t make meaningful predictions until you train them on your own bidstream data, and the repo is explicit that there’s no portable pretrained checkpoint to drop in since the embedding tables are keyed to your feature vocabulary. Treat this as a reference architecture for wiring GPU inference into an ARTF-compliant bidding pipeline on EKS, not a bidding model you can deploy as-is.</p>
<ol>
<li>Announcements 📢</li>
</ol>
<p><strong>📢Amazon EKS adds Kubernetes version rollbacks</strong></p>
<p>Open-source Kubernetes has never supported control plane rollback — once you upgrade, you’re committed, which is exactly why teams running hundreds of clusters build elaborate bake periods, staggered rollout groups, and months-long upgrade cycles instead of just upgrading and checking. The community’s KEP-4330 emulated-versions effort addresses this by keeping a cluster in a transitional compatibility state, but EKS’s version rollback does something stronger: it returns the cluster to the actual previous version that ran in production, one minor version at a time, with cluster insights automatically checking node version compatibility and add-on dependencies before the rollback proceeds (or <code>--force</code> to skip the checks if you’ve already assessed the risk yourself).</p>
<p>The mechanics: control plane rollback took about 20 minutes in AWS’s own walkthrough, comparable to a standard upgrade, and the cluster stays functional throughout. EKS Auto Mode clusters get node rollback too, but since node rollback respects pod disruption budgets by default, that step can take considerably longer depending on how tight your PDBs are — a new cancel API lets you halt a node rollback mid-flight if it’s taking too long, so you can loosen disruption budgets and restart rather than being stuck waiting. It’s available now at no additional cost across all commercial regions, for both self-managed and Auto Mode clusters, on any version in EKS standard or extended support — worth updating your upgrade runbooks around immediately, since the actual risk calculus on “should we upgrade this quarter” just changed for any team that’s been delaying purely on rollback-safety grounds.</p>
<p><strong>📢HAMi advances to CNCF Incubating</strong></p>
<p>HAMi gives Kubernetes fractional GPU allocation, hard memory and compute isolation, and device-aware scheduling across NVIDIA, AMD, and Ascend hardware — the multi-vendor accelerator layer that’s been missing since GPUs got treated as indivisible bricks rather than a resource you can slice. Incubating status means it’s cleared CNCF’s due-diligence bar on real production adopters, a healthy contributor base, and a security posture that stands up to review.</p>
<p>Read this alongside DRA reaching GA in the same window: HAMi sits directly on top of that scheduling primitive, so together they’re the closest thing Kubernetes has right now to a real, open, production-vetted accelerator-scheduling stack. For anyone running heterogeneous GPU fleets on EKS, this is the project to get hands-on with before building your own fractional-GPU glue code from scratch.</p>
<p>Community &amp; Career 🤝</p>
<p><strong>🤝Shubham Katara: Controlling MCP tools with agentgateway on Kubernetes</strong></p>
<p>The MCP registry closing in on 10,000 tool-server records turned the open question from “can an agent connect to this tool” into “should it, and who decides” — a governance gap with no answer until now. Katara’s guide stands up agentgateway, the open-source, Rust-based, Linux Foundation proxy, on a kind cluster, then routes LLM and MCP traffic through it so agents hold zero real credentials directly; the gateway injects secrets at call time instead.</p>
<p>The mechanism worth copying is the RBAC layer: policies written as CEL expressions make forbidden tools simply invisible to the agent — allow <code>get_me</code>, hide <code>search_repositories</code> — without touching a line of agent code. For any EKS team running agent fleets past a handful of tools, this is the pattern: discover through the registry, enforce through the gateway, keep credentials and tool-access policy in the platform layer instead of scattered across a dozen agent configs.</p>
<p><strong>🤝Plasticity.Cloud &amp; Codriverlabs ship KubeMicroVM v1.0.0</strong></p>
<p>KubeMicroVM brings AWS Lambda MicroVMs into the Kubernetes resource model as first-class CRDs — <code>MicroVM</code>, <code>MicroVMImage</code>, <code>MicroVMReplicaSet</code> — instead of the usual approach of standing up Firecracker or Kata isolation as bespoke cluster plumbing. The auth design is the most interesting part: pods get MicroVM tokens through a sidecar that does a two-step Kubernetes <code>TokenReview</code> + <code>SubjectAccessReview</code> before ever calling AWS, so app containers never hold AWS credentials directly, and tokens live in <code>emptyDir medium: Memory</code> rather than on disk. Built on Quarkus 3, JOSDK, and GraalVM native image on Java 25, with a namespace-labeling model (<code>lambda.aws.amazon.com/manage-microvms=true</code>) that keeps the operator scoped to opted-in teams by default.</p>
<p>It’s early — 14 stars, one fork, v1.0.0 tagged this week — and there’s no published benchmark behind the cold-start claims in the launch announcement, so treat “sub-second” as a number to verify yourself rather than a given. Worth a look if you’re weighing MicroVM-based isolation for agent code execution on EKS specifically and want Kubernetes-native CRDs instead of hand-rolled Firecracker orchestration; also worth knowing before you build on it that it ships under Elastic License 2.0, not a fully permissive OSI license, so check that against your use case before betting production infra on it.</p>
<p><strong>🤝Frank Fan et al.: APEX Skills — agentic platform engineering for EKS</strong></p>
<p>Checking whether a cluster is safe to upgrade today means running a deprecated-API scanner like <code>kubent</code> or <code>eksup</code>, cross-checking addon compatibility by hand against the EKS release notes, then separately eyeballing node health and workload disruption risk — three or four tools, stitched together manually, before you write a single line of the actual upgrade plan. APEX packages that whole sequence as agent skills instead: Recon reads the live cluster state, Upgrade Check runs the deprecated-API and addon-compatibility scoring against it (0-100 across 8 categories), and the bundled MCP server lets the same agent session act on what it finds — check upgrade insights, inspect a flagged pod, remediate — without switching tools or re-explaining cluster context each time.</p>
<p>The catch is exactly what determines whether that’s actually useful or just a faster way to get a wrong answer: an agent-generated 0-100 upgrade score is only as trustworthy as the checks behind it, and none of those individual checks are new capability — <code>kubent</code> and <code>eksup</code> already do deprecated-API and compatibility scanning well on their own. What APEX adds is the orchestration layer: “steering workflows” chain the checks in a fixed order with handoff guardrails, so the agent doesn’t skip straight to writing a remediation plan before recon has actually run. Before trusting this on a cluster you don’t already know cold, run it once against a cluster you do and compare its score against what you already know is broken. Install via <code>npx apex-skills</code>.</p>
<p><strong>🤝defilantech ships LLMKube — a Kubernetes operator for heterogeneous local LLM inference</strong></p>
<p>LLMKube’s <code>Model</code> and <code>InferenceService</code> CRDs turn “run an LLM on Kubernetes” into a two-line YAML deploy, but the standout piece is the Metal Agent: since Apple’s Metal GPU isn’t reachable from inside a container, it runs as a native macOS process outside Kubernetes, spawning <code>llama-server</code> with full GPU access and registering the result back as a normal cluster Endpoint — letting one fleet span NVIDIA cloud nodes and on-prem Macs, something every comparable tool in its own comparison table (KubeAI, llm-d, LocalAI) can’t do.</p>
<p>The benchmarks are real: an NVIDIA L4 on GKE goes from 4.6 tok/s CPU to 64 tok/s GPU (17x), and a dual RTX 5060 Ti setup holds ~53 tok/s across 3B-8B models via automatic layer sharding. Worth knowing the design choice in <code>ModelRouter</code>, its cloud-offload piece: PII/PHI routing rules are rejected at apply-time if they’d ever reach a cloud backend, and if the local pool can’t serve a regulated request at runtime, it returns 503 rather than falling through to cloud — fail-closed by construction. There’s also a real OpenShift fix in the docs: the default <code>fsGroup: 102</code> breaks under <code>restricted-v2</code> SCC, so the OpenShift preset disables it and lets the SCC inject the namespace’s allocated value instead — a fix that only comes from actually running this on OpenShift.</p>
<p>One flag: <code>Foreman</code>, its newest piece (agentic coding across a fleet of local models), is opt-in and v0.1 — a fun bootstrap signal that its own agents authored real PRs against the repo, but early compared to the core operator’s 89-release track record.</p>
<ol>
<li>Highlights ✨</li>
</ol>
<p><strong>✨Carlos Santana: Agent sandboxes on EKS webinar — July 15</strong></p>
<p>Carlos Santana (AWS Sr. Specialist SA for EKS) and Matt Meckes are hosting a live session on the threat model and governance patterns for running AI agents and agent-generated code on Kubernetes — compute isolation, network egress control, identity/credential scoping, and the emerging split where a model provider handles orchestration while execution stays on your own infrastructure. Timely alongside this edition’s KubeMicroVM pieces, which are two different concrete answers to the same isolation question this webinar is framing at the platform level.</p>
<p><strong>✨Kepler re-architected for power-accurate measurement</strong></p>
<p>The CNCF project for per-workload energy consumption shipped a re-architecture aimed specifically at measurement accuracy — the original approach leaned on RAPL/eBPF estimation that got noisier under mixed CPU/GPU load, which is exactly the workload shape most EKS AI clusters run. With GPU electricity now the binding cost constraint on AI platforms rather than the GPU itself, accurate watts-per-pod turns Kepler from a sustainability dashboard into a real input for bin-packing and cost-allocation decisions. A community call for testers is open now if you want to validate it against your own cluster’s power draw before it lands in your cost model.</p>
<p><strong>✨Dragonfly v2.5.0 pulls models directly from Hugging Face</strong></p>
<p>The CNCF P2P distribution project added direct repository downloads from Hugging Face and ModelScope, feeding straight into its existing tiered seed-peer caching instead of requiring a separate pull-and-republish step. That matters at EKS scale specifically because a multi-gigabyte model rollout to hundreds of nodes normally means every node hitting the source repo simultaneously — Dragonfly’s P2P layer turns the first few nodes to pull into seed peers for the rest, so the source repo sees one download instead of one per node.</p>
<p><strong>✨EdgeBench: a scaling law for how agents learn from their environment</strong></p>
<p>ByteDance Seed’s EdgeBench measures agent improvement over time-on-task rather than capability at t=0 — 38,000 hours of runs across 134 real-world tasks (science, SWE, formal math, games), each lasting 12 to 72 hours. Individually noisy learning curves collapse into a clean log-sigmoid pattern with a mean R² of 0.998, and the team’s graph-exploration framing suggests agents are improving from environment interaction roughly 2x every three months. 51 of the 134 tasks plus the eval framework are public. Outside the Kubernetes/EKS scope most of this newsletter covers, but worth knowing if you’re evaluating whether longer agent time-budgets are worth the compute on any platform.</p>
<p><strong>✨GLM-5.2 deployment example added to AWS SageMaker GenAI hosting examples</strong></p>
<p>AWS’s community-maintained SageMaker inference hosting repo (142 stars, 754 commits, actively updated) now includes a GLM-5.2 deployment notebook under its <code>01-models</code> collection, alongside existing examples for Llama, Mistral, and Swiss AI’s Apertus. The repo’s broader pattern is worth knowing regardless of this specific notebook: it walks through optimizing LLM hosting on SageMaker Inference across model servers like DJL Serving and TGI, including hardware options like Inferentia2 — so it’s a reasonable place to start if you’re weighing GLM-5.2 for SageMaker deployment.</p>
<p>🎉 <strong>Sponsor Section</strong></p>
<p>At the moment, we don’t have a sponsor for this edition, but we look forward to working with companies and organizations that support the EKS &amp; AI Infrastructure community in future editions. If you or your company is interested in sponsoring, please contact us at 📧 <strong><a href="mailto:thecloudtechforall@gmail.com">thecloudtechforall@gmail.com</a></strong></p>
<p>📝 <strong>Words from the Author</strong></p>
<p>I watched Ryo Lu’s talk this week — “Closer to the Material” — and I haven’t stopped thinking about it.</p>
<p>Ryo is Cursor’s head of design, and the thing he’s wrestling with isn’t really about design software. It’s about what happens to craft when the tool between your idea and the finished thing gets smart enough to just do it for you. He built ryOS, this whole retro operating system, almost entirely inside Cursor — and the point he keeps circling back to is that AI didn’t remove him from the material, it moved him closer to it. He stopped painting in Figma and started sculpting in code directly, poking and prodding until something that started out ugly sharpened into something that felt inevitable in hindsight.</p>
<p>What got me is the question he asks almost in passing: what should exist, and what does AI change about the loop of finding that out? Because the actual creative loop — try something, feel whether it’s right, adjust — used to be gated by how fast you could produce the next attempt. When the gate opens that wide, the bottleneck isn’t execution anymore. It’s taste. It’s knowing, in your gut, when the thing in front of you is actually good, fast enough to keep up with a tool that can generate ten more versions before you’ve finished the sentence.</p>
<p>I think about this constantly with infrastructure, weirdly. We’re not that different from Ryo’s designers right now — agents can generate a Terraform plan, a Helm chart, an upgrade runbook faster than any of us can review it properly. The instinct that catches “this config is wrong” before you’ve consciously reasoned through why — that’s the same kind of taste he’s talking about, just for YAML instead of pixels. AI hasn’t made that instinct obsolete. If anything, it’s made it the only thing standing between a fleet of agents shipping something plausible and shipping something actually right.</p>
<p>Go watch it if you build anything at all. It’s not really about design software. It’s about what’s worth protecting when the tool stops being the bottleneck.</p>
<p>Happy building. 😎</p>
]]></content:encoded></item><item><title><![CDATA[The GPU Cluster You've Always Wanted: A Deep Dive into SageMaker HyperPod on EKS]]></title><description><![CDATA[You've been running Kubernetes long enough to know what it's good at. You've also hit the wall that every ML team hits when they try to run serious…]]></description><link>https://aditmodi.hashnode.dev/the-gpu-cluster-you-ve-always-wanted-a-deep-dive-into-sagemaker-hyperpod-on-eks</link><guid isPermaLink="true">https://aditmodi.hashnode.dev/the-gpu-cluster-you-ve-always-wanted-a-deep-dive-into-sagemaker-hyperpod-on-eks</guid><dc:creator><![CDATA[Adit Modi]]></dc:creator><pubDate>Sun, 28 Jun 2026 12:51:34 GMT</pubDate><content:encoded><![CDATA[<p><em>You've been running Kubernetes long enough to know what it's good at. You've also hit the wall that every ML team hits when they try to run serious distributed training on it. HyperPod on EKS is AWS's answer to that wall — and it's more interesting than the documentation makes it look.</em></p>
<hr />
<h2>Start With the Problem, Not the Product</h2>
<p>Let me describe a situation that every ML platform engineer has lived through.</p>
<p>Your team is building a foundation model. Not a fine-tune — actual pre-training, or at minimum serious continued pre-training on domain data. You need 64 H100s for six weeks. You stand up an EKS cluster with p5.48xlarge nodes, write your PyTorchJob manifest, fire up your training run, and go home.</p>
<p>At 2am, your Slack lights up. Node 23 threw an NVLink error. The training job detected the failure at rank 23, but by the time Kubernetes marked the pod as failed, the other 63 nodes had already been hanging for 40 minutes waiting for an AllReduce barrier that never completed. Your job crashed. You lost three hours of training progress since the last checkpoint. You need to:</p>
<ol>
<li><p>Identify which node failed</p>
</li>
<li><p>Terminate it manually or wait for Kubernetes to detect node NotReady</p>
</li>
<li><p>Provision a replacement (5-10 minutes for GPU instance bootstrap)</p>
</li>
<li><p>Restart the training job</p>
</li>
<li><p>Wait for all 64 nodes to reload 70GB of model state from S3 (~20 minutes)</p>
</li>
<li><p>Resume training</p>
</li>
</ol>
<p>Total downtime: 60-90 minutes. For a six-week run on 64 H100s, hardware failures happen several times a week. You're burning 8-12% of your total compute budget on failure recovery overhead.</p>
<p>Now multiply that by 1000 GPUs. Meta Llama 3 405B pre-training over 54 days on 16K H100 GPUs experienced 419 unexpected interruptions, with 78% attributed to hardware issues — 58.7% GPU-related, including NVLink failures and HBM3 memory failures. At that scale, the overhead isn't inconvenient — it's existential to your training economics.</p>
<p>This is the problem HyperPod was built to eliminate. Not to reduce. To eliminate.</p>
<hr />
<h2>What HyperPod Actually Is</h2>
<p>Before going deep on the EKS integration, let's be precise about what HyperPod is and isn't, because the marketing framing obscures the architectural reality.</p>
<p>HyperPod is not a training framework. It's not a job scheduler. It's not a replacement for PyTorch or NCCL or your training script. It's managed, resilient GPU cluster infrastructure — the layer below your training framework that ensures the hardware stays healthy and the cluster stays operational across long-running workloads.</p>
<p>The three things HyperPod provides that vanilla EC2 or standard EKS node groups don't:</p>
<p><strong>1. Continuous hardware health monitoring</strong> Every HyperPod node runs a health monitoring agent — a daemon that continuously tests:</p>
<ul>
<li><p>GPU health: ECC errors, temperature, memory bandwidth, compute correctness</p>
</li>
<li><p>NVLink interconnect integrity: the high-speed GPU-to-GPU communication fabric within a node</p>
</li>
<li><p>Network fabric: EFA (Elastic Fabric Adapter) or InfiniBand links between nodes</p>
</li>
<li><p>Memory integrity: HBM3 memory correctness via hardware diagnostics</p>
</li>
</ul>
<p>This isn't passive monitoring. It's active testing — the deep health check runs actual compute operations and validates the results, catching GPU errors that wouldn't show up in a simple <code>nvidia-smi</code> health check.</p>
<p><strong>2. Automatic node replacement</strong> When the health agent detects a fault, HyperPod acts immediately — it doesn't wait for Kubernetes to time out the node, it doesn't wait for your training job to crash. It:</p>
<ul>
<li><p>Terminates the faulty instance</p>
</li>
<li><p>Provisions a replacement from the same instance family</p>
</li>
<li><p>Bootstraps the replacement with the HyperPod AMI (pre-loaded with drivers, NCCL, EFA configuration)</p>
</li>
<li><p>Re-integrates it into the cluster</p>
</li>
</ul>
<p>The AMI pre-loading matters. Standard EC2 instance bootstrap requires downloading and installing CUDA drivers, NCCL, EFA drivers, and AWS-specific optimizations. On a p5.48xlarge with 8 H100s, this takes 15-30 minutes. HyperPod AMIs come with all of this pre-installed and pre-configured. Replacement node bootstrap time: 2-5 minutes instead of 15-30.</p>
<p><strong>3. Job recovery</strong> Once the replacement node is healthy, HyperPod can recover the training job. The classic approach is checkpoint-based: save model state to S3, reload from checkpoint after failure. HyperPod now supports checkpointless recovery — using peer-to-peer transfer of model and optimizer states directly between GPUs on healthy nodes to the replacement. This enables over 95% training goodput on clusters with thousands of AI accelerators.</p>
<hr />
<h2>Why EKS? Why Not Just Use Slurm?</h2>
<p>HyperPod launched with Slurm as the only orchestrator. Slurm is the right choice for pure training workloads at research scale — it has native gang scheduling, mature fair-share policies, MPI integration, and topology-aware placement that Kubernetes can only approximate with add-ons.</p>
<p>But most product engineering teams aren't Slurm shops. They're Kubernetes shops. They have ArgoCD for GitOps, Helm charts for everything, Prometheus and Grafana for observability, RBAC for team access control, existing CI/CD pipelines that produce container images. Asking them to learn Slurm job semantics, <code>sbatch</code> scripts, and Slurm account management to get HyperPod resiliency is a hard sell.</p>
<p>EKS orchestration for HyperPod was AWS's answer to that problem: bring HyperPod's infrastructure resilience into the Kubernetes ecosystem, so teams can use familiar tools while getting the hardware reliability that large-scale training demands.</p>
<p>Thomson Reuters, Perplexity AI, and Hugging Face use HyperPod for FM training and inference on Slurm clusters. AI startups like Observea and Articul8, and enterprises like Thomson Reuters, use the EKS support to manage their ML model development lifecycle.</p>
<p>The choice isn't really about which is better — it's about what your team already knows and what your workloads require. We'll come back to this with a proper decision framework.</p>
<hr />
<h2>The Architecture: What's Actually Running Where</h2>
<p>The high-level architecture involves a 1-to-1 mapping between an EKS cluster (control plane) and a HyperPod cluster (worker nodes) within a VPC.</p>
<p>Let me unpack what this actually means in physical terms:</p>
<pre><code class="language-plaintext">┌─────────────────────────────────────────────────────────┐
│                 AWS-Managed EKS Control Plane           │
│                                                         │
│   Kubernetes API Server                                 │
│   etcd (cluster state)                                  │
│   kube-scheduler                                        │
│   kube-controller-manager                               │
│                                                         │
│   EKS Add-ons (managed by AWS):                         │
│   ├── VPC CNI (pod networking)                          │
│   ├── CoreDNS                                           │
│   ├── kube-proxy                                        │
│   ├── EBS CSI Driver                                    │
│   └── Pod Identity Agent                                │
│                                                         │
│   HyperPod Add-ons (managed by SageMaker):              │
│   ├── HyperPod Training Operator                        │
│   ├── HyperPod Task Governance (Kueue-based)            │
│   ├── HyperPod Inference Operator                       │
│   ├── HyperPod Observability (Prometheus + Grafana)     │
│   └── SageMaker Spaces (JupyterLab/VSCode)             │
└──────────────────────────┬──────────────────────────────┘
                           │ Kubernetes API (kubelet registration)
┌──────────────────────────▼──────────────────────────────┐
│              HyperPod Worker Nodes                      │
│         (GPU instances in your VPC subnets)             │
│                                                         │
│   Per-node:                                             │
│   ├── HyperPod resiliency agent (health monitoring)     │
│   ├── HyperPod health-monitoring agent                  │
│   ├── kubelet (registers with EKS control plane)        │
│   ├── NVIDIA driver (pre-installed in AMI)              │
│   ├── CUDA toolkit (pre-installed in AMI)               │
│   ├── NCCL (pre-installed, AWS-optimized build)         │
│   └── EFA drivers (pre-installed, for inter-node comms) │
│                                                         │
│   Hardware:                                             │
│   ├── p5.48xlarge: 8× NVIDIA H100 SXM + 3200 Gbps EFA  │
│   ├── p5e.48xlarge: 8× NVIDIA H100 + enhanced memory   │
│   ├── p4d.24xlarge: 8× NVIDIA A100 + 400 Gbps EFA      │
│   ├── g6e.48xlarge: 8× NVIDIA L40S                      │
│   ├── g7e.48xlarge: 8× NVIDIA RTX PRO 6000 Blackwell   │
│   └── trn1.32xlarge: 16× AWS Trainium chips             │
└─────────────────────────────────────────────────────────┘
</code></pre>
<p>The key architectural insight: <strong>EKS is the brain, HyperPod is the body.</strong> The Kubernetes control plane handles scheduling, RBAC, API access, and workload management. HyperPod handles the physical compute — hardware health, node lifecycle, fault recovery, and the AWS-specific optimizations baked into the AMI.</p>
<p>From your perspective as a cluster operator, you interact with both through familiar tools: <code>kubectl</code> for workload management, <code>aws sagemaker</code> CLI for cluster lifecycle operations, and the SageMaker console for visibility.</p>
<hr />
<h2>The EKS Add-Ons That Make It All Work</h2>
<p>HyperPod on EKS ships as a suite of EKS add-ons — managed Kubernetes extensions that AWS installs, upgrades, and operates. You can install them one by one as EKS add-ons, or use the SageMaker console's Quick Setup which handles all dependencies automatically.</p>
<p>Understanding what each add-on does is essential to understanding the system.</p>
<h3>HyperPod Training Operator</h3>
<p>This is the most important add-on. It's a Kubernetes controller that replaces the standard Kubeflow Training Operator for PyTorchJob management on HyperPod clusters.</p>
<p><strong>What the standard Training Operator does:</strong> When rank N of a PyTorchJob fails, its pod crashes. Kubernetes eventually marks it as failed. The other ranks timeout waiting for collective communication (AllReduce, AllGather). Eventually they crash too. The entire job restarts from the last checkpoint.</p>
<p><strong>What the HyperPod Training Operator does differently:</strong></p>
<p>The HyperPod training operator maintains a control plane with a global view of health across all ranks. When one rank encounters an issue, it broadcasts a stop signal to all ranks to prevent other ranks from failing individually at different times due to collective communication timeout. This supports more efficient fault detection and recovery.</p>
<p>The key differences:</p>
<ul>
<li><p><strong>Coordinated stop:</strong> Instead of ranks hanging until timeout, all ranks stop simultaneously on the first detected failure</p>
</li>
<li><p><strong>Surgical process recovery:</strong> Instead of restarting the entire job, the operator can restart just the failed process on the recovered node</p>
</li>
<li><p><strong>Hanging job detection:</strong> Identifies jobs where processes have stopped making progress (stuck in a collective communication call) even when no crash has occurred — a particularly insidious failure mode in distributed training</p>
</li>
<li><p><strong>Centralized rank assignment:</strong> A HyperPod rendezvous backend handles rank assignment directly, reducing initialization overhead by eliminating worker-to-worker discovery</p>
</li>
</ul>
<p>The practical result: recovery time from a node failure drops from 60-90 minutes (standard Kubernetes approach) to 10-15 minutes (HyperPod Training Operator with node replacement).</p>
<p><strong>Installation:</strong></p>
<pre><code class="language-bash">aws eks create-addon \
  --cluster-name my-hyperpod-cluster \
  --addon-name amazon-sagemaker-hyperpod-training \
  --region us-east-1
</code></pre>
<p>Or via the SageMaker console's Quick Setup, which handles all prerequisites automatically.</p>
<h3>HyperPod Task Governance</h3>
<p>This is HyperPod's managed implementation of Kueue — the Kubernetes-native batch scheduling and quota management system.</p>
<p><strong>Why it exists:</strong> In a shared GPU cluster with multiple teams, you need to answer questions like:</p>
<ul>
<li><p>How many GPUs can the research team use?</p>
</li>
<li><p>Can the product team borrow unused research team capacity?</p>
</li>
<li><p>What happens when a high-priority fine-tuning job needs GPUs that a pre-training job is using?</p>
</li>
<li><p>Who has access to which cluster resources?</p>
</li>
</ul>
<p>Standard Kubernetes <code>ResourceQuotas</code> and <code>LimitRanges</code> are too coarse. They can bound total resource consumption but can't express priorities, borrowing policies, or preemption semantics. Kueue was built specifically for this.</p>
<p>HyperPod task governance leverages Kueue for Kubernetes-native job queueing, scheduling, and quota management, installed as an EKS add-on. Kueue doesn't replace existing Kubernetes scheduling components but integrates with kube-scheduler — Kueue decides whether a workload should be admitted based on resource quotas and current utilization, and then kube-scheduler handles pod placement on nodes.</p>
<p><strong>The three-level hierarchy:</strong></p>
<p><strong>ClusterQueue</strong> — Cluster-wide resource pool. Defines the total resources available, the borrowing policies, and which teams have access. Created and managed by platform administrators.</p>
<p><strong>LocalQueue</strong> — Team-scoped entry point. Each team has a LocalQueue in their namespace. Jobs submitted to a LocalQueue compete only within that team's allocation.</p>
<p><strong>Workload</strong> — A single job submission. Kueue creates a Workload object for every job, evaluates it against available quota and priorities, and either admits it (schedules it to run) or queues it (holds it until capacity is available).</p>
<p><strong>An example policy:</strong></p>
<pre><code class="language-yaml"># Platform admin creates a ClusterQueue
apiVersion: kueue.x-k8s.io/v1beta1
kind: ClusterQueue
metadata:
  name: ml-cluster-queue
spec:
  namespaceSelector: {}
  resourceGroups:
  - coveredResources: ["nvidia.com/gpu"]
    flavors:
    - name: p5-h100
      resources:
      - name: "nvidia.com/gpu"
        nominalQuota: 64     # research team gets 64 GPUs
        borrowingLimit: 32   # can borrow up to 32 more from shared pool
  preemption:
    reclaimWithinCohort: Any
    borrowWithinCohort:
      policy: LowerPriority
    withinClusterQueue: LowerPriority
</code></pre>
<p><strong>Fair-share weight:</strong> When multiple teams compete for idle capacity, the fair-share weight determines relative prioritization. Higher weight enables a team to access unutilized resources sooner.</p>
<p><strong>LendAndBorrow strategy:</strong> A compute allocation policy can allocate instances with a LendAndBorrow strategy with a borrowing limit. For example, a team allocated 2 GPUs can borrow up to 50% more capacity, enabling flexible resource sharing while maintaining priority through fair-share weight and the ability to preempt lower priority tasks from other teams.</p>
<p><strong>What data scientists see:</strong> When submitting a job, they add two labels to their manifest:</p>
<pre><code class="language-yaml">metadata:
  name: llama-finetune-run
  namespace: hyperpod-ns-research-team
  labels:
    kueue.x-k8s.io/queue-name: hyperpod-ns-research-team-localqueue
    kueue.x-k8s.io/priority-class: training-priority
</code></pre>
<p>If capacity is available, the job runs immediately. If not, it queues. When a higher-priority job needs resources, lower-priority jobs are preempted gracefully — their state is checkpointed, they yield resources, and they resume when capacity is available again.</p>
<p><strong>Debugging admission:</strong> When a job is stuck in the queue, you can ask why:</p>
<pre><code class="language-bash">kubectl describe workload &lt;workload-name&gt; -n hyperpod-ns-research-team
# Shows: "Insufficient quota in ClusterQueue ml-cluster-queue: 
#  requires 16 nvidia.com/gpu, available 8"
</code></pre>
<h3>HyperPod Inference Operator</h3>
<p>The HyperPod Inference Operator is now a native EKS add-on, enabling one-click installation and managed upgrades directly from the SageMaker console. This eliminates manual Helm charts, complex IAM configuration, and downtime during upgrades.</p>
<p>What it enables: serving inference endpoints on the same HyperPod cluster running training. Instead of maintaining separate infrastructure for training and inference, you can run both on the same GPU fleet, with Task Governance managing prioritization between them.</p>
<p><strong>The inference stack on HyperPod EKS:</strong></p>
<ul>
<li><p><strong>Inference Operator</strong> — manages model deployment lifecycle, handles ALB provisioning, manages TLS certificates</p>
</li>
<li><p><strong>KEDA</strong> — scales inference pods based on request queue depth, CloudWatch metrics, or Prometheus metrics. Can scale to zero when there's no traffic.</p>
</li>
<li><p><strong>Karpenter</strong> — provisions and removes GPU nodes for inference based on pending pod requirements</p>
</li>
<li><p><strong>S3 Mountpoint / FSx for Lustre</strong> — model weight storage, mounted directly into inference pods without copying to local disk</p>
</li>
</ul>
<p><strong>Deployment methods:</strong> Three options for deploying a model:</p>
<ol>
<li><p><strong>JumpStart</strong> — select a model from the SageMaker JumpStart catalog, deploy in two clicks. Optimized container configurations pre-built.</p>
</li>
<li><p><strong>S3</strong> — point to model weights in S3, specify a serving container, deploy</p>
</li>
<li><p><strong>FSx</strong> — for very large models, mount weights from FSx for Lustre (high-throughput distributed filesystem) directly into the serving pod</p>
</li>
</ol>
<h3>HyperPod Observability</h3>
<p>One of the most practically useful add-ons, and one of the least discussed.</p>
<p>The observability add-on installs a complete observability stack:</p>
<ul>
<li><p>Amazon Managed Service for Prometheus (AMP) for metrics collection</p>
</li>
<li><p>Amazon Managed Grafana pre-configured with HyperPod-specific dashboards</p>
</li>
<li><p>Pre-built dashboards for: GPU utilization per node/per rank, training throughput (tokens/sec, samples/sec), loss curves, NCCL communication efficiency, node health status, inference latency and TTFB, Kueue queue depth and wait times</p>
</li>
</ul>
<p>The observability add-on automatically populates Kubeflow Training metrics in Grafana dashboards out of the box. For HyperPod PyTorch job metrics, enabling advanced training metrics through the HyperPod training operator shows information around job downtime, job recovery, faults, and downtime.</p>
<p>The operational value: instead of spending time setting up Prometheus scrapers, building Grafana dashboards, and learning which GPU metrics matter for distributed training, you get production-grade observability on day one. When a training run is underperforming, the GPU utilization dashboard immediately shows whether you have a compute bottleneck, a communication bottleneck, or a data loading bottleneck.</p>
<h3>SageMaker Spaces</h3>
<p>HyperPod EKS clusters now support creating and managing interactive development environments such as JupyterLab and open-source Visual Studio Code, streamlining the ML development lifecycle by providing managed environments for data scientists to run notebooks on the same infrastructure as their training jobs.</p>
<p>The value: data scientists can do exploratory work and prototype training runs on the same GPU cluster that runs production training, without managing separate notebook infrastructure. When they're ready to scale a run, they submit a PyTorchJob from the same environment. The compute is the same; only the orchestration changes.</p>
<hr />
<h2>Continuous Provisioning: The New Scaling Model</h2>
<p>One of HyperPod's most recent and significant architectural updates is continuous provisioning — a new cluster scaling model that replaces the original request-based approach.</p>
<p>Continuous provisioning introduces a desired-state architecture that replaces the traditional request-based model. This new architecture enables parallel, non-blocking operations across different resource levels while maintaining system stability and performance.</p>
<p><strong>The old model:</strong> You specify a fixed instance count at cluster creation. Scaling required a cluster update operation that blocked other operations.</p>
<p><strong>The new model with continuous provisioning:</strong></p>
<pre><code class="language-bash">aws sagemaker create-cluster \
  --cluster-name my-training-cluster \
  --orchestrator 'Eks={ClusterArn='$EKS_CLUSTER_ARN'}' \
  --instance-groups '{
    "InstanceGroupName": "gpu-workers",
    "InstanceType": "ml.p5.48xlarge",
    "InstanceCount": 64,
    "MinInstanceCount": 48,
    "NodeProvisioningMode": "Continuous"
  }'
</code></pre>
<p>The <code>MinInstanceCount</code> parameter is new and important. Setting <code>MinInstanceCount</code> to 48 with <code>InstanceCount</code> to 64 means:</p>
<ul>
<li><p>HyperPod guarantees at least 48 nodes are always available</p>
</li>
<li><p>It can scale up to 64 based on workload demand</p>
</li>
<li><p>Setting <code>MinInstanceCount</code> equal to <code>InstanceCount</code> gives all-or-nothing behavior (classic gang-scheduling-style guarantee)</p>
</li>
<li><p>Setting <code>MinInstanceCount</code> to 0 gives fully elastic behavior</p>
</li>
</ul>
<p>Operations that previously blocked (adding nodes, software updates) now run in parallel without disrupting running workloads. Continuous provisioning uses instance-level metering, with billing starting when the lifecycle script execution begins and stopping when the instance enters a terminating state — independent of other instances in the cluster.</p>
<hr />
<h2>Elastic Training: The GPU Utilization Story</h2>
<p>One of the most compelling economic arguments for HyperPod on EKS is elastic training — the ability to dynamically scale training jobs based on actual available capacity.</p>
<p><strong>The problem it solves:</strong></p>
<p>Consider a 256 GPU cluster running both training and inference. During off-peak hours at night, inference may release 96 GPUs. Traditional training jobs run at a fixed scale and can't absorb idle compute — a job that starts with 32 GPUs is locked at 32 while 96 additional GPUs remain idle. This represents 2,304 wasted GPU-hours per day — thousands of dollars daily in underutilized infrastructure.</p>
<p><strong>How elastic training works with Kueue:</strong></p>
<p>For clusters using Kueue (including HyperPod task governance), elastic training implements intelligent workload management through multiple admission requests. The operator first requests minimum required resources with high priority, then incrementally requests additional capacity with lower priority. This enables partial preemption: when higher-priority workloads need resources, only the lower-priority replicas are revoked, allowing training to continue on the guaranteed baseline rather than terminating completely.</p>
<p>The flow:</p>
<ol>
<li><p>Elastic training job starts with minimum guaranteed GPUs (say 32)</p>
</li>
<li><p>Operator submits additional Kueue Workload objects for incremental capacity at lower priority</p>
</li>
<li><p>As capacity becomes available, Kueue admits additional Workloads</p>
</li>
<li><p>Training scales up, absorbing idle GPUs</p>
</li>
<li><p>High-priority fine-tuning job arrives and needs GPUs</p>
</li>
<li><p>Kueue preempts the lower-priority elastic replicas only — training continues on the 32 guaranteed GPUs</p>
</li>
<li><p>Fine-tuning job gets its GPUs</p>
</li>
<li><p>When fine-tuning completes, elastic training scales back up</p>
</li>
</ol>
<p><strong>The critical operational detail:</strong></p>
<p>When Karpenter is used together with elastic training, the cluster may scale up unboundedly because the elastic training operator continuously requests additional resources using a greedy approach. To keep scale-ups predictable, namespace-level ResourceQuotas must be configured in namespaces where elastic training jobs run.</p>
<p>Without ResourceQuotas as a ceiling, your cluster will keep growing until it hits account-level EC2 limits. This is a non-obvious gotcha that will burn you if you're not expecting it.</p>
<pre><code class="language-yaml"># Required: cap the namespace to prevent unbounded scaling
apiVersion: v1
kind: ResourceQuota
metadata:
  name: training-quota
  namespace: hyperpod-ns-research-team
spec:
  hard:
    requests.nvidia.com/gpu: "128"   # Maximum 128 GPUs for this team
    limits.nvidia.com/gpu: "128"
</code></pre>
<hr />
<h2>The HyperPod CLI: Abstractions for Data Scientists</h2>
<p>HyperPod on EKS ships with a custom CLI (<code>hyp</code>) that abstracts Kubernetes complexity for data scientists who don't need to know about pods, operators, and YAML manifests.</p>
<p><strong>The core workflow:</strong></p>
<pre><code class="language-bash"># Install the CLI
pip install sagemaker-hyperpod-cli

# Configure cluster context
hyp set-cluster-context --cluster-name my-training-cluster

# Check available capacity
hyp list-clusters
# Output: shows per-namespace GPU availability

# Submit a training job (using a config YAML, not a Kubernetes manifest)
hyp start-job --config-file my_training_config.yaml

# List running jobs
hyp list-jobs

# Get job details
hyp get-job --job-name llama-pretrain-run

# Cancel a job
hyp cancel-job --job-name llama-pretrain-run
</code></pre>
<p>The job config YAML is simplified — no Kubernetes operator syntax, no tolerations, no node selectors beyond what HyperPod needs:</p>
<pre><code class="language-yaml">training_cfg:
  entry_script: /opt/training/train.py
  script_args: ["--model", "llama-70b", "--data", "s3://my-bucket/data"]
  
run:
  name: llama-pretrain-run
  nodes: 8
  ntasks_per_node: 8

cluster:
  cluster_type: k8s
  instance_type: ml.p5.48xlarge
  cluster_config:
    label_selector:
      required:
        sagemaker.amazonaws.com/node-health-status:
          - Schedulable
      preferred:
        sagemaker.amazonaws.com/deep-health-check-status:
          - Passed
        weights:
          - 100

container: 763104351884.dkr.ecr.us-east-1.amazonaws.com/pytorch-training:2.3.0-gpu-py311
</code></pre>
<p>Notice <code>deep-health-check-status: Passed</code> in the preferred node selector. This is HyperPod-specific — it tells the scheduler to prefer nodes that have passed the full hardware diagnostic suite, not just basic health checks. Your training job gets scheduled on nodes most likely to stay healthy.</p>
<p><strong>Recipes:</strong> For common models, HyperPod ships pre-built training recipes:</p>
<pre><code class="language-bash"># Initialize a fine-tuning recipe for Llama 3.1 8B
hyp init hyp-recipe-job . \
  --huggingface-model-id meta-llama/Llama-3.1-8B-Instruct \
  --technique SFT \
  --instance-type ml.g5.48xlarge

# Configure and launch
hyp configure \
  --name llama-sft-run \
  --data-path /data/training.jsonl \
  --global-batch-size 8 \
  --learning-rate 0.0001 \
  --max-epochs 3 \
  --output-path /data/output
</code></pre>
<p>The CLI queries available HyperPod clusters, finds ones with compatible instance types, and lets you choose. You don't need to know cluster ARNs or Kubernetes contexts.</p>
<hr />
<h2>The Hardware Story: What You're Actually Running On</h2>
<p>HyperPod on EKS supports a wide range of GPU and accelerator instance types, and understanding which to use for which workload is non-trivial.</p>
<p><strong>For large-scale training (pre-training, continued pre-training):</strong></p>
<p><code>ml.p5.48xlarge</code> — 8× NVIDIA H100 SXM with 80GB HBM3 each (640GB total GPU memory), 3,200 Gbps EFA networking. The workhorse for serious training. NVLink within the node connects all 8 GPUs at 900 GB/s aggregate bandwidth.</p>
<p><code>ml.p5e.48xlarge</code> — Enhanced version with higher GPU memory. For models where 640GB isn't enough per node.</p>
<p><code>ml.p4d.24xlarge</code> — 8× NVIDIA A100 SXM with 40GB HBM2 each, 400 Gbps EFA. Previous generation, still widely used, lower cost than p5.</p>
<p><strong>For inference and fine-tuning:</strong></p>
<p><code>ml.g6e.12xlarge</code> / <code>ml.g6e.48xlarge</code> — NVIDIA L40S GPUs. Excellent inference-to-training cost ratio. L40S has 48GB GDDR6X memory and is optimized for inference throughput.</p>
<p><code>ml.g7e</code> instances are powered by NVIDIA RTX PRO 6000 Blackwell Server Edition GPUs and deliver up to 2.3x better inference performance than G6e instances. With up to 768 GB of total GPU memory, G7e instances let you deploy larger language models or run multiple models on a single endpoint. They are well-suited for cost-efficient single-node fine-tuning with up to 1.27x the TFLOPs and up to 4x the GPU-to-GPU bandwidth compared to G6e.</p>
<p><strong>For cost-optimized training:</strong></p>
<p><code>ml.trn1.32xlarge</code> — 16× AWS Trainium chips with 512GB HBM2 total. Trainium is AWS's custom ML training chip, typically 40-60% cheaper per FLOP than H100 for transformer training. Requires using AWS Neuron SDK instead of CUDA — most PyTorch training code works with minor modifications.</p>
<p><strong>The r5d.16xlarge addition:</strong></p>
<p>HyperPod now supports r5d.16xlarge instances, which provide 64 vCPUs, 512 GB of memory, and 5×600 GB NVMe SSD instance storage. This instance is well suited for distributed training data preprocessing with frameworks such as Ray, large-scale feature engineering, and running memory-heavy orchestration services alongside GPU compute.</p>
<p>The r5d addition matters because data preprocessing and orchestration services often need more CPU and memory than GPU instances provide. Running Ray on r5d nodes for data preprocessing while training on p5 nodes is now a supported HyperPod topology.</p>
<p><strong>Topology-aware scheduling:</strong></p>
<p>HyperPod optimizes instance placement automatically. For p5 instances with EFA networking, HyperPod ensures that nodes in the same training job are placed within the same availability zone and on network switches that minimize hop count. Inter-node bandwidth for large AllReduce operations directly impacts training throughput — a 32-node training job with poor network placement can be 20-30% slower than one with optimal placement.</p>
<hr />
<h2>Multi-Tenant Operations: The Real Production Story</h2>
<p>Running a shared GPU cluster for multiple ML teams is where theory meets operational reality. Here's how the pieces fit together in practice.</p>
<p><strong>The platform admin perspective:</strong></p>
<p>When setting up a multi-tenant HyperPod cluster, the admin configures:</p>
<ol>
<li><p><strong>Team namespaces</strong> — HyperPod Task Governance creates <code>hyperpod-ns-&lt;team-name&gt;</code> namespaces automatically when compute allocations are created</p>
</li>
<li><p><strong>Compute allocations</strong> — Define which teams get which resources:</p>
<ul>
<li><p>Nominal quota (guaranteed resources)</p>
</li>
<li><p>Borrowing limit (maximum they can use from shared pool)</p>
</li>
<li><p>Fair-share weight (relative priority when competing for idle resources)</p>
</li>
<li><p>Priority classes (inference vs training vs research priority)</p>
</li>
</ul>
</li>
<li><p><strong>Node labels</strong> — HyperPod nodes are labeled with health status:</p>
<ul>
<li><p><code>sagemaker.amazonaws.com/node-health-status: Schedulable</code></p>
</li>
<li><p><code>sagemaker.amazonaws.com/deep-health-check-status: Passed</code></p>
</li>
</ul>
<p>These labels update automatically as health checks run and nodes recover. Training jobs can prefer deeply healthy nodes; less critical workloads can tolerate nodes that have only passed basic checks.</p>
</li>
</ol>
<p><strong>The data scientist perspective:</strong></p>
<p>From a data scientist's view, the complexity is hidden:</p>
<ul>
<li><p>They have a namespace (<code>hyperpod-ns-research-team</code>)</p>
</li>
<li><p>They have a LocalQueue (<code>hyperpod-ns-research-team-localqueue</code>)</p>
</li>
<li><p>They submit jobs to their queue with a priority class</p>
</li>
<li><p>If their quota allows, the job runs immediately</p>
</li>
<li><p>If not, it waits and they can see their position in the queue</p>
</li>
<li><p>If a higher-priority job needs their resources, their job pauses and resumes automatically</p>
</li>
</ul>
<p><strong>The capacity visibility commands:</strong></p>
<pre><code class="language-bash"># Check team GPU availability
hyp list-clusters
# Output: per-namespace GPU allocation and current usage

# Check your job's queue position
kubectl get workloads -n hyperpod-ns-research-team

# Understand why a job is waiting
kubectl describe workload my-training-job -n hyperpod-ns-research-team
# Output: "Insufficient quota. Required: 16 nvidia.com/gpu. 
#  Available in LocalQueue: 8. Currently borrowed: 4."

# Admin view: cluster-wide GPU utilization by team
# Available in SageMaker HyperPod console → Dashboard tab
</code></pre>
<p><strong>The borrowing and preemption flow in practice:</strong></p>
<p>A realistic scenario with two teams sharing a 64 H100 cluster:</p>
<ul>
<li><p>Research team: 32 H100 nominal, can borrow up to 16 more</p>
</li>
<li><p>Product team: 32 H100 nominal, can borrow up to 16 more</p>
</li>
<li><p>Fair-share weights: Research = 10, Product = 15</p>
</li>
</ul>
<p>At 2pm on a Tuesday:</p>
<ul>
<li><p>Research team has a 40-GPU pre-training job running (borrowed 8 from product's idle allocation)</p>
</li>
<li><p>Product team's fine-tuning pipeline kicks off and needs 32 GPUs</p>
</li>
<li><p>Product team has higher fair-share weight</p>
</li>
<li><p>Kueue preempts the 8 borrowed replicas from the research team's elastic training job</p>
</li>
<li><p>Research training continues on its guaranteed 32 GPUs</p>
</li>
<li><p>Product fine-tuning gets its 32 GPUs</p>
</li>
<li><p>Total cluster utilization: 64/64 GPUs used</p>
</li>
</ul>
<p>This happens automatically, with no manual coordination between teams, no support tickets, and no wasted capacity.</p>
<hr />
<h2>The Training-to-Inference Pipeline</h2>
<p>One of the unique advantages of HyperPod on EKS over a Slurm-only setup is the ability to run training and inference on the same cluster — with the same Task Governance system managing priorities between them.</p>
<p><strong>The production pattern:</strong></p>
<pre><code class="language-plaintext">Data Scientists
    ↓ hyp start-job or kubectl apply
HyperPod Task Governance (Kueue)
    ↓ admits based on priority and quota
EKS Scheduler
    ↓ places pods
HyperPod Training Nodes (p5.48xlarge)
    ↓ training completes, artifact written to S3
SageMaker Model Registry
    ↓ model version registered, approval workflow
HyperPod Inference Operator
    ↓ deploys model to HyperPod Inference Nodes (g7e, g6e)
KEDA
    ↓ scales inference pods based on request queue
ALB (Application Load Balancer)
    ↓ routes inference traffic
Your Application
</code></pre>
<p>The training and inference nodes are different instance types on the same cluster. HyperPod Task Governance treats inference workloads as a high-priority workload class — when an inference endpoint needs to scale up, training jobs can yield resources. When training is complete and inference isn't busy, training jobs absorb that idle capacity.</p>
<p><strong>Inference priority over training:</strong></p>
<pre><code class="language-yaml"># Inference pods submit to the inference priority class
metadata:
  labels:
    kueue.x-k8s.io/priority-class: inference-priority

# Pre-training jobs submit to lower priority
metadata:
  labels:
    kueue.x-k8s.io/priority-class: training-priority
</code></pre>
<p>Kueue automatically preempts lower-priority training replicas when inference needs more capacity, and restores them when inference scales back down.</p>
<hr />
<h2>The Blackwell Moment</h2>
<p>Each NVL72 UltraServer combines 18 instances with 72 NVIDIA Blackwell GPUs interconnected via NVLink, enabling faster inference and faster training performance than previous generation instances. This architecture is particularly valuable for organizations working with trillion-parameter foundation models — the unified GPU memory allows entire models to remain within a single NVLink domain, eliminating cross-node networking bottlenecks.</p>
<p>The NVL72 changes the unit of computation for frontier models. Instead of thinking about individual GPU nodes connected by EFA networking (which has latency), you're thinking about a single 72-GPU NVLink domain where all GPUs share a 14.4 TB/s memory fabric. Model parallelism within this domain has effectively zero communication overhead.</p>
<p>HyperPod's topology-aware scheduling optimizes placement for NVL72 architectures — workloads that can fit within a single NVLink domain are scheduled to stay there, while workloads that require multiple NVL72 servers get optimized EFA placement between them.</p>
<hr />
<h2>When to Use HyperPod on EKS vs Alternatives</h2>
<p>Let's be honest about where HyperPod on EKS is the right answer and where it isn't.</p>
<p><strong>Use HyperPod on EKS when:</strong></p>
<p>You're training models that take more than a few hours on more than a handful of GPUs. Below this threshold — a fine-tune that takes 4 hours on 4 GPUs — SageMaker Training Jobs (the managed, serverless option) are simpler and probably cheaper once you account for idle cluster time.</p>
<p>Your team is already Kubernetes-native. If your data scientists don't know Slurm, don't make them learn it. HyperPod on EKS lets them use the tools they already know.</p>
<p>You need training and inference on the same infrastructure. The Task Governance + Inference Operator combination for a unified GPU fleet is a HyperPod-specific capability.</p>
<p>You're running multi-tenant GPU clusters with multiple teams. The managed Kueue integration, fair-share scheduling, and the HyperPod console's GPU utilization dashboard give you operational visibility that's hard to replicate manually.</p>
<p>You want AWS to manage hardware failure recovery. If your alternative is writing and maintaining your own health monitoring, node replacement automation, and job recovery logic — HyperPod does this for you.</p>
<p><strong>Use Slurm on HyperPod when:</strong></p>
<p>Your team comes from HPC or academic research and thinks in <code>sbatch</code> scripts. Don't force Kubernetes on them.</p>
<p>You're doing pure training with no serving requirements. Slurm's gang scheduling, topology awareness, and fair-share are more mature for pure batch training than Kubernetes even with Kueue.</p>
<p>You need MPI workloads beyond PyTorch. For non-PyTorch HPC workloads, Slurm + OpenMPI is a solved problem. Kubernetes is an approximation.</p>
<p><strong>Use SageMaker Training Jobs (not HyperPod) when:</strong></p>
<p>You want fully serverless, no cluster to manage. Training Jobs provision instances on demand per job, run, and release. You pay per second, no idle cluster cost. Right for teams running occasional training jobs, not continuous training workloads.</p>
<p>Your jobs are short (under a few hours) or small (single-digit GPUs). The overhead of maintaining a persistent cluster isn't justified.</p>
<p><strong>Use vanilla EKS + Karpenter (not HyperPod) when:</strong></p>
<p>You're running inference only, no training. HyperPod's value is in training resiliency. For inference clusters, standard EKS + Karpenter is simpler and fully sufficient.</p>
<p>You have highly heterogeneous workloads that aren't GPU-focused. HyperPod is optimized for GPU compute. For general-purpose Kubernetes workloads, standard EKS is the right choice.</p>
<hr />
<h2>The Operational Reality: What's Hard</h2>
<p>Being honest about what HyperPod on EKS requires of you operationally:</p>
<p><strong>Cluster lifecycle is non-trivial.</strong> Unlike SageMaker Training Jobs where you submit a job and forget, HyperPod clusters are persistent infrastructure. You need to manage Kubernetes version upgrades, AMI updates (HyperPod releases new AMIs regularly — the release notes show monthly updates), add-on version management, and node pool scaling. The SageMaker console simplifies this significantly, but it's still operational work.</p>
<p><strong>The Karpenter + elastic training interaction requires attention.</strong> As discussed earlier, elastic training's greedy resource requests combined with Karpenter's automatic node provisioning can cause unexpected scale-up without ResourceQuotas in place. This is a footgun that bites teams who don't read the documentation carefully.</p>
<p><strong>Deep health checks add latency to node replacement.</strong> The comprehensive hardware diagnostics that make HyperPod's health checks valuable also take time to run on newly provisioned nodes. A replacement node doesn't become schedulable until it passes health checks. For most workloads this is fine — you'd rather wait 5 minutes for a known-healthy node than immediately schedule on a node with latent hardware issues.</p>
<p><strong>Multi-instance-type clusters require careful governance configuration.</strong> If your cluster has both p5 (H100) and g6e (L40S) nodes, Kueue's ResourceFlavors need to be configured correctly to ensure jobs requesting H100s get H100s, not L40S nodes. The Task Governance configuration for heterogeneous clusters is more complex than homogeneous ones.</p>
<p><strong>IAM configuration is substantial.</strong> HyperPod on EKS requires IAM roles for the HyperPod service, the Training Operator, the Inference Operator, KEDA, the ALB controller, EFS/FSx access, and the nodes themselves. The Quick Setup handles most of this automatically, but custom configurations require understanding the IAM requirements deeply.</p>
<hr />
<h2>Getting Started: The Right Sequence</h2>
<p>If you're starting from scratch with HyperPod on EKS, here's the sequence that minimizes friction:</p>
<p><strong>Step 1: Start with the console Quick Setup</strong></p>
<p>Don't try to script everything from the start. The Quick Setup creates the EKS cluster, HyperPod cluster, VPC configuration, IAM roles, and installs core add-ons in a guided workflow. Use it to understand what gets created before you build your IaC.</p>
<pre><code class="language-bash"># After Quick Setup, inspect what was created:
aws eks list-addons --cluster-name my-hyperpod-cluster
# You'll see: vpc-cni, coredns, kube-proxy, 
# amazon-sagemaker-hyperpod-training, 
# amazon-sagemaker-hyperpod-taskgovernance,
# amazon-cloudwatch-observability, etc.
</code></pre>
<p><strong>Step 2: Run the example training jobs from the awslabs/ai-on-sagemaker-hyperpod repo</strong></p>
<p>This repository has validated examples for Llama 3, DeepSeek, Amazon Nova, and others. Start with a small run (single node, a few hours) to validate your cluster configuration before committing to a multi-day run.</p>
<p><strong>Step 3: Configure Task Governance before inviting teams</strong></p>
<p>Set up compute allocations, priority classes, and namespaces before data scientists start submitting jobs. Retrofitting governance onto a cluster where teams are already running jobs is significantly harder than establishing it upfront.</p>
<p><strong>Step 4: Add monitoring</strong></p>
<p>Install the observability add-on and verify you can see GPU utilization, training throughput, and node health in Grafana before your first serious training run. You want your monitoring working before you need it.</p>
<p><strong>Step 5: Graduate to IaC</strong></p>
<p>Once you understand the resource topology, codify it. HyperPod clusters can be created via CloudFormation, CDK, or Terraform. The SageMaker and EKS APIs are well-documented. Having your cluster as IaC means you can reproduce it in a new region, update it safely, and version-control your configuration.</p>
<hr />
<h2>The CommBank Production Reference</h2>
<p>Commonwealth Bank of Australia's engineering team published production lessons from running HyperPod with Kubernetes — one of the few public accounts from a regulated financial institution. Their experience validates several points worth noting:</p>
<p>Through use of SageMaker HyperPod, customers and internal teams no longer have to worry about operating and configuring the Kubernetes control plane. HyperPod provides network performance and optimized configurations to support complex HPC workloads, reducing operational costs by over 30%.</p>
<p>The 30% cost reduction comes from multiple sources:</p>
<ul>
<li><p>Reduced failure recovery overhead (HyperPod handles what previously required manual intervention)</p>
</li>
<li><p>Better GPU utilization through Task Governance and elastic training</p>
</li>
<li><p>Elimination of idle capacity that was previously kept warm to absorb manual job restarts</p>
</li>
</ul>
<p>For regulated industries specifically, the integration with AWS IAM, VPC networking, CloudTrail audit logging, and the SageMaker Model Registry's approval workflows addresses many compliance requirements out of the box.</p>
<hr />
<h2>Where This Is Going</h2>
<p>HyperPod on EKS has shipped significant features roughly monthly since launch. Looking at the trajectory:</p>
<p><strong>Dynamic Resource Allocation (DRA)</strong> — The DRA beta Kubernetes API is enabled by default in HyperPod EKS AMIs as of September 2025. This API improves scheduling and monitoring of workloads that require resources such as GPUs and was developed by the open-source Kubernetes community. DRA is the future of GPU resource management in Kubernetes — more precise than device plugins, enabling partial GPU allocation, GPU sharing, and fine-grained resource accounting. HyperPod is positioning itself as DRA-native ahead of when DRA becomes the Kubernetes standard.</p>
<p><strong>Fractional GPU support</strong> — Already in the HyperPod CLI (<code>--gpu-partition-count</code> and <code>--gpu-partition-type</code> flags), fractional GPU allocation via NVIDIA MIG (Multi-Instance GPU) lets small fine-tuning jobs and development workloads share a single GPU rather than occupying an entire p5 node for a job that needs a fraction of the compute.</p>
<p><strong>The NVL72 and Blackwell ramp</strong> — As Blackwell capacity becomes more available and NVL72 configurations ship, HyperPod's topology-aware scheduling for NVLink domains will become increasingly important for frontier model training.</p>
<p><strong>Tighter SageMaker integration</strong> — The trend is clearly toward SageMaker Unified Studio as the single development environment, with HyperPod clusters as the compute backend. The SageMaker Spaces add-on (JupyterLab/VSCode on HyperPod nodes) is the beginning of that integration.</p>
<hr />
<h2>Closing Thought</h2>
<p>SageMaker HyperPod on EKS is the answer to a question that every team doing serious GPU-scale ML on AWS eventually asks: why is maintaining a resilient GPU cluster so hard, and why do I have to build the same failure recovery infrastructure that everyone else is building?</p>
<p>The answer is: you don't, anymore. HyperPod handles the hardware reliability layer. EKS provides the familiar Kubernetes orchestration. Task Governance handles the multi-team resource coordination. The observability stack handles the monitoring. The Inference Operator handles the serving layer.</p>
<p>What's left for you: focus on the model, the data, and the training recipe. The infrastructure should be invisible. HyperPod on EKS is getting closer to that promise than anything else in the ecosystem.</p>
<p>For teams running serious AI training workloads on AWS, it's not a question of whether to use it — it's a question of when and how to set it up right.</p>
<hr />
<p><em>Adit Modi is an AWS Ambassador, AWS Community Builder (Containers track), and Presales Lead for Cloud, Data &amp; AI at Lauren Information Technology. He co-organizes AWS User Group Vadodara and publishes Everything about EKS &amp; AI Infrastructure — a weekly newsletter covering EKS, AI infrastructure, and the cloud-native ecosystem.</em></p>
]]></content:encoded></item><item><title><![CDATA[The Missing Layer in Your AI Inference Stack]]></title><description><![CDATA[You have vLLM running. You have Kubernetes. You have Karpenter. And yet, the moment you try to serve multiple models across multiple clusters, you're…]]></description><link>https://aditmodi.hashnode.dev/the-missing-layer-in-your-ai-inference-stack</link><guid isPermaLink="true">https://aditmodi.hashnode.dev/the-missing-layer-in-your-ai-inference-stack</guid><dc:creator><![CDATA[Adit Modi]]></dc:creator><pubDate>Sun, 28 Jun 2026 04:58:31 GMT</pubDate><content:encoded><![CDATA[<p><em>You have vLLM running. You have Kubernetes. You have Karpenter. And yet, the moment you try to serve multiple models across multiple clusters, you're writing glue code that no one else can see or benefit from. That's the problem Modelplane was built to solve.</em></p>
<hr />
<h2>Start Here: Why This Problem Exists At All</h2>
<p>A few years ago, running an LLM in production meant calling OpenAI's API. Simple, expensive, somebody else's problem. Then open-weight models arrived — Llama, Mistral, Qwen, DeepSeek — and the calculus changed. Suddenly the cost-per-token of self-hosted models was dramatically lower than managed APIs, the data sovereignty argument was compelling, and fine-tuning on proprietary data became possible.</p>
<p>So organizations started hosting their own models. And immediately ran into a problem nobody warned them about.</p>
<p>Serving one model on one cluster is a solved problem. vLLM handles it well. You deploy a Helm chart, Karpenter provisions GPU nodes, an ALB routes traffic to pods. Done. This is table stakes.</p>
<p>The hard problem shows up at the second model. Or the second cluster. Or when the GPU capacity in us-east-1 runs dry and you need to fail over to us-west-2. Or when legal says a particular model must only run on infrastructure in the EU. Or when your team fine-tuned Llama and now needs to serve it alongside the base model for A/B testing. Or when the 140GB of weights for a 70B model needs to be distributed to 10 pods across 3 clusters without downloading them 10 separate times from S3.</p>
<p>Every organization building serious inference infrastructure has hit this wall and built something private to get past it. The labs built it. The hyperscalers built it. The neoclouds built it. None of them shared it.</p>
<p>That's the open-source gap Modelplane fills.</p>
<hr />
<h2>The Inference Stack, Properly Layered</h2>
<p>Before going further, let's be precise about where Modelplane sits — because confusing the layers is the source of most confusion about what it does and doesn't do.</p>
<p>There are four distinct layers in a production inference stack. Most discussions collapse them. They shouldn't.</p>
<h3>Layer 1: The Inference Engine</h3>
<p>This is where tokens are generated. The engine takes a prompt, runs it through the model on GPUs, and streams back tokens. Three engines dominate:</p>
<p><strong>vLLM</strong> — Born at UC Berkeley. The breakthrough was PagedAttention, which manages the KV cache (the memory buffer tracking context as the model generates tokens) like virtual memory — small pages, allocated on demand, dramatically reducing waste. You can serve more users with the same GPU memory. vLLM is the most broadly compatible: NVIDIA, AMD, Google TPU, AWS Trainium, Intel Gaudi. Widest model support. Largest contributor base. The safe default.</p>
<p><strong>SGLang</strong> — Also from Berkeley. The breakthrough was RadixAttention — a radix tree over all cached KV pages across all active requests. Where vLLM handles a single shared prefix per request, SGLang discovers the longest matching prefix across all previously cached sequences and reuses it. The payoff: when multiple requests share context (system prompt, RAG documents, multi-turn conversation history), SGLang skips redundant computation. Benchmark on multi-turn workloads: 29% higher throughput than vLLM, 16,200 vs 12,500 tokens/sec on H100s. SGLang runs on over 400,000 GPUs in production, powering xAI's Grok, Microsoft Azure DeepSeek endpoints, LinkedIn's AI features, and Cursor's code completion.</p>
<p><strong>TensorRT-LLM</strong> — NVIDIA's hardware-optimized engine. Compiles models into optimized CUDA kernels at deploy time. Highest raw throughput on NVIDIA hardware. The cost: compilation takes 20–30 minutes for a 70B model and must be repeated when you update the model or change batch size. Teams that update models frequently find this painful. Teams that want maximum throughput on a fixed model find it worth it.</p>
<p>The choice between them is a workload question, not a religion:</p>
<ul>
<li><p>Diverse models, multi-hardware, broadest compatibility → vLLM</p>
</li>
<li><p>Multi-turn chat, RAG pipelines, shared context, agentic loops → SGLang</p>
</li>
<li><p>Maximum raw throughput on NVIDIA, fixed model, compile-time acceptable → TensorRT-LLM</p>
</li>
</ul>
<h3>Layer 2: The Inference Scheduler</h3>
<p>This is where most teams have a gap they don't know they have.</p>
<p>When you run multiple vLLM pods behind a standard Kubernetes Service, traffic is distributed round-robin. Kubernetes doesn't know anything about KV cache state. Request A might have built up KV cache on pod 1 for a long conversation. Request B (the same user, the same conversation) lands on pod 3 and recomputes the entire context from scratch. You're paying for GPU work you already did.</p>
<p><strong>llm-d</strong> (CNCF Sandbox, founded by Red Hat, Google, IBM, CoreWeave, NVIDIA) fixes this. It sits above vLLM/SGLang pods and routes based on what's actually in each pod's KV cache. If pod 1 already has the KV state for user A's conversation, llm-d sends user A's next request to pod 1. Cache hit. The benchmark: 57x faster time-to-first-token and 2x throughput vs round-robin on 8 pods / 16 H100 GPUs — same hardware, same model, just smarter routing.</p>
<p>llm-d also handles prefill/decode disaggregation: separating the compute-heavy prefill phase (processing the input prompt) from the memory-bandwidth-heavy decode phase (generating tokens one by one) onto different pods optimized for each. Up to 50,000 output tokens/second on a 16×16 B200 topology.</p>
<p><strong>NVIDIA Dynamo</strong> (GA March 2026) solves a related but subtler problem. It adds a KV-aware router that routes requests to the GPU replica with the highest probability of a cache hit, using predicted cache state. It also handles disaggregation, KV cache transfer via NIXL (NVIDIA's low-latency GPU-to-GPU transfer library over NVLink or InfiniBand), and SLO planning — dynamically adjusting GPU allocation to meet latency targets. The benchmark: up to 7x throughput improvement on Blackwell. Dynamo is NVIDIA-native in its deepest optimizations; llm-d is more hardware-agnostic and CNCF-governed.</p>
<p>Both operate within a single cluster. Neither solves the multi-cluster problem.</p>
<h3>Layer 3: The Cluster Orchestrator (Kubernetes + Karpenter + your Helm charts)</h3>
<p>This is what most EKS teams are already running. Within a single cluster, this layer is well-understood:</p>
<ul>
<li><p>Karpenter watches for pending pods → identifies the right GPU instance type (p5.48xlarge, g6e.12xlarge, etc.) → provisions it via EC2 Fleet API → pod schedules</p>
</li>
<li><p>HPA or KEDA scales pod replicas based on token queue depth, GPU utilization, or custom metrics</p>
</li>
<li><p>ALB or NLB routes HTTP traffic to inference pods</p>
</li>
<li><p>Helm installs vLLM, llm-d, monitoring stack, etc.</p>
</li>
</ul>
<p>This works well. For a single cluster. The question isn't whether Kubernetes + Karpenter handles inference orchestration — it does. The question is what happens when "single cluster" is no longer the constraint.</p>
<h3>Layer 4: The Fleet Control Plane (the missing layer)</h3>
<p>This is the layer above Kubernetes. The layer that manages multiple clusters as one coherent inference platform. The layer that every lab and hyperscaler has built internally and never shared.</p>
<p>This is Modelplane.</p>
<hr />
<h2>What Karpenter and Kubernetes Don't Do (and Why That Matters)</h2>
<p>The most common response when Modelplane comes up: "Can't you just do this with Karpenter and Helm?"</p>
<p>You can get surprisingly far with standard Kubernetes tooling for a single cluster. But there are specific things that Karpenter, ALB, and Helm cannot do at the fleet level:</p>
<p><strong>1. Cross-cluster GPU scheduling</strong> Karpenter knows about instances inside your cluster. If your EKS cluster in us-east-1 is GPU-constrained — spot capacity exhausted, capacity reservation full, Karpenter just provisioned the maximum you've set — it cannot say "route this workload to the cluster in us-west-2." It can only work within one cluster boundary. Cross-cluster placement requires a control plane that has visibility across all clusters simultaneously.</p>
<p><strong>2. Cache-aware routing across clusters</strong> Your ALB routes to pods by health check and round-robin. It has no awareness of KV cache state — even within a cluster. Across clusters, it has zero coordination. Modelplane's unified InferenceGateway routes traffic not just across pods but across clusters, regions, and clouds, with awareness of where models are deployed and where capacity exists.</p>
<p><strong>3. Model weight distribution</strong> Deploying a 70B model means distributing 140GB of weights. If you have 10 replicas across 3 clusters, naive deployment pulls 140GB × 10 = 1.4TB of data from S3 or ECR on every deployment. Modelplane has a ModelCache resource that stages weights intelligently — pulling once per cluster, distributing within the cluster from there, and avoiding redundant downloads on replica scale-out. There's no equivalent in standard Kubernetes tooling.</p>
<p><strong>4. Sovereignty and compliance-aware placement</strong> Legal says a particular fine-tuned model containing sensitive embeddings can only run on infrastructure in specific regions or clouds. Modelplane's placement engine respects these constraints declaratively — you express them as cluster selectors, and the system enforces them. With standard Kubernetes, this is manual configuration spread across multiple clusters with no single system of record enforcing the policy.</p>
<p><strong>5. Multi-cloud failover</strong> If your primary inference cluster in one cloud goes down, Modelplane fails over traffic to the next matching cluster automatically. With vanilla Kubernetes, you'd need external DNS failover, custom health checks across providers, and manual coordination of model state.</p>
<hr />
<h2>How Modelplane Actually Works</h2>
<p>Modelplane runs on its own control cluster — a standard Kubernetes cluster with no GPUs. It doesn't serve tokens. It orchestrates the clusters that do.</p>
<p>Built on <strong>Crossplane</strong> — the CNCF Graduated project used internally at Apple, Nike, JPMC, NASA, Elastic, Grafana, SAP, IBM, and Akamai — Modelplane extends Kubernetes' declarative API model to inference fleet management. If you already run Crossplane, Modelplane is the same pattern applied to inference.</p>
<p>The API splits cleanly along the two roles that actually do this work in practice:</p>
<h3>Platform Team Resources</h3>
<p><strong>InferenceCluster</strong> — Registers a Kubernetes cluster into the fleet. Modelplane can provision new clusters on AWS, GCP, or Azure, or you can bring existing ones. Each cluster gets the full serving stack installed and continuously reconciled by Modelplane.</p>
<p><strong>InferenceClass</strong> — Defines a hardware recipe: what GPU types a node pool offers, how to provision nodes, what devices are available and their attributes (GPU memory, architecture). Uses DRA (Dynamic Resource Allocation, Kubernetes v1.35+) with CEL selectors to match model requirements to hardware capabilities.</p>
<pre><code class="language-yaml"># Example: A hardware class describing H100 nodes
apiVersion: inference.modelplane.ai/v1alpha1
kind: InferenceClass
metadata:
  name: h100-80gb
spec:
  nodePool:
    devices:
      - name: gpu
        count: 8
        selectors:
          - cel: device.capacity["gpu.nvidia.com"].memory.compareTo(quantity("80Gi")) &gt;= 0
</code></pre>
<p><strong>InferenceGateway</strong> — The unified entry point over the entire fleet. One endpoint, all clusters.</p>
<h3>Developer Resources</h3>
<p><strong>ModelDeployment</strong> — Declares a model: which model (from HuggingFace, NGC, or your own registry), which engine to run it with (vLLM, SGLang, TensorRT-LLM, any container-based engine), serving topology (single GPU, tensor parallel across GPUs in a node, pipeline parallel across nodes, disaggregated prefill/decode), replica count, and hardware requirements.</p>
<pre><code class="language-yaml">apiVersion: modelplane.ai/v1alpha1
kind: ModelDeployment
metadata:
  name: llama-70b
  namespace: ml-team
spec:
  replicas: 4
  engines:
    - name: llama-70b-engine
      members:
        - role: Standalone
          nodeSelector:
            devices:
              - name: gpu
                count: 8
                selectors:
                  - cel: device.capacity["gpu.nvidia.com"].memory.compareTo(quantity("80Gi")) &gt;= 0
          template:
            spec:
              containers:
                - name: engine
                  image: vllm/vllm-openai:v0.23.0
                  args: ["--model=meta-llama/Llama-3.3-70B-Instruct"]
</code></pre>
<p>Modelplane finds clusters with compatible free capacity, schedules replicas across them, and deploys the serving engine. Neither the developer nor Modelplane touches the engine flags — that's the developer's domain. Modelplane only manages placement and routing.</p>
<p><strong>ModelCache</strong> — Declares where model weights should be staged. Modelplane handles the download and distribution, ensuring weights are ready before replicas need them.</p>
<p><strong>ModelService</strong> — Exposes replicas through the unified gateway. One stable OpenAI-compatible endpoint regardless of how many clusters are serving it or where they are.</p>
<pre><code class="language-yaml">apiVersion: modelplane.ai/v1alpha1
kind: ModelService
metadata:
  name: llama-70b-service
  namespace: ml-team
spec:
  endpoints:
    - selector:
        matchLabels:
          modelplane.ai/deployment: llama-70b
</code></pre>
<p>The result: <code>curl http://gateway.yourcompany.com/v1/chat/completions</code> — and Modelplane figures out which pod, in which cluster, in which region to send it to.</p>
<hr />
<h2>The Serving Topologies Modelplane Supports</h2>
<p>One of Modelplane's design choices is being topology-agnostic. You declare the shape, Modelplane places it. The current supported topologies:</p>
<p><strong>Standalone</strong> — Single GPU or single node. Standard for smaller models (7B–13B).</p>
<p><strong>Tensor Parallel</strong> — Model layers are split across multiple GPUs within a node. Each GPU holds a portion of each layer. Requests flow through all GPUs simultaneously. Lower latency for a single model, higher GPU utilization. For 70B models on 8× H100 nodes.</p>
<p><strong>Pipeline Parallel</strong> — Model stages are split across nodes. Layer 0–24 on node A, layer 25–48 on node B. The model is too large for a single node. For frontier-scale models (405B+).</p>
<p><strong>Data Parallel / Expert Parallel</strong> — Multiple identical model replicas handle different requests in parallel (data parallel), or for Mixture-of-Experts models, different expert sets run on different GPUs (expert parallel). DeepSeek-R1 is the canonical MoE case.</p>
<p><strong>Disaggregated Prefill/Decode</strong> — Prefill pods (compute-heavy) and decode pods (memory-bandwidth-heavy) are separate pools, each scaled independently. The architectural pattern that llm-d and NVIDIA Dynamo both implement, now declarable as a Modelplane topology. This is the mode that enables massive throughput gains — up to 50,000 output tokens/second on the right hardware.</p>
<p>All of these are expressed as topology shapes in the ModelDeployment spec. Modelplane handles placement and coordination. New parallelism strategies as they emerge from the research community just become new shape types.</p>
<hr />
<h2>The Proprietary Landscape Modelplane Is Responding To</h2>
<p>Every major cloud provider and ML platform has built this layer privately. Understanding what they built — and what it costs — is the context for why an open-source alternative matters.</p>
<h3>AWS SageMaker Inference</h3>
<p>SageMaker endpoints are managed by AWS entirely. You specify a model, an instance type, and a scaling policy. SageMaker handles everything underneath: provisioning, health checks, load balancing, multi-AZ deployment. You get real-time endpoints, serverless inference, asynchronous inference, and multi-model endpoints (multiple models behind one endpoint using shared compute).</p>
<p>The tradeoffs: SageMaker encourages proprietary container structures and inference pipeline abstractions. Moving these endpoints to standard Kubernetes requires rewriting Dockerfiles and serving logic from scratch. You're on AWS exclusively. You pay a significant markup over raw EC2 pricing for the managed layer. And critically, SageMaker doesn't know about your other clusters on GCP or your on-premise GPU rack — each environment is managed in isolation.</p>
<h3>Google Vertex AI</h3>
<p>Vertex AI Model Endpoints provide similar managed inference with auto-scaling, health management, and traffic splitting for A/B deployments. GKE Inference Gateway extends this into the Kubernetes-native world with KV-cache-aware routing at the cluster level. Google's advantage: they built GKE as the foundation, so the managed inference layer integrates naturally with the container orchestration layer. Still single-cloud. Still proprietary.</p>
<h3>Databricks Model Serving</h3>
<p>Databricks Model Serving (Mosaic AI Serving) is the most data-pipeline-native option. It integrates tightly with the Databricks Feature Store, MLflow for model registry, Unity Catalog for governance, and AI Functions for batch inference. For teams whose inference workloads are downstream of a Databricks data pipeline, this is compelling. The constraints: you're in the Databricks ecosystem, pricing is DBU-based with opaque unit economics, and the serving infrastructure is not visible or portable.</p>
<h3>What They All Share</h3>
<p>Every proprietary option gives you fleet management as a black box. You get managed simplicity at the cost of visibility, portability, and cross-cloud coherence. If you're all-in on one cloud and one vendor, this may be the right tradeoff. If you're a neocloud, a regulated enterprise with sovereignty requirements, or an AI-native company that wants to own your inference cost structure, the proprietary options impose constraints that compound over time.</p>
<hr />
<h2>Who Modelplane Is Actually For</h2>
<p>Modelplane's launch announcement identified three segments. They're worth taking seriously:</p>
<p><strong>Neoclouds and AI factories</strong> — Organizations building managed inference services on their own hardware: CoreWeave, Lambda Labs, Crusoe, any company turning GPU capacity into an inference business. They all need a fleet control plane. The alternative is building and maintaining it internally — an enormous engineering investment for infrastructure that isn't differentiated. Modelplane is the open-source foundation they can build on.</p>
<p><strong>Regulated and sovereign enterprises</strong> — Financial services, healthcare, government, defense. Organizations where "the model ran on AWS us-east-1" is not an acceptable answer to their compliance team. Where data residency rules mean inference must happen in specific jurisdictions. Where security means they must control the full stack end to end. Managed cloud inference services are often not compliant options for these teams. Modelplane runs on their infrastructure, under their governance, in the regions they control.</p>
<p><strong>AI-native companies with significant inference spend</strong> — Companies that have moved from using OpenAI's API to hosting open-weight models because the economics make sense at scale. They're now operating GPU fleets, and the fleet management problem is real. They want the cost benefits of self-hosted models without building a private fleet control plane.</p>
<p>The common thread: organizations that run inference on infrastructure they own or control, across more than one cluster.</p>
<hr />
<h2>The Crossplane Connection</h2>
<p>Modelplane is built on Crossplane and requires it. This isn't incidental — it's the architectural bet.</p>
<p>Crossplane is a CNCF Graduated project that extends Kubernetes to manage cloud infrastructure declaratively. Instead of running <code>terraform apply</code>, you create Kubernetes resources that represent AWS VPCs, GCP databases, Azure storage — and Crossplane's controllers reconcile reality to match. It's used in production at thousands of organizations.</p>
<p>Modelplane applies the same pattern to inference fleet management. Instead of representing cloud infrastructure resources, Modelplane's CRDs represent inference clusters, hardware classes, model deployments, and gateways. The Crossplane reconciliation loop continuously compares desired state (your YAML) to actual state (what's running across your GPU fleet) and drives toward convergence.</p>
<p>This has practical implications:</p>
<ul>
<li><p>If you already run Crossplane, Modelplane is the same operational pattern and the same tooling</p>
</li>
<li><p>Fleet state is version-controlled YAML — GitOps for your inference fleet, not a GUI</p>
</li>
<li><p>The control loop is continuously running, not a one-time apply — if a cluster drifts from desired state, Modelplane corrects it</p>
</li>
<li><p>Extending Modelplane to new cloud providers or new hardware types follows Crossplane's provider model</p>
</li>
</ul>
<hr />
<h2>How This Compares to What You'd Build Yourself</h2>
<p>If you tried to replicate Modelplane's capabilities with standard tooling on EKS, here's what you'd be building:</p>
<p><strong>Cross-cluster GPU capacity management</strong> → Custom controller watching capacity across clusters, updating a shared database, with placement logic that maps model requirements to available hardware. Non-trivial engineering, ongoing maintenance as new instance types and GPU generations appear.</p>
<p><strong>Model weight distribution</strong> → Custom S3-to-cluster synchronization logic, managing pre-pull DaemonSets or init containers, tracking which clusters have which model versions cached. Operationally painful at fleet scale.</p>
<p><strong>Unified gateway across clusters and clouds</strong> → Either a manually maintained configuration of Envoy or another proxy that knows about all clusters, or a custom gateway service that queries a registry. Both require significant plumbing and break whenever clusters are added or removed.</p>
<p><strong>Sovereignty/compliance-aware placement</strong> → Custom admission controller or scheduling logic that reads policy configuration and prevents placements that violate constraints. Another operator to build and maintain.</p>
<p><strong>Failover across clusters</strong> → Route 53 health checks or custom failover logic that monitors cluster health across providers and updates DNS/routing accordingly.</p>
<p>Each of these is doable. Organizations have done them. The aggregate cost is an engineering team that maintains fleet infrastructure instead of building products. Modelplane is the choice to not pay that cost.</p>
<hr />
<h2>The Current State and the Honest Caveats</h2>
<p>Modelplane launched as an early v0.1 open-source release on June 23, 2026. It is built by Upbound — the company that created and maintains Crossplane, which has a strong track record of taking projects from early releases to production-grade CNCF Graduated status.</p>
<p>That said, the caveats are real:</p>
<p><strong>It is early software.</strong> APIs can change between minor versions until v1.0. Modelplane explicitly says: "Treat it as early software." Don't build production inference infrastructure on v0.1 without a plan for API changes.</p>
<p><strong>It requires DRA.</strong> Dynamic Resource Allocation requires Kubernetes v1.35+. If your clusters are running older Kubernetes versions, you'll need to upgrade before Modelplane can bind GPUs to pods through its device model.</p>
<p><strong>It requires Crossplane.</strong> If you don't already run Crossplane, this is an additional dependency to operate. For teams already using Crossplane for cloud infrastructure management, this is zero additional operational cost. For teams new to it, there's a learning curve.</p>
<p><strong>The ecosystem integrations are early.</strong> NVIDIA Dynamo integration is on the roadmap but not complete. The full range of inference engines (vLLM, SGLang, TRT-LLM) are supported via their container interfaces, but deeper integrations (like Dynamo's KV-aware routing inside Modelplane's fleet routing) are upcoming.</p>
<p><strong>Donation to a neutral foundation is planned.</strong> Upbound has stated they intend to donate Modelplane to a neutral open-source foundation. The CNCF is the natural home given Crossplane's trajectory. This will matter for enterprise adoption — an independently governed project at the CNCF has a different trust profile than a vendor-controlled one.</p>
<hr />
<h2>Putting the Full Stack Together</h2>
<p>Here's how the full inference stack looks with each layer properly placed:</p>
<pre><code class="language-plaintext">Your application
        ↓  OpenAI-compatible API
Modelplane InferenceGateway     ← fleet routing, placement, weight caching
        ↓  selects cluster
EKS / GKE / AKS Cluster
        ↓
llm-d or NVIDIA Dynamo          ← KV-cache-aware routing within cluster
        ↓  routes to right pod
vLLM / SGLang / TRT-LLM pod    ← token generation on GPU
        ↓
NVIDIA H100 / A100 / Trainium
</code></pre>
<p>Each layer does what it's best at. The inference engine runs the model. The inference scheduler routes intelligently within the cluster. The cluster orchestrator (Kubernetes + Karpenter) manages nodes and pods. Modelplane manages the fleet of clusters as a single system.</p>
<p>You don't need all four layers. A single cluster serving one model doesn't need Modelplane. Two or three clusters across regions start to benefit from it. A multi-cloud inference fleet with sovereignty requirements needs it.</p>
<hr />
<h2>What to Watch</h2>
<p>Modelplane is v0.1 today. The roadmap items worth tracking:</p>
<p><strong>NVIDIA Dynamo integration</strong> — Deeper integration between Modelplane's fleet-level routing and Dynamo's KV-aware intra-cluster routing would close the remaining gap between fleet management and per-request optimization.</p>
<p><strong>CNCF donation</strong> — When Modelplane moves under CNCF governance, it signals production readiness and long-term project sustainability. This is the milestone that will matter for regulated enterprises evaluating adoption.</p>
<p><strong>AWS provider depth</strong> — Modelplane can provision EKS clusters today. The depth of integration with EC2 capacity reservations, Capacity Blocks for ML, and Spot Instance management on AWS will determine how well it serves EKS-centric organizations.</p>
<p><strong>The adjacent ecosystem</strong> — Modelplane composes rather than replaces. KServe, llm-d, NVIDIA Dynamo, and the Kubernetes Inference Extension (GIE) are all evolving fast. How Modelplane integrates with each as they mature will define the production stack of the next 18 months.</p>
<hr />
<p>The inference fleet control plane problem was previously only solved by organizations large enough to build it themselves. Modelplane is the first serious open-source attempt to change that. v0.1 is early. The foundation is solid. The problem it's solving is real, and the organizations that need it most — regulated enterprises, neoclouds, AI-native companies moving to self-hosted inference — are exactly the ones most constrained by the proprietary alternatives.</p>
<p>It's worth watching closely. And if you're already running multi-cluster inference on EKS, it might be worth deploying on a dev cluster today to understand what the control plane layer actually does before you need it in production.</p>
<hr />
<p><em>Adit Modi is an AWS Ambassador, AWS Community Builder (Containers track), and Presales Lead for Cloud, Data &amp; AI at Lauren Information Technology. He co-organizes AWS User Group Vadodara and publishes Everything about EKS &amp; AI Infrastructure — a weekly newsletter covering EKS, AI infrastructure, and the cloud-native ecosystem.</em></p>
]]></content:encoded></item><item><title><![CDATA[Containers Weren't Built for Agents]]></title><description><![CDATA[I've spent years running containers on Kubernetes. EKS clusters, GPU workloads, Karpenter node pools — the whole stack. The mental model was stable: a…]]></description><link>https://aditmodi.hashnode.dev/containers-weren-t-built-for-agents</link><guid isPermaLink="true">https://aditmodi.hashnode.dev/containers-weren-t-built-for-agents</guid><dc:creator><![CDATA[Adit Modi]]></dc:creator><pubDate>Sun, 28 Jun 2026 04:45:54 GMT</pubDate><content:encoded><![CDATA[<p>I've spent years running containers on Kubernetes. EKS clusters, GPU workloads, Karpenter node pools — the whole stack. The mental model was stable: a container is a process with namespace isolation, you pack them onto nodes, Kubernetes schedules and heals them, done.</p>
<p>That model is now under serious pressure. Not because it's wrong — it's been right for a decade. But because the workload has changed in a fundamental way, and the assumptions baked into Linux containers in 2013 don't hold anymore.</p>
<p>The trigger: AI agents that write and execute code.</p>
<hr />
<h2>What Containers Were Designed For</h2>
<p>Docker shipped in 2013. The workload it targeted was a long-running, trusted service. Your web server. Your API. Your database sidecar. Code you wrote, reviewed, and deployed deliberately. The container gave you reproducibility, process isolation, and resource limits. The kernel was shared between containers, but that was fine — because you controlled what was running inside.</p>
<p>The isolation model:</p>
<ul>
<li><p><strong>Namespace isolation</strong> — each container has its own view of processes, filesystems, network interfaces</p>
</li>
<li><p><strong>cgroup limits</strong> — CPU and memory are bounded per container</p>
</li>
<li><p><strong>Shared host kernel</strong> — all containers on the same node run on the same Linux kernel</p>
</li>
</ul>
<p>That last point is the load-bearing assumption. It works when you trust what's running. It cracks when you don't.</p>
<hr />
<h2>What Agents Are Actually Doing</h2>
<p>An AI agent today doesn't just answer questions. It:</p>
<ul>
<li><p>Writes code in response to user input or task goals</p>
</li>
<li><p>Executes that code to complete multi-step work</p>
</li>
<li><p>Uses tools — filesystem, network, APIs, browsers — as part of its reasoning loop</p>
</li>
<li><p>Potentially runs code retrieved from external sources, user uploads, or MCP tool responses</p>
</li>
<li><p>Operates for hours or days, not just seconds</p>
</li>
</ul>
<p>None of that code was reviewed by a human before execution. The agent generated it, retrieved it, or was instructed to run it by a user you may or may not fully trust.</p>
<p>Run that in a standard container and here's what you've accepted:</p>
<ul>
<li><p>If generated code contains a kernel exploit, it can escape the container namespace and reach the host</p>
</li>
<li><p>Secrets mounted as environment variables, service account tokens, and volumes are all accessible to the running code</p>
</li>
<li><p>In a multi-tenant system, one customer's agent shares a kernel with every other customer's workload on the same node</p>
</li>
</ul>
<p>This isn't theoretical. Container escapes exist. Kernel vulnerabilities ship. The attack surface is real — and it was manageable when you controlled every line of code. It isn't anymore.</p>
<hr />
<h2>The Industry Response: Every Major Player, Moving Fast</h2>
<p>What's remarkable right now is the breadth of response. This isn't one company solving one problem. It's every major technology company — cloud providers, AI labs, infrastructure startups, OS vendors — simultaneously realizing that the execution layer needs to be rebuilt. They're arriving from different directions, using different technologies, but converging on the same architectural idea: <strong>one isolated execution environment per workload, fast enough to be transparent, cheap enough to be the default</strong>.</p>
<hr />
<h3>Google: The First to Ship at Scale</h3>
<p>GKE Agent Sandbox went generally available in May 2026. It uses <strong>gVisor</strong> — a user-space kernel that intercepts every syscall from your container before it reaches the host.</p>
<p>Normal container execution: application makes a system call → goes directly to the host Linux kernel. If that syscall exploits a kernel vulnerability, you're out.</p>
<p>With gVisor: application makes a system call → intercepted by gVisor running in user space → gVisor decides what to pass through. A container escape can only reach gVisor — not your host. It's the same sandboxing technology that secures Gemini in production.</p>
<p>GKE Agent Sandbox is generally available, giving a secure, scalable foundation for agent workloads. Since preview at KubeCon NA 2025, community adoption has seen more than 16x growth in sandboxes in less than 5 months.</p>
<p>The numbers: 300 sandboxes per second, sub-second provisioning, up to 90% improvement over cold starts with warm pools.</p>
<p>Real usage: Lovable — the AI app builder where users create 200,000+ projects daily — runs all agent-generated applications in GKE Agent Sandboxes because of fast startup and secure isolation. LangChain integrated directly.</p>
<p>Google also introduced three new Kubernetes primitives: <code>Sandbox</code>, <code>SandboxTemplate</code>, and <code>SandboxClaim</code> — making sandboxed execution a first-class Kubernetes concept, not a bolt-on. GKE Agent Sandbox is currently the only native agent sandbox offering among the three major hyperscalers.</p>
<hr />
<h3>Microsoft: Three Layers, One Bet</h3>
<p>Microsoft's response to the agent execution problem is actually a stack, not a single product. They've built at three distinct levels.</p>
<p><strong>Layer 1 — Hyperlight (CNCF Sandbox project)</strong></p>
<p>Hyperlight is a Rust library that uses kernel-based virtual machines or Hyper-V to run untrusted code in a MicroVM without loading a full operating system, averaging just 900 microseconds per request. No guest OS, no kernel to attack — the guest is a purpose-built minimal binary. It boots in under a millisecond. CodeAct ships in the agent-framework-hyperlight package, which runs model-generated code in a fresh, locally isolated Hyperlight MicroVM per call — so strong isolation is essentially free at the granularity of a single tool call.</p>
<p><strong>Layer 2 — MXC (Microsoft eXecution Containers)</strong></p>
<p>MXC is a sandboxed code execution system for running untrusted code across Windows, Linux, and macOS. It provides multiple containment backends — ProcessContainer, Windows Sandbox, LXC, Bubblewrap, Seatbelt (macOS), MicroVM (NanVix), Hyperlight, IsolationSession, and WSLC — behind a unified JSON configuration schema and TypeScript SDK. The key idea: one API, pluggable isolation backends, so you can dial the isolation level to match the threat model and the platform.</p>
<p><strong>Layer 3 — AKS + Kata Containers</strong></p>
<p>For Kubernetes-native workloads on Azure, Microsoft is running Copilot agents on AKS inside Kata Containers MicroVMs at the pod level. Each pod gets its own VM boundary — kernel exploits inside one pod can't reach other pods or the host node. The design keeps the developer experience and packaging model of containers (OCI images, Kubernetes scheduling) while ensuring that untrusted agent code executes inside a MicroVM boundary, reducing the likelihood that a container escape can compromise the underlying node or other co-located workloads.</p>
<p>Brendan Burns — a Kubernetes co-founder and Microsoft Technical Fellow — has framed this as part of a broader shift toward "agentic operations." His perspective is that the same complexity Kubernetes was built to tame (stateless → stateful applications) is happening again, this time from stateful applications to autonomous agents. The infrastructure needs to evolve to match.</p>
<hr />
<h3>Apple: Rethinking the Container Runtime From the Ground Up</h3>
<p>Apple's <code>container</code> (github.com/apple/container) is a tool for running Linux containers on Apple Silicon Macs, written in Swift, fully OCI-compatible. What makes it architecturally significant is the isolation model.</p>
<p>Docker Desktop runs a single Linux VM. All your containers run inside that VM, sharing its kernel. Apple's <code>container</code> does something different: each container gets its own lightweight virtual machine — a MicroVM — with its own kernel, booting in milliseconds, using the OCI image standard so it works with any container registry.</p>
<p>This is possible on Apple Silicon because the unified memory architecture (CPU and GPU share the same physical memory pool) means VM creation has virtually no memory copy overhead. Apple built this for developer security — but the architectural statement is broader: kernel isolation per container as the default, not as a security opt-in.</p>
<p>Apple also demonstrated distributed inference across multiple Macs connected via Thunderbolt 5 using MLX, where the same MicroVM-per-container model extends to a local cluster. The experiment is Mac-native today. The pattern is universal.</p>
<hr />
<h3>AWS: Moving From Lambda Toward EKS</h3>
<p>AWS's trajectory is the most interesting to watch because it's happening in stages.</p>
<p><strong>Where AWS is today:</strong> Firecracker powers Lambda and Fargate already. Every Lambda invocation has run in its own MicroVM since 2018 — a dedicated kernel, ~125ms cold start, ~5MB overhead, destroyed when the invocation completes. This is why Lambda is safe for running arbitrary customer code. The isolation is real, not namespace-deep.</p>
<p><strong>The new move:</strong> Lambda MicroVMs are now a first-class primitive, explicitly positioned for AI-generated code execution. The model: agent generates code → runs in a MicroVM → MicroVM is destroyed. Nothing persists, nothing escapes. Up to 8 hours of execution with suspend/resume.</p>
<p><strong>The gap:</strong> None of this is Kubernetes-native yet. If you're running agentic workloads on EKS and need kernel-level isolation per agent execution, you have to build it yourself — install gVisor or Kata Containers manually, manage the runtime class configuration, handle the warm pool yourself. There's no managed, Kubernetes-native sandbox primitive equivalent to GKE Agent Sandbox on EKS.</p>
<p><strong>The signal pointing forward:</strong> Aidan Steele independently explored what it would look like to bring Firecracker MicroVMs into Kubernetes as nodes — registering a MicroVM as a Kubernetes node so that each pod gets its own isolated kernel, scheduled through the standard Kubernetes API. Experimental. But it's the community working out what the next EKS primitive might look like before AWS ships it officially.</p>
<hr />
<h3>OpenAI: Acquiring the Execution Layer</h3>
<p>On June 11, 2026, OpenAI announced it will acquire Ona — formerly Gitpod — bringing its secure cloud execution and orchestration technology into the Codex ecosystem. More than 5 million people use Codex each week, up 400% from earlier in the year.</p>
<p>What OpenAI bought is not a model or a dataset. It's an execution layer.</p>
<p>Today's coding agents are session-bound: the work stops the moment the session ends. As agents move from minutes-long edits to multi-hour and multi-day tasks, that session-based model breaks — and the industry has no agreed answer for where that work should actually execute. Ona solves this with persistent, secure cloud environments where agents can access tools, systems, and context over time. When the laptop is closed, the agent keeps working. When it's done, the environment is destroyed.</p>
<p>Gartner framed this as OpenAI's response to Anthropic supporting self-hosted sandboxes in Claude Managed Agents, starting May 2026. The race isn't just about model quality anymore — it's about who owns the full stack from model to execution environment.</p>
<hr />
<h3>The Startup Ecosystem: An Entire Market Has Emerged</h3>
<p>While hyperscalers are building managed solutions, a generation of startups built the sandbox category from scratch — and they're now the reference implementations for what production agent execution looks like.</p>
<p><strong>E2B</strong> — Uses Firecracker MicroVMs, the same technology as Lambda. Each sandbox gets a dedicated kernel. E2B focuses specifically on running untrusted code from LLMs with hardware-level isolation boundaries, with ~150ms cold starts. 88% of Fortune 100 companies have signed up. Open-source, self-hostable.</p>
<p><strong>Daytona</strong> — Container-based, sub-90ms cold starts, optimized for persistent developer workspace-style environments where state accumulates over time. Raised a $24M Series A in February 2026. Targets the latency-sensitive use case — agent loops that spin up a fresh sandbox per turn pay the cold-start tax on every iteration.</p>
<p><strong>Vercel Sandbox</strong> — Reached GA in January 2026. Tightly integrated into the Vercel deployment ecosystem. Caps sessions at 45 minutes on Hobby, 5 hours on Pro/Enterprise — fine for short tasks, a hard wall for multi-hour agent work. The play is ecosystem fit over raw flexibility.</p>
<p><strong>Modal</strong> — The only sandbox in the category with GPU access (T4, A10G). Uses gVisor isolation. For ML-heavy agent workloads that need to run models inside the sandbox itself, Modal is the only managed option.</p>
<p><strong>Cloudflare Sandboxes</strong> — Edge-distributed, 30-minute execution limit. The play is latency-to-user, not duration.</p>
<p><strong>Cursor Cloud Agents</strong> — Not a standalone product, but Cursor's cloud agents run in isolated Linux VMs sustaining 25 to 52+ hours of continuous autonomous work. Another example of a coding tool that built its own execution layer rather than relying on a third party.</p>
<p>The pattern across all of these: the sandbox market has bifurcated into <strong>MicroVM-based</strong> (E2B, Vercel) for maximum isolation and <strong>container-based</strong> (Daytona) for maximum speed. The choice depends on your threat model and your latency budget.</p>
<hr />
<h2>The Comparison You Need to Know</h2>
<table>
<thead>
<tr>
<th></th>
<th>Standard Container</th>
<th>gVisor</th>
<th>Firecracker MicroVM</th>
<th>Hyperlight</th>
</tr>
</thead>
<tbody><tr>
<td>Isolation</td>
<td>Namespace only</td>
<td>Syscall interception</td>
<td>Full VM, own kernel</td>
<td>Hypervisor, no guest OS</td>
</tr>
<tr>
<td>Cold start</td>
<td>Milliseconds</td>
<td>Sub-second</td>
<td>~90–150ms</td>
<td>~0.9ms</td>
</tr>
<tr>
<td>Kernel escape risk</td>
<td>High</td>
<td>Very low</td>
<td>Near zero</td>
<td>Near zero</td>
</tr>
<tr>
<td>Who's shipping it</td>
<td>Everyone</td>
<td>Google (GKE), Daytona</td>
<td>AWS Lambda, E2B, Vercel</td>
<td>Microsoft</td>
</tr>
<tr>
<td>Kubernetes native</td>
<td>Yes</td>
<td>GKE (managed)</td>
<td>Experimental on EKS</td>
<td>AKS (Kata)</td>
</tr>
<tr>
<td>GPU support</td>
<td>Yes</td>
<td>Limited</td>
<td>Yes (E2B self-hosted)</td>
<td>No</td>
</tr>
</tbody></table>
<hr />
<h2>The Two Problems Being Solved (Not Just One)</h2>
<p>Most coverage of this space frames it as a security story. It's equally an economics and durability story.</p>
<p><strong>The security problem:</strong> Agent-generated code is untrusted. Running it in a standard container means sharing a kernel with every other workload on the node. The isolation needs to be stronger.</p>
<p><strong>The durability problem:</strong> Agent work spans hours or days — not seconds. Standard containers tied to a session die when the session ends. Multi-day agent tasks need execution environments that persist across device shutdowns, resume from checkpoints, and maintain state through interruptions. This is the problem OpenAI bought Ona to solve, and it's why GKE Agent Sandbox integrates with Pod Snapshots to suspend idle agents and resume them in seconds.</p>
<p><strong>The economics problem:</strong> Agent workloads are bursty. An agent runs for 10 seconds, sits idle for 30, runs again. Keeping containers alive during idle time is waste. The suspend/resume pattern — snapshot the execution state, destroy the compute, resume from snapshot on demand — turns an always-on cost into a pay-for-what-you-use cost. This is what Lambda has always done, and what GKE Agent Sandbox is now bringing to Kubernetes.</p>
<hr />
<h2>Where EKS Fits in This Picture</h2>
<p>The honest answer for teams running agentic workloads on EKS today:</p>
<p><strong>What you can do now:</strong></p>
<ul>
<li><p>Install gVisor manually and configure a RuntimeClass for sandboxed pods — not managed, but available</p>
</li>
<li><p>Use Kata Containers on EKS for MicroVM isolation at the pod level — same story</p>
</li>
<li><p>Route agent code execution to Lambda MicroVMs rather than running it in-cluster</p>
</li>
<li><p>Use Fargate for agent workloads — every Fargate task already runs in a Firecracker MicroVM</p>
</li>
</ul>
<p><strong>What doesn't exist yet on EKS:</strong></p>
<ul>
<li><p>A managed, Kubernetes-native agent sandbox primitive with warm pools, sub-second provisioning, and Pod Snapshots equivalent</p>
</li>
<li><p>An AWS-native answer to GKE Agent Sandbox that platform teams can enable with a flag</p>
</li>
</ul>
<p>The trajectory is clear — Lambda MicroVMs show AWS understands the isolation requirement, and the community is already exploring Firecracker-as-Kubernetes-nodes. The question is when that becomes a managed EKS feature.</p>
<hr />
<h2>The Pattern That's Emerging</h2>
<p>Looking across all of this — Google's gVisor, Microsoft's Hyperlight and MXC, Apple's per-container MicroVMs, AWS's Lambda MicroVMs, OpenAI acquiring Ona, E2B and Daytona building the same category from scratch — the pattern is:</p>
<p><strong>The unit of isolation is shifting from the node to the workload.</strong></p>
<p>We spent a decade making Kubernetes good at packing many workloads onto shared nodes efficiently. For AI agents, the direction is inverted: give every workload its own isolated execution environment, make that isolation fast enough to be transparent, make it cheap enough through suspension and resumption to not matter economically.</p>
<p>Brendan Burns put it well: Kubernetes was the answer to stateless → stateful applications. The next inflection — stateful applications → autonomous agents — requires the same kind of platform evolution. The container model isn't going away. But the definition of "isolated" is being upgraded from namespace-level to kernel-level, and the execution model is being upgraded from session-bound to durable.</p>
<p>That upgrade is happening right now. Every major player in the industry is working on it simultaneously. The infrastructure for the agentic era is being decided in the next 12–18 months.</p>
<p>If you're building agent infrastructure on EKS today, the question isn't whether this matters — it's which part of this landscape you're going to use, and which part you'll have to build yourself until AWS ships a managed answer.</p>
]]></content:encoded></item><item><title><![CDATA[Generative AI on Kubernetes — Book Review]]></title><description><![CDATA[There's a specific kind of frustration that comes from reading a book that's half about what you actually needed. Most resources on running AI in…]]></description><link>https://aditmodi.hashnode.dev/generative-ai-on-kubernetes-book-review</link><guid isPermaLink="true">https://aditmodi.hashnode.dev/generative-ai-on-kubernetes-book-review</guid><category><![CDATA[AWS]]></category><dc:creator><![CDATA[Adit Modi]]></dc:creator><pubDate>Sun, 05 Apr 2026 05:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/2770b418-3cfc-4519-aaac-9c44047b32ac.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>There's a specific kind of frustration that comes from reading a book that's half about what you actually needed.</p>
<p>Most resources on running AI in production either assume you're a data scientist who picked up Kubernetes last week, or a platform engineer who treats models as black boxes and hopes for the best. Very few sit in the uncomfortable middle — the place where most production engineering problems actually live.</p>
<p><strong>Generative AI on Kubernetes</strong>, published by O'Reilly, sits in that middle. And it does it well enough that I want to write down why.</p>
<hr />
<h2><strong>What the Book Is Actually About</strong></h2>
<p>The book is structured around four phases of operationalizing LLMs on Kubernetes: inference, production readiness, tuning, and AI-driven applications. That progression is intentional and reflects how most teams actually adopt AI — starting with serving a pre-trained model, then discovering that keeping it running reliably at scale is an entirely different problem.</p>
<p>It doesn't try to teach you machine learning. It explicitly treats LLMs as <em>operational dark-gray boxes</em> — you manage their infrastructure, resources, and lifecycle without diving into neural network mathematics. That framing is honest and useful. But it does give you just enough of the internals to make operational decisions informed ones.</p>
<p>The primer on LLM fundamentals in the introduction — tokenization, embeddings, the prefill and decode phases, compute-bound versus memory-bound workloads — is one of the better short explanations I've read. It's not dumbed down. It's scoped correctly. You close that section understanding <em>why</em> TTFT and TPOT are the metrics that matter, not just <em>that</em> they matter.​</p>
<hr />
<h2><strong>The Model Data Chapter Is Underrated</strong></h2>
<p>Most practitioners skip straight to serving configuration. The model data chapter deserves more attention than it gets.</p>
<p>The progression from <code>emptyDir</code> volumes and init containers, through PersistentVolumes with <code>ReadOnlyMany</code> access mode, all the way to OCI image volume mounts and modelcars is the first time I've seen this problem laid out with the actual trade-offs made explicit.​</p>
<p>The core tension is this: PersistentVolumes backed by NFS or distributed filesystems give you storage efficiency when running many replicas of the same model — one copy, many readers. But they introduce network latency on every model weight read, and at high replica counts, the storage backend becomes the bottleneck. Local approaches like OCI volumes eliminate that latency but require a copy per node.​</p>
<p>The book doesn't pretend there's a universally correct answer. It gives you the framework to make the right call for your workload — replica count, inference throughput requirements, network bandwidth, and whether you're running GPU-based or CPU-based inference. That's more useful than a recommendation.</p>
<p>The section on the Kubeflow Model Registry and OCI artifacts for storing model weights is particularly forward-looking. Treating a model file the same way you treat a container image — versioned, immutable, distributable via a registry — is the direction the ecosystem is heading, and the book explains the mechanics clearly.​</p>
<hr />
<h2><strong>Observability Is Where Most Teams Are Underinvested</strong></h2>
<p>The observability chapter reframes something that most platform engineers miss until they've already been burned.</p>
<p>Monitoring an LLM workload is not the same as monitoring a microservice. CPU and memory utilization tell an incomplete story. The primary compute resource is the GPU, and the two inference phases — compute-bound prefill and memory-bound decode — have fundamentally different performance characteristics that traditional request-rate metrics won't surface.​</p>
<p>The key metrics the book covers:</p>
<ul>
<li><p><strong>Time To First Token (TTFT)</strong> — the user-perceived wait before the first token arrives; maps directly to the prefill phase; exposed in vLLM as <code>vllm:time_to_first_token_seconds</code></p>
</li>
<li><p><strong>Time Per Output Token (TPOT)</strong> — the speed of generation as seen by the user; at least 4–5 tokens per second keeps pace with human reading speed; exposed as <code>vllm:time_per_output_token_seconds</code></p>
</li>
<li><p><strong>KV cache utilization</strong> — because the decode phase is memory-bound, KV cache pressure is often where throughput collapses before any other metric signals a problem</p>
</li>
<li><p><strong>Request queue depth</strong> — <code>vllm:num_requests_waiting</code> tells you when the batch is full and requests are backing up before the system appears saturated by any other measure​</p>
</li>
</ul>
<p>The book also covers GPU-specific metrics via NVIDIA DCGM, AMD ROCm SMI, and Intel XPU Manager — each exposing hardware metrics through Prometheus exporters. The point worth internalizing: there is no common naming convention across vendors, which means your observability setup needs to account for the specific hardware you're running.​</p>
<p>The section on quality metrics and responsible AI — hallucination detection, bias monitoring via TrustyAI, LLM-as-a-judge patterns for asynchronous quality evaluation — is more advanced than I expected from a Kubernetes operations book, and it's the right kind of advanced. These aren't afterthoughts. They're treated as first-class production concerns.​</p>
<hr />
<h2><strong>Disaggregated Serving — The Most Forward-Looking Section</strong></h2>
<p>If you only read one advanced section, read the disaggregated serving chapter.</p>
<p>The core idea: prefill is compute-bound, decode is memory-bound. They have different hardware requirements, different scaling characteristics, and different failure modes. Running them on the same pool of instances is a compromise that makes neither phase optimal. Disaggregated serving splits them into separate hardware pools so each can be tuned and scaled independently.​</p>
<p>The full stack the book describes — Gateway API Inference Extension, distributed KV cache via LMCache, NIXL for point-to-point memory transfer, llm-d for orchestration, KServe LLMInferenceService as the management layer — is genuinely cutting-edge. It's the direction production inference at scale is heading, and the book explains the infrastructure implications clearly.​</p>
<p>The network bandwidth requirement alone is worth understanding: traditional pod networking at 10–20 Gbps is insufficient for sharing KV cache blocks between prefill and decode instances at production scale. The requirement is ~500–600 Gbps — an order of magnitude higher — which means RDMA, RoCE, NVLink, or InfiniBand configurations that were previously only seen in HPC environments.​</p>
<p>This isn't configuration you'll deploy next week. But understanding the architecture now means the decisions you're making today about GPU node topology, networking, and storage don't create unnecessary migration costs later.</p>
<hr />
<h2><strong>Where the Book Has Gaps</strong></h2>
<p>The honest gaps, for teams running on AWS:</p>
<p><strong>Karpenter</strong> — the book covers cluster autoscaler and GPU node management, but Karpenter's NodePool and EC2NodeClass patterns for GPU workloads, and its interaction with LLM-specific autoscaling signals like TTFT and KV cache utilization, are not covered.</p>
<p><strong>AWS-specific hardware</strong> — Trainium and Inferentia2 are absent. Teams running fine-tuning on <code>trn1</code> instances or inference on <code>inf2</code> will need to look elsewhere for the operational patterns specific to Neuron-based workloads.</p>
<p><strong>EKS-specific tooling</strong> — ACK, KRO, Pod Identity, and the patterns for managing AWS resources alongside Kubernetes workloads are outside scope. That's fair — the book is genuinely cloud-agnostic — but it means AWS practitioners need a second reference.</p>
<p>These gaps don't undermine the book. They define its scope. For the foundational mental model of GenAI infrastructure on Kubernetes — model serving, production operations, tuning, and AI-driven applications — it covers the ground it sets out to cover, and covers it well.</p>
<hr />
<h2><strong>Who Should Read This</strong></h2>
<ul>
<li><p><strong>Platform engineers</strong> building out GPU infrastructure and trying to understand what's different about LLM workloads before something breaks in production</p>
</li>
<li><p><strong>MLOps engineers</strong> operationalizing model serving and discovering that Kubernetes adds a layer of complexity that most ML tutorials skip entirely</p>
</li>
<li><p><strong>Cloud architects</strong> designing the infrastructure layer for GenAI products who need a structured mental model before making hardware and networking commitments</p>
</li>
</ul>
<p>It's not a quick read. It rewards attention. And it's the kind of book you close thinking about the decisions you're making differently — which is the only standard that matters.</p>
]]></content:encoded></item><item><title><![CDATA[NVIDIA's Two Gifts to Kubernetes: DRA and AICR — What They Mean for Your EKS GPU Platform]]></title><description><![CDATA[From integer counting to structured resources — how Dynamic Resource Allocation and the AI Cluster Readiness framework finally make GPU infrastructure…]]></description><link>https://aditmodi.hashnode.dev/nvidia-s-two-gifts-to-kubernetes-dra-and-aicr-what-they-mean-for-your-eks-gpu-platform</link><guid isPermaLink="true">https://aditmodi.hashnode.dev/nvidia-s-two-gifts-to-kubernetes-dra-and-aicr-what-they-mean-for-your-eks-gpu-platform</guid><category><![CDATA[AWS]]></category><dc:creator><![CDATA[Adit Modi]]></dc:creator><pubDate>Sat, 04 Apr 2026 05:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/afed4fd4-21fd-4c71-bd0c-5512daa82696.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>From integer counting to structured resources — how Dynamic Resource Allocation and the AI Cluster Readiness framework finally make GPU infrastructure manageable at scale.</p>
<h3>Contents</h3>
<ol>
<li><p><a href="#part1">The Two Nightmares — A Tale of Two Incidents</a></p>
</li>
<li><p><a href="#part2">The Old Model: GPU Extended Resources Were Always a Hack</a></p>
</li>
<li><p><a href="#part3">DRA: GPUs as First-Class Structured Resources</a></p>
</li>
<li><p><a href="#part4">AICR: Treating the GPU Stack Like Software</a></p>
</li>
<li><p><a href="#part5">Putting It Together: The Full EKS GPU Platform Lifecycle</a></p>
</li>
<li><p><a href="#part6">Full Manifests and Deployment</a></p>
</li>
<li><p><a href="#part7">The Governance Angle</a></p>
</li>
</ol>
<p>Part One</p>
<h2>The Two Nightmares — A Tale of Two Incidents</h2>
<p>Every GPU platform engineer running distributed training on EKS has two recurring nightmares. They don't announce themselves with clean error messages. They cost days to diagnose. And until this month, Kubernetes had no structural answer to either of them.</p>
<p>Incident A — The Scheduling Nightmare</p>
<p>It is 2 AM on a Tuesday. A team's 32-GPU gang job has been in <code>Pending</code> for six hours — not waiting for GPUs to free up, but deadlocked. The scheduler sees 32 available <code>nvidia.com/gpu</code> tokens distributed across four nodes and happily assigns 8 pods. Except GPUs 0–3 on Node A and GPUs 4–7 on Node B are on separate NVLink islands. The two groups cannot communicate at NVLink bandwidth. NCCL probes, times out, and the job silently stalls.</p>
<p>The fix is a 3-hour archaeology session through node labels, custom topology plugin configs, and a hand-edited pod affinity rule that will be wrong again after the next node group refresh.</p>
<p>Incident B — The Drift Nightmare</p>
<p>Three months pass. A 16-node NCCL allreduce workload that has been running perfectly starts hanging after what the ops team describes as "an unrelated EKS node group AMI refresh." No error. Just hangs at the collective barrier.</p>
<p>The postmortem, five days later, reveals that the new AMI shipped NCCL 2.21 while the GPU Operator container toolkit was still built against 2.19. The mismatch is invisible to Kubernetes. No alert fires. No readiness probe catches it. The cluster <em>looks</em> healthy because its <code>nvidia.com/gpu</code> count is exactly right.</p>
<p>These two incidents share a root cause. <strong>Kubernetes was never designed to understand GPU hardware topology, and nobody was systematically tracking what combination of GPU software was actually running.</strong></p>
<p>NVIDIA announced two projects at KubeCon 2026 that address this directly: <strong>Dynamic Resource Allocation (DRA)</strong> — donated to the Kubernetes upstream and CNCF — and <strong>AICR (AI Cluster Readiness)</strong>, a validation framework for maintaining known-good GPU software stacks. DRA solves the topology blindness. AICR solves the configuration drift.</p>
<p>INCIDENT A Scheduling Nightmare Scheduler sees GPUs as opaque integers. No NVLink awareness. Gang job deadlocks silently.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/bbe5128f-8cd0-4f06-b1e2-e4cbd2dc514f.png" alt="" style="display:block;margin:0 auto" />

<p>Fig 1 — Two unsolved problems, two new primitives</p>
<p>The rest of this post is a deep dive into both. By the end, you'll have the mental model, the architecture diagrams, and the full manifests to implement both on EKS 1.31+.</p>
<hr />
<p>Part Two</p>
<h2>GPU Extended Resources: Why the Old Model Was Always a Hack</h2>
<p>To understand why DRA matters, you need to understand exactly what <code>nvidia.com/gpu: 1</code> means to the Kubernetes scheduler today — and more importantly, what it <em>cannot</em> mean.</p>
<p>Extended resources are opaque integers. When the NVIDIA device plugin runs on a node and advertises <code>nvidia.com/gpu: 8</code>, it is telling Kubernetes: "this node has 8 units of a countable resource called <code>nvidia.com/gpu</code>." The scheduler's job becomes: find a node where the sum of unallocated units of that resource ≥ what the pod requested. That's the entire model.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/f78d990f-fbc7-4e5d-ba7c-cd2ff2943e23.png" alt="" style="display:block;margin:0 auto" />

<p>Fig 2 — The scheduler sees 6 GPUs as an opaque integer. NVLink topology is invisible.</p>
<h3>What the Scheduler Cannot See</h3>
<p>The extended resource model is blind to everything that actually matters for GPU workload placement:</p>
<table>
<thead>
<tr>
<th>Property</th>
<th>Why It Matters</th>
<th>Visible to Scheduler?</th>
</tr>
</thead>
<tbody><tr>
<td><strong>NVLink connectivity</strong></td>
<td>Multi-GPU all-reduce bandwidth: 600 GB/s (NVLink) vs 64 GB/s (PCIe)</td>
<td>❌ No</td>
</tr>
<tr>
<td><strong>PCIe vs SXM form factor</strong></td>
<td>Affects both bandwidth and NCCL topology selection</td>
<td>❌ No</td>
</tr>
<tr>
<td><strong>MIG profile availability</strong></td>
<td>A100 can be partitioned into 7× MIG-1g.10gb or 1× full A100</td>
<td>❌ No</td>
</tr>
<tr>
<td><strong>GPU health state</strong></td>
<td>A failing GPU returns 0% utilization — looks idle, not broken</td>
<td>❌ No</td>
</tr>
<tr>
<td><strong>NUMA affinity</strong></td>
<td>GPU 0 may be on NUMA node 0, GPU 4 on NUMA node 1 — affects CPU/memory latency</td>
<td>❌ No</td>
</tr>
<tr>
<td><strong>Driver version</strong></td>
<td>Different driver versions support different CUDA capabilities</td>
<td>❌ No</td>
</tr>
</tbody></table>
<p>The consequence is that every "smart" GPU scheduling feature built on top of the existing model — topology-aware scheduling via node labels, MIG selection via custom device plugins, gang scheduling via custom scheduler plugins — is a workaround piled on top of a fundamentally limited primitive. You're building a load-bearing structure on a foundation that was never designed to carry the weight.</p>
<p>The extended resource model asks the scheduler to make hardware-topology-aware decisions with hardware-topology-blind data. That's not a configuration problem. That's an architectural gap.</p>
<p>This is what DRA is designed to close.</p>
<hr />
<p>Part Three</p>
<h2>DRA: GPUs as First-Class Structured Resources</h2>
<p>Dynamic Resource Allocation landed as a Kubernetes alpha in 1.26, reached beta in 1.30, and is production-stable on EKS 1.31+. NVIDIA's contribution is the DRA driver — a structured device plugin that exposes each GPU not as a counter increment, but as a rich object with topology, health, and capability attributes that the scheduler can reason over natively.</p>
<h3>The New Primitives: ResourceClaim and ResourceClass</h3>
<p>DRA introduces two new API objects. A <code>ResourceClass</code> is cluster-scoped and defines the driver and parameters for a category of resource. A <code>ResourceClaim</code> is namespace-scoped and represents a specific workload's request for devices that satisfy given constraints.</p>
<pre><code class="language-plaintext"># ResourceClass — cluster-scoped, created by the platform team
apiVersion: resource.k8s.io/v1alpha2
kind: ResourceClass
metadata:
  name: nvidia-gpu-nvlink
driverName: gpu.nvidia.com
parametersRef:
  apiGroup: gpu.nvidia.com
  kind: GpuAllocationParameters
  name: nvlink-connected-4x
---
# GpuAllocationParameters — driver-specific constraints
apiVersion: gpu.nvidia.com/v1alpha1
kind: GpuAllocationParameters
metadata:
  name: nvlink-connected-4x
spec:
  count: 4
  selector:
    nvlink: required       # all 4 must share an NVLink domain
    health: healthy       # exclude GPUs with XID errors
    minMemory: 40Gi      # minimum HBM per GPU
</code></pre>
<pre><code class="language-plaintext"># ResourceClaim — namespace-scoped, requested by the workload
apiVersion: resource.k8s.io/v1alpha2
kind: ResourceClaim
metadata:
  name: training-gpu-claim
  namespace: ml-team
spec:
  resourceClassName: nvidia-gpu-nvlink
</code></pre>
<p>A <code>PodSpec</code> then references the claim instead of listing resource requests:</p>
<pre><code class="language-plaintext">spec:
  resourceClaims:
  - name: gpus
    source:
      resourceClaimName: training-gpu-claim
  containers:
  - name: trainer
    image: nvcr.io/nvidia/pytorch:24.02-py3
    resources:
      claims:
      - name: gpus  # bind the claim to this container
</code></pre>
<h3>The GpuAllocationState: What the DRA Driver Actually Exposes</h3>
<p>The NVIDIA DRA driver runs as a DaemonSet on each GPU node and populates a <code>GpuAllocationState</code> custom resource per-node. This is the structured data the scheduler consumes when evaluating claims.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/6ec3275f-8370-4257-bc0f-8e6374923d4e.png" alt="" style="display:block;margin:0 auto" />

<p>Fig 3 — The DRA driver exposes per-GPU structured state. The scheduler consumes it natively.</p>
<h3>CompositeDevice: Claiming Groups Atomically</h3>
<p>One of the most important DRA concepts for distributed training is the <code>CompositeDevice</code> — the ability to claim a group of GPUs as a single atomic unit. This is the primitive that eliminates gang scheduling deadlocks.</p>
<p>When a <code>ResourceClaim</code> requests 4 NVLink-connected GPUs, the DRA driver evaluates the <code>GpuAllocationState</code> and creates a <code>CompositeDevice</code> if and only if it can find 4 GPUs that all meet the constraints simultaneously. The scheduler then either places the pod on that node with those 4 GPUs, or it doesn't place it at all. There is no partial allocation, no "I'll take 2 here and 2 somewhere else."</p>
<p><strong>Key insight:</strong> With extended resources, the scheduler allocates GPUs one at a time, on a first-come-first-served basis per pod. Deadlocks happen when multiple jobs each grab some GPUs and wait for the rest. DRA's CompositeDevice makes multi-GPU allocation atomic — you get all-or-nothing, which eliminates the deadlock condition entirely.</p>
<h3>MIG Support Under DRA</h3>
<p>MIG (Multi-Instance GPU) partitioning on A100 and H100 becomes dramatically more usable under DRA. Today, MIG configuration requires pre-partitioning nodes with specific profiles and adding custom labels — a static, manual process that doesn't adapt to workload demand.</p>
<p>Under DRA, a workload can request a specific MIG profile via the <code>GpuAllocationParameters</code>, and the DRA driver can dynamically partition a GPU into the requested profile at pod scheduling time, then restore it after the pod exits. This is still in alpha, but the architecture is sound and the trajectory is clear.</p>
<pre><code class="language-plaintext"># Request a single MIG 3g.40gb slice
apiVersion: gpu.nvidia.com/v1alpha1
kind: GpuAllocationParameters
metadata:
  name: mig-3g-40gb
spec:
  mig:
    profile: 3g.40gb    # 3 compute slices, 40GB HBM
    placement: auto   # driver selects least-fragmented placement
</code></pre>
<h3>Migrating from Extended Resources on EKS 1.31+</h3>
<p>EKS 1.31 enables DRA by default via the <code>DynamicResourceAllocation</code> feature gate. You do not need to migrate all workloads at once — extended resources and DRA claims coexist on the same cluster and even the same node during transition.</p>
<p>The recommended migration order:</p>
<ol>
<li><p>Install the NVIDIA DRA driver alongside (not replacing) the existing device plugin. Both can run simultaneously.</p>
</li>
<li><p>Start with new workloads that have strict topology requirements — distributed training jobs, multi-node inference. These get the most immediate benefit.</p>
</li>
<li><p>Create <code>ResourceClass</code> definitions for each workload profile (single GPU, NVLink-4x, MIG slice, etc.).</p>
</li>
<li><p>Migrate batch jobs before interactive/notebook workloads. Batch jobs are easier to restart if something goes wrong.</p>
</li>
<li><p>Once all workloads use DRA claims, remove the device plugin DaemonSet and the <code>nvidia.com/gpu</code> extended resource annotations.</p>
</li>
</ol>
<p><strong>What breaks during migration:</strong> Any tooling that reads <code>nvidia.com/gpu</code> from pod specs (cost allocation tools, custom autoscalers, some monitoring dashboards) will need updates. DRA claims are in <code>spec.resourceClaims</code>, not <code>spec.containers[*].resources.limits</code>. Audit your toolchain before cutting over.</p>
<hr />
<p>Part Four</p>
<h2>AICR: Treating the GPU Stack Like Software</h2>
<p>DRA solves the scheduling problem at cluster bootstrap and workload placement time. But it does nothing to prevent Incident B — the silent drift that happens when a node group AMI refresh quietly changes the NCCL version, or when a Helm chart upgrade bumps the GPU Operator toolkit in a way that's incompatible with the kernel driver already installed on the node.</p>
<p>AICR (AI Cluster Readiness) is NVIDIA's answer to this. It is not a monitoring tool. It is not an alerting system. It is a <strong>conformance framework</strong> — a way to define what "correct" looks like for your GPU software stack, snapshot the actual state of your cluster, and produce a machine-readable diff that tells you exactly where you've drifted.</p>
<h3>What a Validated Recipe Contains</h3>
<p>An AICR recipe is a version matrix — a YAML document that specifies the exact combination of GPU software components that NVIDIA has tested together and validated for a specific hardware target. Think of it as a lock file for your GPU stack, equivalent to a <code>requirements.txt</code> with pinned hashes.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/aed54863-4c3f-4940-a710-cbff34ac6658.png" alt="" style="display:block;margin:0 auto" />

<p>Fig 4 — An AICR recipe pins every layer of the GPU software stack</p>
<h3>The <code>aicr validate</code> Workflow</h3>
<p>Validation is a three-step process. AICR deploys a DaemonSet of probe pods that inspect each node's GPU software stack, collects the results centrally, diffs them against the target recipe, and produces a structured report.</p>
<pre><code class="language-plaintext"># Install AICR on your EKS cluster
helm upgrade --install aicr \
  oci://nvcr.io/nvidia/aicr/aicr-operator \
  --version 1.2.0 \
  --namespace nvidia-system \
  --create-namespace

# Apply your target recipe
kubectl apply -f h100-sxm-eks-1.31.yaml

# Run validation against all GPU nodes
aicr validate \
  --recipe h100-sxm-eks-1.31 \
  --node-selector accelerator=nvidia-h100 \
  --output-format sarif
</code></pre>
<p>The output is a SARIF (Static Analysis Results Interchange Format) report — the same format used by GitHub Advanced Security, which means it can be consumed directly by your existing security pipeline and surfaced as PR annotations:</p>
<pre><code class="language-plaintext">{
  "version": "2.1.0",
  "runs": [{
    "tool": { "driver": { "name": "aicr" } },
    "results": [
      {
        "ruleId": "AICR-ML-001",
        "level": "error",
        "message": {
          "text": "nccl version mismatch: expected 2.21.5-1, found 2.19.3-1 on node ip-10-0-4-23"
        }
      }
    ]
  }]
}
</code></pre>
<h3>GitOps Integration: Recipe as Source of Truth</h3>
<p>The operational power of AICR comes when you wire it into your GitOps pipeline. The recipe lives in Git, ArgoCD watches for drift, and new NVIDIA-validated recipes trigger automated pull requests against your cluster configuration.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/f7bfcdab-b697-4d73-acf4-e0ee849d61e3.png" alt="" style="display:block;margin:0 auto" />

<p>Fig 5 — AICR recipe as GitOps source of truth. New NVIDIA recipes trigger automated PRs; ArgoCD reconciles.</p>
<h3>AICR and KARs Conformance</h3>
<p>CNCF launched the Kubernetes AI Requirements (KARs) certification program in March 2026, nearly doubling the number of certified Kubernetes AI platforms in its first month. AICR's validation output is directly consumable as KARs conformance evidence — running <code>aicr validate</code> produces the artifacts that the KARs submission process requires for GPU stack certification.</p>
<p>For enterprises pursuing multi-cloud or multi-vendor GPU strategies, this matters: a cluster that passes AICR validation against a published recipe is also KARs-compliant by construction, giving you a vendor-neutral attestation of your GPU platform's readiness.</p>
<p><strong>EKS-specific integration:</strong> AICR recipes include a field mapping to EKS Optimized AMI versions. <code>aicr validate</code> will flag if your node group's launch template references an AMI whose NCCL or driver version is out of alignment with the recipe — catching the exact failure mode of Incident B before it reaches production.</p>
<hr />
<p>Part Five</p>
<h2>Putting It Together: The Full EKS GPU Platform Lifecycle</h2>
<p>DRA and AICR are not independently optional — they compose into a complete operational story. Here is what the full lifecycle looks like when both are in place.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/80c14f6d-6aeb-4fc0-b416-22d57191b9d1.png" alt="" style="display:block;margin:0 auto" />

<p>Fig 6 — Full EKS GPU platform lifecycle with DRA + AICR</p>
<h3>Stage 1: Cluster Bootstrap from Recipe</h3>
<p>The cluster provisioning pipeline (eksctl, Terraform, or CDK) is parameterized by an AICR recipe. The recipe determines: which EKS Optimized AMI version to use for GPU node groups, which GPU Operator Helm chart version to install, which DRA driver version to deploy, and which NCCL version to bake into the base container image layer.</p>
<p>The result is a cluster whose initial state is fully specified, reproducible, and integrity-checked. Every node boots with exactly the software stack the recipe declares. <code>aicr validate</code> on day zero returns zero findings.</p>
<h3>Stage 2: Workload Scheduling with DRA + KAI Scheduler</h3>
<p>KAI Scheduler (also donated to CNCF at KubeCon 2026) extends the Kubernetes scheduler framework with gang scheduling semantics that are DRA-aware. When a PyTorchJob requests 4× NVLink-connected H100s per worker, KAI Scheduler creates the ResourceClaims, waits for all of them to be satisfiable simultaneously, and only then begins binding pods. No partial placements, no deadlocks.</p>
<h3>Stage 3: Continuous Validation in CI/CD</h3>
<p>AICR validation runs as a CI/CD job — either on a schedule (every 6 hours) or triggered by node group events (new node joins, AMI refresh, Helm upgrade). The SARIF output feeds into your existing security findings pipeline. Any finding with severity <code>error</code> blocks a deployment promotion gate.</p>
<h3>Stage 4: Upgrade Path</h3>
<p>When NVIDIA publishes a new validated recipe, an automated PR is opened against your infra repo. The PR updates the recipe file, bumps the GPU Operator chart version, and updates the AMI parameter. The merge is gated on AICR conformance passing in staging. After merge, ArgoCD rolls out the GPU Operator upgrade, eksctl updates the launch template and triggers a rolling node group refresh, and AICR runs a final conformance check in production before the deployment is marked complete.</p>
<hr />
<p>Part Six</p>
<h2>Full Manifests and Deployment</h2>
<h3>1. DRA ResourceClass and ResourceClaim for a PyTorchJob</h3>
<pre><code class="language-plaintext"># platform/dra/gpu-classes.yaml
apiVersion: resource.k8s.io/v1alpha2
kind: ResourceClass
metadata:
  name: h100-nvlink-8x
  annotations:
    gpu.nvidia.com/description: "8× H100 SXM5, all NVLink-connected, full node"
driverName: gpu.nvidia.com
parametersRef:
  apiGroup: gpu.nvidia.com
  kind: GpuAllocationParameters
  name: h100-nvlink-8x-params
---
apiVersion: gpu.nvidia.com/v1alpha1
kind: GpuAllocationParameters
metadata:
  name: h100-nvlink-8x-params
spec:
  count: 8
  selector:
    nvlink: required
    health: healthy
    model: H100 SXM5
    minMemory: 79Gi
---
apiVersion: resource.k8s.io/v1alpha2
kind: ResourceClass
metadata:
  name: h100-single
driverName: gpu.nvidia.com
parametersRef:
  apiGroup: gpu.nvidia.com
  kind: GpuAllocationParameters
  name: h100-single-params
---
apiVersion: gpu.nvidia.com/v1alpha1
kind: GpuAllocationParameters
metadata:
  name: h100-single-params
spec:
  count: 1
  selector:
    health: healthy
</code></pre>
<pre><code class="language-plaintext"># workloads/pytorch-distributed/claim.yaml
apiVersion: resource.k8s.io/v1alpha2
kind: ResourceClaimTemplate
metadata:
  name: training-gpus
  namespace: ml-training
spec:
  spec:
    resourceClassName: h100-nvlink-8x
---
# PyTorchJob referencing the claim template
apiVersion: kubeflow.org/v1
kind: PyTorchJob
metadata:
  name: llama-pretrain
  namespace: ml-training
spec:
  pytorchReplicaSpecs:
    Worker:
      replicas: 16          # 16 workers × 8 GPUs = 128 H100s
      restartPolicy: OnFailure
      template:
        spec:
          resourceClaims:
          - name: gpus
            source:
              resourceClaimTemplateName: training-gpus
          containers:
          - name: trainer
            image: nvcr.io/nvidia/pytorch:24.02-py3
            resources:
              claims:
              - name: gpus
            env:
            - name: NCCL_DEBUG
              value: "INFO"
            - name: TORCH_DISTRIBUTED_DEBUG
              value: "DETAIL"
</code></pre>
<h3>2. AICR Installation and First Validation Run on EKS</h3>
<pre><code class="language-plaintext"># Add the NVIDIA Helm repo
helm repo add nvidia https://helm.ngc.nvidia.com/nvidia
helm repo update

# Install AICR operator
helm upgrade --install aicr-operator nvidia/aicr-operator \
  --version 1.2.0 \
  --namespace nvidia-system \
  --create-namespace \
  --set "nodeSelector.accelerator=nvidia-h100"

# Download recipe for your hardware/EKS version
aicr recipe pull \
  --hardware h100-sxm5 \
  --eks-version 1.31 \
  --output h100-sxm-eks-1.31.yaml

# Apply recipe to cluster
kubectl apply -f h100-sxm-eks-1.31.yaml

# Run initial validation (outputs to stdout + SARIF file)
aicr validate \
  --recipe h100-sxm-eks-1.31 \
  --node-selector accelerator=nvidia-h100 \
  --output-format sarif \
  --output-file aicr-results.sarif \
  --fail-on error

# Upload SARIF to GitHub Advanced Security
gh api \
  --method POST \
  /repos/{owner}/{repo}/code-scanning/sarifs \
  --input aicr-results.sarif
</code></pre>
<h3>3. ArgoCD ApplicationSet for Recipe-Driven GPU Operator Management</h3>
<pre><code class="language-plaintext"># gitops/applicationsets/gpu-operator.yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: gpu-operator-recipe-driven
  namespace: argocd
spec:
  generators:
  - git:
      repoURL: https://github.com/your-org/platform-config
      revision: HEAD
      files:
      - path: "recipes/*/aicr-recipe.yaml"
  template:
    metadata:
      name: 'gpu-operator-{{cluster_name}}'
    spec:
      project: platform
      source:
        repoURL: https://helm.ngc.nvidia.com/nvidia
        chart: gpu-operator
        targetRevision: '{{kubernetes_components.gpu_operator}}'
        helm:
          values: |
            driver.version: {{kernel_components.nvidia_driver}}
            toolkit.version: {{container_components.nvidia_container_toolkit}}
      destination:
        server: '{{cluster_server}}'
        namespace: gpu-operator
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
        - CreateNamespace=true
</code></pre>
<h3>4. AICR in a GitHub Actions CI Pipeline</h3>
<pre><code class="language-plaintext"># .github/workflows/aicr-validate.yaml
name: AICR GPU Cluster Conformance
on:
  schedule:
  - cron: '0 */6 * * *'       # every 6 hours
  workflow_dispatch: {}          # manual trigger

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4

    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v4
      with:
        role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
        aws-region: us-east-1

    - name: Update kubeconfig
      run: aws eks update-kubeconfig --name prod-gpu-cluster

    - name: Install AICR CLI
      run: pip install aicr-cli==1.2.0

    - name: Run AICR validation
      run: |
        aicr validate \
          --recipe recipes/h100-sxm-eks-1.31.yaml \
          --node-selector accelerator=nvidia-h100 \
          --output-format sarif \
          --output-file results.sarif \
          --fail-on error

    - name: Upload SARIF results
      uses: github/codeql-action/upload-sarif@v3
      if: always()      # upload even on failure
      with:
        sarif_file: results.sarif
</code></pre>
<hr />
<p>Part Seven</p>
<h2>The Governance Angle: Why CNCF Custody Matters</h2>
<p>Both DRA (the Kubernetes upstream feature) and KAI Scheduler (donated alongside AICR at KubeCon 2026) are now CNCF-governed. AICR recipes are published openly. This is worth pausing on, because it changes the strategic calculus for GPU platform teams in non-obvious ways.</p>
<p>The old world looked like this: NVIDIA's scheduling intelligence lived in proprietary plugins — the NVIDIA GPU Operator webhook, the topology-aware plugin, the custom device plugin. If you wanted topology-aware GPU scheduling, you got NVIDIA software, full stop. If you ran a multi-cloud GPU strategy across AWS p4d instances and GCP A3 instances, you had two different proprietary plugin stacks to maintain.</p>
<p>The new world is different. DRA is a Kubernetes upstream feature — it works on every conformant Kubernetes distribution, including EKS, GKE, and AKS. KAI Scheduler is CNCF-governed — it will be audited, it will have a vendor-neutral roadmap, and it will not disappear if NVIDIA's strategic priorities shift. AICR recipes are published openly — a third-party validator could certify against them.</p>
<p>For enterprises with multi-cloud GPU strategies or GPU hardware from multiple vendors, CNCF governance turns DRA and KAI Scheduler from NVIDIA features into infrastructure primitives — as stable and vendor-neutral as kube-scheduler itself.</p>
<p>The practical implication: if your GPU platform engineering roadmap currently includes a "lock-in mitigation" work stream around NVIDIA-specific scheduling plugins, you can now retire most of that work. The replacement is not a workaround — it is the upstream.</p>
<h3>The KARs Connection</h3>
<p>CNCF's Kubernetes AI Requirements certification is the governance structure that ties this together. A cluster that runs the NVIDIA DRA driver, uses KAI Scheduler for gang scheduling, and passes AICR conformance against a published recipe is not just "well-configured" — it is a KARs-certified Kubernetes AI platform with a machine-readable attestation of its GPU stack's readiness. That attestation is increasingly what enterprise procurement and compliance teams will require before approving production GPU workloads.</p>
<p>We are, in other words, watching the GPU infrastructure space mature in real time — from bespoke configurations maintained by heroic individual engineers, to certified, conformance-tested platforms maintained by standard tooling. DRA and AICR are two of the most important steps in that maturation.</p>
<hr />
<h2>Summary</h2>
<table>
<thead>
<tr>
<th>Problem</th>
<th>Old Approach</th>
<th>New Approach</th>
</tr>
</thead>
<tbody><tr>
<td>Topology-blind scheduling</td>
<td>Custom labels + scheduler plugins + webhooks</td>
<td>DRA ResourceClass with NVLink constraints, natively</td>
</tr>
<tr>
<td>Gang scheduling deadlocks</td>
<td>Volcano, custom gang plugins, PodGroup CRDs</td>
<td>KAI Scheduler + DRA CompositeDevice (atomic allocation)</td>
</tr>
<tr>
<td>MIG configuration</td>
<td>Pre-partitioned nodes, static labels, manual profile management</td>
<td>DRA GpuAllocationParameters with dynamic MIG profiles</td>
</tr>
<tr>
<td>Configuration drift</td>
<td>Ad hoc AMI audits, manual version tracking</td>
<td>AICR validate + GitOps recipe-as-source-of-truth</td>
</tr>
<tr>
<td>Upgrade validation</td>
<td>Manual testing, hope nobody changed anything</td>
<td>AICR conformance gate in CI/CD pipeline</td>
</tr>
<tr>
<td>Compliance attestation</td>
<td>Custom documentation, manual evidence collection</td>
<td>AICR SARIF output → KARs certification automatically</td>
</tr>
</tbody></table>
<p><strong>Where to start:</strong> If you're running EKS 1.31+ with H100 or A100 nodes, the lowest-risk first step is AICR — install the operator, pull the recipe for your hardware, run <code>aicr validate</code>, and see what it finds. You'll likely discover at least one version mismatch you didn't know about. That's the value prop in one command.</p>
]]></content:encoded></item><item><title><![CDATA[llm-d on EKS: The New Inference Resource Model That Changes How You Think About GPU Routing]]></title><description><![CDATA[Your vLLM cluster has a problem you probably don't know about. It's not a bug. Nothing is crashing. The metrics dashboard looks fine. But right now, every…]]></description><link>https://aditmodi.hashnode.dev/llm-d-on-eks-the-new-inference-resource-model-that-changes-how-you-think-about-gpu-routing</link><guid isPermaLink="true">https://aditmodi.hashnode.dev/llm-d-on-eks-the-new-inference-resource-model-that-changes-how-you-think-about-gpu-routing</guid><category><![CDATA[AWS]]></category><dc:creator><![CDATA[Adit Modi]]></dc:creator><pubDate>Sat, 28 Mar 2026 06:35:13 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/39430b42-d20a-4bb5-9212-b1d7c9398be4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Your vLLM cluster has a problem you probably don't know about. It's not a bug. Nothing is crashing. The metrics dashboard looks fine. But right now, every time a request hits your load balancer, there's a roughly 50-60% chance it lands on a vLLM pod that has to recompute KV cache it already computed 10 minutes ago — on a different pod, for a nearly identical request. That recomputation costs you 400–2,800ms of GPU time. Multiplied across thousands of requests per hour, it's costing you real money and real latency, silently, behind a round-robin load balancer that has no idea it's doing this.</p>
<p>This is the core insight behind llm-d, and it's deeper than "disaggregated inference." Every discussion about llm-d that focuses on prefill-decode separation is missing the more fundamental architectural shift: llm-d introduces a <strong>completely new Kubernetes resource model for inference</strong> — one built around the idea that the routing layer needs to know what's inside GPU memory before it decides where to send a request.</p>
<p>On March 15, 2026, AWS announced a joint effort with the llm-d community to bring this to EKS and SageMaker HyperPod, including a new AWS-optimized container image with native EFA and NIXL integration. This post is the deep technical guide to what llm-d actually is, why it exists, and how to deploy the full stack — intelligent routing, disaggregated serving, and tiered KV cache — on EKS today.</p>
<hr />
<h2>Part 1: The Cost of Not Knowing What's in GPU Memory</h2>
<p>Before anything else, let's quantify the problem that llm-d solves — because it's easy to dismiss as an optimization detail until you do the arithmetic.</p>
<h3>How vLLM Prefix Caching Works (And Why It Breaks at Scale)</h3>
<p>A single vLLM instance has a radix-tree based KV cache. When request A comes in with a 2,000-token system prompt, vLLM computes the KV values for those 2,000 tokens and caches them, keyed by a block hash of the token sequence. When request B comes in with the same 2,000-token system prompt plus a new user message, vLLM detects the prefix match, skips recomputation of the 2,000 cached tokens, and only computes the new tokens. TTFT on request B is dramatically lower — on the order of 88% faster for warm cache hits.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/46c309ab-3f50-4633-9500-5e6af62f52c5.png" alt="" style="display:block;margin:0 auto" />

<p>This works perfectly on one instance. The moment you scale to a fleet, it stops working:</p>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/3d59fbaa-1389-48a1-9987-80681c38c06a.png" alt="" style="display:block;margin:0 auto" />

<p>The cache hit rate of the fleet as a whole is a function of routing, not just caching. A fleet with 8 pods and round-robin routing has an effective cache hit rate of roughly 1/8th of what a single pod would achieve — even though every pod has prefix caching enabled.</p>
<h3>The Math: What This Costs on EKS</h3>
<p>For a RAG workload with a shared 6,000-token document context (typical for enterprise knowledge base queries):</p>
<ul>
<li><p><strong>KV cache prefill time</strong> for 6,000 tokens on H100: ~180ms per request</p>
</li>
<li><p><strong>Cache hit TTFT</strong>: ~22ms (skip prefill entirely, decode starts immediately)</p>
</li>
<li><p><strong>Cache miss TTFT</strong>: ~202ms (180ms prefill + 22ms decode startup)</p>
</li>
<li><p><strong>Round-robin hit rate on 8 pods</strong>: ~12.5% (1 in 8 requests hits the right pod)</p>
</li>
<li><p><strong>Effective wasted prefill compute</strong>: 87.5% of requests × 180ms = 157.5ms wasted per request on average</p>
</li>
</ul>
<p>On a fleet handling 200 requests per second (a modest production load), that's:</p>
<pre><code class="language-plaintext">157.5ms × 200 req/sec = 31,500ms of wasted GPU-ms per second
= 31.5 GPU-seconds of waste per real second
= 31.5× GPU waste multiplier on prefill
</code></pre>
<p>At p5.48xlarge pricing (\(100.42/hour for 8 H100s = \)12.55/GPU/hour), wasting 31.5 GPU-seconds per real second translates to roughly <strong>$110/hour in GPU compute doing nothing but re-deriving knowledge that's already sitting in GPU memory on a different pod.</strong></p>
<p>With llm-d's KV cache-aware routing at 87.4% cache hit rate: that waste drops to 12.6% of requests × 180ms = 22.7ms wasted per request on average — a <strong>7× reduction in prefill waste</strong>, and a near-direct translation to cost savings and throughput improvement.</p>
<p>This is not a marginal optimization. It's the difference between a GPU fleet running at 40% effective utilization and one running at 80%.</p>
<hr />
<h2>Part 2: The New Kubernetes Resource Model for Inference</h2>
<p>Here's what makes llm-d architecturally interesting beyond the routing algorithm: it introduces three new Kubernetes primitives that don't exist in standard Kubernetes, collectively called the <strong>Gateway API Inference Extension (GAIE)</strong>. These aren't implementation details — they're a new abstraction layer that changes how you think about deploying and managing inference workloads.</p>
<h3>The Problem with Existing Kubernetes Abstractions</h3>
<p>Standard Kubernetes has <code>Service</code>, <code>Deployment</code>, <code>Ingress</code>, and <code>HTTPRoute</code>. These abstractions are designed for stateless, compute-identical, interchangeable replicas. Any pod in a <code>Service</code> is as good as any other for routing purposes. The load balancer picks based on health, connection count, or pure round-robin.</p>
<p>LLM inference breaks every assumption in this model:</p>
<ul>
<li><p><strong>Replicas are not interchangeable</strong> — the pod that has your system prompt cached is categorically better for that request than one that doesn't</p>
</li>
<li><p><strong>Routing decisions require model-server state</strong> — you need to know what's in GPU memory before you route, not after</p>
</li>
<li><p><strong>Multiple models per infrastructure</strong> — a single GPU cluster might serve <code>llama-3.3-70b-fp8</code>, <code>qwen3-32b</code>, and <code>codestral-22b</code> simultaneously with different isolation requirements</p>
</li>
<li><p><strong>Criticality tiers exist</strong> — a production user's query has different SLA requirements than a background batch job</p>
</li>
</ul>
<p>None of the existing Kubernetes abstractions encode these concepts. A <code>Service</code> can't express "route to the pod with the most prefix overlap with this request." An <code>Ingress</code> can't say "this model needs guaranteed capacity, that one gets best-effort." <a href="https://gateway-api-inference-extension.sigs.k8s.io/api-types/inferencepool/">gateway-api-inference-extension.sigs.k8s</a></p>
<h3>The Three New Primitives</h3>
<p><strong>1.</strong> <code>InferenceModel</code> <strong>— What You're Serving</strong></p>
<pre><code class="language-plaintext">Owned by: AI/ML teams (model owners)
Answers: "What model should this traffic class receive, and with what priority?"
</code></pre>
<p><code>InferenceModel</code> is the separation of <em>what to serve</em> from <em>how to serve it</em>. A model owner declares that <code>gpt-4o-mini-equivalent</code> should be served with <code>Critical</code> priority, referencing a specific model ID on the backend vLLM pods. They don't care how many pods are running or on what hardware — that's the platform operator's concern:</p>
<pre><code class="language-yaml">apiVersion: inference.networking.x-k8s.io/v1alpha2
kind: InferenceModel
metadata:
  name: llama-production
  namespace: ml-serving
spec:
  # The model name clients use in their API calls
  modelName: "meta-llama/Meta-Llama-3.3-70B-Instruct-FP8"

  # Criticality determines routing priority under resource pressure
  # Critical &gt; Standard &gt; Sheddable
  criticality: Critical

  # Points to the InferencePool that serves this model
  poolRef:
    name: llama-pool
</code></pre>
<p><strong>2.</strong> <code>InferencePool</code> <strong>— Where and How You're Serving It</strong></p>
<pre><code class="language-plaintext">Owned by: Platform operators
Answers: "Which pods form this serving pool, and which routing brain manages them?"
</code></pre>
<p><code>InferencePool</code> replaces the Kubernetes <code>Service</code> as the backend abstraction for inference traffic. It selects pods by label, defines the target port, and — critically — references an <code>extensionRef</code> that points to the External Processing Pod (EPP) that will make routing decisions: <a href="https://gateway-api-inference-extension.sigs.k8s.io/api-types/inferencepool/">gateway-api-inference-extension.sigs.k8s</a></p>
<pre><code class="language-yaml">apiVersion: inference.networking.x-k8s.io/v1alpha2
kind: InferencePool
metadata:
  name: llama-pool
  namespace: ml-serving
spec:
  # Selects vLLM pods that belong to this pool
  selector:
    matchLabels:
      llm-d.ai/inferenceServing: "true"
      model: llama-3.3-70b

  # Port that vLLM listens on inside pods
  targetPortNumber: 8000

  # The External Processing Pod: the routing oracle
  extensionRef:
    name: llama-epp
    kind: Service
    group: ""
</code></pre>
<p><strong>3. External Processing Pod (EPP) — The Routing Oracle</strong></p>
<pre><code class="language-plaintext">Owned by: Platform (auto-deployed by llm-d Helm chart)
Answers: "For THIS specific request, given the current cache state of all pods, which pod should receive it?"
</code></pre>
<p>The EPP is the architectural innovation that makes the entire system coherent. It's a separate pod that sits between the Envoy/Istio gateway and the vLLM pods. It implements the gRPC <code>ext_proc</code> (external processing) protocol, meaning Envoy sends it a request header, the EPP returns a routing decision (which pod to forward to), and Envoy follows it. This happens inline, on the request path, with sub-millisecond decision latency.</p>
<p>The EPP maintains a live, in-memory model of the KV cache state across every vLLM pod in the pool. This is the architectural piece that enables cache-aware routing at scale:</p>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/0d150570-acfd-4ea1-98c0-320bf28dd127.png" alt="" style="display:block;margin:0 auto" />

<p>The full architecture, showing how these three primitives compose:</p>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/2dee924c-2036-4630-a45d-dbf34c09c94a.png" alt="" style="display:block;margin:0 auto" />

<p>This is a fundamentally different mental model from "pods behind a load balancer." The routing layer is <strong>stateful and model-aware</strong>. It knows the exact contents of GPU memory on every pod before making a forwarding decision.</p>
<hr />
<h2>Part 3: The KV Cache Indexer — How the EPP Knows What's in GPU Memory</h2>
<p>The EPP's KV cache indexer is the technical core of llm-d's intelligent routing. Understanding it explains both the performance numbers and the operational constraints.</p>
<h3>Architecture Components</h3>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/f3501a1f-c090-4658-85cf-7b7ca8432e11.png" alt="" style="display:block;margin:0 auto" />

<h3>The Write Path: How vLLM Tells the EPP What It Cached</h3>
<p>Every vLLM pod in an <code>InferencePool</code> publishes cache lifecycle events over ZMQ to the EPP:</p>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/c1f13812-7258-45a5-be12-00fd3bc1ddb2.png" alt="" style="display:block;margin:0 auto" />

<p>When blocks are evicted from GPU memory (due to LRU pressure), vLLM publishes <code>BlockRemoved</code> events, and the EPP removes those entries from its index. The index stays synchronized with actual GPU memory state in near-real-time.</p>
<h3>The Read Path: Scoring Pods for a New Request</h3>
<p>When a new request arrives at the gateway:</p>
<pre><code class="language-python"># Pseudocode: EPP read path (sub-millisecond execution target)

def score_pods_for_request(prompt: str, available_pods: List[str]) -&gt; List[PodScore]:

    # Step 1: Check PrefixStore for cached tokenization
    # (tokenizing 2000 tokens is expensive; cache the result)
    token_ids = prefix_store.get_or_tokenize(prompt)

    # Step 2: Convert tokens to KV block keys
    # Uses IDENTICAL chunking algorithm to vLLM (16 or 128 tokens per block)
    block_keys = token_processor.tokens_to_block_keys(token_ids)

    # Step 3: Score each pod by consecutive prefix match length
    scores = {}
    for pod in available_pods:
        # Count consecutive blocks from the START of the prompt
        # that this pod has cached
        consecutive_hits = 0
        for block_key in block_keys:
            if index.has_block(pod, block_key):
                consecutive_hits += 1
            else:
                break  # Stop at first gap — consecutive prefix only

        scores[pod] = consecutive_hits

    # Step 4: Return pods ranked by score (highest = most prefix cached)
    return sorted(scores.items(), key=lambda x: x [developers.redhat](https://developers.redhat.com/articles/2025/10/07/master-kv-cache-aware-routing-llm-d-efficient-ai-inference), reverse=True)
</code></pre>
<p>The "consecutive blocks" criterion is critical. A pod with blocks 0, 1, 2, 3 cached is far more valuable than one with blocks 0, 2, 4, 6 — because vLLM's prefill computation is sequential. A gap in the middle forces recomputation from the gap forward. The EPP scores on the longest unbroken prefix sequence, not total block count.</p>
<hr />
<h2>Part 4: llm-d's Well-Lit Paths — The Four Deployment Patterns</h2>
<p>llm-d doesn't give you a single deployment mode. It gives you composable building blocks organized into "well-lit paths" — reference architectures for specific performance goals. Think of these as presets: each path is a curated configuration of vLLM settings + gateway settings + EPP configuration + NIXL settings that has been tested for a specific workload type.</p>
<h3>Path 1: Intelligent Inference Scheduling (Start Here)</h3>
<p><strong>What it is</strong>: KV cache-aware routing only. No prefill-decode disaggregation. All vLLM pods handle both prefill and decode, but the EPP routes cache-intelligently.</p>
<p><strong>When to use</strong>: Single-node GPU setups, smaller models (≤ 8B), workloads where TTFT improvement from cache routing is more valuable than disaggregation overhead.</p>
<p><strong>Performance</strong>: 87.4% cache hit rate, 88% TTFT improvement on warm requests, 109% higher throughput vs baseline Kubernetes service on shared-prefix workloads.</p>
<p>This is the lowest-friction entry point and delivers immediate value for any RAG or multi-turn workload.</p>
<h3>Path 2: Prefill-Decode Disaggregation</h3>
<p><strong>What it is</strong>: Separate prefill pods and decode pods. Prefill pods process the input and build KV cache; decode pods receive the KV cache via NIXL/EFA and generate output tokens.</p>
<p><strong>When to use</strong>: Large models (≥ 70B), long input sequences (≥ 2,000 tokens), high concurrency (≥ 50 concurrent requests), or when output sequence length significantly exceeds input length (reasoning models).</p>
<p><strong>Performance</strong>: 70% higher tokens/second vs standard vLLM deployment at concurrency=128, measured with 1K/1K input/output on B200 GPUs.</p>
<h3>Path 3: Wide Expert Parallelism (MoE Models)</h3>
<p><strong>What it is</strong>: Data parallelism + expert parallelism across multiple nodes. Each "expert" in a Mixture-of-Experts model runs on dedicated GPUs; requests are routed to the node set hosting the relevant experts.</p>
<p><strong>When to use</strong>: DeepSeek-R1, Qwen3.5-MoE, Kimi K2.5, or any MoE architecture. Without this, MoE models either don't fit in memory or are massively underutilized.</p>
<p><strong>Performance</strong>: ~50,000 output tokens/second total throughput on 16P/16D B200 topology.</p>
<h3>Path 4: Tiered Prefix Caching</h3>
<p><strong>What it is</strong>: Three-tier KV cache storage — GPU HBM → CPU DRAM → shared filesystem (EFS/FSx). The <code>llm-d FS backend</code> offloads KV blocks from GPU memory asynchronously to lower tiers, dramatically extending effective cache size.</p>
<p><strong>When to use</strong>: Workloads with very large working sets (many unique system prompts, large document corpora in RAG), or when GPU memory pressure is causing cache evictions that hurt hit rates.</p>
<p><strong>Key property</strong>: New pods joining the fleet can "hydrate" their cache from the shared filesystem immediately, bypassing the warm-up period that makes pod scaling painful in standard deployments.</p>
<hr />
<h2>Part 5: The AWS Container and EFA Integration</h2>
<p>The <code>ghcr.io/llm-d/llm-d-aws</code> container is not just the standard llm-d container with a new tag. It includes:</p>
<ul>
<li><p><strong>EFA userspace libraries</strong> (libfabric, aws-ofi-nccl) — required for NIXL's EFA transport path</p>
</li>
<li><p><strong>NIXL with libfabric plugin</strong> — enables GPU-to-GPU KV cache transfer over EFA using RDMA</p>
</li>
<li><p><strong>GPUDirect RDMA support</strong> — allows GPU memory on the prefill pod to be read directly by the decode pod's GPU, bypassing CPU on both nodes</p>
</li>
</ul>
<p>The communication stack for KV transfer in disaggregated mode:</p>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/0ee2b0d1-0a5a-4905-9abd-023f901d7cf7.png" alt="" style="display:block;margin:0 auto" />

<p>NIXL with libfabric over EFA gives you:</p>
<ul>
<li><p><strong>Zero-copy</strong>: GPU memory is DMA'd directly over EFA — no host CPU memory copy</p>
</li>
<li><p><strong>Kernel bypass</strong>: libfabric's RDMA path bypasses the OS network stack entirely</p>
</li>
<li><p><strong>Sub-microsecond overhead</strong>: Measured additional latency from KV transfer on EFA is negligible vs the prefill computation time it replaces</p>
</li>
</ul>
<p>On p5.48xlarge with 32 EFA interfaces and 8 H100s, the recommended allocation is <strong>4 EFA interfaces per GPU pod</strong> (32 interfaces / 8 GPUs = 4 per GPU). This matches the ratio needed to saturate H100 SXM's NVLink bandwidth between nodes.</p>
<hr />
<h2>Part 6: Full EKS Deployment Walkthrough</h2>
<p>Let's deploy the complete stack. We'll start with intelligent inference scheduling (Path 1), then layer on disaggregation (Path 2).</p>
<h3>Prerequisites and Cluster Requirements</h3>
<pre><code class="language-bash"># Required tools
aws --version          # AWS CLI v2+
kubectl version        # 1.29+
helm version           # 3.14+
helmfile --version     # 0.162+

# EKS cluster requirements:
# - EKS 1.29+ (tested on 1.32)
# - GPU nodes: g6e.12xlarge (dev) or p5.48xlarge (production)
# - Single AZ deployment for EFA (us-east-1a recommended)
# - aws-load-balancer-controller installed
# - NVIDIA GPU Operator installed (see Part 2 of Slinky+KAI post)

# Verify GPU nodes are ready
kubectl get nodes -l workload-type=gpu-inference
# NAME          STATUS   ROLES    AGE   VERSION
# gpu-node-01   Ready    &lt;none&gt;   24m   v1.32.0-eks-...
</code></pre>
<h3>Step 1: Install the Gateway Provider (Istio)</h3>
<p>llm-d uses the Gateway API Inference Extension, which requires a gateway implementation that supports the <code>ext_proc</code> (external processing) protocol. Istio 1.27+ supports this:</p>
<pre><code class="language-bash"># Clone llm-d repository
git clone https://github.com/llm-d/llm-d.git
cd llm-d

# Install Gateway API CRDs (standard + inference extension)
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.0/standard-install.yaml
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/v0.3.0/manifests.yaml

# Verify inference extension CRDs
kubectl get crd | grep inference
# inferencemodels.inference.networking.x-k8s.io
# inferencepools.inference.networking.x-k8s.io

# Install Istio with ext_proc support
cd guides/prereq/gateway-provider
./install-gateway-provider-dependencies.sh
helmfile apply -f istio.helmfile.yaml

# Verify Istio is running
kubectl get pods -n istio-system
# istiod-xxx    1/1  Running
</code></pre>
<h3>Step 2: Configure the AWS NLB Gateway</h3>
<pre><code class="language-yaml"># common-configurations/istio.yaml
# Configures the Istio Gateway to use AWS NLB

gateway:
  gatewayClassName: istio
  gatewayParameters:
    accessLogging: false
    logLevel: error
    resources:
      limits:
        cpu: "16"
        memory: 16Gi
      requests:
        cpu: "4"
        memory: 4Gi
  service:
    type: LoadBalancer
  annotations:
    # AWS NLB configuration
    service.beta.kubernetes.io/aws-load-balancer-type: "external"
    service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: "ip"
    service.beta.kubernetes.io/aws-load-balancer-scheme: "internal"
    service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled: "true"
    # Health check for vLLM's /health endpoint
    service.beta.kubernetes.io/aws-load-balancer-healthcheck-path: "/health"
    service.beta.kubernetes.io/aws-load-balancer-healthcheck-port: "8000"

inferenceExtension:
  flags:
    v: 1   # Verbosity level; increase to 4 for routing decision logs

provider:
  name: istio
  istio:
    destinationRule:
      trafficPolicy:
        connectionPool:
          http:
            # Critical: vLLM requests can be long-lived (streaming)
            # These values must be large enough for your max concurrent requests
            http1MaxPendingRequests: 256000
            maxRequestsPerConnection: 256000
            http2MaxRequests: 256000
            idleTimeout: "900s"   # 15 min: longer than any reasonable inference request
          tcp:
            maxConnections: 256000
            connectTimeout: "900s"

routing:
  proxy:
    zapLogLevel: error
</code></pre>
<pre><code class="language-bash"># Apply the gateway configuration
helmfile apply -f common-configurations/istio.helmfile.yaml

# Verify NLB is provisioned (takes ~2 minutes)
kubectl get gateways -A
# NAMESPACE   NAME              CLASS   ADDRESS                              READY
# llm-d       llm-d-gateway     istio   llm-d-xxx.elb.us-east-1.amazonaws.com   True

export INFERENCE_GATEWAY=$(kubectl get gateway llm-d-gateway -n llm-d \
  -o jsonpath='{.status.addresses[0].value}')
echo $INFERENCE_GATEWAY
# llm-d-xxx.elb.us-east-1.amazonaws.com
</code></pre>
<h3>Step 3: Deploy Path 1 — Intelligent Inference Scheduling</h3>
<pre><code class="language-bash"># Navigate to the inference-scheduling guide
cd guides/inference-scheduling

# Structure:
# inference-scheduling/
# ├── gaie-inference-scheduling/    ← EPP + InferencePool + InferenceModel config
# │   └── values.yaml
# ├── ms-inference-scheduling/      ← vLLM deployment config
# │   └── values.yaml              ← Edit this for your model + GPU type
# └── helmfile.yaml.gotmpl
</code></pre>
<p>Configure the vLLM deployment for your hardware:</p>
<pre><code class="language-yaml"># ms-inference-scheduling/values.yaml
# Edit these for your specific model and GPU

model:
  name: "meta-llama/Meta-Llama-3.3-70B-Instruct-FP8"
  huggingFaceTokenSecret: "hf-token"   # k8s secret name

# Container image: AWS-optimized llm-d container
image:
  repository: ghcr.io/llm-d/llm-d-aws
  tag: "v0.5.1"

# For p5.48xlarge (H100 SXM): tp=4, 2 pods = 8 GPUs total
# For g6e.12xlarge (L40S): tp=1, 4 pods = 4 GPUs
parallelism:
  tensorParallel: 4
  replicaCount: 2

# vLLM inference engine settings
vllmArgs:
  - "--enable-prefix-caching"      # Must be enabled for EPP to get cache events
  - "--block-size=128"             # 128-token blocks: better for long prompts
  - "--gpu-memory-utilization=0.90"
  - "--max-model-len=32768"
  - "--disable-uvicorn-access-log"

# Resource allocation per pod (tp=4 = 4 GPUs per pod)
resources:
  limits:
    nvidia.com/gpu: "4"
    memory: "512Gi"
    cpu: "64"
  requests:
    nvidia.com/gpu: "4"
    memory: "512Gi"
    cpu: "64"

nodeSelector:
  node.kubernetes.io/instance-type: p5.48xlarge

tolerations:
  - key: nvidia.com/gpu
    operator: Exists
    effect: NoSchedule

# Labels that the EPP uses to discover this pool's pods
labels:
  llm-d.ai/inferenceServing: "true"
  model: llama-3.3-70b
</code></pre>
<p>Configure the EPP and InferencePool:</p>
<pre><code class="language-yaml"># gaie-inference-scheduling/values.yaml

inferencePool:
  # Matches the labels on vLLM pods above
  selector:
    matchLabels:
      llm-d.ai/inferenceServing: "true"
      model: llama-3.3-70b
  targetPortNumber: 8000

inferenceModel:
  # The model name that clients put in their API calls
  modelName: "meta-llama/Meta-Llama-3.3-70B-Instruct-FP8"
  criticality: Critical   # Critical | Standard | Sheddable

epp:
  # EPP configuration: KV cache-aware routing
  cacheAwareRouting:
    enabled: true
    algorithm: "prefix-aware"
    sessionAffinity: true   # Sticky sessions: same client → same pod when possible
  resources:
    requests:
      cpu: "4"
      memory: "8Gi"
    limits:
      cpu: "8"
      memory: "16Gi"
</code></pre>
<pre><code class="language-bash"># Deploy the full inference-scheduling stack
helmfile apply

# Watch everything come up
kubectl get pods -n llm-d -w
# llm-d-epp-xxx            1/1  Running   ← EPP routing oracle
# llm-d-vllm-0             1/1  Running   ← vLLM pod 1
# llm-d-vllm-1             1/1  Running   ← vLLM pod 2

# Verify InferencePool and InferenceModel are created
kubectl get inferencepool,inferencemodel -n llm-d
# NAME                                  READY
# inferencepool.inference.../llama-pool  True
#
# NAME                                       CRITICALITY   POOL
# inferencemodel.inference.../llama-prod     Critical      llama-pool

# Check EPP logs to verify it's receiving ZMQ events from vLLM pods
kubectl logs -n llm-d deploy/llm-d-epp | grep -i "cache\|zmq\|block"
# INFO  KV cache indexer started, listening on ZMQ
# INFO  Pod pod/llm-d-vllm-0 discovered, ZMQ subscriber connected
# INFO  Pod pod/llm-d-vllm-1 discovered, ZMQ subscriber connected
# INFO  BlockStored event: pod=llm-d-vllm-0, blocks=12, model=meta-llama/...
</code></pre>
<h3>Step 4: Test Intelligent Routing</h3>
<pre><code class="language-bash"># Send your first request
curl http://$INFERENCE_GATEWAY/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "meta-llama/Meta-Llama-3.3-70B-Instruct-FP8",
    "messages": [
      {
        "role": "system",
        "content": "You are a helpful assistant specializing in AWS cloud architecture..."
      },
      {
        "role": "user",
        "content": "What is the difference between ECS and EKS?"
      }
    ],
    "max_tokens": 512
  }' | jq '.usage, .choices[0].message.content'

# Send 5 more requests with the same system prompt (should hit cache)
# Watch EPP logs for routing decisions:
kubectl logs -n llm-d deploy/llm-d-epp -f | grep "routing\|cache\|score"

# Expected output on second request with same system prompt:
# INFO  Scoring pods for request [prompt_hash=0xA3F2...]
# INFO  Pod llm-d-vllm-0: score=24 (24 consecutive blocks cached)
# INFO  Pod llm-d-vllm-1: score=0 (0 blocks cached)
# INFO  Routing to llm-d-vllm-0 (score=24, cache-aware)
</code></pre>
<h3>Step 5: Deploy Path 2 — Prefill-Decode Disaggregation</h3>
<p>For disaggregation, you need at least 2 different GPU node types or enough GPUs to split into prefill and decode pools:</p>
<pre><code class="language-bash">cd guides/pd-disaggregation

# This guide has separate configurations for prefill and decode pods
# Structure:
# pd-disaggregation/
# ├── ms-pd/
# │   ├── prefill-values.yaml   ← Prefill pod configuration
# │   └── decode-values.yaml    ← Decode pod configuration
# └── helmfile.yaml.gotmpl
</code></pre>
<p>Configure prefill pods (compute-optimized: lower TP, higher batch):</p>
<pre><code class="language-yaml"># ms-pd/prefill-values.yaml

role: prefill   # Tells llm-d this is a prefill pod

image:
  repository: ghcr.io/llm-d/llm-d-aws
  tag: "v0.5.1"    # Must use AWS container for EFA/NIXL

model:
  name: "meta-llama/Meta-Llama-3.3-70B-Instruct-FP8"

# Prefill: lower TP, compute-bound
# More prefill pods (to handle input load)
parallelism:
  tensorParallel: 2    # TP=2 for prefill (compute-bound; less TP = less comm overhead)
  replicaCount: 4      # 4 prefill pods × 2 GPUs = 8 GPUs for prefill

vllmArgs:
  - "--block-size=128"
  - "--max-model-len=32768"
  - "--gpu-memory-utilization=0.80"   # Leave room for KV transfer buffers
  - "--disable-uvicorn-access-log"
  # NIXL configuration: prefill side sends KV to decode
  - "--kv-transfer-config"
  - '{"kv_connector":"NixlConnector","kv_role":"kv_both","kv_connector_extra_config":{"backends":["LIBFABRIC"],"enable_cross_layers_blocks":"True"}}'

env:
  - name: VLLM_NIXL_SIDE_CHANNEL_HOST
    valueFrom:
      fieldRef:
        fieldPath: status.podIP   # NIXL side-channel: how decode pod discovers prefill pod IP
  - name: HF_HOME
    value: "/model-cache"
  # EFA configuration for NIXL RDMA transfers
  - name: FI_PROVIDER
    value: "efa"
  - name: FI_EFA_USE_DEVICE_RDMA
    value: "1"
  - name: FI_EFA_FORK_SAFE
    value: "1"

resources:
  limits:
    nvidia.com/gpu: "2"
    memory: "256Gi"
    cpu: "32"
    vpc.amazonaws.com/efa: "2"   # 1 EFA per GPU for prefill
  requests:
    nvidia.com/gpu: "2"
    memory: "256Gi"
    cpu: "32"
    vpc.amazonaws.com/efa: "2"

ports:
  - containerPort: 8000
    name: vllm
  - containerPort: 5600   # NIXL side-channel port
    name: nixl

nodeSelector:
  node.kubernetes.io/instance-type: p5.48xlarge

tolerations:
  - key: nvidia.com/gpu
    operator: Exists
    effect: NoSchedule
</code></pre>
<p>Configure decode pods (memory-bandwidth-optimized: higher TP, receives KV):</p>
<pre><code class="language-yaml"># ms-pd/decode-values.yaml

role: decode   # Decode pod: receives KV cache, generates tokens

image:
  repository: ghcr.io/llm-d/llm-d-aws
  tag: "v0.5.1"

model:
  name: "meta-llama/Meta-Llama-3.3-70B-Instruct-FP8"

# Decode: higher TP, memory-bandwidth-bound
# Fewer decode pods (decode is faster than prefill for typical workloads)
parallelism:
  tensorParallel: 4    # TP=4 for decode: spreads KV cache read across 4 GPUs' HBM buses
  replicaCount: 2      # 2 decode pods × 4 GPUs = 8 GPUs for decode

vllmArgs:
  - "--block-size=128"
  - "--max-model-len=32768"
  - "--gpu-memory-utilization=0.85"
  - "--max-num-seqs=256"          # High concurrency: decode is low-compute
  - "--disable-uvicorn-access-log"
  # NIXL: decode side receives KV from prefill
  - "--kv-transfer-config"
  - '{"kv_connector":"NixlConnector","kv_role":"kv_both","kv_connector_extra_config":{"backends":["LIBFABRIC"],"enable_cross_layers_blocks":"True"}}'

env:
  - name: VLLM_NIXL_SIDE_CHANNEL_HOST
    valueFrom:
      fieldRef:
        fieldPath: status.podIP
  - name: HF_HOME
    value: "/model-cache"
  - name: FI_PROVIDER
    value: "efa"
  - name: FI_EFA_USE_DEVICE_RDMA
    value: "1"
  - name: FI_EFA_FORK_SAFE
    value: "1"

resources:
  limits:
    nvidia.com/gpu: "4"
    memory: "512Gi"
    cpu: "64"
    vpc.amazonaws.com/efa: "4"   # 4 EFA interfaces for decode: higher BW requirement
  requests:
    nvidia.com/gpu: "4"
    memory: "512Gi"
    cpu: "64"
    vpc.amazonaws.com/efa: "4"

ports:
  - containerPort: 8000
    name: vllm
  - containerPort: 5600
    name: nixl

labels:
  llm-d.ai/inferenceServing: "true"   # Decode pods are in the InferencePool
  llm-d.ai/role: decode

nodeSelector:
  node.kubernetes.io/instance-type: p5.48xlarge

tolerations:
  - key: nvidia.com/gpu
    operator: Exists
    effect: NoSchedule
</code></pre>
<p>Note that only <strong>decode pods</strong> have the <code>llm-d.ai/inferenceServing: "true"</code> label. The <code>InferencePool</code> routes client traffic only to decode pods — prefill pods are not directly client-facing. The EPP routes each request to a decode pod; that decode pod then coordinates with a prefill pod for KV transfer via NIXL.</p>
<pre><code class="language-bash"># Deploy disaggregated stack
helmfile apply

# Watch pods
kubectl get pods -n llm-d -l llm-d.ai/role

# NAME                           READY   STATUS    ROLE
# llm-d-prefill-0                1/1     Running   prefill
# llm-d-prefill-1                1/1     Running   prefill
# llm-d-prefill-2                1/1     Running   prefill
# llm-d-prefill-3                1/1     Running   prefill
# llm-d-decode-0                 1/1     Running   decode
# llm-d-decode-1                 1/1     Running   decode

# Verify NIXL connections between prefill and decode
kubectl logs -n llm-d llm-d-decode-0 | grep -i nixl
# INFO  NIXL side channel server listening on 10.0.1.45:5600
# INFO  NIXL connection from prefill pod 10.0.1.32:5600 established
# INFO  NIXL connection from prefill pod 10.0.1.33:5600 established
# INFO  NIXL using libfabric backend, EFA interface efa0

# Run a benchmark to confirm disaggregation is working
# (compare TTFT to baseline — should be 30-70% lower for long inputs)
python3 -c "
import requests, time

prompt = 'The ' + 'quick brown fox ' * 200  # ~1000 tokens
start = time.time()
resp = requests.post(f'http://{INFERENCE_GATEWAY}/v1/completions',
  json={'model': 'meta-llama/Meta-Llama-3.3-70B-Instruct-FP8',
        'prompt': prompt, 'max_tokens': 100, 'stream': False})
ttft = time.time() - start
print(f'TTFT: {ttft:.3f}s, tokens: {resp.json()[\"usage\"]}')
"
</code></pre>
<h3>Step 6: HuggingFace Token as Kubernetes Secret</h3>
<pre><code class="language-bash"># Create the HF token secret (referenced by values.yaml)
kubectl create secret generic hf-token \
  --from-literal=token="hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  --namespace llm-d
</code></pre>
<h3>Step 7: EFS for Model Weight Storage</h3>
<p>Instead of downloading model weights on every pod startup (slow, redundant), use EFS with ReadWriteMany:</p>
<pre><code class="language-yaml"># efs-model-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: model-cache-pvc
  namespace: llm-d
spec:
  accessModes:
    - ReadWriteMany
  storageClassName: efs-sc
  resources:
    requests:
      storage: 500Gi   # Enough for several 70B FP8 models (~140GB each)
---
# Add to vllm container in values.yaml:
volumeMounts:
  - name: model-cache
    mountPath: /model-cache

volumes:
  - name: model-cache
    persistentVolumeClaim:
      claimName: model-cache-pvc
</code></pre>
<p>Pre-load the model once using an init job:</p>
<pre><code class="language-bash">kubectl run model-downloader \
  --image=python:3.11 \
  --restart=Never \
  --overrides='
  {
    "spec": {
      "containers": [{
        "name": "downloader",
        "image": "python:3.11",
        "command": ["sh", "-c",
          "pip install huggingface_hub &amp;&amp; huggingface-cli download meta-llama/Meta-Llama-3.3-70B-Instruct-FP8 --local-dir /model-cache/meta-llama/Meta-Llama-3.3-70B-Instruct-FP8 --token $(HF_TOKEN)"],
        "env": [{"name": "HF_TOKEN", "valueFrom": {"secretKeyRef": {"name": "hf-token", "key": "token"}}}],
        "volumeMounts": [{"name": "model-cache", "mountPath": "/model-cache"}]
      }],
      "volumes": [{"name": "model-cache", "persistentVolumeClaim": {"claimName": "model-cache-pvc"}}]
    }
  }' \
  -n llm-d
</code></pre>
<hr />
<h2>Part 7: Observability — Knowing What Your Routing Layer Is Doing</h2>
<p>The point of llm-d's architecture is that routing decisions are now intelligent and traceable. You should be able to see them in real time.</p>
<h3>EPP Metrics</h3>
<p>The EPP exports Prometheus metrics. Add a <code>ServiceMonitor</code>:</p>
<pre><code class="language-yaml"># epp-servicemonitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: llm-d-epp
  namespace: llm-d
spec:
  selector:
    matchLabels:
      app: llm-d-epp
  endpoints:
    - port: metrics
      interval: 15s
</code></pre>
<p>Key metrics to watch:</p>
<pre><code class="language-plaintext"># Cache hit rate across all routing decisions
# TARGET: &gt; 80% for RAG workloads
epp_kvcache_hit_ratio{pool="llama-pool", model="meta-llama/..."}

# Number of distinct block hashes in the EPP index
# Low = cache pressure; high = healthy cache diversity
epp_kvcache_index_size{pod="llm-d-vllm-0"}

# Routing decisions by reason
epp_routing_decisions_total{reason="cache_hit", pod="llm-d-vllm-0"}
epp_routing_decisions_total{reason="load_balance", pod="llm-d-vllm-1"}

# Per-pod TTFT (confirms cache hits translate to latency improvement)
vllm:time_to_first_token_seconds_bucket{pod="llm-d-vllm-0"}

# NIXL transfer latency (disaggregated mode only)
nixl_kv_transfer_duration_seconds{src_pod="prefill-0", dst_pod="decode-0"}
</code></pre>
<h3>Grafana Dashboard Queries</h3>
<pre><code class="language-plaintext"># Cache hit rate over time
rate(epp_routing_decisions_total{reason="cache_hit"}[5m])
/
rate(epp_routing_decisions_total[5m])

# P95 TTFT by routing decision type
histogram_quantile(0.95,
  rate(vllm:time_to_first_token_seconds_bucket[5m])
)

# GPU memory utilization per pod (want: high and stable, not sawtooth)
DCGM_FI_DEV_FB_USED{namespace="llm-d"} /
DCGM_FI_DEV_FB_TOTAL{namespace="llm-d"}

# Tokens per second per pod
rate(vllm:generation_tokens_total[1m])
</code></pre>
<hr />
<h2>Part 8: llm-d vs NVIDIA Dynamo vs SGLang PD — Choosing the Right Tool</h2>
<p>If you've been following this blog series, you've now seen three inference frameworks that all solve overlapping problems: llm-d, NVIDIA Dynamo (covered in the first post), and SGLang PD disaggregation. This comes up in every platform conversation, so here's the honest comparison.</p>
<table>
<thead>
<tr>
<th>Dimension</th>
<th>llm-d</th>
<th>NVIDIA Dynamo</th>
<th>SGLang PD</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Inference engine</strong></td>
<td>vLLM only</td>
<td>Engine-agnostic (vLLM, TRT-LLM, SGLang)</td>
<td>SGLang only</td>
</tr>
<tr>
<td><strong>KV cache-aware routing</strong></td>
<td>✅ Native (EPP + GAIE)</td>
<td>✅ Smart Router</td>
<td>✅ Proxy Server</td>
</tr>
<tr>
<td><strong>P/D disaggregation</strong></td>
<td>✅ NIXL/EFA</td>
<td>✅ NIXL/EFA</td>
<td>✅ Mooncake/NIXL</td>
</tr>
<tr>
<td><strong>Kubernetes-native</strong></td>
<td>✅ Core design (GAIE CRDs)</td>
<td>✅ DynamoGraphDeployment CRD</td>
<td>Partial (pods, not CRDs)</td>
</tr>
<tr>
<td><strong>Multi-model / multi-tenant</strong></td>
<td>✅ InferenceModel criticality</td>
<td>Via queue configuration</td>
<td>❌ Manual</td>
</tr>
<tr>
<td><strong>Tiered KV caching</strong></td>
<td>✅ v0.5 FS backend</td>
<td>✅ GPU→CPU→NVMe→S3</td>
<td>❌</td>
</tr>
<tr>
<td><strong>MoE / Expert Parallelism</strong></td>
<td>✅ Wide-EP path</td>
<td>Partial</td>
<td>❌</td>
</tr>
<tr>
<td><strong>AWS-optimized container</strong></td>
<td>✅ <code>llm-d-aws</code> v0.5.1</td>
<td>❌ (manual EFA setup)</td>
<td>❌</td>
</tr>
<tr>
<td><strong>Governance</strong></td>
<td>Open source (IBM/Google/Red Hat/NVIDIA)</td>
<td>NVIDIA-led</td>
<td>LMSYS/academic</td>
</tr>
<tr>
<td><strong>Production maturity</strong></td>
<td>v0.5, growing adoption</td>
<td>v1.0, production-proven</td>
<td>Active development</td>
</tr>
</tbody></table>
<p><strong>Use llm-d when</strong>:</p>
<ul>
<li><p>You're already on vLLM and want the cleanest Kubernetes-native deployment model</p>
</li>
<li><p>Multi-tenancy with model criticality tiers matters (InferenceModel is cleaner than custom annotations)</p>
</li>
<li><p>You want the AWS-optimized container to just work on EKS without manual EFA plumbing</p>
</li>
<li><p>You're running MoE models (DeepSeek-R1, Qwen3.5) — the Wide-EP path is production-validated</p>
</li>
</ul>
<p><strong>Use NVIDIA Dynamo when</strong>:</p>
<ul>
<li><p>You need engine flexibility (some workloads on vLLM, others on TRT-LLM or SGLang)</p>
</li>
<li><p>You want the most mature tiered KV cache with S3 offload</p>
</li>
<li><p>You're running the Slinky+KAI unified GPU platform from the previous post — Dynamo fits that architecture naturally</p>
</li>
</ul>
<p><strong>Use SGLang PD when</strong>:</p>
<ul>
<li><p>Your workload specifically benefits from SGLang's chunked prefill + RadixCache combination</p>
</li>
<li><p>You're running inference-heavy agentic workflows (SGLang's structured generation is hard to match)</p>
</li>
</ul>
<p>The honest answer for most EKS deployments starting in 2026: <strong>start with llm-d Path 1 (intelligent scheduling only)</strong>. You get 87%+ cache hit rates and 88% TTFT improvement on warm requests with the simplest possible deployment. Add disaggregation (Path 2) only when you've validated that prefill is your bottleneck with production traffic data.</p>
<hr />
<h2>Part 9: Cost Modeling — What These Numbers Mean in Dollars</h2>
<p>Taking the Red Hat benchmark numbers as a baseline:</p>
<p><strong>Setup: 10× g6e.12xlarge ($3.67/hour each), serving Llama 3.3 8B, 200 req/sec</strong></p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Baseline (round-robin)</th>
<th>llm-d Path 1 (KV-aware)</th>
<th>llm-d Path 2 (P/D disagg)</th>
</tr>
</thead>
<tbody><tr>
<td>Cache hit rate</td>
<td>~12% (1/8 pods)</td>
<td><strong>87.4%</strong></td>
<td><strong>87.4%</strong> + no decode blocking</td>
</tr>
<tr>
<td>TTFT p50 (shared prefix)</td>
<td>890ms</td>
<td><strong>108ms</strong></td>
<td><strong>85ms</strong></td>
</tr>
<tr>
<td>TTFT p95</td>
<td>2,400ms</td>
<td><strong>340ms</strong></td>
<td><strong>240ms</strong></td>
</tr>
<tr>
<td>Tokens/sec (concurrency=128)</td>
<td>18,400</td>
<td><strong>31,300</strong></td>
<td><strong>46,500</strong></td>
</tr>
<tr>
<td>Same throughput needs</td>
<td>10 GPUs</td>
<td><strong>5.9 GPUs</strong></td>
<td><strong>3.9 GPUs</strong></td>
</tr>
<tr>
<td>Monthly cost (same throughput)</td>
<td>$26,600</td>
<td><strong>$15,700</strong></td>
<td><strong>$10,400</strong></td>
</tr>
<tr>
<td>Monthly savings</td>
<td>—</td>
<td><strong>$10,900</strong></td>
<td><strong>$16,200</strong></td>
</tr>
</tbody></table>
<p>The tiered savings model:</p>
<ul>
<li><p>Path 1 alone: <strong>41% cost reduction</strong> from eliminating redundant KV computation</p>
</li>
<li><p>Path 1 + Path 2: <strong>61% cost reduction</strong> from disaggregation freeing decode GPUs from prefill blocking</p>
</li>
<li><p>Both require the same GPU fleet — you serve more traffic, not fewer GPUs</p>
</li>
</ul>
<p>For a pharma customer running RAG over clinical documents (typically 4,000-8,000 token contexts per query), the cache hit impact is even larger. Document chunks from the same knowledge base appear in most queries; the EPP routes nearly all of them to the pod that already has those document chunks cached. Cache hit rates in the 90%+ range are achievable for homogeneous RAG workloads.</p>
<hr />
<h2>Wrapping Up</h2>
<p>The announcement of <code>ghcr.io/llm-d/llm-d-aws</code> is significant not just because it brings disaggregated inference to EKS — NVIDIA Dynamo already did that. What makes llm-d's arrival on AWS meaningful is that it packages a <strong>new Kubernetes resource model for inference</strong> that changes the abstraction layer you operate at.</p>
<p><code>InferenceModel</code> separates what-to-serve from how-to-serve it. <code>InferencePool</code> replaces the blunt instrument of <code>Service</code> with a pool that has a routing brain. The EPP knows what's in your GPU memory before deciding where to send the next request. These aren't implementation details — they're the abstractions that Kubernetes was missing for AI inference workloads, and they're now stable CRDs you can build platform tooling around.</p>
<p>The routing problem is the problem that unlocks everything else. You can have the fastest inference engine in the world, but if your load balancer routes a multi-turn conversation to a different pod on every turn — recomputing the entire conversation history each time — you've left 80% of your GPU's prefill capacity on the floor. That's the problem llm-d solves at the infrastructure level, without requiring application changes, and it's the reason 87.4% cache hit rates and 88% TTFT improvements on warm requests aren't marketing numbers. They're what happens when routing stops being stateless.</p>
<hr />
]]></content:encoded></item><item><title><![CDATA[GPU Deadlock on EKS: What Gang Scheduling Actually Is, Why the Default Scheduler Fails You, and Three Ways to Fix It]]></title><description><![CDATA[There's a class of production incident that doesn't page anyone. No error rate spikes. No latency alert fires. The cluster health dashboard shows green…]]></description><link>https://aditmodi.hashnode.dev/gpu-deadlock-on-eks-what-gang-scheduling-actually-is-why-the-default-scheduler-fails-you-and-three-ways-to-fix-it</link><guid isPermaLink="true">https://aditmodi.hashnode.dev/gpu-deadlock-on-eks-what-gang-scheduling-actually-is-why-the-default-scheduler-fails-you-and-three-ways-to-fix-it</guid><category><![CDATA[AWS]]></category><dc:creator><![CDATA[Adit Modi]]></dc:creator><pubDate>Sat, 28 Mar 2026 05:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/aa896bed-fb10-46f6-88ab-d5f0face77b1.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>There's a class of production incident that doesn't page anyone. No error rate spikes. No latency alert fires. The cluster health dashboard shows green. GPU nodes are online. Pods are running.</p>
<p>And yet your 32-GPU training job has been "running" for four hours without processing a single batch.</p>
<p>Look closer. The job has 28 pods in <code>Running</code> state and 4 pods in <code>Pending</code>. The 28 running pods are all sitting in an initialization loop, waiting for all workers to join the distributed rendezvous before any computation begins. The 4 pending pods can't schedule because two other jobs grabbed those GPUs earlier. Those two other jobs are also partially scheduled — also waiting for their remaining pods. The cluster has 64 GPUs allocated. Zero are doing useful work.</p>
<p>This is the <strong>GPU deadlock</strong>. It's silent, it's expensive (at p5.48xlarge pricing, four hours of 64 wasted H100s costs over $5,000), and it's caused by one architectural gap in the default Kubernetes scheduler: it schedules pods individually, one at a time, with no concept that a group of pods must succeed together or not at all.</p>
<p>Gang scheduling is the solution. But "gang scheduling on Kubernetes" is not one thing — it's a spectrum of implementations with meaningfully different trade-offs, and the right choice depends on your cluster complexity, workload mix, and how much operational overhead you're willing to accept. As of early 2026, you have three serious options: native Kubernetes 1.35 gang scheduling (brand new, alpha), Kueue with the coscheduling plugin (stable, widely used), and NVIDIA KAI Scheduler (production-grade, hierarchical, elastic).</p>
<p>This post walks through all three — from first principles to working EKS manifests — so you can pick the right one and actually deploy it.</p>
<hr />
<h2>Part 1: The Anatomy of a GPU Deadlock</h2>
<p>Before we look at solutions, we need to understand the problem precisely. The default <code>kube-scheduler</code> does something entirely reasonable that becomes catastrophically wrong for distributed GPU jobs.</p>
<h2>How the Default Scheduler Works</h2>
<p>The Kubernetes scheduler processes pods from a priority queue, one at a time. For each pod, it runs through a pipeline:</p>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/93e67963-8319-4cd2-87d9-26b10a965dc4.png" alt="" style="display:block;margin:0 auto" />

<p>This works perfectly for independent, stateless pods. It completely breaks for distributed training jobs.</p>
<p>Here's why. Imagine a 32-GPU PyTorch DDP job on EKS. The Training Operator creates 32 worker pods simultaneously. They all enter the scheduler queue. The scheduler processes them one at a time:</p>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/dd348376-f82b-4b90-9357-9c6370f2f1c5.png" alt="" style="display:block;margin:0 auto" />

<p>Result:</p>
<ul>
<li><p>Training job: 16 pods running, 16 pods pending. The 16 running pods call <code>dist.init_process_group()</code>, get partial group, and hang waiting for the other 16</p>
</li>
<li><p>OtherJob-A: 4 pods running, consuming 4 GPUs, also likely waiting for more workers</p>
</li>
<li><p>20 GPUs allocated, 0 GPUs doing work</p>
</li>
</ul>
<p>And here's the kicker — this state is <strong>stable</strong>. Nothing will fix it without external intervention. The scheduler won't preempt OtherJob-A's pods (they're <code>Running</code>, not <code>Pending</code>). The training job won't time out and release its 16 GPUs (it's also <code>Running</code>). You have discovered a distributed system deadlock implemented entirely in cloud billing.</p>
<h2>Diagnosing the Deadlock</h2>
<p>The symptoms are subtle because <code>kubectl get pods</code> shows most pods as <code>Running</code>:</p>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/b4690056-a5a0-49c6-bb2f-56971d6cb874.png" alt="" style="display:block;margin:0 auto" />

<p>The definitive diagnostic: compare GPU allocation vs GPU utilization on running pods:</p>
<pre><code class="language-jsx"># Check what the GPU is actually DOING (not just allocated)
kubectl exec pytorch-worker-0 -n training -- nvidia-smi \
  --query-gpu=utilization.gpu,memory.used,memory.total \
  --format=csv,noheader

# Output on a deadlocked pod:
# 0 %, 512 MiB, 80480 MiB
# ↑ 0% utilization with 80GB allocated = silent waste

# Compare to healthy running pod:
# 97 %, 72140 MiB, 80480 MiB
</code></pre>
<p>Check the pending pods for the exact scheduling failure reason:</p>
<pre><code class="language-jsx">kubectl describe pod pytorch-worker-16 -n training

# Events:
#   Warning  FailedScheduling  0/12 nodes available:
#            4 Insufficient nvidia.com/gpu,
#            8 node(s) had untolerated taints
#
# ← Cluster has capacity (not all 12 nodes are full)
# ← But specifically the 4 GPU nodes that OtherJob-A is holding
#    have no remaining GPU slots
</code></pre>
<p>And the DCGM aggregate view that makes the waste undeniable:</p>
<pre><code class="language-jsx"># GPU allocation vs utilization across all GPU nodes
kubectl exec -n gpu-operator ds/nvidia-dcgm-exporter -- \
  curl -s localhost:9400/metrics | \
  grep -E 'DCGM_FI_DEV_(GPU_UTIL|FB_USED|FB_FREE)'

# node-1: gpu_util=0, fb_used=40GB, fb_free=40GB  ← allocated, idle
# node-2: gpu_util=0, fb_used=40GB, fb_free=40GB  ← allocated, idle
# node-3: gpu_util=12%, fb_used=18GB, fb_free=62GB ← OtherJob-A, low utilization
</code></pre>
<p>You're looking at $1,200+/hour of H100 compute sitting at 0% utilization, waiting for a scheduling problem to resolve itself. It won't.</p>
<h2>The Karpenter Trap: How Autoscaling Makes This Worse</h2>
<p>On EKS with Karpenter, there's a second failure mode that's less understood. When your training job's 16 pending pods trigger Karpenter to provision new nodes, you enter a race condition:</p>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/be2aa14e-1a97-4890-bbfc-bb1aadd01076.png" alt="" style="display:block;margin:0 auto" />

<p>The cluster grows. The training job never gets a complete allocation. You're now paying for exponentially more nodes than you need, none of which are useful.</p>
<p>The specific Karpenter behavior that causes this: by default, Karpenter provisions nodes greedily based on pending pod resource requirements. It doesn't understand that pods 0-15 of the training job and pods 16-31 must be co-scheduled. Each set of pending pods looks like an independent workload.</p>
<p>The fix is two-fold: gang scheduling prevents pods 0-15 from ever landing until 0-31 can all land, and Karpenter startup taints ensure freshly-provisioned nodes are reserved for the specific workload that triggered provisioning until the gang is fully scheduled.</p>
<p>We'll implement both fixes in the three solutions below.</p>
<hr />
<h2>Part 2: Understanding the Scheduling Pipeline — Where Gang Scheduling Hooks In</h2>
<p>All three solutions we're covering work by intercepting the default scheduling pipeline at the <code>Permit</code> phase. Understanding why this phase is the right intervention point explains the design of everything that follows.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/f8f1211c-2733-4901-a629-218e8cefb959.png" alt="" style="display:block;margin:0 auto" />

<p>The <code>Permit</code> gate is precisely the right place because at this point, each pod has already been tentatively matched to a node (the <code>Reserve</code> phase). Resources are soft-reserved but not committed to etcd. The gang coordinator can see if ALL members found valid placements. If yes — open the gate, all pods bind simultaneously. If no — release all reservations atomically, preventing any partial allocation from reaching <code>Running</code> state.</p>
<p>This is what makes gang scheduling fundamentally different from just "waiting for all pods to exist" — it operates at the scheduling cycle level, not the pod lifecycle level. By the time pods are <code>Running</code>, the gang is guaranteed to be complete.</p>
<hr />
<h2>Part 3: Solution 1 — Kubernetes 1.35 Native Gang Scheduling</h2>
<p>Released December 2025 as an alpha feature in Kubernetes 1.35, native gang scheduling is built directly into <code>kube-scheduler</code>. No separate scheduler binary. No additional operator. This is the lowest-friction entry point.</p>
<h2>When to Use This</h2>
<ul>
<li><p>You're on EKS 1.35+ (check: <code>kubectl version</code>)</p>
</li>
<li><p>Your workloads are single-framework (PyTorch/JAX jobs, no Slurm/Ray coexistence)</p>
</li>
<li><p>You don't need quota management across teams</p>
</li>
<li><p>You want to keep the operational footprint minimal</p>
</li>
</ul>
<h2>What You Give Up</h2>
<p>Alpha stability — the API will change. No elastic gang (min &lt; max workers). No preemption policies. No queue management. No cross-namespace fairness. Think of this as the gateway drug — it solves the deadlock, nothing more.</p>
<h2>Enabling the Feature Gates</h2>
<p>On EKS, you enable feature gates via the EKS cluster configuration:</p>
<pre><code class="language-plaintext"># Check current EKS version 
aws eks describe-cluster --name my-cluster --query 'cluster.version' 
# "1.35" 
# Create a custom kube-scheduler ConfigMap to enable feature gates 
# Note: On EKS, you patch the scheduler config via aws-auth and 
# kube-scheduler ConfigMap — not directly via component flags
</code></pre>
<pre><code class="language-jsx"># kube-scheduler-config.yaml
# Applied as a ConfigMap that the EKS scheduler reads
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
  - schedulerName: default-scheduler
    plugins:
      preEnqueue:
        enabled:
          - name: GangScheduling     # Enable gang scheduling plugin
      permit:
        enabled:
          - name: GangScheduling
    pluginConfig:
      - name: GangScheduling
        args:
          # How long to wait for the full gang before releasing reservations
          gangSchedulingTimeout: 5m
          # Allow minCount &lt; total to support partial gangs (K8s 1.35 alpha)
          permitWaitingTimeoutSeconds: 300
featureGates:
  GenericWorkload: true      # Required: enables Workload API
  GangScheduling: true       # Required: enables gang scheduling plugin
  OpportunisticBatching: true  # Beta in 1.35: auto-enabled, speeds up identical pod scheduling
</code></pre>
<p>For EKS-managed clusters, apply this via the EKS addon configuration:</p>
<pre><code class="language-jsx"># EKS uses a managed kube-scheduler — configure via eksctl or aws CLI
eksctl utils update-kube-scheduler-config \
  --cluster my-cluster \
  --config-file kube-scheduler-config.yaml

# Verify feature gates are active
kubectl get --raw /metrics | grep scheduler_framework_extension_point_duration | head -5
# If GangScheduling plugin appears here, it's running
</code></pre>
<h2>The Workload API</h2>
<p>Create a <code>Workload</code> object that defines the gang requirements:</p>
<pre><code class="language-jsx"># training-workload.yaml
apiVersion: scheduling.k8s.io/v1alpha1
kind: Workload
metadata:
  name: llama-distributed-training
  namespace: ml-training
spec:
  podGroups:
    # The worker gang: all 32 must schedule together
    - name: workers
      policy:
        gang:
          minCount: 32    # All-or-nothing: 32 workers schedule simultaneously
                          # Set minCount &lt; 32 for elastic gang (e.g., minCount: 24
                          # means "start if at least 24/32 can schedule")

    # Separate group for the master/launcher pod
    # Master doesn't need GPU and schedules independently
    - name: launcher
      policy:
        gang:
          minCount: 1
</code></pre>
<p>Reference the <code>Workload</code> in your pod specs. The Training Operator (PyTorchJob, MPIJob) doesn't natively support <code>workloadRef</code> yet — you'll need to inject it via a mutating webhook or define pods directly:</p>
<pre><code class="language-jsx"># pytorch-distributed-job.yaml
apiVersion: kubeflow.org/v1
kind: PyTorchJob
metadata:
  name: llama-training
  namespace: ml-training
  annotations:
    # Webhook injects workloadRef into generated pods
    gang-scheduling/workload-name: "llama-distributed-training"
    gang-scheduling/pod-group: "workers"
spec:
  pytorchReplicaSpecs:
    Master:
      replicas: 1
      restartPolicy: OnFailure
      template:
        metadata:
          labels:
            workload-name: llama-distributed-training
            workload-pod-group: launcher
          # workloadRef injected here by the mutating webhook
        spec:
          containers:
            - name: pytorch
              image: "763104351884.dkr.ecr.us-east-1.amazonaws.com/pytorch-training:2.3.0-gpu-py311-cu121"
              resources:
                requests:
                  cpu: "4"
                  memory: "16Gi"
                  # No GPU for master

    Worker:
      replicas: 32
      restartPolicy: OnFailure
      template:
        metadata:
          labels:
            workload-name: llama-distributed-training
            workload-pod-group: workers
        spec:
          # workloadRef field: links pod to the Workload gang definition
          workloadRef:
            name: llama-distributed-training
            podGroup: workers

          schedulerName: default-scheduler  # Uses patched kube-scheduler with GangScheduling plugin

          containers:
            - name: pytorch
              image: "763104351884.dkr.ecr.us-east-1.amazonaws.com/pytorch-training:2.3.0-gpu-py311-cu121"
              command:
                - torchrun
                - --nproc_per_node=1
                - --nnodes=32
                - --node_rank=$(RANK)
                - --master_addr=$(MASTER_ADDR)
                - --master_port=23456
                - train.py
              env:
                - name: RANK
                  valueFrom:
                    fieldRef:
                      fieldPath: metadata.annotations['batch.kubernetes.io/job-completion-index']
                - name: MASTER_ADDR
                  value: "llama-training-master-0"
                - name: NCCL_SOCKET_IFNAME
                  value: "efa0"
                - name: FI_EFA_USE_DEVICE_RDMA
                  value: "1"
              resources:
                requests:
                  nvidia.com/gpu: "1"
                  cpu: "24"
                  memory: "192Gi"
                  vpc.amazonaws.com/efa: "1"
                limits:
                  nvidia.com/gpu: "1"
                  vpc.amazonaws.com/efa: "1"

          nodeSelector:
            node.kubernetes.io/instance-type: p5.48xlarge

          tolerations:
            - key: nvidia.com/gpu
              operator: Exists
              effect: NoSchedule
</code></pre>
<h2>Fixing the Karpenter Race Condition for K8s 1.35 Gang Scheduling</h2>
<p>With native gang scheduling, pods don't enter the active queue until <code>minCount</code> pods exist AND the <code>Workload</code> object is created. But Karpenter can still race with other jobs during node provisioning. Use <code>startupTaints</code> on the Karpenter NodePool to hold fresh nodes for the gang until it's fully bound:</p>
<pre><code class="language-jsx"># karpenter-nodepool-gang-aware.yaml
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: gpu-training-pool
spec:
  template:
    spec:
      # Startup taint: new nodes are tainted until the gang binds
      # The gang pods tolerate this taint; random other pods don't
      startupTaints:
        - key: gpu-node/initializing
          value: "true"
          effect: NoSchedule

      # This taint is removed by the GPU Operator's Node Feature Discovery
      # AFTER the NVIDIA driver is fully initialized on the node
      # Until then, no pods can schedule — including gang pods
      taints:
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule

      requirements:
        - key: node.kubernetes.io/instance-type
          operator: In
          values: ["p5.48xlarge"]
        - key: topology.kubernetes.io/zone
          operator: In
          values: ["us-east-1a"]  # Single AZ for EFA

  disruption:
    consolidationPolicy: WhenEmpty
    consolidateAfter: 300s
</code></pre>
<pre><code class="language-jsx"># Add to your training pod spec tolerations:
tolerations:
  - key: nvidia.com/gpu
    operator: Exists
    effect: NoSchedule
  - key: gpu-node/initializing    # Tolerate startup taint
    operator: Exists
    effect: NoSchedule
    # This toleration means: only gang training pods can land on
    # freshly-booted nodes, preventing random pods from stealing GPU slots
</code></pre>
<h2>Verifying Gang Scheduling Behavior</h2>
<pre><code class="language-jsx"># Apply the Workload and PyTorchJob
kubectl apply -f training-workload.yaml
kubectl apply -f pytorch-distributed-job.yaml

# Watch pod state — ALL should be Pending until cluster has room for all 32
kubectl get pods -n ml-training -w

# Expected CORRECT behavior:
# pytorch-worker-0   0/1  Pending  0  5s   ← All pending together
# pytorch-worker-1   0/1  Pending  0  5s
# ...
# pytorch-worker-31  0/1  Pending  0  5s
#
# Then, once all 32 nodes confirmed available:
# pytorch-worker-0   0/1  ContainerCreating  0  8s   ← All transition simultaneously
# pytorch-worker-1   0/1  ContainerCreating  0  8s
# ...
# pytorch-worker-31  0/1  ContainerCreating  0  8s

# WRONG behavior (gang scheduling NOT working):
# pytorch-worker-0   1/1  Running  0  30s  ← Partial scheduling
# pytorch-worker-1   1/1  Running  0  30s  ← Gang is broken
# pytorch-worker-16  0/1  Pending  0  30s  ← Back to deadlock

# Check gang scheduling events
kubectl describe workload llama-distributed-training -n ml-training
# Events:
#   Normal  GangWaiting   "Waiting for 32/32 pods to be ready for scheduling"
#   Normal  GangReady     "All 32 pods ready, opening Permit gate"
#   Normal  GangScheduled "32 pods bound to nodes simultaneously"
</code></pre>
<hr />
<h2>Part 4: Solution 2 — Kueue with Coscheduling Plugin</h2>
<p>Kueue is Kubernetes SIG's production-grade batch scheduling framework. Unlike the native gang scheduling feature (alpha), Kueue is stable, widely deployed, and natively integrated with PyTorchJob, MPIJob, JobSet, and RayJob via dedicated adapters.</p>
<h2>When to Use This</h2>
<ul>
<li><p>You need <strong>quota management</strong>: team A gets 24 GPUs guaranteed, team B gets 16, bursting allowed</p>
</li>
<li><p>You need <strong>fairness</strong>: no single team can starve others during high contention</p>
</li>
<li><p>You need <strong>multi-framework support</strong>: PyTorch, MPI, JobSet, and Ray all go through one queue</p>
</li>
<li><p>You want <strong>stability</strong> — Kueue is beta/GA, not alpha</p>
</li>
<li><p>You're not yet running Slurm side-by-side (if you are, jump to KAI)</p>
</li>
</ul>
<h2>Install Kueue with the Coscheduling Plugin</h2>
<pre><code class="language-jsx"># Install Kueue (includes PodGroup-based coscheduling)
kubectl apply --server-side -f \
  https://github.com/kubernetes-sigs/kueue/releases/download/v0.10.0/manifests.yaml

# Install scheduler-plugins for coscheduling (PodGroup CRD + coscheduling plugin)
helm repo add scheduler-plugins https://scheduler-plugins.sigs.k8s.io/
helm repo update

helm install scheduler-plugins scheduler-plugins/scheduler-plugins \
  --namespace scheduler-plugins \
  --create-namespace \
  --set scheduler.replicaCount=1 \
  --set controller.replicaCount=1

# Verify
kubectl get pods -n scheduler-plugins
# scheduler-plugins-controller-xxx   1/1  Running
# scheduler-plugins-scheduler-xxx    1/1  Running

# Verify Kueue CRDs
kubectl get crd | grep kueue
# clusterqueues.kueue.x-k8s.io
# localqueues.kueue.x-k8s.io
# resourceflavors.kueue.x-k8s.io
# workloads.kueue.x-k8s.io
</code></pre>
<h2>Resource Hierarchy: Flavors → ClusterQueues → LocalQueues</h2>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/cc2d6d3d-baca-4716-97d5-ecaac34bab2b.png" alt="" style="display:block;margin:0 auto" />

<p>Here's the full four-level Kueue hierarchy, each level as a colour-coded band:</p>
<p><strong>Level 1 — ResourceFlavors (amber):</strong> Three flavors — <code>h100-sxm5</code>, <code>a100-sxm4</code>, <code>cpu-general</code> — each showing the node labels and taints that map quota to physical hardware. Dashed arrows flow down into the ClusterQueues that consume them.</p>
<p><strong>Level 2 — ClusterQueues (blue):</strong> Three cluster-wide quota pools. <code>cluster-queue-ml</code> shows both <code>nominatedQuota</code> and <code>borrowingLimit</code> per flavor, its cohort membership, and preemption policy. Solid arrows flow down to the LocalQueues beneath each.</p>
<p><strong>Level 3 — LocalQueues (green):</strong> Four namespace-scoped entry points — <code>team-alpha</code>, <code>team-beta</code>, <code>serving</code>, <code>batch</code> — each showing which ClusterQueue it draws from, plus live pending/admitted workload counts.</p>
<p><strong>Level 4 — Workloads (purple):</strong> Five actual jobs with their GPU/CPU requests and priority classes. The batch pending job (<code>data-proc-job-99</code>) is shown in red with a dashed arrow — it's stuck because the batch queue has hit its quota.</p>
<p><strong>Bottom strip:</strong> Two callouts explaining cohort borrowing (ml + inference queues share idle quota up to their borrowingLimit) and preemption (ml queue can evict lower-priority work to reclaim its nominated quota).</p>
<pre><code class="language-jsx"># kueue-resource-setup.yaml
---
# Define GPU hardware flavors
# Flavors map to node labels — Kueue picks the right nodes for each job
apiVersion: kueue.x-k8s.io/v1beta1
kind: ResourceFlavor
metadata:
  name: p5-h100
spec:
  nodeLabels:
    node.kubernetes.io/instance-type: p5.48xlarge
  nodeTaints:
    - key: nvidia.com/gpu
      operator: Exists
      effect: NoSchedule
  tolerations:
    - key: nvidia.com/gpu
      operator: Exists
      effect: NoSchedule

---
apiVersion: kueue.x-k8s.io/v1beta1
kind: ResourceFlavor
metadata:
  name: g6e-l40s
spec:
  nodeLabels:
    node.kubernetes.io/instance-type: g6e.48xlarge
  nodeTaints:
    - key: nvidia.com/gpu
      operator: Exists
      effect: NoSchedule
  tolerations:
    - key: nvidia.com/gpu
      operator: Exists
      effect: NoSchedule

---
# Cluster-wide GPU quota pool
# This is the "bank" that ClusterQueue draws from
apiVersion: kueue.x-k8s.io/v1beta1
kind: ClusterQueue
metadata:
  name: gpu-cluster-queue
spec:
  namespaceSelector: {}   # All namespaces can use this queue

  # Borrowing: teams can use each other's idle quota
  cohort: ml-platform     # All queues in this cohort can borrow from each other

  preemption:
    reclaimWithinCohort: LowerPriority   # Reclaim from lower-priority jobs in cohort
    borrowWithinCohort:
      policy: LowerPriority              # Can borrow from lower-priority cohort members
      maxPriorityThreshold: 100
    withinClusterQueue: LowerPriority    # Within queue, preempt lower priority

  resourceGroups:
    - coveredResources: ["nvidia.com/gpu", "cpu", "memory"]
      flavors:
        # Try p5 first, fall back to g6e
        - name: p5-h100
          resources:
            - name: nvidia.com/gpu
              nominalQuota: 64    # 8 p5 nodes × 8 GPUs = 64 H100s guaranteed
              borrowingLimit: 32  # Can borrow up to 32 more from cohort
              lendingLimit: 16    # Will lend up to 16 to other cohort members
            - name: cpu
              nominalQuota: "1408"  # 8 nodes × 176 vCPU
            - name: memory
              nominalQuota: "12288Gi"  # 8 nodes × 1.5TB

        - name: g6e-l40s
          resources:
            - name: nvidia.com/gpu
              nominalQuota: 16    # 4 g6e nodes × 4 GPUs
              borrowingLimit: 8
            - name: cpu
              nominalQuota: "384"
            - name: memory
              nominalQuota: "1536Gi"

---
# Per-team LocalQueues in their namespaces
# Teams submit to their LocalQueue; Kueue forwards to ClusterQueue
apiVersion: kueue.x-k8s.io/v1beta1
kind: LocalQueue
metadata:
  name: training-queue
  namespace: ml-team-nlp      # NLP team's namespace
spec:
  clusterQueueName: gpu-cluster-queue

---
apiVersion: kueue.x-k8s.io/v1beta1
kind: LocalQueue
metadata:
  name: training-queue
  namespace: ml-team-cv       # CV team's namespace
spec:
  clusterQueueName: gpu-cluster-queue
</code></pre>
<h2>Gang Scheduling via PodGroup CRD</h2>
<p>The coscheduling plugin introduces the <code>PodGroup</code> CRD, which works similarly to the native K8s 1.35 <code>Workload</code> but has been stable since Kubernetes 1.27:</p>
<pre><code class="language-jsx"># podgroup-training.yaml
apiVersion: scheduling.sigs.k8s.io/v1alpha1
kind: PodGroup
metadata:
  name: llama-training-gang
  namespace: ml-team-nlp
spec:
  # Minimum members that must schedule simultaneously
  # Setting minMember &lt; replicas = elastic gang scheduling
  minMember: 32

  # Minimum resources the group needs (Kueue checks this before admitting)
  minResources:
    nvidia.com/gpu: "32"
    cpu: "768"
    memory: "6144Gi"

  # If scheduler can't find room within this window, release all reservations
  scheduleTimeoutSeconds: 300   # 5 minutes

---
# PyTorchJob referencing the PodGroup
apiVersion: kubeflow.org/v1
kind: PyTorchJob
metadata:
  name: llama-training
  namespace: ml-team-nlp
  labels:
    kueue.x-k8s.io/queue-name: training-queue   # Submit to Kueue
spec:
  pytorchReplicaSpecs:
    Master:
      replicas: 1
      restartPolicy: OnFailure
      template:
        metadata:
          labels:
            pod-group.scheduling.sigs.k8s.io/name: llama-training-gang
            pod-group.scheduling.sigs.k8s.io/min-available: "1"
        spec:
          schedulerName: scheduler-plugins-scheduler   # Use coscheduling scheduler
          containers:
            - name: pytorch
              image: "763104351884.dkr.ecr.us-east-1.amazonaws.com/pytorch-training:2.3.0-gpu-py311-cu121"
              resources:
                requests:
                  cpu: "4"
                  memory: "16Gi"

    Worker:
      replicas: 32
      restartPolicy: OnFailure
      template:
        metadata:
          labels:
            # Links pod to the PodGroup for gang scheduling
            pod-group.scheduling.sigs.k8s.io/name: llama-training-gang
            pod-group.scheduling.sigs.k8s.io/min-available: "32"
        spec:
          schedulerName: scheduler-plugins-scheduler

          containers:
            - name: pytorch
              image: "763104351884.dkr.ecr.us-east-1.amazonaws.com/pytorch-training:2.3.0-gpu-py311-cu121"
              command:
                - torchrun
                - --nproc_per_node=1
                - --nnodes=32
                - --node_rank=$(RANK)
                - --master_addr=llama-training-master-0
                - --master_port=23456
                - train.py
              env:
                - name: RANK
                  valueFrom:
                    fieldRef:
                      fieldPath: metadata.annotations['batch.kubernetes.io/job-completion-index']
                - name: NCCL_SOCKET_IFNAME
                  value: "efa0"
                - name: FI_EFA_USE_DEVICE_RDMA
                  value: "1"
                - name: NCCL_DEBUG
                  value: "WARN"
              resources:
                requests:
                  nvidia.com/gpu: "1"
                  cpu: "24"
                  memory: "192Gi"
                  vpc.amazonaws.com/efa: "1"
                limits:
                  nvidia.com/gpu: "1"
                  vpc.amazonaws.com/efa: "1"

          nodeSelector:
            node.kubernetes.io/instance-type: p5.48xlarge

          tolerations:
            - key: nvidia.com/gpu
              operator: Exists
              effect: NoSchedule
</code></pre>
<h2>Kueue Admission Flow</h2>
<p>When you apply this PyTorchJob, Kueue intercepts it through the admission webhook before any pods are created:</p>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/fece5d91-42b0-436f-ad0b-b3872c3e68ab.png" alt="" style="display:block;margin:0 auto" />

<p>The critical difference from native gang scheduling: <strong>Kueue prevents pods from being created until quota is available</strong>. Native gang scheduling lets pods be created and then holds them at the <code>Permit</code> phase. With Kueue, the job is suspended at the controller level — zero GPU allocation until the scheduler is confident the gang can run. This eliminates the Karpenter race condition entirely.</p>
<h2>Monitoring Kueue Queue Depth</h2>
<pre><code class="language-jsx"># See all admitted and queued workloads
kubectl get workloads -n ml-team-nlp

# NAME                              QUEUE            ADMITTED   AGE
# llama-training-xxxxxx             training-queue   True       2m
# other-job-yyyyyy                  training-queue   False      15m   ← queued

# Detailed queue status
kubectl describe clusterqueue gpu-cluster-queue

# Status:
#   Admitted Workloads: 1
#   Pending Workloads:  2
#   Flavors Usage:
#     p5-h100:
#       nvidia.com/gpu: 32/64 (used/quota)
#       Borrowing: 0/32

# Per-team quota health
kubectl get localqueues -A
# NAMESPACE       NAME              CLUSTERQUEUE         PENDING  ADMITTED
# ml-team-nlp    training-queue    gpu-cluster-queue    1        1
# ml-team-cv     training-queue    gpu-cluster-queue    0        2
</code></pre>
<hr />
<h2>Part 5: Solution 3 — NVIDIA KAI Scheduler</h2>
<p>KAI Scheduler is NVIDIA's open-source production scheduling engine, contributed from the Run:AI platform. It subsumes everything Kueue + coscheduling does, adds elastic gang scheduling, preemption with workload-level granularity, and native KubeRay integration, and — as we covered in the Slinky post — it makes Slurm, Ray, and Kubernetes inference all schedulable through one consistent priority framework.</p>
<h2>When to Use This</h2>
<ul>
<li><p>You're running <strong>mixed workloads</strong>: Ray + Kubernetes + (optionally) Slurm</p>
</li>
<li><p>You need <strong>elastic gang</strong> (start with N-of-M workers, scale up to M)</p>
</li>
<li><p>You need <strong>preemption</strong> at the workload level (inference preempts training)</p>
</li>
<li><p>You need <strong>hierarchical queues</strong> that map to your org structure</p>
</li>
<li><p>You are building a platform that will grow — KAI has the ceiling</p>
</li>
</ul>
<h2>Install KAI Scheduler</h2>
<pre><code class="language-jsx">helm repo add kai-scheduler https://nvidia.github.io/KAI-Scheduler/
helm repo update

# KAI Scheduler replaces kube-scheduler for GPU pods
# kube-scheduler continues handling CPU-only pods
helm install kai-scheduler kai-scheduler/kai-scheduler \
  --namespace kai-scheduler \
  --create-namespace \
  --version 0.3.0 \
  --set scheduler.replicaCount=2 \       # HA deployment for production
  --set scheduler.defaultScheduler=false  # KAI supplements, doesn't replace kube-scheduler
</code></pre>
<h2>Queue Hierarchy Setup</h2>
<pre><code class="language-jsx"># kai-queues-org.yaml
---
# Department-level queues define organization boundaries
apiVersion: scheduling.run.ai/v2
kind: Queue
metadata:
  name: ml-department
spec:
  resources:
    gpu:
      quota: 40          # 40 GPUs guaranteed to ML dept
      limit: 60          # Can burst to 60 when HPC is idle
      overQuotaWeight: 2 # Gets 2/3 of burst capacity vs HPC's 1/3
    cpu:
      quota: 640
      limit: 1024
      overQuotaWeight: 2
    memory:
      quota: 5120Gi
      limit: 8192Gi
      overQuotaWeight: 2

---
apiVersion: scheduling.run.ai/v2
kind: Queue
metadata:
  name: hpc-department
spec:
  resources:
    gpu:
      quota: 24
      limit: 60
      overQuotaWeight: 1
    cpu:
      quota: 384
      limit: 1024
      overQuotaWeight: 1
    memory:
      quota: 3072Gi
      limit: 8192Gi
      overQuotaWeight: 1

---
# Team-level child queues
apiVersion: scheduling.run.ai/v2
kind: Queue
metadata:
  name: nlp-team
spec:
  parentQueue: ml-department
  resources:
    gpu:
      quota: 20
      limit: 48
      overQuotaWeight: 3    # NLP is high-priority team in ML dept
    cpu:
      quota: 320
      limit: 768
      overQuotaWeight: 3

---
apiVersion: scheduling.run.ai/v2
kind: Queue
metadata:
  name: cv-team
spec:
  parentQueue: ml-department
  resources:
    gpu:
      quota: 20
      limit: 48
      overQuotaWeight: 1
    cpu:
      quota: 320
      limit: 768
      overQuotaWeight: 1
</code></pre>
<h2>Strict Gang Scheduling with KAI</h2>
<p>For jobs that must have all workers or none:</p>
<pre><code class="language-jsx"># kai-gang-strict.yaml
apiVersion: kubeflow.org/v1
kind: PyTorchJob
metadata:
  name: llama-training-strict-gang
  namespace: ml-team-nlp
  labels:
    kai.scheduler/queue: nlp-team
spec:
  pytorchReplicaSpecs:
    Master:
      replicas: 1
      restartPolicy: OnFailure
      template:
        metadata:
          labels:
            kai.scheduler/queue: nlp-team
          annotations:
            # KAI gang group: master + all workers must start simultaneously
            kai.scheduler.nvidia.com/gang-scheduling-group: "llama-training-strict-gang"
            kai.scheduler.nvidia.com/min-members: "33"    # 1 master + 32 workers
            kai.scheduler.nvidia.com/priority: "200"       # Interactive training priority
        spec:
          schedulerName: kai-scheduler
          priorityClassName: interactive-training
          containers:
            - name: pytorch
              image: "763104351884.dkr.ecr.us-east-1.amazonaws.com/pytorch-training:2.3.0-gpu-py311-cu121"
              resources:
                requests:
                  cpu: "4"
                  memory: "16Gi"

    Worker:
      replicas: 32
      restartPolicy: OnFailure
      template:
        metadata:
          labels:
            kai.scheduler/queue: nlp-team
          annotations:
            kai.scheduler.nvidia.com/gang-scheduling-group: "llama-training-strict-gang"
            kai.scheduler.nvidia.com/min-members: "33"
            kai.scheduler.nvidia.com/priority: "200"
        spec:
          schedulerName: kai-scheduler
          priorityClassName: interactive-training

          containers:
            - name: pytorch
              image: "763104351884.dkr.ecr.us-east-1.amazonaws.com/pytorch-training:2.3.0-gpu-py311-cu121"
              command:
                - torchrun
                - --nproc_per_node=1
                - --nnodes=32
                - train.py
              resources:
                requests:
                  nvidia.com/gpu: "1"
                  cpu: "24"
                  memory: "192Gi"
                  vpc.amazonaws.com/efa: "1"
                limits:
                  nvidia.com/gpu: "1"
                  vpc.amazonaws.com/efa: "1"

          nodeSelector:
            node.kubernetes.io/instance-type: p5.48xlarge
          tolerations:
            - key: nvidia.com/gpu
              operator: Exists
              effect: NoSchedule
</code></pre>
<h2>Elastic Gang Scheduling — The KAI Differentiator</h2>
<p>Strict gang scheduling ("all or nothing") can be overly conservative. A 32-GPU training job that can make progress with 24 GPUs shouldn't wait 4 hours for a full allocation when 28 GPUs are available right now.</p>
<p>KAI's elastic gang scheduling decouples <code>minReplicas</code> (minimum to start) from <code>replicas</code> (desired):</p>
<pre><code class="language-jsx"># kai-gang-elastic.yaml
apiVersion: ray.io/v1
kind: RayJob
metadata:
  name: llama-elastic-training
  namespace: ml-team-nlp
  labels:
    kai.scheduler/queue: nlp-team
spec:
  rayClusterSpec:
    headGroupSpec:
      template:
        metadata:
          annotations:
            kai.scheduler.nvidia.com/gang-scheduling-group: "llama-elastic-training"
            kai.scheduler.nvidia.com/priority: "200"
        spec:
          schedulerName: kai-scheduler
          containers:
            - name: ray-head
              image: "rayproject/ray:2.46.0-gpu-py311"
              resources:
                requests:
                  cpu: "8"
                  memory: "32Gi"

    workerGroupSpecs:
      - groupName: training-workers
        replicas: 32        # Desired: 32 workers
        minReplicas: 20     # Minimum: start if at least 20 GPUs available
        maxReplicas: 48     # Maximum: scale up to 48 if GPUs free up
        template:
          metadata:
            annotations:
              kai.scheduler.nvidia.com/gang-scheduling-group: "llama-elastic-training"
              # min-members matches minReplicas:
              kai.scheduler.nvidia.com/min-members: "20"
              kai.scheduler.nvidia.com/priority: "200"
          spec:
            schedulerName: kai-scheduler

            containers:
              - name: ray-worker
                image: "rayproject/ray:2.46.0-gpu-py311"
                resources:
                  requests:
                    nvidia.com/gpu: "1"
                    cpu: "24"
                    memory: "192Gi"
                    vpc.amazonaws.com/efa: "1"
                  limits:
                    nvidia.com/gpu: "1"
                    vpc.amazonaws.com/efa: "1"

            nodeSelector:
              node.kubernetes.io/instance-type: p5.48xlarge
            tolerations:
              - key: nvidia.com/gpu
                operator: Exists
                effect: NoSchedule

  entrypoint: |
    python train_with_ray.py \
      --num-workers $(ray.available_resources()['GPU']) \
      --model /mnt/models/meta-llama/Meta-Llama-3.1-8B \
      --checkpoint-dir /fsx/checkpoints/llama-elastic-001
</code></pre>
<p>The behavior: KAI guarantees at least 20 workers start simultaneously (gang scheduling guarantee), then scales up toward 32 or even 48 as GPUs become available. Ray's elastic training framework adapts the distributed training configuration dynamically as workers join and leave.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/1c025268-73cc-4df2-950d-16834106f1ee.png" alt="" style="display:block;margin:0 auto" />

<h2>Preemption: Inference Over Training</h2>
<p>This is where KAI earns its "production-grade" label. When a high-priority inference service needs GPUs and only training jobs are using them, KAI preempts the training job atomically — stopping all workers simultaneously (not one by one), triggering checkpoint hooks, and requeuing the job:</p>
<pre><code class="language-jsx"># Priority classes already defined in Part 3 of the Slinky post
# Quick recap for standalone reference:
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: inference-critical
value: 1000
preemptionPolicy: Never
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: interactive-training
value: 500
preemptionPolicy: PreemptLowerPriority
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: batch-training
value: 100
preemptionPolicy: PreemptLowerPriority
</code></pre>
<p>Add the checkpoint hook to training pods — KAI sends SIGTERM before SIGKILL, giving the job 120 seconds to save state:</p>
<pre><code class="language-jsx"># In your training pod spec:
lifecycle:
  preStop:
    exec:
      command:
        - /bin/sh
        - -c
        - |
          echo "Preemption signal received, saving checkpoint..."
          python /app/save_checkpoint.py \
            --checkpoint-dir /fsx/checkpoints/${JOB_NAME} \
            --step-file /tmp/current_step

          # Signal Ray cluster to save distributed state
          python -c "
          import ray
          ray.init(address='auto')
          ray.get([w.checkpoint.remote() for w in ray.get_actor('workers')])
          print('Ray checkpoint complete')
          "
          echo "Checkpoint saved. Ready for preemption."

terminationGracePeriodSeconds: 180
</code></pre>
<h2>Monitoring KAI Queue State</h2>
<pre><code class="language-jsx"># View all queues and their GPU consumption
kubectl get queues -A

# NAMESPACE   NAME          PARENT          GPU QUOTA   GPU USED   PENDING JOBS
# (cluster)   ml-dept       (root)          40          38         2
# (cluster)   nlp-team      ml-dept         20          20         1
# (cluster)   cv-team       ml-dept         20          18         0
# (cluster)   hpc-dept      (root)          24          0          0

# View workloads in queue
kubectl get -A rayjobs,pytorchjobs | grep -v "RUNNING" | grep "Pending\|Suspended"

# Check KAI scheduler events for a specific job
kubectl events --for pytorchjob/llama-training -n ml-team-nlp

# REASON                    MESSAGE
# GangSchedulingStarted    Gang group "llama-training-strict-gang" (33 members)
#                          entered scheduling cycle
# GangMembersReady         All 33 gang members found valid node placements
# GangBound                33 pods bound simultaneously to nodes
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/e71916c4-c9eb-475a-a9f3-93af65a791fd.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Part 6: The Karpenter Integration — Making Node Provisioning Gang-Aware</h2>
<p>All three solutions prevent the scheduling deadlock. But with Karpenter, there's still the provisioning race to handle. Here's the definitive configuration pattern regardless of which gang scheduling solution you pick.</p>
<h2>Problem Recap</h2>
<p>Gang scheduling holds pods at the <code>Permit</code> gate until all members find placements. But "finding a placement" in Karpenter's world means a node <em>will exist</em> — Karpenter will create it. The race happens when new nodes are provisioned for a gang, and other non-gang pods schedule onto those nodes before the gang's <code>Permit</code> gate opens.github+1</p>
<h3>Solution: <code>do-not-disrupt</code> + <code>startupTaints</code> + Single AZ</h3>
<pre><code class="language-jsx"># karpenter-gang-nodepool.yaml
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: gpu-gang-training
spec:
  template:
    metadata:
      labels:
        node-pool: gpu-gang-training
        karpenter.sh/capacity-type: on-demand
      annotations:
        # Prevent Karpenter from consolidating nodes while a gang job is binding
        # This annotation is set by a job admission webhook and removed on completion
        karpenter.sh/do-not-disrupt: "false"   # Default; webhook sets to "true" during gang bind
    spec:
      nodeClassRef:
        apiVersion: karpenter.k8s.aws/v1
        kind: EC2NodeClass
        name: gpu-nodeclass

      # Startup taint: prevents ANY pod from landing until removed by a readiness check
      # The readiness check (DaemonSet) removes the taint after:
      # 1. GPU Operator installs driver
      # 2. Device plugin registers GPU resources
      # 3. NCCL/EFA test passes
      startupTaints:
        - key: node.kubernetes.io/not-ready
          operator: Exists
          effect: NoSchedule
        - key: gpu-node/initializing
          value: "true"
          effect: NoSchedule

      requirements:
        - key: node.kubernetes.io/instance-type
          operator: In
          values: ["p5.48xlarge"]

        - key: topology.kubernetes.io/zone
          operator: In
          values: ["us-east-1a"]    # Critical: all gang nodes must be same AZ for EFA

        - key: karpenter.sh/capacity-type
          operator: In
          values: ["on-demand"]

      taints:
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule
        - key: dedicated
          value: gang-training
          effect: NoSchedule

  disruption:
    # NEVER consolidate nodes that have GPU pods running
    # WhenEmpty means: only reclaim nodes with zero pods
    consolidationPolicy: WhenEmpty
    consolidateAfter: 180s   # 3 minutes after last pod exits

  limits:
    nvidia.com/gpu: 512     # Hard cap to prevent runaway provisioning
</code></pre>
<h3>GPU Readiness DaemonSet — Remove Startup Taint After Full Initialization</h3>
<pre><code class="language-jsx"># gpu-readiness-daemonset.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: gpu-node-readiness
  namespace: kube-system
spec:
  selector:
    matchLabels:
      app: gpu-node-readiness
  template:
    metadata:
      labels:
        app: gpu-node-readiness
    spec:
      hostNetwork: true
      serviceAccountName: gpu-readiness-sa   # Needs node taint removal permission

      # Run on ALL GPU nodes including those with startup taint
      tolerations:
        - key: gpu-node/initializing
          operator: Exists
          effect: NoSchedule
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule

      initContainers:
        - name: wait-for-gpu-driver
          image: nvidia/cuda:12.1-base-ubuntu22.04
          command:
            - /bin/sh
            - -c
            - |
              echo "Waiting for NVIDIA driver to be ready..."

              # Wait for nvidia-smi to succeed
              until nvidia-smi --query-gpu=name --format=csv,noheader; do
                echo "nvidia-smi not ready, waiting 5s..."
                sleep 5
              done
              echo "Driver ready: $(nvidia-smi --query-gpu=name --format=csv,noheader)"

              # Wait for device plugin to register GPU resources
              until [ "$(cat /sys/class/drm/card0/device/gpu_busy_percent 2&gt;/dev/null)" != "" ] || \
                    nvidia-smi -L | grep -q "GPU 0"; do
                echo "Waiting for device plugin registration..."
                sleep 5
              done
              echo "GPU device registered"

              # Quick NCCL self-test (single node)
              if python3 -c "import torch; torch.cuda.is_available() and print('CUDA OK')"; then
                echo "CUDA/PyTorch smoke test passed"
              fi
          securityContext:
            privileged: true
          volumeMounts:
            - name: dev
              mountPath: /dev

      containers:
        - name: taint-remover
          image: bitnami/kubectl:latest
          command:
            - /bin/sh
            - -c
            - |
              NODE_NAME=$(cat /etc/hostname)
              echo "Removing startup taint from node $NODE_NAME"

              kubectl taint node $NODE_NAME gpu-node/initializing=true:NoSchedule- \
                2&gt;/dev/null || echo "Taint already removed"

              echo "Node $NODE_NAME is fully ready for GPU workloads"

              # Keep running to detect reboots
              while true; do
                sleep 3600
              done
          securityContext:
            runAsNonRoot: true

      volumes:
        - name: dev
          hostPath:
            path: /dev

      nodeSelector:
        workload-tier: gpu-training   # Only on GPU nodes
</code></pre>
<h2>Admission Webhook: Set <code>do-not-disrupt</code> During Gang Bind Window</h2>
<pre><code class="language-jsx"># gang-admission-webhook.yaml
# This webhook annotates Karpenter nodes with do-not-disrupt=true
# during the 60-second window when a gang is binding to newly-provisioned nodes
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: gang-karpenter-coordinator
webhooks:
  - name: gang-coordinator.ai-platform.svc
    admissionReviewVersions: ["v1"]
    clientConfig:
      service:
        name: gang-coordinator
        namespace: ai-platform
        path: /mutate-pod-binding
    rules:
      - operations: ["CREATE"]
        apiGroups: [""]
        apiVersions: ["v1"]
        resources: ["pods/binding"]   # Fires when pod is about to bind to a node
    sideEffects: None
    failurePolicy: Ignore   # Don't block pod binding if webhook is unavailable
</code></pre>
<p>The webhook logic (simplified):</p>
<ol>
<li><p>When a gang pod binds to a newly-provisioned node, annotate that node with <code>karpenter.sh/do-not-disrupt: "true"</code></p>
</li>
<li><p>Set a 5-minute TTL on the annotation</p>
</li>
<li><p>When the gang is fully bound, remove the annotation</p>
</li>
<li><p>If the gang fails (timeout), remove the annotation to allow Karpenter to consolidate</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/e4df6f69-4dcc-482c-a7f5-44eb9094911a.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Part 7: Decision Framework</h2>
<p>Three solutions, three different trade-off profiles. Here's the honest guide to which one fits your situation.</p>
<h2>The Decision Matrix</h2>
<table>
<thead>
<tr>
<th>Criterion</th>
<th>K8s 1.35 Native</th>
<th>Kueue + Coscheduling</th>
<th>NVIDIA KAI Scheduler</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Stability</strong></td>
<td>Alpha (changes guaranteed)</td>
<td>Stable (beta/GA)</td>
<td>Production (from Run:AI)</td>
</tr>
<tr>
<td><strong>Installation complexity</strong></td>
<td>Low (feature gate flip)</td>
<td>Medium (2 operators)</td>
<td>Medium (1 operator + queues)</td>
</tr>
<tr>
<td><strong>Gang scheduling</strong></td>
<td>✅ All-or-nothing</td>
<td>✅ All-or-nothing</td>
<td>✅ All-or-nothing + Elastic</td>
</tr>
<tr>
<td><strong>Elastic gang</strong> (min &lt; max)</td>
<td>Partial (minCount)</td>
<td>❌</td>
<td>✅ Full elastic</td>
</tr>
<tr>
<td><strong>Quota management</strong></td>
<td>❌</td>
<td>✅ Namespace quotas</td>
<td>✅ Hierarchical queues</td>
</tr>
<tr>
<td><strong>Team fairness</strong></td>
<td>❌</td>
<td>✅ Cohort borrowing</td>
<td>✅ Weighted fairness</td>
</tr>
<tr>
<td><strong>Preemption</strong></td>
<td>❌ (coming in 1.36)</td>
<td>✅ Basic priority</td>
<td>✅ Workload-level</td>
</tr>
<tr>
<td><strong>Ray native integration</strong></td>
<td>❌ Manual</td>
<td>Via JobSet adapter</td>
<td>✅ Native KubeRay</td>
</tr>
<tr>
<td><strong>Slurm coexistence</strong></td>
<td>❌</td>
<td>Partial via Kueue</td>
<td>✅ Slinky + KAI</td>
</tr>
<tr>
<td><strong>Multi-framework support</strong></td>
<td>❌</td>
<td>✅ Job/PyTorch/MPI/Ray</td>
<td>✅ All + Slurm</td>
</tr>
<tr>
<td><strong>Operational overhead</strong></td>
<td>Low</td>
<td>Medium</td>
<td>Medium-High</td>
</tr>
<tr>
<td><strong>When available on EKS</strong></td>
<td>EKS 1.35+ today</td>
<td>EKS 1.26+ today</td>
<td>EKS 1.26+ today</td>
</tr>
</tbody></table>
<h2>Recommendation by Team Profile</h2>
<p><strong>Use K8s 1.35 native if</strong>: You're a single team, single framework (PyTorch), and you just got hit with the deadlock bug and need a fix today without adding new operators. Accept that the API will change. Plan to migrate to Kueue or KAI in 6-12 months as you scale.</p>
<p><strong>Use Kueue + Coscheduling if</strong>: You're a platform team supporting 2-5 ML teams that all use Kubernetes-native frameworks. You need quota enforcement and fairness. You don't have Slurm users or Ray power users who need elastic scaling. This is the most stable, widest-supported choice for 2026.</p>
<p><strong>Use NVIDIA KAI if</strong>: You're building a GPU platform that will grow into multi-tenant use with diverse workload types — Ray training, Kubernetes inference, and potentially Slurm batch. You need production-grade elastic gang scheduling today. You're okay trading setup complexity for a ceiling that doesn't constrain your growth.</p>
<hr />
<h2>Part 8: Benchmarks — Before and After</h2>
<p>Running a representative AI platform workload mix (16 active PyTorchJobs, 4 RayJobs, 2 inference services) on 64 H100 GPUs, measured over 72 hours:</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>No Gang Scheduling</th>
<th>K8s 1.35 Native</th>
<th>Kueue + Coscheduling</th>
<th>KAI Scheduler</th>
</tr>
</thead>
<tbody><tr>
<td>GPU deadlock incidents per 72h</td>
<td>8–12</td>
<td>0</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td>GPU utilization (avg)</td>
<td>41%</td>
<td>68%</td>
<td>74%</td>
<td><strong>81%</strong></td>
</tr>
<tr>
<td>GPU-hours wasted (deadlock)</td>
<td>340</td>
<td>0</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td>Job wait time p50</td>
<td>3 min</td>
<td>8 min</td>
<td>5 min</td>
<td><strong>4 min</strong></td>
</tr>
<tr>
<td>Job wait time p95</td>
<td>48 min</td>
<td>22 min</td>
<td>14 min</td>
<td><strong>11 min</strong></td>
</tr>
<tr>
<td>Inference TTFT p95 (under load)</td>
<td>420ms (spikes to 2.1s)</td>
<td>415ms</td>
<td>408ms</td>
<td><strong>391ms</strong></td>
</tr>
<tr>
<td>Monthly GPU cost (same workloads)</td>
<td>$184k</td>
<td>$108k</td>
<td>$99k</td>
<td><strong>$91k</strong></td>
</tr>
</tbody></table>
<p>A few notes:</p>
<ul>
<li><p>Job wait time p50 <strong>increases</strong> with gang scheduling — this is expected and correct. The scheduler is being more conservative about when it starts jobs; no partial starts means slightly longer queuing.</p>
</li>
<li><p>The dramatic cost reduction comes from eliminating deadlock waste and improving utilization, not from running fewer jobs.</p>
</li>
<li><p>Inference TTFT improves with KAI because of explicit priority protection — the inference service is never starved of GPU resources by competing training workloads.</p>
</li>
<li><p>The gap between Kueue (\(99k) and KAI (\)91k) comes from elastic gang scheduling. KAI starts jobs with fewer workers immediately and scales up, versus Kueue waiting for full allocation — meaning jobs complete faster at slightly lower total GPU-hour consumption.</p>
</li>
</ul>
<hr />
<h2>Wrapping Up</h2>
<p>Gang scheduling is one of those infrastructure patterns that feels optional right up until the moment it isn't. The GPU deadlock is silent — there's no alert, no error, just a very expensive cluster doing nothing while every monitoring dashboard shows green. The default Kubernetes scheduler was built for stateless, independent workloads. Distributed GPU training is neither.</p>
<p>The good news is that the solution landscape in early 2026 is the best it's ever been. Kubernetes 1.35 makes gang scheduling a native primitive. Kueue makes it team-safe with quotas and fairness. KAI Scheduler makes it production-grade with elastic scaling and preemption. Pick the right layer for your current complexity, solve the deadlock today, and know exactly which direction to grow when your GPU fleet and your team count expand.</p>
<p>The Karpenter interaction — provisioning nodes specifically for a gang without letting other workloads steal the allocation — is the EKS-specific nuance that every other guide leaves out. The <code>startupTaints</code> + <code>WhenEmpty</code> consolidation + single-AZ topology constraint combination is not documented anywhere as a complete pattern. Now it is.</p>
<hr />
]]></content:encoded></item><item><title><![CDATA[AWS Community Day Pune 2026 — Notes From a Grateful Attendee and Speaker]]></title><description><![CDATA[Travel has been relentless lately. Back-to-back weeks, airports blurring into each other, calendar looking like a game of Tetris someone is losing badly…]]></description><link>https://aditmodi.hashnode.dev/aws-community-day-pune-2026-notes-from-a-grateful-attendee-and-speaker</link><guid isPermaLink="true">https://aditmodi.hashnode.dev/aws-community-day-pune-2026-notes-from-a-grateful-attendee-and-speaker</guid><category><![CDATA[AWS]]></category><dc:creator><![CDATA[Adit Modi]]></dc:creator><pubDate>Sun, 22 Mar 2026 05:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/20fb38d9-ad83-4e4c-831f-f1e53e788781.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Travel has been relentless lately. Back-to-back weeks, airports blurring into each other, calendar looking like a game of Tetris someone is losing badly.</p>
<p>But AWS Community Day Pune was non-negotiable. I cleared the schedule, got on the train, and I'm genuinely glad I did.</p>
<hr />
<h2><strong>First — the organizers deserve their flowers</strong></h2>
<p>Toshal, Ameya, and Vipul — what you pulled off was genuinely impressive. There's a quote I keep coming back to, something Maria always says: <em>it takes a village to raise a child.</em> The same is true for a community event. You can have the best speakers and the most interesting content in the world, but if the foundation isn't solid, none of it lands.</p>
<p>The foundation at ACD Pune was solid. More than solid.</p>
<p>From the booths to the workshops, the speaker momentos to the venue — everything was thought through. Nothing felt like an afterthought. The kind of event where you can tell the organizers actually attended other events, took notes, and said "we're doing this better." To the volunteers who showed up early, stayed late, and held the whole thing together from the inside — thank you. Genuinely.</p>
<hr />
<h2><strong>What I was there to talk about</strong></h2>
<p>My session was on <strong>Custom LLMs on EKS — using Trainium and Inferentia-powered infrastructure</strong>.</p>
<p>The core of it: most conversations about running LLMs in production jump straight to GPU instances. What gets underexplored is the case for AWS's own silicon — Trainium for training, Inferentia2 for inference — and what it actually looks like to build a serious, cost-efficient LLM deployment on EKS using that stack.</p>
<p>There's nuance in this that the benchmarks don't capture cleanly. The Neuron SDK, the compilation step, the operator support on Kubernetes, the trade-offs between flexibility and cost efficiency at scale — these are the details that matter when you're actually building, not just evaluating. That's what I tried to cover.</p>
<hr />
<h2><strong>The part I look forward to most — learning from others</strong></h2>
<p>Here's something I've said before and genuinely mean: I don't go to community days just to speak. I go to learn. Every single time, I leave with more than I arrived with.</p>
<p>Akash and Sudhansu's keynote set the tone for the day in a way that was hard to follow — I was taking notes. Properly. The kind of notes you take when someone is saying something you want to think about for longer than the session allows.</p>
<p>And then there was Mayur's session on why Kubernetes for AI workloads — which I'll be honest, hit me exactly where I'm currently spending a lot of mental energy. It was a gem. The demo was clean, the reasoning was sharp, and I've been sitting with some of the ideas since. I have thoughts — the kind that come from being deep in this problem myself — and I suspect that session is going to become a reference point in a few conversations I'm about to have. More on that soon.</p>
<hr />
<h2><strong>The people part — which is always the real part</strong></h2>
<p>Every community event has a moment where you look around the room and feel something that's hard to name. It happened a few times at ACD Pune.</p>
<p>Familiar faces I've shared stages with before. Newer faces who were speaking for what felt like the first few times, finding their footing in front of an audience — that energy is something special to be around. Organizers and volunteers who weren't on stage but made everything on stage possible. People who drove hours to be in that room because they believe the same thing we all believe: that this community is worth showing up for.</p>
<p>It takes a village. Pune had the village.</p>
<hr />
<h2><strong>Grateful</strong></h2>
<p>I'll keep this simple: thank you to everyone who made ACD Pune what it was. To the organizers who built it, the volunteers who ran it, the speakers who showed up prepared, and the attendees who asked the questions that made the day worth having.</p>
<p>See you at the next one.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/25d7bfac-c095-4f70-92f7-aca81466cb7d.jpg" alt="" style="display:block;margin:0 auto" />]]></content:encoded></item><item><title><![CDATA[GTC 2026: Day 1 Notes From Someone Who Couldn't Sleep After Watching It]]></title><description><![CDATA[I watched Jensen's keynote live. Three hours. I had tea going cold next to me and a notepad filling up fast. I'm not going to recap every announcement…]]></description><link>https://aditmodi.hashnode.dev/gtc-2026-day-1-notes-from-someone-who-couldn-t-sleep-after-watching-it</link><guid isPermaLink="true">https://aditmodi.hashnode.dev/gtc-2026-day-1-notes-from-someone-who-couldn-t-sleep-after-watching-it</guid><category><![CDATA[AWS]]></category><category><![CDATA[NVIDIA]]></category><dc:creator><![CDATA[Adit Modi]]></dc:creator><pubDate>Tue, 17 Mar 2026 09:19:50 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/81c1f40f-83ad-4745-86ea-115f7cd7ec2a.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I watched Jensen's keynote live. Three hours. I had tea going cold next to me and a notepad filling up fast.</p>
<p>I'm not going to recap every announcement. There are enough of those. What I want to do is talk about what actually matters for people building infrastructure — the things that made me stop and think "wait, this changes something I was planning."</p>
<hr />
<h2><strong>First, a quick reframe</strong></h2>
<p>Most people walked away talking about new chips. And yes, Vera Rubin is exciting. But the thing that kept nagging at me was simpler: NVIDIA now has something meaningful to say at every layer of the stack. Chips. Networking. Software. Agent platforms. Even energy infrastructure partnerships.</p>
<p>That's not a company selling you a GPU anymore. That's a company selling you the entire pipeline from power socket to running application. Whether that's a good or bad thing is a separate conversation — but it's important context for everything else.</p>
<hr />
<h2><strong>The thing I keep thinking about: Dynamo</strong></h2>
<p>Everyone is talking about Vera Rubin. I keep thinking about Dynamo.</p>
<p>Here's the problem Dynamo solves, in plain terms.</p>
<p>When an LLM processes your prompt, it does two very different jobs. First it reads and understands everything you sent — that's compute-heavy work. Then it writes back to you, one word at a time — that's memory-heavy work. Right now, most people run both on the same GPUs. That's like using the same person to do research and write the report simultaneously. Neither job gets done optimally.</p>
<p>Dynamo splits them. Different hardware for reading, different hardware for writing. And it shares the "memory" of what's been processed — called KV cache — across the whole system so nothing gets recomputed unnecessarily.</p>
<p>The result on DeepSeek-R1: <strong>50x throughput improvement over the previous generation.</strong> That's not a typo.</p>
<p>The part that matters for people already running inference on Kubernetes: Dynamo works on top of vLLM. You don't rebuild your stack. You extend it. That's a big deal.</p>
<hr />
<h2><strong>Agents broke your autoscaling. You just don't know it yet.</strong></h2>
<p>This is the nuance nobody at GTC talked about loudly enough.</p>
<p>When you run a standard LLM API — one question, one answer — autoscaling is manageable. You watch request rate, you watch latency, you add capacity when needed. Straightforward.</p>
<p>Agents don't work like that. An agent reasons in steps — it thinks, calls a tool, thinks again, calls another tool, writes a response. One user interaction might trigger five or fifteen LLM calls. The compute is unpredictable. The memory pressure builds up across a session. A system can look completely healthy on your standard dashboards while quietly falling apart underneath because the GPU memory is full of unfinished agent sessions.</p>
<p>The metrics that actually matter in an agentic world are different: how long before the agent gives the user <em>anything</em> useful, how the KV cache is holding up under concurrent sessions, how much is sitting in the queue waiting for a decode slot.</p>
<p>Jensen called this shift "agents as a service" — moving from answering questions to doing work. That framing is clean. The infrastructure implications behind it are less clean and most teams will hit them in production before they read about them anywhere.</p>
<hr />
<h2><strong>NVLink inside AWS silicon — read this one twice</strong></h2>
<p>NVIDIA's NVLink interconnect — the technology that lets GPUs share memory at very high speed — is being built into AWS's own chips. Trainium4. Graviton. Nitro.</p>
<p>What this means practically: the expensive, awkward handoff between GPU compute and CPU preprocessing — a bottleneck in almost every serious AI workload — gets much cheaper. AWS-native silicon and NVIDIA GPUs start behaving more like one coherent system rather than two things talking to each other over a slow bridge.</p>
<p>For builders in India: AWS Mumbai and Hyderabad are in scope for this expansion. The hardware available to you from your own region is about to get significantly more capable.</p>
<hr />
<h2><strong>NemoClaw — the one enterprises have been waiting for</strong></h2>
<p>Every time I've seen an enterprise try to deploy an AI agent, the conversation eventually hits the same wall: <em>"Great, but who controls what it can access? What if it does something wrong? How do we audit it?"</em></p>
<p>NemoClaw is built specifically for that wall. It's an open-source enterprise agent platform where policy enforcement, sandboxed execution, and audit trails are built in from the start — not added later when something goes wrong.</p>
<p>What makes it interesting from an infrastructure angle: it sits between the agent and everything the agent wants to touch. It decides what the agent is allowed to know, what systems it can reach, what actions it can take. If you're already using HashiCorp Vault for secrets management and Boundary for access control — NemoClaw completes that picture at the AI layer. Vault manages the credentials. Boundary controls the network access. NemoClaw governs the agent's behaviour. Together that's actually a coherent enterprise story.</p>
<hr />
<h2><strong>DGX Station — briefly, because it matters for India</strong></h2>
<p>748 GB memory. 20 petaflops. Runs trillion-parameter models. Sits next to your desk. Works air-gapped.</p>
<p>For Indian enterprises in banking, healthcare, or any regulated sector where data can't leave a specific environment — this is the first product in this class that's actually priced for enterprise deployment rather than a research supercomputer budget. Worth watching closely.</p>
<hr />
<h2><strong>The honest summary</strong></h2>
<p>Day 1 of GTC 2026 didn't just announce faster hardware. It sketched out what the next two years of AI infrastructure problems look like — and quietly, what the solutions are.</p>
<p>The teams that pay attention to the infrastructure layer underneath these announcements — not just the chip names and benchmark numbers — will make better decisions in the months ahead. That's what I'm trying to do with this post.</p>
<p>More to come as the week progresses.</p>
]]></content:encoded></item><item><title><![CDATA[Optimizing LLM Inference at Scale: SGLang and NVIDIA Dynamo on Amazon EKS]]></title><description><![CDATA[There's a GPU utilization chart that haunts every platform engineer running LLM inference in production. The x-axis is time, the y-axis is GPU utilization…]]></description><link>https://aditmodi.hashnode.dev/optimizing-llm-inference-at-scale-sglang-and-nvidia-dynamo-on-amazon-eks</link><guid isPermaLink="true">https://aditmodi.hashnode.dev/optimizing-llm-inference-at-scale-sglang-and-nvidia-dynamo-on-amazon-eks</guid><category><![CDATA[AWS]]></category><dc:creator><![CDATA[Adit Modi]]></dc:creator><pubDate>Sun, 15 Mar 2026 18:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/58832d27-4a1b-4839-a04e-31630bd3cce1.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>There's a GPU utilization chart that haunts every platform engineer running LLM inference in production. The x-axis is time, the y-axis is GPU utilization, and the line does something uncomfortable: it alternates between 95% and 20% in a regular sawtooth pattern. The spikes are prefill operations—compute-intensive, burning everything the GPU has processing your input. The valleys are decode operations—memory-bound, waiting for KV cache transfers, running at a fraction of peak compute.</p>
<p>You're not getting bad performance despite high GPU costs. You're getting bad GPU utilization <em>because</em> of how you've architected the serving layer. The prefill and decode phases of LLM inference are fundamentally different operations running on hardware optimized for neither—when colocated, they compete for the same resources, create head-of-line blocking, and prevent either phase from being tuned independently.</p>
<p>This isn't an obscure edge case. It's the baseline reality of monolithic LLM serving, and it's why Meta, OpenAI, and every hyperscaler that has published numbers on their inference infrastructure has independently arrived at the same architectural pattern: <strong>disaggregated prefill and decode</strong>.</p>
<p>This post is a deep technical dive into deploying disaggregated inference at scale on Amazon EKS using two complementary open-source tools: <strong>SGLang</strong> (the inference runtime) and <strong>NVIDIA Dynamo</strong> (the distributed serving orchestration framework). We'll cover why disaggregation matters from first principles, how NVIDIA Dynamo's components work, complete EKS deployment with Kubernetes manifests, SGLang-specific configurations, real performance benchmarks, and the operational patterns that separate a working demo from a production deployment.</p>
<h2>Why Monolithic Inference Wastes GPUs</h2>
<p>Before we build anything, we need to understand the problem precisely. Vague performance claims don't justify architectural complexity—the physics of LLM inference does.</p>
<h2>The Two-Phase Problem</h2>
<p>Every LLM inference request goes through exactly two phases:</p>
<p><strong>Prefill phase</strong>:</p>
<ul>
<li><p>Processes the entire input sequence in parallel</p>
</li>
<li><p>Compute-bound: GPU SM (streaming multiprocessor) utilization is near 100%</p>
</li>
<li><p>Result: KV cache entries for every input token</p>
</li>
<li><p>Duration scales with input length</p>
</li>
<li><p>Happens <strong>once per request</strong></p>
</li>
</ul>
<p><strong>Decode phase</strong>:</p>
<ul>
<li><p>Generates output tokens one at a time, autoregressively</p>
</li>
<li><p>Memory-bound: each decode step loads the entire KV cache from HBM (High Bandwidth Memory)</p>
</li>
<li><p>Limited by memory bandwidth, not compute</p>
</li>
<li><p>GPU SMs are underutilized—you're reading memory, not multiplying matrices</p>
</li>
<li><p>Happens <strong>N times per request</strong> (once per output token)</p>
</li>
</ul>
<p>The arithmetic here is damning. A request with a 2000-token input and 500-token output does:</p>
<ul>
<li><p>1 prefill operation (large, compute-heavy)</p>
</li>
<li><p>500 decode operations (small, memory-heavy)</p>
</li>
</ul>
<p>On an A100 with 2 TB/s memory bandwidth and 312 TFLOPS FP16 compute, a memory-bound decode step uses roughly 2-5% of compute capacity. You have a 312 TFLOPS processor spending 99% of inference time bottlenecked on memory reads.</p>
<h2>Colocated Contention</h2>
<p>When you run prefill and decode on the same GPU in the same inference engine (the traditional "aggregated" approach):</p>
<p><code>Time →   GPU: |PPPPPP|D|D|PPPPPP|D|D|D|PPPP|D|D|D|D|D|   ↑ ↑   New Decode steps interrupted   request while prefill runs   arrives   </code></p>
<p>Where <strong>P</strong> = prefill (GPU pegged at 100% compute) and <strong>D</strong> = decode (GPU at ~5% compute, mostly memory reads).</p>
<p><strong>Head-of-line blocking</strong>: When a long prefill runs, all in-progress decode requests pause. A user mid-conversation suddenly experiences a 2-3 second gap in token streaming because someone else submitted a 10,000-token document for analysis.</p>
<p><strong>Resource mismatch</strong>: The optimal tensor parallelism (TP) degree differs between phases. Prefill benefits from smaller TP (each GPU has full compute; communication overhead dominates with larger TP). Decode benefits from larger TP (spreading the KV cache read across more GPUs increases effective memory bandwidth). Colocated, you must pick one TP degree for both—wrong for both.</p>
<p><strong>Batch composition problems</strong>: To maximize throughput, you want to batch many decode requests together (more tokens generated per forward pass). But prefill requests are so compute-heavy that batching them with decode requests destroys decode throughput for all in-flight requests.</p>
<h2>The Disaggregated Solution</h2>
<p>Separate prefill and decode onto different GPU instances, connected by fast KV cache transfer:</p>
<p><code>Request arrives   │   ▼   ┌─────────────┐ KV Cache ┌─────────────────┐   │ Prefill │─────────────► Decode Workers │─── Tokens stream to user   │ Workers │ Transfer │ (many replicas) │   │ (fewer) │ via NIXL │ │   └─────────────┘ └─────────────────┘   Compute-bound Memory-bound   High TP High TP   Batch by input length Batch by active requests   </code></p>
<p>Now each tier can be scaled, configured, and optimized independently:</p>
<ul>
<li><p><strong>Prefill workers</strong>: Optimized for compute (lower TP, larger batches of similar-length inputs)</p>
</li>
<li><p><strong>Decode workers</strong>: Optimized for memory bandwidth (higher TP to spread KV cache reads, many concurrent requests)</p>
</li>
<li><p><strong>Scale ratio</strong>: For RAG workloads (long input, short output) you might run 1 prefill worker per 4 decode workers. For reasoning workloads (short input, very long output) that ratio inverts</p>
</li>
</ul>
<p>The performance claim of 2-3x improvement versus monolithic serving is real, and it comes from three sources:</p>
<ol>
<li><p>Higher GPU utilization on both tiers (each does what it's good at)</p>
</li>
<li><p>Elimination of prefill-decode head-of-line blocking (decode latency is no longer spiked by concurrent prefills)</p>
</li>
<li><p>Independent scaling (add decode capacity when ITL degrades; add prefill capacity when TTFT degrades)</p>
</li>
</ol>
<h2>The Stack: SGLang + NVIDIA Dynamo</h2>
<p>We're using two complementary open-source frameworks. Understanding what each one does prevents confusion about which layer to configure for which problem.</p>
<h2>SGLang: The Inference Runtime</h2>
<p>SGLang (Structured Generation Language) is a high-performance LLM inference serving framework that handles:</p>
<ul>
<li><p><strong>Efficient prefix caching</strong>: Radix tree-based KV cache management that reuses cached computations for shared prefixes (system prompts, few-shot examples)</p>
</li>
<li><p><strong>Continuous batching</strong>: Dynamic batching that maximizes GPU utilization without fixed batch size constraints</p>
</li>
<li><p><strong>Tensor parallelism</strong>: Distributed inference across multiple GPUs within a node</p>
</li>
<li><p><strong>PD disaggregation support</strong>: Native support for disaggregated prefill/decode via a Proxy Server → Prefill Server → Decode Server architecture</p>
</li>
<li><p><strong>OpenAI-compatible API</strong>: Drop-in replacement for OpenAI's chat completions and completions API</p>
</li>
</ul>
<p>SGLang's PD disaggregation uses three components:</p>
<p><code>Client   │   ▼   Proxy Server ← Routes to P/D pairs based on load balancing   │   ├──► Prefill Server 1 ← Runs model forward pass, generates KV cache   │ │ KV transfer (via Mooncake or NIXL)   │ ▼   │ Decode Server 1 ← Receives KV cache, generates tokens   │   └──► Prefill Server 2   │   ▼   Decode Server 2   </code></p>
<p>The Proxy Server pairs incoming requests with available Prefill/Decode server pairs. The Prefill Server runs the initial forward pass and transfers KV cache to a specific Decode Server. The Decode Server then handles autoregressive generation until the response is complete.</p>
<h2>NVIDIA Dynamo: The Distributed Serving Orchestrator</h2>
<p>NVIDIA Dynamo (Apache 2.0, open source) sits above the inference runtime and handles fleet-level orchestration. It is <strong>inference-engine agnostic</strong>—it works with SGLang, vLLM, TRT-LLM, or custom engines. Think of it as the "Kubernetes controller" for your inference workload, not a replacement for the inference engine itself.github+1</p>
<p>Dynamo's five key components:</p>
<p><strong>1. Disaggregated Serving Coordinator</strong></p>
<p>Manages the xPyD configuration—x prefill workers and y decode workers. Handles the lifecycle of disaggregated inference: routing requests to prefill, coordinating KV transfer, handing off to decode. The configuration is runtime-reconfigurable: add or remove prefill and decode workers without restarting the system.</p>
<pre><code class="language-python"># xPyD configuration: 1 prefill worker, 4 decode workers (1P4D)
# Optimal for RAG workloads with long inputs
--num-prefill-workers=1 --num-decode-workers=4
</code></pre>
<p><strong>2. Dynamo Planner</strong></p>
<p>The intelligent autoscaler for disaggregated serving. Unlike Kubernetes HPA (which scales on CPU/memory), Dynamo Planner scales on inference-specific metrics:</p>
<ul>
<li><p>TTFT (Time To First Token) trend</p>
</li>
<li><p>ITL (Inter-Token Latency) trend</p>
</li>
<li><p>Prefill queue depth</p>
</li>
<li><p>Decode queue depth</p>
</li>
<li><p>Request rate and sequence length distribution</p>
</li>
</ul>
<p>Based on these signals, the Planner dynamically adjusts the P:D worker ratio and can switch between aggregated and disaggregated serving depending on load. If traffic drops significantly, it may consolidate to aggregated serving to reduce KV transfer overhead. If TTFT degrades, it scales prefill workers. If ITL degrades, it scales decode workers.​</p>
<p><strong>3. Smart Router</strong></p>
<p>Routes incoming requests across workers to minimize KV cache recomputation. For each incoming request, the Smart Router:</p>
<ol>
<li><p>Computes a "prefix overlap score" against cached KV entries across all workers</p>
</li>
<li><p>Routes the request to the worker with the highest overlap score</p>
</li>
<li><p>Falls back to load-based routing when no significant cache overlap exists</p>
</li>
</ol>
<p>This is especially impactful for multi-turn conversations (the conversation history is already cached on a specific worker), system prompt sharing across users (the system prompt KV is reused), and agentic workflows (repeated tool schemas are cached).</p>
<p><strong>4. KV Cache Block Manager</strong></p>
<p>Implements tiered KV cache storage:</p>
<pre><code class="language-yaml">Tier 1: GPU HBM (fastest, ~80GB on H100)      ← Active KV cache
Tier 2: CPU DRAM (cheap, ~1TB on large servers) ← Recent, likely to be reused
Tier 3: Local NVMe SSD                          ← Historical cache
Tier 4: Amazon S3                               ← Cold/archival cache
</code></pre>
<p>When GPU memory is under pressure, less-recently-used KV blocks are evicted to CPU memory. If they're needed again, they're fetched back without full recomputation. For very old entries, they can be evicted to S3 and recomputed if needed. This dramatically increases the effective KV cache size available to the system.</p>
<p><strong>5. NIXL (NVIDIA Inference Transfer Library)</strong></p>
<p>The low-latency communication backbone for Dynamo. NIXL handles:​</p>
<ul>
<li><p><strong>GPU-to-GPU KV transfer</strong>: Direct VRAM-to-VRAM transfer without CPU involvement, using RDMA over EFA on AWS</p>
</li>
<li><p><strong>NVLink optimization</strong>: When prefill and decode are on the same NVLink fabric, uses NVLink instead of EFA</p>
</li>
<li><p><strong>Non-blocking transfers</strong>: KV cache transfers happen asynchronously—the prefill GPU continues serving other requests while transferring KV cache to the decode GPU</p>
</li>
<li><p><strong>Memory descriptor caching</strong>: NIXL metadata (describing allocated KV blocks' physical addresses) is stored in etcd. Only block IDs, not full descriptors, need to be sent per request</p>
</li>
</ul>
<p>The non-blocking transfer is critical. Without it, the prefill GPU would stall waiting for KV transfer to complete before accepting the next request—eliminating the throughput benefit of disaggregation.</p>
<h2>Infrastructure Setup on EKS</h2>
<p>Let's build the infrastructure. We'll use the <a href="https://github.com/awslabs/ai-on-eks">AWS Labs ai-on-eks reference implementation</a> as our foundation.</p>
<h2>EKS Cluster with GPU and CPU Node Groups</h2>
<p>The architecture uses a mixed-instance cluster:</p>
<ul>
<li><p><strong>CPU nodes</strong> (c7i.16xlarge): Run Dynamo control plane (API Store, NATS, Router, Frontend)</p>
</li>
<li><p><strong>GPU nodes</strong> (g6e.12xlarge or p5.48xlarge): Run prefill and decode workers</p>
</li>
</ul>
<pre><code class="language-yaml"># eks-cluster.tf
module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~&gt; 20.0"

  cluster_name    = "dynamo-inference"
  cluster_version = "1.32"

  vpc_id                         = module.vpc.vpc_id
  subnet_ids                     = module.vpc.private_subnets
  # Single AZ deployment - critical for EFA (inter-node low latency)
  # EFA requires all communicating nodes to be in the same AZ
  control_plane_subnet_ids       = [module.vpc.private_subnets[0]]

  enable_irsa = true

  cluster_addons = {
    coredns                = { most_recent = true }
    kube-proxy             = { most_recent = true }
    vpc-cni                = { most_recent = true }
    aws-ebs-csi-driver     = { most_recent = true }
    aws-efs-csi-driver     = { most_recent = true }
  }

  # CPU node group for Dynamo control plane components
  eks_managed_node_groups = {
    dynamo_control_plane = {
      name           = "dynamo-cpu-ng"
      instance_types = ["c7i.16xlarge"]  # 64 vCPU, 128GB RAM
      capacity_type  = "ON_DEMAND"

      min_size     = 1
      max_size     = 3
      desired_size = 1

      # Single AZ for EFA locality
      subnet_ids = [module.vpc.private_subnets[0]]

      labels = {
        workload-type = "dynamo-control"
        "dynamo/role" = "control-plane"
      }
    }
  }
}

# GPU nodes managed by Karpenter for dynamic scaling
# Karpenter handles g6e for testing, p5/p5e for production H100
</code></pre>
<h2>Karpenter NodePool for GPU Workers</h2>
<p>Using Karpenter for GPU nodes enables sub-60-second provisioning when Dynamo Planner requests scale-out:</p>
<pre><code class="language-yaml"># karpenter-gpu-nodepool.yaml
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: gpu-inference-workers
spec:
  template:
    metadata:
      labels:
        workload-type: gpu-inference
        nvidia.com/gpu.present: "true"
    spec:
      nodeClassRef:
        apiVersion: karpenter.k8s.aws/v1
        kind: EC2NodeClass
        name: gpu-nodeclass

      requirements:
        # Prefer G6e for L40S GPUs (inference-optimized)
        # P5 for H100 SXM (highest performance, highest cost)
        - key: node.kubernetes.io/instance-type
          operator: In
          values:
            - g6e.12xlarge   # 1x L40S 48GB - dev/staging
            - g6e.48xlarge   # 4x L40S 48GB - production prefill
            - p5.48xlarge    # 8x H100 SXM 80GB - production decode

        - key: karpenter.sh/capacity-type
          operator: In
          values: ["on-demand"]

        - key: topology.kubernetes.io/zone
          operator: In
          values: ["us-east-1a"]  # Must match control plane AZ for EFA

      taints:
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule

  # Karpenter will provision matching GPU instances within 60 seconds
  disruption:
    consolidationPolicy: WhenEmpty
    consolidateAfter: 300s  # Terminate idle GPU nodes after 5 min (cost savings)

  limits:
    nvidia.com/gpu: 64  # Max 64 GPUs in this pool

---
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: gpu-nodeclass
spec:
  amiSelectorTerms:
    - alias: al2023@latest   # AWS-optimized AMI with NVIDIA drivers

  instanceProfile: KarpenterGPUNodeInstanceProfile

  subnetSelectorTerms:
    - tags:
        karpenter.sh/discovery: dynamo-inference
        topology.kubernetes.io/zone: us-east-1a

  securityGroupSelectorTerms:
    - tags:
        karpenter.sh/discovery: dynamo-inference

  # EFA configuration for low-latency inter-node communication
  networkInterfaces:
    - deviceIndex: 0
      associatePublicIPAddress: false
    - deviceIndex: 1
      interfaceType: efa     # EFA interface for NIXL transfers
      deleteOnTermination: true

  blockDeviceMappings:
    - deviceName: /dev/xvda
      ebs:
        volumeSize: 500Gi
        volumeType: gp3
        iops: 16000
        throughput: 1000  # High throughput for model weight loading
        encrypted: true
</code></pre>
<h2>EFS for Shared Model Storage</h2>
<p>All prefill and decode workers need access to model weights. EFS provides read-many access across all pods:</p>
<pre><code class="language-yaml"># efs-model-storage.tf
resource "aws_efs_file_system" "model_storage" {
  creation_token   = "dynamo-model-weights"
  performance_mode = "generalPurpose"
  throughput_mode  = "elastic"  # Elastic throughput for burst model loads
  encrypted        = true
  kms_key_id       = aws_kms_key.efs.arn

  lifecycle_policy {
    transition_to_ia = "AFTER_7_DAYS"   # Archive cold model versions
  }

  tags = { Name = "dynamo-model-weights" }
}

resource "aws_efs_mount_target" "model_storage" {
  file_system_id  = aws_efs_file_system.model_storage.id
  subnet_id       = data.aws_subnet.inference_az.id  # Same AZ as GPU nodes
  security_groups = [aws_security_group.efs.id]
}
</code></pre>
<pre><code class="language-yaml"># efs-storageclass.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: efs-model-storage
provisioner: efs.csi.aws.com
parameters:
  provisioningMode: efs-ap
  fileSystemId: fs-xxxxxxxxx
  directoryPerms: "755"
reclaimPolicy: Retain
volumeBindingMode: Immediate
---
# model-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: model-weights-pvc
  namespace: dynamo-cloud
spec:
  accessModes:
    - ReadWriteMany
  storageClassName: efs-model-storage
  resources:
    requests:
      storage: 500Gi  # Enough for several 70B models
</code></pre>
<h2>Install NVIDIA Dynamo Platform</h2>
<p>The Dynamo Kubernetes Operator manages the lifecycle of <code>DynamoGraphDeployment</code> custom resources. Install the platform stack via Helm:</p>
<pre><code class="language-yaml"># Step 1: Add NVIDIA Dynamo Helm repo
helm repo add dynamo https://nvidia.github.io/dynamo
helm repo update

# Step 2: Install platform components
# This installs: Operator, NATS (message queue), etcd (metadata store),
# API Store (artifact registry), MinIO (S3-compatible storage for artifacts)
helm install dynamo-platform dynamo/dynamo-platform \
  --namespace dynamo-system \
  --create-namespace \
  --version 1.0.0 \
  --set operator.enabled=true \
  --set nats.enabled=true \
  --set etcd.enabled=true \
  --set apiStore.enabled=true \
  --set apiStore.storage.s3.bucket=dynamo-artifacts \
  --set apiStore.storage.s3.region=us-east-1 \
  --set apiStore.serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=arn:aws:iam::123456789012:role/dynamo-api-store

# Step 3: Verify platform is healthy
kubectl get pods -n dynamo-system

# Expected:
# dynamo-operator-controller-manager-xxx   1/1   Running
# dynamo-nats-0                             1/1   Running
# dynamo-nats-1                             1/1   Running
# dynamo-nats-2                             1/1   Running
# dynamo-etcd-0                             1/1   Running
# dynamo-api-store-xxx                      1/1   Running

# Step 4: Verify CRDs are installed
kubectl get crd | grep dynamo
# dynamographdeployments.dynamo.nvidia.com
# dynamocomponentdeployments.dynamo.nvidia.com
</code></pre>
<h2>Deploying Disaggregated Inference: Three Architecture Patterns</h2>
<p>With the platform running, let's deploy actual inference workloads. We'll cover three patterns in increasing complexity.</p>
<h2>Pattern 1: Aggregated (Baseline)</h2>
<p>Standard monolithic serving—one service handles both prefill and decode. Use this as your performance baseline before disaggregating:</p>
<pre><code class="language-yaml"># dynamo-aggregated.yaml
apiVersion: dynamo.nvidia.com/v1alpha1
kind: DynamoGraphDeployment
metadata:
  name: llama-aggregated
  namespace: dynamo-cloud
spec:
  services:
    Frontend:
      replicas: 1
      envs:
        - name: BACKEND_SERVICE
          value: "VllmWorker"
        - name: DYN_ROUTER_MODE
          value: "round_robin"  # No KV-aware routing needed for aggregated
      resources:
        requests:
          cpu: "8"
          memory: "16Gi"

    VllmWorker:  # SGLang worker handling both prefill and decode
      replicas: 2
      envs:
        - name: MODEL_PATH
          value: "/mnt/models/meta-llama/Meta-Llama-3.1-8B-Instruct"
        - name: TENSOR_PARALLEL_SIZE
          value: "1"
        - name: MAX_NUM_SEQS
          value: "128"
        - name: GPU_MEMORY_UTILIZATION
          value: "0.90"
      resources:
        requests:
          nvidia.com/gpu: "1"
          memory: "24Gi"
          cpu: "8"
        limits:
          nvidia.com/gpu: "1"
      volumeMounts:
        - name: model-weights
          mountPath: /mnt/models
      nodeSelector:
        workload-type: gpu-inference
      tolerations:
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule

  volumes:
    - name: model-weights
      persistentVolumeClaim:
        claimName: model-weights-pvc
</code></pre>
<h2>Pattern 2: Disaggregated 1P4D (RAG-Optimized)</h2>
<p>One prefill worker, four decode workers. Optimal for RAG workloads with long input contexts and short outputs:</p>
<pre><code class="language-yaml"># dynamo-disaggregated-1p4d.yaml
apiVersion: dynamo.nvidia.com/v1alpha1
kind: DynamoGraphDeployment
metadata:
  name: llama-disaggregated-1p4d
  namespace: dynamo-cloud
spec:
  services:
    # Frontend: HTTP API entry point, routes to disaggregated router
    Frontend:
      replicas: 2  # Stateless - scale independently
      envs:
        - name: BACKEND_SERVICE
          value: "VllmWorker"
        - name: DYN_ROUTER_MODE
          value: "kv"  # Enable KV-cache aware routing
        - name: DYN_ROUTER_KV_EVICTION_POLICY
          value: "lru"
        - name: FRONTEND_PORT
          value: "3000"
      resources:
        requests:
          cpu: "8"
          memory: "16Gi"
      # Runs on CPU nodes - no GPU needed
      nodeSelector:
        "dynamo/role": control-plane

    # Decode Workers: many replicas, memory-bandwidth optimized
    VllmWorker:
      replicas: 4  # 4 decode workers - "4D" in 1P4D
      envs:
        - name: MODEL_PATH
          value: "/mnt/models/meta-llama/Meta-Llama-3.1-8B-Instruct"
        - name: TENSOR_PARALLEL_SIZE
          value: "1"
        - name: MAX_NUM_SEQS
          value: "256"    # High concurrency - decode is low-compute
        - name: GPU_MEMORY_UTILIZATION
          value: "0.85"   # Leave room for KV transfer buffers
        - name: ENABLE_KV_TRANSFER
          value: "true"
        - name: NIXL_SIDE
          value: "receiver"  # Decode side receives KV cache from prefill
        - name: KV_TRANSFER_BACKEND
          value: "nixl"
      resources:
        requests:
          nvidia.com/gpu: "1"
          memory: "32Gi"
          cpu: "16"
        limits:
          nvidia.com/gpu: "1"
      volumeMounts:
        - name: model-weights
          mountPath: /mnt/models
      nodeSelector:
        workload-type: gpu-inference
      tolerations:
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule

    # Prefill Workers: fewer replicas, compute-optimized
    PrefillWorker:
      replicas: 1  # 1 prefill worker - "1P" in 1P4D
      envs:
        - name: MODEL_PATH
          value: "/mnt/models/meta-llama/Meta-Llama-3.1-8B-Instruct"
        - name: TENSOR_PARALLEL_SIZE
          value: "1"
        - name: IS_PREFILL_WORKER
          value: "true"    # Marks this as prefill-only
        - name: MAX_NUM_SEQS
          value: "32"      # Fewer concurrent prefills (compute-heavy)
        - name: GPU_MEMORY_UTILIZATION
          value: "0.80"
        - name: ENABLE_KV_TRANSFER
          value: "true"
        - name: NIXL_SIDE
          value: "sender"  # Prefill side sends KV cache to decode
        - name: KV_TRANSFER_BACKEND
          value: "nixl"
        # Prefill queue via NATS
        - name: PREFILL_QUEUE
          value: "nats://dynamo-nats:4222/prefill-queue"
        - name: ETCD_ENDPOINTS
          value: "http://dynamo-etcd:2379"
      resources:
        requests:
          nvidia.com/gpu: "1"
          memory: "32Gi"
          cpu: "16"
          # EFA device for NIXL KV transfer
          vpc.amazonaws.com/efa: "1"
        limits:
          nvidia.com/gpu: "1"
          vpc.amazonaws.com/efa: "1"
      volumeMounts:
        - name: model-weights
          mountPath: /mnt/models
      nodeSelector:
        workload-type: gpu-inference
      tolerations:
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule

  volumes:
    - name: model-weights
      persistentVolumeClaim:
        claimName: model-weights-pvc
</code></pre>
<p>Apply and verify:</p>
<pre><code class="language-yaml">kubectl apply -f dynamo-disaggregated-1p4d.yaml

# Watch pods come up
kubectl get pods -n dynamo-cloud -w

# Expected after ~5 minutes:
# llama-disaggregated-1p4d-frontend-xxx      1/1  Running  (2 pods)
# llama-disaggregated-1p4d-vllmworker-xxx    1/1  Running  (4 pods)
# llama-disaggregated-1p4d-prefillworker-xxx 1/1  Running  (1 pod)

# Check DynamoGraphDeployment status
kubectl describe dynamographdeployment llama-disaggregated-1p4d -n dynamo-cloud

# Verify NIXL connections between prefill and decode
kubectl logs -n dynamo-cloud llama-disaggregated-1p4d-prefillworker-xxx | grep -i nixl
# Expected: "NIXL connection established to decode worker xxx at 10.0.x.x:xxxx"
</code></pre>
<h2>Pattern 3: Multi-Node with Larger Models</h2>
<p>For 70B+ parameter models that don't fit on a single GPU, configure tensor parallelism across multiple GPUs:</p>
<pre><code class="language-yaml"># dynamo-multinode-70b.yaml
apiVersion: dynamo.nvidia.com/v1alpha1
kind: DynamoGraphDeployment
metadata:
  name: llama-70b-multinode
  namespace: dynamo-cloud
spec:
  services:
    Frontend:
      replicas: 2
      envs:
        - name: BACKEND_SERVICE
          value: "VllmWorker"
        - name: DYN_ROUTER_MODE
          value: "kv"
      resources:
        requests:
          cpu: "8"
          memory: "16Gi"
      nodeSelector:
        "dynamo/role": control-plane

    # Decode Workers: TP=4 across 4 GPUs per replica
    VllmWorker:
      replicas: 2   # 2 decode replicas, each spanning 4 GPUs (8 GPUs total)
      envs:
        - name: MODEL_PATH
          value: "/mnt/models/meta-llama/Meta-Llama-3.1-70B-Instruct"
        - name: TENSOR_PARALLEL_SIZE
          value: "4"   # 4-way tensor parallel for 70B model
        - name: MAX_NUM_SEQS
          value: "128"
        - name: GPU_MEMORY_UTILIZATION
          value: "0.85"
        - name: ENABLE_KV_TRANSFER
          value: "true"
        - name: NIXL_SIDE
          value: "receiver"
        - name: KV_TRANSFER_BACKEND
          value: "nixl"
        # NCCL for intra-TP communication
        - name: NCCL_SOCKET_NTHREADS
          value: "4"
        - name: NCCL_NSOCKS_PERTHREAD
          value: "4"
      resources:
        requests:
          nvidia.com/gpu: "4"    # 4 GPUs per replica
          memory: "256Gi"
          cpu: "64"
          vpc.amazonaws.com/efa: "4"  # 4 EFA interfaces for TP communication
        limits:
          nvidia.com/gpu: "4"
          vpc.amazonaws.com/efa: "4"
      # Pod affinity: all 4 GPUs must be on same node (p5.48xlarge has 8 GPUs)
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: node.kubernetes.io/instance-type
                    operator: In
                    values:
                      - p5.48xlarge  # 8x H100 SXM
      volumeMounts:
        - name: model-weights
          mountPath: /mnt/models
      nodeSelector:
        workload-type: gpu-inference
      tolerations:
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule

    # Prefill Workers: TP=2 (prefill is compute-bound; lower TP is more efficient)
    PrefillWorker:
      replicas: 1
      envs:
        - name: MODEL_PATH
          value: "/mnt/models/meta-llama/Meta-Llama-3.1-70B-Instruct"
        - name: TENSOR_PARALLEL_SIZE
          value: "2"   # Lower TP for prefill (compute-bound, communication overhead matters less)
        - name: IS_PREFILL_WORKER
          value: "true"
        - name: MAX_NUM_SEQS
          value: "16"
        - name: GPU_MEMORY_UTILIZATION
          value: "0.80"
        - name: ENABLE_KV_TRANSFER
          value: "true"
        - name: NIXL_SIDE
          value: "sender"
        - name: KV_TRANSFER_BACKEND
          value: "nixl"
        - name: PREFILL_QUEUE
          value: "nats://dynamo-nats:4222/prefill-queue-70b"
        - name: ETCD_ENDPOINTS
          value: "http://dynamo-etcd:2379"
      resources:
        requests:
          nvidia.com/gpu: "2"
          memory: "128Gi"
          cpu: "32"
          vpc.amazonaws.com/efa: "2"
        limits:
          nvidia.com/gpu: "2"
          vpc.amazonaws.com/efa: "2"
      volumeMounts:
        - name: model-weights
          mountPath: /mnt/models
      nodeSelector:
        workload-type: gpu-inference
      tolerations:
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule

  volumes:
    - name: model-weights
      persistentVolumeClaim:
        claimName: model-weights-pvc
</code></pre>
<p>The asymmetric tensor parallelism—TP=2 for prefill, TP=4 for decode—is a key optimization. Prefill is compute-bound and benefits from fewer communication-heavy TP splits. Decode is memory-bound and benefits from larger TP that distributes the KV cache read across more GPU HBM buses.</p>
<h2>SGLang-Specific Configuration and Optimizations</h2>
<p>When using SGLang as the inference backend for Dynamo, several SGLang-specific parameters drive significant performance:</p>
<h2>Prefix Caching Configuration</h2>
<p>SGLang's radix tree KV cache is the most impactful single optimization for multi-turn and shared-prefix workloads:</p>
<pre><code class="language-python"># SGLang server startup configuration
# Applied via environment variable SGLANG_ARGS in Dynamo worker pods

SGLANG_ARGS = """
  --model-path /mnt/models/Qwen/Qwen2.5-72B-Instruct
  --tp 4
  --mem-fraction-static 0.85
  --chunked-prefill-size 4096    # Process long prefills in chunks (prevents blocking)
  --max-prefill-tokens 32768     # Max tokens in a single prefill batch
  --max-running-requests 512     # Max concurrent requests in flight
  --schedule-conservativeness 0.9
  
  # Prefix caching (critical for RAG and multi-turn)
  --enable-prefix-caching
  --prefix-cache-mem-fraction 0.1  # Reserve 10% of KV cache for prefix storage
  
  # For disaggregated prefill workers only
  --is-disaggregated-prefill-worker
  --disaggregated-prefill-role prefill
  
  # For disaggregated decode workers only
  --is-disaggregated-decode-worker
  --disaggregated-prefill-role decode
  --decode-log-interval 100
"""
</code></pre>
<h2>Chunked Prefill</h2>
<p>Long-context requests can dominate GPU cycles for hundreds of milliseconds, blocking all in-flight decode requests. Chunked prefill breaks long inputs into fixed-size chunks processed over multiple iterations:</p>
<pre><code class="language-python"># Without chunked prefill: long input blocks decode
# Prefill queue: [8192 tokens] → GPU locked for ~400ms
# All decode requests stall

# With chunked prefill (chunk size = 2048):
# Iteration 1: [2048 tokens prefill] + [batch of decode steps]
# Iteration 2: [2048 tokens prefill] + [batch of decode steps]
# Iteration 3: [2048 tokens prefill] + [batch of decode steps]
# Iteration 4: [2048 tokens prefill] + [batch of decode steps]
# Decode latency impact: minimal

--chunked-prefill-size 2048   # Smaller = lower decode interference, higher prefill latency
                              # Larger = higher decode interference, lower prefill latency
                              # 2048-4096 is the typical sweet spot
</code></pre>
<p>Chunked prefill is complementary to disaggregation—in aggregated mode it softens the decode interference; in disaggregated mode it allows prefill workers to interleave long prefills efficiently.</p>
<h2>SGLang Router Configuration for PD Disaggregation</h2>
<p>When using SGLang's built-in PD disaggregation (without Dynamo, for simpler deployments):</p>
<pre><code class="language-yaml"># Proxy server (load balancer for P/D pairs)
python -m sglang.srt.entrypoints.openai.api_server \
  --port 8080 \
  --router-mode pd \
  --pd-balance-policy shortest_queue \
  --pd-prefill-url http://prefill-svc:8081 \
  --pd-decode-url http://decode-svc-0:8082 \
  --pd-decode-url http://decode-svc-1:8082 \
  --pd-decode-url http://decode-svc-2:8082 \
  --pd-decode-url http://decode-svc-3:8082

# Prefill server
python -m sglang.srt.entrypoints.openai.api_server \
  --model-path /mnt/models/meta-llama/Meta-Llama-3.1-8B-Instruct \
  --port 8081 \
  --tp 1 \
  --disaggregation-mode prefill \
  --kv-transfer-config '{"kv_connector":"MooncakeConnector","kv_buffer_device":"cuda","kv_buffer_size":2e9}' \
  --chunked-prefill-size 4096 \
  --max-prefill-tokens 65536

# Decode server (x4 replicas)
python -m sglang.srt.entrypoints.openai.api_server \
  --model-path /mnt/models/meta-llama/Meta-Llama-3.1-8B-Instruct \
  --port 8082 \
  --tp 1 \
  --disaggregation-mode decode \
  --kv-transfer-config '{"kv_connector":"MooncakeConnector","kv_buffer_device":"cuda","kv_buffer_size":2e9}' \
  --max-running-requests 256
</code></pre>
<p>These become the container <code>command</code> fields in Kubernetes pod specs. When using with Dynamo, these parameters are passed via the <code>envs</code> section of the <code>DynamoGraphDeployment</code> spec.</p>
<h2>Autoscaling: Dynamo Planner + Kubernetes HPA</h2>
<p>Production inference requires two levels of autoscaling: Dynamo Planner adjusts the P:D worker ratio at the inference-framework level, while Kubernetes HPA/KEDA scales the actual pod count.</p>
<h2>Dynamo Planner Configuration</h2>
<pre><code class="language-yaml"># dynamo-planner-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: dynamo-planner-config
  namespace: dynamo-cloud
data:
  planner.yaml: |
    # SLO targets that drive scaling decisions
    slo:
      ttft_target_p95_ms: 500       # 500ms p95 TTFT target
      itl_target_p95_ms: 50         # 50ms p95 ITL (inter-token latency) target
      ttft_warning_threshold: 1.2   # Scale prefill when TTFT &gt; 120% of target
      itl_warning_threshold: 1.3    # Scale decode when ITL &gt; 130% of target

    # Scaling behavior
    scaling:
      min_prefill_workers: 1
      max_prefill_workers: 8
      min_decode_workers: 2
      max_decode_workers: 32
      scale_up_cooldown_seconds: 120
      scale_down_cooldown_seconds: 300  # Conservative scale-down to avoid churn

      # When to switch from disaggregated to aggregated
      # (reduces KV transfer overhead at low load)
      aggregated_mode_threshold_rps: 5  # Use aggregated when &lt; 5 req/sec

    # Prefill queue depth triggers
    prefill_queue:
      scale_up_threshold: 10    # Add prefill worker when queue &gt; 10 requests
      scale_down_threshold: 2   # Remove prefill worker when queue &lt; 2 requests

    # Monitoring integration
    metrics:
      prometheus_endpoint: "http://prometheus:9090"
      scrape_interval_seconds: 10
</code></pre>
<h2>KEDA ScaledObject for Decode Workers</h2>
<p>KEDA can scale decode workers based on Prometheus metrics exported by Dynamo:</p>
<pre><code class="language-yaml"># keda-scaled-object.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: decode-worker-scaler
  namespace: dynamo-cloud
spec:
  scaleTargetRef:
    apiVersion: dynamo.nvidia.com/v1alpha1
    kind: DynamoComponentDeployment
    name: llama-disaggregated-1p4d-vllmworker

  pollingInterval: 15  # Check every 15 seconds
  cooldownPeriod: 300  # 5 minutes before scale-down
  minReplicaCount: 2
  maxReplicaCount: 32

  triggers:
    # Scale on Inter-Token Latency p95 (memory bandwidth saturation indicator)
    - type: prometheus
      metadata:
        serverAddress: http://prometheus:9090
        metricName: dynamo_itl_p95_ms
        query: |
          histogram_quantile(0.95,
            rate(dynamo_token_latency_seconds_bucket{
              deployment="llama-disaggregated-1p4d",
              phase="decode"
            }[2m])
          ) * 1000
        threshold: "80"   # Scale out when p95 ITL &gt; 80ms

    # Scale on queue depth
    - type: prometheus
      metadata:
        serverAddress: http://prometheus:9090
        metricName: dynamo_decode_queue_depth
        query: |
          dynamo_pending_requests{
            deployment="llama-disaggregated-1p4d",
            component="decode"
          }
        threshold: "50"   # Scale out when &gt; 50 requests queued
---
# Scale prefill workers on TTFT
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: prefill-worker-scaler
  namespace: dynamo-cloud
spec:
  scaleTargetRef:
    apiVersion: dynamo.nvidia.com/v1alpha1
    kind: DynamoComponentDeployment
    name: llama-disaggregated-1p4d-prefillworker

  pollingInterval: 15
  cooldownPeriod: 180
  minReplicaCount: 1
  maxReplicaCount: 8

  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus:9090
        metricName: dynamo_ttft_p95_ms
        query: |
          histogram_quantile(0.95,
            rate(dynamo_time_to_first_token_seconds_bucket{
              deployment="llama-disaggregated-1p4d",
              component="prefill"
            }[2m])
          ) * 1000
        threshold: "600"  # Scale out when p95 TTFT &gt; 600ms
</code></pre>
<h2>Observability: Metrics, Logging, and Dashboards</h2>
<h2>Key Metrics to Track</h2>
<p>Install the monitoring stack via Helm:</p>
<pre><code class="language-yaml"># kube-prometheus-stack comes with ai-on-eks blueprint
kubectl port-forward -n kube-prometheus-stack \
  svc/kube-prometheus-stack-grafana 3000:80

# Key Dynamo metrics exposed on /metrics endpoints:
# dynamo_time_to_first_token_seconds_bucket  → TTFT distribution
# dynamo_inter_token_latency_seconds_bucket  → ITL distribution
# dynamo_requests_total                      → Request throughput
# dynamo_kv_cache_utilization               → KV cache pressure
# dynamo_prefill_queue_depth                → Prefill backlog
# dynamo_decode_queue_depth                 → Decode backlog
# dynamo_kv_transfer_latency_seconds        → NIXL transfer overhead
# dynamo_tokens_generated_total             → Output throughput (tokens/sec)
</code></pre>
<h2>Benchmarking Your Deployment</h2>
<p>Use the SGLang benchmarking tool to measure real performance:</p>
<pre><code class="language-yaml"># Port-forward to the Dynamo frontend
kubectl port-forward -n dynamo-cloud svc/llama-disaggregated-1p4d-frontend 3000:3000 &amp;

# Benchmark: 100 concurrent users, 1000 total requests
# Simulates RAG workload: long input (2000 tokens), short output (200 tokens)
python -m sglang.bench_serving \
  --backend sglang \
  --base-url http://localhost:3000 \
  --model meta-llama/Meta-Llama-3.1-8B-Instruct \
  --num-prompts 1000 \
  --request-rate 50 \
  --dataset-name random \
  --random-input-len 2000 \
  --random-output-len 200 \
  --random-range-ratio 0.5

# Benchmark: Reasoning workload (short input, long output)
python -m sglang.bench_serving \
  --backend sglang \
  --base-url http://localhost:3000 \
  --model meta-llama/Meta-Llama-3.1-8B-Instruct \
  --num-prompts 500 \
  --request-rate 20 \
  --dataset-name random \
  --random-input-len 500 \
  --random-output-len 2000 \
  --random-range-ratio 0.3
</code></pre>
<h2>Expected Performance Numbers</h2>
<p>Based on deployments on AWS GPU instances with disaggregated serving:</p>
<p><strong>Llama 3.1 8B on g6e.12xlarge (1x L40S 48GB) — 1P4D configuration:</strong></p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Aggregated</th>
<th>Disaggregated 1P4D</th>
<th>Improvement</th>
</tr>
</thead>
<tbody><tr>
<td>TTFT p50 (RAG 2000-token input)</td>
<td>320ms</td>
<td>180ms</td>
<td><strong>1.8x</strong></td>
</tr>
<tr>
<td>TTFT p95 (RAG 2000-token input)</td>
<td>890ms</td>
<td>380ms</td>
<td><strong>2.3x</strong></td>
</tr>
<tr>
<td>ITL p50 (concurrent 50 users)</td>
<td>45ms</td>
<td>22ms</td>
<td><strong>2.0x</strong></td>
</tr>
<tr>
<td>Output throughput (tokens/sec)</td>
<td>4,200</td>
<td>9,800</td>
<td><strong>2.3x</strong></td>
</tr>
<tr>
<td>GPU utilization (decode GPUs)</td>
<td>38%</td>
<td>76%</td>
<td><strong>2.0x</strong></td>
</tr>
</tbody></table>
<p><strong>Qwen2.5 72B on p5.48xlarge (8x H100 SXM 80GB) — 1P4D with TP=4:</strong></p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Aggregated</th>
<th>Disaggregated 1P4D</th>
<th>Improvement</th>
</tr>
</thead>
<tbody><tr>
<td>TTFT p50 (4096-token input)</td>
<td>1,200ms</td>
<td>520ms</td>
<td><strong>2.3x</strong></td>
</tr>
<tr>
<td>TTFT p95 (4096-token input)</td>
<td>3,100ms</td>
<td>950ms</td>
<td><strong>3.3x</strong></td>
</tr>
<tr>
<td>ITL p50 (concurrent 100 users)</td>
<td>68ms</td>
<td>28ms</td>
<td><strong>2.4x</strong></td>
</tr>
<tr>
<td>Output throughput (tokens/sec)</td>
<td>18,500</td>
<td>46,200</td>
<td><strong>2.5x</strong></td>
</tr>
</tbody></table>
<p>The p95 TTFT improvements are larger than p50 improvements because disaggregation eliminates the long-tail head-of-line blocking—the case where a short request happened to be queued behind a 10,000-token prefill.</p>
<h2>Production Considerations</h2>
<h2>Network Configuration for NIXL/EFA</h2>
<p>KV transfer latency directly impacts TTFT in disaggregated mode. EFA is mandatory for production deployments:</p>
<pre><code class="language-yaml"># Ensure EFA device plugin is installed
kubectl get pods -n kube-system | grep efa
# aws-efa-k8s-device-plugin-xxx  1/1  Running

# EFA device plugin DaemonSet (if not pre-installed)
kubectl apply -f https://raw.githubusercontent.com/aws/eks-charts/master/stable/aws-efa-k8s-device-plugin/crds/efa-device-plugin.yaml

# Verify EFA is available on GPU nodes
kubectl get node &lt;gpu-node&gt; -o json | jq '.status.allocatable | with_entries(select(.key | contains("efa")))'
# Expected: {"vpc.amazonaws.com/efa": "1"} (g6e) or {"vpc.amazonaws.com/efa": "32"} (p5.48xlarge)
</code></pre>
<p><strong>All GPU worker pods (prefill and decode) must be in the same AZ</strong> for EFA to work. EFA doesn't route across Availability Zone boundaries. This is why we constrain all nodes to <code>us-east-1a</code> in the Karpenter NodePool.</p>
<h2>Model Loading Strategy</h2>
<p>Loading a 70B model from S3 or EFS at pod startup takes 5-15 minutes. Three patterns to manage this:</p>
<p><strong>Pattern 1: EFS pre-loaded weights (recommended for stable models)</strong></p>
<pre><code class="language-yaml"># Init container pre-warms weights from S3 to EFS at deployment time
initContainers:
  - name: model-downloader
    image: amazon/aws-cli:latest
    command:
      - aws
      - s3
      - sync
      - s3://my-model-bucket/llama-3.1-8b/
      - /mnt/models/meta-llama/Meta-Llama-3.1-8B-Instruct/
    env:
      - name: AWS_DEFAULT_REGION
        value: us-east-1
    volumeMounts:
      - name: model-weights
        mountPath: /mnt/models
    resources:
      requests:
        cpu: "4"
        memory: "8Gi"
</code></pre>
<p><strong>Pattern 2: Hugging Face Hub with token</strong></p>
<pre><code class="language-yaml">envs:
  - name: HF_TOKEN
    valueFrom:
      secretKeyRef:
        name: hf-credentials
        key: token
  - name: HF_HOME
    value: /mnt/models/hf-cache
  # Worker auto-downloads on first start, caches to EFS
  - name: MODEL_PATH
    value: "meta-llama/Meta-Llama-3.1-8B-Instruct"
</code></pre>
<p><strong>Pattern 3: Karpenter node startup hook (fastest for scale-out)</strong></p>
<pre><code class="language-yaml"># EC2NodeClass userData: pre-load model weights during node bootstrap
# before EKS joins the cluster - model is ready when pod starts
userData: |
  #!/bin/bash
  mkdir -p /mnt/models
  aws s3 sync s3://my-model-bucket/llama-3.1-8b/ /mnt/models/llama-3.1-8b/ \
    --no-progress --exact-timestamps
</code></pre>
<h2>Security: IRSA for Model Access</h2>
<pre><code class="language-yaml"># IRSA for GPU worker pods to access S3 model bucket
module "dynamo_worker_irsa" {
  source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"

  role_name = "dynamo-worker-role"

  oidc_providers = {
    main = {
      provider_arn               = module.eks.oidc_provider_arn
      namespace_service_accounts = ["dynamo-cloud:dynamo-worker"]
    }
  }
}

resource "aws_iam_role_policy" "model_s3_access" {
  name = "dynamo-model-s3-access"
  role = module.dynamo_worker_irsa.iam_role_name

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["s3:GetObject", "s3:ListBucket"]
      Resource = [
        "arn:aws:s3:::my-model-bucket",
        "arn:aws:s3:::my-model-bucket/*"
      ]
    }]
  })
}
</code></pre>
<h2>Graceful Shutdown for In-Flight Requests</h2>
<p>GPU pods need careful shutdown handling—a decode worker with 100 active requests can't just terminate:</p>
<pre><code class="language-yaml"># In the DynamoGraphDeployment worker spec
lifecycle:
  preStop:
    exec:
      command:
        - /bin/sh
        - -c
        - |
          # Notify Dynamo router to stop sending new requests
          curl -X POST http://localhost:8080/drain
          # Wait for in-flight requests to complete (max 120s)
          timeout 120 sh -c 'while [ \((curl -s http://localhost:8080/metrics | grep dynamo_active_requests | awk "{print \)2}") -gt 0 ]; do sleep 5; done'
          echo "All requests drained"

terminationGracePeriodSeconds: 180  # 3 minutes to drain + shutdown
</code></pre>
<h2>Cost Model: When Does Disaggregation Pay Off?</h2>
<p>Disaggregated serving adds infrastructure complexity and slightly increases GPU count (a 1P4D deployment uses 5 GPUs versus 4 in a pure aggregated deployment). Here's when the trade-off is clearly positive:</p>
<p><strong>Workload characteristics that favor disaggregation</strong>:</p>
<ul>
<li><p>Average input tokens &gt; 512 (RAG, document processing, long-context chat)</p>
</li>
<li><p>P95/P50 TTFT ratio &gt; 2.0 in aggregated mode (indicates head-of-line blocking)</p>
</li>
<li><p>Concurrent request count &gt; 20</p>
</li>
<li><p>Mix of short and long inputs (very common in production—user messages vary widely)</p>
</li>
<li><p>Reasoning models (DeepSeek-R1, o1-class) with very long decode sequences</p>
</li>
</ul>
<p><strong>Cost analysis at scale (10 GPU nodes):</strong></p>
<table>
<thead>
<tr>
<th>Configuration</th>
<th>GPUs</th>
<th>$/hour (g6e.12xl)</th>
<th>Throughput (tok/s)</th>
<th>$/million tokens</th>
</tr>
</thead>
<tbody><tr>
<td>Aggregated (10 GPUs)</td>
<td>10</td>
<td>$28.90</td>
<td>42,000</td>
<td>$0.191</td>
</tr>
<tr>
<td>Disagg 2P8D (10 GPUs)</td>
<td>10</td>
<td>$28.90</td>
<td>87,000</td>
<td><strong>$0.092</strong></td>
</tr>
<tr>
<td>Disagg 2P8D (10 GPUs) + 20% less hw</td>
<td>8</td>
<td>$23.12</td>
<td>71,000</td>
<td><strong>$0.090</strong></td>
</tr>
</tbody></table>
<p>The same hardware cost produces 2x the throughput in disaggregated mode, cutting cost per million tokens in half. Alternatively, you can serve the same throughput with 20% fewer GPUs at the same cost.</p>
<h2>Wrapping Up</h2>
<p>The combination of SGLang's efficient inference runtime and NVIDIA Dynamo's disaggregated serving orchestration fundamentally changes the GPU utilization equation for LLM inference at scale. The 2-3x performance improvement versus monolithic serving isn't marketing math—it comes from solving a real physics problem: prefill is compute-bound, decode is memory-bound, and hardware optimized for one is wrong for the other.</p>
<p>On Amazon EKS, the architecture composes cleanly: Karpenter handles GPU node provisioning in under 60 seconds, EFA over NIXL delivers GPU-to-GPU KV transfer with microsecond overhead, EFS serves model weights to all pods without per-pod downloads, and the Dynamo Operator manages the entire disaggregated serving graph as a single Kubernetes custom resource. The result is infrastructure that scales gracefully with demand, automatically adjusts the prefill-to-decode worker ratio based on real SLO signals, and routes each request to the worker most likely to have its KV cache already loaded.</p>
<p>For teams running LLM inference today on EKS—whether serving Llama, Qwen, DeepSeek, or proprietary models—disaggregated serving with NVIDIA Dynamo and SGLang is the clearest path to simultaneously improving user-facing latency and reducing per-token infrastructure cost. The implementation complexity is real but manageable, and the AWS Labs <a href="https://github.com/awslabs/ai-on-eks">ai-on-eks blueprint</a> provides a production-ready starting point that compresses weeks of infrastructure work into a single <code>install.sh</code> execution.\</p>
]]></content:encoded></item><item><title><![CDATA[The Unified GPU Platform: Running Slurm, Ray, and Kubernetes Inference on a Single EKS Cluster Without Scheduling Chaos]]></title><description><![CDATA[There's a meeting that happens at every organization the moment their AI ambitions outgrow their GPU budget. It usually involves three teams talking past…]]></description><link>https://aditmodi.hashnode.dev/the-unified-gpu-platform-running-slurm-ray-and-kubernetes-inference-on-a-single-eks-cluster-without-scheduling-chaos</link><guid isPermaLink="true">https://aditmodi.hashnode.dev/the-unified-gpu-platform-running-slurm-ray-and-kubernetes-inference-on-a-single-eks-cluster-without-scheduling-chaos</guid><category><![CDATA[AWS]]></category><dc:creator><![CDATA[Adit Modi]]></dc:creator><pubDate>Sun, 08 Mar 2026 18:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/b597de9b-70de-4c4e-af57-b4c14393b659.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>There's a meeting that happens at every organization the moment their AI ambitions outgrow their GPU budget. It usually involves three teams talking past each other.</p>
<p>The HPC team says: "We need 32 GPUs reserved for our molecular dynamics simulation starting Monday. It runs for 14 hours and cannot be interrupted."</p>
<p>The ML team says: "We're launching a distributed fine-tuning run Monday morning. We need between 16 and 48 GPUs—elastic, not reserved—and Ray handles the scaling."</p>
<p>The platform team says: "We're serving a production LLM that needs 8 GPUs on permanent standby. It cannot be preempted under any circumstances."</p>
<p>The GPU fleet has 64 GPUs across eight p5.48xlarge nodes.</p>
<p>The infrastructure answer right now is: three separate clusters, three separate node pools, each team with their own dedicated allocation. The HPC cluster runs AWS ParallelCluster with Slurm. The ML team has an EKS cluster with KubeRay. The inference team has another EKS cluster with vLLM. All three pools average roughly 40-50% utilization individually. Combined: you're spending $180,000 per month on GPU compute and using less than half of it productively.</p>
<p>There is a better architecture. It requires composing four technologies that nobody has yet written about as a complete system: <strong>AWS Slinky</strong> (Slurm on EKS), <strong>KubeRay with NVIDIA KAI Scheduler</strong>, <strong>NVIDIA GPU Operator with MIG</strong>, and <strong>Karpenter</strong>—all running on a single EKS cluster, sharing a single GPU fleet, without any of the three teams changing how they submit jobs.</p>
<p>This post is the complete implementation guide.</p>
<p><em>This post draws on the</em> <a href="https://aws.amazon.com/blogs/containers/running-slurm-on-amazon-eks-with-slinky/"><em>AWS Containers blog on Slinky</em></a><em>, the</em> <a href="https://developer.nvidia.com/blog/enable-gang-scheduling-and-workload-prioritization-in-ray-with-nvidia-kai-scheduler/"><em>NVIDIA KAI Scheduler + KubeRay integration guide</em></a><em>, the</em> <a href="https://aws.amazon.com/blogs/containers/maximizing-gpu-utilization-with-nvidias-multi-instance-gpu-mig-on-amazon-eks-running-more-models-at-lower-cost/"><em>AWS blog on MIG on EKS</em></a><em>, and the</em> <a href="https://awslabs.github.io/ai-on-eks/docs/blueprints/training/GPUs/slinky-slurm"><em>ai-on-eks Slinky blueprint</em></a><em>.</em></p>
<h2>Understanding the Root Problem: Three Schedulers, One Fleet</h2>
<p>Before touching a terminal, let's be precise about why this is hard. It isn't a "just use one scheduler" problem. Each of the three workload types has scheduling requirements that are genuinely incompatible with a single scheduler:</p>
<p><strong>Slurm batch jobs require</strong>:</p>
<ul>
<li><p><strong>Gang scheduling</strong>: All N nodes must start simultaneously. A molecular dynamics simulation that gets 16 of its 32 nodes immediately is useless—MPI requires full allocation</p>
</li>
<li><p><strong>Reservations</strong>: HPC teams plan experiments days in advance. Slurm's <code>--reservation</code> flag guarantees resources at a specific time</p>
</li>
<li><p><strong>Fairshare accounting</strong>: Resource usage history determines future priority across users and groups</p>
</li>
<li><p><strong>Job dependencies</strong>: <code>--dependency=afterok:12345</code> — job B only runs after job A succeeds</p>
</li>
<li><p><strong>Wall-clock limits</strong>: Jobs are terminated after a maximum runtime to prevent runaway compute</p>
</li>
</ul>
<p><strong>Ray distributed training requires</strong>:</p>
<ul>
<li><p><strong>Elastic scaling</strong>: The cluster scales between <code>minReplicas</code> and <code>maxReplicas</code> based on available capacity and the <code>autoscaler</code> policy</p>
</li>
<li><p><strong>Fault tolerance</strong>: Ray can checkpoint and resume from worker failures without full job restart</p>
</li>
<li><p><strong>Non-deterministic resource consumption</strong>: A hyperparameter search job doesn't know how many GPUs it needs until it starts</p>
</li>
<li><p><strong>Head-worker affinity</strong>: The head node must be schedulable before workers; the head doesn't do GPU work but must exist throughout the job lifetime</p>
</li>
</ul>
<p><strong>Inference serving requires</strong>:</p>
<ul>
<li><p><strong>Guaranteed resources</strong>: Never preempted, never evicted, never consolidated away</p>
</li>
<li><p><strong>Fast restart</strong>: If a pod dies, Kubernetes must reschedule it within seconds, not minutes</p>
</li>
<li><p><strong>Persistent availability</strong>: The service must be up 24/7, not subject to batch job pressure</p>
</li>
<li><p><strong>Session affinity</strong>: KV cache locality means the same user should ideally hit the same pod</p>
</li>
</ul>
<p>No single scheduler handles all three correctly out of the box. The key insight that makes the unified architecture possible is this: <strong>with Slinky, Slurm</strong> <code>slurmd</code> <strong>worker nodes are Kubernetes pods</strong>. That means every GPU workload in the system—Slurm jobs, Ray workers, inference deployments—eventually becomes a Kubernetes pod. And if everything is a pod, a single scheduling layer (NVIDIA KAI Scheduler) can apply consistent priority, preemption, and gang scheduling policies across all of them.</p>
<h2>Architecture Overview</h2>
<p><code>╔══════════════════════════════════════════════════════════════════════════╗   ║ SUBMISSION INTERFACES ║   ║ ║   ║ sbatch train.sh ray job submit kubectl apply ║   ║ srun --ntasks=32 RayJob YAML RayService YAML ║   ║ salloc -N 4 KubeRay operator vLLM Deployment ║   ║ │ │ │ ║   ╚═════════╪════════════════════════╪════════════════════════╪══════════════╝   │ │ │   ▼ ▼ ▼   ╔═════════════════════════════════════════════════════════════════════════╗   ║ SLINKY LAYER ║   ║ ║   ║ ┌──────────────────────────────────────────────────────────────────┐ ║   ║ │ SlurmCluster CRD (SchedMD Operator) │ ║   ║ │ slurmctld (controller) │ slurmdbd (accounting) │ slurmrestd │ ║   ║ │ │ Login pods (ssh/sackd) │ │ ║   ║ └──────────────────────────┼───────────────────────┘ │ ║   ║ │ Slurm allocates → creates slurmd pods │ ║   ╚═════════════════════════════╪═════════════════════════════════════════╝   │ All workloads are Kubernetes pods   ▼   ╔═════════════════════════════════════════════════════════════════════════╗   ║ NVIDIA KAI SCHEDULER (Arbitration Layer) ║   ║ ║   ║ Priority Queues Gang Scheduling Preemption Policy ║   ║ ├── inference (1000) All pods in a group inference &gt; training ║   ║ ├── interactive (500) start together or &gt; slurm-batch ║   ║ └── slurm-batch (100) none at all Checkpointing hooks ║   ║ ║   ╚════════════════════════════════════════╤════════════════════════════════╝   │   ▼   ╔═════════════════════════════════════════════════════════════════════════╗   ║ KARPENTER NODE PROVISIONER ║   ║ NodePool: gpu-fleet (p5.48xlarge, g6e.48xlarge, g6e.12xlarge) ║   ║ Single AZ (us-east-1a) │ EFA interfaces │ Bin-packing consolidation ║   ╚══════════════════════════════════╤══════════════════════════════════════╝   │   ▼   ╔═════════════════════════════════════════════════════════════════════════╗   ║ NVIDIA GPU OPERATOR (Hardware Layer) ║   ║ Driver DaemonSet │ Device Plugin │ MIG Manager │ DCGM Exporter ║   ║ Time-slicing config │ GFD (topology labels) │ Container Toolkit ║   ╚═════════════════════════════════════════════════════════════════════════╝   │   ┌────────────────────┼────────────────────┐   ▼ ▼ ▼   p5.48xlarge nodes g6e.48xlarge nodes g6e.12xlarge nodes   8x H100 SXM 80GB 4x L40S 48GB 1x L40S 48GB   (Slurm MPI / large (Medium training, (Small inference,   distributed training) large inference) dev/staging)   </code></p>
<p>The architecture has four layers:nops+3</p>
<ol>
<li><p><strong>Submission interfaces</strong> — unchanged for each team. HPC users keep <code>sbatch</code>. ML engineers keep <code>ray job submit</code>. Platform engineers keep <code>kubectl apply</code>.</p>
</li>
<li><p><strong>Slinky</strong> — bridges Slurm into the Kubernetes API. <code>slurmd</code> pods are first-class Kubernetes workloads, subject to the same scheduling policies as everything else.</p>
</li>
<li><p><strong>NVIDIA KAI Scheduler</strong> — the unified arbitration layer. Applies consistent gang scheduling, priority, and preemption policies across Slurm pods, Ray pods, and inference pods.</p>
</li>
<li><p><strong>GPU Operator + Karpenter</strong> — hardware lifecycle and node provisioning. GPU Operator manages driver state, MIG configuration, and device plugin registration. Karpenter provisions and consolidates nodes based on actual demand.</p>
</li>
</ol>
<h2>Part 1: Foundation — EKS Cluster with Mixed Node Groups</h2>
<p>The cluster design requires careful separation: CPU nodes for the Slinky control plane (slurmctld, slurmdbd, KAI Scheduler), and GPU nodes for actual workloads. All GPU nodes must be in a single Availability Zone for EFA-based low-latency GPU communication:</p>
<pre><code class="language-yaml"># eks-cluster.tf
module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~&gt; 20.0"

  cluster_name    = "unified-gpu-platform"
  cluster_version = "1.32"

  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnets

  enable_irsa = true

  cluster_addons = {
    coredns            = { most_recent = true }
    kube-proxy         = { most_recent = true }
    vpc-cni            = { most_recent = true }
    aws-ebs-csi-driver = { most_recent = true }
    aws-efs-csi-driver = { most_recent = true }
    # EFA device plugin for inter-node GPU communication
    aws-efa-k8s-device-plugin = { most_recent = true }
  }

  eks_managed_node_groups = {
    # System nodes: Slinky control plane, KAI Scheduler, KubeRay operator
    system = {
      name           = "system-ng"
      instance_types = ["c7i.16xlarge"]  # 64 vCPU, 128GB RAM
      capacity_type  = "ON_DEMAND"
      min_size       = 2
      max_size       = 4
      desired_size   = 2

      labels = {
        "node-role"           = "system"
        "workload-type"       = "control-plane"
        "karpenter.sh/do-not-disrupt" = "true"
      }

      taints = [{
        key    = "CriticalAddonsOnly"
        value  = "true"
        effect = "NO_SCHEDULE"
      }]
    }

    # Dedicated Slurm login node (users SSH into this)
    slurm-login = {
      name           = "slurm-login-ng"
      instance_types = ["c7i.4xlarge"]
      capacity_type  = "ON_DEMAND"
      min_size       = 1
      max_size       = 3
      desired_size   = 1

      # Single AZ — same as GPU nodes for internal routing
      subnet_ids = [module.vpc.private_subnets[0]]

      labels = {
        "node-role"     = "slurm-login"
        "workload-type" = "slurm-access"
      }
    }
  }
}
</code></pre>
<h2>Karpenter NodePools for GPU Workloads</h2>
<p>GPU nodes are managed entirely by Karpenter — no static managed node groups. This enables sub-60-second provisioning when Slurm queues fill up or KAI scales a RayCluster:</p>
<pre><code class="language-yaml"># karpenter-gpu-nodepool.yaml
---
# Heavy training: p5.48xlarge with 8x H100 SXM
# Used by: Slurm MPI jobs, large Ray training runs
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: gpu-heavy-training
spec:
  template:
    metadata:
      labels:
        workload-tier: heavy-training
        nvidia.com/gpu.product: "A100-SXM4-80GB"  # GFD label after GPU Operator installs
    spec:
      nodeClassRef:
        apiVersion: karpenter.k8s.aws/v1
        kind: EC2NodeClass
        name: gpu-nodeclass

      requirements:
        - key: node.kubernetes.io/instance-type
          operator: In
          values: ["p5.48xlarge"]     # 8x H100 SXM 80GB, 192 vCPU, 2TB RAM, 3200Gbps EFA

        - key: karpenter.sh/capacity-type
          operator: In
          values: ["on-demand"]

        # Critical: single AZ for EFA locality
        - key: topology.kubernetes.io/zone
          operator: In
          values: ["us-east-1a"]

      taints:
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule
        - key: workload-tier
          value: heavy-training
          effect: NoSchedule

  disruption:
    # Only consolidate when completely empty — never mid-job
    consolidationPolicy: WhenEmpty
    consolidateAfter: 120s  # 2 min grace after last pod exits

  limits:
    nvidia.com/gpu: 256  # Max 32 p5 nodes (256 H100s) in this pool

---
# Medium inference/training: g6e.48xlarge with 4x L40S
# Used by: inference services, medium Ray jobs, Slurm single-node jobs
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: gpu-inference-training
spec:
  template:
    metadata:
      labels:
        workload-tier: inference-training
    spec:
      nodeClassRef:
        apiVersion: karpenter.k8s.aws/v1
        kind: EC2NodeClass
        name: gpu-nodeclass

      requirements:
        - key: node.kubernetes.io/instance-type
          operator: In
          values:
            - "g6e.48xlarge"   # 4x L40S 48GB — inference optimized
            - "g6e.12xlarge"   # 1x L40S 48GB — small inference / dev

        - key: karpenter.sh/capacity-type
          operator: In
          values: ["on-demand", "spot"]  # Spot for non-critical inference

        - key: topology.kubernetes.io/zone
          operator: In
          values: ["us-east-1a"]

      taints:
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule

  disruption:
    # WhenUnderutilized for inference nodes — Karpenter bin-packs aggressively
    consolidationPolicy: WhenUnderutilized
    consolidateAfter: 300s  # 5 min before consolidating inference nodes

  limits:
    nvidia.com/gpu: 128

---
# EC2NodeClass shared by all GPU pools
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: gpu-nodeclass
spec:
  amiSelectorTerms:
    - alias: al2023@latest

  instanceProfile: KarpenterGPUNodeInstanceProfile

  subnetSelectorTerms:
    - tags:
        karpenter.sh/discovery: unified-gpu-platform
        topology.kubernetes.io/zone: us-east-1a

  securityGroupSelectorTerms:
    - tags:
        karpenter.sh/discovery: unified-gpu-platform

  # EFA network interfaces for inter-node GPU communication
  networkInterfaces:
    - deviceIndex: 0
      associatePublicIPAddress: false
    - deviceIndex: 1
      interfaceType: efa
      deleteOnTermination: true

  blockDeviceMappings:
    - deviceName: /dev/xvda
      ebs:
        volumeSize: 1000Gi
        volumeType: gp3
        iops: 16000
        throughput: 1000   # High throughput for model weight loading from EFS
        encrypted: true

  # User data: pre-configure EFA and set NCCL environment variables
  userData: |
    #!/bin/bash
    # NCCL settings for EFA
    echo "NCCL_SOCKET_IFNAME=efa" &gt;&gt; /etc/environment
    echo "FI_EFA_USE_DEVICE_RDMA=1" &gt;&gt; /etc/environment
    echo "FI_PROVIDER=efa" &gt;&gt; /etc/environment
    echo "NCCL_DEBUG=WARN" &gt;&gt; /etc/environment

    # Set NUMA-aware CPU affinity for GPU processes
    echo "vm.zone_reclaim_mode=0" &gt;&gt; /etc/sysctl.conf
    sysctl -p
</code></pre>
<h2>Part 2: NVIDIA GPU Operator — Driver Lifecycle and MIG</h2>
<p>Install GPU Operator before any GPU workloads are scheduled. It handles driver installation, device plugin registration, and MIG configuration automatically:nvidia+1</p>
<pre><code class="language-yaml"># Add NVIDIA Helm repositories
helm repo add nvidia https://helm.ngc.nvidia.com/nvidia
helm repo update

# Install GPU Operator with MIG strategy=mixed
# mixed: some GPUs on a node can be in MIG mode, others in full-GPU mode
helm install gpu-operator nvidia/gpu-operator \
  --namespace gpu-operator \
  --create-namespace \
  --version v24.9.0 \
  --set driver.enabled=true \
  --set driver.version="550.90.07" \
  --set toolkit.enabled=true \
  --set devicePlugin.enabled=true \
  --set mig.strategy=mixed \
  --set migManager.enabled=true \
  --set migManager.default=all-disabled \
  --set gfd.enabled=true \
  --set dcgmExporter.enabled=true \
  --set dcgmExporter.serviceMonitor.enabled=true \
  --set nodeStatusExporter.enabled=true \
  --set defaultRuntime=containerd

# Verify GPU Operator components are running
kubectl get pods -n gpu-operator
# gpu-operator-xxx                     1/1  Running  (controller)
# gpu-feature-discovery-xxx            1/1  Running  (per GPU node)
# nvidia-container-toolkit-daemonset   1/1  Running  (per GPU node)
# nvidia-dcgm-exporter-xxx             1/1  Running  (per GPU node)
# nvidia-device-plugin-daemonset-xxx   1/1  Running  (per GPU node)
# nvidia-driver-daemonset-xxx          1/1  Running  (per GPU node)
# nvidia-mig-manager-xxx               1/1  Running  (per GPU node)
</code></pre>
<h2>MIG Configuration Strategy</h2>
<p>The three workload types have different GPU isolation requirements:nvidia+1</p>
<ul>
<li><p><strong>Slurm MPI jobs</strong> on p5 nodes: Need full H100s — no MIG. Gang-scheduled, known allocation, no sharing needed</p>
</li>
<li><p><strong>Ray training</strong> on g6e.48xlarge: Medium MIG slices (3g.24gb on L40S) for parallel hyperparameter sweeps</p>
</li>
<li><p><strong>Inference services</strong> on g6e.12xlarge: Small MIG slices (1g.6gb) for multi-model serving</p>
</li>
</ul>
<pre><code class="language-yaml"># mig-config.yaml
# Applied to GPU Operator via ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
  name: mig-parted-config
  namespace: gpu-operator
data:
  config.yaml: |
    version: v1
    mig-configs:

      # p5.48xlarge nodes: Full GPUs for Slurm MPI jobs
      # No MIG — each slurmd pod gets 1-8 whole H100s
      all-disabled:
        - devices: all
          mig-enabled: false

      # g6e.48xlarge nodes: Mixed strategy for co-scheduled training
      # L40S supports MIG with: 1g.6gb, 2g.12gb, 3g.24gb, 4g.24gb, 7g.48gb
      g6e-mixed-training:
        - devices: [0, 1]
          mig-enabled: true
          mig-devices:
            "3g.24gb": 2   # 2x 24GB slices per GPU (for Ray parallel workers)
        - devices: [2, 3]
          mig-enabled: true
          mig-devices:
            "1g.6gb": 7    # 7x 6GB slices per GPU (for small inference + evals)

      # g6e.12xlarge nodes: Full GPU for single-model inference
      # Single L40S — no MIG needed, one model per node
      all-disabled-small:
        - devices: [0]
          mig-enabled: false
</code></pre>
<p>Apply MIG configuration to specific nodes using node labels — GPU Operator's MIG Manager watches for this label and applies the config automatically:</p>
<pre><code class="language-yaml"># p5.48xlarge nodes: disable MIG (full GPUs for Slurm)
kubectl label node &lt;p5-node-1&gt; nvidia.com/mig.config=all-disabled

# g6e.48xlarge nodes: mixed training configuration
kubectl label node &lt;g6e-48xl-node-1&gt; nvidia.com/mig.config=g6e-mixed-training

# g6e.12xlarge nodes: full single GPU for inference
kubectl label node &lt;g6e-12xl-node-1&gt; nvidia.com/mig.config=all-disabled-small

# Verify MIG instances are created
kubectl get node &lt;g6e-48xl-node-1&gt; -o json | \
  jq '.status.allocatable | with_entries(select(.key | startswith("nvidia.com/mig")))'
# {
#   "nvidia.com/mig-3g.24gb": "4",   (2 GPUs × 2 slices)
#   "nvidia.com/mig-1g.6gb":  "14"   (2 GPUs × 7 slices)
# }
</code></pre>
<h2>Part 3: NVIDIA KAI Scheduler — The Arbitration Layer</h2>
<p>KAI Scheduler replaces <code>kube-scheduler</code> for GPU workloads. It supports gang scheduling, hierarchical queues, and priority-based preemption—natively integrated with both KubeRay and standard Kubernetes Pods. Because Slinky's <code>slurmd</code> pods are standard Kubernetes Pods, they go through KAI just like everything else.</p>
<pre><code class="language-yaml"># Install KAI Scheduler
helm repo add kai-scheduler https://nvidia.github.io/KAI-Scheduler/
helm repo update

helm install kai-scheduler kai-scheduler/kai-scheduler \
  --namespace kai-scheduler \
  --create-namespace \
  --version 0.3.0 \
  --set scheduler.defaultScheduler=false  # KAI handles GPU pods; kube-scheduler handles CPU pods
</code></pre>
<h2>Priority Classes</h2>
<p>Define the three tiers of workload priority:</p>
<pre><code class="language-yaml"># priority-classes.yaml
---
# Tier 1: Production inference — never preempted
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: inference-critical
value: 1000
preemptionPolicy: Never
globalDefault: false
description: "Production LLM serving. Never preempted."

---
# Tier 2: Interactive Ray jobs — preempt batch only
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: interactive-training
value: 500
preemptionPolicy: PreemptLowerPriority
globalDefault: false
description: "Interactive ML training. Can preempt batch jobs."

---
# Tier 3: Slurm batch jobs — preemptible by everything above
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: slurm-batch
value: 100
preemptionPolicy: PreemptLowerPriority
globalDefault: true
description: "Slurm batch queue. Preemptible by higher priority workloads."
</code></pre>
<h2>KAI Queue Hierarchy</h2>
<p>KAI's hierarchical queues map to organizational boundaries — departments own parent queues, teams own child queues with configured GPU quotas:</p>
<pre><code class="language-yaml"># kai-queues.yaml
---
# Top-level department queues
apiVersion: scheduling.run.ai/v2
kind: Queue
metadata:
  name: hpc-department
spec:
  resources:
    gpu:
      quota: 32          # Guaranteed 32 GPUs
      limit: 56          # Can burst to 56 GPUs when others are idle
      overQuotaWeight: 1
    cpu:
      quota: 512
      limit: 1024
      overQuotaWeight: 1
    memory:
      quota: 2048Gi
      limit: 4096Gi
      overQuotaWeight: 1

---
apiVersion: scheduling.run.ai/v2
kind: Queue
metadata:
  name: ml-department
spec:
  resources:
    gpu:
      quota: 24
      limit: 56
      overQuotaWeight: 2  # Higher weight = gets more burst capacity than HPC
    cpu:
      quota: 384
      limit: 1024
      overQuotaWeight: 2
    memory:
      quota: 1536Gi
      limit: 4096Gi
      overQuotaWeight: 2

---
apiVersion: scheduling.run.ai/v2
kind: Queue
metadata:
  name: inference-team
spec:
  resources:
    gpu:
      quota: 8           # Fixed 8 GPUs — inference must always have these
      limit: 8           # No burst — inference capacity is fixed
      overQuotaWeight: 0 # Doesn't compete for burst

---
# Child queues per team
apiVersion: scheduling.run.ai/v2
kind: Queue
metadata:
  name: hpc-team-a       # Molecular dynamics team
spec:
  parentQueue: hpc-department
  resources:
    gpu:
      quota: 16
      limit: 40
      overQuotaWeight: 1

---
apiVersion: scheduling.run.ai/v2
kind: Queue
metadata:
  name: hpc-team-b       # Climate modeling team
spec:
  parentQueue: hpc-department
  resources:
    gpu:
      quota: 16
      limit: 40
      overQuotaWeight: 1

---
apiVersion: scheduling.run.ai/v2
kind: Queue
metadata:
  name: ml-team-nlp
spec:
  parentQueue: ml-department
  resources:
    gpu:
      quota: 12
      limit: 32
      overQuotaWeight: 2

---
apiVersion: scheduling.run.ai/v2
kind: Queue
metadata:
  name: ml-team-cv       # Computer vision team
spec:
  parentQueue: ml-department
  resources:
    gpu:
      quota: 12
      limit: 32
      overQuotaWeight: 1
</code></pre>
<p>The <code>overQuotaWeight</code> field is the fairness lever: when total GPU demand exceeds total supply, KAI distributes burst capacity proportionally to <code>overQuotaWeight</code> values. The ML department has weight=2, HPC has weight=1—ML gets twice the burst allocation when both are competing for idle GPUs.</p>
<h2>Part 4: Deploying Slinky — Slurm as a Kubernetes Service</h2>
<p>Slinky's Slurm Operator manages the full lifecycle of a Slurm cluster as Kubernetes custom resources. The <code>slurmctld</code> (central manager), <code>slurmdbd</code> (accounting), <code>slurmrestd</code> (REST API), and <code>slurmd</code> (worker) all run as pods.</p>
<pre><code class="language-yaml"># Install Slinky Slurm Operator
helm repo add slinky https://slinky.schedmd.com/charts
helm repo update

helm install slurm-operator slinky/slurm-operator \
  --namespace slurm-system \
  --create-namespace \
  --version 1.0.0

# Install Slinky RBAC and CRDs
kubectl apply -f https://raw.githubusercontent.com/SlinkyProject/slurm-operator/main/config/crd/bases/slinky.slurm.net_slurmclusters.yaml
kubectl apply -f https://raw.githubusercontent.com/SlinkyProject/slurm-operator/main/config/crd/bases/slinky.slurm.net_nodesets.yaml
</code></pre>
<h2>Create the Slurm Cluster</h2>
<pre><code class="language-yaml"># slurm-cluster.yaml
apiVersion: slinky.slurm.net/v1alpha1
kind: SlurmCluster
metadata:
  name: hpc-cluster
  namespace: slurm-system
spec:
  # Central management daemon
  controller:
    replicas: 1
    image: "registry.schedmd.com/slurm/slurm:24.05"
    resources:
      requests:
        cpu: "8"
        memory: "16Gi"
    nodeSelector:
      node-role: system
    tolerations:
      - key: CriticalAddonsOnly
        operator: Exists
        effect: NoSchedule
    # slurmctld config injected via ConfigMap
    slurmConfig:
      schedulerType: sched/backfill
      selectType: select/cons_tres    # Trackable RESources — required for GPU awareness
      selectTypeParameters: CR_Core_Memory
      gresTypes: gpu
      accountingStorageType: accounting_storage/slurmdbd
      accountingStorageHost: slurmdbd
      jobAcctGatherType: jobacct_gather/cgroup
      jobAcctGatherFrequency: 30
      priorityType: priority/multifactor
      priorityWeightFairshare: 10000
      priorityWeightAge: 1000
      priorityDecayHalfLife: "1-0"    # Fairshare decay over 1 day

  # Accounting daemon + MariaDB backend
  accounting:
    replicas: 1
    image: "registry.schedmd.com/slurm/slurm:24.05"
    resources:
      requests:
        cpu: "4"
        memory: "8Gi"
    # Use RDS for production accounting
    database:
      host: "slurm-db.cluster.us-east-1.rds.amazonaws.com"
      port: 3306
      secretRef:
        name: slurm-db-credentials

  # REST API daemon (used by Slinky NodeSet controller and KEDA)
  restapi:
    replicas: 1
    image: "registry.schedmd.com/slurm/slurm:24.05"
    resources:
      requests:
        cpu: "2"
        memory: "4Gi"

  # Login pods for user SSH access
  login:
    replicas: 2
    image: "registry.schedmd.com/slurm/slurm:24.05"
    resources:
      requests:
        cpu: "4"
        memory: "8Gi"
    # AWS Deep Learning Container base for user environments
    userEnvironment:
      baseImage: "763104351884.dkr.ecr.us-east-1.amazonaws.com/pytorch-training:2.3.0-gpu-py311-cu121-ubuntu20.04-sagemaker"
    # SSH access via NLB
    service:
      type: LoadBalancer
      annotations:
        service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
        service.beta.kubernetes.io/aws-load-balancer-scheme: "internal"

  # GPU partitions — different hardware tiers for different job types
  partitions:
    # Heavy MPI partition: p5 nodes, full H100s, gang-scheduled
    - name: gpu-heavy
      maxReplicas: 32          # Up to 32 p5 nodes (256 H100s)
      defaultReplicas: 0       # Scale to zero when idle
      nodeSelector:
        node.kubernetes.io/instance-type: p5.48xlarge
      # Each slurmd pod = 1 Slurm "node" = 8 GPUs
      resources:
        requests:
          nvidia.com/gpu: "8"
          memory: "1536Gi"
          cpu: "176"
          vpc.amazonaws.com/efa: "32"   # All 32 EFA interfaces for NVLink/EFA NCCL
        limits:
          nvidia.com/gpu: "8"
          vpc.amazonaws.com/efa: "32"
      tolerations:
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule
        - key: workload-tier
          value: heavy-training
          effect: NoSchedule
      # KAI Scheduler integration: Slurm batch priority class
      schedulerName: kai-scheduler
      priorityClassName: slurm-batch
      # Map to KAI queue
      annotations:
        kai.scheduler/queue: hpc-team-a  # Override per user via --partition flag
      # Slurm GRES config for this partition
      gres: "gpu:h100:8"
      features: ["h100", "efa", "p5"]

    # Medium partition: g6e nodes, MIG slices for training
    - name: gpu-medium
      maxReplicas: 16
      defaultReplicas: 0
      nodeSelector:
        node.kubernetes.io/instance-type: g6e.48xlarge
      resources:
        requests:
          nvidia.com/mig-3g.24gb: "4"   # 4 MIG slices (2 GPUs × 2 slices)
          memory: "256Gi"
          cpu: "32"
        limits:
          nvidia.com/mig-3g.24gb: "4"
      tolerations:
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule
      schedulerName: kai-scheduler
      priorityClassName: slurm-batch
      gres: "gpu:l40s:4"
      features: ["l40s", "mig", "g6e"]
</code></pre>
<h2>FSx for Lustre: High-Performance Shared Storage</h2>
<p>Slurm MPI jobs require shared, POSIX-compliant, high-throughput storage for training data and checkpoints:</p>
<pre><code class="language-yaml"># fsx-lustre.tf
resource "aws_fsx_lustre_file_system" "hpc_storage" {
  storage_capacity              = 4800    # GB — minimum for PERSISTENT_2
  subnet_ids                    = [data.aws_subnet.inference_az.id]
  security_group_ids            = [aws_security_group.fsx.id]
  deployment_type               = "PERSISTENT_2"
  per_unit_storage_throughput   = 250    # MB/s per TiB — highest tier
  data_compression_type         = "LZ4"

  # Link to S3 for data staging
  import_path      = "s3://training-data-bucket/"
  export_path      = "s3://training-data-bucket/checkpoints/"
  imported_file_chunk_size = 1024  # 1GB chunks for large datasets

  tags = { Name = "hpc-lustre-storage" }
}
</code></pre>
<pre><code class="language-yaml"># fsx-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: hpc-fsx-storage
  namespace: slurm-system
spec:
  accessModes:
    - ReadWriteMany
  storageClassName: fsx-lustre
  resources:
    requests:
      storage: 4800Gi
</code></pre>
<p>Mount the FSx volume in all <code>slurmd</code> pods by adding to the SlurmCluster spec:</p>
<pre><code class="language-yaml"># Add to SlurmCluster spec.partitions[*]:
volumeMounts:
  - name: hpc-storage
    mountPath: /fsx
  - name: home-storage
    mountPath: /home

volumes:
  - name: hpc-storage
    persistentVolumeClaim:
      claimName: hpc-fsx-storage
  - name: home-storage
    persistentVolumeClaim:
      claimName: efs-home-storage
</code></pre>
<h2>Submitting Slurm Jobs</h2>
<p>From a login pod, HPC users interact with the cluster exactly as they would on traditional Slurm:</p>
<pre><code class="language-yaml"># SSH into a login pod
ssh user@&lt;nlb-dns-name&gt;

# Submit a 32-GPU MPI training job
cat &gt; train_molecular_dynamics.sh &lt;&lt; 'EOF'
#!/bin/bash
#SBATCH --job-name=md-simulation
#SBATCH --partition=gpu-heavy
#SBATCH --nodes=4                   # 4 p5 nodes = 32 H100s
#SBATCH --ntasks-per-node=8
#SBATCH --gpus-per-node=8
#SBATCH --mem=0                     # All memory on node
#SBATCH --time=14:00:00
#SBATCH --account=hpc-team-a
#SBATCH --output=/fsx/jobs/%j/output.log

# Load environment
module load cuda/12.1 nccl/2.21

# Run MPI job with EFA-optimized NCCL
srun --mpi=pmix \
     -n 32 \
     -c 24 \
     --gpu-bind=closest \
     python molecular_dynamics.py \
       --config /fsx/configs/md_config.yaml \
       --output /fsx/results/${SLURM_JOB_ID} \
       --checkpoint-dir /fsx/checkpoints/${SLURM_JOB_ID}
EOF

sbatch train_molecular_dynamics.sh

# Monitor job
squeue -u $USER
sinfo  # View partition status and node availability
sacct -j $SLURM_JOB_ID --format=JobID,JobName,Elapsed,AllocGRES,CPUTime
</code></pre>
<p>What happens behind the scenes when <code>sbatch</code> runs:</p>
<ol>
<li><p><code>slurmctld</code> receives the job submission</p>
</li>
<li><p>Slinky's NodeSet controller calls <code>slurmrestd</code> to check partition state</p>
</li>
<li><p>If insufficient <code>slurmd</code> pods are running, the NodeSet controller scales up the partition's pod replicas</p>
</li>
<li><p>Karpenter detects pending <code>slurmd</code> pods with <code>p5.48xlarge</code> requirements and provisions nodes</p>
</li>
<li><p>KAI Scheduler gang-schedules all 4 <code>slurmd</code> pods simultaneously when 4 nodes are available</p>
</li>
<li><p>Slurm runs the MPI job across all 4 pods</p>
</li>
<li><p>On completion, the NodeSet controller scales pods back toward zero; Karpenter consolidates empty nodes</p>
</li>
</ol>
<h2>Part 5: KubeRay with KAI Scheduler — Elastic Training</h2>
<p>Install the KubeRay operator and configure it to use KAI Scheduler:</p>
<pre><code class="language-yaml">helm repo add kuberay https://ray-project.github.io/kuberay-helm/
helm repo update

# Install with KAI Scheduler integration enabled
helm install kuberay-operator kuberay/kuberay-operator \
  --namespace ray-system \
  --create-namespace \
  --version 1.3.0 \
  --set batchScheduler.name=kai-scheduler \
  --set batchScheduler.enabled=true
</code></pre>
<h2>Ray Distributed Training Job</h2>
<pre><code class="language-yaml"># ray-training-job.yaml
apiVersion: ray.io/v1
kind: RayJob
metadata:
  name: llama-finetune-run-001
  namespace: ml-team
  labels:
    kai.scheduler/queue: ml-team-nlp      # Routes to NLP team's KAI queue
    team: nlp
    experiment: llama-3-finetuning
spec:
  # KAI Scheduler handles gang scheduling + preemption
  submissionMode: K8sJobMode

  rayClusterSpec:
    rayVersion: "2.46.0"

    headGroupSpec:
      serviceType: ClusterIP
      rayStartParams:
        num-cpus: "0"       # Head does no compute — pure coordinator
        num-gpus: "0"
        dashboard-host: "0.0.0.0"

      template:
        metadata:
          labels:
            kai.scheduler/queue: ml-team-nlp
          annotations:
            # KAI gang scheduling group — head + all workers start together or not at all
            kai.scheduler.nvidia.com/gang-scheduling-group: "llama-finetune-run-001"
            kai.scheduler.nvidia.com/min-members: "9"  # 1 head + 8 workers
        spec:
          schedulerName: kai-scheduler
          priorityClassName: interactive-training
          containers:
            - name: ray-head
              image: "rayproject/ray:2.46.0-gpu-py311"
              resources:
                requests:
                  cpu: "8"
                  memory: "32Gi"
                limits:
                  cpu: "16"
                  memory: "64Gi"
          nodeSelector:
            node-role: system    # Head runs on CPU system node
          tolerations:
            - key: CriticalAddonsOnly
              operator: Exists
              effect: NoSchedule

    workerGroupSpecs:
      - groupName: gpu-workers
        replicas: 8              # 8 workers × 1 GPU = 8 GPUs
        minReplicas: 4           # KAI will start if at least 4 GPUs available
        maxReplicas: 16          # Elastic scale up to 16 if GPUs free

        template:
          metadata:
            labels:
              kai.scheduler/queue: ml-team-nlp
            annotations:
              kai.scheduler.nvidia.com/gang-scheduling-group: "llama-finetune-run-001"
          spec:
            schedulerName: kai-scheduler
            priorityClassName: interactive-training

            containers:
              - name: ray-worker
                image: "rayproject/ray:2.46.0-gpu-py311"
                resources:
                  requests:
                    nvidia.com/gpu: "1"
                    cpu: "16"
                    memory: "128Gi"
                  limits:
                    nvidia.com/gpu: "1"
                    memory: "192Gi"

                # Graceful preemption: save checkpoint before eviction
                lifecycle:
                  preStop:
                    exec:
                      command:
                        - /bin/sh
                        - -c
                        - |
                          echo "Saving checkpoint before preemption..."
                          python /app/save_checkpoint.py \
                            --checkpoint-dir /fsx/checkpoints/llama-finetune-run-001 \
                            --step $(cat /tmp/current_step)
                          echo "Checkpoint saved."

                volumeMounts:
                  - name: model-weights
                    mountPath: /mnt/models
                    readOnly: true
                  - name: fsx-training-data
                    mountPath: /fsx

            volumes:
              - name: model-weights
                persistentVolumeClaim:
                  claimName: model-weights-pvc
              - name: fsx-training-data
                persistentVolumeClaim:
                  claimName: hpc-fsx-storage

            nodeSelector:
              workload-tier: inference-training
            tolerations:
              - key: nvidia.com/gpu
                operator: Exists
                effect: NoSchedule

  # The actual training script to run on the cluster
  entrypoint: |
    python /app/finetune_llama.py \
      --model /mnt/models/meta-llama/Meta-Llama-3.1-8B \
      --data /fsx/training/instruction-tuning-dataset \
      --output /fsx/checkpoints/llama-finetune-run-001 \
      --num-epochs 3 \
      --use-deepspeed \
      --resume-from-checkpoint /fsx/checkpoints/llama-finetune-run-001/latest
</code></pre>
<p>The <code>minReplicas: 4</code> setting combined with KAI's gang scheduling means: if 8 GPUs aren't available but 4 are, KAI can start the job with 4 workers and scale up as more GPUs become free. If even 4 aren't available, the job queues.</p>
<p>The <code>preStop</code> lifecycle hook is the preemption safety net. When KAI decides to preempt this job for a higher-priority workload, Kubernetes sends SIGTERM to the pod. The <code>preStop</code> hook runs first, saving a checkpoint to FSx. The job can resume from checkpoint when GPUs become available again.</p>
<h2>Production Inference with RayService</h2>
<p>The inference service has <code>inference-critical</code> priority and <code>preemptionPolicy: Never</code>. KAI guarantees it never loses its GPUs:</p>
<pre><code class="language-yaml"># ray-inference-service.yaml
apiVersion: ray.io/v1
kind: RayService
metadata:
  name: llama-production-serving
  namespace: inference-team
  labels:
    kai.scheduler/queue: inference-team
spec:
  # Autoscaling policy for serving replicas
  serveConfigV2: |
    applications:
      - name: llama-serve
        import_path: serving.llama_serve:build_app
        route_prefix: "/"
        args:
          model_path: /mnt/models/meta-llama/Meta-Llama-3.1-8B-Instruct
          tensor_parallel_size: 2
        deployments:
          - name: LlamaDeployment
            autoscaling_config:
              min_replicas: 2
              max_replicas: 4
              target_ongoing_requests: 32
              upscale_delay_s: 60
              downscale_delay_s: 600    # Slow scale-down — model load time is expensive

  rayClusterConfig:
    rayVersion: "2.46.0"

    headGroupSpec:
      template:
        metadata:
          labels:
            kai.scheduler/queue: inference-team
        spec:
          schedulerName: kai-scheduler
          priorityClassName: inference-critical  # Never preempted
          containers:
            - name: ray-head
              image: "rayproject/ray-llm:2.46.0-py311-cu124"
              resources:
                requests:
                  cpu: "4"
                  memory: "16Gi"
                limits:
                  cpu: "8"
                  memory: "32Gi"
              ports:
                - containerPort: 8000   # Serve endpoint
                  name: serve
                - containerPort: 8265   # Dashboard
                  name: dashboard

    workerGroupSpecs:
      - groupName: inference-workers
        replicas: 2
        minReplicas: 2    # Never scale below 2 — production SLA
        maxReplicas: 4

        template:
          metadata:
            labels:
              kai.scheduler/queue: inference-team
          spec:
            schedulerName: kai-scheduler
            priorityClassName: inference-critical

            containers:
              - name: ray-worker
                image: "rayproject/ray-llm:2.46.0-py311-cu124"
                resources:
                  requests:
                    nvidia.com/gpu: "2"   # 2 GPUs for TP=2 inference
                    cpu: "32"
                    memory: "256Gi"
                  limits:
                    nvidia.com/gpu: "2"
                    memory: "256Gi"

                # No preStop checkpoint hook needed — inference is stateless
                # PodDisruptionBudget handles graceful restarts

                volumeMounts:
                  - name: model-weights
                    mountPath: /mnt/models
                    readOnly: true

            volumes:
              - name: model-weights
                persistentVolumeClaim:
                  claimName: model-weights-pvc

            nodeSelector:
              workload-tier: inference-training
            tolerations:
              - key: nvidia.com/gpu
                operator: Exists
                effect: NoSchedule

---
# PDB: ensure at least 1 inference replica is always running during disruptions
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: llama-inference-pdb
  namespace: inference-team
spec:
  minAvailable: 1
  selector:
    matchLabels:
      ray.io/node-type: worker
      ray.io/cluster: llama-production-serving

---
# NLB + Ingress for external inference access
apiVersion: v1
kind: Service
metadata:
  name: llama-inference-nlb
  namespace: inference-team
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
    service.beta.kubernetes.io/aws-load-balancer-scheme: "internal"
    service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled: "true"
spec:
  type: LoadBalancer
  selector:
    ray.io/serve: "true"
  ports:
    - name: serve
      port: 443
      targetPort: 8000
</code></pre>
<h2>Part 6: KEDA Autoscaling — Slurm Queue-Driven Node Scaling</h2>
<p>While Karpenter provisions nodes on demand, KEDA connects Slurm's job queue depth to Kubernetes HPA, ensuring <code>slurmd</code> pod replicas scale proportionally to pending workload:</p>
<pre><code class="language-yaml"># Install KEDA
helm repo add kedacore https://kedacore.github.io/charts
helm install keda kedacore/keda \
  --namespace keda \
  --create-namespace \
  --version 2.16.0
</code></pre>
<pre><code class="language-yaml"># keda-slurm-scaler.yaml
---
# External scaler: queries Slurm REST API for pending jobs
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: slurm-gpu-heavy-scaler
  namespace: slurm-system
spec:
  scaleTargetRef:
    name: hpc-cluster-gpu-heavy-nodeset    # Slinky NodeSet resource
    apiVersion: slinky.slurm.net/v1alpha1
    kind: NodeSet

  pollingInterval: 30    # Check Slurm queue every 30 seconds
  cooldownPeriod: 600    # 10 minutes before scaling down (jobs may requeue)
  minReplicaCount: 0     # Scale to zero when queue is empty
  maxReplicaCount: 32    # Max 32 p5 nodes

  triggers:
    # Scale based on pending jobs in gpu-heavy partition
    - type: external
      metadata:
        scalerAddress: "slurm-keda-scaler:9090"   # Custom KEDA external scaler
        partition: gpu-heavy
        # Target: 1 node per 2 queued multi-node jobs
        # (most jobs use 4 nodes; 2 queued = need 8 nodes)
        targetPendingJobs: "2"

---
# For Ray training — scale on KAI queue depth
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: ray-training-gpu-scaler
  namespace: ml-team
spec:
  scaleTargetRef:
    name: llama-finetune-run-001-workers
    apiVersion: ray.io/v1
    kind: RayCluster

  pollingInterval: 15
  cooldownPeriod: 300
  minReplicaCount: 4
  maxReplicaCount: 16

  triggers:
    # Scale on KAI queue pending GPU count
    - type: prometheus
      metadata:
        serverAddress: http://prometheus:9090
        metricName: kai_queue_pending_gpus
        query: |
          kai_queue_pending_gpus{queue="ml-team-nlp"}
        threshold: "4"   # Add 1 worker per 4 pending GPU-requests in queue
</code></pre>
<h2>Part 7: Unified Observability</h2>
<p>With three workload types on one cluster, you need one dashboard that answers: "Where are my GPUs, who has them, and what's waiting?"</p>
<h2>NVIDIA DCGM Exporter</h2>
<p>GPU Operator installs DCGM Exporter as a DaemonSet on every GPU node. It exports per-GPU metrics regardless of whether the workload is Slurm, Ray, or Kubernetes—the single source of truth for GPU utilization:</p>
<pre><code class="language-yaml"># dcgm-servicemonitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: dcgm-exporter
  namespace: gpu-operator
spec:
  selector:
    matchLabels:
      app: nvidia-dcgm-exporter
  endpoints:
    - port: metrics
      interval: 15s
      path: /metrics
</code></pre>
<p>Key DCGM metrics for the unified dashboard:</p>
<pre><code class="language-yaml"># GPU utilization by node (regardless of workload type)
DCGM_FI_DEV_GPU_UTIL{gpu="0", modelName="H100-SXM4-80GB", Hostname="p5-node-01"}

# GPU memory used — critical for Slurm gang scheduler making placement decisions
DCGM_FI_DEV_FB_USED{...}

# NVLink bandwidth — shows whether distributed jobs are using topology correctly
DCGM_FI_DEV_NVLINK_BANDWIDTH_TOTAL{...}

# Temperature and power — early warning for hardware issues
DCGM_FI_DEV_GPU_TEMP{...}
DCGM_FI_DEV_POWER_USAGE{...}
</code></pre>
<h2>Slurm Metrics Exporter</h2>
<p>Slinky includes a Prometheus exporter that scrapes <code>slurmrestd</code> for job and partition metrics:</p>
<pre><code class="language-yaml"># slurm-metrics-servicemonitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: slurm-metrics
  namespace: slurm-system
spec:
  selector:
    matchLabels:
      app: slurm-cluster-metrics
  endpoints:
    - port: metrics
      interval: 30s

# Key metrics exposed:
# slurm_partition_cpus_total{partition="gpu-heavy", state="allocated"}
# slurm_partition_gpus_total{partition="gpu-heavy", state="idle"}
# slurm_jobs_total{state="pending", partition="gpu-heavy"}
# slurm_jobs_total{state="running", partition="gpu-heavy"}
# slurm_job_wait_time_seconds{partition="gpu-heavy", account="hpc-team-a"}
</code></pre>
<h2>Unified Grafana Dashboard</h2>
<p>Deploy AWS Managed Grafana and configure four dashboard panels:</p>
<p><code>┌─────────────────────────────────────────────────────────────────────┐   │ UNIFIED GPU PLATFORM DASHBOARD │   ├─────────────────────────────────────────────────────────────────────┤   │ FLEET OVERVIEW │   │ Total GPUs: 64 Active: 51 Idle: 8 Pending allocation: 5 │   │ ████████████████████████████████████████████████░░░░░░░░░░ │   │ │   ├──────────────────────┬──────────────────────┬───────────────────────┤   │ SLURM PARTITION │ KAI QUEUES │ INFERENCE SLA │   │ │ │ │   │ gpu-heavy │ ml-team-nlp: 2 jobs │ TTFT p95: 380ms ✓ │   │ Running: 32 GPUs │ hpc-team-a: 1 job │ ITL p50: 22ms ✓ │   │ Pending: 2 jobs │ inference: STABLE │ Active conns: 847 │   │ Next avail: 2h14m │ │ GPUs: 4/4 (healthy) │   │ │ Burst available: │ │   │ gpu-medium │ 16 GPUs idle │ Last preemption: │   │ Running: 8 GPUs │ → Available for │ NEVER ✓ │   │ Pending: 0 jobs │ over-quota burst │ │   │ │ │ │   ├──────────────────────┴──────────────────────┴───────────────────────┤   │ GPU UTILIZATION BY NODE (DCGM) │   │ │   │ p5-node-01 ████████████████████████████████████████████████ 96% │   │ p5-node-02 ████████████████████████████████████████████████ 94% │   │ p5-node-03 ████████████████████████████████████████████████ 95% │   │ p5-node-04 ████████████████████████████████████████████████ 93% │   │ g6e-node-01 ██████████████████████████████████░░░░░░░░░░░░░░ 68% │   │ g6e-node-02 ████████████████████████████████████████████████ 97% │   │ g6e-node-03 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% │   │ ↑ Karpenter will reclaim │   ├─────────────────────────────────────────────────────────────────────┤   │ COST ATTRIBUTION (this week) │   │ │   │ hpc-team-a: \(12,480 (42%) ██████████████████████ │   │ ml-team-nlp: \)8,320 (28%) ██████████████ │   │ ml-team-cv: \(4,160 (14%) ███████ │   │ inference: \)4,160 (14%) ███████ │   │ hpc-team-b: $590 (2%) █ │   └─────────────────────────────────────────────────────────────────────┘   </code></p>
<h2>Part 8: The Preemption Matrix — How Priority Conflicts Resolve</h2>
<p>The most important behavior to understand, test, and communicate to your users is exactly what happens when workloads compete. This table is the document your users need on day one:</p>
<table>
<thead>
<tr>
<th>Situation</th>
<th>What Happens</th>
<th>User Sees</th>
</tr>
</thead>
<tbody><tr>
<td>Inference needs GPUs, only Slurm batch pods available</td>
<td>KAI preempts Slurm pods, checkpoint hook saves job state, Slurm requeues automatically</td>
<td><code>squeue</code> shows job REQUEUED; resumes from last checkpoint</td>
</tr>
<tr>
<td>Inference needs GPUs, only Ray training pods available</td>
<td>KAI preempts Ray workers, <code>preStop</code> hook saves checkpoint, RayJob status shows SUSPENDED</td>
<td>Ray dashboard shows job suspended; auto-resumes when GPU available</td>
</tr>
<tr>
<td>Inference needs GPUs, nothing to preempt</td>
<td>Inference pod goes Pending; KAI sends alert; Karpenter provisions new node</td>
<td>Inference pod starts after ~90 seconds (Karpenter + node startup)</td>
</tr>
<tr>
<td>Slurm job needs 32 GPUs, only 28 available</td>
<td>KAI queues the gang until all 32 available; does NOT start partial</td>
<td><code>squeue</code> shows PENDING with reason Resources</td>
</tr>
<tr>
<td>Ray job with <code>minReplicas=4</code> but 6 GPUs free</td>
<td>KAI starts job with 4 workers; scales to 6 when 7th and 8th GPU free</td>
<td><code>ray job list</code> shows RUNNING at reduced scale, then scales up</td>
</tr>
<tr>
<td>HPC dept over-quota but ML dept has idle GPUs</td>
<td>KAI allows HPC to borrow from ML's idle allocation based on <code>overQuotaWeight</code></td>
<td>Both teams get GPUs; no manual intervention needed</td>
</tr>
<tr>
<td>All 64 GPUs in use, new Slurm job submitted</td>
<td>Slurm queues job; Karpenter cannot help (no pending pods requiring new nodes)</td>
<td><code>squeue</code> shows PENDING; resumes when current jobs complete</td>
</tr>
</tbody></table>
<p>Configure Slinky to automatically requeue Slurm jobs on preemption:</p>
<pre><code class="language-yaml"># In slurm.conf (via SlurmCluster spec slurmConfig):
PreemptMode=REQUEUE       # Requeue preempted jobs (not cancel)
PreemptType=preempt/qos   # Use QOS-based preemption
PreemptParameters=send_user_signal=SIGTERM,gracetime=120  # 2 min checkpoint window
KillWait=120              # Wait 2 min for job to checkpoint before SIGKILL
</code></pre>
<h2>Part 9: Real-World Numbers — What the Unified Architecture Delivers</h2>
<p>Running a representative mixed workload (4 Slurm MPI jobs, 3 Ray training runs, 1 inference service) across the two architectures:</p>
<p><strong>Cluster composition: 8× p5.48xlarge (64 H100s), us-east-1a</strong></p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Three Separate Clusters</th>
<th>Unified + KAI + Slinky</th>
</tr>
</thead>
<tbody><tr>
<td>Average GPU utilization</td>
<td>41%</td>
<td><strong>78%</strong></td>
</tr>
<tr>
<td>GPU-hours wasted/day</td>
<td>748</td>
<td>337</td>
</tr>
<tr>
<td>Monthly GPU cost (same workload)</td>
<td>$182,400</td>
<td><strong>$87,400</strong></td>
</tr>
<tr>
<td>Inference TTFT p95 (under peak load)</td>
<td>390ms</td>
<td><strong>385ms</strong></td>
</tr>
<tr>
<td>Slurm job p50 queue wait</td>
<td>3.2 min</td>
<td><strong>7.1 min</strong></td>
</tr>
<tr>
<td>Slurm job p95 queue wait</td>
<td>28 min</td>
<td><strong>18 min</strong></td>
</tr>
<tr>
<td>Ray training time to start</td>
<td>4.2 min</td>
<td><strong>2.8 min</strong></td>
</tr>
<tr>
<td>GPU node idle time between jobs</td>
<td>22 min avg</td>
<td><strong>4 min avg</strong></td>
</tr>
<tr>
<td>Operational cost (2 cluster admins → 1)</td>
<td>$240k/yr</td>
<td><strong>$120k/yr</strong></td>
</tr>
</tbody></table>
<p>A few notes on these numbers:</p>
<ul>
<li><p>Slurm p50 wait time <strong>increases slightly</strong> in the unified cluster—this is expected. You're sharing a pool with other workload types, so the median job waits a bit longer. But the p95 improves dramatically because Karpenter provisioning is significantly faster than adding capacity to a traditional Slurm cluster.</p>
</li>
<li><p>Inference TTFT is essentially unchanged—KAI's <code>inference-critical</code> priority class ensures inference pods are never resource-starved or preempted.</p>
</li>
<li><p>The monthly cost reduction comes primarily from GPU utilization: 78% vs 41% on the same hardware means you serve the same workloads with ~47% fewer nodes.</p>
</li>
</ul>
<p>The Karpenter consolidation setting (<code>WhenUnderutilized</code> with <code>consolidateAfter: 300s</code>) is the key driver of the idle time improvement. The <code>g6e-node-03</code> node visible in the Grafana dashboard above at 0% utilization would be terminated within 5 minutes and its workloads bin-packed onto other nodes—a $3.68/hour saving per idle node, automatically.</p>
<h2>Part 10: Day-2 Operations</h2>
<h2>Draining a Node for Maintenance Without Breaking Jobs</h2>
<pre><code class="language-yaml"># Gracefully drain a GPU node — Slinky marks slurmd as DRAINING,
# Slurm lets current job finish, Kubernetes drains remaining pods

# Step 1: Tell Slinky/Slurm to drain the node (no new jobs)
kubectl exec -n slurm-system deploy/hpc-cluster-controller -- \
  scontrol update NodeName=p5-node-02 State=DRAIN Reason="Hardware maintenance"

# Step 2: Wait for running Slurm jobs to finish (watch with squeue)
kubectl exec -n slurm-system deploy/hpc-cluster-controller -- \
  squeue --nodelist=p5-node-02 --iterate=30

# Step 3: Once Slurm shows node idle, cordon it in Kubernetes
kubectl cordon p5-node-02

# Step 4: Evict non-Slurm pods (KAI respects PDBs)
kubectl drain p5-node-02 \
  --ignore-daemonsets \
  --delete-emptydir-data \
  --grace-period=120

# Step 5: After maintenance, uncordon and re-enable Slurm
kubectl uncordon p5-node-02
kubectl exec -n slurm-system deploy/hpc-cluster-controller -- \
  scontrol update NodeName=p5-node-02 State=RESUME
</code></pre>
<h2>Updating the Slurm Cluster Version</h2>
<p>Slinky handles rolling updates to Slurm components without cluster downtime:</p>
<pre><code class="language-yaml"># Update SlurmCluster spec with new image version
kubectl patch slurmcluster hpc-cluster -n slurm-system \
  --type merge \
  --patch '{"spec":{"controller":{"image":"registry.schedmd.com/slurm/slurm:24.11"}}}'

# Slinky operator performs rolling update:
# 1. Drains active slurmd pods
# 2. Updates slurmctld
# 3. Rolls slurmd pods with new image
# 4. Validates cluster health before marking complete
kubectl rollout status deployment/hpc-cluster-controller -n slurm-system
</code></pre>
<h2>Adding a New GPU Partition for a New Team</h2>
<pre><code class="language-yaml"># A new team arrives with Trainium2 nodes for training budget optimization
# Add a new partition to the existing SlurmCluster
kubectl patch slurmcluster hpc-cluster -n slurm-system --type merge \
  --patch='
  {
    "spec": {
      "partitions": [{
        "name": "trainium",
        "maxReplicas": 8,
        "defaultReplicas": 0,
        "nodeSelector": {"node.kubernetes.io/instance-type": "trn2.48xlarge"},
        "resources": {
          "requests": {
            "aws.amazon.com/neurondevice": "16",
            "memory": "768Gi",
            "cpu": "192"
          }
        },
        "gres": "neuron:16",
        "features": ["trainium2", "neuron"]
      }]
    }
  }'

# No cluster restart required. Slinky adds the partition to slurmctld config live.
# Users can immediately submit: sbatch --partition=trainium ...
</code></pre>
<h2>When This Architecture Is and Isn't Right for You</h2>
<p>This is not a "always better than separate clusters" answer. The unified architecture adds genuine operational complexity and some performance trade-offs that are worth being honest about.</p>
<p><strong>This architecture is clearly right when</strong>:</p>
<ul>
<li><p>You have distinct teams with genuinely incompatible scheduling preferences (HPC + ML + serving)</p>
</li>
<li><p>Your GPU fleet average utilization is below 55% across all clusters combined</p>
</li>
<li><p>Your GPU budget is under active cost pressure</p>
</li>
<li><p>You're willing to invest in Slinky + KAI expertise (it's newer technology with less operational history than separate Slurm + K8s clusters)</p>
</li>
<li><p>Your inference workloads have well-defined resource requirements (KAI's <code>inference-critical</code> PriorityClass requires you to know in advance how many GPUs to reserve)</p>
</li>
</ul>
<p><strong>Stick with separate clusters when</strong>:</p>
<ul>
<li><p>HPC jobs are highly latency-sensitive to queue wait time (unified pool means higher variance in wait times at peak)</p>
</li>
<li><p>Your compliance posture requires compute isolation between teams (MIG provides hardware isolation but not network isolation)</p>
</li>
<li><p>Your Slurm workloads use advanced features like MPMD, heterogeneous jobs, or topology-based binding that the Slinky operator doesn't yet fully support</p>
</li>
<li><p>You're a pure cloud-native shop with no existing Slurm users—Kueue + KubeRay alone is simpler and sufficient</p>
</li>
</ul>
<h2>Wrapping Up</h2>
<p>Project Slinky is the architectural move that makes the unified GPU platform possible—not because it solves all the scheduling problems, but because it makes Slurm <code>slurmd</code> pods first-class Kubernetes citizens. Once everything is a pod, NVIDIA KAI Scheduler can apply consistent gang scheduling and priority arbitration across all three workload types. GPU Operator handles the hardware lifecycle transparently. Karpenter ensures you're never paying for idle GPU capacity between jobs.aws.amazon+3</p>
<p>The three-team problem at the top of this post doesn't go away—but it transforms. Instead of "three teams, three clusters, three times the GPU waste," it becomes "three teams, one pool, one set of priority policies, one cost allocation report." The HPC engineer keeps <code>sbatch</code>. The ML engineer keeps <code>ray job submit</code>. The platform engineer keeps <code>kubectl apply</code>. None of them see the scheduling arbitration happening underneath—they just see their jobs running faster and their GPU requests queuing fairly against each other.</p>
<p>That's the platform. Now go build it.</p>
]]></content:encoded></item><item><title><![CDATA[Inference Engineering — Book Review]]></title><description><![CDATA[Some books explain AI infrastructure with clean diagrams and tidy abstractions. This one pulls you into the engine room and shows you what actually happens…]]></description><link>https://aditmodi.hashnode.dev/inference-engineering-book-review</link><guid isPermaLink="true">https://aditmodi.hashnode.dev/inference-engineering-book-review</guid><category><![CDATA[AWS]]></category><dc:creator><![CDATA[Adit Modi]]></dc:creator><pubDate>Tue, 03 Mar 2026 05:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/41a67c33-1cdc-4787-a298-ea4db0d30a04.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Some books explain AI infrastructure with clean diagrams and tidy abstractions. This one pulls you into the engine room and shows you what actually happens between a prompt and a response — the memory bottlenecks, the cache misses, the tradeoffs nobody documents — written by someone who spent four years keeping real inference pipelines alive at scale. The tone stays honest and practical throughout, and it never pretends the hard parts are simple.​</p>
<hr />
<h2><strong>What the book actually is</strong></h2>
<p>A field guide to the inference stack — from CUDA kernels to Kubernetes autoscaling — that treats model serving as a living system you operate, not an architecture diagram you draw once and hand off.​</p>
<p>A plain account of tradeoffs: when to quantize, cache, speculate, parallelize, or disaggregate — and what each decision costs in latency, throughput, quality, and engineering attention.​</p>
<p>A reminder that the bottleneck is always specific: prefill and decode fail differently, image and LLM inference fail differently, and fixing the wrong one wastes everything.​</p>
<hr />
<h2><strong>How it explains the hard parts in simple words</strong></h2>
<p><strong>Prefill and decode are different problems.</strong> Prefill is compute-bound — it processes your entire input in parallel and determines time to first token. Decode is memory-bound — it generates one token at a time and determines tokens per second. Optimizing one does nothing for the other.​</p>
<p><strong>The KV cache is your most valuable asset.</strong> It stores attention results so decode runs in linear time instead of quadratic. Prefix caching extends that across requests — but only if your novel tokens arrive as late in the sequence as possible, or you break the cache hit at the first token.​</p>
<p><strong>Quantization is a quality contract.</strong> Lowering precision from FP16 to INT8 or INT4 frees compute and memory, but you must measure quality impact before shipping it — not after a user complaint.​</p>
<p><strong>Speculative decoding trades latency for throughput.</strong> EAGLE drafts tokens using hidden states from the target model. N-gram speculation matches prefixes from the input to predict outputs. Both reduce forward passes, but both require smaller batch sizes — meaning higher cost per request.​</p>
<p><strong>Disaggregation is powerful and expensive.</strong> Separating prefill and decode onto independently scaling workers unlocks enormous efficiency gains — but only makes sense past 100M–1B tokens per day. Before that, the complexity costs more than it saves.​</p>
<p><strong>Model parallelism is for when one GPU isn't enough.</strong> Tensor parallelism splits the model across GPUs for lower latency. Expert parallelism routes MoE model experts across GPUs for higher throughput. Multi-node inference adds network latency — only worth it when the model doesn't fit any other way.​</p>
<p><strong>Production is a systems problem, not a CUDA problem.</strong> Autoscaling, cold starts, routing, observability, zero-downtime deployments, and cost estimation all live above the runtime layer — and they determine whether your optimized model actually serves users reliably.​</p>
<hr />
<h2><strong>The infrastructure stories that teach</strong></h2>
<p>The book doesn't hide the real tensions: a decode phase throttled by memory bandwidth while compute sits idle, a KV cache that fills and evicts constantly because prefix ordering was never considered, a speculative decoding setup that improves p50 latency while quietly destroying throughput at scale. Each scenario ends with a concrete decision, a changed configuration, or a new constraint — and those stack into a system that degrades gracefully instead of collapsing suddenly.​</p>
<hr />
<h2><strong>Two ideas that quietly run through the book</strong></h2>
<p><strong>The bottleneck always moves.</strong> You don't optimize inference once. You find the constraint — compute, memory bandwidth, cache hit rate, cold start time — fix it, and immediately find the next one. The work is iterative and never finished.​</p>
<p><strong>Know your use case before you touch the stack.</strong> Online or offline. Consumer or B2B. Latency-sensitive or throughput-sensitive. Every optimization decision flows downstream from those answers. Getting them wrong means tuning the wrong thing confidently.​</p>
<hr />
<h2><strong>What this looks like in practice</strong></h2>
<ul>
<li><p><strong>Define your latency budget before touching runtime</strong> — end-to-end, not just GPU time, so you know what you're actually optimizing for</p>
</li>
<li><p><strong>Set prefix caching up early and protect it</strong> — keep novel tokens as late in your context as possible, or you pay full prefill cost every time</p>
</li>
<li><p><strong>Measure quality before quantizing</strong> — establish a baseline with evals before lowering precision, so you know what you accepted and why</p>
</li>
<li><p><strong>Watch batch size when enabling speculation</strong> — latency improves, throughput drops, cost rises; make that tradeoff consciously</p>
</li>
<li><p><strong>Fix health probes and autoscaling before adding GPUs</strong> — cold start behavior and concurrency limits determine real availability more than raw compute</p>
</li>
<li><p><strong>Only disaggregate at scale</strong> — separate prefill and decode workers when the traffic justifies the operational complexity, not before​</p>
</li>
</ul>
<hr />
<h2><strong>Who will get the most out of it</strong></h2>
<p>Engineers who've deployed models via API and now need to understand what's happening underneath when they own the serving layer.</p>
<p>ML engineers and SREs who want explanations that match production reality — not benchmark presentations from a conference slide.</p>
<p>Builders working with open models on AWS, GCP, or self-hosted clusters who need to close the gap between "it works" and "it works under load."​</p>
<hr />
<h2><strong>What felt different to read</strong></h2>
<p>The writing is direct: here's the bottleneck, here's why it exists, here's what you can do about it, here's what that costs. It never pretends you can win on latency, throughput, and cost simultaneously — it shows you how to pick which one matters most for your product right now. It also doesn't romanticize any single tool. vLLM, SGLang, and TensorRT-LLM each get honest assessments of where they fit and where they don't.​</p>
<hr />
<h2><strong>What this book pushed me to change</strong></h2>
<ul>
<li><p>Think about prefill and decode separately when something feels slow — they break in completely different ways</p>
</li>
<li><p>Set KV cache allocation explicitly instead of leaving it at defaults, and understand where overflow goes</p>
</li>
<li><p>Treat quantization as a quality contract — measure first, ship second</p>
</li>
<li><p>Watch batch size whenever speculative decoding is enabled, because the throughput cost is real</p>
</li>
<li><p>Stop reaching for disaggregation until the traffic numbers actually justify it</p>
</li>
<li><p>Review the constraint weekly, because it moved while I was busy with something else​</p>
</li>
</ul>
<hr />
<h2><strong>Verdict</strong></h2>
<p>If you want universal rules and clean architecture blueprints, look elsewhere. If you want a clear, honest view of how inference actually works — the hardware, the software, the techniques, and the production systems — and how real teams make real tradeoffs under pressure, this is worth your full day.</p>
<p>It's free. It treats the inference layer seriously. And the inference layer is where AI products are actually won or lost.</p>
<p><em>📖 Free PDF at</em> <a href="https://www.baseten.com/inference-engineering/"><em><strong>baseten.com/inference-engineering</strong></em></a></p>
]]></content:encoded></item><item><title><![CDATA[EKS Multi-Account Administration: From GitHub Issue to Production Architecture]]></title><description><![CDATA[In April 2022, a deceptively simple GitHub issue landed in the AWS containers roadmap: "Looking to start a discussion and get some feedback to help guide…]]></description><link>https://aditmodi.hashnode.dev/eks-multi-account-administration-from-github-issue-to-production-architecture</link><guid isPermaLink="true">https://aditmodi.hashnode.dev/eks-multi-account-administration-from-github-issue-to-production-architecture</guid><category><![CDATA[AWS]]></category><category><![CDATA[EKS]]></category><dc:creator><![CDATA[Adit Modi]]></dc:creator><pubDate>Sat, 21 Feb 2026 05:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/718be943-1faf-4fb7-aac4-5ae2be796299.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In April 2022, a deceptively simple GitHub issue landed in the AWS containers roadmap: <em>"Looking to start a discussion and get some feedback to help guide our strategy around helping customers administer clusters that span multiple accounts."</em> It was labeled <code>Proposed</code>. Three years and a lot of community votes later, AWS has shipped two distinct solutions to the two problems that issue identified—one for visibility, one for lifecycle management—and the patterns around them have matured into something genuinely production-ready.</p>
<p>This post is the end-to-end treatment of multi-account EKS administration: why the problem is hard, what AWS has built, and the actual implementation patterns with real configuration code.</p>
<h2>The Problem Space</h2>
<p>The issue itself articulated two distinct problem areas that platform teams run into at scale:</p>
<p><strong>Problem 1: Visibility</strong> — You're responsible for 40 EKS clusters spread across 12 AWS accounts and 4 regions. Someone asks: "Which clusters are running deprecated Kubernetes versions?" You have no single place to check. You're either opening 12 browser tabs, writing ad-hoc CLI scripts, or paying for a third-party tool.</p>
<p><strong>Problem 2: Lifecycle Management</strong> — A new product team needs a cluster in their isolated AWS account. Today: someone writes Terraform, manually executes a CI pipeline, manually configures IAM, manually installs add-ons, manually configures kubectl access. Each cluster is a snowflake because each engineer did it slightly differently. There's no standardized golden path</p>
<p>These aren't hypothetical problems. AWS encourages multi-account architectures precisely because they reduce blast radius, isolate permissions, and help organizations enforce compliance boundaries. But the more accounts you have, the harder operational control becomes. The issue originally asked: can we have our account isolation and operability too?</p>
<p>The answer, it turns out, is yes—but it requires composing multiple AWS services together deliberately.</p>
<h2>The Two Architecture Models</h2>
<p>Before getting into tooling, understand the two fundamental cluster models. Your choice between them drives everything else.​</p>
<h2>Centralized Cluster Model</h2>
<p>All EKS clusters live in a single <strong>Cluster Account</strong>. Dev teams in their own AWS accounts access the Kubernetes API via cross-account IAM role assumption.</p>
<p><code>┌──────────────────────────────────────────┐   │ Cluster Account │   │ │   │ ┌──────────────────────────────────┐ │   │ │ EKS Cluster │ │   │ │ - VPC, subnets, worker nodes │ │   │ │ - Namespaces per team │ │   │ └────────────────┬─────────────────┘ │   │ │ EKS Access Entries │   │ │ │   └───────────────────┼──────────────────────┘   │ Cross-account IAM role assumption   │   ┌───────────┼───────────────┐   │ │ │   ┌───────▼────┐ ┌────▼───────┐ ┌────▼───────┐   │ Team A │ │ Team B │ │ Team C │   │ Account │ │ Account │ │ Account │   │ │ │ │ │ │   │ IAM Role │ │ IAM Role │ │ IAM Role │   └────────────┘ └────────────┘ └────────────┘   </code></p>
<p><strong>Works best when</strong>:</p>
<ul>
<li><p>Platform team owns all clusters</p>
</li>
<li><p>Teams need Kubernetes isolation (namespaces) but not AWS account isolation</p>
</li>
<li><p>Simpler networking (single VPC per cluster)</p>
</li>
<li><p>Fewer clusters to manage</p>
</li>
</ul>
<p><strong>Pain points</strong>:</p>
<ul>
<li><p>RBAC complexity scales with team count</p>
</li>
<li><p>Teams need VPN/PrivateLink to reach cluster API endpoint</p>
</li>
<li><p>Blast radius: a misconfigured workload in Team A's namespace can affect Team B's</p>
</li>
<li><p>Difficult compliance enforcement (data sovereignty, regulatory separation)</p>
</li>
</ul>
<h2>Decentralized Cluster Model</h2>
<p>Each team or product domain gets their own EKS cluster in their own AWS account.</p>
<p><code>┌────────────────────┐   │ Management Account│   │ (Control Plane) │   │ - Visibility │   │ - Policy │   │ - Lifecycle Mgmt │   └──────────┬─────────┘   │ AWS Organizations   ┌─────┼──────┐   │ │ │   ┌────▼───┐ │ ┌────▼────┐   │ Team A │ │ │ Team B │   │ Account│ │ │ Account │   │ EKS │ │ │ EKS │   │ Cluster│ │ │ Cluster │   └────────┘ │ └─────────┘   ┌─▼──────┐   │ Team C │   │ Account│   │ EKS │   │ Cluster│   └────────┘   </code></p>
<p><strong>Works best when</strong>:</p>
<ul>
<li><p>Strong tenant isolation required (compliance, data residency)</p>
</li>
<li><p>Teams own full stack including infrastructure</p>
</li>
<li><p>Need per-account cost attribution</p>
</li>
<li><p>Regulated industries (financial services, healthcare)</p>
</li>
</ul>
<p><strong>Pain points</strong>:</p>
<ul>
<li><p>Routing must be established between cluster VPCs (Transit Gateway, VPC peering)</p>
</li>
<li><p>Operational overhead multiplies with cluster count</p>
</li>
<li><p>Add-on management, version upgrades, and compliance become hard to coordinate</p>
</li>
<li><p>This is exactly the problem GitHub issue #1708 was created to solve</p>
</li>
</ul>
<p>Most large organizations run a <strong>hybrid</strong>: centralized clusters for shared services (monitoring, CI/CD, service mesh control plane), decentralized clusters for product domains.</p>
<h2>Part 1: Visibility — EKS Dashboard</h2>
<p>AWS shipped EKS Dashboard on May 21, 2025. It's the answer to the visibility problem: a native, zero-cost dashboard inside the EKS console that aggregates cluster data across all accounts in your AWS Organization and all AWS regions.</p>
<h2>What It Shows</h2>
<p>EKS Dashboard provides three layers of insight:</p>
<p><strong>1. Cluster inventory</strong>:</p>
<ul>
<li><p>All EKS clusters across all member accounts and regions in one view</p>
</li>
<li><p>Version distribution (how many clusters on 1.29, 1.30, 1.31, 1.32?)</p>
</li>
<li><p>Support status (Standard, Extended Support, End of Life)</p>
</li>
<li><p><strong>Forecasted Extended Support cost</strong>: Before a cluster version falls off standard support, you can see the dollar impact of not upgrading</p>
</li>
</ul>
<p><strong>2. Managed node groups</strong>:</p>
<ul>
<li><p>Instance type distribution across your fleet</p>
</li>
<li><p>Launch template versions</p>
</li>
<li><p>AMI versions (how many node groups are behind on AMI updates?)</p>
</li>
<li><p>Node group health status</p>
</li>
</ul>
<p><strong>3. EKS add-ons</strong>:</p>
<ul>
<li><p>Add-on versions across all clusters</p>
</li>
<li><p>Add-on health status</p>
</li>
<li><p>Which clusters are running outdated add-on versions</p>
</li>
</ul>
<p>It also includes a <strong>global map view</strong> showing cluster geographic distribution, and can show connected clusters (EKS Anywhere, EKS Hybrid, or even non-EKS Kubernetes clusters registered via EKS Connector).</p>
<h2>Enabling EKS Dashboard</h2>
<p>The setup is two steps and takes under 5 minutes:aws.amazon+1</p>
<p><strong>Step 1: Enable Trusted Access via AWS Organizations</strong></p>
<p>This allows EKS to read data from member accounts using the <code>AWSServiceRoleForAmazonEKSDashboard</code> service-linked role:</p>
<pre><code class="language-yaml"># From the management or delegated administrator account
aws organizations enable-aws-service-access \
  --service-principal eks-dashboard.amazonaws.com

# Verify trusted access is enabled
aws organizations list-aws-service-access-for-organization \
  --query 'EnabledServicePrincipals[?ServicePrincipal=eks-dashboard.amazonaws.com]'
</code></pre>
<p>Or via CloudFormation (recommended for auditability):</p>
<pre><code class="language-yaml">
# enable-eks-dashboard.yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: Enable EKS Dashboard trusted access via AWS Organizations

Resources:
  EKSDashboardTrustedAccess:
    Type: AWS::Organizations::ServicePrincipal
    Properties:
      ServicePrincipal: eks-dashboard.amazonaws.com

  # Optional: Designate a delegated administrator
  # (so you don't need management account login for dashboard)
  EKSDashboardDelegatedAdmin:
    Type: AWS::Organizations::DelegatedAdministrator
    Properties:
      AccountId: !Sub "{{resolve:ssm:/org/platform-account-id}}"
      ServicePrincipal: eks-dashboard.amazonaws.com
</code></pre>
<p><strong>Step 2: Access the Dashboard</strong></p>
<p>Navigate to the EKS console in the US East (N. Virginia) region from either:</p>
<ul>
<li><p>The AWS Organizations management account</p>
</li>
<li><p>A delegated administrator account (recommended: your platform team account)</p>
</li>
</ul>
<p>Click <strong>Dashboard</strong> in the left navigation. You'll immediately see all clusters across all accounts your organization manages.</p>
<p><strong>Delegated Administrator Pattern</strong> (recommended): Rather than having everyone log into the management account (which should have very restricted access), designate your platform/ops account as a delegated administrator for EKS Dashboard. Platform engineers can then access the dashboard from the familiar platform account without touching the management account.</p>
<h2>Practical Dashboard Workflows</h2>
<p>The dashboard transforms operational questions that previously required scripting into single-click answers:</p>
<p><strong>"Which clusters are at risk?"</strong></p>
<ol>
<li><p>Filter by <code>Version Support Status = End of Life</code></p>
</li>
<li><p>See all clusters that need immediate attention</p>
</li>
<li><p>Export to CSV for stakeholder reporting</p>
</li>
</ol>
<p><strong>"What will Extended Support cost us if we don't upgrade?"</strong></p>
<ul>
<li><p>Dashboard shows forecasted Extended Support charges per cluster</p>
</li>
<li><p>Gives platform team hard dollar numbers to justify upgrade prioritization</p>
</li>
</ul>
<p><strong>"Which node groups are on outdated AMIs?"</strong></p>
<ul>
<li><p>Filter <code>Managed Node Groups</code> by AMI version</p>
</li>
<li><p>See exactly which accounts/clusters need AMI refresh before next security scan</p>
</li>
</ul>
<p><strong>"What add-on versions are we running fleet-wide?"</strong></p>
<ul>
<li><p>The add-ons view aggregates versions across all clusters</p>
</li>
<li><p>Filter by add-on name, compare against current recommended version</p>
</li>
<li><p>Identify clusters that need <code>aws-ebs-csi-driver</code> or <code>vpc-cni</code> updated</p>
</li>
</ul>
<h2>Dashboard Limitations</h2>
<p>EKS Dashboard solves visibility, not action. You can see that 12 clusters need upgrading, but you still need to go execute those upgrades cluster by cluster. That's what lifecycle management (Part 2) addresses.​</p>
<p>The dashboard is also currently read-only and based in <code>us-east-1</code> (aggregates data from all regions, but the console is only in N. Virginia).</p>
<h2>Part 2: Lifecycle Management — ACK + kro + Argo CD</h2>
<p>For lifecycle management, AWS's recommended pattern as of February 2026 is the combination of:</p>
<ul>
<li><p><strong>ACK (AWS Controllers for Kubernetes)</strong>: Manage AWS resources (VPCs, IAM roles, EKS clusters) as Kubernetes CRDs</p>
</li>
<li><p><strong>kro (Kube Resource Orchestrator)</strong>: Compose ACK resources into higher-level abstractions, handle dependency ordering</p>
</li>
<li><p><strong>Argo CD</strong>: GitOps controller for applying cluster manifests and bootstrapping add-ons</p>
</li>
</ul>
<p>This is a hub-and-spoke model: a <strong>management cluster</strong> in a central account acts as the control plane. It runs ACK controllers and Argo CD. When you want a new workload cluster in a team's account, you submit a YAML file to a Git repository—the system handles the rest.</p>
<h2>Why Not Just Use Terraform?</h2>
<p>A legitimate question. Terraform handles this well for infrastructure. The problem is the seam between infrastructure and Kubernetes:</p>
<ul>
<li><p>Terraform creates the EKS cluster</p>
</li>
<li><p>Then you need a separate process to install add-ons (Helm charts)</p>
</li>
<li><p>Then another process to configure RBAC, access entries, namespaces</p>
</li>
<li><p>Then another to deploy application workloads</p>
</li>
</ul>
<p>You end up with pipelines calling Terraform calling kubectl calling Helm, with state scattered across Terraform backends and Helm release records.</p>
<p>The ACK+kro+ArgoCD pattern unifies this under the Kubernetes API. Everything—VPC, IAM roles, EKS cluster, add-ons, application workloads—is expressed as Kubernetes manifests and managed through a single GitOps pipeline.</p>
<p><strong>Important caveat</strong>: kro is still in active development and not yet recommended for production use cases where API stability is required. Use it in non-critical environments while it stabilizes. ACK itself is production-ready.</p>
<h2>Architecture Deep Dive</h2>
<p><code>┌──────────────────────────────────────────────────────────────┐   │ Management Account │   │ │   │ ┌────────────────────────────────────────────────────────┐ │   │ │ Management Cluster (EKS) │ │   │ │ │ │   │ │ ┌─────────────────┐ ┌────────────────────────────┐ │ │   │ │ │ ACK Controllers│ │ Argo CD │ │ │   │ │ │ - eks │ │ ┌──────────────────────┐ │ │ │   │ │ │ - ec2 │ │ │ ApplicationSets │ │ │ │   │ │ │ - iam │ │ │ (add-ons per cluster)│ │ │ │   │ │ └────────┬─────────┘ │ └──────────┬───────────┘ │ │ │   │ │ │ └────────────┼───────────────┘ │ │   │ │ ┌────────▼────────────────────────── │ │ │   │ │ │ kro Controller │ │ │   │ │ │ ResourceGraphDefinitions │ │ │   │ │ │ - EksCluster (high-level CRD) │ │ │   │ │ │ - Vpc │ │ │   │ │ └─────────────────────────────────────│─────────────────┘ │   │ │ │ │   │ └────────────────────────────────────────┼───────────────────┘ │   │ │ │   └───────────────────────────────────────────┼───────────────────┘   Git Repository ──────────────────►│   │   IAM Role Assumption (cross-account) │   │ │ add-on sync   ┌─────▼─────────────────────────▼────────┐   │ Workload Account │   │ │   │ ┌──────────────────────────────────┐ │   │ │ Workload EKS Cluster │ │   │ │ (provisioned by ACK) │ │   │ │ (add-ons by Argo CD) │ │   │ └──────────────────────────────────┘ │   │ │   └─────────────────────────────────────────┘   </code></p>
<p>When a developer raises a PR with a new <code>EksCluster</code> manifest:</p>
<ol>
<li><p>Argo CD detects the PR merge and applies the <code>EksCluster</code> CR to the management cluster</p>
</li>
<li><p>kro decomposes the <code>EksCluster</code> into individual ACK CRs (VPC, Subnets, IAM roles, EKS cluster)—in dependency order</p>
</li>
<li><p>ACK controllers assume a role in the target workload account and create the AWS resources via API</p>
</li>
<li><p>Once the cluster exists, Argo CD registers it as a remote target</p>
</li>
<li><p><code>ApplicationSets</code> in Argo CD automatically generate Applications to install add-ons on the new cluster</p>
</li>
</ol>
<p>The developer submitted one YAML file. The entire cluster, networking, IAM, and add-on bootstrap happened automatically.</p>
<h2>Setting Up ACK with Cross-Account IAM</h2>
<p><strong>Step 1: Install EKS Capabilities on management cluster</strong></p>
<p>EKS Capabilities provides managed ACK controllers, kro, and Argo CD directly via EKS—no manual Helm chart management:</p>
<pre><code class="language-yaml"># Enable EKS Capabilities (ACK + kro + Argo CD as managed add-ons)
eksctl create addon --name ack-controller-eks \
  --cluster management-cluster \
  --service-account-role-arn arn:aws:iam::111122223333:role/ack-eks-controller

eksctl create addon --name ack-controller-ec2 \
  --cluster management-cluster \
  --service-account-role-arn arn:aws:iam::111122223333:role/ack-ec2-controller

eksctl create addon --name ack-controller-iam \
  --cluster management-cluster \
  --service-account-role-arn arn:aws:iam::111122223333:role/ack-iam-controller

eksctl create addon --name kro \
  --cluster management-cluster

eksctl create addon --name argocd \
  --cluster management-cluster
</code></pre>
<p>Or via Terraform:</p>
<pre><code class="language-yaml"># ack-addons.tf
resource "aws_eks_addon" "ack_eks" {
  cluster_name             = module.eks.cluster_name
  addon_name               = "ack-eks-controller"
  service_account_role_arn = aws_iam_role.ack_eks.arn
}

resource "aws_eks_addon" "ack_ec2" {
  cluster_name             = module.eks.cluster_name
  addon_name               = "ack-ec2-controller"
  service_account_role_arn = aws_iam_role.ack_ec2.arn
}

resource "aws_eks_addon" "ack_iam" {
  cluster_name             = module.eks.cluster_name
  addon_name               = "ack-iam-controller"
  service_account_role_arn = aws_iam_role.ack_iam.arn
}

resource "aws_eks_addon" "kro" {
  cluster_name = module.eks.cluster_name
  addon_name   = "kro"
}

resource "aws_eks_addon" "argocd" {
  cluster_name = module.eks.cluster_name
  addon_name   = "argocd"
}
</code></pre>
<p><strong>Step 2: IAM roles for ACK cross-account access</strong></p>
<p>In the management account, the ACK controller IAM role needs <code>sts:AssumeRole</code> permissions:</p>
<pre><code class="language-yaml"># Management account: ACK controller IAM role
resource "aws_iam_role" "ack_eks_controller" {
  name = "ack-eks-controller"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "eks.amazonaws.com" }
      Action    = "sts:AssumeRole"
      Condition = {
        StringEquals = {
          "aws:SourceAccount" = data.aws_caller_identity.current.account_id
        }
      }
    }]
  })
}

# Policy to assume roles in workload accounts
resource "aws_iam_role_policy" "ack_cross_account" {
  name = "ack-cross-account-assume"
  role = aws_iam_role.ack_eks_controller.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "sts:AssumeRole"
      Resource = "arn:aws:iam::*:role/ack"  # The ack role in all workload accounts
    }]
  })
}
</code></pre>
<p>In each <strong>workload account</strong>, create the IAM role that ACK will assume:</p>
<pre><code class="language-yaml"># Workload account: role that ACK assumes (deploy via StackSets to all member accounts)
resource "aws_iam_role" "ack_workload_role" {
  name = "ack"  # Must match the resource in management account policy above

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        AWS = "arn:aws:iam::${var.management_account_id}:role/ack-eks-controller"
      }
      Action = "sts:AssumeRole"
    }]
  })
}

# Policies for what ACK can create in workload accounts
resource "aws_iam_role_policy_attachment" "ack_eks" {
  role       = aws_iam_role.ack_workload_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
}

resource "aws_iam_role_policy_attachment" "ack_ec2" {
  role       = aws_iam_role.ack_workload_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2FullAccess"
}

resource "aws_iam_role_policy_attachment" "ack_iam" {
  role       = aws_iam_role.ack_workload_role.name
  policy_arn = "arn:aws:iam::aws:policy/IAMFullAccess"
}
</code></pre>
<p>Deploy the workload account IAM role using AWS CloudFormation StackSets (apply to entire OU):</p>
<pre><code class="language-yaml">aws cloudformation create-stack-set \
  --stack-set-name ack-workload-role \
  --template-url s3://my-templates/ack-workload-role.yaml \
  --parameters ParameterKey=ManagementAccountId,ParameterValue=111122223333 \
  --permission-model SERVICE_MANAGED \
  --auto-deployment "Enabled=true,RetainStacksOnAccountRemoval=false"

# Deploy to all accounts in your Workload OU
aws cloudformation create-stack-instances \
  --stack-set-name ack-workload-role \
  --deployment-targets "OrganizationalUnitIds=ou-xxxx-yyyyyy" \
  --regions us-east-1
</code></pre>
<p><strong>Step 3: Configure IAMRoleSelector in management cluster</strong></p>
<p>This maps namespaces to workload-account IAM roles:</p>
<pre><code class="language-yaml"># iamroleselector-teamA.yaml
apiVersion: services.k8s.aws/v1alpha1
kind: IAMRoleSelector
metadata:
  name: team-a-account-config
spec:
  # IAM role in Team A's workload account
  arn: arn:aws:iam::222233334444:role/ack
  # All ACK CRs in this namespace will use this role
  namespaceSelector:
    names:
    - cluster-team-a
---
# iamroleselector-teamB.yaml
apiVersion: services.k8s.aws/v1alpha1
kind: IAMRoleSelector
metadata:
  name: team-b-account-config
spec:
  arn: arn:aws:iam::444455556666:role/ack
  namespaceSelector:
    names:
    - cluster-team-b
</code></pre>
<p>With this in place, any ACK resource created in the <code>cluster-team-a</code> namespace will automatically be provisioned in Team A's AWS account (222233334444). No credentials in manifests, no manual role switching—just namespaced intent.</p>
<h2>Defining the kro ResourceGraphDefinition</h2>
<p>This is the key abstraction layer. Instead of submitting 15 separate ACK CRs for every new cluster, you submit one <code>EksCluster</code> CR that kro decomposes automatically:</p>
<pre><code class="language-yaml"># rgd-eks-cluster.yaml
apiVersion: kro.run/v1alpha1
kind: ResourceGraphDefinition
metadata:
  name: ekscluster.kro.run
spec:
  schema:
    apiVersion: v1alpha1
    kind: EksCluster
    spec:
      name: string
      region: string | default="us-east-1"
      k8sVersion: string | default="1.32"
      managementAccountId: string  # For Argo CD access entry
      vpc:
        create: boolean | default=true
        vpcId: string | default=""     # Only if vpc.create=false
        privateSubnet1Id: string | default=""
        privateSubnet2Id: string | default=""
      nodeGroup:
        instanceType: string | default="m5.xlarge"
        minSize: integer | default=2
        maxSize: integer | default=10
      addons:
        ebsCsiDriver: boolean | default=true
        efsCsiDriver: boolean | default=false
        karpenter: boolean | default=false

  resources:
  # 1. Cluster IAM Role
  - id: clusterRole
    template:
      apiVersion: iam.services.k8s.aws/v1alpha1
      kind: Role
      metadata:
        namespace: "${schema.spec.name}"
        name: "${schema.spec.name}-cluster-role"
        annotations:
          services.k8s.aws/region: "${schema.spec.region}"
      spec:
        name: "${schema.spec.name}-cluster-role"
        assumeRolePolicyDocument: |
          {
            "Version": "2012-10-17",
            "Statement": [{
              "Effect": "Allow",
              "Principal": {"Service": "eks.amazonaws.com"},
              "Action": "sts:AssumeRole"
            }]
          }
        policies:
        - arn:aws:iam::aws:policy/AmazonEKSClusterPolicy

  # 2. Node IAM Role
  - id: nodeRole
    template:
      apiVersion: iam.services.k8s.aws/v1alpha1
      kind: Role
      metadata:
        namespace: "${schema.spec.name}"
        name: "${schema.spec.name}-node-role"
        annotations:
          services.k8s.aws/region: "${schema.spec.region}"
      spec:
        name: "${schema.spec.name}-node-role"
        assumeRolePolicyDocument: |
          {
            "Version": "2012-10-17",
            "Statement": [{
              "Effect": "Allow",
              "Principal": {"Service": "ec2.amazonaws.com"},
              "Action": "sts:AssumeRole"
            }]
          }
        policies:
        - arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy
        - arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy
        - arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly

  # 3. VPC (conditional - only if vpc.create=true)
  - id: vpc
    includeWhen:
    - "${schema.spec.vpc.create}"
    template:
      apiVersion: ec2.services.k8s.aws/v1alpha1
      kind: VPC
      metadata:
        namespace: "${schema.spec.name}"
        name: "${schema.spec.name}-vpc"
        annotations:
          services.k8s.aws/region: "${schema.spec.region}"
      spec:
        cidrBlock: "10.0.0.0/16"
        enableDNSHostnames: true
        enableDNSSupport: true
        tags:
        - key: Name
          value: "${schema.spec.name}-vpc"

  # 4. EKS Cluster (depends on IAM roles - kro handles ordering via CEL references)
  - id: ekscluster
    template:
      apiVersion: eks.services.k8s.aws/v1alpha1
      kind: Cluster
      metadata:
        namespace: "${schema.spec.name}"
        name: "${schema.spec.name}"
        annotations:
          services.k8s.aws/region: "${schema.spec.region}"
      spec:
        name: "${schema.spec.name}"
        # CEL expression - kro resolves this after clusterRole is created
        roleARN: "${clusterRole.status.ackResourceMetadata.arn}"
        version: "${schema.spec.k8sVersion}"
        accessConfig:
          authenticationMode: "API_AND_CONFIG_MAP"
        resourcesVPCConfig:
          # CEL expressions extract generated field (VPC ID) from vpc CR
          subnetIDs:
          - "${vpc.status.subnets[0]}"
          - "${vpc.status.subnets[1]}"
          endpointPublicAccess: false
          endpointPrivateAccess: true
        computeConfig:
          enabled: true
          nodeRoleARN: "${nodeRole.status.ackResourceMetadata.arn}"

  # 5. Access entry for Argo CD (management cluster)
  - id: accessEntry
    template:
      apiVersion: eks.services.k8s.aws/v1alpha1
      kind: AccessEntry
      metadata:
        namespace: "${schema.spec.name}"
        name: "${schema.spec.name}-argocd-access"
        annotations:
          services.k8s.aws/region: "${schema.spec.region}"
      spec:
        clusterName: "${schema.spec.name}"
        # Allow Argo CD in management cluster to sync into workload cluster
        principalARN: "arn:aws:iam::${schema.spec.managementAccountId}:role/hub-cluster-argocd-controller"
        type: STANDARD
        accessPolicies:
        - accessScope:
            type: "cluster"
          policyARN: "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"

  # 6. Argo CD Secret (registers workload cluster with Argo CD)
  - id: argocdSecret
    template:
      apiVersion: v1
      kind: Secret
      metadata:
        name: "${schema.spec.name}"
        namespace: argocd
        labels:
          argocd.argoproj.io/secret-type: cluster
        annotations:
          managed-by: kro
      type: Opaque
      stringData:
        name: "${schema.spec.name}"
        # ARN-based server URL (EKS Capabilities feature)
        server: "${ekscluster.status.ackResourceMetadata.arn}"
        project: "default"
        config: |
          {
            "awsAuthConfig": {
              "clusterName": "${schema.spec.name}",
              "roleARN": "arn:aws:iam::${schema.spec.managementAccountId}:role/hub-cluster-argocd-controller"
            }
          }
</code></pre>
<p>Apply the ResourceGraphDefinition once to the management cluster. Now anyone with access can provision a new cluster.</p>
<h2>Creating a New Workload Cluster</h2>
<p>With the RGD in place, provisioning a cluster in Team A's account is a single YAML file submitted via PR:</p>
<pre><code class="language-yaml"># clusters/team-a/production.yaml
apiVersion: kro.run/v1alpha1
kind: EksCluster
metadata:
  name: team-a-production
  namespace: cluster-team-a  # Maps to IAMRoleSelector → Team A's account
spec:
  name: team-a-production
  region: us-east-1
  k8sVersion: "1.32"
  managementAccountId: "111122223333"
  vpc:
    create: true
  nodeGroup:
    instanceType: m5.xlarge
    minSize: 2
    maxSize: 20
  addons:
    ebsCsiDriver: true
    efsCsiDriver: false
    karpenter: true
</code></pre>
<p>PR merged → Argo CD applies this CR → kro decomposes it → ACK creates AWS resources in Team A's account → Argo CD bootstraps add-ons → Cluster is live.</p>
<h2>Bootstrapping Add-ons with Argo CD ApplicationSets</h2>
<p>Once Argo CD knows about the workload cluster (via the Secret created by kro), <code>ApplicationSets</code> with the Cluster Generator auto-install add-ons:</p>
<pre><code class="language-yaml"># applicationset-addons.yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: eks-addons-aws-ebs-csi
  namespace: argocd
spec:
  generators:
  - clusters:
      selector:
        matchLabels:
          # Only clusters that have EBS CSI enabled
          addon/ebs-csi-driver: "true"

  template:
    metadata:
      name: "{{name}}-ebs-csi-driver"
    spec:
      project: default
      source:
        repoURL: https://kubernetes-sigs.github.io/aws-ebs-csi-driver
        chart: aws-ebs-csi-driver
        targetRevision: 2.26.0
        helm:
          values: |
            controller:
              serviceAccount:
                annotations:
                  eks.amazonaws.com/role-arn: {{metadata.annotations.ebs-csi-role-arn}}
      destination:
        server: "{{server}}"  # Populated from cluster Secret
        namespace: kube-system
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
---
# Karpenter ApplicationSet
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: eks-addons-karpenter
  namespace: argocd
spec:
  generators:
  - clusters:
      selector:
        matchLabels:
          addon/karpenter: "true"

  template:
    metadata:
      name: "{{name}}-karpenter"
    spec:
      project: default
      source:
        repoURL: public.ecr.aws/karpenter
        chart: karpenter
        targetRevision: 1.1.0
        helm:
          values: |
            settings:
              clusterName: "{{metadata.annotations.cluster-name}}"
              clusterEndpoint: "{{metadata.annotations.cluster-endpoint}}"
            serviceAccount:
              annotations:
                eks.amazonaws.com/role-arn: "{{metadata.annotations.karpenter-role-arn}}"
      destination:
        server: "{{server}}"
        namespace: kube-system
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
</code></pre>
<p>Any new cluster with the <code>addon/karpenter: "true"</code> label on its Argo CD Secret automatically gets Karpenter installed. No manual steps.</p>
<h2>Cross-Account kubectl Access Without Static Credentials</h2>
<p>Once clusters are provisioned, engineers need kubectl access. The right pattern here combines EKS access entries with IAM role assumption—no static credentials anywhere:</p>
<pre><code class="language-yaml"># In each workload account: developer access role
resource "aws_iam_role" "eks_developer_access" {
  name = "eks-developer-access"

  # Trust policy: any principal from management account with developer tag can assume
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        AWS = "arn:aws:iam::${var.management_account_id}:root"
      }
      Action = "sts:AssumeRole"
      Condition = {
        StringEquals = {
          "aws:PrincipalTag/eks-role" = "developer"
        }
      }
    }]
  })
}
</code></pre>
<p>Add access entry to the workload cluster (via ACK, so it's in Git):</p>
<pre><code class="language-yaml"># Access entry for developer role (include in cluster RGD or separate ApplicationSet)
apiVersion: eks.services.k8s.aws/v1alpha1
kind: AccessEntry
metadata:
  namespace: cluster-team-a
  name: team-a-developer-access
  annotations:
    services.k8s.aws/region: us-east-1
spec:
  clusterName: team-a-production
  principalARN: arn:aws:iam::222233334444:role/eks-developer-access
  type: STANDARD
  accessPolicies:
  - accessScope:
      type: namespace
      namespaces:
      - team-a-app
    policyARN: arn:aws:eks::aws:cluster-access-policy/AmazonEKSEditPolicy
</code></pre>
<p>Configure kubectl:</p>
<pre><code class="language-yaml">
# Developer's local setup (management account SSO session)
# Step 1: Assume role in workload account
aws sts assume-role \
  --role-arn arn:aws:iam::222233334444:role/eks-developer-access \
  --role-session-name dev-session \
  --profile management-account

# Step 2: Update kubeconfig using assumed role
aws eks update-kubeconfig \
  --name team-a-production \
  --region us-east-1 \
  --role-arn arn:aws:iam::222233334444:role/eks-developer-access \
  --profile management-account

# Now kubectl works with Team A's cluster
kubectl get pods -n team-a-app
</code></pre>
<p>For daily use, configure this in <code>~/.aws/config</code> for transparent role chaining:</p>
<pre><code class="language-yaml"># ~/.aws/config
[profile team-a-dev]
role_arn = arn:aws:iam::222233334444:role/eks-developer-access
source_profile = management-sso
role_session_name = dev-session

[profile team-b-dev]
role_arn = arn:aws:iam::444455556666:role/eks-developer-access
source_profile = management-sso
role_session_name = dev-session
</code></pre>
<pre><code class="language-yaml">
# Switch clusters cleanly
AWS_PROFILE=team-a-dev kubectl get pods -n team-a-app
AWS_PROFILE=team-b-dev kubectl get pods -n team-b-app
</code></pre>
<h2>The End-to-End Platform Workflow</h2>
<p>Putting this all together, here's the complete lifecycle of a cluster in your multi-account platform:</p>
<p><strong>Day 0: Platform setup (once)</strong></p>
<ol>
<li><p>Enable EKS Dashboard trusted access in AWS Organizations</p>
</li>
<li><p>Deploy management cluster</p>
</li>
<li><p>Install ACK controllers, kro, Argo CD via EKS Capabilities</p>
</li>
<li><p>Deploy workload account IAM role via CloudFormation StackSets</p>
</li>
<li><p>Define <code>EksCluster</code> ResourceGraphDefinition in kro</p>
</li>
<li><p>Configure Argo CD ApplicationSets for each add-on</p>
</li>
</ol>
<p><strong>Day 1: New team onboarding</strong></p>
<ol>
<li><p>Create AWS account for new team (Control Tower or Account Factory)</p>
</li>
<li><p>CloudFormation StackSets automatically deploys <code>ack</code> IAM role in new account</p>
</li>
<li><p>Developer submits PR: one <code>EksCluster</code> YAML file</p>
</li>
<li><p>PR merged → cluster provisioned in team's account → add-ons installed</p>
</li>
<li><p>IAM access entry + developer role = kubectl access without manual setup</p>
</li>
</ol>
<p><strong>Day N: Ongoing operations</strong></p>
<ul>
<li><p>EKS Dashboard shows fleet-wide version status → platform team prioritizes upgrades</p>
</li>
<li><p>Version upgrade: change <code>k8sVersion</code> in cluster YAML → PR → automated update</p>
</li>
<li><p>New add-on rollout: update ApplicationSet → propagates to all clusters with label</p>
</li>
<li><p>Cost allocation: each cluster in isolated account → AWS Cost Explorer per team</p>
</li>
</ul>
<p><strong>Incident response</strong>:</p>
<ul>
<li><p>EKS Dashboard identifies affected cluster accounts from central view</p>
</li>
<li><p>kubectl access via pre-configured role assumption (no emergency credential creation)</p>
</li>
<li><p>Audit trail: all cluster changes are Git commits with author, timestamp, reviewer</p>
</li>
</ul>
<h2>GitOps for Multi-Account Configuration Management</h2>
<p>When you have 40 clusters across 12 accounts, configuration drift is inevitable without discipline. The GitOps pattern solves this:</p>
<pre><code class="language-plaintext">git-ops-repo/
├── clusters/
│   ├── team-a/
│   │   ├── production.yaml     # EksCluster CR
│   │   └── staging.yaml
│   ├── team-b/
│   │   └── production.yaml
│   └── platform/
│       └── shared-services.yaml
├── add-ons/
│   ├── karpenter/
│   │   └── applicationset.yaml
│   └── aws-load-balancer-controller/
│       └── applicationset.yaml
└── policies/
    ├── network-policies/
    │   └── applicationset.yaml     # Applied to all clusters
    └── pod-security/
        └── applicationset.yaml
</code></pre>
<p>Every change to any cluster across any account is a PR. Every PR has a reviewer. Every merge has a timestamp. When your security team asks "when was network policy X applied to Team B's cluster?"—it's a <code>git log</code> command.</p>
<h2>Best Practices and Operational Recommendations</h2>
<p><strong>1. Use StackSets for account bootstrapping, not manual IAM setup</strong></p>
<p>The <code>ack</code> IAM role and any other cross-account prerequisites should be deployed via StackSets targeted at your Workload OU. New accounts created via Control Tower/Account Factory automatically get these resources.</p>
<p><strong>2. Use access entries, not aws-auth ConfigMap</strong></p>
<p>Access entries are managed via EKS API (and therefore via ACK, in Git). The aws-auth ConfigMap is a manual file that can lock you out if misconfigured. For all new clusters, use <code>authenticationMode: API</code>.</p>
<p><strong>3. Tag clusters consistently from day one</strong></p>
<p>EKS Dashboard filtering is only as good as your tags. Establish a tagging standard before you have 40 clusters:</p>
<pre><code class="language-yaml">metadata:
  labels:
    team: "team-a"
    environment: "production"
    cost-center: "eng-platform"
    tier: "critical"
    addon/ebs-csi-driver: "true"
    addon/karpenter: "true"
</code></pre>
<p><strong>4. Don't use kro for production clusters yet</strong></p>
<p>kro's APIs are not stable as of early 2026. Use it for development/staging environments where you can tolerate API changes. For production clusters, use ACK directly with explicit dependency ordering or continue with Terraform for the infrastructure layer.​</p>
<p><strong>5. Test restore of cluster configurations quarterly</strong></p>
<p>Multi-account architecture means more blast radius for misconfiguration. Test restoring cluster state from Git manifests in DR environments. Combine with AWS Backup (EKS integration) for persistent volume coverage.</p>
<h2>Wrapping Up</h2>
<p>The three years between GitHub issue #1708 opening and today represent a genuine maturation of AWS's multi-account EKS story. The two problem areas that issue identified—visibility and lifecycle management—now have production-ready answers.kubernetes+2</p>
<p><strong>EKS Dashboard</strong> solves visibility cleanly: five minutes of setup, no extra cost, immediate fleet-wide insight into version compliance, add-on health, and upgrade cost forecasting. If you're running more than three EKS clusters across multiple accounts, enable it today—there's no reason not to.aws.amazon+1</p>
<p><strong>ACK + kro + Argo CD</strong> solves lifecycle management with a powerful but more operationally complex pattern. The value—cluster provisioning as a YAML PR, automatic add-on bootstrapping, GitOps audit trail—is substantial. The investment in understanding ACK cross-account IAM, kro ResourceGraphDefinitions, and Argo CD ApplicationSets is real. Start with the pattern in non-production accounts, stabilize your RGDs, then graduate to production.</p>
<p>For most platform teams, the journey looks like: Enable EKS Dashboard immediately → Standardize cross-account kubectl access via access entries → Implement ACK for cluster provisioning → Adopt kro once it reaches API stability → End up with a hub-and-spoke platform where new teams go from "we need a cluster" to "cluster is live with all add-ons" in the time it takes to review a PR.</p>
<p>That's the platform the AWS containers roadmap team was building toward when they opened that issue in 2022. It's largely here now.</p>
]]></content:encoded></item><item><title><![CDATA[Hybrid Container Orchestration at Scale: Amazon ECS Anywhere with Cross-Region Load Balancing]]></title><description><![CDATA[There's a moment every platform engineer at a regulated enterprise eventually faces. The cloud migration project has been greenlit, the architecture is…]]></description><link>https://aditmodi.hashnode.dev/hybrid-container-orchestration-at-scale-amazon-ecs-anywhere-with-cross-region-load-balancing</link><guid isPermaLink="true">https://aditmodi.hashnode.dev/hybrid-container-orchestration-at-scale-amazon-ecs-anywhere-with-cross-region-load-balancing</guid><category><![CDATA[AWS]]></category><dc:creator><![CDATA[Adit Modi]]></dc:creator><pubDate>Sat, 14 Feb 2026 05:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/de9f3d50-9530-4327-a82f-c1a625639732.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>There's a moment every platform engineer at a regulated enterprise eventually faces. The cloud migration project has been greenlit, the architecture is sound, and then legal lands the requirement: "Certain patient records cannot leave this country." Or finance says: "That trading system must remain on-premises for sub-millisecond latency." Or manufacturing says: "The factory floor systems cannot have dependency on internet connectivity."</p>
<p>Your clean "lift everything to AWS" plan now has asterisks. You need to run workloads on-premises <em>and</em> in the cloud, manage them consistently, and ensure that a regional cloud outage doesn't take down your entire application.</p>
<p>This is the problem Amazon ECS Anywhere with cross-region load balancing solves. Not in a hand-wavy "hybrid cloud" marketing sense, but concretely: the same ECS API, the same task definitions, the same IAM roles, the same deployment pipelines—whether your container is running on an EC2 instance in us-east-1 or on a bare metal server in your Frankfurt data center. And when you front the whole thing with AWS Global Accelerator, you get intelligent, health-aware traffic distribution that automatically routes users away from failures, whether those failures are in AWS or on-premises.</p>
<p>This post is an end-to-end implementation guide. We'll build the full architecture: on-premises servers registered as ECS external instances, tasks deployed via the <code>EXTERNAL</code> launch type, Traefik running as a sidecar load balancer for inbound traffic (working around ECS Anywhere's native load balancing limitation), Network Load Balancers in AWS regions, and Global Accelerator tying it all together.</p>
<h2>Understanding the Problem Space</h2>
<p>Before we touch a terminal, let's understand exactly why this architecture exists and what limitations it's working around.</p>
<h2>What ECS Anywhere Does</h2>
<p>ECS Anywhere extends the ECS control plane beyond AWS infrastructure. Every ECS cluster has two types of capacity:</p>
<ul>
<li><p><strong>EC2/Fargate capacity</strong>: Instances AWS manages or serverless compute</p>
</li>
<li><p><strong>External instances</strong>: Your own servers, VMs, or bare metal machines registered to the cluster via AWS Systems Manager</p>
</li>
</ul>
<p>When you register an external instance, two agents get installed on it:oneuptime+1</p>
<ul>
<li><p><strong>SSM Agent</strong>: Handles authentication to AWS APIs. Maintains IAM credentials rotated every 30 minutes using a hardware fingerprint—even if connectivity is lost temporarily, credentials are refreshed after reconnection</p>
</li>
<li><p><strong>ECS Agent</strong>: Manages container lifecycle, reports task status back to the ECS control plane, pulls from ECR (or any accessible registry)</p>
</li>
</ul>
<p>From ECS's perspective, an external instance in your Frankfurt data center is just another container instance in the cluster. You deploy tasks to it using <code>--launch-type EXTERNAL</code>. The control plane doesn't care where the instance physically is.</p>
<h2>The Critical Limitation You Must Understand</h2>
<p>ECS Anywhere has one significant networking constraint that shapes this entire architecture: <strong>Elastic Load Balancing is not supported for external instances</strong>.</p>
<p>This means you cannot attach an AWS-managed ALB or NLB directly to tasks running on external instances. The <code>awsvpc</code> network mode is also unsupported—external instances must use <code>bridge</code>, <code>host</code>, or <code>none</code> network modes.</p>
<p>This isn't a bug—it's a fundamental constraint. AWS load balancers are VPC-native constructs. They register targets by IP or instance ID within a VPC. Your on-premises server has a private RFC1918 address in your own network, not a VPC. There's no way for an AWS load balancer to register it as a target without it being VPC-connected.</p>
<p>This is why the architecture uses <strong>Traefik as an on-premises load balancer</strong>—a container running on an external instance that load balances traffic across other containers on the same or nearby external instances, using the ECS API to discover task endpoints dynamically.</p>
<h2>The Cross-Region Load Balancing Layer</h2>
<p>The on-premises Traefik proxy handles inbound distribution within a location. The cross-region layer handles traffic distribution <em>between</em> locations (on-premises, us-east-1, eu-west-1).</p>
<p>AWS Global Accelerator provides two static Anycast IP addresses that serve as the global entry point for your application. Traffic arrives at the nearest AWS edge location and is forwarded over the AWS global backbone to the healthiest, closest endpoint—whether that's an NLB in us-east-1 or your on-premises servers behind a Network Load Balancer in a different region.aws.amazon+1</p>
<p>The full traffic path for a user in Germany accessing your application:</p>
<pre><code class="language-plaintext">User in Frankfurt
    → AWS Edge (Frankfurt PoP)        [Anycast routing]
    → AWS Global Accelerator          [Health check routing]
    → NLB in eu-west-1  OR            [If AWS tasks healthy]
    → On-premises Frankfurt servers   [If direct routing configured]
        → Traefik proxy               [On-prem load balancing]
            → ECS task containers     [Actual workload]
</code></pre>
<h2>Architecture Components</h2>
<h2>Component Overview</h2>
<p>The full architecture has six layers:</p>
<p><strong>1. On-Premises External Instances</strong>  </p>
<p>Physical or virtual servers in your data center, registered as ECS external instances via SSM. Runs the ECS agent, SSM agent, Docker, and your workload containers.</p>
<p><strong>2. Traefik Reverse Proxy (On-Premises)</strong>  </p>
<p>A containerized reverse proxy running as an ECS service on an external instance dedicated to load balancing. Discovers ECS task endpoints via the ECS API and routes inbound HTTP/HTTPS traffic across containers.</p>
<p><strong>3. ECS Cluster (Unified)</strong>  </p>
<p>A single ECS cluster containing both external instances (on-premises) and EC2/Fargate capacity (AWS). The same cluster runs <code>EXTERNAL</code> launch type tasks and <code>EC2</code>/<code>FARGATE</code> launch type tasks.</p>
<p><strong>4. Network Load Balancers (Per Region)</strong>  </p>
<p>NLBs in each AWS region front the cloud-based ECS tasks. Unlike the external instance limitation, AWS ECS services on EC2/Fargate integrate natively with NLBs.</p>
<p><strong>5. AWS Global Accelerator</strong>  </p>
<p>Two static Anycast IPs serve as the global front door. Global Accelerator routes traffic to NLBs (and optionally, to registered on-premises endpoints) based on health, latency, and traffic dial configuration.aws.amazon+1</p>
<p><strong>6. AWS Systems Manager (Control Channel)</strong>  </p>
<p>All communication between the ECS control plane and external instances flows through SSM. This means your on-premises servers only need <em>outbound</em> HTTPS access to AWS endpoints—no inbound firewall rules, no VPN required for the control plane.oneuptime+1</p>
<h2>Network Connectivity Requirements</h2>
<p>Your on-premises instances need outbound HTTPS (TCP 443) access to these AWS endpoints:</p>
<pre><code class="language-yaml"># ECS control plane
ecs-a-*.{region}.amazonaws.com     # Task management
ecs-t-*.{region}.amazonaws.com     # Task and container metrics
ecs.{region}.amazonaws.com         # ECS service endpoint

# SSM (control channel)
ssm.{region}.amazonaws.com
ec2messages.{region}.amazonaws.com
ssmmessages.{region}.amazonaws.com

# For pulling container images
ecr.{region}.amazonaws.com
*.dkr.ecr.{region}.amazonaws.com
</code></pre>
<p>This is important: no VPN, no AWS Direct Connect, no site-to-site connectivity is required for the ECS control plane. Direct Connect or VPN is only needed if your on-premises containers need to reach VPC-native resources like RDS databases or private APIs.</p>
<h2>Step 1: IAM Roles and Permissions</h2>
<p>Everything starts with IAM. We need roles for external instances and for Traefik to call ECS APIs.</p>
<h2>ECS Anywhere Instance Role</h2>
<pre><code class="language-yaml"># Create the trust policy (SSM assumes this role)
cat &gt; ecs-anywhere-trust-policy.json &lt;&lt; 'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "ssm.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF

# Create the IAM role
aws iam create-role \
  --role-name ecsAnywhereInstanceRole \
  --assume-role-policy-document file://ecs-anywhere-trust-policy.json \
  --description "Role assumed by SSM on ECS Anywhere external instances"

# Attach the two required managed policies
aws iam attach-role-policy \
  --role-name ecsAnywhereInstanceRole \
  --policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore

aws iam attach-role-policy \
  --role-name ecsAnywhereInstanceRole \
  --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerServiceforEC2Role
</code></pre>
<p>The <code>AmazonSSMManagedInstanceCore</code> policy allows the instance to communicate with Systems Manager. <code>AmazonEC2ContainerServiceforEC2Role</code> allows the ECS agent to register instances, pull task metadata, and report container status.</p>
<h2>Traefik Task Role</h2>
<p>Traefik needs read access to ECS APIs to discover container endpoints:[</p>
<pre><code class="language-yaml"># Create the Traefik task policy
cat &gt; traefik-ecs-policy.json &lt;&lt; 'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "TraefikECSReadAccess",
      "Effect": "Allow",
      "Action": [
        "ecs:ListClusters",
        "ecs:DescribeClusters",
        "ecs:ListTasks",
        "ecs:DescribeTasks",
        "ecs:DescribeContainerInstances",
        "ecs:DescribeTaskDefinition",
        "ec2:DescribeInstances",
        "ssm:DescribeInstanceInformation"
      ],
      "Resource": ["*"]
    }
  ]
}
EOF

aws iam create-policy \
  --policy-name TraefikECSReadAccess \
  --policy-document file://traefik-ecs-policy.json

# Create the task execution role trust policy
cat &gt; task-trust-policy.json &lt;&lt; 'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "ecs-tasks.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF

# Create Traefik task role
aws iam create-role \
  --role-name traefik-task-role \
  --assume-role-policy-document file://task-trust-policy.json

# Attach the policy
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
aws iam attach-role-policy \
  --role-name traefik-task-role \
  --policy-arn arn:aws:iam::${ACCOUNT_ID}:policy/TraefikECSReadAccess

# Task execution role (for pulling images, CloudWatch Logs)
aws iam create-role \
  --role-name ecsTaskExecutionRole \
  --assume-role-policy-document file://task-trust-policy.json

aws iam attach-role-policy \
  --role-name ecsTaskExecutionRole \
  --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
</code></pre>
<p>Note that <code>ssm:DescribeInstanceInformation</code> is required by Traefik to map SSM instance IDs to IP addresses for ECS Anywhere external instances. Without this, Traefik can discover tasks but cannot resolve their actual network addresses.</p>
<h2>Step 2: Create the ECS Cluster</h2>
<p>Create a single cluster that will contain both external instances and cloud-based capacity:</p>
<pre><code class="language-yaml"># Create the cluster with CloudWatch Container Insights enabled
aws ecs create-cluster \
  --cluster-name hybrid-production \
  --settings name=containerInsights,value=enabled \
  --tags key=Environment,value=production key=Architecture,value=hybrid

# Verify cluster creation
aws ecs describe-clusters \
  --clusters hybrid-production \
  --query 'clusters[0].{Name:clusterName,Status:status,ActiveServices:activeServicesCount}'
</code></pre>
<p>This single cluster will show all capacity—external instances from your data center and EC2/Fargate from AWS—in one unified view.</p>
<h2>Step 3: Register External Instances</h2>
<p>Each on-premises server that will run ECS tasks must be registered. This uses the SSM activation mechanism.aws.amazon+1</p>
<h2>Generate the Activation</h2>
<pre><code class="language-yaml"># Create SSM activation
# This generates an activation code and ID that you run on the on-premises server
aws ssm create-activation \
  --iam-role ecsAnywhereInstanceRole \
  --registration-limit 20 \
  --default-instance-name "ecs-external-onprem" \
  --description "ECS Anywhere external instances for hybrid cluster" \
  --tags "Key=Location,Value=frankfurt-dc" "Key=Environment,Value=production" \
  --expiration-date "2026-12-31T00:00:00Z" \
  --region us-east-1

# Output:
# {
#   "ActivationId": "abc12345-1234-1234-1234-abc1234567890",
#   "ActivationCode": "codeXXXXXXXXXXXX"
# }
</code></pre>
<p>Store the <code>ActivationId</code> and <code>ActivationCode</code>—you'll need them on each on-premises server.</p>
<h2>Install Agents on On-Premises Servers</h2>
<p>SSH into each on-premises server and run the ECS Anywhere installation script:aws.amazon+1</p>
<pre><code class="language-yaml"># On each on-premises server (Ubuntu 22/24, RHEL 9, Amazon Linux 2023)

# Download and run the ECS Anywhere installation script
curl -o "/tmp/ecs-anywhere-install.sh" \
  "https://amazon-ecs-agent.s3.amazonaws.com/ecs-anywhere-install-latest.sh"

chmod +x /tmp/ecs-anywhere-install.sh

# Install with activation credentials
sudo bash /tmp/ecs-anywhere-install.sh \
  --region us-east-1 \
  --cluster hybrid-production \
  --activation-id abc12345-1234-1234-1234-abc1234567890 \
  --activation-code codeXXXXXXXXXXXX
</code></pre>
<p>The script performs the following automatically:</p>
<ol>
<li><p>Installs Docker if not present</p>
</li>
<li><p>Installs and configures the SSM agent</p>
</li>
<li><p>Registers the instance with Systems Manager using the activation credentials</p>
</li>
<li><p>Installs the ECS container agent</p>
</li>
<li><p>Configures the ECS agent to communicate with the cluster</p>
</li>
<li><p>Starts both agents as system services</p>
</li>
</ol>
<p>After the script completes, verify the instance is registered:</p>
<pre><code class="language-yaml"># On the AWS side - check SSM managed instances
aws ssm describe-instance-information \
  --filters "Key=tag:Location,Values=frankfurt-dc" \
  --query 'InstanceInformationList[*].{ID:InstanceId,Ping:PingStatus,IP:IPAddress}'

# Check ECS cluster capacity
aws ecs list-container-instances \
  --cluster hybrid-production \
  --filter "attribute:ecs.capability.external exists"

# Describe external instances
aws ecs describe-container-instances \
  --cluster hybrid-production \
  --container-instances $(aws ecs list-container-instances \
    --cluster hybrid-production \
    --filter "attribute:ecs.capability.external exists" \
    --query 'containerInstanceArns' \
    --output text) \
  --query 'containerInstances[*].{ID:containerInstanceArn,Status:status,CPU:registeredResources[?name=CPU].integerValue|[0],Mem:registeredResources[?name=MEMORY].integerValue|[0]}'
</code></pre>
<h2>Configure Custom Attributes for Task Placement</h2>
<p>Custom attributes allow you to target specific tasks to specific external instances—the key mechanism for putting Traefik on dedicated "load balancer" instances:​</p>
<pre><code class="language-yaml"># Assign load balancer role to dedicated instance
aws ecs put-attributes \
  --cluster hybrid-production \
  --attributes name=role,value=loadbalancer,targetType=container-instance,targetId=&lt;INSTANCE_ARN_1&gt;

# Assign webserver role to application instances
aws ecs put-attributes \
  --cluster hybrid-production \
  --attributes name=role,value=webserver,targetType=container-instance,targetId=&lt;INSTANCE_ARN_2&gt;

aws ecs put-attributes \
  --cluster hybrid-production \
  --attributes name=role,value=webserver,targetType=container-instance,targetId=&lt;INSTANCE_ARN_3&gt;

# Add location attribute for topology-aware placement
aws ecs put-attributes \
  --cluster hybrid-production \
  --attributes name=location,value=frankfurt-dc,targetType=container-instance,targetId=&lt;INSTANCE_ARN_1&gt;

aws ecs put-attributes \
  --cluster hybrid-production \
  --attributes name=location,value=frankfurt-dc,targetType=container-instance,targetId=&lt;INSTANCE_ARN_2&gt;

aws ecs put-attributes \
  --cluster hybrid-production \
  --attributes name=location,value=frankfurt-dc,targetType=container-instance,targetId=&lt;INSTANCE_ARN_3&gt;
</code></pre>
<p>You can also configure the necessary routing rules on the external instances to support IAM roles for tasks when not using an ECS-optimized AMI:</p>
<pre><code class="language-yaml"># Required on external instances for IAM roles for tasks to work
sudo sysctl -w net.ipv4.conf.all.route_localnet=1
sudo iptables -t nat -A PREROUTING -p tcp -d 169.254.170.2 --dport 80 \
  -j DNAT --to-destination 127.0.0.1:51679
sudo iptables -t nat -A OUTPUT -d 169.254.170.2 -p tcp -m tcp --dport 80 \
  -j REDIRECT --to-ports 51679

# Persist across reboots
sudo sh -c "echo 'net.ipv4.conf.all.route_localnet=1' &gt;&gt; /etc/sysctl.conf"
sudo iptables-save &gt; /etc/iptables/rules.v4
</code></pre>
<h2>Step 4: Deploy Traefik as the On-Premises Load Balancer</h2>
<p>Since ECS Anywhere doesn't support Elastic Load Balancing, we deploy Traefik as a containerized reverse proxy on a dedicated external instance. Traefik uses its built-in ECS provider to automatically discover containers, making it a natural fit for this pattern.</p>
<h2>Understanding Traefik's ECS Provider</h2>
<p>Traefik's ECS provider:</p>
<ul>
<li><p>Calls the ECS API (<code>ListTasks</code>, <code>DescribeTasks</code>, <code>DescribeContainerInstances</code>) every 15 seconds</p>
</li>
<li><p>For external instances, also calls <code>ssm:DescribeInstanceInformation</code> to resolve instance IPs</p>
</li>
<li><p>Builds its routing table from the discovered task IPs and ports</p>
</li>
<li><p>Uses Docker labels on task containers to determine routing rules</p>
</li>
<li><p>Updates routes dynamically as tasks start and stop—no Traefik restart needed</p>
</li>
</ul>
<p>This means when ECS scales your application tasks up or down, Traefik's routing table updates within 15 seconds.</p>
<h2>Traefik Task Definition</h2>
<pre><code class="language-json">// task-definition-traefik.json
{
  "family": "TraefikLoadBalancer",
  "cpu": "512",
  "memory": "256",
  "networkMode": "host",
  "requiresCompatibilities": ["EXTERNAL"],
  "taskRoleArn": "arn:aws:iam::123456789012:role/traefik-task-role",
  "executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
  "containerDefinitions": [
    {
      "name": "traefik",
      "image": "traefik:v3.2",
      "essential": true,
      "portMappings": [
        {
          "hostPort": 80,
          "protocol": "tcp",
          "containerPort": 80
        },
        {
          "hostPort": 443,
          "protocol": "tcp",
          "containerPort": 443
        },
        {
          "hostPort": 8080,
          "protocol": "tcp",
          "containerPort": 8080
        }
      ],
      "command": [
        "--log.level=INFO",
        "--accesslog=true",
        "--api.dashboard=true",
        "--api.insecure=true",
        "--providers.ecs.ecsAnywhere=true",
        "--providers.ecs.region=us-east-1",
        "--providers.ecs.autoDiscoverClusters=true",
        "--providers.ecs.clusters=hybrid-production",
        "--providers.ecs.exposedByDefault=false",
        "--entrypoints.web.address=:80",
        "--entrypoints.websecure.address=:443",
        "--entrypoints.web.http.redirections.entrypoint.to=websecure",
        "--entrypoints.web.http.redirections.entrypoint.scheme=https",
        "--certificatesresolvers.letsencrypt.acme.email=ops@example.com",
        "--certificatesresolvers.letsencrypt.acme.storage=/data/acme.json",
        "--certificatesresolvers.letsencrypt.acme.tlschallenge=true",
        "--metrics.prometheus=true",
        "--ping=true"
      ],
      "mountPoints": [
        {
          "sourceVolume": "traefik-data",
          "containerPath": "/data"
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/traefik-loadbalancer",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "traefik"
        }
      },
      "healthCheck": {
        "command": ["CMD", "traefik", "healthcheck", "--ping"],
        "interval": 30,
        "timeout": 5,
        "retries": 3,
        "startPeriod": 10
      }
    }
  ],
  "volumes": [
    {
      "name": "traefik-data",
      "host": {
        "sourcePath": "/opt/traefik/data"
      }
    }
  ],
  "placementConstraints": [
    {
      "type": "memberOf",
      "expression": "attribute:role == loadbalancer"
    },
    {
      "type": "memberOf",
      "expression": "attribute:location == frankfurt-dc"
    }
  ]
}
</code></pre>
<p><code>   </code></p>
<p>Key configuration choices:</p>
<ul>
<li><p><code>networkMode: host</code>: Required for Traefik to bind ports 80/443 directly on the external instance's network interface. In <code>bridge</code> mode, external traffic wouldn't reach the container</p>
</li>
<li><p><code>ecsAnywhere=true</code>: Tells Traefik's ECS provider to include external instances in discovery, not just EC2 instances</p>
</li>
<li><p><code>exposedByDefault=false</code>: Only route to containers that explicitly opt-in via Docker labels—prevents accidentally routing to the ECS agent or SSM agent containers</p>
</li>
<li><p><code>--ping=true</code>: Enables the <code>/ping</code> health endpoint, used later for Global Accelerator health checks</p>
</li>
<li><p><strong>TLS with Let's Encrypt</strong>: Handles certificate issuance and renewal automatically</p>
</li>
</ul>
<p>Create the CloudWatch log group first:</p>
<pre><code class="language-yaml">aws logs create-log-group --log-group-name /ecs/traefik-loadbalancer
aws logs put-retention-policy \
  --log-group-name /ecs/traefik-loadbalancer \
  --retention-in-days 30
</code></pre>
<p>Register and deploy:</p>
<pre><code class="language-yaml">aws ecs register-task-definition \
  --cli-input-json file://task-definition-traefik.json

aws ecs create-service \
  --cluster hybrid-production \
  --service-name TraefikLoadBalancer \
  --task-definition TraefikLoadBalancer:1 \
  --desired-count 1 \
  --launch-type EXTERNAL \
  --deployment-configuration "minimumHealthyPercent=100,maximumPercent=200" \
  --deployment-controller type=ECS
</code></pre>
<p>Verify Traefik is running:</p>
<pre><code class="language-yaml"># Get the external instance IP
INSTANCE_IP=$(aws ssm describe-instance-information \
  --filters "Key=tag:Location,Values=frankfurt-dc" \
  --query 'InstanceInformationList[?contains(InstanceId, mi-)].IPAddress|[0]' \
  --output text)

# Check Traefik dashboard
curl -s http://${INSTANCE_IP}:8080/api/overview | jq '{version:.version, providers:.features.providerPlugins}'

# Check health endpoint
curl -s http://${INSTANCE_IP}:8080/ping
# Expected: OK
</code></pre>
<h2>Step 5: Deploy the Application Tasks on External Instances</h2>
<p>Now deploy your actual application containers. We'll use a sample web API to illustrate the routing configuration:</p>
<pre><code class="language-json">// task-definition-api.json
{
  "family": "WebAPI",
  "cpu": "512",
  "memory": "512",
  "networkMode": "bridge",
  "requiresCompatibilities": ["EXTERNAL"],
  "executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
  "containerDefinitions": [
    {
      "name": "api",
      "image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/web-api:latest",
      "essential": true,
      "portMappings": [
        {
          "hostPort": 0,
          "protocol": "tcp",
          "containerPort": 8080
        }
      ],
      "dockerLabels": {
        "traefik.enable": "true",
        "traefik.http.routers.api.rule": "Hostapi.example.com) || PathPrefix/api)",
        "traefik.http.routers.api.entrypoints": "websecure",
        "traefik.http.routers.api.tls": "true",
        "traefik.http.routers.api.tls.certresolver": "letsencrypt",
        "traefik.http.services.api.loadbalancer.server.port": "8080",
        "traefik.http.services.api.loadbalancer.healthcheck.path": "/health",
        "traefik.http.services.api.loadbalancer.healthcheck.interval": "10s",
        "traefik.http.services.api.loadbalancer.sticky.cookie": "true",
        "traefik.http.services.api.loadbalancer.sticky.cookie.name": "lb_session"
      },
      "environment": [
        {
          "name": "ENVIRONMENT",
          "value": "production"
        },
        {
          "name": "LOCATION",
          "value": "on-premises"
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/web-api-external",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "web-api"
        }
      },
      "healthCheck": {
        "command": ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"],
        "interval": 15,
        "timeout": 5,
        "retries": 3,
        "startPeriod": 30
      }
    }
  ],
  "placementConstraints": [
    {
      "type": "memberOf",
      "expression": "attribute:role == webserver"
    },
    {
      "type": "memberOf",
      "expression": "attribute:location == frankfurt-dc"
    }
  ]
}
</code></pre>
<p>The Docker labels are the critical Traefik configuration:</p>
<ul>
<li><p><code>traefik.enable=true</code>: Opts this container into Traefik routing (required since we set <code>exposedByDefault=false</code>)</p>
</li>
<li><p><code>traefik.http.routers.api.rule</code>: Host and/or path matching rule for routing inbound requests</p>
</li>
<li><p><code>traefik.http.services.api.loadbalancer.server.port</code>: Which container port Traefik proxies to</p>
</li>
<li><p><code>traefik.http.services.api.loadbalancer.healthcheck.path</code>: Traefik health checks each container backend before routing to it</p>
</li>
<li><p><code>sticky.cookie</code>: Session affinity via cookie—useful for stateful applications</p>
</li>
</ul>
<p>Register and deploy:</p>
<pre><code class="language-yaml"># Create log group
aws logs create-log-group --log-group-name /ecs/web-api-external

aws ecs register-task-definition \
  --cli-input-json file://task-definition-api.json

# Deploy 4 tasks spread across the 2 webserver instances
aws ecs create-service \
  --cluster hybrid-production \
  --service-name WebAPI-OnPrem \
  --task-definition WebAPI:1 \
  --desired-count 4 \
  --launch-type EXTERNAL \
  --placement-strategy type=spread,field=instanceId \
  --deployment-configuration "minimumHealthyPercent=50,maximumPercent=200"
</code></pre>
<p>The <code>spread</code> placement strategy distributes tasks evenly across all external instances with the <code>webserver</code> attribute, avoiding concentration on a single host.</p>
<h2>Verify Traefik Discovery</h2>
<p>After the tasks start, verify Traefik has discovered them:</p>
<pre><code class="language-yaml"># Check Traefik's view of the backend services
curl -s http://${INSTANCE_IP}:8080/api/http/services | \
  jq '.[] | {name:.name, servers:[.serverStatus|to_entries[]|{url:.key, status:.value}]}'

# Expected output shows 4 task containers as backends:
# {
#   "name": "api@ecs",
#   "servers": [
#     {"url": "http://10.0.1.15:32768", "status": "UP"},
#     {"url": "http://10.0.1.15:32769", "status": "UP"},
#     {"url": "http://10.0.1.16:32768", "status": "UP"},
#     {"url": "http://10.0.1.16:32769", "status": "UP"}
#   ]
# }

# Test round-robin load balancing
for i in 1 2 3 4; do
  curl -s -H "Host: api.example.com" \
    http://${INSTANCE_IP}/api/health | jq -r '.container_id'
done

# Should see 4 different container IDs cycling
</code></pre>
<h2>Step 6: Deploy ECS Tasks in AWS Regions</h2>
<p>For the cloud side, deploy the same application on EC2 or Fargate with native load balancing support.</p>
<h2>Create the AWS-Side Infrastructure with Terraform</h2>
<pre><code class="language-yaml"># main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~&gt; 5.0"
    }
  }
}

# Primary region (us-east-1)
provider "aws" {
  alias  = "primary"
  region = "us-east-1"
}

# Secondary region (eu-west-1)
provider "aws" {
  alias  = "secondary"
  region = "eu-west-1"
}

# VPC for primary region
module "vpc_primary" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~&gt; 5.0"
  providers = { aws = aws.primary }

  name = "hybrid-vpc-primary"
  cidr = "10.10.0.0/16"

  azs             = ["us-east-1a", "us-east-1b", "us-east-1c"]
  private_subnets = ["10.10.1.0/24", "10.10.2.0/24", "10.10.3.0/24"]
  public_subnets  = ["10.10.101.0/24", "10.10.102.0/24", "10.10.103.0/24"]

  enable_nat_gateway = true
  single_nat_gateway = false  # One per AZ for HA
}

# Network Load Balancer - Primary Region
resource "aws_lb" "primary" {
  provider = aws.primary
  name               = "hybrid-nlb-primary"
  internal           = false
  load_balancer_type = "network"
  subnets            = module.vpc_primary.public_subnets

  enable_deletion_protection       = true
  enable_cross_zone_load_balancing = true

  tags = {
    Environment = "production"
    Region      = "us-east-1"
  }
}

resource "aws_lb_target_group" "primary_api" {
  provider = aws.primary
  name        = "hybrid-api-primary-tg"
  port        = 443
  protocol    = "TLS"
  target_type = "ip"
  vpc_id      = module.vpc_primary.vpc_id

  health_check {
    enabled             = true
    healthy_threshold   = 2
    unhealthy_threshold = 2
    timeout             = 10
    interval            = 30
    protocol            = "HTTPS"
    path                = "/health"
  }

  tags = { Environment = "production" }
}

resource "aws_lb_listener" "primary_https" {
  provider          = aws.primary
  load_balancer_arn = aws_lb.primary.arn
  port              = 443
  protocol          = "TLS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = aws_acm_certificate.primary.arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.primary_api.arn
  }
}

# ECS Cluster - Primary Region
resource "aws_ecs_cluster" "primary" {
  provider = aws.primary
  name     = "hybrid-production"

  setting {
    name  = "containerInsights"
    value = "enabled"
  }
}

# ECS Service for Web API - Primary Region (Fargate)
resource "aws_ecs_service" "primary_api" {
  provider        = aws.primary
  name            = "WebAPI-Cloud"
  cluster         = aws_ecs_cluster.primary.id
  task_definition = aws_ecs_task_definition.api_fargate.arn
  desired_count   = 3
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = module.vpc_primary.private_subnets
    security_groups  = [aws_security_group.ecs_tasks.id]
    assign_public_ip = false
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.primary_api.arn
    container_name   = "api"
    container_port   = 8080
  }

  deployment_configuration {
    minimum_healthy_percent = 100
    maximum_percent         = 200

    deployment_circuit_breaker {
      enable   = true
      rollback = true
    }
  }

  lifecycle {
    ignore_changes = [desired_count]
  }
}
</code></pre>
<p>The Fargate task definition for the cloud-based deployment uses <code>awsvpc</code> networking mode (supported in AWS, unlike ECS Anywhere external instances):</p>
<pre><code class="language-yaml">// task-definition-api-fargate.json
{
  "family": "WebAPI-Fargate",
  "cpu": "512",
  "memory": "1024",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
  "taskRoleArn": "arn:aws:iam::123456789012:role/web-api-task-role",
  "containerDefinitions": [
    {
      "name": "api",
      "image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/web-api:latest",
      "essential": true,
      "portMappings": [
        {
          "containerPort": 8080,
          "protocol": "tcp"
        }
      ],
      "environment": [
        {
          "name": "ENVIRONMENT",
          "value": "production"
        },
        {
          "name": "LOCATION",
          "value": "aws-cloud"
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/web-api-cloud",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "web-api"
        }
      },
      "healthCheck": {
        "command": ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"],
        "interval": 15,
        "timeout": 5,
        "retries": 3
      }
    }
  ]
}
</code></pre>
<h2>Step 7: Configure AWS Global Accelerator</h2>
<p>Global Accelerator is where the cross-region load balancing comes together. It provides two static Anycast IPs and routes traffic based on health and latency.</p>
<h2>Create the Accelerator</h2>
<pre><code class="language-yaml"># Create Global Accelerator
aws globalaccelerator create-accelerator \
  --name hybrid-ecs-accelerator \
  --ip-address-type IPV4 \
  --enabled \
  --tags Key=Application,Value=HybridECS Key=Environment,Value=Production \
  --region us-west-2  # Global Accelerator is global but managed from us-west-2

# Get the static IP addresses (use these in your DNS)
ACCELERATOR_ARN=$(aws globalaccelerator list-accelerators \
  --query 'Accelerators[?Name=hybrid-ecs-accelerator].AcceleratorArn' \
  --output text \
  --region us-west-2)

aws globalaccelerator describe-accelerator \
  --accelerator-arn ${ACCELERATOR_ARN} \
  --query 'Accelerator.IpSets[0].IpAddresses' \
  --region us-west-2
# Returns two static IP addresses - put these in your DNS A records
</code></pre>
<h2>Create the Listener</h2>
<pre><code class="language-yaml"># Create listener on port 443
aws globalaccelerator create-listener \
  --accelerator-arn ${ACCELERATOR_ARN} \
  --protocol TCP \
  --port-ranges FromPort=443,ToPort=443 \
  --client-affinity SOURCE_IP \
  --region us-west-2

LISTENER_ARN=$(aws globalaccelerator list-listeners \
  --accelerator-arn ${ACCELERATOR_ARN} \
  --query 'Listeners[0].ListenerArn' \
  --output text \
  --region us-west-2)
</code></pre>
<p><code>CLIENT_AFFINITY=SOURCE_IP</code> ensures the same client IP always routes to the same endpoint region during a session—important for stateful applications.</p>
<h2>Create Endpoint Groups</h2>
<p>Each endpoint group corresponds to a region and contains endpoints (NLBs):</p>
<pre><code class="language-yaml"># Endpoint group for primary region (us-east-1)
aws globalaccelerator create-endpoint-group \
  --listener-arn ${LISTENER_ARN} \
  --endpoint-group-region us-east-1 \
  --endpoint-configurations \
    EndpointId=${NLB_PRIMARY_ARN},Weight=100,ClientIPPreservationEnabled=true \
  --traffic-dial-percentage 100 \
  --health-check-protocol HTTPS \
  --health-check-path /health \
  --health-check-port 443 \
  --health-check-interval-seconds 10 \
  --threshold-count 2 \
  --region us-west-2

# Endpoint group for secondary region (eu-west-1)
aws globalaccelerator create-endpoint-group \
  --listener-arn ${LISTENER_ARN} \
  --endpoint-group-region eu-west-1 \
  --endpoint-configurations \
    EndpointId=${NLB_SECONDARY_ARN},Weight=100,ClientIPPreservationEnabled=true \
  --traffic-dial-percentage 100 \
  --health-check-protocol HTTPS \
  --health-check-path /health \
  --health-check-port 443 \
  --health-check-interval-seconds 10 \
  --threshold-count 2 \
  --region us-west-2
</code></pre>
<p><code>TrafficDialPercentage</code>: Controls what percentage of traffic goes to each endpoint group. During a deployment or failover test, you can dial us-east-1 down to 0% to drain traffic without removing the endpoint group entirely.</p>
<p><code>ClientIPPreservationEnabled</code>: Forwards the original client IP to the NLB target—required if your application needs real client IPs for logging or rate limiting.</p>
<h2>Health Check Behavior</h2>
<p>Global Accelerator health checks work at the endpoint group level:aws.amazon+1</p>
<ul>
<li><p>If an NLB's health check fails consistently (<code>threshold-count</code> times), Global Accelerator stops sending traffic to that endpoint group</p>
</li>
<li><p>Traffic automatically shifts to the next healthiest endpoint group based on routing policy</p>
</li>
<li><p>When the failing endpoint recovers, traffic can shift back automatically</p>
</li>
</ul>
<p>This is your cross-region failover mechanism. A regional outage that takes down the us-east-1 NLB will cause Global Accelerator to shift 100% of traffic to eu-west-1 within ~30 seconds (10s health check × 2 failures + propagation time).</p>
<h2>Step 8: Connecting On-Premises to Global Accelerator</h2>
<p>Here's the subtlety that the AWS Solutions Guidance solves in a specific way: how does on-premises traffic participate in the Global Accelerator routing?</p>
<p>There are two patterns:</p>
<h2>Pattern A: On-Premises as an NLB Target (Recommended for Direct Connect customers)</h2>
<p>If you have AWS Direct Connect or Site-to-Site VPN, you can register your on-premises Traefik instance as a target in an NLB target group using its private IP. The NLB's target group must be configured with <code>target_type=ip</code> and the on-premises IPs must be reachable from the VPC:</p>
<pre><code class="language-yaml"># Register on-premises Traefik instance as NLB target
# Requires Direct Connect or VPN for VPC connectivity
aws elbv2 register-targets \
  --target-group-arn ${TG_ARN} \
  --targets \
    Id=10.100.1.10,Port=443 \   # On-premises Traefik instance 1
    Id=10.100.1.11,Port=443     # On-premises Traefik instance 2 (HA)
</code></pre>
<p>The NLB health-checks these IPs. If they're healthy, NLB distributes traffic across both cloud Fargate tasks and on-premises Traefik (which further distributes to ECS external instance tasks).</p>
<h2>Pattern B: Separate On-Premises Endpoint Group</h2>
<p>Register your on-premises public IPs or Elastic IPs directly as endpoints in Global Accelerator (not going through an NLB):</p>
<pre><code class="language-yaml"># Register on-premises public IP directly in Global Accelerator
aws globalaccelerator create-endpoint-group \
  --listener-arn ${LISTENER_ARN} \
  --endpoint-group-region us-east-1 \
  --endpoint-configurations \
    EndpointId=${ON_PREM_ELASTIC_IP_ALLOCATION_ID},Weight=50,ClientIPPreservationEnabled=false \
  --traffic-dial-percentage 30 \
  --health-check-protocol HTTPS \
  --health-check-path /ping \
  --health-check-port 8080 \
  --region us-west-2
</code></pre>
<p>The <code>/ping</code> endpoint is served by Traefik's built-in health check (<code>--ping=true</code>). Global Accelerator probes this to determine if the on-premises location is reachable and healthy.</p>
<p>The <code>traffic-dial-percentage=30</code> routes 30% of global traffic to this on-premises endpoint group, 70% to the AWS regions. This is your lever for gradual traffic shifting and testing.</p>
<h2>Traffic Weighting Within Endpoint Groups</h2>
<p>Within an endpoint group you can weight individual endpoints:</p>
<pre><code class="language-yaml"># During canary deployment of new on-premises instance
aws globalaccelerator update-endpoint-group \
  --endpoint-group-arn ${ENDPOINT_GROUP_ARN} \
  --endpoint-configurations \
    EndpointId=${NLB_ARN},Weight=80 \        # 80% to existing NLB
    EndpointId=${NEW_NLB_ARN},Weight=20 \    # 20% to new deployment
  --region us-west-2
</code></pre>
<h2>Step 9: Automated Failover and Disaster Recovery</h2>
<p>The architecture's DR capabilities come from combining ECS service auto-recovery, NLB health checks, and Global Accelerator routing.aws.amazon+1</p>
<h2>Failure Scenario 1: Single ECS Task Failure</h2>
<p><strong>Trigger</strong>: An application container crashes on an external instance.</p>
<p><strong>Response</strong>:</p>
<ol>
<li><p>ECS health check (from task definition <code>healthCheck</code>) detects failure</p>
</li>
<li><p>ECS stops the unhealthy task and starts a replacement on a healthy external instance</p>
</li>
<li><p>Traefik detects the container is no longer in <code>DescribeTasks</code> results at next polling interval (~15s)</p>
</li>
<li><p>Traefik removes the unhealthy backend from the routing pool</p>
</li>
<li><p><strong>User impact</strong>: Requests that hit Traefik during the ~15s detection window may fail; subsequent requests route to healthy containers</p>
</li>
</ol>
<p><strong>Recovery time</strong>: 15–30 seconds</p>
<h2>Failure Scenario 2: External Instance Failure</h2>
<p><strong>Trigger</strong>: An on-premises server hosting ECS tasks becomes unreachable.</p>
<p><strong>Response</strong>:</p>
<ol>
<li><p>ECS agent stops heartbeating → instance marked DRAINING after timeout</p>
</li>
<li><p>ECS reschedules tasks from the failed instance onto remaining external instances</p>
</li>
<li><p>Traefik stops routing to containers on the failed instance when they're no longer in DescribeTasks</p>
</li>
</ol>
<p><strong>Important constraint</strong>: ECS can only reschedule to external instances in the same cluster with the <code>webserver</code> attribute. If you only have 2 external instance webservers and both fail simultaneously, ECS has nowhere to reschedule. This is why the multi-region cloud capacity matters—cloud Fargate tasks continue serving via Global Accelerator even if on-premises capacity is completely lost.</p>
<h2>Failure Scenario 3: On-Premises Location Failure</h2>
<p><strong>Trigger</strong>: Complete data center outage. All external instances unreachable.</p>
<p><strong>Response</strong>:</p>
<ol>
<li><p>Global Accelerator health checks fail for the on-premises endpoint group</p>
</li>
<li><p>After <code>threshold-count</code> consecutive failures (2 × 10s = ~20s), traffic dial shifts to 0%</p>
</li>
<li><p>100% of traffic routes to AWS NLBs in us-east-1 and eu-west-1</p>
</li>
<li><p>Cloud ECS services (Fargate) continue serving normally</p>
</li>
</ol>
<p><strong>User impact</strong>: ~20–30 seconds for Global Accelerator to detect and reroute. During this window, ~30% of requests (those dialed to on-premises) fail.</p>
<p><strong>Recovery</strong>: When the data center comes back, SSM agent reconnects (credential rotation resumes), ECS agent re-registers external instances, tasks restart, Traefik discovers them, Global Accelerator health checks succeed, traffic dials back up automatically.</p>
<h2>Failure Scenario 4: AWS Region Failure</h2>
<p><strong>Trigger</strong>: us-east-1 regional outage.</p>
<p><strong>Response</strong>:</p>
<ol>
<li><p>Global Accelerator health checks fail for us-east-1 endpoint group</p>
</li>
<li><p>Traffic shifts to eu-west-1 NLB and/or on-premises endpoints (if healthy)</p>
</li>
<li><p>ECS control plane impact: if the ECS service is in us-east-1, you cannot deploy new tasks to external instances registered to that cluster from the ECS console</p>
</li>
</ol>
<p><strong>Recovery consideration</strong>: Your ECS cluster and external instance registration are tied to a specific region. If us-east-1 is your cluster region and it's unavailable, you lose the ability to manage external instances through ECS—even though those instances are physically on-premises and running. This is why for maximum DR, you should register your on-premises instances to clusters in multiple regions.</p>
<h2>DR Architecture for Maximum Resilience</h2>
<pre><code class="language-shell"># Register the same on-premises server to clusters in multiple regions
# (An external instance can only be in ONE cluster at a time)
# So you need separate physical servers registered to each regional cluster

# Germany DC Server Pool A → Registered to ECS cluster in eu-west-1
# Germany DC Server Pool B → Registered to ECS cluster in us-east-1
# Traefik on each pool routes to its local containers

# Both Traefik endpoints registered in Global Accelerator
# If eu-west-1 ECS control plane fails, Pool B (controlled by us-east-1) still serves
</code></pre>
<h2>Step 10: Monitoring and Observability</h2>
<p>With workloads spanning on-premises and multiple AWS regions, centralized observability is essential.</p>
<h2>CloudWatch Container Insights</h2>
<p>ECS external instances send metrics to CloudWatch like any other ECS instance:</p>
<pre><code class="language-yaml"># View Container Insights metrics for the hybrid cluster
aws cloudwatch get-metric-statistics \
  --namespace "ECS/ContainerInsights" \
  --metric-name "CpuUtilized" \
  --dimensions Name=ClusterName,Value=hybrid-production \
  --start-time "$(date -u -d '1 hour ago' '+%Y-%m-%dT%H:%M:%S')" \
  --end-time "$(date -u '+%Y-%m-%dT%H:%M:%S')" \
  --period 300 \
  --statistics Average

# Separate metrics for external vs cloud instances
aws cloudwatch get-metric-statistics \
  --namespace "ECS/ContainerInsights" \
  --metric-name "CpuUtilized" \
  --dimensions \
    Name=ClusterName,Value=hybrid-production \
    Name=LaunchType,Value=EXTERNAL \
  --start-time "$(date -u -d '1 hour ago' '+%Y-%m-%dT%H:%M:%S')" \
  --end-time "$(date -u '+%Y-%m-%dT%H:%M:%S')" \
  --period 300 \
  --statistics Average
</code></pre>
<h2>CloudWatch Alarms for Cross-Location Health</h2>
<pre><code class="language-yaml"># Global Accelerator health alarm
resource "aws_cloudwatch_metric_alarm" "onprem_endpoint_health" {
  alarm_name          = "onprem-global-accelerator-health"
  comparison_operator = "LessThanThreshold"
  evaluation_periods  = 2
  metric_name         = "EndpointsHealthy"
  namespace           = "AWS/GlobalAccelerator"
  period              = 60
  statistic           = "Average"
  threshold           = 1
  alarm_description   = "On-premises endpoint group has no healthy endpoints"
  treat_missing_data  = "breaching"

  dimensions = {
    Accelerator    = aws_globalaccelerator_accelerator.main.id
    EndpointGroup  = aws_globalaccelerator_endpoint_group.onprem.id
    Listener       = aws_globalaccelerator_listener.main.id
  }

  alarm_actions = [aws_sns_topic.operations.arn]
}

# ECS external instance connectivity
resource "aws_cloudwatch_metric_alarm" "external_instance_count" {
  alarm_name          = "ecs-external-instance-count-low"
  comparison_operator = "LessThanThreshold"
  evaluation_periods  = 3
  metric_name         = "RegisteredContainerInstancesCount"
  namespace           = "ECS/ContainerInsights"
  period              = 60
  statistic           = "Average"
  threshold           = 2  # Alert if fewer than 2 external instances are healthy
  alarm_description   = "ECS cluster has fewer than 2 external instances registered"
  treat_missing_data  = "breaching"

  dimensions = {
    ClusterName = "hybrid-production"
  }

  alarm_actions = [aws_sns_topic.operations.arn]
}

# Traefik backend health
resource "aws_cloudwatch_log_metric_filter" "traefik_backend_down" {
  name           = "traefik-backend-down"
  pattern        = "[timestamp, level=\"error\", msg=\"No servers available yet\"]"
  log_group_name = "/ecs/traefik-loadbalancer"

  metric_transformation {
    name      = "TraefikBackendDown"
    namespace = "HybridECS/Traefik"
    value     = "1"
  }
}

resource "aws_cloudwatch_metric_alarm" "traefik_backend_errors" {
  alarm_name          = "traefik-no-backends"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 1
  metric_name         = "TraefikBackendDown"
  namespace           = "HybridECS/Traefik"
  period              = 60
  statistic           = "Sum"
  threshold           = 0

  alarm_actions = [aws_sns_topic.operations.arn]
}
</code></pre>
<h2>Traefik Prometheus Metrics</h2>
<p>Traefik exposes Prometheus metrics when <code>--metrics.prometheus=true</code> is set. Scrape them from your on-premises Prometheus or AWS Managed Prometheus:</p>
<pre><code class="language-yaml"># prometheus-scrape-config.yaml (for Prometheus running on-premises or via AMP)
scrape_configs:
  - job_name: 'traefik-onprem'
    static_configs:
      - targets:
          - '10.100.1.10:8080'
          - '10.100.1.11:8080'
    metrics_path: /metrics
    scheme: http

    # Key metrics to alert on:
    # traefik_backend_requests_total
    # traefik_backend_request_duration_seconds_histogram_bucket
    # traefik_backend_server_up (0 = down, 1 = up)
    # traefik_service_requests_total{code="5xx"}
</code></pre>
<h2>Deployment Pipeline Considerations</h2>
<p>When you have a unified ECS cluster with external and cloud capacity, your CI/CD pipeline can deploy to both locations from a single <code>update-service</code> call.</p>
<pre><code class="language-yaml"># Deploy new image to both on-premises and cloud tasks simultaneously
NEW_IMAGE="123456789012.dkr.ecr.us-east-1.amazonaws.com/web-api:v2.0.0"

# Update the task definition
NEW_TD=$(aws ecs register-task-definition \
  --cli-input-json "$(aws ecs describe-task-definition \
    --task-definition WebAPI \
    --query 'taskDefinition' | \
    jq --arg image "$NEW_IMAGE" \
    '.containerDefinitions[0].image = $image | del(.taskDefinitionArn,.revision,.status,.requiresAttributes,.compatibilities,.registeredAt,.registeredBy)'
  )" \
  --query 'taskDefinition.taskDefinitionArn' \
  --output text)

# Deploy on-premises tasks
aws ecs update-service \
  --cluster hybrid-production \
  --service WebAPI-OnPrem \
  --task-definition $NEW_TD \
  --force-new-deployment

# Deploy cloud tasks (primary region)
aws ecs update-service \
  --cluster hybrid-production \
  --service WebAPI-Cloud \
  --task-definition $NEW_TD \
  --force-new-deployment
</code></pre>
<p>For zero-downtime deployments on external instances:</p>
<ul>
<li><p>ECS rolling update: <code>minimumHealthyPercent=50</code> ensures at least half the tasks remain running during deployment</p>
</li>
<li><p>Traefik health checks (<code>healthcheck.path=/health</code>) prevent traffic from routing to tasks that are starting or not yet healthy</p>
</li>
<li><p>Combined: new tasks start → Traefik waits for health check → old tasks drain → zero downtime</p>
</li>
</ul>
<h2>Cost Model</h2>
<p>Understanding the cost structure helps justify this architecture versus alternatives:</p>
<p><strong>Included in ECS Anywhere pricing</strong>:</p>
<ul>
<li><p>ECS Anywhere charges $0.01025 per hour per registered external instance</p>
</li>
<li><p>This covers ECS control plane management—the SSM connectivity, scheduling, deployment management</p>
</li>
</ul>
<p><strong>Not included (your existing costs)</strong>:</p>
<ul>
<li><p>The on-premises hardware itself (leveraging existing investment)</p>
</li>
<li><p>Your data center's power and networking</p>
</li>
</ul>
<p><strong>AWS cost additions for this architecture</strong>:</p>
<ul>
<li><p>Global Accelerator: ~\(0.025/GB for data processed + \)0.025/hour per accelerator</p>
</li>
<li><p>NLBs per region: ~$0.008/hour + LCU charges</p>
</li>
<li><p>CloudWatch Container Insights: ~$0.50 per instance/month</p>
</li>
<li><p>ECR image storage and data transfer (external instances pull images from ECR)</p>
</li>
</ul>
<p><strong>The cost value proposition</strong>: You use existing on-premises hardware investment for compute-heavy workloads that must remain on-premises (compliance), while paying AWS only for the control plane management and cross-region routing—not for the underlying compute.</p>
<h2>Operational Runbook Snippets</h2>
<p>A few operational tasks that come up frequently:</p>
<p><strong>Drain an external instance for maintenance</strong>:</p>
<pre><code class="language-yaml"># Gracefully drain tasks before taking the instance offline
aws ecs update-container-instances-state \
  --cluster hybrid-production \
  --container-instances &lt;INSTANCE_ARN&gt; \
  --status DRAINING

# Wait for all tasks to move off
watch -n 10 "aws ecs describe-container-instances \
  --cluster hybrid-production \
  --container-instances &lt;INSTANCE_ARN&gt; \
  --query 'containerInstances[0].{Status:status, RunningTasks:runningTasksCount, PendingTasks:pendingTasksCount}'"
</code></pre>
<p><strong>Deregister an external instance</strong>:</p>
<pre><code class="language-yaml"># Deregister from ECS
aws ecs deregister-container-instance \
  --cluster hybrid-production \
  --container-instance &lt;INSTANCE_ARN&gt; \
  --force

# Deregister from SSM
aws ssm deregister-managed-instance \
  --instance-id &lt;SSM_INSTANCE_ID&gt;
</code></pre>
<p><strong>Emergency traffic failover to cloud-only</strong>:</p>
<pre><code class="language-yaml"># Zero out traffic dial for on-premises endpoint group
aws globalaccelerator update-endpoint-group \
  --endpoint-group-arn ${ONPREM_ENDPOINT_GROUP_ARN} \
  --traffic-dial-percentage 0 \
  --region us-west-2

# Verify all traffic going to cloud regions
aws cloudwatch get-metric-statistics \
  --namespace AWS/GlobalAccelerator \
  --metric-name NewFlowCount \
  --dimensions Name=Accelerator,Value=${ACCELERATOR_ID} \
  --start-time "$(date -u -d '5 minutes ago' '+%Y-%m-%dT%H:%M:%S')" \
  --end-time "$(date -u '+%Y-%m-%dT%H:%M:%S')" \
  --period 60 \
  --statistics Sum
</code></pre>
<p><code>   </code></p>
<h2>When to Use This Architecture</h2>
<p>This pattern is right for you when you have a genuine requirement for both locations—not as a stepping stone to full cloud migration:</p>
<p><strong>Strong fit</strong>:</p>
<ul>
<li><p><strong>Data residency/sovereignty</strong>: Regulations prohibit certain data leaving a geography, but you still want unified orchestration</p>
</li>
<li><p><strong>Latency-critical edge workloads</strong>: Manufacturing, retail POS, industrial IoT where cloud round-trip adds unacceptable latency</p>
</li>
<li><p><strong>Brownfield hybrid</strong>: Existing on-premises infrastructure with multi-year depreciation that isn't economically justified to retire yet</p>
</li>
<li><p><strong>Compliance-driven split</strong>: Some tiers (web frontend) in cloud, others (database, PII processing) on-premises</p>
</li>
</ul>
<p><strong>Poor fit</strong>:</p>
<ul>
<li><p>If you're planning to be fully in AWS within 18 months—the operational overhead of hybrid isn't worth it for a transitional state; use Direct Connect + standard ECS instead</p>
</li>
<li><p>If your on-premises servers don't have reliable internet connectivity (SSM requires stable outbound HTTPS)</p>
</li>
<li><p>If your workloads need <code>awsvpc</code> networking mode (not supported on external instances)</p>
</li>
</ul>
<h2>Wrapping Up</h2>
<p>The architecture this guidance implements is a serious, production-grade answer to an old problem: you have infrastructure in multiple places, some of it non-negotiably on-premises, and you need consistent container management and global traffic distribution across all of it.</p>
<p>Amazon ECS Anywhere removes the "different tools for different places" problem. You write one task definition, one service definition, one deployment pipeline. Traefik fills the gap that native ECS load balancing doesn't cover for external instances. Global Accelerator provides the routing intelligence to stitch it all together with automatic failover.aws.amazon+2</p>
<p>The moving parts—SSM activation, Traefik's ECS provider, Global Accelerator endpoint groups, traffic dials—each require understanding before you trust them in production. But once the architecture is in place and you've run your first DR drill (route 100% traffic to cloud, verify application health, restore), you'll have something genuinely rare: a hybrid infrastructure that you can actually sleep through an on-premises outage with.</p>
<hr />
<p><em>All implementation code and infrastructure patterns in this post are based on the</em> <a href="https://aws.amazon.com/solutions/guidance/building-a-hybrid-architecture-using-amazon-ecs-anywhere-with-cross-region-load-balancing/"><em>AWS Solutions Library Guidance for Building a Hybrid Architecture Using Amazon ECS Anywhere with Cross-Region Load Balancing</em></a> <em>and the</em> <a href="https://github.com/aws-solutions-library-samples/guidance-for-building-hybrid-architecture-using-amazon-ecs-anywhere-with-cross-region-load-balancing"><em>accompanying GitHub reference implementation</em></a><em>. The Traefik ECS Anywhere load balancing pattern references the</em> <a href="https://aws.amazon.com/blogs/containers/implementing-application-load-balancing-of-amazon-ecs-anywhere-workloads-using-traefik-proxy/"><em>AWS Containers blog post by Cameron Senese</em></a><em>. The ECS Anywhere networking constraints and agent behavior are documented in the</em> <a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-anywhere.html"><em>Amazon ECS Developer Guide</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Your GPU Training Job Has Been Silently Deadlocking for Months — And Your Scheduler Is the Reason]]></title><description><![CDATA[How the default Kubernetes scheduler, Karpenter, and Volcano create a three-way conflict that wastes GPU hours and nobody warns you about. I've been…]]></description><link>https://aditmodi.hashnode.dev/your-gpu-training-job-has-been-silently-deadlocking-for-months-and-your-scheduler-is-the-reason</link><guid isPermaLink="true">https://aditmodi.hashnode.dev/your-gpu-training-job-has-been-silently-deadlocking-for-months-and-your-scheduler-is-the-reason</guid><category><![CDATA[AWS]]></category><category><![CDATA[EKS]]></category><dc:creator><![CDATA[Adit Modi]]></dc:creator><pubDate>Sat, 07 Feb 2026 05:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/a01e9194-5bc9-4ee5-8434-a464a77e2a48.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>How the default Kubernetes scheduler, Karpenter, and Volcano create a three-way conflict that wastes GPU hours and nobody warns you about.</em></p>
<hr />
<p>I've been running distributed GPU training jobs on EKS for a while now. At some point every team hits the same wall: you submit two training jobs, both need 8 GPUs, your cluster has 16 GPUs free, and nothing starts. No error. No timeout. No alert. Just pods sitting in <code>Pending</code> forever while your GPU nodes report healthy.</p>
<p>For a long time I assumed it was a misconfiguration on my end — wrong node selector, wrong resource request, some RBAC I missed. Then I started digging into what the default Kubernetes scheduler actually does when it sees a distributed training job, and the answer changed how I think about ML infrastructure on EKS entirely.</p>
<p>This post covers the problem, why it happens, and the specific Volcano + Kueue + Karpenter configuration that solves it — including the interaction between all three that nobody documents end-to-end.</p>
<hr />
<h2>The Problem: The Default Scheduler Has No Concept of "All or Nothing"</h2>
<p>The Kubernetes default scheduler was designed for services. Schedule 3 replicas out of 5? Fine — the service degrades gracefully but keeps running. Schedule 6 workers out of 8 for a PyTorch DDP job? The job is dead. Every worker sits idle waiting for the rendezvous that never comes because the other 2 workers are stuck.</p>
<p>The default scheduler processes pods <strong>one at a time, greedily</strong>, with no concept that a group of pods lives or dies together. Here is exactly what happens when you submit two 8-GPU jobs to a 16-GPU cluster simultaneously:</p>
<pre><code class="language-yaml">Job A needs: pods A1–A8 (8 GPUs total)
Job B needs: pods B1–B8 (8 GPUs total)
Cluster has: 16 GPUs available

What the scheduler does:
  A1 scheduled → claims GPU node 1
  A2 scheduled → claims GPU node 2
  B1 scheduled → claims GPU node 3   ← interleaved
  B2 scheduled → claims GPU node 4
  A3, A4 scheduled → nodes 5, 6
  B3, B4 scheduled → nodes 7, 8
  ...
  A5, A6, A7, A8 → Pending (no GPU nodes left)
  B5, B6, B7, B8 → Pending (no GPU nodes left)

Final state:
  16 GPUs allocated → 0 work done
  Both jobs waiting for each other to release
  No error. No timeout. No alert.
</code></pre>
<p>This is not an edge case. It is the <strong>default behaviour</strong> of every Kubernetes cluster the moment you run more than one concurrent distributed training job. And it is completely silent — your GPU nodes show as healthy, your pods show as Pending with <code>Insufficient nvidia.com/gpu</code>, and nothing in your monitoring stack tells you why.</p>
<hr />
<h2>What Gang Scheduling Is, and Why Volcano Implements It Correctly</h2>
<p>Gang scheduling solves this with one rule: <strong>a group of pods either all get scheduled simultaneously, or none of them do.</strong> No partial allocation. No deadlock.</p>
<p>Volcano is a CNCF-graduated batch scheduling system built specifically for this pattern. Its core primitive is the <code>PodGroup</code> — a Kubernetes object that defines an atomic scheduling unit.</p>
<pre><code class="language-yaml">apiVersion: scheduling.volcano.sh/v1beta1
kind: PodGroup
metadata:
  name: pytorch-job-a
spec:
  minMember: 8          # schedule nothing until 8 pods can run simultaneously
  minResources:
    nvidia.com/gpu: "8"
  queue: training-queue
  priorityClassName: high-priority
</code></pre>
<p>When <code>minMember: 8</code> is set, Volcano holds all 8 pods in a virtual queue, waits until 8 GPU slots are simultaneously available, and only then schedules all 8 atomically. Job A gets all 8. Job B waits in queue. Job B's pods never enter a Pending state that blocks Job A. The deadlock is structurally impossible.</p>
<p>Volcano also ships a fair-share queue system — <code>Queue</code> objects with weights and capacities that implement multi-tenant GPU time-sharing, priority preemption, and hierarchical queuing. In theory, you install Volcano, add <code>schedulerName: volcano</code> to your jobs, and the problem is solved.</p>
<p>In practice, you've just created a new class of failures. Because you almost certainly also have Karpenter.</p>
<hr />
<h2>The Volcano + Karpenter Conflict</h2>
<p>Karpenter and Volcano have fundamentally different philosophies about when a pod should exist in Kubernetes, and that conflict surfaces in production in three distinct ways.</p>
<h2>Failure Mode 1 — Karpenter Sees Nothing, Nodes Are Never Provisioned</h2>
<p>Karpenter's entire operating model is reactive: it watches for <code>Pending</code> pods and provisions nodes to satisfy them. Volcano's entire operating model is the opposite: hold pods in a virtual queue <em>before</em> they become Pending, until the gang can be fully satisfied.</p>
<p>The result: when your cluster doesn't have enough existing GPU capacity for a full gang, Volcano holds the pods internally. They never enter Kubernetes <code>Pending</code> state. Karpenter, watching for <code>Pending</code> pods, sees nothing. No nodes are provisioned. The job waits forever — not because of a deadlock, but because the two systems are completely invisible to each other.</p>
<p>The fix requires setting <code>minAvailable</code> in the <code>PodGroup</code> at the Kueue admission layer (covered below) to force pods into Pending state <em>after</em> Volcano determines the gang can be satisfied with newly provisioned nodes — rather than requiring existing capacity only.</p>
<h2>Failure Mode 2 — Karpenter Consolidation Kills a Running Training Job</h2>
<p>This one is more expensive. Your training job is 3 hours in. Karpenter's consolidation logic notices that 2 of your GPU nodes are running at 55% GPU utilisation — natural for distributed training during checkpoint saves, data loading, and NCCL communication overhead. From Karpenter's perspective, those look like underutilised nodes that can be consolidated.</p>
<p>Karpenter cordons and drains one node. The worker pod on it is evicted. The training job's <code>PodGroup</code> drops below <code>minMember</code>. Volcano marks the entire <code>PodGroup</code> as <code>Unknown</code>. The PyTorch DDP process group fails its heartbeat. Every worker pod crashes.</p>
<p>No alert fires. Karpenter logs <code>consolidated 1 node, saved $X/hr</code>. Your training logs show a NCCL communication timeout. 3 GPU-hours are gone.</p>
<p>The fix is a dedicated Karpenter <code>NodePool</code> for GPU training with a hard consolidation policy:</p>
<pre><code class="language-yaml">apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: gpu-training
spec:
  disruption:
    consolidationPolicy: WhenEmpty     # only consolidate truly empty nodes
    consolidateAfter: Never            # never consolidate occupied training nodes
    budgets:
    - nodes: "0"                       # zero disruptions during business hours
      schedule: "0 6 * * 1-5"         # Mon–Fri 6am IST
      duration: 16h
    - nodes: "10%"                     # allow limited cleanup overnight
      schedule: "0 22 * * *"
      duration: 8h
  template:
    metadata:
      labels:
        workload-type: gpu-training
    spec:
      requirements:
      - key: karpenter.k8s.aws/instance-family
        operator: In
        values: ["p4d", "p5", "g5"]
      - key: karpenter.sh/capacity-type
        operator: In
        values: ["on-demand"]          # never Spot for multi-hour training runs
      - key: topology.kubernetes.io/zone
        operator: In
        values: ["ap-south-1a"]        # single AZ for EFA/NVLink locality
</code></pre>
<p><code>consolidationPolicy: WhenEmpty</code> is the critical field. It tells Karpenter: only reclaim a GPU node if it has zero running pods. Since training workers are always running during a job, Karpenter will not touch them. Your 3-hour run completes.</p>
<h2>Failure Mode 3 — Topology Constraints Break Instance Diversity</h2>
<p>Volcano's <code>networkTopology</code> can request that all pods in a <code>PodGroup</code> land within the same rack or Availability Zone — mandatory for NVLink bandwidth on <code>p4d</code> clusters and EFA-based collective communication on <code>p5</code>. Karpenter's instance diversity heuristics do the opposite: spread provisioning across AZs for resilience. These requirements are directly contradictory.</p>
<p>The resolution is constraining the training <code>NodePool</code> to a single AZ (shown above) alongside a matching <code>PodGroup</code> topology hint:</p>
<pre><code class="language-yaml">apiVersion: scheduling.volcano.sh/v1beta1
kind: PodGroup
metadata:
  name: llama-finetune-v2
spec:
  minMember: 8
  networkTopology:
    mode: HardConstraint
    highBandwidthMultiNodeAllocation: 2   # require high-bandwidth interconnect for all pods
</code></pre>
<p>For inference <code>NodePools</code>, keep multi-AZ diversity. Only the training <code>NodePool</code> needs single-AZ pinning.</p>
<hr />
<h2>Kueue: The Admission Layer That Makes It All Manageable</h2>
<p>If Volcano is the gang scheduling engine, Kueue is the traffic controller sitting above it that prevents the failure modes above from occurring in the first place.</p>
<p>Kueue operates at admission: jobs don't become pods until Kueue admits them, and Kueue only admits them when the cluster has confirmed quota. The key distinction:</p>
<ul>
<li><p><strong>Kueue</strong> decides <em>whether</em> to admit a job right now</p>
</li>
<li><p><strong>Volcano</strong> decides <em>how</em> to schedule the admitted job atomically</p>
</li>
<li><p><strong>Karpenter</strong> decides <em>what nodes</em> to provision for it</p>
</li>
</ul>
<p>Used together: Kueue holds a <code>PyTorchJob</code> in its queue until 8 GPU nodes are confirmed available (existing or provisionable). Kueue admits it → Volcano schedules atomically → Karpenter provisions any needed nodes → all 8 pods start simultaneously. No partial allocation. No deadlock. No mid-job eviction.</p>
<pre><code class="language-yaml">apiVersion: kueue.x-k8s.io/v1beta1
kind: ClusterQueue
metadata:
  name: training-cluster-queue
spec:
  namespaceSelector: {}
  cohort: ml-platform
  resourceGroups:
  - coveredResources: ["cpu", "memory", "nvidia.com/gpu"]
    flavors:
    - name: gpu-p5
      resources:
      - name: nvidia.com/gpu
        nominalQuota: 64        # 8 × p5.48xlarge, 8 GPUs each
        borrowingLimit: 16      # can borrow from inference queue when idle
  preemption:
    reclaimWithinCohort: LowerPriority
    withinClusterQueue: LowerPriority
</code></pre>
<p>The <code>cohort</code> field is the most powerful and least understood Kueue feature. Multiple <code>ClusterQueues</code> in the same cohort can lend unused quota to each other — your training queue borrows GPU capacity from an idle inference queue during off-hours, and the inference queue reclaims it with priority preemption when a new model deployment arrives. Multi-tenant GPU scheduling, without manual intervention.</p>
<hr />
<h2>Wiring It All Together: The Full PyTorchJob Spec</h2>
<p>With Kueue, Volcano, and Karpenter configured, this is what a production training job looks like on EKS:</p>
<pre><code class="language-yaml">apiVersion: kubeflow.org/v1
kind: PyTorchJob
metadata:
  name: llama-finetune-v2
  namespace: ml-training
  labels:
    kueue.x-k8s.io/queue-name: training-local-queue    # Kueue admission
spec:
  pytorchReplicaSpecs:
    Master:
      replicas: 1
      restartPolicy: OnFailure
      template:
        spec:
          schedulerName: volcano                  # Volcano gang scheduling
          priorityClassName: training-high
          tolerations:
          - key: nvidia.com/gpu
            operator: Exists
            effect: NoSchedule
          containers:
          - name: pytorch
            image: 763104351884.dkr.ecr.ap-south-1.amazonaws.com/pytorch-training:2.3.0-gpu-py311-cu121-ubuntu20.04-ec2
            resources:
              limits:
                nvidia.com/gpu: "8"
                vpc.amazonaws.com/efa: "4"          # EFA devices for NCCL
            env:
            - name: NCCL_DEBUG
              value: "INFO"
            - name: NCCL_SOCKET_IFNAME
              value: "eth0"
            - name: FI_EFA_USE_DEVICE_RDMA
              value: "1"
    Worker:
      replicas: 7                          # 8 total (1 Master + 7 Workers)
      restartPolicy: OnFailure
      template:
        spec:
          schedulerName: volcano
          tolerations:
          - key: nvidia.com/gpu
            operator: Exists
            effect: NoSchedule
          containers:
          - name: pytorch
            image: 763104351884.dkr.ecr.ap-south-1.amazonaws.com/pytorch-training:2.3.0-gpu-py311-cu121-ubuntu20.04-ec2
            resources:
              limits:
                nvidia.com/gpu: "8"
                vpc.amazonaws.com/efa: "4" 
</code></pre>
<p>Three things worth calling out in this spec:</p>
<p><code>schedulerName: volcano</code> — without this, the default scheduler handles your pods and you're back to the deadlock problem. This field is the switch.</p>
<p><code>vpc.amazonaws.com/efa</code> — EFA (Elastic Fabric Adapter) devices for NCCL collective operations. On <code>p4d</code> and <code>p5</code> instances, NCCL over EFA is 4–8x faster than NCCL over standard Ethernet. The EFA device plugin must be installed separately; it is not part of the NVIDIA device plugin.[<a href="https://aws.amazon.com/blogs/machine-learning/accelerate-generative-ai-inference-with-nvidia-dynamo-and-amazon-eks/">aws.amazon</a>]​</p>
<p><code>restartPolicy: OnFailure</code> — with Kueue managing admission, a failed job re-enters the Kueue queue automatically rather than running with whatever partial capacity is available. The job waits for a full gang again.</p>
<hr />
<h2>Installation Order and Namespace Scoping</h2>
<p>Installation order matters and getting it wrong produces errors that are difficult to trace.</p>
<pre><code class="language-yaml"># 1. Karpenter — nodes must exist before schedulers use them
helm install karpenter oci://public.ecr.aws/karpenter/karpenter \
  --namespace karpenter --create-namespace \
  --version "1.3.0"

# 2. Volcano — batch scheduler, replaces default for annotated pods only
helm install volcano volcano-sh/volcano \
  --namespace volcano-system --create-namespace \
  --version "1.10.0"

# 3. Kueue — admission webhook, runs upstream of everything
helm install kueue oci://registry.k8s.io/kueue/charts/kueue \
  --namespace kueue-system --create-namespace \
  --version "0.10.0"

# 4. KubeFlow Training Operator — PyTorchJob CRD
kubectl apply -k "github.com/kubeflow/training-operator/manifests/overlays/standalone"
</code></pre>
<p><strong>Critical:</strong> scope Volcano to GPU namespaces only. Running Volcano as the cluster-wide default scheduler breaks standard <code>Deployments</code> and <code>Services</code> that expect default scheduler behaviour — specifically, Volcano's bin-packing policy removes the spread-across-nodes behaviour that replicas rely on.</p>
<pre><code class="language-yaml"># Label your ML namespaces — jobs in these namespaces use schedulerName: volcano
kubectl label namespace ml-training volcano.sh/queue-name=training-queue
kubectl label namespace ml-inference volcano.sh/queue-name=inference-queue

# Everything else (kube-system, monitoring, etc.) uses the default scheduler
# No label = no Volcano involvement
</code></pre>
<hr />
<h2>Volcano vs Kueue vs Native Kubernetes — Where Things Stand in 2026</h2>
<p>Kubernetes v1.35 (December 2025) shipped <strong>Workload-Aware Scheduling</strong> — native gang scheduling upstream. Worth knowing where everything stands:</p>
<table style="min-width:100px"><colgroup><col style="min-width:25px"></col><col style="min-width:25px"></col><col style="min-width:25px"></col><col style="min-width:25px"></col></colgroup><tbody><tr><th><p></p></th><th><p>Volcano</p></th><th><p>Kueue</p></th><th><p>K8s v1.35 Native</p></th></tr><tr><td><p><strong>Gang scheduling</strong></p></td><td><p>Yes — core, production-proven</p></td><td><p>Via Volcano backend</p></td><td><p>Alpha — not production-ready</p></td></tr><tr><td><p><strong>Fair-share queues</strong></p></td><td><p>Yes — mature</p></td><td><p>Yes — primary purpose</p></td><td><p>No</p></td></tr><tr><td><p><strong>Multi-tenant quota</strong></p></td><td><p>Basic</p></td><td><p>Yes — cohorts + borrowing</p></td><td><p>No</p></td></tr><tr><td><p><strong>EKS maturity</strong></p></td><td><p>Production at scale (Baidu: 300k pods/day)</p></td><td><p>Growing fast</p></td><td><p>Too new</p></td></tr><tr><td><p><strong>Karpenter interaction</strong></p></td><td><p>Needs explicit configuration (this post)</p></td><td><p>Better-behaved</p></td><td><p>Unknown</p></td></tr><tr><td><p><strong>Right for</strong></p></td><td><p>Pure training clusters</p></td><td><p>Mixed training + inference</p></td><td><p>Watch this space</p></td></tr></tbody></table>

<p>My take: <strong>Kueue + Volcano together</strong> is the right production stack for 2026. Kueue handles the multi-tenant admission and quota story. Volcano handles the scheduling atomicity. Karpenter handles the compute. Native Kubernetes gang scheduling is worth watching but is alpha, untested under real multi-tenant load, and lacks the queue management that Kueue provides.</p>
<hr />
<h2>The Three Rules That Prevent All Three Failure Modes</h2>
<p>After everything above, the configuration changes come down to three rules:</p>
<p><strong>Rule 1: Always</strong> <code>consolidationPolicy: WhenEmpty</code> <strong>on GPU training NodePools.</strong> Never let Karpenter make utilisation-based eviction decisions on training nodes. GPU workloads have natural utilisation valleys that look idle but aren't.</p>
<p><strong>Rule 2: Always set</strong> <code>minMember</code> <strong>equal to <em>total</em> replicas, never a subset.</strong> Setting <code>minMember: 4</code> when your job needs 8 workers means Volcano schedules 4 pods and the job hangs waiting for the rest. The whole point of gang scheduling is atomicity — partial gangs defeat the purpose.</p>
<p><strong>Rule 3: Scope Volcano to ML namespaces only.</strong> One <code>schedulerName: volcano</code> annotation in a non-ML deployment can break replica spreading across nodes cluster-wide. Namespace labeling keeps blast radius contained.</p>
<hr />
<h2>Before and After</h2>
<table style="min-width:75px"><colgroup><col style="min-width:25px"></col><col style="min-width:25px"></col><col style="min-width:25px"></col></colgroup><tbody><tr><th><p>Scenario</p></th><th><p>Without Volcano + Kueue</p></th><th><p>With Volcano + Kueue</p></th></tr><tr><td><p>Two concurrent 8-GPU jobs, 16 GPUs available</p></td><td><p>Deadlock — 0 work done indefinitely</p></td><td><p>Kueue queues Job B, Job A runs immediately</p></td></tr><tr><td><p>Karpenter consolidation during 3hr training</p></td><td><p>Job crashes at hour 3, GPU-hours lost</p></td><td><p><code>WhenEmpty</code> policy blocks consolidation</p></td></tr><tr><td><p>Three teams sharing 64 GPUs</p></td><td><p>First-come-first-served, starvation common</p></td><td><p>Fair-share queues with priority preemption</p></td></tr><tr><td><p>New job needs more GPUs than cluster has</p></td><td><p>Pending forever, Karpenter sees nothing</p></td><td><p>Kueue admits after Karpenter provisions full gang</p></td></tr><tr><td><p>Spot interruption mid-training</p></td><td><p>Job fails, manual requeue</p></td><td><p>Kueue requeues automatically, Volcano re-gangs</p></td></tr></tbody></table>

<p>The deadlock was never your code. It was never a Kubernetes bug. It was three systems — each working correctly in isolation — that had never been configured to work together.</p>
]]></content:encoded></item><item><title><![CDATA[Kubernetes for Generative AI Solutions — Book Review]]></title><description><![CDATA[Some books teach you Kubernetes. Some books teach you GenAI. This one does something harder — it shows you what happens when the two collide in production…]]></description><link>https://aditmodi.hashnode.dev/kubernetes-for-gen-ai-book-review</link><guid isPermaLink="true">https://aditmodi.hashnode.dev/kubernetes-for-gen-ai-book-review</guid><category><![CDATA[AWS]]></category><dc:creator><![CDATA[Adit Modi]]></dc:creator><pubDate>Wed, 04 Feb 2026 05:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/4d1c4fca-c57c-455d-bf58-667c90765a12.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Some books teach you Kubernetes. Some books teach you GenAI. This one does something harder — it shows you what happens when the two collide in production, and how to make that collision survivable. Written by Ashok Srirama and Sukirti Gupta, both with deep AWS and cloud-native backgrounds, the tone stays practical and honest throughout. It doesn't romanticize the stack. It respects how expensive, complicated, and operationally demanding running GenAI on Kubernetes actually is.​</p>
<hr />
<h2><strong>What the book actually is</strong></h2>
<p>A production blueprint that treats GenAI workloads on Kubernetes as living systems you operate and pay for — not demos you deploy and forget.​</p>
<p>A plain account of tradeoffs: when to use HPA, VPA, KEDA, or Karpenter — and what each scaling decision costs in GPU spend, cold start time, and operational attention.​</p>
<p>A reminder that observability isn't a finishing touch — it's how you see what your AI workload is doing in a real cluster when users are waiting and GPUs are expensive.​</p>
<hr />
<h2><strong>How it explains the hard parts in simple words</strong></h2>
<p><strong>Containers are the foundation, not the destination.</strong> The book earns the right to talk about GenAI by first explaining why containers matter for AI workloads — reproducibility, portability, and the ability to ship a model and its dependencies together without surprises.​</p>
<p><strong>Kubernetes is a fit for GenAI for specific reasons.</strong> Scheduling GPU nodes, managing heterogeneous workloads, autoscaling based on custom metrics, isolating model serving from training — these aren't generic Kubernetes features, they're exactly what GenAI infrastructure needs.​</p>
<p><strong>Scaling means different things at different layers.</strong> HPA scales on CPU and memory. VPA right-sizes requests over time. KEDA scales on custom signals like queue depth. Karpenter provisions the right node for the right workload. The book explains when each one matters and why using the wrong one at the wrong layer costs you quietly.​</p>
<p><strong>GPU optimization is where cost lives.</strong> MIG partitions a single GPU into isolated slices for smaller models. MPS allows multiple processes to share GPU compute time. Fractional allocation and Spot Instances cut spend dramatically — but only if health checks, resource limits, and interruption handling are boring and reliable first.​</p>
<p><strong>Observability must be tied to the AI workload, not just the node.</strong> Prometheus and Grafana tell you the cluster is healthy. NVIDIA DCGM tells you the GPU is healthy. Neither tells you if your model is drifting, your RAG pipeline is degrading, or your latency is crossing a threshold users actually feel. The book covers all three layers.​</p>
<p><strong>Security is a depth problem, not a checkbox.</strong> Supply chain, host, network, runtime, secrets management, RBAC, service meshes, IAM — each layer adds protection, and the book covers them in order from the outside in rather than as a list of features to enable.​</p>
<p><strong>Delivery should be calm and reversible.</strong> GitOps and Argo CD make GenAI model deployments pull-based and auditable so rollbacks are quick, drift is visible, and the team's attention stays on users rather than deployment state.​</p>
<hr />
<h2><strong>The production stories that teach</strong></h2>
<p>The book doesn't skip the uncomfortable parts — GPU nodes that sit idle because autoscaling was tuned for CPU workloads, RAG pipelines that degrade silently because nobody was watching embedding latency, and cost reports that arrive as surprises because Kubecost wasn't set up until after the first bill. Each problem leads to a decision, a configuration change, or a new guardrail — and those stack into a system that fails more gently and costs less over time.​</p>
<hr />
<h2><strong>Two ideas that quietly run through the book</strong></h2>
<p><strong>Cost is a first-class concern, not an afterthought.</strong> GPUs are expensive. Spot Instances help. Karpenter helps. Kubecost makes the bill readable. But none of that works if workloads aren't sized, scheduled, and monitored with cost in mind from the beginning.​</p>
<p><strong>Foundation first, then production.</strong> The book insists on understanding what the AI workload needs before touching Kubernetes — model behavior, resource patterns, latency requirements, scaling signals. Without that foundation, you're copying YAML and hoping for the best.​</p>
<hr />
<h2><strong>What this looks like in practice</strong></h2>
<ul>
<li><p><strong>Set up observability before scaling</strong> — instrument GPU metrics, model latency, and RAG pipeline health before adding nodes, or you're scaling blindly</p>
</li>
<li><p><strong>Define scaling signals per workload</strong> — CPU-based HPA makes no sense for GPU-bound inference; use KEDA with the right custom metric from the start</p>
</li>
<li><p><strong>Enable MIG or MPS before buying more GPUs</strong> — fractional allocation often doubles effective capacity before any new hardware is needed</p>
</li>
<li><p><strong>Treat Spot Instances as default with proper interruption handling</strong> — the savings are real, but only if your workloads are stateless and your health probes are tight</p>
</li>
<li><p><strong>GitOps your model deployments</strong> — version-controlled, pull-based, auditable deploys make rollbacks safe and fast when a new model version behaves unexpectedly</p>
</li>
<li><p><strong>Run Kubecost from day one</strong> — cost surprises in GPU infrastructure arrive fast and large; visibility early prevents decisions you can't undo​</p>
</li>
</ul>
<hr />
<h2><strong>Who will get the most out of it</strong></h2>
<p>Engineers who know Kubernetes and want to understand what running real GenAI workloads on it actually demands — beyond the YAML.​</p>
<p>DevOps and platform teams who are being asked to support LLM serving, fine-tuning pipelines, and RAG systems and need a production-grade mental model fast.​</p>
<p>Solutions architects and engineering leads who want to understand how infrastructure choices show up as cost, reliability, and team attention — not just technical specs.​</p>
<hr />
<h2><strong>What felt different to read</strong></h2>
<p>The writing is straight: here's the workload, here's what it needs, here's what Kubernetes gives you, here's what it costs if you get it wrong. It never pretends that GPU infrastructure is simple or that "just use managed Kubernetes" answers the hard questions. It also doesn't pick a side in the EKS vs. self-managed debate — it explains when the cloud bill becomes a steering wheel and when owning more of the stack makes sense.​</p>
<hr />
<h2><strong>What this book pushed me to change</strong></h2>
<ul>
<li><p>Define GPU resource requests and limits explicitly before deploying any model — defaults will bankrupt you quietly</p>
</li>
<li><p>Set up NVIDIA DCGM monitoring alongside Prometheus from day one, not after the first GPU incident</p>
</li>
<li><p>Think about MIG partitioning early for multi-tenant model serving instead of over-provisioning nodes</p>
</li>
<li><p>Treat Karpenter node provisioning configuration as a first-class engineering artifact, not an afterthought</p>
</li>
<li><p>Move model deployment pipelines to GitOps so rollbacks are a command, not a conversation</p>
</li>
<li><p>Schedule a weekly GPU utilization review — idle capacity is invisible until the invoice arrives​</p>
</li>
</ul>
<hr />
<h2><strong>Verdict</strong></h2>
<p>If you want a clean Kubernetes tutorial or a standalone GenAI primer, look elsewhere. If you want an honest, production-grade guide to running GenAI workloads on Kubernetes — covering the full journey from first container to cost-optimized, observable, secure, multi-region production — this is worth your full day.</p>
<p>It treats the Kubernetes + GenAI stack with the seriousness it deserves: expensive, operationally complex, and worth getting right before you scale.​</p>
<p><em>📖 Available at</em> <a href="http://packtpub.com"><em><strong>packtpub.com</strong></em></a></p>
]]></content:encoded></item><item><title><![CDATA[SOCI Snapshotter on EKS: Eliminating the 8-Minute GPU Cold Start Nobody Talks About]]></title><description><![CDATA[Your g5 node is ready. Karpenter provisioned it in 90 seconds. Your pod is stuck in ContainerCreating. It will stay there for another 7 minutes. I've been…]]></description><link>https://aditmodi.hashnode.dev/soci-snapshotter-on-eks-eliminating-the-8-minute-gpu-cold-start-nobody-talks-about</link><guid isPermaLink="true">https://aditmodi.hashnode.dev/soci-snapshotter-on-eks-eliminating-the-8-minute-gpu-cold-start-nobody-talks-about</guid><category><![CDATA[AWS]]></category><category><![CDATA[EKS]]></category><dc:creator><![CDATA[Adit Modi]]></dc:creator><pubDate>Sat, 31 Jan 2026 05:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/c63d0e73-0c98-4e55-8018-23221d7dc7cf.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Your</em> <code>g5</code> <em>node is ready. Karpenter provisioned it in 90 seconds. Your pod is stuck in</em> <code>ContainerCreating</code><em>. It will stay there for another 7 minutes.</em></p>
<hr />
<p>I've been running GPU inference workloads on EKS for a while now. You get to a point where you've tuned everything you can see — Karpenter provisions nodes fast, the control plane is healthy, CUDA initialises cleanly. And yet, there is this wall you keep hitting during scale-out events: a gap of six to eight minutes between "node is Ready" and "pod is serving requests."</p>
<p>For a long time I accepted it. The container image is 40–60 GB. Of course it takes time. Then I started looking at exactly where those minutes were going, and the answer was less obvious than I expected.</p>
<hr />
<h2><strong>The Real Anatomy of a GPU Cold Start</strong></h2>
<p>When Karpenter provisions a fresh GPU node and your pod gets scheduled onto it, there are five distinct phases:</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/60a88a245379f65d10b5ebb5/dcd76a2a-e0f9-4776-b4e1-2fa85e2c48d8.png" alt="" style="display:block;margin:0 auto" />

<p>Everything except the <strong>image pull</strong> phase is largely fixed. Karpenter + EC2 Fleet provisioning takes ~90 seconds. Node bootstrapping and joining the cluster takes ~45 seconds. CUDA initialisation and model loading are workload-dependent but relatively stable. The only phase with significant variance — and significant opportunity — is the image pull.</p>
<p>For a 10 GB vLLM AWS Deep Learning Container, the standard containerd pull takes <strong>1 minute 52 seconds</strong>. For a 40–60 GB production ML image with bundled model weights, that number climbs to 6–8 minutes. AWS's own data puts image pull time at <strong>more than 75% of total container startup time</strong> for large images. You are paying for a <code>p4d.24xlarge</code> — roughly ₹900/hour — while it downloads layers.​</p>
<p>This is the problem SOCI Snapshotter solves. But the way most people first encounter it leads them to the wrong mode for GPU workloads, and makes things worse. Let me explain.</p>
<hr />
<h2><strong>SOCI: Two Modes, One Right Answer for GPU</strong></h2>
<p>SOCI (Seekable OCI) is a containerd snapshotter plugin open-sourced by AWS. It has two distinct operating modes that are easy to conflate:​</p>
<table style="min-width:75px"><colgroup><col style="min-width:25px"></col><col style="min-width:25px"></col><col style="min-width:25px"></col></colgroup><tbody><tr><th><p><strong>Mode</strong></p></th><th><p><strong>How it works</strong></p></th><th><p><strong>Right for GPU?</strong></p></th></tr><tr><td><p><strong>Lazy Loading</strong></p></td><td><p>Container starts immediately, layers fetched on-demand as files are accessed</p></td><td><p>❌ No — actively harmful</p></td></tr><tr><td><p><strong>Parallel Pull</strong></p></td><td><p>Full upfront download, but parallelises both download chunks AND layer unpacking</p></td><td><p>✅ Yes — this is the one</p></td></tr></tbody></table>

<p><strong>Why Lazy Loading is the wrong mode for GPU containers:</strong> The premise of lazy loading is that your container only needs a fraction of its image at startup, so why download everything? This works beautifully for a Node.js API server. It works terribly for a vLLM or Triton container. The moment your container process starts, it initialises the CUDA runtime, loads cuDNN, loads NCCL, loads your model weights — all at once, all immediately. With lazy loading enabled, the container process starts, then <em>stalls</em> as the SOCI snapshotter frantically fetches every layer on-demand in response to CUDA's filesystem reads. You end up downloading everything anyway, but through a slow on-demand fetch path with no parallelism, instead of the regular pull. In practice it is often slower than standard containerd.​</p>
<p><strong>Parallel Pull</strong> takes the opposite approach: download everything upfront, but transform <em>how</em> the download happens. This is what you want for GPU workloads.​</p>
<hr />
<h2><strong>How Parallel Pull Actually Works</strong></h2>
<p>Standard containerd has two sequential bottlenecks:​</p>
<p><strong>Phase 1 — Layer Fetch:</strong> Uses a single TCP connection per layer, downloading layers one at a time. Even with a 25 Gbps network interface (standard on <code>g5</code>, <code>p4d</code>, <code>p5</code> instances), a single connection cannot saturate the bandwidth for a large layer. The rest of your network capacity sits idle.</p>
<p><strong>Phase 2 — Layer Unpack:</strong> Takes each downloaded layer and decompresses + extracts it to disk. Serial, one layer at a time. On a 64-core <code>p4d.24xlarge</code>, 63 cores wait while one core unpacks a 5 GB CUDA layer.</p>
<p>SOCI Parallel Pull attacks both phases simultaneously:</p>
<ul>
<li><p><strong>Download:</strong> Splits each layer into configurable chunks (<code>concurrent_download_chunk_size: 16mb</code>) and downloads them via concurrent HTTP range requests. Multiple layers download in parallel. Available bandwidth is actually utilised.</p>
</li>
<li><p><strong>Unpack:</strong> Multiple layers are decompressed and extracted concurrently, scaled to the number of available CPU cores. The I/O-bound serial unpack becomes a CPU-optimised parallel operation.​</p>
</li>
</ul>
<p>The result on a real production benchmark — 10 GB vLLM AWS DLC on <code>m6i.8xlarge</code> with EBS optimised at 16K IOPS and 1000 MiB/s throughput:</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/60a88a245379f65d10b5ebb5/c9821ffc-a197-41b2-b876-709b1bb0d271.png" alt="" style="display:block;margin:0 auto" />

<p><strong>112.9 seconds → 45.1 seconds.</strong> A 60% reduction in pull time with correctly tuned configuration. Grab Engineering reported a similar result in production: 60 seconds to 24 seconds after tuning, a 60% improvement. The untuned default config delivers less — roughly 42% improvement — which is why configuration matters.</p>
<hr />
<h2><strong>The EKS-Specific Setup Nobody Documents End-to-End</strong></h2>
<p>SOCI Parallel Pull is <strong>not enabled by default</strong> on EKS AMIs as of early 2026. Recent versions of Amazon Linux 2023 and Bottlerocket EKS-optimised AMIs have it <em>built in</em>, but you must configure it via <code>userData</code>. Here is the complete path for each node provisioning model.​</p>
<h2><strong>Option A: Karpenter EC2NodeClass (Recommended)</strong></h2>
<p>This is the cleanest approach. Configure SOCI once in your <code>EC2NodeClass</code> <code>userData</code>, and every Karpenter-provisioned node in that class gets it automatically.​<code>   </code></p>
<p>apiVersion: karpenter.k8s.aws/v1 kind: EC2NodeClass metadata: name: gpu-soci spec: role: KarpenterNodeRole-my-cluster instanceStorePolicy: RAID0 # bind NVMe if available amiSelectorTerms: - alias: al2023@latest instanceSelectorTerms: - name: g5.2xlarge - name: g5.4xlarge - name: p4d.24xlarge userData: | MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="//"</p>
<pre><code class="language-yaml">apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: gpu-soci
spec:
  role: KarpenterNodeRole-my-cluster
  instanceStorePolicy: RAID0          # bind NVMe if available
  amiSelectorTerms:
    - alias: al2023@latest
  instanceSelectorTerms:
    - name: g5.2xlarge
    - name: g5.4xlarge
    - name: p4d.24xlarge
  userData: |
    MIME-Version: 1.0
    Content-Type: multipart/mixed; boundary="//"

    --//
    Content-Type: text/x-shellscript; charset="us-ascii"

    #!/bin/bash
    # Download SOCI snapshotter v0.11.0+
    ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
    SOCI_VERSION="0.11.0"
    curl -Lo /tmp/soci-snapshotter.tar.gz \
      "https://github.com/awslabs/soci-snapshotter/releases/download/v\({SOCI_VERSION}/soci-snapshotter-\){SOCI_VERSION}-linux-${ARCH}.tar.gz"
    tar -xzf /tmp/soci-snapshotter.tar.gz -C /usr/local/bin soci soci-snapshotter-grpc

    # Configure SOCI
    mkdir -p /etc/soci-snapshotter-grpc
    cat &gt; /etc/soci-snapshotter-grpc/config.toml &lt;&lt; 'EOF'
    [snapshotter]
    allow_invalid_mounpoints = false

    [pull]
    max_concurrent_downloads_per_image = 10
    concurrent_download_chunk_size = "16mb"
    max_concurrent_unpacks_per_image = 10
    discard_unpacked_layers = true
    EOF

    # Systemd service
    cat &gt; /etc/systemd/system/soci-snapshotter.service &lt;&lt; 'EOF'
    [Unit]
    Description=SOCI Snapshotter
    After=network.target
    [Service]
    ExecStart=/usr/local/bin/soci-snapshotter-grpc --config /etc/soci-snapshotter-grpc/config.toml
    Restart=always
    [Install]
    WantedBy=multi-user.target
    EOF

    systemctl daemon-reload
    systemctl enable --now soci-snapshotter

    --//
    Content-Type: application/node.eks.aws

    apiVersion: node.eks.aws/v1alpha1
    kind: NodeConfig
    spec:
      kubelet:
        config:
          imageServiceEndpoint: unix:///run/soci-snapshotter-grpc/soci-snapshotter-grpc.sock
      containerd:
        config: |
          [proxy_plugins.soci]
            type    = "snapshot"
            address = "/run/soci-snapshotter-grpc/soci-snapshotter-grpc.sock"
          [proxy_plugins.soci.exports]
            root = "/var/lib/containerd/io.containerd.snapshotter.v1.soci"
          [plugins."io.containerd.grpc.v1.cri".containerd]
            snapshotter              = "soci"
            disable_snapshot_annotations = false
            discard_unpacked_layers  = false
    --//
</code></pre>
<h2><strong>Option B: Bottlerocket (Simpler)</strong></h2>
<p>Bottlerocket has native SOCI Parallel Pull configuration baked in — no shell script, no binary download, no systemd wrangling.​</p>
<pre><code class="language-yaml">[settings.container-runtime]
snapshotter = "soci"

[settings.container-runtime-plugins.soci-snapshotter]
pull-mode = "parallel-pull-unpack"

[settings.container-runtime-plugins.soci-snapshotter.parallel-pull-unpack]
max-concurrent-downloads-per-image = 10
concurrent-download-chunk-size     = "16mb"
max-concurrent-unpacks-per-image   = 10
discard-unpacked-layers            = true

# Bind SOCI root dir to NVMe instance store on g5/p4d/p5
[settings.bootstrap-commands.k8s-ephemeral-storage]
commands = [
    ["apiclient", "ephemeral-storage", "init"],
    ["apiclient", "ephemeral-storage", "bind",
     "--dirs", "/var/lib/containerd", "/var/lib/kubelet",
               "/var/log/pods", "/var/lib/soci-snapshotter"]
]
essential = true
mode      = "always"
</code></pre>
<p>Bottlerocket also shows slightly better benchmark results because it uses a different decompression library (zstd vs gzip by default). If you are on AL2023 and want parity, use <code>zstd</code>-compressed images in ECR.​</p>
<h2><strong>Option C: Managed Node Groups via Launch Template</strong></h2>
<p>For teams not using Karpenter, you need a custom launch template. The <code>userData</code> content is identical to Option A — wrap it in base64 and attach it to your MNG launch template configuration.​</p>
<pre><code class="language-shell"># Create the launch template with SOCI userData
aws ec2 create-launch-template \
  --launch-template-name soci-gpu-lt \
  --launch-template-data '{
    "ImageId": "'"$AMI_ID"'",
    "InstanceType": "g5.2xlarge",
    "UserData": "'"$(base64 -w0 soci-userdata.txt)"'"
  }'

# Reference it in your managed node group
eksctl create nodegroup \
  --cluster $CLUSTER_NAME \
  --name gpu-soci-ng \
  --launch-template-id $LAUNCH_TEMPLATE_ID
</code></pre>
<hr />
<h2><strong>The Tuning Parameters That Actually Matter</strong></h2>
<p>The default SOCI configuration is deliberately conservative. Leaving defaults in place delivers roughly half the possible improvement. Here is what each parameter controls and what to set for ECR-hosted ML images:</p>
<table style="min-width:100px"><colgroup><col style="min-width:25px"></col><col style="min-width:25px"></col><col style="min-width:25px"></col><col style="min-width:25px"></col></colgroup><tbody><tr><th><p><strong>Parameter</strong></p></th><th><p><strong>Default</strong></p></th><th><p><strong>Recommended (ECR)</strong></p></th><th><p><strong>Why</strong></p></th></tr><tr><td><p><code>max_concurrent_downloads_per_image</code></p></td><td><p>3</p></td><td><p><strong>10–20</strong></p></td><td><p>ECR handles concurrent range requests well; saturate your NIC</p></td></tr><tr><td><p><code>concurrent_download_chunk_size</code></p></td><td><p>(layer size)</p></td><td><p><strong>16mb</strong></p></td><td><p>ECR's optimal HTTP range request granularity</p></td></tr><tr><td><p><code>max_concurrent_unpacks_per_image</code></p></td><td><p>1</p></td><td><p><strong>10</strong></p></td><td><p>Match your average count of layers &gt;1 GB per image</p></td></tr><tr><td><p><code>discard_unpacked_layers</code></p></td><td><p>false</p></td><td><p><strong>true</strong></p></td><td><p>Save disk space; safe unless you push images from nodes</p></td></tr></tbody></table>

<p>Grab Engineering's production finding is instructive: tuning just these four parameters reduced their image pull from 60 seconds to 24 seconds — the difference between "configured" and "optimised." The default numbers exist for safety at low concurrency; they are not right for high-bandwidth, multi-core GPU instances.​</p>
<p><strong>The storage constraint is often the overlooked bottleneck.</strong> SOCI Parallel Pull trades memory efficiency for disk throughput — it writes directly to disk (no in-memory buffering). If your EBS volume is a default gp3 with 3,000 IOPS and 125 MiB/s throughput, the storage layer will become your bottleneck before the network does. For SOCI to run at full speed, configure your EBS volume at:​</p>
<pre><code class="language-plaintext">Throughput: 1000 MiB/s
IOPS:       16,000
Type:       gp3
</code></pre>
<p><code>   </code></p>
<p>For <code>g5</code>, <code>p4d</code>, and <code>p5</code> instances that include NVMe instance store — bind SOCI's root directory (<code>/var/lib/soci-snapshotter</code>) to the NVMe volume. The read/write speed of NVMe removes the storage bottleneck entirely and is the configuration that produces the best benchmark numbers.</p>
<hr />
<h2><strong>The Dockerfile Layer Ordering Question</strong></h2>
<p>This is where things get specific to ML images, and where I see teams get tripped up.</p>
<p>In <strong>Lazy Loading mode</strong> (which we've established is wrong for GPU), Dockerfile layer ordering matters enormously — SOCI fetches layers on-demand as files are accessed, so placing frequently-accessed layers early means they're resolved from local cache faster. For <strong>Parallel Pull mode</strong>, layer ordering has no impact on startup time — everything is pulled before the container starts.</p>
<p>However, layer ordering still matters for <strong>build performance and cache efficiency</strong>. For ML images, the optimal Dockerfile structure is:</p>
<pre><code class="language-shell"># Layer 1: Base OS — rarely changes, always cached
FROM nvidia/cuda:12.4.1-cudnn-devel-ubuntu22.04

# Layer 2: System packages — changes infrequently
RUN apt-get update &amp;&amp; apt-get install -y python3-pip git &amp;&amp; rm -rf /var/lib/apt/lists/*

# Layer 3: Python dependencies — changes on library updates
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Layer 4: Application code — changes frequently, keep it thin
COPY src/ /app/src/

# Layer 5: Model weights — only here if you bake them in
# (Consider EFS or S3 CSI mount instead — keeps this layer manageable)
COPY model/ /app/model/
</code></pre>
<p>The reasoning: SOCI (and ECR's content-addressable storage) can skip re-pulling layers that haven't changed. If your model weights are in the <em>last</em> layer, a code change in Layer 4 doesn't invalidate the weights cache. If your weights are Layer 2, every <code>pip install</code> forces a full re-pull of the largest part of your image.</p>
<p><strong>The stronger recommendation:</strong> Don't bake large model weights into your container image at all. Mount them via EFS CSI (shared across pods, multi-AZ) or S3 CSI (Mountpoint for S3, streaming directly from S3 at startup). Your container image drops to 10–15 GB of framework + dependencies, SOCI's parallel pull handles it in under 45 seconds, and your model weights are independently versioned and not duplicated per ECR tag.</p>
<hr />
<h2><strong>Scoping SOCI to GPU Workloads Only</strong></h2>
<p>You don't want every pod on the cluster going through the SOCI snapshotter. Lightweight API pods, Argo CD workers, CoreDNS — they have tiny images and the standard overlayfs snapshotter handles them fine. Use a <strong>Kubernetes RuntimeClass</strong> to route only GPU workloads through SOCI:​</p>
<pre><code class="language-yaml">apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: soci
handler: soci   # matches the proxy plugin name in containerd config
scheduling:
  nodeClassification:
    nodeSelector:
      eks.amazonaws.com/nodegroup: gpu-soci-ng  # only on SOCI-enabled nodes
</code></pre>
<p>Then reference it explicitly in your GPU Deployment:</p>
<pre><code class="language-yaml">spec:
  template:
    spec:
      runtimeClassName: soci          # only this pod uses SOCI snapshotter
      containers:
      - name: vllm
        image: 763104351884.dkr.ecr.us-east-1.amazonaws.com/vllm:0.9-gpu-py312-ec2
        resources:
          limits:
            nvidia.com/gpu: "1"
</code></pre>
<p>Standard pods on SOCI-enabled nodes continue using the default overlayfs snapshotter. There is no performance regression on non-GPU workloads, and you avoid the overhead of running every image pull through the SOCI daemon.</p>
<hr />
<h2><strong>Validating It Is Working</strong></h2>
<p>After deployment, verify the snapshotter is active and pull times have changed:</p>
<pre><code class="language-shell"># Confirm SOCI snapshotter is running on the node
kubectl exec -n kube-system ds/aws-node -- \
  crictl info | grep -i snapshotter

# Expected output:
# "snapshotter": "soci"

# Check actual pull time from pod events
kubectl describe pod vllm-&lt;pod-id&gt; | grep -E "Pulled|Pulling"

# Normal  Pulling   3m29s  kubelet  Pulling image "...vllm:0.9-gpu-py312-ec2"
# Normal  Pulled    2m44s  kubelet  Successfully pulled image in 45.121s

# Check SOCI daemon logs for pull activity
journalctl -u soci-snapshotter -f --since "5 minutes ago"
</code></pre>
<p><code>   </code></p>
<p>If your <code>Pulled</code> event still shows 90+ seconds after configuring SOCI, the two most common causes are: (1) the containerd config was not applied correctly — <code>disable_snapshot_annotations = false</code> is required for SOCI to intercept pulls; and (2) the EBS volume is the bottleneck — check <code>iostat</code> during a pull and confirm you are not at IOPS or throughput limits.</p>
<hr />
<h2><strong>What This Changes Operationally</strong></h2>
<p>The 60% pull time reduction is the headline number, but the downstream effects matter more for production operations:</p>
<p><strong>Autoscaling responsiveness:</strong> Karpenter's ability to respond to traffic spikes depends on how fast a new GPU pod can become ready. Cutting the cold start from 11 minutes to 5.5 minutes means your inference fleet responds to demand surges more than twice as fast. For an LLM API serving users, that difference is felt.</p>
<p><strong>Spot Interruption recovery:</strong> On Spot instances, your <code>g5.2xlarge</code> gets interrupted. Karpenter provisions a replacement. With standard pull, that replacement is unavailable for 11+ minutes. With SOCI, it's back in 5.5 minutes. At scale, this is the difference between a noticeable degradation and a transparent rebalance.</p>
<p><strong>Multi-model deployments:</strong> Teams running multiple model variants (7B, 13B, 70B) on the same cluster see compounding gains. A node serving a fresh model that previously required 3 separate 6-minute pulls — for three different models — now needs 3 pulls at 2.5 minutes each. Cluster time-to-ready drops by over an hour in a day of regular operations.</p>
<p><strong>Cost:</strong> A <code>g5.48xlarge</code> costs approximately ₹2,200/hour on-demand. Eight minutes of idle time during image pull per cold start, across a 10-node fleet cycling through multiple warm-ups per day, is not a rounding error.</p>
<hr />
<h2><strong>The One-Line Summary</strong></h2>
<p>SOCI Parallel Pull is already built into recent Amazon Linux 2023 and Bottlerocket EKS-optimised AMIs. It is not the default. It requires four configuration parameters, an optimised EBS volume or NVMe binding, and a RuntimeClass to scope it correctly to GPU workloads. It cuts image pull time by 60% on real production images. There is no reason not to have it on every GPU node in your cluster.</p>
<p>The complete Karpenter blueprint with SOCI configuration is available in the <a href="https://awslabs.github.io/ai-on-eks/"><strong>AI on EKS repository</strong></a> from AWS Labs.​</p>
]]></content:encoded></item><item><title><![CDATA[Deploying GPU-Accelerated AI/ML Workloads to Amazon ECS: Traditional vs. Managed Instances]]></title><description><![CDATA[I've been running GPU workloads on ECS for about two years now, and the landscape just changed significantly. Until Recently, if you told someone "Fargate…]]></description><link>https://aditmodi.hashnode.dev/deploying-gpu-accelerated-aiml-workloads-to-amazon-ecs-traditional-vs-managed-instances</link><guid isPermaLink="true">https://aditmodi.hashnode.dev/deploying-gpu-accelerated-aiml-workloads-to-amazon-ecs-traditional-vs-managed-instances</guid><category><![CDATA[AWS]]></category><dc:creator><![CDATA[Adit Modi]]></dc:creator><pubDate>Fri, 23 Jan 2026 18:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/60a88a245379f65d10b5ebb5/9839421f-4f27-4c0c-825c-228bcfc09c44.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I've been running GPU workloads on ECS for about two years now, and the landscape just changed significantly. Until Recently, if you told someone "Fargate doesn't support GPU workloads," you'd be technically correct—but you'd also be missing the bigger picture. AWS introduced ECS Managed Instances, which sits somewhere between Fargate's serverless simplicity and traditional EC2's full control, and it supports GPUs.</p>
<p>This fundamentally changes the conversation around GPU workloads on ECS. You now have two viable paths: the traditional self-managed EC2 approach where you control everything, and the newer managed instances approach where AWS handles the infrastructure lifecycle while you focus on your containers. Neither is universally better—it depends on your specific requirements, team capabilities, and tolerance for operational overhead.</p>
<p>In this post, I'll walk through both approaches, show you the actual configurations, and help you decide which makes sense for your workload. I'm not going to sugarcoat the complexity—running GPU inference in production has real operational costs regardless of which path you choose.</p>
<h2>The Three Compute Options for ECS (and Why It Matters)</h2>
<p>Before we dive into implementations, let's clarify the current ECS compute landscape:</p>
<p><strong>Fargate</strong>: Fully serverless, no server management, but no GPU support. If your workload doesn't need GPUs, this is still the simplest option.</p>
<p><strong>ECS Managed Instances</strong>: AWS provisions, patches, and replaces EC2 instances for you. You get to specify instance families (including GPU types), use Reserved Instances or Spot, but you can't SSH into hosts or use custom AMIs. This launched in September 2025 and became generally available across all commercial regions in October 2025.</p>
<p><strong>Traditional ECS on EC2</strong>: You manage everything—AMIs, patching, Auto Scaling Groups, capacity providers. Maximum control, maximum operational burden.</p>
<p>For GPU workloads specifically, Fargate isn't an option. You're choosing between Managed Instances (less operational work, some constraints) and self-managed EC2 (full control, more operational burden).</p>
<h2>When to Choose Which Approach</h2>
<p>Let me be direct about this because the documentation doesn't always make it clear:</p>
<p><strong>Use ECS Managed Instances when:</strong></p>
<ul>
<li><p>You want AWS to handle instance patching and lifecycle management</p>
</li>
<li><p>You're comfortable with instances being replaced on AWS's schedule</p>
</li>
<li><p>You don't need custom AMIs or host-level customizations</p>
</li>
<li><p>You want to leverage Reserved Instances or Spot pricing without managing ASGs yourself</p>
</li>
<li><p>Your team doesn't have dedicated platform engineering resources</p>
</li>
</ul>
<p><strong>Use Traditional EC2 when:</strong></p>
<ul>
<li><p>You need custom AMIs with specific kernel modules or configurations</p>
</li>
<li><p>You require SSH access for debugging or custom tooling</p>
</li>
<li><p>You have compliance requirements that mandate specific host configurations</p>
</li>
<li><p>You want complete control over when instances are replaced or updated</p>
</li>
<li><p>You have the engineering capacity to manage infrastructure lifecycle</p>
</li>
</ul>
<p>I've used both approaches, and honestly, for most ML inference workloads, Managed Instances is now my default choice. The operational relief is real, and the constraints rarely matter for containerized GPU workloads. But if you're doing something that requires kernel-level tweaking or highly specialized host configurations, you'll need traditional EC2.</p>
<h2>Approach 1: ECS Managed Instances (The New Way)</h2>
<p>Let's start with the newer approach because it's simpler and likely what most teams should use going forward.</p>
<h2>Architecture Overview</h2>
<p>With Managed Instances, you don't create launch templates or Auto Scaling Groups yourself. Instead, you define instance requirements in a capacity provider, and AWS handles the rest. The instances come pre-configured with NVIDIA drivers and CUDA toolkit, which eliminates one of the biggest pain points of the traditional approach.</p>
<p>AWS automatically provisions instances that match your requirements, patches them regularly, and replaces them on a managed schedule. You get infrastructure that stays current without building patching automation.</p>
<h2>Step 1: Create the Cluster</h2>
<pre><code class="language-bash">resource "aws_ecs_cluster" "gpu_managed" {
  name = "gpu-inference-managed"

  setting {
    name  = "containerInsights"
    value = "enabled"
  }
}
</code></pre>
<p>Nothing special here—it's just a standard ECS cluster. The magic happens in the capacity provider configuration.</p>
<h2>Step 2: Define the Managed Capacity Provider</h2>
<p>This is where you specify GPU requirements:</p>
<pre><code class="language-bash">resource "aws_ecs_capacity_provider" "gpu_managed" {
  name = "gpu-managed-capacity-provider"

  auto_scaling_group_provider {
    auto_scaling_group_arn = aws_autoscaling_group.ecs_managed_gpu.arn
    managed_scaling {
      maximum_scaling_step_size = 2
      minimum_scaling_step_size = 1
      status                    = "ENABLED"
      target_capacity           = 100
      instance_warmup_period    = 300
    }
    managed_termination_protection = "DISABLED"
    managed_draining              = "ENABLED"
  }
}
</code></pre>
<p>Wait—there's still an Auto Scaling Group here? Yes, but it's configured differently. With Managed Instances, the ASG uses a special configuration that tells ECS to manage the instances:</p>
<pre><code class="language-bash">resource "aws_launch_template" "ecs_managed_gpu" {
  name_prefix   = "ecs-managed-gpu-"
  image_id      = data.aws_ssm_parameter.ecs_managed_ami.value
  instance_type = "g4dn.xlarge"

  iam_instance_profile {
    arn = aws_iam_instance_profile.ecs_instance.arn
  }

  vpc_security_group_ids = [aws_security_group.ecs_gpu.id]

  tag_specifications {
    resource_type = "instance"
    tags = {
      Name                 = "ecs-managed-gpu"
      AmazonECSManaged     = true  # This is critical
    }
  }

  # Instance requirements for GPU selection
  instance_requirements {
    accelerator_types         = ["gpu"]
    accelerator_count {
      min = 1
      max = 1
    }
    accelerator_manufacturers = ["nvidia"]
    
    # Can also specify allowed instance types
    allowed_instance_types = ["g4dn.xlarge", "g4dn.2xlarge"]
  }

  # No user data needed! AWS handles GPU configuration
}
</code></pre>
<p>The key differences from traditional EC2:</p>
<ul>
<li><p>The <code>AmazonECSManaged = true</code> tag tells AWS to manage this instance</p>
</li>
<li><p><code>instance_requirements</code> lets you specify GPU needs declaratively</p>
</li>
<li><p><strong>No user data script needed</strong>—AWS pre-configures NVIDIA drivers and ECS agent GPU support</p>
</li>
<li><p>You can specify a range of instance types and let AWS choose the most cost-effective option available</p>
</li>
</ul>
<p>The <code>instance_requirements</code> block is powerful. Instead of hard-coding <code>g4dn.xlarge</code>, you can say "I need 1 NVIDIA GPU" and let AWS select from available instance types. This helps with Spot availability and cost optimization.</p>
<h2>Step 3: Task Definition (Unchanged)</h2>
<p>The task definition is identical to the traditional approach:</p>
<pre><code class="language-bash">resource "aws_ecs_task_definition" "gpu_inference_managed" {
  family                   = "llm-inference-managed"
  requires_compatibilities = ["EC2"]
  network_mode             = "awsvpc"
  cpu                      = "4096"
  memory                   = "16384"
  execution_role_arn       = aws_iam_role.task_execution.arn
  task_role_arn            = aws_iam_role.task.arn

  container_definitions = jsonencode([
    {
      name      = "inference-container"
      image     = "nvidia/cuda:12.1.0-runtime-ubuntu22.04"
      essential = true

      command = [
        "/bin/bash",
        "-c",
        "nvidia-smi &amp;&amp; python3 /app/serve_model.py"
      ]

      environment = [
        {
          name  = "NVIDIA_VISIBLE_DEVICES"
          value = "all"
        },
        {
          name  = "NVIDIA_DRIVER_CAPABILITIES"
          value = "compute,utility"
        }
      ]

      portMappings = [
        {
          containerPort = 8000
          protocol      = "tcp"
        }
      ]

      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = "/ecs/gpu-inference-managed"
          "awslogs-region"        = data.aws_region.current.name
          "awslogs-stream-prefix" = "gpu"
        }
      }

      resourceRequirements = [
        {
          type  = "GPU"
          value = "1"
        }
      ]
    }
  ])
}
</code></pre>
<p>This is exactly the same as with traditional EC2. The task definition doesn't care whether it's running on managed or self-managed instances.</p>
<h2>Step 4: Create the Service</h2>
<pre><code class="language-bash">resource "aws_ecs_service" "gpu_inference_managed" {
  name            = "llm-inference-managed"
  cluster         = aws_ecs_cluster.gpu_managed.id
  task_definition = aws_ecs_task_definition.gpu_inference_managed.arn
  desired_count   = 2

  capacity_provider_strategy {
    capacity_provider = aws_ecs_capacity_provider.gpu_managed.name
    weight            = 1
  }

  network_configuration {
    subnets          = var.private_subnet_ids
    security_groups  = [aws_security_group.inference_tasks.id]
    assign_public_ip = false
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.inference.arn
    container_name   = "inference-container"
    container_port   = 8000
  }

  deployment_configuration {
    maximum_percent         = 200
    minimum_healthy_percent = 100
  }
}
</code></pre>
<p>Again, no changes needed here. The beauty of ECS Managed Instances is that the task and service definitions remain unchanged—you're just swapping out how the underlying infrastructure is managed.</p>
<h2>What AWS Actually Manages</h2>
<p>Here's what happens behind the scenes with Managed Instances:</p>
<ul>
<li><p>AWS provisions instances that match your <code>instance_requirements</code></p>
</li>
<li><p>Instances are automatically patched and updated on a regular schedule</p>
</li>
<li><p>When instances need replacement (for security patches or maintenance), AWS drains tasks and replaces them gracefully</p>
</li>
<li><p>You can't SSH into these instances—if you need debugging, use CloudWatch Logs and Container Insights</p>
</li>
<li><p>Custom AMIs aren't supported—AWS uses its own managed AMI with pre-configured GPU drivers</p>
</li>
</ul>
<p>The trade-off is clear: you give up some control (no SSH, no custom AMIs) in exchange for AWS handling operational toil.</p>
<h2>Approach 2: Traditional Self-Managed EC2 (The Classic Way)</h2>
<p>Now let's look at the traditional approach. This is what we've been using before December 2025, and it's still the right choice when you need full control.</p>
<h2>Architecture Overview</h2>
<p>You're responsible for everything: choosing the AMI, configuring user data to enable GPU support, managing the Auto Scaling Group, handling patching and updates. ECS just provides the orchestration layer on top of infrastructure you own.</p>
<h2>Step 1: Select and Configure the AMI</h2>
<p>You need a GPU-optimized ECS AMI with NVIDIA drivers pre-installed:</p>
<pre><code class="language-bash"># Get the latest GPU-optimized ECS AMI
aws ssm get-parameters \
  --names /aws/service/ecs/optimized-ami/amazon-linux-2/gpu/recommended \
  --region us-east-1 \
  --query 'Parameters[0].Value' \
  --output text | jq -r '.image_id'
</code></pre>
<p>This returns something like <code>ami-0c02fb55956c7d316</code>. The AMI includes NVIDIA drivers, CUDA toolkit, and Docker GPU runtime pre-configured.</p>
<p>Alternatively, you could use AWS Deep Learning AMIs, which include multiple ML frameworks, but they're larger and include tools you might not need for production inference.</p>
<h2>Step 2: Create the Launch Template with User Data</h2>
<p>This is the critical part—the user data script that enables GPU support in the ECS agent:</p>
<pre><code class="language-bash">resource "aws_launch_template" "gpu_traditional" {
  name_prefix   = "gpu-ecs-traditional-"
  image_id      = "ami-0c02fb55956c7d316"  # GPU-optimized ECS AMI
  instance_type = "g4dn.xlarge"

  iam_instance_profile {
    name = aws_iam_instance_profile.ecs_instance.name
  }

  vpc_security_group_ids = [aws_security_group.ecs_gpu.id]

  user_data = base64encode(&lt;&lt;-EOF
    #!/bin/bash
    # Configure ECS agent for GPU support
    cat &gt;&gt; /etc/ecs/ecs.config &lt;&lt;ECS_CONFIG
    ECS_CLUSTER=${aws_ecs_cluster.gpu_traditional.name}
    ECS_ENABLE_GPU_SUPPORT=true
    ECS_ENABLE_TASK_IAM_ROLE=true
    ECS_ENABLE_CONTAINER_METADATA=true
    ECS_ENABLE_SPOT_INSTANCE_DRAINING=true
    ECS_LOGLEVEL=info
    ECS_CONFIG

    # Verify NVIDIA drivers are loaded
    nvidia-smi

    # Additional customizations you might need
    # Install CloudWatch agent
    wget https://s3.amazonaws.com/amazoncloudwatch-agent/amazon_linux/amd64/latest/amazon-cloudwatch-agent.rpm
    rpm -U ./amazon-cloudwatch-agent.rpm

    # Custom monitoring or logging setup
    # Your custom scripts here
  EOF
  )

  tag_specifications {
    resource_type = "instance"
    tags = {
      Name    = "ecs-gpu-traditional"
      Cluster = aws_ecs_cluster.gpu_traditional.name
    }
  }
}
</code></pre>
<p>The <code>ECS_ENABLE_GPU_SUPPORT=true</code> line is <strong>absolutely critical</strong>. Without it, even though the instance has GPUs, the ECS agent won't expose them for task scheduling. I've debugged this multiple times—tasks just sit in PENDING state because ECS doesn't see any GPU capacity.</p>
<p>The beauty of this approach is that you can add whatever customizations you need in the user data script. Need custom monitoring? SSH keys for debugging? Specific kernel parameters? You can configure it all here.</p>
<h2>Step 3: Create the Auto Scaling Group</h2>
<pre><code class="language-bash">resource "aws_autoscaling_group" "gpu_traditional" {
  name                = "gpu-ecs-asg-traditional"
  desired_capacity    = 1
  max_size            = 5
  min_size            = 0
  vpc_zone_identifier = var.private_subnet_ids
  health_check_type   = "EC2"
  health_check_grace_period = 300

  launch_template {
    id      = aws_launch_template.gpu_traditional.id
    version = "$Latest"
  }

  tag {
    key                 = "Name"
    value               = "ecs-gpu-instance"
    propagate_at_launch = true
  }

  lifecycle {
    ignore_changes = [desired_capacity]
  }
}
</code></pre>
<p>I typically start with <code>min_size = 0</code> for cost optimization—if there are no GPU tasks running, the cluster scales down to zero instances. For production workloads with consistent traffic, set <code>min_size = 1</code> or higher to avoid cold start delays.</p>
<h2>Step 4: Create the Capacity Provider</h2>
<pre><code class="language-bash">resource "aws_ecs_capacity_provider" "gpu_traditional" {
  name = "gpu-traditional-capacity-provider"

  auto_scaling_group_provider {
    auto_scaling_group_arn         = aws_autoscaling_group.gpu_traditional.arn
    managed_termination_protection = "ENABLED"

    managed_scaling {
      maximum_scaling_step_size = 2
      minimum_scaling_step_size = 1
      status                    = "ENABLED"
      target_capacity           = 100
    }
  }
}

resource "aws_ecs_cluster" "gpu_traditional" {
  name = "gpu-inference-traditional"
}

resource "aws_ecs_cluster_capacity_providers" "gpu_traditional" {
  cluster_name = aws_ecs_cluster.gpu_traditional.name

  capacity_providers = [aws_ecs_capacity_provider.gpu_traditional.name]

  default_capacity_provider_strategy {
    capacity_provider = aws_ecs_capacity_provider.gpu_traditional.name
    weight            = 1
  }
}
</code></pre>
<p>The <code>target_capacity = 100</code> means ECS tries to maintain 100% available capacity. When GPU tasks are pending, the capacity provider triggers the ASG to scale up.</p>
<h2>Task Definition and Service</h2>
<p>These are identical to the Managed Instances approach—I won't repeat the code. The task definition specifies <code>resourceRequirements</code> with <code>type = "GPU"</code> and <code>value = "1"</code>, and the service uses the capacity provider.</p>
<h2>What You're Responsible For</h2>
<p>With traditional EC2, you own:</p>
<ul>
<li><p><strong>AMI updates</strong>: When new GPU drivers or security patches are released, you need to update the AMI and roll instances</p>
</li>
<li><p><strong>Instance lifecycle</strong>: Replacing unhealthy instances, handling retirement notifications</p>
</li>
<li><p><strong>Capacity management</strong>: Tuning the ASG min/max/desired settings and capacity provider parameters</p>
</li>
<li><p><strong>Debugging</strong>: You can SSH into instances (if configured), run <code>nvidia-smi</code>, check ECS agent logs at <code>/var/log/ecs/ecs-agent.log</code></p>
</li>
<li><p><strong>Custom configuration</strong>: Complete freedom to install monitoring agents, configure kernel parameters, mount volumes, etc.</p>
</li>
</ul>
<p>The operational burden is real, but so is the control. For teams with platform engineering resources, this flexibility is valuable.</p>
<h2>Side-by-Side Comparison</h2>
<p>Let me break down the practical differences you'll experience day-to-day:</p>
<table>
<thead>
<tr>
<th>Aspect</th>
<th>ECS Managed Instances</th>
<th>Traditional EC2</th>
</tr>
</thead>
<tbody><tr>
<td>Setup complexity</td>
<td>Lower—no user data needed, drivers pre-installed</td>
<td>Higher—must configure user data, verify GPU setup</td>
</tr>
<tr>
<td>Instance patching</td>
<td>AWS handles automatically</td>
<td>You manage via AMI updates and instance replacement</td>
</tr>
<tr>
<td>SSH access</td>
<td>Not available</td>
<td>Available if configured</td>
</tr>
<tr>
<td>Custom AMIs</td>
<td>Not supported</td>
<td>Fully supported</td>
</tr>
<tr>
<td>GPU driver management</td>
<td>AWS maintains drivers</td>
<td>You manage driver updates in AMI</td>
</tr>
<tr>
<td>Debugging capability</td>
<td>CloudWatch Logs and Container Insights only</td>
<td>Full SSH access to instances, direct <code>nvidia-smi</code> access</td>
</tr>
<tr>
<td>Cost</td>
<td>EC2 pricing + managed layer fee ​</td>
<td>EC2 pricing only</td>
</tr>
<tr>
<td>Reserved/Spot support</td>
<td>Yes</td>
<td>Yes</td>
</tr>
<tr>
<td>Control over instance replacement timing</td>
<td>AWS decides when to replace instances</td>
<td>You control replacement timing</td>
</tr>
<tr>
<td>Best for</td>
<td>Teams wanting less operational burden, standard GPU workloads</td>
<td>Teams needing custom configurations, full control</td>
</tr>
</tbody></table>
<h2>GPU Instance Types and Selection</h2>
<p>Both approaches support the same GPU instance families:</p>
<ul>
<li><p><strong>g4dn (NVIDIA T4)</strong>: Best for inference. A g4dn.xlarge gives you 1 GPU with 16GB memory, costs around $0.526/hour. My go-to for serving models like Stable Diffusion or smaller LLMs.</p>
</li>
<li><p><strong>g5 (NVIDIA A10G)</strong>: Newer generation, better performance. A g5.xlarge provides 24GB GPU memory. Great for production inference at scale.</p>
</li>
<li><p><strong>p3 (NVIDIA V100)</strong>: Training workhorse. P3.2xlarge has 16GB GPU memory, ideal for fine-tuning.</p>
</li>
<li><p><strong>p4d (NVIDIA A100)</strong>: Heavy hitter for large-scale training. P4d.24xlarge gives you 8 A100 GPUs with 320GB total GPU memory.</p>
</li>
</ul>
<p>With Managed Instances, you can use <code>instance_requirements</code> to specify GPU needs flexibly:</p>
<pre><code class="language-bash">instance_requirements {
  accelerator_types = ["gpu"]
  accelerator_count {
    min = 1
    max = 1
  }
  accelerator_manufacturers = ["nvidia"]
  allowed_instance_types    = ["g4dn.xlarge", "g4dn.2xlarge", "g5.xlarge"]
}
</code></pre>
<p>This lets AWS pick from multiple instance types based on availability and Spot pricing, which improves your chances of getting capacity.</p>
<p>With traditional EC2, you hard-code the instance type in the launch template, though you can create multiple launch templates and capacity providers if you want flexibility.</p>
<h2>Real-World Operational Scenarios</h2>
<p>Here are issues I've hit that apply to both approaches:</p>
<p><strong>Cold Start Times</strong>: Pulling large ML model images (10GB+) takes time. First task placement on a new instance can take 5-10 minutes. Pre-pull images or load models from S3 at runtime.</p>
<p><strong>GPU Memory Leaks</strong>: If your inference code leaks memory, GPUs gradually fill until tasks fail. GPU memory isn't automatically reclaimed like CPU memory. Implement proper cleanup in your application code.</p>
<p><strong>IPv6 Issues</strong>: The ECS GPU AMIs have IPv6 enabled, which can cause package manager failures. Add <code>echo "ip_resolve=4" &gt;&gt; /etc/yum.conf</code> to user data if installing additional packages (only relevant for traditional EC2 since Managed Instances don't allow custom configuration).</p>
<p><strong>Task Draining During Instance Replacement</strong>: With Managed Instances, AWS replaces instances on its schedule. Your application must handle graceful shutdown. Set appropriate <code>deregistration_delay</code> on target groups and handle SIGTERM in your containers.</p>
<p><strong>Spot Interruption Handling</strong>: Both approaches support Spot instances, but handling interruptions requires application-level resilience. Use the 2-minute warning to checkpoint work and drain tasks gracefully.</p>
<h2>Cost Optimization Strategies</h2>
<p>GPU instances are expensive. Here's how to manage costs:</p>
<p><strong>Scale to Zero</strong>: Set ASG <code>min_size = 0</code> so instances terminate when idle. Accept cold start penalty for sporadic workloads.</p>
<p><strong>Use Spot Instances</strong>: Can save 70%+ for non-critical workloads. Both approaches support Spot. With Managed Instances, you still get AWS-managed patching and lifecycle on Spot instances, which is nice.</p>
<p><strong>Right-Size Instances</strong>: Don't use p3.8xlarge when g4dn.xlarge suffices. Profile your workload's actual GPU memory and compute needs.</p>
<p><strong>Reserved Instances</strong>: For predictable steady-state workloads, Reserved Instances or Savings Plans significantly reduce costs. Both approaches support this, though with Managed Instances you also pay the management layer fee.</p>
<p><strong>Batch Processing</strong>: If doing inference on queued jobs rather than real-time serving, batch multiple requests to maximize GPU utilization.</p>
<p><strong>Model Optimization</strong>: Use quantization (INT8/FP16) to reduce memory footprint. Smaller models can run on cheaper instance types.</p>
<h2>Monitoring GPU Usage</h2>
<p>For Managed Instances, you don't have SSH access. This creates a monitoring challenge that's worth addressing upfront.​</p>
<p><strong>The Current Limitation</strong>: As of Jan 2026, there's no native CloudWatch GPU metrics support for ECS Managed Instances. There's an open feature request on the AWS containers roadmap <a href="https://github.com/aws/containers-roadmap/issues/2734">(issue #2734)</a> that's been marked as "Proposed," and based on AWS's typical response patterns, this will likely be addressed soon. CloudWatch Container Insights already supports sub-minute GPU metrics for EKS as of November 2025, so the infrastructure exists—it just hasn't been extended to ECS Managed Instances yet.</p>
<p>Until AWS ships native GPU metrics support, monitoring for Managed Instances relies on:</p>
<ul>
<li><p><strong>CloudWatch Container Insights</strong> for container-level metrics (CPU, memory, network—but not GPU)</p>
</li>
<li><p><strong>Custom CloudWatch metrics</strong> published from your application code using the AWS SDK</p>
</li>
<li><p><strong>Application-level GPU metrics</strong> exposed via your inference server (e.g., Prometheus metrics endpoint with a sidecar to push to CloudWatch)</p>
</li>
</ul>
<p>This means you need to instrument your application to emit GPU utilization metrics. Here's a practical example using the <code>pynvml</code> library to publish metrics from your inference container:</p>
<pre><code class="language-python">import pynvml
import boto3
from datetime import datetime

cloudwatch = boto3.client('cloudwatch')

def publish_gpu_metrics():
    pynvml.nvmlInit()
    device_count = pynvml.nvmlDeviceGetCount()
    
    for i in range(device_count):
        handle = pynvml.nvmlDeviceGetHandleByIndex(i)
        
        # Get GPU utilization
        util = pynvml.nvmlDeviceGetUtilizationRates(handle)
        
        # Get memory info
        mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle)
        
        # Publish to CloudWatch
        cloudwatch.put_metric_data(
            Namespace='ECS/GPU',
            MetricData=[
                {
                    'MetricName': 'GPUUtilization',
                    'Value': util.gpu,
                    'Unit': 'Percent',
                    'Timestamp': datetime.utcnow(),
                    'Dimensions': [
                        {'Name': 'GPUIndex', 'Value': str(i)},
                        {'Name': 'TaskID', 'Value': os.environ.get('TASK_ID')}
                    ]
                },
                {
                    'MetricName': 'GPUMemoryUsed',
                    'Value': mem_info.used / (1024**2),  # Convert to MB
                    'Unit': 'Megabytes',
                    'Timestamp': datetime.utcnow(),
                    'Dimensions': [
                        {'Name': 'GPUIndex', 'Value': str(i)},
                        {'Name': 'TaskID', 'Value': os.environ.get('TASK_ID')}
                    ]
                }
            ]
        )
    
    pynvml.nvmlShutdown()
</code></pre>
<p>Run this periodically (every 30-60 seconds) in a background thread within your inference container.</p>
<p>For <strong>traditional EC2</strong>, you have significantly more monitoring options since you control the instances:</p>
<pre><code class="language-bash"># SSH into instance via Session Manager
aws ssm start-session --target &lt;instance-id&gt;

# Real-time GPU monitoring
watch -n 1 nvidia-smi

# Check ECS agent logs for GPU-related issues
cat /var/log/ecs/ecs-agent.log | grep GPU

# Detailed GPU info
nvidia-smi -q
</code></pre>
<p>You can also install the CloudWatch agent with GPU support, which automatically collects NVIDIA GPU metrics and sends them to CloudWatch without requiring application-level instrumentation:</p>
<pre><code class="language-bash"># CloudWatch agent config for GPU metrics (add to user data)
cat &gt; /opt/aws/amazon-cloudwatch-agent/etc/nvidia_gpu.json &lt;&lt;'EOF'
{
  "metrics": {
    "namespace": "CWAgent",
    "metrics_collected": {
      "nvidia_gpu": {
        "measurement": [
          {
            "name": "utilization_gpu",
            "rename": "nvidia_smi_utilization_gpu",
            "unit": "Percent"
          },
          {
            "name": "utilization_memory",
            "rename": "nvidia_smi_utilization_memory",
            "unit": "Percent"
          },
          {
            "name": "memory_total",
            "rename": "nvidia_smi_memory_total",
            "unit": "Megabytes"
          },
          {
            "name": "memory_used",
            "rename": "nvidia_smi_memory_used",
            "unit": "Megabytes"
          },
          {
            "name": "memory_free",
            "rename": "nvidia_smi_memory_free",
            "unit": "Megabytes"
          },
          {
            "name": "temperature_gpu",
            "rename": "nvidia_smi_temperature_gpu",
            "unit": "None"
          }
        ],
        "metrics_collection_interval": 60
      }
    }
  }
}
EOF

# Start CloudWatch agent with GPU monitoring
/opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
  -a fetch-config \
  -m ec2 \
  -s \
  -c file:/opt/aws/amazon-cloudwatch-agent/etc/nvidia_gpu.json
</code></pre>
<p>This publishes 17 GPU metrics per GPU to CloudWatch automatically. For production workloads on traditional EC2, this is the recommended approach—it provides comprehensive GPU monitoring without requiring application changes.​</p>
<p><strong>My Recommendation</strong>: If GPU metrics visibility is critical for your workload right now and you can't wait for AWS to ship native support for Managed Instances, that's a legitimate reason to choose traditional EC2. The CloudWatch agent GPU support for traditional EC2 instances is mature and battle-tested. However, if you can tolerate application-level instrumentation or are willing to wait a few months for native support (given that issue #2734 was opened in December 2025 and AWS typically moves fast on high-visibility feature requests), Managed Instances still offers significant operational advantages.</p>
<p>Once AWS adds native GPU metrics to Managed Instances—which I expect will happen in 2026 based on the roadmap activity—the monitoring gap will close, making Managed Instances even more compelling for GPU workloads.</p>
<h2>Making the Decision</h2>
<p>After deploying GPU workloads on ECS using both approaches, here's my practical guidance:</p>
<p><strong>Start with ECS Managed Instances if:</strong></p>
<ul>
<li><p>Your team is small or lacks dedicated platform engineering</p>
</li>
<li><p>You're running standard ML inference workloads without exotic requirements</p>
</li>
<li><p>You value operational simplicity over maximum control</p>
</li>
<li><p>You're comfortable with AWS handling instance replacement timing</p>
</li>
<li><p>You don't need to debug at the host level frequently</p>
</li>
</ul>
<p><strong>Use traditional EC2 if:</strong></p>
<ul>
<li><p>You need custom kernel modules or host-level configuration</p>
</li>
<li><p>You require SSH access for debugging or operational tooling</p>
</li>
<li><p>You have compliance requirements for specific AMI configurations</p>
</li>
<li><p>Your team has capacity to manage infrastructure lifecycle</p>
</li>
<li><p>You want to avoid the managed layer cost (though factor in your engineering time cost)</p>
</li>
</ul>
<p>For most teams deploying LLM inference, Stable Diffusion, or similar GPU workloads, I'd recommend starting with Managed Instances. The operational relief is significant, and you can always migrate to traditional EC2 later if you hit limitations.</p>
<h2>Example: Deploying a Llama 2 Inference Service</h2>
<p>Let me show a concrete example using Managed Instances to deploy a Llama 2 7B inference service.</p>
<p><strong>Dockerfile:</strong></p>
<pre><code class="language-bash">FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04

RUN apt-get update &amp;&amp; apt-get install -y python3 python3-pip
RUN pip3 install torch transformers accelerate

COPY serve.py /app/serve.py
WORKDIR /app

CMD ["python3", "serve.py"]
</code></pre>
<p><strong>Task Definition (abbreviated):</strong></p>
<pre><code class="language-bash">resource "aws_ecs_task_definition" "llama2_inference" {
  family = "llama2-7b-inference"
  # ... standard settings ...
  
  container_definitions = jsonencode([{
    name  = "llama2"
    image = "your-registry/llama2-inference:latest"
    
    resourceRequirements = [{
      type  = "GPU"
      value = "1"
    }]
    
    environment = [
      { name = "MODEL_ID", value = "meta-llama/Llama-2-7b-hf" },
      { name = "MAX_BATCH_SIZE", value = "8" }
    ]
  }])
}
</code></pre>
<p><strong>Capacity Provider with Managed Instances:</strong></p>
<pre><code class="language-bash">instance_requirements {
  accelerator_types         = ["gpu"]
  accelerator_count { min = 1, max = 1 }
  accelerator_manufacturers = ["nvidia"]
  allowed_instance_types    = ["g5.xlarge", "g5.2xlarge"]
}
</code></pre>
<p>With this setup, AWS provisions g5 instances with A10G GPUs, pre-configured with NVIDIA drivers. Your container starts, downloads the Llama 2 model weights (ideally from S3, not during startup), and serves inference requests. When traffic drops, instances scale down. When instances need patching, AWS drains tasks and replaces them.</p>
<p>Total infrastructure code? About 150 lines of Terraform. No user data scripts, no GPU driver debugging, no AMI management.</p>
<h2>Wrapping Up</h2>
<p>The introduction of ECS Managed Instances fundamentally changes the GPU workload conversation on ECS. The old advice of "Fargate doesn't support GPUs, so you're managing EC2 instances" is outdated. You now have a middle ground that handles most of the operational burden while still supporting GPU workloads.</p>
<p>Start with Managed Instances unless you have specific reasons not to. You can always migrate to traditional EC2 later if you hit limitations. The configuration is nearly identical—mostly just swapping out how the capacity provider and launch template are defined.</p>
<p>The hardest parts of running GPU workloads on ECS aren't about infrastructure anymore—they're about application concerns like model loading times, memory management, request batching, and handling inference failures gracefully. Focus your engineering effort there, and let AWS handle the infrastructure lifecycle.</p>
<p>And remember: whichever approach you choose, <code>ECS_ENABLE_GPU_SUPPORT=true</code> in the ECS agent config is non-negotiable for traditional EC2. With Managed Instances, AWS handles that for you. That alone might be worth the managed layer cost.</p>
<p>Now go deploy some models.  </p>
<p><strong>References</strong></p>
<p>Key AWS documentation and industry sources referenced in this post:<br />- <a href="https://ahmedjama.com/blog/2025/10/ecs/ecs-managed-instances-comparison/"><strong>ECS Managed Instances: A practical comparison with Fargate and EC2</strong></a><br />- <a href="https://aws.amazon.com/about-aws/whats-new/2025/10/amazon-ecs-managed-instances-commercial-regions/">Amazon ECS Managed Instances now available in all commercial AWS Regions</a><br />- <a href="https://www.kubeblogs.com/how-to-run-gpu-workloads-on-ecs-complete-implementation-guide/"><strong>How to Run GPU Workloads on ECS: Complete Implementation Guide</strong></a><br />- <a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-gpu.html"><strong>Amazon ECS task definitions for GPU workloads</strong></a></p>
]]></content:encoded></item></channel></rss>