<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Kubernetes on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/kubernetes/</link><description>Recent content in Kubernetes on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Tue, 15 Sep 2026 07:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/kubernetes/index.xml" rel="self" type="application/rss+xml"/><item><title>deepagents on a cluster of your own: the SDK is free, the server is not, and what you have to build in between</title><link>https://blog.lo0.es/en/posts/deepagents-free-sdk-licensed-server/</link><pubDate>Tue, 15 Sep 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/deepagents-free-sdk-licensed-server/</guid><description>&lt;blockquote>
&lt;p>Opening of the agentic platform vertical. The previous articles dealt with the pieces separately: &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">the inference gateway&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">its second door&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">what a fleet of agents costs&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">isolation&lt;/a>. This one deals with what goes on top. Verified against &lt;code>deepagents&lt;/code> 0.7.14 and the LangGraph repository, commits of 14 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>deepagents is not a runtime.&lt;/strong> It is a factory that assembles middleware and delegates to LangChain&amp;rsquo;s agent factory. It returns a compiled LangGraph graph and nothing else. The repository&amp;rsquo;s own documentation says so without decoration: it introduces no new runtime. That is good news for whoever deploys it, because it runs wherever Python runs.&lt;/p>
&lt;p>&lt;strong>The planning tool no longer exists.&lt;/strong> It disappeared in 0.7.0 as a breaking change, along with the state channel that backed it. Almost everything written about this harness still describes it as one of its three legs. Today you have to ask for it explicitly, and you have to ask for it in each subagent too.&lt;/p>
&lt;p>&lt;strong>With the default configuration, the agent&amp;rsquo;s files live inside the checkpoint.&lt;/strong> The state backend stores the full content, binaries in base64, in the same place the conversation is persisted. Changing that is one line and it is the first architecture decision of the deployment.&lt;/p>
&lt;p>&lt;strong>The database grows per loop step, not per turn.&lt;/strong> Four tables, one row per superstep, one per channel and version, another per task write. And of the four cleanup operations the base class declares, the Postgres implementation has only one: delete an entire thread. The other three raise not-implemented.&lt;/p>
&lt;p>&lt;strong>Pruning by hand can empty the history silently.&lt;/strong> The code itself warns that a partial delete breaks the delta chain and leaves the channels rebuilding empty without raising any error. The only safe pruning is by whole thread.&lt;/p>
&lt;p>&lt;strong>Tenant isolation is the thread identifier, and there is no owner check.&lt;/strong> Whoever can send a thread identifier reads that thread. The on-disk filesystem backend fixes its root when the agent is built, so it separates nobody. And the documentation&amp;rsquo;s example for computing the per-user namespace does not work outside the managed platform.&lt;/p>
&lt;p>&lt;strong>There is a configuration trap that leaves a short-window model uncompacted.&lt;/strong> If the model does not expose its window, the compaction threshold falls back to 170,000 tokens. Served on a vLLM with a 32,768 window, the agent blows up on context length long before it compacts. The failure is silent and is fixed by passing the profile by hand.&lt;/p>
&lt;p>&lt;strong>Observability without the platform does exist, and it goes through a package that is not obvious.&lt;/strong> LangChain&amp;rsquo;s core does not emit OpenTelemetry: zero references. The exporter lives inside the LangSmith SDK, and there is a mode that emits over OTLP only, without talking to the service. It is the road to a Langfuse of your own, and it is worth knowing where it passes.&lt;/p>
&lt;p>&lt;strong>And here is where the free part ends: the server.&lt;/strong> The SDK, the graph engine and the checkpointers are MIT. The server that publishes them over HTTP carries an Elastic 2.0 licence, asks for a commercial licence key for production, adds Redis to the stack and reports metadata to an external endpoint unless there is an isolation agreement. The development tool is explicitly for development.&lt;/p>
&lt;p>&lt;strong>What is left is a list of things you have to write.&lt;/strong> Per-thread authorisation, retention, a run queue, cancellation, clean draining and migrations. None of them is hard. All of them are work, and none of them appears in the twenty-line front-page example.&lt;/p>
&lt;h2 id="you-are-here-the-harness-arrives-after-the-model">You are here: the harness arrives after the model&lt;/h2>
&lt;p>The sequence repeats on every project. First you set up inference, which is the visible part. Then the gateway, because you need keys and budgets. Then observability, because nobody knows what is going on. And when all of that works, somebody asks why the assistant cannot do multi-step tasks, and the word agent enters the scene.&lt;/p>
&lt;p>At that point the temptation is to write the loop by hand, and for two weeks it looks like a good idea. The loop is easy. What is not easy is everything you discover afterwards: what happens when the context fills up, where the intermediate files are stored, how you resume a task that was left half done, how you ask a human for confirmation without losing state.&lt;/p>
&lt;p>An agent harness is exactly that: the packaged answer to those questions. And choosing one looks like a library decision, about the size of choosing an HTTP client. It is not. It decides where your users&amp;rsquo; state lives, which database grows, what can be isolated and what cannot, and in the case at hand, whether there is a commercial licence waiting at the end of the road.&lt;/p>
&lt;p>This article looks at &lt;code>deepagents&lt;/code> with those eyes. It is not a usage guide, and I do not assess whether its agents are any good. I look at what it forces you to build.&lt;/p>
&lt;h2 id="the-analogy-the-scaffolding-and-the-crane">The analogy: the scaffolding and the crane&lt;/h2>
&lt;p>A building site needs scaffolding and it needs a crane, and they are not the same thing even though both are metal structures surrounding the building.&lt;/p>
&lt;p>You put up the scaffolding yourself with parts you bought. It adapts to the façade, it can be extended, and the day the work finishes you take it down and put it away. Nobody charges you for using it once it is bought.&lt;/p>
&lt;p>The crane is another matter. You hire it, it comes with an operator, it has a serial number and you have to notify somebody when it goes up. Without a crane the work still moves forward, more slowly and with more people, but it moves forward.&lt;/p>
&lt;p>Here the scaffolding is the SDK and the graph engine, and it belongs to whoever buys it. The crane is the server that exposes the agent over HTTP, and that one comes with a contract. This article is about where exactly the line between the two sits, because you cannot see it in the brochure, and about how much work can be done without a crane.&lt;/p>
&lt;h2 id="what-deepagents-actually-is">What deepagents actually is&lt;/h2>
&lt;p>The first thing to get out of your head is that it is a system. It is a function.&lt;/p>
&lt;p>The main factory assembles a list of middleware and calls LangChain&amp;rsquo;s agent factory, which is what builds the graph. What it returns is a compiled graph, with an added configuration on top that raises the recursion limit to a very high number and adds an integration tag in the metadata. The repository&amp;rsquo;s own architecture document states it: no new runtime is introduced.&lt;/p>
&lt;p>Compiled with the default options, the graph has the nodes you would expect from a reasoning-and-action loop: start, a prior node that repairs incomplete tool calls, the model node, the tools node and the end. The conditional edges run from the model to tools, to the model itself or to the end. There is nothing more. Everything that looks like a system is middleware that adds nodes only when it declares hooks of its own.&lt;/p>
&lt;p>The tools that ship by default are the filesystem ones, plus the subagent delegation one: list, read, write, edit, delete, search by pattern, search by content, execute, and the task tool.&lt;/p>
&lt;p>The good consequence of this is portability. If something is a LangGraph graph and nothing else, it runs in any Python container, with the checkpointer you choose, against the model you choose. There is no hidden service behind it.&lt;/p>
&lt;h3 id="what-is-no-longer-there">What is no longer there&lt;/h3>
&lt;p>It deserves its own section because it contradicts almost everything published. The planning tool, the one that wrote a task list and kept it in a state channel, &lt;strong>was removed in 0.7.0&lt;/strong> as a breaking change declared in the changelog. The tool is gone, the channel is gone, and the prompt fragment that went with it is gone.&lt;/p>
&lt;p>It survives as LangChain middleware, and you have to ask for it by hand. With one detail that bites: you have to ask for it in each subagent too, because each subagent assembles its own stack. All that remains from the previous stage are archaeological leftovers, such as the state key that is still on the list of keys excluded when passing context to a subagent.&lt;/p>
&lt;p>The plan, today, rests on files. Which is consistent with the rest of the design, and is another reason to look closely at where those files live.&lt;/p>
&lt;h2 id="where-state-lives">Where state lives&lt;/h2>
&lt;p>This is the first architecture decision and the one that is most expensive to get wrong.&lt;/p>
&lt;p>The agent&amp;rsquo;s state has two large channels. Messages, with a reducer of its own that deduplicates by identifier and treats deletions as tombstones. And files, with its own reducer where a null value means deleted. Both use a channel type that writes deltas and stores a full snapshot every fifty updates, and the code comment explains why: to take checkpoint growth from quadratic to linear with respect to the number of messages.&lt;/p>
&lt;p>That channel type is marked beta in its own code, with a warning that the on-disk representation may change. Worth knowing before you rest a platform on top of it.&lt;/p>
&lt;h3 id="the-backends">The backends&lt;/h3>
&lt;p>There is a common interface and several implementations. The ones that matter for deciding:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Backend&lt;/th>
&lt;th>Where the files live&lt;/th>
&lt;th>Persistence&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>State&lt;/td>
&lt;td>Inside the graph state, that is, inside the checkpoint&lt;/td>
&lt;td>Per thread, in the checkpointer&amp;rsquo;s database&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Filesystem&lt;/td>
&lt;td>In a real directory anchored to a root&lt;/td>
&lt;td>Wherever you mount the volume&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Store&lt;/td>
&lt;td>In LangGraph&amp;rsquo;s store, with a namespace&lt;/td>
&lt;td>Crosses threads, with a time to live&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Composite&lt;/td>
&lt;td>Routed by path prefix among the above&lt;/td>
&lt;td>Mixed&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The default backend is the state one. That is: &lt;strong>by default, everything the agent writes ends up inside the checkpoint&lt;/strong>, and binaries go base64-encoded inside the same JSON. For a demo it is convenient, because you have nothing to set up. For a platform with real users it is a sizing decision taken by omission.&lt;/p>
&lt;p>There is a detail that rounds the matter off. The filesystem middleware automatically evicts large tool results to a file when they exceed a token threshold, so that they do not occupy the context window. It is a good mechanism. But with the state backend, evicting to a file means moving the content from one place in the checkpoint to another place in the same checkpoint. It leaves the model&amp;rsquo;s window and it does not leave the database.&lt;/p>
&lt;h3 id="what-grows-in-the-database">What grows in the database&lt;/h3>
&lt;p>The Postgres checkpointer creates four tables: one for migrations, one for checkpoints, one for blobs by channel and version, and one for writes by task. The primary keys all start with the thread identifier, and there is an index per table on that column.&lt;/p>
&lt;p>What you have to internalise is the rhythm. One checkpoint row is written &lt;strong>per engine superstep&lt;/strong>, not per conversation turn. A turn with four tool calls is several supersteps. None of that is recycled on its own.&lt;/p>
&lt;p>And here comes the uncomfortable part. The checkpointer&amp;rsquo;s base class declares four maintenance operations: delete a thread, delete by run, copy a thread and prune with a strategy. In the Postgres implementation &lt;strong>only the first is implemented&lt;/strong>. The other three inherit the not-implemented exception from the base class. Deleting a thread is three delete statements by identifier, and that is it.&lt;/p>
&lt;p>In other words: retention is yours to build. And with a care that the code itself documents and that is worth quoting in full, because it is the kind of warning you read after the incident. A naive pruning that removes intermediate checkpoints and their writes can cut the delta chain; the surviving checkpoint is rarely a snapshot point, so its channels would rebuild empty &lt;strong>without any error being raised&lt;/strong>.&lt;/p>
&lt;p>Translated into operations: the periodic sweep deletes whole threads and only whole threads. Never loose rows. And since the checkpoints table has no date column, to know which thread is old you have to keep count outside or rely on the checkpoint identifier being a time-sortable UUID.&lt;/p>
&lt;p>There is a variant of the checkpointer that stores only the last state and retains no history, which caps growth at the root. With one important reservation to verify before using it: it is not clear that it is compatible with the delta channels this harness uses by default for messages and files. Combining the two without checking is asking for a silent problem.&lt;/p>
&lt;h3 id="the-store-which-does-have-cleanup">The store, which does have cleanup&lt;/h3>
&lt;p>Curiously, the sibling piece is better resolved. The store has a configurable time to live, with refresh on read, skipping of expired entries and a sweep interval, and the sweep is a delete by expiry date.&lt;/p>
&lt;p>Two warnings. The sweeper is a thread inside the process, so with several replicas there are several sweepers competing for the same delete; it is idempotent, but it is free contention and you avoid it by switching it off and putting the delete in a scheduled cluster job. And the store&amp;rsquo;s semantic search requires the vector extension in the database, which constrains the managed Postgres image, which normally does not carry it.&lt;/p>
&lt;h2 id="the-isolation-that-does-not-exist">The isolation that does not exist&lt;/h2>
&lt;p>If the platform is going to have more than one tenant, this is the section that decides the deployment topology.&lt;/p>
&lt;p>The only thing separating one thread from another is the thread identifier. It is the first column of the primary key in all three tables. &lt;strong>There is no owner check anywhere in the checkpointer.&lt;/strong> Whoever manages to send a thread identifier in the call configuration reads that thread. Authorisation is the responsibility of the layer you write on top, and since that layer also has to be written, it is worth noting down now.&lt;/p>
&lt;p>By backend, the situation differs:&lt;/p>
&lt;ul>
&lt;li>The state one inherits thread isolation, which is enough if the HTTP layer validates that the user owns the thread.&lt;/li>
&lt;li>The store one computes the namespace per call, through a function that receives the runtime context. It is the correct option for multi-tenant.&lt;/li>
&lt;li>The filesystem one fixes its root &lt;strong>in the constructor&lt;/strong>, when the agent is built. There is no path in the code that recomputes it per request, per thread or per tenant. Sharing one deployment between tenants with this backend means everybody sees the same directory.&lt;/li>
&lt;/ul>
&lt;p>On that last point, do not confuse it with the protection that does exist. Virtual mode, which now comes enabled by default, blocks paths that escape the root. That protects against directory traversal. It does not protect against the neighbour. The project&amp;rsquo;s own threat document admits it: that mode exists to support the composite backend&amp;rsquo;s prefix routing, not as a security boundary.&lt;/p>
&lt;p>There is also a documentation trap worth flagging because it costs an afternoon. The example the documentation proposes for computing the per-user namespace reads the identity from a server-information field of the runtime context. That field is annotated in the LangGraph code as metadata injected by the managed server, and &lt;strong>null when running open-source LangGraph without managed deployments&lt;/strong>. On a cluster of your own with an HTTP layer of your own, that example fails. What you have to use is the context you fill in yourself, with the identifier that comes validated from the identity provider.&lt;/p>
&lt;p>And one more boundary that gets crossed without warning: the memory and skills middleware interpolate file content into the system prompt as is, without sanitising. If two tenants share a directory that serves as the source for that, the first writes instructions that the second&amp;rsquo;s agent executes. It is prompt injection through shared storage, and it is avoided with the same measure as everything above: do not share the filesystem backend between tenants.&lt;/p>
&lt;p>The topology conclusion is short. Real multi-tenant with files means &lt;strong>one deployment per tenant&lt;/strong>, with its namespace, its volume and its quota, or else the store backend with a namespace computed per request. There is no third way.&lt;/p>
&lt;h2 id="the-model-and-the-trap-you-need-to-know-about">The model, and the trap you need to know about&lt;/h2>
&lt;p>The default model is a specific commercial provider, and it is marked deprecated: passing an empty model warns and will stop working. You can inject any LangChain chat model, which is what anyone serving their own weights will do.&lt;/p>
&lt;p>With two integration details that are not obvious.&lt;/p>
&lt;p>The first. To point at an OpenAI-compatible endpoint, which is how a vLLM behind a gateway looks, you have to pass &lt;strong>the already-built instance&lt;/strong>, not the string with the provider prefix. With the string, a provider profile is applied that forces the use of the responses API, which a vLLM normally does not implement. Passing the built object applies no profile and the agent stays clean.&lt;/p>
&lt;p>The second is the trap, and in my view it is the most likely configuration failure in the whole deployment.&lt;/p>
&lt;p>The compaction middleware computes its thresholds from the model profile. If the model declares its input window, it compacts on reaching 85 % of the window and keeps 10 %. If it does not declare it, it falls back to a fixed value: &lt;strong>170,000 tokens&lt;/strong>.&lt;/p>
&lt;p>The chain that leads to not declaring it is the usual one in a deployment of your own. The OpenAI client resolves the profile by looking the model name up in a static table of the provider&amp;rsquo;s models. A model served as &lt;code>qwen3-30b-a3b&lt;/code> is not in that table, so the profile comes out empty, and empty becomes null. The method that assigns it swallows any exception, so there is no warning.&lt;/p>
&lt;p>Result: a vLLM with a 32,768-token window will not compact until 170,000. The server will return a context-length error long before that, and it will do so intermittently, only when conversations get long. There is a safety net that catches the error and trims, but that is reactive recovery after a failed call, not planning.&lt;/p>
&lt;p>The fix is one line, because the profile is a public field and only fills itself in when it is empty: you pass it when building the model, with a value below the real window to leave room for generation. And it deserves an &lt;code>assert&lt;/code> at start-up, because it is the kind of thing nobody looks at until it fails.&lt;/p>
&lt;p>A related sizing note. With the default thresholds and a 32,768 window, compaction would trigger at 27,852 tokens, while eviction of large tool results is fixed at 20,000. That is, a single tool result can occupy almost the whole budget before anything evicts it. With 128,000 windows the defaults are proportionate; below 64,000 you have to lower those two thresholds by hand.&lt;/p>
&lt;h2 id="the-tools-and-the-mcp-that-is-not-where-it-seems">The tools, and the MCP that is not where it seems&lt;/h2>
&lt;p>This harness has its filesystem tools and its delegation tool, and the rest are passed as an ordinary list of LangChain tools.&lt;/p>
&lt;p>The fact that matters to anyone with an MCP gateway in place: &lt;strong>the SDK does not integrate MCP&lt;/strong>. There is no reference to the protocol in the package, nor any dependency on adapters. The integration lives in the command-line tool that accompanies the project, which does depend on the MCP adapters for LangChain, resolves configurations in the style of the desktop clients, and turns each remote tool into a LangChain tool that then goes through the ordinary list.&lt;/p>
&lt;p>So the path exists and is proven, but you have to walk it yourself: load the gateway&amp;rsquo;s tools with the adapters and pass them in the list. Which, incidentally, fits well with &lt;a href="https://blog.lo0.es/en/posts/mcp-tool-priority-visibility-and-exclusion/">what we already know about the catalogue&lt;/a>: the credential-based filtering the gateway does is applied in the listing, so each agent receives the subset that belongs to it without the harness having to know anything.&lt;/p>
&lt;h3 id="the-subagents">The subagents&lt;/h3>
&lt;p>They are declared in three ways and invoked with a task tool that takes a description and a type. What you need to know to design with them:&lt;/p>
&lt;p>The subagent receives the parent&amp;rsquo;s state &lt;strong>except&lt;/strong> the messages and a few private keys, and it starts with a single human message which is the task description. Files &lt;strong>are&lt;/strong> inherited, and it shares the same backend object, so parent and child work on the same logical filesystem. The result comes back as a tool message with the structured response or the child&amp;rsquo;s last text.&lt;/p>
&lt;p>Depth is effectively one level. Verified by compiling the graph: the stack assembled for a declarative subagent does not include the subagent middleware, so the child does not have the task tool and cannot delegate in turn.&lt;/p>
&lt;p>And there is no concurrency limit of its own. The parallelism is that of the engine&amp;rsquo;s parallel tool calls, and the task tool&amp;rsquo;s prompt explicitly invites launching them in parallel. Anyone with a per-tenant GPU quota will want to put the brake where it belongs, because it is not here.&lt;/p>
&lt;h2 id="code-execution">Code execution&lt;/h2>
&lt;p>The sandbox protocol is well defined and the packaged implementations are nearly all paid services: four commercial providers, plus an embedded JavaScript interpreter and a sandbox from the platform vendor itself.&lt;/p>
&lt;p>That leaves a local shell backend that is worth looking at closely before considering it. It runs the command with the system shell, and its own docstring lists what it does not do: no isolation, no process separation, no resource limits, and it lists production and multi-tenant environments as inappropriate use cases. It also has an option to inherit the process environment which, enabled, hands the model every variable, including the gateway credentials and the database connection string. It comes disabled, and it is an easy foot to shoot.&lt;/p>
&lt;p>The reasonable way out on a cluster of your own is to implement the protocol yourself, and it is smaller than it looks. The sandbox base class requires four things: an identifier, execute, upload files and download files. Everything else, list, read, write, edit, delete and search, is built by the base class on top of execute. Looking at one of the commercial implementations, it is on the order of a hundred and fifty lines.&lt;/p>
&lt;p>One image requirement that constrains the Dockerfile and is worth knowing beforehand: the base class helpers inject small encoded Python scripts to resolve searches and checks, so the sandbox image &lt;strong>needs Python and a POSIX shell&lt;/strong> or half the calls fail.&lt;/p>
&lt;p>On how to implement it, the shape that fits a Kubernetes of your own is an ephemeral container per session with a bounded time to live, resource limits, no service account token mounted and a network policy that only allows egress towards the inference gateway. Execute is resolved against the cluster&amp;rsquo;s exec API, and upload and download through the same channel. The alternative with less code is an isolated runtime class for the agent pod, one pod per tenant, and the shell backend inside; you lose multi-tenancy in a single pod and you have nothing to write.&lt;/p>
&lt;h2 id="observability-and-the-package-it-goes-through">Observability, and the package it goes through&lt;/h2>
&lt;p>Here there is a fact that surprises and that is worth being clear about before designing the trace pipeline.&lt;/p>
&lt;p>&lt;strong>LangChain&amp;rsquo;s core does not emit OpenTelemetry.&lt;/strong> Zero references in the whole package. Its native tracer talks to the vendor&amp;rsquo;s observability service and that is that.&lt;/p>
&lt;p>The OTLP exporter does exist, but it lives inside that service&amp;rsquo;s SDK. And there is the good news: that SDK has a tracing mode that emits &lt;strong>only&lt;/strong> over OTLP, without talking to the service, reading the destination and headers from OpenTelemetry&amp;rsquo;s standard environment variables. The transport is HTTP with protobuf, which is exactly what a self-hosted Langfuse accepts on its ingestion route.&lt;/p>
&lt;p>That is: to take the agent&amp;rsquo;s traces to your own observability you have to install the vendor&amp;rsquo;s package and ask it not to talk to the vendor. It works, it is supported and it is documented, but it is a dependency you have to declare in the supply chain analysis, not a configuration detail. And if the OpenTelemetry packages are missing, it emits a warning and silently stops tracing, so start-up should check for it.&lt;/p>
&lt;p>There is the alternative of the callback handler that Langfuse itself publishes, with fewer pieces. The practical difference is correlation: over the OTLP route the agent&amp;rsquo;s spans share a trace identifier with the gateway&amp;rsquo;s and the inference engine&amp;rsquo;s, and you see a whole request end to end. Over the callback route, you do not. For anyone who already has a collector deployed, the OTLP route is the one that pays.&lt;/p>
&lt;h2 id="the-server-here-is-where-the-free-part-ends">The server: here is where the free part ends&lt;/h2>
&lt;p>Everything above is an architecture decision. This is a licence decision, and it is the one that gives the article its title.&lt;/p>
&lt;p>The harness SDK is MIT. The graph engine is MIT. The memory, SQLite and Postgres checkpointers are MIT. All of that runs anywhere, with no key, no registration and without calling anybody.&lt;/p>
&lt;p>The server that exposes a graph over HTTP is another matter. The package that implements it carries an &lt;strong>Elastic 2.0 licence&lt;/strong>, whose relevant clauses forbid offering the software to third parties as a managed service and forbid modifying or circumventing the licence key functionality. The command-line tool&amp;rsquo;s own command prints the conditions: for local development it asks for a service API key, and &lt;strong>for production use it asks for a licence key&lt;/strong> in an environment variable.&lt;/p>
&lt;p>There is also usage reporting. The server code contains a beacon endpoint to which it sends metadata, with a header carrying the licence key. The self-hosting documentation confirms it from the other side, listing among the requirements network egress to that domain for licence verification and usage reporting &lt;strong>if not running in isolated mode&lt;/strong>. Isolated mode is a contractual variant, not a box you tick.&lt;/p>
&lt;p>And it drags infrastructure along: besides Postgres, it asks for Redis.&lt;/p>
&lt;p>The development tool, which is the one that appears in every tutorial, describes itself as development mode with hot reload and an in-memory server. It is not a production server and it does not claim to be.&lt;/p>
&lt;p>None of this is a reproach. It is a legitimate and fairly common business model: permissive core, commercial control plane. What is not legitimate is finding out late. Anyone building a platform with a sovereignty requirement, with no dependencies on external services and with the argument that the whole stack is open, has to know that &lt;strong>the piece that turns the graph into a service does not meet that requirement&lt;/strong>.&lt;/p>
&lt;h2 id="what-you-have-to-build">What you have to build&lt;/h2>
&lt;p>The way out is to write the HTTP layer yourself, and the good news is that it is boring. A service that compiles the graph once with the checkpointer pointing at the database, and exposes an endpoint that validates the identity provider&amp;rsquo;s token, derives the thread identifier and the tenant context, and invokes the graph. Event streaming if needed.&lt;/p>
&lt;p>What you additionally have to write, and which the commercial server gave you ready-made:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Per-thread authorisation.&lt;/strong> The engine does not have it. It is the most important and the easiest to forget, because nothing fails if it is missing.&lt;/li>
&lt;li>&lt;strong>Retention and deletion.&lt;/strong> A scheduled job that deletes whole threads, never loose rows, for the reasons in the state section.&lt;/li>
&lt;li>&lt;strong>Migrations.&lt;/strong> Schema setup has to be called explicitly and needs data-definition permissions, so it goes in a separate job with a different role from the runtime one. And since it creates indexes concurrently, it cannot go through a connection pooler in transaction mode; point it at the direct write service.&lt;/li>
&lt;li>&lt;strong>Health probes and draining.&lt;/strong> The engine has a cooperative draining mechanism, but wiring it to the container&amp;rsquo;s termination signal is your own work.&lt;/li>
&lt;li>&lt;strong>Run queue and cancellation&lt;/strong>, if you need long background runs. This is where the absence is most felt, and the reasonable answer is a queue on the same database rather than another system.&lt;/li>
&lt;li>&lt;strong>Durability decided deliberately.&lt;/strong> There are three modes: persist before the next step, persist in parallel, or persist only on exit. The last one is no good on Kubernetes: if the pod dies, the whole turn is lost.&lt;/li>
&lt;/ul>
&lt;p>On that last point it is worth being explicit, because it affects tool design. The engine stores each task&amp;rsquo;s writes as soon as it finishes, and on resume it reapplies those that were already there and does not repeat them. A tool that finished is not run again. A tool &lt;strong>that was in flight when the pod died&lt;/strong> is, because its write never landed. The semantics are at-least-once, so every tool with an external effect has to be idempotent or carry an idempotency key. There is no exactly-once.&lt;/p>
&lt;p>And the same applies to interrupts for human confirmation: on resume, the node is &lt;strong>re-executed in full&lt;/strong>, so any side effect that sat before the interrupt inside the same node is repeated. The rule is that the interrupt goes first.&lt;/p>
&lt;h2 id="how-it-fits-with-activity-logging">How it fits with activity logging&lt;/h2>
&lt;p>Two things about this setup touch compliance head on, and both come out of earlier sections.&lt;/p>
&lt;p>The first is that the checkpoint is, de facto, a repository of personal data. It contains the whole conversation and, with the default backend, the files the agent has written. If the platform serves identifiable people, that database needs encryption, retention of its own and separate access control, exactly like the gateway&amp;rsquo;s spend table. The difference is that here there is no redaction switch that helps: the state is the state.&lt;/p>
&lt;p>The second is traceability. An agent that delegates to subagents and executes tools produces a chain of actions that you have to be able to reconstruct, and the only place where that chain is complete is the trace. Traces going out over OTLP to your own observability stops being a technical preference and becomes the logging mechanism. It is worth designing it that way from the start, and not adding it afterwards.&lt;/p>
&lt;p>As always in this series, the annex&amp;rsquo;s specific codes have to be checked against the current text before taking them into a compliance document; the approach is developed in &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">the technical controls article&lt;/a>.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Decide the filesystem backend before the first demo, because the default one puts the content in the database.&lt;/li>
&lt;li>Pass the model profile by hand with the real window, and check it at start-up.&lt;/li>
&lt;li>Lower the eviction thresholds if the model&amp;rsquo;s window is below 64,000 tokens.&lt;/li>
&lt;li>Build the model as an instance, never as a string with a provider prefix, if there is an OpenAI-compatible endpoint behind it.&lt;/li>
&lt;li>Write per-thread authorisation before opening the service to more than one user.&lt;/li>
&lt;li>One deployment per tenant if you use the filesystem backend; a namespace computed per request if you use the store one.&lt;/li>
&lt;li>Compute that namespace from your own context, not from the server-information field, which is null outside the managed platform.&lt;/li>
&lt;li>A scheduled retention job that deletes whole threads and only whole threads.&lt;/li>
&lt;li>Schema migrations in a separate job, with a role of their own and against the direct write service.&lt;/li>
&lt;li>Synchronous durability, and tools with external effects made idempotent.&lt;/li>
&lt;li>Traces over OTLP with the mode that does not talk to the vendor&amp;rsquo;s service, and a start-up check that the packages are there.&lt;/li>
&lt;li>Put a concurrency limit on subagents if there is a GPU quota involved.&lt;/li>
&lt;li>If you need code execution, implement the sandbox protocol against an ephemeral container; the local shell backend is advised against by its own documentation.&lt;/li>
&lt;li>Note in the supply chain analysis the hard dependencies on commercial providers that the package drags in even when unused.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>The planning tool was removed in 0.7.0.&lt;/strong> You have to add it by hand, and in each subagent too.&lt;/li>
&lt;li>&lt;strong>The default backend stores files inside the checkpoint&lt;/strong>, binaries included in base64.&lt;/li>
&lt;li>&lt;strong>Evicting a large result to a file does not get it out of the database&lt;/strong> with that backend, only out of the context window.&lt;/li>
&lt;li>&lt;strong>One checkpoint is written per superstep&lt;/strong>, not per turn.&lt;/li>
&lt;li>&lt;strong>Of the four declared cleanup operations, only delete-a-thread exists in Postgres.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>A partial pruning can empty the history without raising any error.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The delta channel is in beta&lt;/strong> and its on-disk representation may change.&lt;/li>
&lt;li>&lt;strong>There is no owner check in the checkpointer.&lt;/strong> The thread identifier is not a credential.&lt;/li>
&lt;li>&lt;strong>The filesystem backend&amp;rsquo;s root is fixed when the agent is built&lt;/strong> and it separates no tenants.&lt;/li>
&lt;li>&lt;strong>The documentation&amp;rsquo;s example for the per-user namespace does not work outside the managed platform.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The filesystem middleware and the subagent middleware cannot be removed.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>If the model does not declare its window, compaction does not trigger until 170,000 tokens&lt;/strong>, silently.&lt;/li>
&lt;li>&lt;strong>Passing the model as a string with a provider prefix forces an API that a vLLM does not implement.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The SDK does not integrate MCP&lt;/strong>; the integration lives in the command-line tool.&lt;/li>
&lt;li>&lt;strong>Subagents do not delegate&lt;/strong>: depth is one level.&lt;/li>
&lt;li>&lt;strong>LangChain&amp;rsquo;s core does not emit OpenTelemetry&lt;/strong>; the exporter lives in the vendor&amp;rsquo;s service SDK.&lt;/li>
&lt;li>&lt;strong>The HTTP server carries an Elastic 2.0 licence&lt;/strong>, asks for a licence key for production and reports usage unless there is an isolation agreement.&lt;/li>
&lt;li>&lt;strong>The development tool is for development&lt;/strong>, with an in-memory server.&lt;/li>
&lt;li>&lt;strong>Resumption is at-least-once&lt;/strong> for in-flight tools.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The question this article started with was whether &lt;code>deepagents&lt;/code> fits into a platform of your own. The answer is that it does, and that it fits better than I expected, precisely because it is less than it appears. A middleware stack on top of a graph is easy to host, easy to understand and easy to replace. What does not fit is the piece next to it.&lt;/p>
&lt;p>That asymmetry is the pattern I have been seeing all year across the ecosystem, and it deserves naming. The core is published under a permissive licence because the core is where you compete for adoption. The control plane is published under a restrictive licence because the control plane is where you charge. It happened with the gateways, it is happening with observability, and it happens here. Anyone building a sovereign platform cannot assess a whole project by the licence of its main repository: you have to look package by package, and look at which of them is the one you are going to need the day the thing has users.&lt;/p>
&lt;p>The good part is that the bill, in this case, is paid in work and not in money. What you have to build to do without the commercial server is an HTTP service with no mystery to it, a retention job, a migration job and an authorisation layer. None of that is research. All of it is a week of an engineer who knows what they are doing, and in exchange you are left with a system that deploys the same way in a school as in a classified datacenter, with no internet egress and with nobody counting runs on the other side.&lt;/p>
&lt;p>What I do recommend is making that list before the first demo and not after. Because the front-page example works in twenty lines, and the twenty lines have the default backend, the model without a profile and no authorisation at all.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">Choosing an inference gateway&lt;/a> — the piece underneath, and the same licence pattern seen somewhere else.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a> — where the tools this harness consumes come from.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-tool-priority-visibility-and-exclusion/">MCP tool priority&lt;/a> — how it is decided which subset of the catalogue each agent sees.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a> — the real load a tool loop generates on the gateway and the engine.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key&lt;/a> — the network isolation this article takes as necessary.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/">The second cost vector of agents&lt;/a> — durable execution and what a loop that fails halfway costs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls for ENS, 42001 and the AI Act&lt;/a> — the framework where logging and retention fit.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — the destination of the OTLP traces the article talks about.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;code>langchain-ai/deepagents&lt;/code>, código y documento de arquitectura: &lt;a href="https://github.com/langchain-ai/deepagents">https://github.com/langchain-ai/deepagents&lt;/a>.&lt;/li>
&lt;li>&lt;code>langchain-ai/deepagents&lt;/code>, registro de cambios de la rama del SDK (eliminación de la herramienta de planificación en la 0.7.0): &lt;a href="https://github.com/langchain-ai/deepagents/blob/main/libs/deepagents/CHANGELOG.md">https://github.com/langchain-ai/deepagents/blob/main/libs/deepagents/CHANGELOG.md&lt;/a>.&lt;/li>
&lt;li>&lt;code>langchain-ai/langgraph&lt;/code>, motor, canales y checkpointers: &lt;a href="https://github.com/langchain-ai/langgraph">https://github.com/langchain-ai/langgraph&lt;/a>.&lt;/li>
&lt;li>&lt;code>langgraph-checkpoint-postgres&lt;/code>, esquema de tablas y operaciones de mantenimiento: &lt;a href="https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres">https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres&lt;/a>.&lt;/li>
&lt;li>&lt;code>langgraph-checkpoint&lt;/code>, clase base del checkpointer y aviso sobre poda con canales de deltas: &lt;a href="https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint">https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint&lt;/a>.&lt;/li>
&lt;li>LangChain, despliegue de servidor autónomo (requisitos de licencia, Postgres, Redis y salida de red): &lt;a href="https://docs.langchain.com/langsmith/deploy-standalone-server">https://docs.langchain.com/langsmith/deploy-standalone-server&lt;/a>.&lt;/li>
&lt;li>LangChain, trazado con OpenTelemetry: &lt;a href="https://docs.langchain.com/langsmith/trace-with-opentelemetry">https://docs.langchain.com/langsmith/trace-with-opentelemetry&lt;/a>.&lt;/li>
&lt;li>Elastic License 2.0: &lt;a href="https://www.elastic.co/licensing/elastic-license">https://www.elastic.co/licensing/elastic-license&lt;/a>.&lt;/li>
&lt;li>Langfuse, integración nativa OpenTelemetry (ruta de ingesta y autenticación): &lt;a href="https://langfuse.com/integrations/native/opentelemetry">https://langfuse.com/integrations/native/opentelemetry&lt;/a>.&lt;/li>
&lt;li>Langfuse, integración con LangChain por retrollamadas: &lt;a href="https://langfuse.com/integrations/frameworks/langchain">https://langfuse.com/integrations/frameworks/langchain&lt;/a>.&lt;/li>
&lt;li>&lt;code>langchain-ai/helm&lt;/code>, chart del servidor: &lt;a href="https://github.com/langchain-ai/helm">https://github.com/langchain-ai/helm&lt;/a>.&lt;/li>
&lt;li>Boletín Oficial del Estado, Real Decreto 311/2022, Esquema Nacional de Seguridad: &lt;a href="https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191">https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Keycloak in an AI platform: where it fits, what it solves and the two standards MCP requires and it does not implement</title><link>https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/</link><pubDate>Sat, 12 Sep 2026 07:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/</guid><description>&lt;blockquote>
&lt;p>This post opens the identity block of the operations track. &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The previous one&lt;/a> covered the gateway side: the JWT that does not validate audience, the user who never reaches the trace. This one covers the piece on the other side. Verified against Keycloak 26.7.3, revision 2026-07-28 of the MCP specification and the text of Royal Decree 311/2022 in the BOE.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Keycloak 26.7.3 was released on 31 August 2026&lt;/strong> and is still incubating at the CNCF, which it entered in April 2023. The cadence is four minor releases a year and support in practice covers a single branch: the twenty security bugs fixed in 26.7.3 were not backported to the 26.4, 26.5 or 26.6 branches at the time of release.&lt;/p>
&lt;p>&lt;strong>The Quarkus-based distribution separates build and runtime configuration.&lt;/strong> What is set in the build phase is frozen into the image, and changing it at startup forces a rebuild. In containers, the pattern is to build into the image and start with the optimised flag.&lt;/p>
&lt;p>&lt;strong>Storage is relational and only relational.&lt;/strong> The alternative engine that was in development was withdrawn in version 25.0. Since 26.0 sessions are persisted to the database by default, which changes the load profile: the dominant cost in the official benchmarks is database CPU.&lt;/p>
&lt;p>&lt;strong>Organisations solves multi-tenancy inside a realm&lt;/strong>, fully supported since 26.0. It groups members, associates email domains and links identity providers per organisation. It is the alternative to replicating realms per customer.&lt;/p>
&lt;p>&lt;strong>The measured bottleneck is password hashing.&lt;/strong> Since 25.0 the default algorithm outside FIPS environments is Argon2, with around 7 MB of memory per operation. The official sizing guide gives one vCPU for every 15 password logins per second, against 120 per second for client credentials and for refreshes.&lt;/p>
&lt;p>&lt;strong>Keycloak does not implement two standards the MCP specification marks as mandatory.&lt;/strong> Protected resource metadata belongs to the MCP server, not the authorisation server, so there is nothing to hold against it there. Resource indicators do belong to it and are &lt;strong>not supported&lt;/strong>. Keycloak&amp;rsquo;s own documentation declares partial conformance with the last three MCP revisions for that reason. The practical substitute is scopes plus an audience mapper.&lt;/p>
&lt;p>&lt;strong>There is a recent critical bug worth looking at today.&lt;/strong> &lt;code>CVE-2026-18963&lt;/code>, scored 9.1, allowed resetting any user&amp;rsquo;s password without authenticating, administrators included. Fixed in 26.7.2, released on 19 August 2026.&lt;/p>
&lt;h2 id="you-are-here-the-cross-cutting-layer">You are here: the cross-cutting layer&lt;/h2>
&lt;p>Identity is not a stage of the pipeline, it is a layer that crosses all of them. It shows up in &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">stack hardening&lt;/a> as one of the seven layers of defence, in &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">the model&amp;rsquo;s chain of trust&lt;/a> as workload identity, in &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">the gateway&lt;/a> as token validation, and in &lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">the post about authenticating MCP servers&lt;/a> as authorisation server.&lt;/p>
&lt;p>What was missing was the post about the piece itself, and above all the decision of where to put it and what to expect from it.&lt;/p>
&lt;h2 id="the-analogy-the-civil-registry-and-building-permits">The analogy: the civil registry and building permits&lt;/h2>
&lt;p>A small town hall has a population register, which says who lives there, and it has permits, which say who can do what. They are two different things and it is worth not confusing them, because the register is good for the whole town while a permit is good for one specific job on one specific street.&lt;/p>
&lt;p>Keycloak is the register. It knows who each person is, identifies them reliably, knows which groups they belong to and issues a credential document that expires. What it does not do is decide whether this person can modify this particular file: that is the permit, and it lives in each service.&lt;/p>
&lt;p>Confusing the two produces the most expensive design mistake in platforms of this kind, which consists of putting into the token everything that will need to be decided later. The register document grows, gets handed out everywhere, and when a permission changes you have to wait for everyone&amp;rsquo;s document to expire.&lt;/p>
&lt;p>There is a third element the analogy captures well. A credential document issued for presenting at the tax office should not be good for entering the municipal depot. That is the token&amp;rsquo;s audience, and it is the central subject of the second half of this post.&lt;/p>
&lt;h2 id="part-1-what-keycloak-is-today">Part 1. What Keycloak is today&lt;/h2>
&lt;h3 id="project-status">Project status&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Item&lt;/th>
&lt;th>Value&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Latest stable version&lt;/td>
&lt;td>26.7.3, released on 31 August 2026&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Previous ones&lt;/td>
&lt;td>26.7.2 (19 August), 26.7.0 (9 July), 26.6.0 (8 April)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cadence&lt;/td>
&lt;td>four minor releases a year&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CNCF&lt;/td>
&lt;td>incubating since 10 April 2023, not graduated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Support&lt;/td>
&lt;td>the current branch; earlier ones do not receive fixes reliably&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>That last point deserves underlining because it conditions the upgrade policy. The analysis of 26.7.3 shows that its twenty security fixes were not backported to earlier branches at release time. Operating Keycloak means keeping up to date with the current branch, not staying on a &amp;ldquo;stable&amp;rdquo; version for a year.&lt;/p>
&lt;p>Since 26.0 there is a backward compatibility guarantee across minor versions for features marked as fully supported, and breaking changes are opt-in.&lt;/p>
&lt;h3 id="the-internal-architecture-in-five-pieces">The internal architecture in five pieces&lt;/h3>
&lt;p>&lt;strong>Two startup modes.&lt;/strong> The development one enables HTTP, does not resolve the hostname strictly and disables the theme cache. The production one requires a hostname and TLS, or it will not start. Confusing them in production is the first antipattern on the final list.&lt;/p>
&lt;p>&lt;strong>The build phase.&lt;/strong> The build command materialises the optimisations into the image. Build options, such as the database engine, the enabled features or metrics, are frozen. Runtime ones, such as the database password or the hostname, are applied at startup. Changing a build option at startup forces a rebuild with the corresponding latency cost. The correct pattern in containers is to build into the image and start with the optimised flag.&lt;/p>
&lt;p>&lt;strong>Extensions via SPI.&lt;/strong> Authenticators, protocol mappers, user storage, event listeners, hashing algorithms and secret vaults. They are packaged as JARs and resolved in the build phase.&lt;/p>
&lt;p>&lt;strong>Infinispan cache.&lt;/strong> Embedded by default, with database-based discovery as the recommended mechanism. The discovery alternatives via Kubernetes DNS, TCP and UDP are deprecated. There are local caches, with default capacities of 10,000 entries in several of them, and replicated caches for sessions, client sessions, offline sessions, authentication sessions, login failures and action tokens. Remote mode, with an external Infinispan, is needed for first-generation multi-site deployment.&lt;/p>
&lt;p>&lt;strong>Relational storage.&lt;/strong> JPA over a database. The alternative engine was removed in version 25.0. The databases supported today include PostgreSQL from 14 to 18, MariaDB and MySQL in their extended support versions, SQL Server 2019 and 2022, Oracle 19c and 23.x, and the managed variants Aurora PostgreSQL and Azure SQL.&lt;/p>
&lt;h3 id="the-minimum-conceptual-model">The minimum conceptual model&lt;/h3>
&lt;p>Before touching anything you need to be clear on eight concepts, and the order in which they relate:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Realm&lt;/strong>: the isolation boundary. Its own users, its own signing keys, its own policies. Two realms share nothing.&lt;/li>
&lt;li>&lt;strong>Client&lt;/strong>: an application that asks for tokens. Public if it cannot keep a secret, such as a browser application; confidential if it can, such as a service; and bearer-only if it merely validates tokens without requesting them.&lt;/li>
&lt;li>&lt;strong>Client scope&lt;/strong>: a reusable bundle of scopes and mappers, assigned to clients as default or as optional. It is the unit with which you bound what a token carries.&lt;/li>
&lt;li>&lt;strong>Protocol mapper&lt;/strong>: what projects an attribute, a role or an audience into the token. The audience mapper is the one that solves the central problem of this post.&lt;/li>
&lt;li>&lt;strong>Role&lt;/strong>, realm or client, and &lt;strong>composite role&lt;/strong>, which aggregates others.&lt;/li>
&lt;li>&lt;strong>Group&lt;/strong>, hierarchical, with inherited roles and attributes.&lt;/li>
&lt;li>&lt;strong>Identity provider&lt;/strong>: federation towards another external OIDC or SAML. &lt;strong>User federation&lt;/strong>: LDAP, Active Directory and Kerberos, with synchronisation and attribute mapping.&lt;/li>
&lt;li>&lt;strong>Authentication flow&lt;/strong>: a sequence of executions with required, alternative, conditional or disabled requirements, and subflows. It is where you build a second factor or a check of your own.&lt;/li>
&lt;/ul>
&lt;p>And one feature that changes architectures: &lt;strong>organisations&lt;/strong>, fully supported since 26.0. It solves multi-tenancy inside a single realm, grouping members, associating email domains and linking identity providers per organisation, so that provider discovery is done by the user&amp;rsquo;s email domain. The classic alternative, one realm per customer, multiplies administration and shares no configuration.&lt;/p>
&lt;h2 id="part-2-who-can-authenticate-against-it">Part 2. Who can authenticate against it&lt;/h2>
&lt;p>This is the table you need before drawing anything. The right-hand column is usually the one that decides.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Protocol&lt;/th>
&lt;th>Cost&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Kubernetes API&lt;/td>
&lt;td>native OIDC, or the structured authentication configuration file&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM&lt;/td>
&lt;td>OIDC and JWT, plus UI SSO&lt;/td>
&lt;td>&lt;strong>JWT and SSO above five users: Enterprise&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>vLLM&lt;/td>
&lt;td>static key only&lt;/td>
&lt;td>No OIDC&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse&lt;/td>
&lt;td>generic OIDC and Keycloak provider&lt;/td>
&lt;td>SSO free; per-project access control: Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Grafana&lt;/td>
&lt;td>generic OAuth &lt;strong>yes in the open edition&lt;/strong>&lt;/td>
&lt;td>Team synchronisation: Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Backstage&lt;/td>
&lt;td>generic OIDC and community-maintained Keycloak provider&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Open WebUI&lt;/td>
&lt;td>OIDC, with role management by claim&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LibreChat&lt;/td>
&lt;td>generic OIDC, with Keycloak documented&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Argo CD&lt;/td>
&lt;td>native OIDC or Dex, with groups into its RBAC file&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MinIO&lt;/td>
&lt;td>OIDC with role assumption via web identity&lt;/td>
&lt;td>See the note below&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two clarifications that change decisions.&lt;/p>
&lt;p>&lt;strong>Kubernetes.&lt;/strong> The modern mechanism is structured authentication configuration, which allows several JWT issuers plus validations and mappings with CEL expressions. The corresponding proposal, KEP-3331, went alpha in 1.29, beta in 1.30 and is &lt;strong>stable since 1.34&lt;/strong>. It is the route to use today, and it replaces the loose API server flags.&lt;/p>
&lt;p>&lt;strong>vLLM has no identity.&lt;/strong> All there is is a static key via environment variable. This is not a defect to fix, it is an architectural fact: the engine sits behind the gateway and its access control is network-based, not identity-based. Any design that exposes vLLM directly to users is giving up per-person traceability.&lt;/p>
&lt;p>About MinIO a note of honesty is in order: the public documentation now redirects to the commercial edition, so the detail of how it maps policies from a claim is verified there and not in the open edition. Anyone depending on that mechanism should confirm it against their own version before designing on top of it.&lt;/p>
&lt;h2 id="part-3-humans-and-machines">Part 3. Humans and machines&lt;/h2>
&lt;p>They are two different problems and it is worth solving them separately.&lt;/p>
&lt;h3 id="people">People&lt;/h3>
&lt;p>Authorization code with PKCE, with the S256 method. The MCP specification requires it when the client is technically capable, and it goes further: it mandates &lt;strong>rejecting the flow&lt;/strong> if the authorisation server metadata does not publish the supported challenge methods.&lt;/p>
&lt;h3 id="workloads-with-no-person-behind-them">Workloads with no person behind them&lt;/h3>
&lt;p>Here the obvious option is client credentials with a Keycloak service account, authenticated with a secret or, better, with a JWT signed by private key.&lt;/p>
&lt;p>But there is a less known and better option for workloads inside the cluster: &lt;strong>federated client authentication&lt;/strong>, introduced in January 2026 under the banner of doing away with secrets. It lets a client authenticate with a token issued by an external provider, and one of those providers is Kubernetes. The announcement placed full support for the Kubernetes service account variant and for OIDC in version 26.6; the SPIFFE variant, by contrast, expressly says it will stay in preview until the SPIFFE client authentication standard is finalised at the IETF.&lt;/p>
&lt;p>That means a pod can authenticate against Keycloak with the token the kubelet already projects into it, with no additional secret to rotate. It is worth confirming the exact status and the flag name against the release notes of whichever version you have deployed, because the feature is recent and has changed status between 26.5 and 26.6. It is preferable to token exchange, whose standard version is supported but is internal-to-internal only, while the old one, which covered external-to-internal and impersonation, is deprecated and in preview.&lt;/p>
&lt;h3 id="keycloak-against-spiffe">Keycloak against SPIFFE&lt;/h3>
&lt;p>The question always comes up and the answer is that they solve different things with a small overlap.&lt;/p>
&lt;p>SPIRE attests the node and the process, and issues short-lived identity documents with no prior secret. Keycloak does not attest workloads: it trusts a credential someone had to place there, except in the federated authentication case just mentioned, where the one attesting is Kubernetes. In exchange, Keycloak federates people, issues tokens with business data and governs consent, things SPIFFE does not do.&lt;/p>
&lt;p>The practical rule: SPIFFE for service-to-service identity inside the perimeter, Keycloak for anything that has a person behind it or crosses the perimeter. The SPIFFE detail is in &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">the close of the chain of trust series&lt;/a>.&lt;/p>
&lt;h2 id="part-4-mcp-and-the-two-missing-standards">Part 4. MCP, and the two missing standards&lt;/h2>
&lt;p>This part has the most consequences and is the worst documented outside the primary sources.&lt;/p>
&lt;h3 id="what-the-specification-requires">What the specification requires&lt;/h3>
&lt;p>From revision 2026-07-28, with the normative words as they appear:&lt;/p>
&lt;ul>
&lt;li>The MCP server &lt;strong>must&lt;/strong> implement RFC 9728 protected resource metadata, and the client &lt;strong>must&lt;/strong> use it to discover the authorisation server.&lt;/li>
&lt;li>The client &lt;strong>must&lt;/strong> implement RFC 8707 resource indicators, including the &lt;code>resource&lt;/code> parameter &lt;strong>in both the authorisation request and the token request&lt;/strong>, and &lt;strong>must&lt;/strong> send it regardless of whether the authorisation server supports it.&lt;/li>
&lt;li>The MCP server &lt;strong>must&lt;/strong> validate that the access token was issued specifically for it as the audience, and &lt;strong>must not&lt;/strong> accept or relay any other token.&lt;/li>
&lt;li>Authorisation servers and clients &lt;strong>should&lt;/strong> support client identifier metadata documents. RFC 7591 dynamic registration becomes optional and is declared deprecated, retained for compatibility.&lt;/li>
&lt;/ul>
&lt;p>The revision also adds the issuer in the authorisation response, from RFC 9207, and a stateless model with state handlers where it is expressly stated that holding a state handler is not equivalent to being authenticated.&lt;/p>
&lt;h3 id="what-keycloak-supports">What Keycloak supports&lt;/h3>
&lt;p>From its own MCP integration guide:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Standard&lt;/th>
&lt;th>Keycloak&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>OAuth 2.1 and authorisation server metadata&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RFC 9207, issuer in the response&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RFC 7591, dynamic registration&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Client identifier metadata document&lt;/td>
&lt;td>Supported, &lt;strong>experimental&lt;/strong>, after enabling the feature&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RFC 8707, resource indicators&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Not supported&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RFC 9728, protected resource metadata&lt;/strong>&lt;/td>
&lt;td>Out of scope, it belongs to the MCP server&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The conformance declared by the project itself is &amp;ldquo;partially supported, without resource indicators&amp;rdquo; for the last three MCP revisions.&lt;/p>
&lt;p>Two practical conclusions:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The protected resource metadata document is published by the MCP server or a proxy in front of it&lt;/strong>, never by Keycloak. That is correct and there is nothing to fix, but you have to remember to implement it, because the client needs it to discover where to go.&lt;/li>
&lt;li>&lt;strong>Without resource indicators, the audience is bounded with scopes.&lt;/strong> The official route is to use the &lt;code>scope&lt;/code> parameter instead of &lt;code>resource&lt;/code>, and to hang from that client scope an audience mapper with the MCP server URL as the included custom audience.&lt;/li>
&lt;/ol>
&lt;p>It is a workaround, it works, and it has to be documented in the design because anyone coming after will look for the standard parameter and will not find it.&lt;/p>
&lt;h2 id="part-5-the-confused-deputy">Part 5. The confused deputy&lt;/h2>
&lt;p>With the above on the table, the classic mistake becomes obvious on its own. A token with a generic audience turns any compromised service into a master key: whoever obtains the token a user presented to the dashboard can present it to the gateway, and from there to the MCP server.&lt;/p>
&lt;p>The MCP specification is explicit in three sentences worth quoting verbatim in any architecture document:&lt;/p>
&lt;ul>
&lt;li>MCP servers &lt;strong>must not&lt;/strong> accept tokens that were not explicitly issued for them.&lt;/li>
&lt;li>If the MCP server calls upstream APIs, it &lt;strong>must not&lt;/strong> forward the token it received from the client.&lt;/li>
&lt;li>MCP proxies that use static client identifiers &lt;strong>must&lt;/strong> obtain user consent for each dynamically registered client before forwarding to third-party authorisation servers.&lt;/li>
&lt;/ul>
&lt;p>Translated into the specific figure this track has been dealing with: &lt;strong>the gateway must not forward the user&amp;rsquo;s JWT to third-party MCP servers&lt;/strong>. It has to obtain a different token with the right audience, or act as an OAuth client in its own right. By default, as already documented &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in the MCP gateway post&lt;/a>, the behaviour is to use the server&amp;rsquo;s own credential, which is the correct one; there are two modes that forward the caller&amp;rsquo;s token and both have a name in the specification.&lt;/p>
&lt;h2 id="part-6-two-hours-of-task-against-five-minutes-of-token">Part 6. Two hours of task against five minutes of token&lt;/h2>
&lt;p>The specification pushes towards short-lived tokens and mandatory refresh token rotation in public clients. A two-hour agentic task comfortably exceeds the life of an access token and may also exceed session idle time if nobody refreshes.&lt;/p>
&lt;p>Three real options, with their cost:&lt;/p>
&lt;p>&lt;strong>Offline tokens.&lt;/strong> They survive session expiry, and expire through their own idle timeout or through a maximum if that limit is enabled. The risk is obvious: a near-permanent credential, hard to inventory. And the MCP specification adds that MCP servers &lt;strong>should not&lt;/strong> include that scope either in the authentication header or in the supported scopes of their metadata.&lt;/p>
&lt;p>&lt;strong>Automatic refresh in the client.&lt;/strong> It is the right answer when there is a client that can implement it, with refresh token rotation and secure storage. For an agent with no person behind it, it is better to renew via client credentials.&lt;/p>
&lt;p>&lt;strong>Long-lived virtual keys in the gateway.&lt;/strong> It works, and it is what most people end up doing. The price has to be written down in the risk analysis: identity ends up decoupled from the provider, so deactivating someone in Keycloak does not invalidate their key, and traceability back to the real person depends on the discipline with which the keys were created. The levers that compensate are expiry, automatic rotation and a mandatory alias, covered &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">in the virtual keys post&lt;/a>.&lt;/p>
&lt;p>A note of honesty about the default values. The numbers going around (five minutes of access token, thirty of session idle time, ten hours of maximum) do not appear published in the administration guide and I did not locate them in the code. Treat them as a console observation and confirm them in your own realm before quoting them in a document.&lt;/p>
&lt;h2 id="part-7-sizing-and-operating">Part 7. Sizing and operating&lt;/h2>
&lt;h3 id="on-kubernetes">On Kubernetes&lt;/h3>
&lt;p>The operator exposes three custom resources: the server, the realm import and the OIDC client. Probes and metrics live on &lt;strong>management port 9000&lt;/strong>, with the health and readiness paths, and metrics in Prometheus format provided the corresponding option was enabled in the build phase. That port is not exposed publicly.&lt;/p>
&lt;p>For high availability there are three shapes. A single cluster spread across zones, with no additional infrastructure. First-generation multi-cluster, with an external load balancer and an external Infinispan at each site. And second-generation multi-cluster, introduced in 26.7.0 and still in preview, which connects clusters &lt;strong>without external Infinispan&lt;/strong>, using the synchronously replicated database as the source of truth, with less cache and more database load.&lt;/p>
&lt;h3 id="the-official-sizing-figures">The official sizing figures&lt;/h3>
&lt;p>From the CPU and memory sizing concepts guide, which is still published:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>1 vCPU for every 15 password logins per second&lt;/strong>, tested up to 300 per second.&lt;/li>
&lt;li>&lt;strong>1 vCPU for every 120 client credential grants per second&lt;/strong>, tested up to 2,000.&lt;/li>
&lt;li>&lt;strong>1 vCPU for every 120 token refresh requests per second&lt;/strong>, tested up to 435.&lt;/li>
&lt;li>Leave a &lt;strong>150 % CPU headroom&lt;/strong> for spikes, startup and failover.&lt;/li>
&lt;li>&lt;strong>1,250 MB of memory per pod&lt;/strong> with realm data and 10,000 cached sessions. Keycloak allocates 70 % of the limit to the heap and around 300 MB outside it, so the limit comes from subtracting those 300 MB from the expected usage and dividing by 0.7.&lt;/li>
&lt;/ul>
&lt;p>The stated assumptions behind those figures include Argon2 with five iterations, sessions in the database and the default cache of 10,000 entries.&lt;/p>
&lt;p>The difference between 15 and 120 is the whole story of Keycloak&amp;rsquo;s performance: &lt;strong>the cost is in password hashing&lt;/strong>. Since 25.0, the default algorithm outside FIPS environments is Argon2, with around 7 MB of memory per operation. Before it was PBKDF2, whose iterations were multiplied by ten in version 24. In FIPS mode PBKDF2 is still used.&lt;/p>
&lt;p>From that comes an architectural consequence for an AI platform: &lt;strong>machine workloads, which go through client credentials, cost eight times less than a human login&lt;/strong>. Sizing by number of services is cheap; sizing by morning login peaks is the expensive part.&lt;/p>
&lt;h3 id="the-benchmark">The benchmark&lt;/h3>
&lt;p>From the 26.4 performance report, published on 1 October 2025: up to 2,000 logins per second and 10,000 refreshes per second with three pods, 74 vCPU in total and 8 GB per pod, over an Aurora PostgreSQL database, with almost linear vertical scaling.&lt;/p>
&lt;p>Two useful findings from that report. With 20 ms of round-trip time between sites, the 99th percentile rises from 51 to 130 ms, and the project &lt;strong>advises against deploying across different regions&lt;/strong>. And raising the cache from 10,000 to 200,000 entries brought the database CPU peak down from 77.77 % to 63.77 %, in exchange for somewhat longer garbage collection pauses.&lt;/p>
&lt;h3 id="security">Security&lt;/h3>
&lt;p>The urgent matter, if the deployment is below 26.7.2:&lt;/p>
&lt;p>&lt;strong>&lt;code>CVE-2026-18963&lt;/code>, score 9.1, critical.&lt;/strong> A bypass of the email action token in the credential reset flow allowed resetting any user&amp;rsquo;s password with no prior authentication, administrators included. Fixed in 26.7.2, released on 19 August 2026. The temporary mitigation, if you cannot upgrade, is to disable password recovery in all realms. No known exploitation was recorded as of the end of August.&lt;/p>
&lt;p>In 26.7.3 there are another nineteen, among them the missing hostname verification in the TLS certificate towards LDAP with a score of 8.8, two tenant restriction bypasses in token exchange with Microsoft and Google brokers, and two access control bypasses in organisations.&lt;/p>
&lt;p>Official hardening comes down to four decisions:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Strict hostname&lt;/strong>, which is the default value since the second generation of that configuration. The server does not infer the URL from headers, which prevents poisoning email links and redirects.&lt;/li>
&lt;li>&lt;strong>Administration console on a different hostname&lt;/strong>, and network-restricted.&lt;/li>
&lt;li>&lt;strong>Proxy headers declared explicitly&lt;/strong>, trusting only a controlled reverse proxy.&lt;/li>
&lt;li>&lt;strong>Separate management port, not exposed.&lt;/strong>&lt;/li>
&lt;/ol>
&lt;h2 id="part-8-the-mapping-to-the-ens">Part 8. The mapping to the ENS&lt;/h2>
&lt;p>The codes and titles below are verified against the official text of Royal Decree 311/2022 published in the BOE, Annex II.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Code&lt;/th>
&lt;th>Literal title&lt;/th>
&lt;th>What a central IdP contributes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>op.acc.1&lt;/td>
&lt;td>Identificación&lt;/td>
&lt;td>A unique identifier per entity, user or process that gains access&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.2&lt;/td>
&lt;td>Requisitos de acceso&lt;/td>
&lt;td>Permissions are granted by role and group, not by loose credential&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.3&lt;/td>
&lt;td>Segregación de funciones y tareas&lt;/td>
&lt;td>It supports it with roles, but the control is organisational&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.4&lt;/td>
&lt;td>Proceso de gestión de derechos de acceso&lt;/td>
&lt;td>Least privilege and a specific remote access policy&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.5&lt;/td>
&lt;td>Mecanismo de autenticación (usuarios externos)&lt;/td>
&lt;td>Flows and factors configurable per flow&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.6&lt;/td>
&lt;td>Mecanismo de autenticación (usuarios de la organización)&lt;/td>
&lt;td>Reinforcements required already at the low level&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.exp.8&lt;/td>
&lt;td>Registro de la actividad&lt;/td>
&lt;td>It contributes part of the events, not all of them&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.exp.10&lt;/td>
&lt;td>Protección de claves criptográficas&lt;/td>
&lt;td>Management and rotation of the realm signing keys&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two nuances an auditor is going to look for.&lt;/p>
&lt;p>The first, about &lt;strong>op.acc.1&lt;/strong>: the text requires every entity that accesses the system, whether user or process, to have a unique identifier. That includes agents. A virtual key shared between several agents does not comply, and that is exactly what usually happens when JWT authentication falls outside the budget.&lt;/p>
&lt;p>The second, about &lt;strong>op.exp.8&lt;/strong>: the control requires recording the identifier of the user or entity associated with the event, the date and time, what information it is performed on, the type and the result. Keycloak contributes the authentication events. The usage events are contributed by the gateway and the MCP servers, and there the two already documented gaps reappear: &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">adding and removing MCP servers generates no audit log&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">the trace user is set by the client&lt;/a>.&lt;/p>
&lt;p>The correspondence between control and product in this table is my own interpretation and must be validated by the certification body. The codes and titles are not: those are literal from the BOE.&lt;/p>
&lt;h2 id="part-9-the-configuration-to-leave-written-down">Part 9. The configuration to leave written down&lt;/h2>
&lt;p>Three concrete pieces, so as not to stay at the level of concepts.&lt;/p>
&lt;p>&lt;strong>Hardened startup&lt;/strong>, as container arguments:&lt;/p>
&lt;pre tabindex="0">&lt;code>kc.sh start --optimized \
--hostname=https://sso.ejemplo.es \
--hostname-admin=https://sso-admin.interna.ejemplo.es \
--proxy-headers=xforwarded \
--health-enabled=true \
--metrics-enabled=true \
--http-management-port=9000 \
--cache=ispn --cache-stack=jdbc-ping \
--db=postgres
&lt;/code>&lt;/pre>&lt;p>The metrics, health, cache and database options are build options: they go into the image, and passing them here forces a rebuild if they do not match.&lt;/p>
&lt;p>&lt;strong>The client scope with audience&lt;/strong>, which is the workaround forced by the lack of resource indicators. Created with the administration tool:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># one scope per protected resource&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kcadm.sh create client-scopes -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>mcp-inventario -s &lt;span class="nv">protocol&lt;/span>&lt;span class="o">=&lt;/span>openid-connect &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;attributes.&amp;#34;include.in.token.scope&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># the mapper that sets the token audience&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kcadm.sh create &lt;span class="s2">&amp;#34;client-scopes/&amp;lt;id&amp;gt;/protocol-mappers/models&amp;#34;&lt;/span> -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>aud-mcp-inventario &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">protocol&lt;/span>&lt;span class="o">=&lt;/span>openid-connect &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">protocolMapper&lt;/span>&lt;span class="o">=&lt;/span>oidc-audience-mapper &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;included.custom.audience&amp;#34;=https://mcp.ejemplo.es/inventario&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;access.token.claim&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The client asks for that scope and receives a token whose audience is only that MCP server. The MCP server validates the audience and rejects any other token, as the specification requires.&lt;/p>
&lt;p>&lt;strong>Realm import via GitOps&lt;/strong>, with the operator&amp;rsquo;s custom resource, so that the configuration does not live only in the console:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">k8s.keycloak.org/v2beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">KeycloakRealmImport&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">plataforma&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">identidad&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">keycloakCRName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sso&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">realm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">realm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">plataforma&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># without this, the recovery email is attack surface&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resetPasswordAllowed&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bruteForceProtected&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sslRequired&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">all&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="part-10-what-keycloak-does-not-solve">Part 10. What Keycloak does not solve&lt;/h2>
&lt;p>&lt;strong>Fine-grained authorisation over resources.&lt;/strong> The question &amp;ldquo;can this person see this particular trace&amp;rdquo; is a relation between objects, not a role. That problem is solved by Zanzibar-style engines, with OpenFGA and its object, relation and user tuples, or a policy language such as Cedar. Keycloak gives roles and scopes, and with those you do not model a graph of relations.&lt;/p>
&lt;p>&lt;strong>Machine secrets.&lt;/strong> Storage credentials, provider keys, database passwords. That is a secrets manager, covered in &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">stack hardening&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Cryptographic workload identity with attestation.&lt;/strong> That is SPIFFE and SPIRE. In Keycloak it exists as an identity provider in preview, which is a different thing.&lt;/p>
&lt;p>&lt;strong>Authorisation inside the model.&lt;/strong> Which MCP tool an agent may invoke, and with what arguments. The specification offers scopes, the insufficient scope error and step-up elevation, but the per-tool and per-argument decision is taken by the MCP server or a policy engine, not by the identity provider.&lt;/p>
&lt;h2 id="antipatterns">Antipatterns&lt;/h2>
&lt;ol>
&lt;li>Forwarding the user&amp;rsquo;s token from the gateway to third-party MCP servers. Explicitly forbidden by the specification.&lt;/li>
&lt;li>Static client identifier with dynamic registration and no per-client consent, which is the exact recipe for the confused deputy.&lt;/li>
&lt;li>General scopes, of the full-access kind, and publishing the entire catalogue of supported scopes in the metadata.&lt;/li>
&lt;li>Treating a state handler as authentication, which the new revision expressly forbids.&lt;/li>
&lt;li>Following OAuth discovery towards any URL, which opens the door to requests towards internal addresses. It also applies to the authorisation server when it downloads a client metadata document.&lt;/li>
&lt;li>Trust headers badly exposed in chat front ends. The Open WebUI documentation warns that an incorrect configuration allows authenticating as any user.&lt;/li>
&lt;li>Starting in development mode in production, with the hostname not resolved strictly.&lt;/li>
&lt;li>A single shared static key as the only control between the gateway and the engine.&lt;/li>
&lt;/ol>
&lt;h2 id="startup-checklist">Startup checklist&lt;/h2>
&lt;ol>
&lt;li>Check the version. Below 26.7.2 there is a critical password reset bug.&lt;/li>
&lt;li>Build the image with the build phase done and start with the optimised flag.&lt;/li>
&lt;li>Strict hostname, administration console on another hostname, separate management port and not exposed.&lt;/li>
&lt;li>Decide between organisations and one realm per tenant before creating the second tenant.&lt;/li>
&lt;li>One client scope per resource, with an audience mapper, because resource indicators are not supported.&lt;/li>
&lt;li>In the gateway, define the two audience and issuer variables. Without them none of that is validated.&lt;/li>
&lt;li>For workloads inside the cluster, look at the Kubernetes identity provider before handing out client secrets.&lt;/li>
&lt;li>Size by login peaks, not by number of services: the difference is eightfold.&lt;/li>
&lt;li>Export the Keycloak events to the same place the gateway&amp;rsquo;s go, because the activity logging control needs them together.&lt;/li>
&lt;li>Write into the design what the system does with a two-hour task, before someone solves the problem with an offline token.&lt;/li>
&lt;/ol>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>Keycloak is a mature piece that solves one bounded problem well: knowing who the caller is, and saying so verifiably in a document that expires. In a sovereign inference platform that is worth a lot, because the alternative is an inventory of static keys spread across ten services that no audit can walk through.&lt;/p>
&lt;p>What is not advisable is asking more of it. Fine-grained authorisation lives in each service, workload identity with attestation lives in SPIFFE, and the decision of which tool an agent may call lives in the MCP server.&lt;/p>
&lt;p>And there is one concrete gap worth noting down, because it does not close on its own: the MCP specification marks two standards as mandatory, and of the two that would fall to an authorisation server, resource indicators are not implemented. The workaround with scopes and an audience mapper works, but it is a workaround, and whoever comes after will look for the standard parameter. Leaving it written in the design saves an afternoon.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a>: how the gateway validates the tokens this piece issues, and the two variables you have to define.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows up: giving it authentication with Keycloak&lt;/a>: the concrete setup for MCP servers.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>: the second front door, its permissions and its lack of audit logging.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">The model&amp;rsquo;s chain of trust (4 of 4)&lt;/a>: SPIFFE, SPIRE and workload identity with attestation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets of the sovereign LLM stack&lt;/a>: where identity fits among the seven layers.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits&lt;/a>: the alternative when JWT falls outside the budget.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: ENS, ISO 42001 and the EU AI Act&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/">Completing Keycloak for MCP&lt;/a>: the follow-up: the protected resource side you have to build, token exchange and the enterprise authorisation extension.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.keycloak.org/2026/08/keycloak-2673-released">Keycloak 26.7.3&lt;/a>, 31 de agosto de 2026, y &lt;a href="https://www.keycloak.org/downloads">descargas&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/server/configuration">Configuración del servidor&lt;/a>, &lt;a href="https://www.keycloak.org/server/caching">caché&lt;/a>, &lt;a href="https://www.keycloak.org/server/db">bases de datos&lt;/a> y &lt;a href="https://www.keycloak.org/server/hostname">nombre de host&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/securing-apps/mcp-authz-server">Integración con Model Context Protocol&lt;/a>, &lt;a href="https://www.keycloak.org/securing-apps/token-exchange">intercambio de tokens&lt;/a> y &lt;a href="https://www.keycloak.org/2026/01/federated-client-authentication">autenticación federada de clientes&lt;/a>, 27 de enero de 2026.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/high-availability/multi-cluster/concepts-memory-and-cpu-sizing">Dimensionado de CPU y memoria&lt;/a> e &lt;a href="https://www.keycloak.org/high-availability/introduction">introducción a alta disponibilidad&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/2025/10/keycloak-benchmark">Bancos de pruebas de rendimiento de Keycloak 26.4&lt;/a>, 1 de octubre de 2025.&lt;/li>
&lt;li>&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization">Especificación de MCP, revisión 2026-07-28, autorización&lt;/a> y &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices">buenas prácticas de seguridad&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/kubernetes/enhancements/tree/master/keps/sig-auth/3331-structured-authentication-configuration">KEP-3331, configuración estructurada de autenticación&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.boe.es/diario_boe/xml.php?id=BOE-A-2022-7191">Real Decreto 311/2022, Anexo II&lt;/a>.&lt;/li>
&lt;li>Registro de &lt;code>CVE-2026-18963&lt;/code> en la base de datos de Red Hat y notas de la versión 26.7.3 en GitHub.&lt;/li>
&lt;li>&lt;a href="https://www.cncf.io/projects/keycloak/">CNCF, proyecto Keycloak&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The gateway does not live alone: the JWT that does not validate the audience, the user who never reaches the trace, and the seams you have to write by hand</title><link>https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/</link><pubDate>Sat, 12 Sep 2026 06:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/</guid><description>&lt;blockquote>
&lt;p>Eighth article in the operational track of the control layer. The previous seven deal with the gateway from the inside: &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the pair with Langfuse&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">day 2&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">virtual keys&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">humans and agents&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">prefix routing&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">the MCP gateway&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">sizing for agents&lt;/a>. This one deals with what surrounds it. Verified against LiteLLM 1.102.0 and Langfuse 4.35.0, both with a commit of 11 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The gateway has five neighbours: a database, a cache, an identity provider, a trace backend and a collector. Each neighbourhood has its own physics, and these are the seven things it decides.&lt;/p>
&lt;p>&lt;strong>The proxy starts without Postgres, but then only the master key works.&lt;/strong> The &lt;code>DATABASE_URL&lt;/code> variable is optional (&lt;code>proxy_server.py:1163&lt;/code>); without it, any key other than the master one returns a 400 for a missing connection (&lt;code>user_api_key_auth.py:1886&lt;/code>). With a database configured but down, the default behaviour is to return 503, unless &lt;code>general_settings.allow_requests_on_db_unavailable&lt;/code> is switched on.&lt;/p>
&lt;p>&lt;strong>Redis is never mandatory, and that is precisely the trap.&lt;/strong> There is a circuit breaker with a threshold of five failures and a recovery of sixty seconds (&lt;code>constants.py:456&lt;/code>). When it opens, the proxy fails no request: it degrades to local memory. What is lost silently is that rate limits stop being global and become per pod.&lt;/p>
&lt;p>&lt;strong>No observability backend can bring a request down.&lt;/strong> All callbacks are launched with &lt;code>asyncio.create_task&lt;/code> and their exceptions are caught and logged (&lt;code>litellm_logging.py:3235&lt;/code>). If Langfuse does not respond, events are discarded as the queue fills up. There is no added latency, and there is no reliable record either: the pair&amp;rsquo;s observability is a statistical figure, not an audit record.&lt;/p>
&lt;p>&lt;strong>JWT authentication validates neither the audience nor the issuer unless two variables are defined.&lt;/strong> In &lt;code>_build_decode_kwargs&lt;/code> (&lt;code>handle_jwt.py:1003&lt;/code>) the audience comes from &lt;code>JWT_AUDIENCE&lt;/code> and the issuer from &lt;code>JWT_ISSUER&lt;/code>; if they are empty, the code sets &lt;code>verify_aud=False&lt;/code> and &lt;code>verify_iss=False&lt;/code> and emits a warning once only. Any valid token signed by that provider, issued for another service, is accepted.&lt;/p>
&lt;p>&lt;strong>Role-based access control ships disabled.&lt;/strong> &lt;code>enforce_rbac&lt;/code> has a default value of &lt;code>False&lt;/code> (&lt;code>_types.py:4848&lt;/code> onwards). With that value, a token whose role resolves to nothing does not produce a 403: it carries on through the flow. Real authorisation, then, comes from team membership, if there is a team claim configured at all.&lt;/p>
&lt;p>&lt;strong>The user identifier that reaches Langfuse is not the one from Keycloak.&lt;/strong> The &lt;code>user_id&lt;/code> field of the trace is filled in with &lt;code>user_api_key_end_user_id&lt;/code> (&lt;code>integrations/langfuse/langfuse.py:604&lt;/code>), that is, with the &lt;code>user&lt;/code> field of the OpenAI body, which the client sets. The subject of the token and the owner of the key do not travel. Anyone who wants per-person traceability needs to configure &lt;code>end_user_id_jwt_field&lt;/code> or make the client send that field.&lt;/p>
&lt;p>&lt;strong>Langfuse provisions nothing from a claim.&lt;/strong> There is no variable that maps an OIDC claim to an organisation or to a project. The only thing available is static assignment at sign-up with &lt;code>LANGFUSE_DEFAULT_ORG_ID&lt;/code> and &lt;code>LANGFUSE_DEFAULT_PROJECT_ID&lt;/code>, the same for everyone. On top of that, per-project access control sits behind the Enterprise licence in the self-hosted edition, as do audit logs and per-project retention.&lt;/p>
&lt;h2 id="you-are-here-between-the-four-pieces">You are here: between the four pieces&lt;/h2>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse pair&lt;/a> walked through the seam between the gateway and the trace backend: integration routes, cost that arrives as zero, correlation and queues with discarding. This article widens the focus to the other three neighbourhoods, with identity at the centre, because that is the one that orders everything else and the one an auditor asks about.&lt;/p>
&lt;p>The identity piece itself, that is, Keycloak, is dealt with in &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">the next article&lt;/a>. Here we deal with the gateway side.&lt;/p>
&lt;h2 id="the-analogy-the-building-site-with-five-trades">The analogy: the building site with five trades&lt;/h2>
&lt;p>On a small building site, the problem is rarely inside each trade&amp;rsquo;s work, it is at the joins: where the plumber leaves the pipe sleeve and the bricklayer closes up the partition wall, where the electrician wants to run through the same gap. Each trade does its part well and the join ends up badly resolved because it belongs to nobody.&lt;/p>
&lt;p>An inference platform has five trades. The gateway knows about tokens, the identity provider knows about people, the trace backend knows about events, the database knows about spend and the cache knows about counters. Each one has good documentation. The joins have nobody&amp;rsquo;s documentation, and that is where the user&amp;rsquo;s identity gets lost between the token and the trace.&lt;/p>
&lt;p>There is one more detail of the analogy that is useful. On a building site, the part that causes the most problems is not the one that looks badly finished, it is the one that gets covered up. The same happens here: the five seams in this article fail silently.&lt;/p>
&lt;h2 id="seam-1-what-brings-things-down-and-what-degrades-them">Seam 1. What brings things down and what degrades them&lt;/h2>
&lt;p>It pays to have written down what happens when each neighbour falls over, because intuition fails in almost every case.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Neighbour&lt;/th>
&lt;th>Does it start without it?&lt;/th>
&lt;th>If it falls over&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Postgres&lt;/td>
&lt;td>Yes, master key only&lt;/td>
&lt;td>503 by default; degraded with &lt;code>allow_requests_on_db_unavailable&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Redis&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Circuit breaker after 5 failures; limits become per pod&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Events discarded as the queue fills; no impact on latency&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTel collector&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Same as the previous one&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Identity provider&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Access to the interface and the JWT flow goes down; virtual keys carry on&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="postgres">Postgres&lt;/h3>
&lt;p>Without &lt;code>DATABASE_URL&lt;/code>, &lt;code>_setup_prisma_client&lt;/code> returns &lt;code>None&lt;/code> without error (&lt;code>proxy_server.py:10375&lt;/code>). The proxy serves &lt;code>/chat/completions&lt;/code> with the master key. Any other key gets a 400. There is no global budget either, and start-up itself warns about it: Redis does not replace the database (&lt;code>proxy_server.py:9240&lt;/code>).&lt;/p>
&lt;p>With a database configured and down, the exception handler re-raises unless the degradation flag is active (&lt;code>db/exception_handler.py:56&lt;/code>). There is also a &lt;code>DISABLE_PRISMA_HEALTH_CHECK_ON_STARTUP&lt;/code> and a watcher that reconnects. The detail that matters for diagnosis: &lt;strong>a transient database failure returns 503, not 401&lt;/strong>. If during an incident you see 401s, the problem is credentials; if you see 503s, it is the database or admission.&lt;/p>
&lt;h3 id="redis">Redis&lt;/h3>
&lt;p>The circuit breaker is enabled by default, with a threshold of five failures, a recovery of sixty seconds and a minimum duration of five (&lt;code>constants.py:456&lt;/code>, logic in &lt;code>caching/redis_cache.py:153&lt;/code>). The socket has a timeout of 0.1 seconds (&lt;code>constants.py:453&lt;/code>). With the circuit open, &lt;code>DualCache&lt;/code> serves from memory without propagating an exception (&lt;code>dual_cache.py:215&lt;/code>).&lt;/p>
&lt;p>The consequence has to be measured, not assumed: with four workers and three replicas, a limit of one hundred requests per minute becomes one thousand two hundred. It is a failure mode that generates no errors and that can last for days without anyone noticing, until the bill arrives or the engine saturates.&lt;/p>
&lt;p>The right alert is not about Redis, it is about &lt;code>litellm_service_latency&lt;/code> labelled by service (&lt;code>integrations/prometheus_services.py:119&lt;/code>), which exposes Redis and database latency separately.&lt;/p>
&lt;h3 id="the-callbacks">The callbacks&lt;/h3>
&lt;p>Everything behind, that is, Langfuse and the collector, is launched outside the response path and its failures are caught. The classic integration uses the v2 SDK with its own background thread and a flush interval of one second, adjustable with &lt;code>LANGFUSE_FLUSH_INTERVAL&lt;/code> (&lt;code>langfuse.py:198&lt;/code>). There is a cap of fifty instantiated Langfuse clients (&lt;code>constants.py:554&lt;/code>), because each client is a thread.&lt;/p>
&lt;p>The OTLP integration uses the batch processor of the OpenTelemetry SDK (&lt;code>opentelemetry.py:3060&lt;/code>) with its default values. In both cases, saturation means discarding, and discarding generates no error towards the client.&lt;/p>
&lt;h2 id="seam-2-identity-the-three-doors-and-what-each-one-validates">Seam 2. Identity: the three doors and what each one validates&lt;/h2>
&lt;p>Everything goes through a single authentication constructor (&lt;code>user_api_key_auth.py:1245&lt;/code>), in this order:&lt;/p>
&lt;ol>
&lt;li>Custom authentication from the enterprise edition.&lt;/li>
&lt;li>Public routes.&lt;/li>
&lt;li>Opaque OAuth2, if enabled, with a licence check.&lt;/li>
&lt;li>JWT, if &lt;code>general_settings.enable_jwt_auth&lt;/code> is active and the token has three parts.&lt;/li>
&lt;li>Master key, with constant-time comparison.&lt;/li>
&lt;li>Virtual key, with SHA-256 hash and lookup in the verification table.&lt;/li>
&lt;/ol>
&lt;p>A JWT works for &lt;code>/chat/completions&lt;/code>, not just for administration: the routes allowed by default for a team include &lt;code>openai_routes&lt;/code> (&lt;code>_types.py:4886&lt;/code>), which contains the chat endpoint. Administration, on the other hand, is only reachable by a token with an administrator role.&lt;/p>
&lt;h3 id="the-first-thing-to-know-it-is-paid-for">The first thing to know: it is paid for&lt;/h3>
&lt;p>The JWT branch carries an explicit licence check, with the literal message that JWT authentication is an enterprise-only feature (&lt;code>user_api_key_auth.py:1416&lt;/code>). SSO for the administration interface is free up to five billable users and requires a licence above that (&lt;code>ui_sso.py:976&lt;/code>).&lt;/p>
&lt;p>This conditions the architecture of any sovereign deployment that does not want to pay for a licence: the Keycloak integration is left for the administration interface and for provisioning, while inference traffic authenticates with virtual keys. Virtual keys are not worse, but they are a different thing, and it has to be said in the risk analysis: they are credentials decoupled from the identity provider, so removing a person from Keycloak does not invalidate their key.&lt;/p>
&lt;h3 id="the-litellm_jwtauth-fields-that-decide-security">The &lt;code>litellm_jwtauth&lt;/code> fields that decide security&lt;/h3>
&lt;p>From the &lt;code>LiteLLM_JWTAuth&lt;/code> class (&lt;code>_types.py:4848&lt;/code> onwards), these are the ones that matter, with their default values:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Field&lt;/th>
&lt;th>Default&lt;/th>
&lt;th>What it does&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>enforce_rbac&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>If false, an unresolved role does not produce a 403&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>enforce_scope_based_access&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>Without this, &lt;code>scope_mappings&lt;/code> is not applied&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>enforce_team_based_model_access&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>Model access by team&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>team_id_jwt_field&lt;/code>&lt;/td>
&lt;td>&lt;code>None&lt;/code>&lt;/td>
&lt;td>Claim the team comes from&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>team_id_upsert&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>If false, a team that does not exist gives a 404&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>user_id_upsert&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>Same with the user&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>public_key_ttl&lt;/code>&lt;/td>
&lt;td>&lt;code>600&lt;/code>&lt;/td>
&lt;td>Cache of the public keys&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>public_key_stale_ttl&lt;/code>&lt;/td>
&lt;td>&lt;code>3600&lt;/code>&lt;/td>
&lt;td>Extra margin if the provider is down&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>admin_jwt_scope&lt;/code>&lt;/td>
&lt;td>&lt;code>litellm_proxy_admin&lt;/code>&lt;/td>
&lt;td>Scope that grants administration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>user_allowed_email_domain&lt;/code>&lt;/td>
&lt;td>&lt;code>None&lt;/code>&lt;/td>
&lt;td>Restriction by email domain&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The accepted algorithms are RS256/384/512, PS256/384/512, ES256/384/512 and EdDSA (&lt;code>handle_jwt.py:163&lt;/code>). There is no symmetric one, which eliminates the algorithm confusion class of attack. It is a good design decision and it deserves recognition.&lt;/p>
&lt;p>Key download supports several comma-separated URLs in &lt;code>JWT_PUBLIC_KEY_URL&lt;/code>, resolves discovery if the URL points to the well-known OpenID configuration, retries three times with increasing waits and memoises the failure for thirty seconds (&lt;code>handle_jwt.py:680&lt;/code>, &lt;code>:751&lt;/code>, &lt;code>:88&lt;/code>). If the provider is down, it serves the stale copy up to the sum of the two TTLs, that is, up to seventy minutes. For availability that is fine; for revocation it is not, and it has to be written down.&lt;/p>
&lt;p>One detail of key rotation: with more than one key in the set, an exact match of the key identifier is required, but &lt;strong>with a single key and no identifier it is accepted without checking&lt;/strong> (&lt;code>handle_jwt.py:913&lt;/code>).&lt;/p>
&lt;h3 id="the-validation-that-does-not-happen">The validation that does not happen&lt;/h3>
&lt;p>This is the part to fix on day one. In &lt;code>_build_decode_kwargs&lt;/code> (&lt;code>handle_jwt.py:1003&lt;/code>), the audience is taken from the &lt;code>JWT_AUDIENCE&lt;/code> environment variable and the issuer from &lt;code>JWT_ISSUER&lt;/code>. If they are not defined, the code sets &lt;code>verify_aud=False&lt;/code> and &lt;code>verify_iss=False&lt;/code>, and emits a warning once only.&lt;/p>
&lt;p>What that means in a real deployment with Keycloak: a token issued for the Grafana client, or for the Backstage one, signed by the same realm, is accepted by the gateway as a valid credential. Expiry is always validated, with zero margin. So is the signature. The audience is not.&lt;/p>
&lt;p>There is a second route, &lt;code>issuers&lt;/code>, with per-issuer configuration, which does validate issuer and audience unless explicitly disabled (&lt;code>handle_jwt.py:1186&lt;/code>). That is the one to use when there is more than one provider.&lt;/p>
&lt;p>The minimum fix is two lines of environment:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_AUDIENCE&lt;/span>&lt;span class="o">=&lt;/span>litellm-gateway
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_ISSUER&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And on the Keycloak side, an audience mapper in a dedicated client scope that injects that audience. The detail is &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">in the Keycloak article&lt;/a>, because the standard that would resolve this cleanly, resource indicators, is not supported.&lt;/p>
&lt;h3 id="from-claim-to-permission">From claim to permission&lt;/h3>
&lt;p>The administrator role comes from &lt;code>admin_jwt_scope&lt;/code> appearing in the &lt;code>scope&lt;/code> claim, and it returns a result without going through a team (&lt;code>handle_jwt.py:309&lt;/code>, &lt;code>:1373&lt;/code>). The rest is resolved by team.&lt;/p>
&lt;p>If the team in the claim does not exist in the database, with &lt;code>team_id_upsert&lt;/code> false a 404 is returned with the message that the team has to be created (&lt;code>auth_checks.py:3035&lt;/code>). With the flag active, it is created by invoking team creation with a synthetic administrator identity (&lt;code>auth_checks.py:2874&lt;/code>). That is the only automatic provisioning route that exists from Keycloak towards the gateway, and it works well. It pays to know that it creates teams with no budget and no limits, so a default value is needed in the configuration.&lt;/p>
&lt;h3 id="the-vulnerability-worth-knowing-about">The vulnerability worth knowing about&lt;/h3>
&lt;p>&lt;code>CVE-2026-35030&lt;/code>, scoring 9.1 on CVSS 3.1 and 9.4 on CVSS 4.0, advisory &lt;code>GHSA-jjhc-v7c2-5hh6&lt;/code>. With JWT authentication active, the cache of the OIDC user info endpoint used the first twenty characters of the token as the key, so that two tokens with the same prefix were confused with each other. Fixed in 1.83.0.&lt;/p>
&lt;p>In 1.102.0 the code uses the full SHA-256 hash (&lt;code>handle_jwt.py:962&lt;/code>). A search for similar patterns across the tree finds no authentication cache indexed by token prefix: the truncations that remain are masked for logging. And it is worth recalling what has already been published &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">about the MCP gateway&lt;/a>: below 1.83.14 there is unauthenticated remote execution or SQL injection.&lt;/p>
&lt;h2 id="seam-3-identity-does-not-reach-the-trace">Seam 3. Identity does not reach the trace&lt;/h2>
&lt;p>Here lies the most expensive design flaw in the arrangement, because it is discovered at the first audit.&lt;/p>
&lt;p>The &lt;code>user_id&lt;/code> field of a Langfuse trace is filled in with &lt;code>user_api_key_end_user_id&lt;/code> (&lt;code>integrations/langfuse/langfuse.py:604&lt;/code> and &lt;code>:724&lt;/code>), which comes from the &lt;code>user&lt;/code> field of the OpenAI request body or from &lt;code>end_user_id_jwt_field&lt;/code> (&lt;code>litellm_pre_call_utils.py:1584&lt;/code>). It is not the subject of the token nor the owner of the virtual key.&lt;/p>
&lt;p>The knock-on consequences:&lt;/p>
&lt;ul>
&lt;li>If the client does not send &lt;code>user&lt;/code>, the trace has no user.&lt;/li>
&lt;li>If the client does send &lt;code>user&lt;/code>, the trace has whatever value the client cares to put there, unverified.&lt;/li>
&lt;li>An agent calling with a service key leaves traces with no person behind them.&lt;/li>
&lt;/ul>
&lt;p>There are three ways to fix it, in order of robustness:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Configure &lt;code>end_user_id_jwt_field&lt;/code>&lt;/strong> so that the end user comes from a token claim and not from the body. It is the only option in which the value is not controlled by the client, and it requires JWT authentication, that is, a licence.&lt;/li>
&lt;li>&lt;strong>Force the &lt;code>trace_user_id&lt;/code> metadata field&lt;/strong>, which overwrites the field (&lt;code>langfuse.py:726&lt;/code>; on the OTLP route, &lt;code>langfuse_otel.py:96&lt;/code>). It can be set per team.&lt;/li>
&lt;li>&lt;strong>Contractually require the client to send &lt;code>user&lt;/code>&lt;/strong> and validate it in a guardrail. It works, but it is a client declaration, not a proof.&lt;/li>
&lt;/ol>
&lt;p>Something similar happens towards the engine. The caller&amp;rsquo;s identity does not travel to vLLM unless &lt;code>litellm.add_user_information_to_llm_headers&lt;/code> is switched on, whose default value is &lt;code>None&lt;/code> (&lt;code>litellm/__init__.py:223&lt;/code>). With it, &lt;code>x-litellm-*&lt;/code> headers are injected with user identifier, team identifier and key hash (&lt;code>litellm_pre_call_utils.py:1356&lt;/code>). Forwarding client headers requires &lt;code>general_settings.forward_client_headers_to_llm_api&lt;/code>, and the &lt;code>authorization&lt;/code> header is always filtered out, which is correct.&lt;/p>
&lt;p>Towards MCP servers, by default the server&amp;rsquo;s own credential is used, not the user&amp;rsquo;s token. Only two modes forward the caller&amp;rsquo;s token, and doing so has a name in the MCP specification: token passthrough, which is explicitly forbidden. It comes up again &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">in the Keycloak article&lt;/a>.&lt;/p>
&lt;h2 id="seam-4-langfuse-as-a-neighbour">Seam 4. Langfuse as a neighbour&lt;/h2>
&lt;h3 id="what-can-be-automated-and-what-cannot">What can be automated and what cannot&lt;/h3>
&lt;p>The exact list of variables for SSO in Langfuse 4.x, taken from &lt;code>web/src/env.mjs&lt;/code>:&lt;/p>
&lt;p>With the Keycloak provider: &lt;code>AUTH_KEYCLOAK_CLIENT_ID&lt;/code>, &lt;code>AUTH_KEYCLOAK_CLIENT_SECRET&lt;/code>, &lt;code>AUTH_KEYCLOAK_ISSUER&lt;/code>, &lt;code>AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING&lt;/code>, &lt;code>AUTH_KEYCLOAK_CLIENT_AUTH_METHOD&lt;/code>, &lt;code>AUTH_KEYCLOAK_CHECKS&lt;/code>, &lt;code>AUTH_KEYCLOAK_ID_TOKEN_SIGNED_RESPONSE_ALG&lt;/code>, &lt;code>AUTH_KEYCLOAK_SCOPE&lt;/code>, &lt;code>AUTH_KEYCLOAK_ID_TOKEN&lt;/code>, &lt;code>AUTH_KEYCLOAK_NAME&lt;/code>.&lt;/p>
&lt;p>With generic OIDC: the same set with the &lt;code>AUTH_CUSTOM_&lt;/code> prefix, plus &lt;code>AUTH_CUSTOM_FETCH_USERINFO&lt;/code> and claim mapping with &lt;code>LANGFUSE_CUSTOM_SSO_SUB_CLAIM&lt;/code>, &lt;code>LANGFUSE_CUSTOM_SSO_EMAIL_CLAIM&lt;/code>, &lt;code>LANGFUSE_CUSTOM_SSO_NAME_CLAIM&lt;/code> and &lt;code>LANGFUSE_CUSTOM_SSO_IMAGE_CLAIM&lt;/code>.&lt;/p>
&lt;p>Global ones: &lt;code>AUTH_DISABLE_USERNAME_PASSWORD&lt;/code>, &lt;code>AUTH_DISABLE_SIGNUP&lt;/code>, &lt;code>AUTH_DOMAINS_WITH_SSO_ENFORCEMENT&lt;/code>, &lt;code>AUTH_SESSION_MAX_AGE&lt;/code>.&lt;/p>
&lt;p>And now what is missing. &lt;strong>There is no variable that maps a claim to an organisation or a project.&lt;/strong> The only thing available is static assignment at sign-up with &lt;code>LANGFUSE_DEFAULT_ORG_ID&lt;/code>, &lt;code>LANGFUSE_DEFAULT_ORG_ROLE&lt;/code> with a default value of &lt;code>VIEWER&lt;/code>, &lt;code>LANGFUSE_DEFAULT_PROJECT_ID&lt;/code> and &lt;code>LANGFUSE_DEFAULT_PROJECT_ROLE&lt;/code> (&lt;code>features/auth/lib/createProjectMembershipsOnSignup.ts:61&lt;/code>). The documentation itself confirms it: enterprise SSO does not provision roles automatically at sign-up.&lt;/p>
&lt;p>On top of that, in the per-edition permissions table (&lt;code>features/entitlements/constants/entitlements.ts:139&lt;/code>), the open and self-hosted professional editions &lt;strong>do not include&lt;/strong> per-project access control or the administration API. They belong to the enterprise edition, as do audit logs and per-project retention. In the open edition, every user inherits the role of their organisation.&lt;/p>
&lt;h3 id="the-declarative-bootstrap-which-does-exist">The declarative bootstrap, which does exist&lt;/h3>
&lt;p>For on-premise without a licence, the workable route is bootstrapping through variables (&lt;code>web/src/initialize.ts&lt;/code>), which is idempotent: &lt;code>LANGFUSE_INIT_ORG_ID&lt;/code> (mandatory, if missing the rest is ignored with a warning), &lt;code>LANGFUSE_INIT_ORG_NAME&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_ID&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_NAME&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_RETENTION&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_PUBLIC_KEY&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_SECRET_KEY&lt;/code>, &lt;code>LANGFUSE_INIT_USER_EMAIL&lt;/code>, &lt;code>LANGFUSE_INIT_USER_NAME&lt;/code>, &lt;code>LANGFUSE_INIT_USER_PASSWORD&lt;/code>.&lt;/p>
&lt;p>With that, one project per tenant is created from GitOps with one initialisation container per tenant and the keys in Kubernetes secrets.&lt;/p>
&lt;h3 id="langfuse-credentials-per-team">Langfuse credentials per team&lt;/h3>
&lt;p>This part is well resolved on the gateway side and few people use it. &lt;code>TeamCallbackMetadata.callback_vars&lt;/code> (&lt;code>_types.py:2152&lt;/code>) accepts &lt;code>langfuse_public_key&lt;/code>, &lt;code>langfuse_secret_key&lt;/code>, &lt;code>langfuse_host&lt;/code> and &lt;code>langfuse_environment&lt;/code>, with an allow list in &lt;code>initialize_dynamic_callback_params.py:67&lt;/code>. The gateway caches one logger per credential set (&lt;code>langfuse_handler.py:24&lt;/code>) and, on the OTLP route, builds a full exporter per key (&lt;code>langfuse_otel.py:400&lt;/code>).&lt;/p>
&lt;p>That is to say: &lt;strong>one Langfuse project per LiteLLM team is possible today&lt;/strong>, without a licence, and it is the correct way to isolate traces between tenants. What does not exist is automatic project creation, nor the correspondence between the team and the project. That is glue you have to write.&lt;/p>
&lt;h3 id="the-ingestion-route">The ingestion route&lt;/h3>
&lt;p>Langfuse accepts &lt;strong>only OTLP over HTTP&lt;/strong>, in &lt;code>http/protobuf&lt;/code> or &lt;code>http/json&lt;/code>. gRPC is not supported. The base route is &lt;code>/api/public/otel&lt;/code> and the signal lives at &lt;code>/api/public/otel/v1/traces&lt;/code>, verified in the repository. Authentication is Basic with the project&amp;rsquo;s key pair, plus the &lt;code>x-langfuse-ingestion-version: 4&lt;/code> header for the version 4 route.&lt;/p>
&lt;p>The gateway builds it exactly like that (&lt;code>langfuse_otel.py:328&lt;/code>, &lt;code>:347&lt;/code>, &lt;code>:359&lt;/code>) and then normalises the endpoint by adding the signal suffix (&lt;code>opentelemetry.py:3237&lt;/code>). The result matches the real route, so the integration works without touching anything. The detail matters when a collector is put in the middle and somebody copies the endpoint by hand.&lt;/p>
&lt;p>And the calendar warning that already appeared &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">in the operational pair&lt;/a> still stands: the classic callback is tied to the v2 SDK and the date marked is 16 November 2026. With a nuance worth reading before scheduling an emergency migration, which is detailed &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">in the data model article&lt;/a>: that date applies to Langfuse Cloud, and in the code of the self-hosted version the old route is not switched off, it changes behaviour according to the write mode.&lt;/p>
&lt;h3 id="when-to-put-a-collector-in">When to put a collector in&lt;/h3>
&lt;p>Direct if Langfuse is the only trace destination. Via a collector if there is more than one consumer, or if any of these three things is needed that the proxy&amp;rsquo;s batch processor does not provide:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Retries with increasing waits and an on-disk queue&lt;/strong>, with the &lt;code>sending_queue&lt;/code> and &lt;code>file_storage&lt;/code> processors. With that, a Langfuse maintenance window stops losing traces.&lt;/li>
&lt;li>&lt;strong>Tail sampling&lt;/strong>, which lets you keep 100 % of errors and 1 % of the rest. The proxy cannot sample by outcome, because at the point it decides it does not yet know how things ended.&lt;/li>
&lt;li>&lt;strong>Attribute redaction&lt;/strong> before they leave the namespace. It is the only layer where you can delete the MCP tool arguments which, as has already been documented, are written in the clear, bypassing the message redaction switch.&lt;/li>
&lt;/ul>
&lt;h2 id="seam-5-postgres-redis-and-the-memory-policy">Seam 5. Postgres, Redis and the memory policy&lt;/h2>
&lt;p>&lt;strong>Separate Postgres, always.&lt;/strong> Each piece applies its own Prisma migrations over the public schema of the database it is given. Two Prisma clients migrating the same schema collide over table names and over migration history. On top of that, Langfuse 4.x requires Postgres 15 as a minimum, recommends 16 and wants UTC by default. The load profile is also the opposite: the gateway writes spend records at high frequency, while Langfuse does light transactional work because trace data goes to ClickHouse.&lt;/p>
&lt;p>&lt;strong>Separate Redis, and the argument is not the one it looks like.&lt;/strong> There is no key collision by design: the gateway uses hash tags of the form &lt;code>{api_key:...}:requests&lt;/code> and an optional namespace, and Langfuse uses the BullMQ prefix plus its own prefix for its key cache. The decisive argument is another one: &lt;strong>Langfuse requires &lt;code>maxmemory-policy=noeviction&lt;/code>&lt;/strong> because its queues are data, not cache, while the gateway assumes a disposable cache. Sharing an instance means that a spike of rate limit keys can fill the memory and cause write errors in the ingestion queues instead of a harmless eviction.&lt;/p>
&lt;p>If it is shared anyway, two minimum measures: &lt;code>REDIS_KEY_PREFIX&lt;/code> in Langfuse, &lt;code>namespace&lt;/code> in the gateway, and different logical databases.&lt;/p>
&lt;p>And a reminder about surface area: Langfuse 4.x &lt;strong>does not start without ClickHouse or without an S3-compatible bucket&lt;/strong>. &lt;code>CLICKHOUSE_URL&lt;/code> and &lt;code>LANGFUSE_S3_EVENT_UPLOAD_BUCKET&lt;/code> are mandatory and have no default value (&lt;code>packages/shared/src/env.ts:120&lt;/code> and &lt;code>:271&lt;/code>). Its failure surface is far larger than the gateway&amp;rsquo;s, and that changes where to put the high availability effort.&lt;/p>
&lt;h2 id="ports-for-the-networkpolicy">Ports, for the NetworkPolicy&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Port&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>LiteLLM (API, interface and metrics)&lt;/td>
&lt;td>4000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse web&lt;/td>
&lt;td>3000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse worker&lt;/td>
&lt;td>3030&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ClickHouse&lt;/td>
&lt;td>8123 HTTP, 9000 native&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>S3-compatible storage&lt;/td>
&lt;td>9000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Redis&lt;/td>
&lt;td>6379&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Postgres&lt;/td>
&lt;td>5432&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTel collector&lt;/td>
&lt;td>4317 gRPC, 4318 HTTP&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The pairs to allow under a default-deny policy, following the method from &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">hardening the stack&lt;/a>:&lt;/p>
&lt;pre tabindex="0">&lt;code>litellm → postgres:5432, redis:6379
litellm → collector:4318 (or langfuse-web:3000 if going direct)
litellm → vllm:8000
litellm → keycloak:8443 (interface and JWT flow only)
collector → langfuse-web:3000
langfuse-web → postgres:5432, redis:6379, clickhouse:8123, s3:9000
langfuse-worker→ postgres:5432, redis:6379, clickhouse:8123, s3:9000
langfuse-web → keycloak:8443
ingress → litellm:4000, langfuse-web:3000
prometheus → litellm:4000
&lt;/code>&lt;/pre>&lt;p>The Langfuse worker needs no inbound traffic beyond the health probe.&lt;/p>
&lt;h2 id="the-glue-you-have-to-write">The glue you have to write&lt;/h2>
&lt;p>Put into a table, the real state of the tenant chain:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Segment&lt;/th>
&lt;th>Automatic?&lt;/th>
&lt;th>How&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Keycloak → LiteLLM team&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>&lt;code>team_ids_jwt_field&lt;/code> plus &lt;code>team_id_upsert: true&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak → LiteLLM user&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>&lt;code>user_id_jwt_field&lt;/code> plus &lt;code>user_id_upsert: true&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak → Langfuse organisation&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>Static assignment at sign-up only&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak → Langfuse project&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>Same&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM team → Langfuse project&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>&lt;code>callback_vars&lt;/code> by hand, or an operator of your own&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak user → trace &lt;code>user_id&lt;/code>&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>&lt;code>end_user_id_jwt_field&lt;/code>, or a contract with the client&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The minimum viable architecture without a licence, then, is this:&lt;/p>
&lt;ol>
&lt;li>One Langfuse project per tenant, created with the initialisation variables from GitOps.&lt;/li>
&lt;li>That project&amp;rsquo;s keys in a Kubernetes secret.&lt;/li>
&lt;li>A periodic job of your own that reads the teams through the gateway&amp;rsquo;s administration API and writes &lt;code>callback_vars&lt;/code> with the corresponding keys.&lt;/li>
&lt;li>People entering Langfuse through Keycloak, but assigned by hand to their organisation.&lt;/li>
&lt;li>The end user identifier resolved by contract with the clients, and validated.&lt;/li>
&lt;/ol>
&lt;p>That is about one hundred and fifty lines of your own code. It pays to budget for them from the start, because the gap does not close by itself.&lt;/p>
&lt;h2 id="reference-configuration">Reference configuration&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># only with a licence; without one, virtual keys&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enable_jwt_auth&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_jwtauth&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">team_ids_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;groups&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">team_id_upsert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_id_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sub&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_id_upsert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">end_user_id_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;preferred_username&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_allowed_email_domain&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;ejemplo.es&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">admin_jwt_scope&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;litellm_proxy_admin&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># the three that ship switched off&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enforce_rbac&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enforce_scope_based_access&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enforce_team_based_model_access&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allow_requests_on_db_unavailable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">litellm_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">callbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;langfuse_otel&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;prometheus&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># without this, the engine does not know who is calling&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">add_user_information_to_llm_headers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">turn_off_message_logging&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cache&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cache_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">redis&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">host&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/REDIS_HOST&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">litellm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># the two that enable the validation that by default does not happen&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_AUDIENCE&lt;/span>&lt;span class="o">=&lt;/span>litellm-gateway
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_ISSUER&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_PUBLIC_KEY_URL&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma/.well-known/openid-configuration
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_HOST&lt;/span>&lt;span class="o">=&lt;/span>http://langfuse-web.observabilidad.svc:3000
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_PUBLIC_KEY&lt;/span>&lt;span class="o">=&lt;/span>...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_SECRET_KEY&lt;/span>&lt;span class="o">=&lt;/span>...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And on the Langfuse side:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_KEYCLOAK_CLIENT_ID&lt;/span>&lt;span class="o">=&lt;/span>langfuse
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_KEYCLOAK_ISSUER&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_DISABLE_USERNAME_PASSWORD&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_DISABLE_SIGNUP&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_DEFAULT_ORG_ID&lt;/span>&lt;span class="o">=&lt;/span>plataforma
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_DEFAULT_ORG_ROLE&lt;/span>&lt;span class="o">=&lt;/span>VIEWER
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ENCRYPTION_KEY&lt;/span>&lt;span class="o">=&lt;/span>... &lt;span class="c1"># 64 hexadecimal characters&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>Define &lt;code>JWT_AUDIENCE&lt;/code> and &lt;code>JWT_ISSUER&lt;/code> on day one, or use the per-issuer configuration. Without that, neither audience nor issuer is validated.&lt;/li>
&lt;li>Set &lt;code>enforce_rbac&lt;/code>, &lt;code>enforce_scope_based_access&lt;/code> and &lt;code>enforce_team_based_model_access&lt;/code> to true, and test that a token from another client of the same realm is rejected.&lt;/li>
&lt;li>Decide where the end user of the traces comes from and write it into the design. If it is not decided, traces come out with no person.&lt;/li>
&lt;li>Separate Postgres. Separate Redis. If Redis is shared, different prefix and logical database, and review the memory policy.&lt;/li>
&lt;li>Check that the proxy starts with Postgres down only if that is what you want, and that the on-call team can tell an admission 503 from a credential 401.&lt;/li>
&lt;li>Alert on &lt;code>litellm_service_latency&lt;/code> per service, not on Redis availability, because the circuit breaker hides the problem.&lt;/li>
&lt;li>One Langfuse project per tenant with the initialisation variables, and the per-team keys in &lt;code>callback_vars&lt;/code>.&lt;/li>
&lt;li>Put a collector in the middle if you need an on-disk queue, tail sampling or attribute redaction.&lt;/li>
&lt;li>Check that the gateway version is above 1.83.14 because of the earlier critical vulnerabilities.&lt;/li>
&lt;li>Write into the risk analysis that virtual keys are credentials decoupled from the identity provider, and define the offboarding procedure.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>A properly signed JWT is not a JWT addressed to this service.&lt;/strong> Without the two environment variables, the audience is not checked.&lt;/p>
&lt;p>&lt;strong>&lt;code>enforce_rbac&lt;/code> set to false does not mean there is no authorisation&lt;/strong>, it means that authorisation comes only from team membership, if there is a team claim. With no team claim and without the flag, a valid token reaches &lt;code>/chat/completions&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The stale copy of the public keys lasts up to seventy minutes&lt;/strong>, adding the two TTLs. Revoking a key at the provider does not take effect immediately.&lt;/p>
&lt;p>&lt;strong>With a single key in the JWKS and no key identifier, the identifier is not checked.&lt;/strong>&lt;/p>
&lt;p>&lt;strong>The trace user is set by the client.&lt;/strong> Any per-person spend report built on that field is a declaration, not a measurement.&lt;/p>
&lt;p>&lt;strong>Redis going down generates no errors&lt;/strong>, it generates per-pod limits. It is the most expensive of the failures that give no warning.&lt;/p>
&lt;p>&lt;strong>Langfuse does not start without ClickHouse or without an S3 bucket.&lt;/strong> Its failure surface is bigger than the gateway&amp;rsquo;s, and that changes how the high availability effort is shared out.&lt;/p>
&lt;p>&lt;strong>Langfuse&amp;rsquo;s per-project access control is paid for in self-hosted&lt;/strong>, as are audit logs. For an ENS file, that gets decided before building, not after.&lt;/p>
&lt;p>&lt;strong>Sharing Redis does not break through key collision, it breaks through memory policy.&lt;/strong> It is a failure that shows up under load and gets diagnosed badly.&lt;/p>
&lt;p>&lt;strong>Langfuse&amp;rsquo;s ingestion route is OTLP over HTTP only.&lt;/strong> A collector configured with the gRPC exporter points at somewhere that does not exist.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The five seams share a common pattern: they fail without warning. The token from another service gets in, the trace user comes from the client, the rate limit stops being global, the spend queue discards on reaching 64 MB and the Langfuse project does not create itself. None of those five things produces a visible error.&lt;/p>
&lt;p>Day 2 work, then, consists above all of turning silences into signals. Two environment variables so that the audience is validated, three flags so that authorisation is real, an alert on service latency instead of on Redis availability, and an explicit contract on where the end user&amp;rsquo;s identity comes from.&lt;/p>
&lt;p>And one decision worth taking early, before the budget is closed. The gateway&amp;rsquo;s JWT authentication and Langfuse&amp;rsquo;s per-project access control are both behind a licence. A sovereign platform can live perfectly well without either, with virtual keys and one project per tenant, but it is a different architecture and it has to be drawn as such from the start.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a>: the seam between gateway and traces, with the four queues that discard.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a>: the previous article in the track, with the size of each piece.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a>: the identity piece from the inside and its position in the architecture.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>: the second door, with its permissions and its privacy problem.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">what goes into a trace&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets of the sovereign LLM stack&lt;/a>: the default-deny method that orders the port table.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows: adding authentication with Keycloak&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Código de LiteLLM 1.102.0, commit &lt;code>9071ca50&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>Código de Langfuse 4.35.0, commit &lt;code>39e3cd7d&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs/administration/authentication-and-sso">Autenticación y SSO en Langfuse&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs/administration/scim-and-org-api">SCIM y API de organización&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/integrations/native/opentelemetry">Integración OpenTelemetry de Langfuse&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/self-hosting/deployment/infrastructure/postgres">Postgres&lt;/a> y &lt;a href="https://langfuse.com/self-hosting/deployment/infrastructure/cache">caché&lt;/a> en la documentación de self-hosting, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://docs.litellm.ai/docs/proxy/token_auth">Autenticación por token en LiteLLM&lt;/a> y &lt;a href="https://docs.litellm.ai/docs/proxy/config_settings">ajustes de configuración&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;code>GHSA-jjhc-v7c2-5hh6&lt;/code> y registro de &lt;code>CVE-2026-35030&lt;/code> en NVD.&lt;/li>
&lt;/ul></description></item><item><title>Sizing for agents: 275 input tokens for every output token, and why the bottleneck is still decode</title><link>https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/</link><pubDate>Sat, 12 Sep 2026 06:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/</guid><description>&lt;blockquote>
&lt;p>Seventh article in the operational track of the control layer. &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">The fourth&lt;/a> dealt with the coexistence of human traffic and agentic traffic on a fixed fleet. This one deals with the prior question: how big the fleet has to be, and how big the gateway in front of it. Verified against LiteLLM 1.102.0 (commit of 11 September 2026) and vLLM 0.29.0, with load data published between May and September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Until this year, sizing for agents meant extrapolating a chat profile and adding margin. In 2026 the first production-scale characterisations were published and the profile turned out to be something else. These are the seven conclusions.&lt;/p>
&lt;p>&lt;strong>The median prompt of an agentic call is 68,000 tokens, of which 63,000 arrive cached.&lt;/strong> The measurement covers 13.5 million sessions, 3.2 million users, 760.5 million model calls and 44.9 trillion prompt tokens in one week of June 2026. The median input-to-output ratio exceeds 275 to 1. Any calculation done on a profile of 800 prompt tokens and 250 output tokens describes a different system.&lt;/p>
&lt;p>&lt;strong>The load is prefill-dominant when counting tokens and decode-dominant when counting time.&lt;/strong> The genuinely new tokens that have to be prefilled on each turn are only between 1.5 and 7.3 times the output, not 275. With the prefix cache working, the split of execution time runs from 91 % to 98.6 % in decode. You size by memory bandwidth and by KV cache capacity, not by prefill FLOPs.&lt;/p>
&lt;p>&lt;strong>The sizing risk is not the average, it is the collapse of the prefix cache.&lt;/strong> The median hit rate within a turn is 98 %, it falls to 55 % at the boundary between turns and to 8 % when the client changes model. With dynamically retrieved context, prefix reuse drops to 1 %. One miss turns 142,000 prefix tokens into real prefill. Session affinity per replica stops being an optimisation and becomes a capacity requirement.&lt;/p>
&lt;p>&lt;strong>The intra-turn concurrency measured in production is 1.15 at the median and 1.4 at the 90th percentile.&lt;/strong> 93 % of tool batches contain a single invocation. The image of the agent opening twenty calls in parallel does not appear in the data. The real multiplier comes from the duty cycle and from the fact that 87 % of calls are started by the agent, not by the person.&lt;/p>
&lt;p>&lt;strong>The gateway is sized per worker, and each worker is a universe.&lt;/strong> One vCPU and 4 GiB per worker, with requests equal to limits. The limit counters, the caches and the cooldowns live in the worker&amp;rsquo;s memory unless there is Redis, so a limit of 100 requests per minute with four workers and three replicas allows 1,200. The Prisma pool is also per process: the number of connections open against Postgres is workers times &lt;code>connection_limit&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The CPU cost that grows with agentic load is the token counter.&lt;/strong> It serialises the entire tool catalogue on every request and tokenises it. The project itself measured 46, 53 and 100 ms for prompts of 50K, 75K and 100K tokens, and the operation passes through a concurrency limiter of four per worker. With agents sending long catalogues and six-figure contexts, that limit is the ceiling before the CPU is.&lt;/p>
&lt;p>&lt;strong>Autoscaling by memory does not work on this piece and the startup probe takes five minutes.&lt;/strong> The official chart deliberately leaves memory without a target because Prisma&amp;rsquo;s query engine never returns the maximum reached. That leaves CPU at 60 %, with a &lt;code>startupProbe&lt;/code> of 300 seconds that makes any new replica arrive late. For agents there are two better metrics, and they are in &lt;code>/metrics&lt;/code>.&lt;/p>
&lt;h2 id="you-are-here-the-arithmetic-that-comes-before-buying">You are here: the arithmetic that comes before buying&lt;/h2>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Classic capacity planning&lt;/a> starts from a chat SLO and crosses two budgets, the VRAM one and the time one. That method is still correct. What changes with agents are the inputs: the prompt length, the number of calls per unit of human work, the split between prefill and decode, and the very definition of an SLO. This article replaces those inputs with measured figures and drags the result through to two numbers: gateway replicas and GPUs.&lt;/p>
&lt;p>Out of scope is the splitting of traffic once it is built, which was already covered by &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the coexistence of humans and agents&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">prefix routing&lt;/a>.&lt;/p>
&lt;h2 id="the-analogy-the-repair-shop-and-the-front-desk">The analogy: the repair shop and the front desk&lt;/h2>
&lt;p>An appliance repair shop sizes its staff by counting faults, not phone calls. The difference matters because the person who brings in the washing machine occupies the front desk for three minutes, while the technician who takes it apart goes back to consult the machine&amp;rsquo;s data sheet fourteen times over the afternoon. If the shop manager sizes by visits to the front desk, they end up short of technicians and overstaffed at reception.&lt;/p>
&lt;p>The inference platform has the same asymmetry. The person sends one turn. The agent sends fifteen calls for that turn, and each one drags along the complete file of everything done so far. The front desk is the gateway and the technicians are the GPUs, and the usual mistake is to size both with the same counter.&lt;/p>
&lt;p>There is a second detail the analogy captures well. The technician does not read the whole data sheet each time: they have it open on the bench. That is the prefix cache. The day somebody moves them to a different bench between consultations, they have to fetch it from the archive again, and the work multiplies without the number of faults having increased.&lt;/p>
&lt;h2 id="part-1-the-real-load-profile">Part 1. The real load profile&lt;/h2>
&lt;p>For two years, any capacity calculation for agents was done on assumptions. In 2026 three pieces of work appeared that measure the matter, and it is worth reading them together because they measure different scaffolds and their figures are not averageable.&lt;/p>
&lt;h3 id="the-three-sources">The three sources&lt;/h3>
&lt;p>The characterisation at scale was published by a research team working on GitHub Copilot production telemetry (&lt;a href="https://arxiv.org/html/2608.00101v1">arXiv 2608.00101&lt;/a>, 30 July 2026): 13.5 million sessions, 3.2 million users, 95.1 million user turns, 760.5 million model calls and 44.9 trillion prompt tokens, in one week of June 2026. It is the largest sample published to date.&lt;/p>
&lt;p>The characterisation by benchmark suites comes from an architecture team (&lt;a href="https://arxiv.org/html/2605.26297">arXiv 2605.26297&lt;/a>, 25 May 2026), which instruments five of them (ADE-Bench, DABStep, GAIA, SWE-bench Pro and Terminal-Bench 2.0) and measures the internal split of time.&lt;/p>
&lt;p>The production measurement on a different scaffold was published by the vLLM project itself together with AgentX (&lt;a href="https://vllm.ai/blog/2026-09-08-vllm-agentx">vLLM blog&lt;/a>, 8 September 2026).&lt;/p>
&lt;p>The three disagree on turns per session: 15 calls at Copilot&amp;rsquo;s median, 43 turns in AgentX, 62.4 in SWE-bench Pro with Qwen. It is not a contradiction, they are scaffolds with different loops. The sizing lesson is that the number of calls per unit of human work depends on the client that connects, and that is why you have to measure it at home before fixing the size.&lt;/p>
&lt;h3 id="the-figures-you-can-actually-use">The figures you can actually use&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Quantity&lt;/th>
&lt;th>Measured value&lt;/th>
&lt;th>Source&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Median prompt per call&lt;/td>
&lt;td>68K tokens, 63K of them cached&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Input:output ratio (median)&lt;/td>
&lt;td>above 275:1&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Median session&lt;/td>
&lt;td>3 user turns, 15 calls, 4.2 min&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mean session&lt;/td>
&lt;td>6.1 turns, 40.6 calls, 62.6 min&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Calls per turn (median)&lt;/td>
&lt;td>4.5&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Calls started by the agent&lt;/td>
&lt;td>87 %&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Range across user profiles&lt;/td>
&lt;td>50× (23K to 1.1M tokens per turn)&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Median input context&lt;/td>
&lt;td>142K tokens; output 444 tokens per turn&lt;/td>
&lt;td>AgentX&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Accumulated context in SWE-bench Pro&lt;/td>
&lt;td>mean 68.7 to 80.1K, maximum 146 to 166K&lt;/td>
&lt;td>2605.26297&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reasoning tokens over the output&lt;/td>
&lt;td>29.0 % to 67.6 %&lt;/td>
&lt;td>2605.26297&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two operational observations come out of that and appear in no sizing guide.&lt;/p>
&lt;p>The first: &lt;strong>agents that fail accumulate up to 1.8 times more average context than those that finish well&lt;/strong> (2605.26297). The cost does not correlate with success, it correlates with difficulty and with getting stuck. A sizing done on the task that goes well falls short.&lt;/p>
&lt;p>The second: the range across user profiles is 50 to 1. A user who only converses spends 23,000 tokens per turn; one who leaves the agent in a long loop spends 1.1 million. Segmenting by usage profile pays off more than any difficulty-based routing, because the variance is in the people, not in the requests.&lt;/p>
&lt;h3 id="what-to-measure-before-calculating">What to measure before calculating&lt;/h3>
&lt;p>Three of your own figures, taken from a week of real traffic, are worth more than any table in this article:&lt;/p>
&lt;ol>
&lt;li>Model calls per hour of human work, by user profile.&lt;/li>
&lt;li>Prompt length distribution (median and 95th percentile), not the mean.&lt;/li>
&lt;li>Aggregate prefix cache hit rate, with the time series, not the single number.&lt;/li>
&lt;/ol>
&lt;p>All three come from what the gateway already stores. In &lt;code>LiteLLM_SpendLogs&lt;/code> you have the tokens per request and the key and team identifiers; in &lt;code>proxy_server_request&lt;/code> you have the &lt;code>tools&lt;/code> block of each call; and in vLLM&amp;rsquo;s &lt;code>/metrics&lt;/code> you have &lt;code>vllm:prefix_cache_queries&lt;/code> and &lt;code>vllm:prefix_cache_hits&lt;/code>.&lt;/p>
&lt;h2 id="part-2-prefill-decode-and-the-cache-trap">Part 2. Prefill, decode and the cache trap&lt;/h2>
&lt;p>Here is the finding that corrects almost everyone&amp;rsquo;s intuition, including mine when I started this article.&lt;/p>
&lt;p>Counting tokens, agentic load is overwhelmingly input: 275 to 1. The apparently obvious conclusion would be to size by prefill capacity, that is by FLOPs. It is false.&lt;/p>
&lt;p>What has to be prefilled on each turn is not the whole prompt, but what was added since the previous turn. That addition, measured, sits between 1.5 and 7.3 times the output length (2605.26297). The rest is served by the prefix cache. And the real split of the model&amp;rsquo;s execution time comes out like this: &lt;strong>between 91.0 % and 98.6 % in decode, between 1.4 % and 9.0 % in prefill&lt;/strong> (2605.26297).&lt;/p>
&lt;p>The hit rate that sustains that split is high and has been measured in three places: theoretical from 87.9 % to 99.3 % and empirical from 84.6 % to 99.5 % (2605.26297); above 96 % in production (AgentX); and a median of 98 % within a turn in Copilot (2608.00101).&lt;/p>
&lt;h3 id="where-it-falls-over">Where it falls over&lt;/h3>
&lt;p>The same Copilot work measures the degradation, and it is the most actionable figure in the article:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>98 %&lt;/strong> median hit rate within a turn. The detail matters: the first call of a turn hits around 45 %, the second rises to 86 % and from the third onwards it stabilises between 92 % and 94 %.&lt;/li>
&lt;li>&lt;strong>55 %&lt;/strong> at the boundary between turns, with idle gaps of 4.1 minutes at the median.&lt;/li>
&lt;li>&lt;strong>8 %&lt;/strong> when the client changes model.&lt;/li>
&lt;/ul>
&lt;p>And there is a fourth, worse regime: with dynamically retrieved context, that is when the scaffold injects search or RAG results that change on every iteration, prefix reuse falls to 1 % or less (&lt;a href="https://arxiv.org/html/2608.15127">arXiv 2608.15127&lt;/a>, 15 August 2026).&lt;/p>
&lt;p>A cache miss with a context of 142,000 tokens turns a prefill of a few thousand tokens into a six-figure one. The work multiplies by a factor of between 20 and 60 without the number of requests having changed. That is the difference between a fleet that meets its targets and one that seizes up mid-afternoon.&lt;/p>
&lt;p>Two sizing rules come from that:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Without session affinity towards the same replica, the calculated sizing is worthless.&lt;/strong> LiteLLM&amp;rsquo;s concrete mechanism is &lt;code>session_affinity&lt;/code>, which is not active by default and which was already covered &lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">in the prefix routing article&lt;/a>. Without it, the calculation has to be redone assuming a prefill regime, which is between 20 and 60 times more expensive.&lt;/li>
&lt;li>&lt;strong>Changing model halfway through a session costs the entire cache.&lt;/strong> Any difficulty-based routing policy that moves a live conversation from one model group to another pays an 8 % hit rate on the next call. If you want to route by difficulty, do it at the start of the session.&lt;/li>
&lt;/ol>
&lt;h3 id="co-batching-which-is-the-other-effect-nobody-measures">Co-batching, which is the other effect nobody measures&lt;/h3>
&lt;p>Mixing a short request into the same batch as long-context requests degrades the short one&amp;rsquo;s inter-token latency. The measured figure: &lt;strong>one long request in the batch raises time per token by 38.3 %; two raise it by 79.7 %&lt;/strong> (2608.15127). It is the technical reason why separating human and agent pools improves the human experience more than the capacity split suggests, and it is also the reason not to raise &lt;code>--max-num-seqs&lt;/code> above what the KV budget allows.&lt;/p>
&lt;h2 id="part-3-the-kv-budget-with-the-numbers-of-the-agentic-case">Part 3. The KV budget, with the numbers of the agentic case&lt;/h2>
&lt;p>The formula does not change with respect to &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">classic capacity planning&lt;/a>, but the inputs do, and the result is surprising:&lt;/p>
$$\text{KV}_{\text{bytes/token}} = 2 \times n_{\text{layers}} \times n_{\text{kv heads}} \times d_{\text{head}} \times \text{bytes}_{\text{elem}}$$
&lt;p>For a 70B-class geometry (80 layers, 8 KV heads with grouped attention, head dimension 128, 2-byte elements) that gives 0.3125 MiB per token. With the median agentic context of 142,000 tokens:&lt;/p>
$$142{,}000 \times 0.3125\ \text{MiB} \approx 43.3\ \text{GiB per sequence}$$
&lt;p>For a 30B-class geometry (48 layers, everything else the same) that gives 0.1875 MiB per token, that is around 26.0 GiB per sequence.&lt;/p>
&lt;p>It is worth pausing on what that means. An 80 GB H100 with the weights of a 70B model in FP8 already taking up space does not have 43 GiB free for a single sequence. The conclusion is harsh and has to be written down: &lt;strong>serving real 70B-class agentic context demands tensor parallelism of at least 2 and a quantised KV cache&lt;/strong>. And the number of simultaneous sequences that fit, which is what sets &lt;code>--max-num-seqs&lt;/code>, comes from dividing the available KV by that figure, not from a chat heuristic.&lt;/p>
$$\text{sequences} \approx \frac{\text{VRAM}_{\text{KV available}}}{\text{KV}_{\text{bytes/token}} \times \text{max context}}$$
&lt;p>With FP8 in the KV, the divisor halves. It is the cheapest capacity lever there is in this load profile, and its cost in quality is validated with evals, not assumed.&lt;/p>
&lt;h2 id="part-4-from-people-to-concurrent-requests">Part 4. From people to concurrent requests&lt;/h2>
&lt;p>This is where most calculations overshoot, because it is assumed that an agent equals many simultaneous requests. The data says otherwise.&lt;/p>
&lt;p>The concurrency of calls within a turn, measured in production, is &lt;strong>1.15 at the median and 1.4 at the 90th percentile&lt;/strong>, and 93 % of tool batches contain a single invocation (2608.00101). Intra-turn parallelism is small. Subagents appear in 44 % of AgentX sessions, with a median of 4 deployments among those that use them, so there is a long tail, but it is not the usual regime.&lt;/p>
&lt;p>What does multiply is the duty cycle. The mean session lasts 62.6 minutes and contains 40.6 calls. At 444 output tokens and an interactivity of around 60 tokens per second, each call occupies around 7.4 seconds of decode, which gives between 5 and 7 active minutes out of those 62.6.&lt;/p>
$$C = D \times \rho \times A$$
&lt;p>where $D$ is the people working, $\rho$ the duty cycle and $A$ the amplification. With $\rho \approx 0.10$ to $0.12$ derived from the previous figures, twenty developers give between 2 and 2.4 concurrent sequences in the steady state. It is the peak, not the average, that has to be sized for.&lt;/p>
&lt;h3 id="the-amplification">The amplification&lt;/h3>
&lt;p>Three factors published separately:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tool failures&lt;/strong>: 9 % of turns trigger them, with a compute amplification of 4 times (2608.00101).&lt;/li>
&lt;li>&lt;strong>Subagents&lt;/strong>: 44 % of sessions, median of 4 deployments (AgentX).&lt;/li>
&lt;li>&lt;strong>Internal variance&lt;/strong>: the same task from the same agent varies by up to 30 times in cost within a single trace (2608.15127).&lt;/li>
&lt;/ul>
&lt;p>The product of the first two gives $(1 + 0.09 \times 3) \times (1 + 0.44 \times 3) \approx 2.9$. Nobody publishes that product as a single figure, so it is flagged as my own derivation, but &lt;strong>an amplification factor of 3 is the reasonable design provision&lt;/strong> and it fits the measured variance.&lt;/p>
&lt;p>For the twenty developers in the example: between 6 and 7 concurrent design sequences, with 142K contexts. At 43.3 GiB of KV per sequence in the 70B class, that is around 300 GiB of KV cache, that is four H100s dedicated to KV alone with the weights on top. With KV in FP8, half. With a 30B-class model, a third.&lt;/p>
&lt;p>That calculation, done with the chat profile of 800 prompt tokens, would have given a single GPU. That is the distance between the two regimes.&lt;/p>
&lt;h2 id="part-5-the-gateway-which-also-has-to-be-sized">Part 5. The gateway, which also has to be sized&lt;/h2>
&lt;p>The proxy consumes no GPU, but it has its own arithmetic and it is the part that produces the most surprises in production, because almost all of its state is per process.&lt;/p>
&lt;h3 id="a-worker-is-a-universe">A worker is a universe&lt;/h3>
&lt;p>The default value of &lt;code>--num_workers&lt;/code> is &lt;strong>1&lt;/strong> (&lt;code>litellm/constants.py:57&lt;/code>). The usual startup is uvicorn directly with uvloop (&lt;code>proxy_cli.py:1477&lt;/code>), and there are alternatives with gunicorn, hypercorn and granian.&lt;/p>
&lt;p>What lives in each worker&amp;rsquo;s memory, and therefore multiplies:&lt;/p>
&lt;ul>
&lt;li>The in-memory cache, limited to &lt;strong>200 elements&lt;/strong> with a TTL of 600 seconds (&lt;code>litellm/caching/in_memory_cache.py:27&lt;/code>). With many deployments, the cooldowns and the counters compete for that same space and get evicted before they expire.&lt;/li>
&lt;li>The key authentication cache, with a TTL of &lt;strong>60 seconds&lt;/strong> (&lt;code>proxy_server.py:1650&lt;/code>), adjustable with &lt;code>general_settings.user_api_key_cache_ttl&lt;/code>.&lt;/li>
&lt;li>The limit and budget counters when there is no Redis (&lt;code>parallel_request_limiter_v3.py&lt;/code>, &lt;code>local_only=True&lt;/code> paths).&lt;/li>
&lt;li>The router cooldowns, with &lt;code>DEFAULT_COOLDOWN_TIME_SECONDS = 5&lt;/code> (&lt;code>constants.py:75&lt;/code>).&lt;/li>
&lt;li>A periodic task scheduler of its own per worker (&lt;code>proxy_server.py:9674&lt;/code>), coordinated between pods with a lock in Redis.&lt;/li>
&lt;/ul>
&lt;p>The practical consequence: &lt;strong>a limit of 100 requests per minute with four workers and three replicas allows up to 1,200 without Redis&lt;/strong>. With Redis, the v3 limiter registers seven Lua scripts at startup (&lt;code>parallel_request_limiter_v3.py:608&lt;/code>) and the limits become global.&lt;/p>
&lt;p>One detail of the v3 limiter that matters with agentic load: &lt;code>LITELLM_TPM_TOKEN_RESERVATION_ENABLED&lt;/code> comes enabled by default and reserves estimated tokens before the call, which adds a round trip to Redis per request. And there is an &lt;code>asyncio.Lock&lt;/code> per instance that serialises the atomic batch checks (&lt;code>:665&lt;/code>), documented by the code itself as a 99th-percentile risk under contention.&lt;/p>
&lt;p>If Redis goes down, requests do not fail: there is a circuit breaker with a threshold of 5 failures and a recovery of 60 seconds (&lt;code>constants.py:456&lt;/code>), and the proxy degrades to local memory. What is lost silently is the globality of the limits.&lt;/p>
&lt;h3 id="postgres">Postgres&lt;/h3>
&lt;p>The pool is also per process. The default values are &lt;code>database_connection_pool_limit = 10&lt;/code> and &lt;code>database_connection_pool_timeout = 60&lt;/code> (&lt;code>proxy_cli.py:62&lt;/code>), injected as parameters of the Prisma URL. A pod with four workers opens forty connections. Three pods, one hundred and twenty. It is the fast route to exhausting Postgres&amp;rsquo;s &lt;code>max_connections&lt;/code> without having served any traffic, and the reason the official chart offers PgBouncer inside the pod itself with &lt;code>maxDbConnections: 20&lt;/code> and &lt;code>maxClientConn: 1000&lt;/code>.&lt;/p>
&lt;p>On the request path there is only the authentication, which is cached for 60 seconds, and the rate limiting. Spend is deferred. The &lt;code>SpendLogs&lt;/code> queue is capped at &lt;strong>64 MB&lt;/strong> and on overflow it discards the oldest elements with an error in the log (&lt;code>utils.py:6704&lt;/code>). The write interval is &lt;code>proxy_batch_write_at&lt;/code>, with a default value of 10 seconds plus a random spread of up to 5 (&lt;code>constants.py:1719&lt;/code>, &lt;code>proxy_server.py:9704&lt;/code>); the production guide recommends raising it to 60.&lt;/p>
&lt;p>With a slow Postgres, the queue grows to those 64 MB and then loses billing data without returning any error to the client. It is the silent failure mode you have to alert on.&lt;/p>
&lt;h3 id="the-token-counter-which-is-the-agentic-trap">The token counter, which is the agentic trap&lt;/h3>
&lt;p>This is the piece that turns a correct sizing into an insufficient one, and it appears in no guide.&lt;/p>
&lt;p>Before admitting a request, the proxy counts tokens. With chat load the cost is negligible. With agentic load it is not, for two reasons that add up.&lt;/p>
&lt;p>The first is the prompt length. The project itself measured the admission counting cost at &lt;strong>46, 53 and 100 ms for prompts of 50K, 75K and 100K tokens&lt;/strong> (&lt;a href="https://docs.litellm.ai/docs/benchmarks">benchmarks&lt;/a>, consulted on 12 September 2026), and published that with a counter written in Rust those figures fall to 4.9, 6.8 and 10.2 ms.&lt;/p>
&lt;p>The second is the tool catalogue. The function that prepares the definitions serialises the entire catalogue to a pseudo-TypeScript and tokenises it (&lt;code>litellm_core_utils/token_counter.py:934&lt;/code>), plus nine fixed tokens. The cost is linear in the size of the catalogue and it is paid on every request. That is exactly the pattern of an agent connected to an MCP gateway: a long catalogue, resent on every turn. The token bill of that catalogue was already covered &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in the MCP gateway article&lt;/a>; here its other face appears, which is proxy CPU.&lt;/p>
&lt;p>And on top of that there is an explicit ceiling. The counting runs in a thread with a capacity limiter whose default value is &lt;strong>4 per worker&lt;/strong> (&lt;code>token_counter.py:340&lt;/code>, &lt;code>constants.py:407&lt;/code>). With 100K prompts, four concurrent counts of 100 ms mean a theoretical ceiling of around 40 admissions per second per worker before the limiter starts queueing, and that is without having called the model yet.&lt;/p>
&lt;p>What is cached is the tokeniser, with an LRU of &lt;strong>64&lt;/strong> entries (&lt;code>utils.py:2206&lt;/code>), not the result of the count.&lt;/p>
&lt;p>Three levers, in order of return: raise &lt;code>TOKEN_COUNTER_MAX_CONCURRENT_COUNTS&lt;/code> if CPU is spare, reduce the tool catalogue exposed per key, and watch the gateway overhead figure in &lt;code>/metrics&lt;/code> to know whether this is happening.&lt;/p>
&lt;h3 id="streaming-and-admission">Streaming and admission&lt;/h3>
&lt;p>There is no connection limit by default. &lt;code>--limit_concurrency&lt;/code> exists but is optional, and it returns 503. The built-in admission control is only activated if &lt;code>general_settings.max_in_flight_requests_per_worker&lt;/code> is defined (&lt;code>middleware/admission_control_middleware.py:285&lt;/code>), with &lt;code>max_queued_requests_per_worker&lt;/code> and &lt;code>admission_queue_timeout_seconds&lt;/code> at 1.0 by default, rejecting with 503 and a &lt;code>retry-after: 1&lt;/code> header.&lt;/p>
&lt;p>For agents, that header matters: an agentic client that retries without respecting it multiplies the load exactly when the gateway is saying it cannot take any more. The combination of gateway retries was already calculated by &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the fourth article&lt;/a> and it reached 45 calls per turn in the worst case.&lt;/p>
&lt;p>The default request timeout is &lt;strong>6,000 seconds&lt;/strong> (&lt;code>constants.py:512&lt;/code>). The production guide recommends 600. With long agentic tasks, leaving it at 6,000 means a hung connection occupies a slot for an hour and a half.&lt;/p>
&lt;h3 id="resources-and-autoscaling">Resources and autoscaling&lt;/h3>
&lt;p>The official chart recommends &lt;strong>1 vCPU and 4 GiB per worker, with requests equal to limits&lt;/strong> (&lt;code>helm/litellm-helm/values.yaml:235&lt;/code>), and the production documentation suggests one worker per pod scaling horizontally. The project&amp;rsquo;s own high-performance benchmark, by contrast, uses four workers per pod with PgBouncer in front.&lt;/p>
&lt;p>The HPA comes with &lt;code>targetCPUUtilizationPercentage: 60&lt;/code>. And there is a comment in the chart worth reading twice: &lt;strong>memory is deliberately left without a target&lt;/strong> because the resident consumption of Prisma&amp;rsquo;s query engine is a high-water mark that is never returned, so that an HPA on memory would add replicas and never remove them (&lt;code>values.yaml:260&lt;/code>).&lt;/p>
&lt;p>The other problem is startup. The &lt;code>startupProbe&lt;/code> has &lt;code>failureThreshold: 30&lt;/code> with a period of 10 seconds, that is &lt;strong>up to 300 seconds&lt;/strong> before the pod is considered ready (&lt;code>values.yaml:109&lt;/code>). An HPA that reacts at 60 % CPU and takes five minutes to add capacity arrives late to any agentic burst.&lt;/p>
&lt;p>The alternative, optional in the chart, is to scale by Prometheus metrics: &lt;code>targetRequestsPerSecond&lt;/code> and &lt;code>targetTokensPerSecond&lt;/code> over &lt;code>rate(litellm_proxy_total_requests_metric_total[1m])&lt;/code> and &lt;code>rate(litellm_total_tokens_metric_total[1m])&lt;/code>. The chart itself warns that the token metric lags with long streaming responses, because tokens are accounted for on completion. For agents, that is almost always.&lt;/p>
&lt;h2 id="part-6-what-to-watch-the-metrics-that-work-and-the-ones-that-broke">Part 6. What to watch: the metrics that work and the ones that broke&lt;/h2>
&lt;h3 id="at-the-gateway">At the gateway&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>What for&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>litellm_in_flight_requests&lt;/code>&lt;/td>
&lt;td>Real saturation per worker. It is the scaling metric&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm_overhead_latency_metric&lt;/code>&lt;/td>
&lt;td>Proxy cost per request; it rises with token counting&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm_overhead_with_guardrails_latency_metric&lt;/code>&lt;/td>
&lt;td>The same including guardrails&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm_request_queue_time_seconds&lt;/code>&lt;/td>
&lt;td>Arrival through to pre-call, includes authentication and ASGI queueing&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm_deployment_cooled_down&lt;/code>&lt;/td>
&lt;td>Replicas out of the pool&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm_service_latency&lt;/code>&lt;/td>
&lt;td>Redis and database latency, per service&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;code>/health/backlog&lt;/code> endpoint returns &lt;code>in_flight_requests&lt;/code>, &lt;code>admitted_requests&lt;/code>, &lt;code>queued_requests&lt;/code> and &lt;code>rejected_requests&lt;/code> (&lt;code>health_endpoints/_health_endpoints.py:1871&lt;/code>).&lt;/p>
&lt;p>What it does &lt;strong>not&lt;/strong> expose: the depth of the &lt;code>SpendLogs&lt;/code> queue or its bytes, the Prisma connections in use against the limit, nor the depth of the logging worker&amp;rsquo;s queue. All three are real failure modes and have to be instrumented from outside.&lt;/p>
&lt;h3 id="at-the-engine">At the engine&lt;/h3>
&lt;p>Names verified in the vLLM 0.29.0 metrics documentation:&lt;/p>
&lt;ul>
&lt;li>&lt;code>vllm:num_requests_running&lt;/code>, &lt;code>vllm:num_requests_waiting&lt;/code>&lt;/li>
&lt;li>&lt;code>vllm:time_to_first_token_seconds&lt;/code>, &lt;code>vllm:e2e_request_latency_seconds&lt;/code>, &lt;code>vllm:request_queue_time_seconds&lt;/code>, &lt;code>vllm:inter_token_latency_seconds&lt;/code>&lt;/li>
&lt;li>&lt;code>vllm:prefix_cache_queries&lt;/code>, &lt;code>vllm:prefix_cache_hits&lt;/code>, &lt;code>vllm:kv_cache_usage_perc&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>And the ones that break dashboards on upgrade:&lt;/p>
&lt;ul>
&lt;li>&lt;code>vllm:time_in_queue_requests&lt;/code> is duplicated by &lt;code>vllm:request_queue_time_seconds&lt;/code>.&lt;/li>
&lt;li>The direct prefix cache hit rate indicator disappeared. It is now computed as &lt;code>rate(vllm:prefix_cache_hits) / rate(vllm:prefix_cache_queries)&lt;/code>.&lt;/li>
&lt;li>&lt;code>vllm:num_requests_swapped&lt;/code> and &lt;code>vllm:cpu_cache_usage_perc&lt;/code> are deprecated along with CPU swapping.&lt;/li>
&lt;li>&lt;code>vllm:gpu_cache_usage_perc&lt;/code> became &lt;code>vllm:kv_cache_usage_perc&lt;/code>, which also breaks any HPA that uses it.&lt;/li>
&lt;/ul>
&lt;h3 id="the-slo-worth-chasing">The SLO worth chasing&lt;/h3>
&lt;p>A 95th percentile of time to first token describes nobody&amp;rsquo;s experience when the unit of work is 43 turns. Coding tasks frequently exceed ten minutes and research tasks reach hours (2608.15127).&lt;/p>
&lt;p>Two indicators serve better:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>90th percentile of full-task latency&lt;/strong>, measured per session, not per request. It comes from grouping &lt;code>LiteLLM_SpendLogs&lt;/code> by session identifier.&lt;/li>
&lt;li>&lt;strong>90th percentile of interactivity&lt;/strong>, in tokens per second per user. It is the one AgentX uses, with values between 58.3 and 74.2 in its runs.&lt;/li>
&lt;/ol>
&lt;p>To autoscale the engine, the official production-stack example uses &lt;code>vllm:num_requests_waiting&lt;/code> with a threshold of 5, polling every 15 seconds and a cooldown of 360. With agentic load it is worth adding &lt;code>vllm:kv_cache_usage_perc&lt;/code> as a second trigger, because with 26 to 43 GiB of KV per sequence the memory pressure arrives before the queue does.&lt;/p>
&lt;h2 id="part-7-the-engine-levers-ordered-by-return">Part 7. The engine levers, ordered by return&lt;/h2>
&lt;p>Verified against vLLM 0.29.0, released on 9 September 2026.&lt;/p>
&lt;p>&lt;strong>&lt;code>--long-prefill-token-threshold 512&lt;/code>.&lt;/strong> The AgentX measurement attributes to it &lt;strong>+93 % in tokens per GPU per second, with a 2.3-times improvement at the 90th percentile&lt;/strong>, by attacking the head-of-line blocking that a long prefill produces in front of short requests. It is the highest-return lever documented for this profile.&lt;/p>
&lt;p>&lt;strong>KV cache in FP8.&lt;/strong> It halves the memory budget per sequence, which is the limiting resource. Validate with evals.&lt;/p>
&lt;p>&lt;strong>&lt;code>--max-num-queued-reqs&lt;/code> and &lt;code>--max-num-queued-tokens&lt;/code>.&lt;/strong> New in 0.29. They are admission control in the engine, and they are the right mechanism to protect the SLO instead of letting the queue grow without limit.&lt;/p>
&lt;p>&lt;strong>&lt;code>max_num_batched_tokens&lt;/code> downwards.&lt;/strong> High values improve time to first token; low values improve inter-token latency. In a regime where 91 % of the time is decode, inter-token latency is what is perceived. Chunked prefill is enabled by default whenever possible in the V1 engine.&lt;/p>
&lt;p>&lt;strong>Session affinity at the gateway.&lt;/strong> It is not an engine setting, but it is what holds up everything above. Without it, that 98 % hit rate turns into random distribution across replicas.&lt;/p>
&lt;p>Two version warnings: Model Runner V2 becomes the default in 0.29 and V1 is deprecated with removal planned for 0.32; and starting with &lt;code>python -m vllm.entrypoints.openai.api_server&lt;/code> is deprecated.&lt;/p>
&lt;h2 id="the-full-calculation-from-start-to-finish">The full calculation, from start to finish&lt;/h2>
&lt;p>Case: twenty developers, a coding scaffold, a 30B-class model in the fleet, maximum context of 128K, KV in FP8.&lt;/p>
&lt;p>&lt;strong>1. Steady-state concurrency.&lt;/strong> $C = 20 \times 0.11 \times 3 \approx 6.6$ sequences.&lt;/p>
&lt;p>&lt;strong>2. KV budget per sequence.&lt;/strong> 0.1875 MiB per token in BF16, half in FP8, that is around 0.094 MiB. At 128K of context: around 12.0 GiB per sequence. For 6.6 sequences with margin, rounding to 8: 96 GiB of KV.&lt;/p>
&lt;p>&lt;strong>3. GPUs.&lt;/strong> The weights of a 30B in FP8 take up around 30 GB. On an 80 GB H100 with &lt;code>gpu_memory_utilization&lt;/code> of 0.90 there are around 72 GB usable per card. With tensor parallelism of 2, the weights are split: around 15 GB per GPU, so around 57 GB of KV per GPU and around 114 GB in the replica. That covers the 96 GiB calculated, and TP=2 also adds memory bandwidth, which is the resource that dominates decode. &lt;strong>Two H100s, one replica, TP=2.&lt;/strong> It is worth seeing the contrast: on a single H100 the full weights leave around 42 GB of KV, that is three and a half sequences, less than half of what is needed. With a second replica for availability and maintenance windows: four GPUs.&lt;/p>
&lt;p>&lt;strong>4. Requests per second at the gateway.&lt;/strong> 20 people, a mean session of 40.6 calls in 62.6 minutes, duty cycle included: of the order of 0.2 to 0.3 requests per second on average, with peaks of 2 to 3.&lt;/p>
&lt;p>&lt;strong>5. Gateway workers.&lt;/strong> The ceiling is not set by throughput but by token counting: four concurrent counts per worker, at 100 ms with long prompts. With peaks of 3 requests per second and six-figure prompts, &lt;strong>two replicas of one worker each&lt;/strong> cover the case with margin for the rolling update, at 1 vCPU and 4 GiB each. Postgres will see 20 connections.&lt;/p>
&lt;p>&lt;strong>6. What needs switching on.&lt;/strong> Session affinity, admission control at the gateway and at the engine, &lt;code>--long-prefill-token-threshold&lt;/code>, and Redis so that the limits are global.&lt;/p>
&lt;p>The same calculation with the chat profile would have given one GPU and one worker. The distance is not in the safety margin, it is in the inputs.&lt;/p>
&lt;h2 id="a-reference-configyaml">A reference &lt;code>config.yaml&lt;/code>&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">model_list&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes-30b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/qwen3-coder-30b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-agentes.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rpm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">input_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000018&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">output_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000072&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># without this, the capacity calculation in this article is worthless&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">optional_pre_call_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;session_affinity&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allowed_fails&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cooldown_time&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># admission control: below this the queue grows without limit&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_in_flight_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">24&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_queued_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">48&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">admission_queue_timeout_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># the spend queue discards at 64 MB; shorter interval, less backlog&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enable_drain_endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">drain_endpoint_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/DRAIN_TOKEN&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">database_connection_pool_limit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">litellm_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># 6000 s by default: a hung connection holds a slot for an hour and a half&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">request_timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">callbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;langfuse_otel&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;prometheus&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cache&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cache_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">redis&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">host&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/REDIS_HOST&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">litellm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Environment variables that go with it:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">NUM_WORKERS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">TOKEN_COUNTER_MAX_CONCURRENT_COUNTS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">8&lt;/span> &lt;span class="c1"># default 4; raise only if CPU is spare&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LITELLM_TPM_TOKEN_RESERVATION_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">PROXY_CONFIG_RELOAD_INTERVAL_SECONDS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">30&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And on the engine side:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen3-Coder-30B &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --tensor-parallel-size &lt;span class="m">2&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-model-len &lt;span class="m">131072&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --long-prefill-token-threshold &lt;span class="m">512&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-queued-reqs &lt;span class="m">64&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --gpu-memory-utilization 0.90
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>Measure three figures of your own over a week: calls per hour of human work and per profile, prompt length distribution, and the time series of prefix cache hit rate.&lt;/li>
&lt;li>Calculate the KV per sequence with the model&amp;rsquo;s real geometry, taken from its &lt;code>config.json&lt;/code>, not from a table.&lt;/li>
&lt;li>Set &lt;code>--max-num-seqs&lt;/code> from that budget, not from a chat heuristic.&lt;/li>
&lt;li>Switch on session affinity before accepting the calculated number of GPUs.&lt;/li>
&lt;li>Switch on admission control in both layers and check that the client respects &lt;code>retry-after&lt;/code>.&lt;/li>
&lt;li>Lower &lt;code>request_timeout&lt;/code> from 6,000 to 600.&lt;/li>
&lt;li>Raise &lt;code>proxy_batch_write_at&lt;/code> to 60 and alert on the growth of the spend queue from outside, because there is no metric.&lt;/li>
&lt;li>Leave the HPA on CPU at 60 %, never on memory, and assume the 300 seconds of the &lt;code>startupProbe&lt;/code> when planning the margin.&lt;/li>
&lt;li>Watch &lt;code>litellm_in_flight_requests&lt;/code> and &lt;code>litellm_overhead_latency_metric&lt;/code>; if the overhead rises with the size of the tool catalogue, the culprit is the token counter.&lt;/li>
&lt;li>Go over the dashboards for the vLLM metric renames before upgrading.&lt;/li>
&lt;li>Check that no routing policy moves a live session between model groups.&lt;/li>
&lt;li>Redo the calculation when a new user profile arrives: the range across profiles is 50 to 1.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>The ratio of 275 to 1 does not mean the system is prefill-bound.&lt;/strong> The new tokens per turn are between 1.5 and 7.3 times the output. The time is between 91 % and 98.6 % in decode.&lt;/p>
&lt;p>&lt;strong>The aggregate prefix cache hit rate hides the problem.&lt;/strong> An aggregate 90 % may be 98 % intra-turn and 55 % at boundaries. You have to look at the series, not the number, and segment by request type.&lt;/p>
&lt;p>&lt;strong>vLLM&amp;rsquo;s hit rate counts the reprefill after an eviction as a hit.&lt;/strong> The counter exists internally and is not exposed, so the published number is optimistic under a memory-pressure regime.&lt;/p>
&lt;p>&lt;strong>Agents that fail cost more than those that succeed&lt;/strong>, up to 1.8 times more context. Sizing on the task that goes well leaves the calculation short.&lt;/p>
&lt;p>&lt;strong>The database pool is per process, not per pod.&lt;/strong> Four workers are forty connections.&lt;/p>
&lt;p>&lt;strong>The HPA on memory does not work with this piece&lt;/strong>, and not because of a configuration mistake: Prisma&amp;rsquo;s query engine does not return the memory.&lt;/p>
&lt;p>&lt;strong>The 300-second &lt;code>startupProbe&lt;/code> turns any autoscaling into late reaction.&lt;/strong> For agentic peaks, it is better to over-provision than to trust the scaling.&lt;/p>
&lt;p>&lt;strong>Raising &lt;code>--max-num-seqs&lt;/code> to &amp;ldquo;make use of the GPU&amp;rdquo; degrades perceived latency&lt;/strong>: two long requests in the batch raise time per token by 79.7 %.&lt;/p>
&lt;p>&lt;strong>The token counter has a limiter of four per worker&lt;/strong> and it appears in no sizing guide. With large tool catalogues and long contexts it is the first ceiling you hit.&lt;/p>
&lt;p>&lt;strong>The figures in this article come from three different scaffolds and are not averageable.&lt;/strong> Fifteen, forty-three and sixty-two turns per session are three systems, not three measurements of the same one.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The arithmetic of sizing has not changed. What has changed are the inputs, and now there are public measurements at scale instead of intuitions. Three figures sum up the change of regime: 68,000 tokens of median prompt, 87 % of calls started by the agent rather than by the person, and a time split that is decode for more than 90 % as long as the prefix cache hits.&lt;/p>
&lt;p>From that comes an order of work that is not the usual one. First, session affinity, because without it the rest of the calculation describes a different system. Second, the KV budget with the model&amp;rsquo;s real geometry, which is what sets how many sequences fit. Third, the gateway, where the ceiling is not throughput but a token counter with four threads per worker.&lt;/p>
&lt;p>And a warning about your own figures. The measured range across user profiles is 50 to 1. Any platform that does not segment its measurement by profile is averaging two different populations and producing a number that describes neither of them.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">Humans and agents on the same gateway&lt;/a>: coexistence on a fleet that is already sized.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">Prefix routing: what LiteLLM does not do&lt;/a>: the affinity mechanism on which this article&amp;rsquo;s entire calculation depends.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>: the other face of the tool catalogue, the one paid in tokens.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a>: the classic method whose inputs this article replaces.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: fundamentals&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix cache hit rate engineering&lt;/a>: the two pieces that dominate the budget.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">Day 2 of the proxy&lt;/a>: availability, draining and deployments.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://arxiv.org/html/2608.00101v1">Agentic Coding in the Wild: Characterizing GitHub Copilot at Production Scale&lt;/a>, arXiv 2608.00101, 30 de julio de 2026.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/html/2605.26297">Agentic AI Workload Characteristics&lt;/a>, arXiv 2605.26297, 25 de mayo de 2026.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/html/2608.15127">From LLM Inference to Agentic Workloads&lt;/a>, arXiv 2608.15127, 15 de agosto de 2026.&lt;/li>
&lt;li>&lt;a href="https://vllm.ai/blog/2026-09-08-vllm-agentx">vLLM x AgentX: serving agentic workloads&lt;/a>, 8 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/design/metrics/">vLLM metrics design&lt;/a> y &lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">optimization guide&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://docs.litellm.ai/docs/benchmarks">LiteLLM benchmarks&lt;/a> y &lt;a href="https://docs.litellm.ai/docs/proxy/prod">production setup&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>Código de LiteLLM 1.102.0, commit &lt;code>9071ca50&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/projects/production-stack/en/latest/use_cases/autoscaling-keda.html">Autoescalado con KEDA en vLLM production-stack&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;/ul></description></item><item><title>Prefix routing: what LiteLLM does not do, who does, and how much it really improves</title><link>https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/</link><pubDate>Fri, 11 Sep 2026 00:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/</guid><description>&lt;blockquote>
&lt;p>Fifth article in the operational track of the control layer. &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">The pair with Langfuse&lt;/a> covered observability, &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">the proxy&amp;rsquo;s day 2&lt;/a> covered availability, &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">virtual keys&lt;/a> covered governance and &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">humans and agents&lt;/a> covered the coexistence of two classes of traffic. Here a claim of mine from June gets taken apart. Everything that follows is verified against LiteLLM 1.102.0 (commit of 10 September 2026), vLLM 0.29.0 and the main branches of production-stack, llm-d and Dynamo from the second week of September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>LiteLLM does not do prefix-aware routing, neither in the open version nor in the paid one.&lt;/strong> It is not a licensing question. A search across the whole tree for &lt;code>kv_aware&lt;/code>, &lt;code>cache_aware&lt;/code>, &lt;code>radix&lt;/code>, &lt;code>prefix_cache&lt;/code> and &lt;code>block_hash&lt;/code> returns zero matches in the router. Nor is there any call from the router to &lt;code>/metrics&lt;/code> or to any vLLM endpoint. In June I wrote the opposite in &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the inference router article&lt;/a> and I was wrong.&lt;/p>
&lt;p>&lt;strong>What LiteLLM has is affinity, which is a different thing.&lt;/strong> Four pre-call checks pin a request to a replica by session identifier, by key hash, by previous response identifier or by exact hash of a marked prefix. None is active by default. Affinity distributes conversations; prefix routing distributes prefixes shared between different conversations, which is the case that multiplies throughput.&lt;/p>
&lt;p>&lt;strong>The only one that looks at the prompt is fragile in three ways.&lt;/strong> If the client does not send &lt;code>cache_control&lt;/code>, the key comes out null and the check does nothing. If the cut point goes in the last turn, which is what the automatic injection does by default, the key changes on every turn and it never hits. And the pin&amp;rsquo;s TTL is 300 seconds hardcoded in two lines, while the provider offers one-hour caches.&lt;/p>
&lt;p>&lt;strong>The engine does not answer questions, it publishes events.&lt;/strong> There is no vLLM endpoint you can ask whether it has a prefix. What there is, is a ZMQ socket that emits stored and removed blocks in msgpack, switched off by default, with silent discarding when the consumer does not keep up. A router that wants to be exact has to replicate the engine&amp;rsquo;s hash in full: chained SHA256, deterministic seed, block granularity and the extra keys in their order.&lt;/p>
&lt;p>&lt;strong>And by default it does not publish the hits, only the additions.&lt;/strong> The index of a router that only listens drifts away from the engine&amp;rsquo;s real LRU without noticing. To see the hits you have to ask for it request by request with &lt;code>kv_cache_report_mode: full&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The three routers that do do it are very different from each other.&lt;/strong> The vLLM production-stack one indexes chunks of 128 raw characters in a trie that is never purged, models no eviction and dies on every restart. The llm-d one offers an approximate mode and an exact event-driven one, all its plugins are in alpha or beta, and its default tokeniser does not produce real tokens. NVIDIA Dynamo&amp;rsquo;s keeps an event-driven radix tree with a weighted cost function, and ships with the parameter that avoids starving a freshly scaled replica switched off.&lt;/p>
&lt;p>&lt;strong>The big improvements only appear in the best possible case.&lt;/strong> The two- and three-digit numbers are published by the projects themselves, with synthetic shared prefixes and a round-robin balancer as the reference. The most complete independent measurement, from Meta, obtains 2.3 times more throughput and also identifies load regimes where affinity leaves capacity at between half and two thirds of the reference. Another, on a multi-region deployment with 89 % reuse, measures single-digit improvements.&lt;/p>
&lt;p>&lt;strong>The correct architecture has two layers, not one.&lt;/strong> LiteLLM on top for identity, budget and audit, pointing at a single destination per model group. The KV-aware router underneath, inside the engine&amp;rsquo;s domain. Mixing it into a single piece is not an option that exists today.&lt;/p>
&lt;h2 id="you-are-here-between-the-gateway-and-the-engine">You are here: between the gateway and the engine&lt;/h2>
&lt;p>This article occupies the gap between two layers that already have an article of their own. Above, &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the L7 switchboard&lt;/a> and the gateway that has been operated since the first article in this track. Below, &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">the fundamentals of the KV cache&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">hit rate engineering&lt;/a>.&lt;/p>
&lt;p>The gap is the piece that decides which replica each request goes to, knowing what each replica has in memory. It is the only function in the stack that cannot be resolved in the gateway or in the engine separately.&lt;/p>
&lt;h2 id="the-analogy-the-library-with-four-counters">The analogy: the library with four counters&lt;/h2>
&lt;p>A library with four counters. Each librarian has in front of them a pile of the books they have consulted lately. When a reader comes in asking for the third volume of an encyclopaedia, if that volume is in the pile at counter two, serving them there takes seconds. At any other counter someone has to go down to the store room.&lt;/p>
&lt;p>A normal balancer sends the reader to the counter with the shortest queue. It is the right decision if all the counters are interchangeable, and it is the wrong decision here, because they are not: what distinguishes them is not the queue, it is the pile.&lt;/p>
&lt;p>Session affinity resolves one specific case of this. The returning reader is always sent to the same counter, because they will probably ask for the fourth volume of the same encyclopaedia. It works, and it does not resolve the general case: when a hundred different readers ask for the same third volume, session affinity spreads them across the four counters and four librarians go down to the store room for the same book.&lt;/p>
&lt;p>Prefix routing is looking at what the reader asks for before assigning them a counter. It has two costs that the analogy makes visible straight away. The first is that somebody has to keep track of what is in each pile, and that bookkeeping takes work. The second is that if everybody asks for the third volume, counter two becomes the library&amp;rsquo;s only desk while the other three look on.&lt;/p>
&lt;h2 id="the-correction-what-i-wrote-in-june">The correction: what I wrote in June&lt;/h2>
&lt;p>In the inference router article, on 2 June 2026, I closed the prefix routing section with this sentence: &amp;ldquo;vLLM Production Stack router implements it natively. NVIDIA Dynamo too. LiteLLM in its enterprise version has a beta.&amp;rdquo;&lt;/p>
&lt;p>The first two hold up. The third is false and was no less false in June. While preparing &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the humans and agents article&lt;/a> the first hint appeared, and full verification against 1.102.0 confirms it.&lt;/p>
&lt;p>Case-insensitive search over &lt;code>litellm/&lt;/code>, &lt;code>enterprise/&lt;/code> and &lt;code>gateway/&lt;/code>, for the terms &lt;code>kv_aware&lt;/code>, &lt;code>kv-aware&lt;/code>, &lt;code>cache_aware&lt;/code>, &lt;code>cache-aware&lt;/code>, &lt;code>radix&lt;/code>, &lt;code>prefix_cache&lt;/code>, &lt;code>prefix-cache&lt;/code>, &lt;code>kv_cache&lt;/code>, &lt;code>block_hash&lt;/code>, &lt;code>prefix_hash&lt;/code>, &lt;code>prefix_score&lt;/code> and &lt;code>longest_prefix&lt;/code>: zero matches. Search for &lt;code>vllm&lt;/code> inside &lt;code>litellm/router.py&lt;/code>, &lt;code>litellm/router_strategy/&lt;/code> and &lt;code>litellm/router_utils/&lt;/code>: zero. Search for &lt;code>num_requests_waiting&lt;/code>, &lt;code>gpu_cache_usage&lt;/code> or any scraping of the backends&amp;rsquo; &lt;code>/metrics&lt;/code> from the router: zero. LiteLLM&amp;rsquo;s router never asks the engine anything.&lt;/p>
&lt;p>Search for &lt;code>premium_user&lt;/code> in the files where those checks would live: a single result, and it is about something else. There is no paid version with this feature. The only routing piece that is behind a licence is per-tag budgets, in &lt;code>litellm/router_strategy/budget_limiter.py&lt;/code>.&lt;/p>
&lt;p>Where the error came from. LiteLLM&amp;rsquo;s documentation has an entry on dynamic TPM and RPM allocation marked as beta and enterprise, and a pre-call check called &lt;code>prompt_caching&lt;/code>. Skim-read and added together, they give the impression of prefix routing in beta. Read in the code, &lt;code>prompt_caching&lt;/code> is something else, and it deserves a section of its own because it is still recommended for this problem.&lt;/p>
&lt;h2 id="what-litellm-does-have-four-affinities">What LiteLLM does have: four affinities&lt;/h2>
&lt;p>The &lt;code>optional_pre_call_checks&lt;/code> field accepts eight values. None is enabled by default: &lt;code>apply_default_settings&lt;/code> passes an empty list. One of the eight, &lt;code>forward_client_headers_by_model_group&lt;/code>, is declared in the type and in the interface schema and is consumed by no branch of the code. It is a dead value.&lt;/p>
&lt;p>The four that pin a request to a replica are these.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Check&lt;/th>
&lt;th>What it pins&lt;/th>
&lt;th>Where it stores it&lt;/th>
&lt;th>TTL&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>session_affinity&lt;/code>&lt;/td>
&lt;td>Session identifier&lt;/td>
&lt;td>Redis with a Lua script, mirrored in memory&lt;/td>
&lt;td>&lt;code>deployment_affinity_ttl_seconds&lt;/code>, 3600 by default, refreshed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>deployment_affinity&lt;/code>&lt;/td>
&lt;td>API key hash&lt;/td>
&lt;td>Same&lt;/td>
&lt;td>Same&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>responses_api_deployment_check&lt;/code>&lt;/td>
&lt;td>&lt;code>previous_response_id&lt;/code>&lt;/td>
&lt;td>Same&lt;/td>
&lt;td>Same&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>prompt_caching&lt;/code>&lt;/td>
&lt;td>Exact hash of a marked prefix&lt;/td>
&lt;td>&lt;code>DualCache&lt;/code>&lt;/td>
&lt;td>&lt;strong>300 s, hardcoded&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The other four are &lt;code>router_budget_limiting&lt;/code>, &lt;code>encrypted_content_affinity&lt;/code>, &lt;code>enforce_model_rate_limits&lt;/code> and the dead value.&lt;/p>
&lt;h3 id="prompt_caching-why-it-is-no-use-for-this">&lt;code>prompt_caching&lt;/code>: why it is no use for this&lt;/h3>
&lt;p>The key is a SHA256 over the serialisation of the cacheable prefix, and &amp;ldquo;cacheable prefix&amp;rdquo; means everything up to and including the last block marked with &lt;code>cache_control: {&amp;quot;type&amp;quot;: &amp;quot;ephemeral&amp;quot;}&lt;/code>. Three consequences, checked by running the functions in isolation over the repository&amp;rsquo;s code.&lt;/p>
&lt;p>If the request carries no &lt;code>cache_control&lt;/code>, the extraction returns an empty list, the key comes out null and the check pins nothing. Automatic injection of cut points is only enabled with &lt;code>litellm.enable_anthropic_prompt_caching&lt;/code>, which defaults to false, and only for Anthropic and Bedrock providers.&lt;/p>
&lt;p>If the cut point is in the system prompt, the key does not change when turns are added and the pin works. This is the good case and it requires the client to mark it deliberately.&lt;/p>
&lt;p>If the cut point is in the last message, the key changes on every turn and the pin never hits. And this is exactly the default case of the automatic injection, which places one point on the &lt;code>system&lt;/code> and another at index &lt;code>-1&lt;/code>. Since the second is the last, the cacheable prefix becomes the whole conversation.&lt;/p>
&lt;p>On top of that there is the TTL. Two lines with &lt;code>ttl=300&lt;/code>, one of them with the comment &lt;code># store for 5 minutes&lt;/code>. There is no parameter to change it. Issue 28427 has had it open for months with no response from the maintainers, and it points to the mismatch with the one-hour ephemeral caches that LiteLLM itself knows how to request via &lt;code>cache_control.ttl: &amp;quot;1h&amp;quot;&lt;/code>.&lt;/p>
&lt;p>There is a nuance the issue does not capture and that makes diagnosis worse: in the default case the pin does not even last five minutes, because the key changes first.&lt;/p>
&lt;h3 id="session_affinity-the-one-to-use">&lt;code>session_affinity&lt;/code>: the one to use&lt;/h3>
&lt;p>For conversations, this is the right piece and it is reasonably built. The session identifier is looked for in this exact order of headers:&lt;/p>
&lt;ol>
&lt;li>&lt;code>x-litellm-trace-id&lt;/code>&lt;/li>
&lt;li>&lt;code>x-litellm-session-id&lt;/code>&lt;/li>
&lt;li>Any &lt;code>x-&amp;lt;vendor&amp;gt;-session-id&lt;/code>, with the regular expression &lt;code>^x-.+-session-id$&lt;/code> and a value of at least eight alphanumeric characters&lt;/li>
&lt;li>Only if the &lt;code>User-Agent&lt;/code> is Codex&amp;rsquo;s: &lt;code>session-id&lt;/code>, &lt;code>session_id&lt;/code>, &lt;code>thread-id&lt;/code>, &lt;code>conversation_id&lt;/code>&lt;/li>
&lt;li>Plain &lt;code>x-session-id&lt;/code>, for opencode&lt;/li>
&lt;/ol>
&lt;p>And if nothing matches, two fallbacks: a &lt;code>metadata.user_id&lt;/code> in Anthropic format, and the W3C &lt;code>baggage&lt;/code> with &lt;code>session.id=&lt;/code>. The third point is the one that captures Claude Code with nothing configured.&lt;/p>
&lt;p>Storage is a Lua script in Redis that does &lt;code>GET&lt;/code>, &lt;code>SET NX EX&lt;/code> and &lt;code>EXPIRE&lt;/code>, so the first writer wins and the TTL is refreshed on every request that confirms the pin. It is an inactivity TTL, not a total session duration. Without Redis it degrades to a pod-local reservation, which is atomic but not shared.&lt;/p>
&lt;p>A detail of the pipeline order that already came up in the previous article and still holds: the affinity checks run inside &lt;code>async_get_healthy_deployments&lt;/code> before tag filtering. If the pin returns a single deployment and that deployment does not satisfy the request&amp;rsquo;s tag, the later filter removes it and the request fails. Affinity gets no vote on the tags.&lt;/p>
&lt;h3 id="the-routers-that-do-read-the-prompt-and-why-they-are-no-good">The routers that do read the prompt, and why they are no good&lt;/h3>
&lt;p>LiteLLM has four pieces that read message content: &lt;code>AutoRouter&lt;/code> with semantic matching, &lt;code>RequestComplexityRouter&lt;/code>, &lt;code>AdaptiveRouter&lt;/code> and &lt;code>QualityRouter&lt;/code>. They all live in &lt;code>async_pre_routing_hook&lt;/code>, which runs before looking for healthy deployments.&lt;/p>
&lt;p>The reason they do not solve this problem is in what they choose. All four choose a &lt;strong>model group&lt;/strong>, not a replica. They decide whether an easy question goes to the thirty-billion-parameter model or to the small one. None has access to the replica list or knows what is in each one&amp;rsquo;s memory.&lt;/p>
&lt;h3 id="what-did-land-admission-control">What did land: admission control&lt;/h3>
&lt;p>A minor correction to the previous article, which placed this in the 1.101 branch as an upcoming feature. In 1.102.0 it is already there, in &lt;code>litellm/proxy/middleware/admission_control_middleware.py&lt;/code>, with the three settings in &lt;code>general_settings&lt;/code>: &lt;code>max_in_flight_requests_per_worker&lt;/code>, &lt;code>max_queued_requests_per_worker&lt;/code> and &lt;code>admission_queue_timeout_seconds&lt;/code>, the last with a default value of 1.0 seconds.&lt;/p>
&lt;p>It stays inactive as long as the first one is not declared. It exposes an authenticated &lt;code>/health/backlog&lt;/code> and the three metrics &lt;code>litellm_admission_admitted_requests&lt;/code>, &lt;code>litellm_admission_queued_requests&lt;/code> and &lt;code>litellm_admission_rejected_requests_total&lt;/code>, the last with a reason label. It is a per-process semaphore, not coordinated across pods.&lt;/p>
&lt;h2 id="what-the-engine-exposes-upwards">What the engine exposes upwards&lt;/h2>
&lt;p>Before looking at the routers that do do this, you have to understand what raw material they work with. The natural question would be whether vLLM has an endpoint you can ask about a prefix. It does not.&lt;/p>
&lt;p>The only cache router in the entry points is &lt;code>vllm/entrypoints/serve/dev/cache/api_router.py&lt;/code>, and it only exposes destructive writes: &lt;code>POST /reset_prefix_cache&lt;/code>, &lt;code>/reset_mm_cache&lt;/code> and &lt;code>/reset_encoder_cache&lt;/code>. All three are behind &lt;code>VLLM_SERVER_DEV_MODE=1&lt;/code> and do not exist in a normal deployment. There is no read.&lt;/p>
&lt;p>What there is, is an event stream.&lt;/p>
&lt;h3 id="the-zmq-stream">The ZMQ stream&lt;/h3>
&lt;p>It is enabled with &lt;code>--kv-events-config&lt;/code>, and it is off by default. The configuration accepts a publisher (&lt;code>null&lt;/code> or &lt;code>zmq&lt;/code>), an endpoint (by default &lt;code>tcp://*:5557&lt;/code>, which binds), an optional replay endpoint, the number of batches retained for that replay (10,000), and a high water mark limit of 100,000.&lt;/p>
&lt;p>That limit is the one that matters operationally: above it, ZMQ discards events if the consumer does not keep up. Silently.&lt;/p>
&lt;p>The transport is a PUB socket with msgpack serialisation and three-part frames: topic, eight-byte sequence number and payload. The sequence number lets a subscriber ask for replay from where it left off through the optional ROUTER socket. With data parallelism the port shifts by rank, so you have to subscribe to &lt;code>5557+rank&lt;/code>.&lt;/p>
&lt;p>Three event types. &lt;code>BlockStored&lt;/code> carries the hashes, the parent hash, the token identifiers, the block size, the LoRA adapter name, the extra keys, the medium (GPU, CPU or storage) and a session identifier. &lt;code>BlockRemoved&lt;/code> carries only hashes. &lt;code>AllBlocksCleared&lt;/code> carries no payload and is emitted when the prefix cache is reset.&lt;/p>
&lt;p>They are published once per scheduler step, only if there are events. With the feature switched off the cost is zero. With it switched on, the full token identifiers of each block travel over the wire, so bandwidth scales with tokens per second and not with requests per second.&lt;/p>
&lt;p>An example in vLLM&amp;rsquo;s own repository declares itself experimental on its eighth line.&lt;/p>
&lt;h3 id="the-hash-that-has-to-be-replicated">The hash that has to be replicated&lt;/h3>
&lt;p>An exact router does not receive the engine&amp;rsquo;s state, it reconstructs it. For that it has to compute the same hashes, and the engine computes them like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># vllm/v1/core/kv_cache_utils.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">hash_block_tokens&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">hash_function&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">parent_block_hash&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">curr_block_token_ids&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">extra_keys&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="n">parent_block_hash&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">parent_block_hash&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">NONE_HASH&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">hash_function&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">parent_block_hash&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">tuple&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">curr_block_token_ids&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">extra_keys&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It is chained: each hash identifies the complete prefix up to that boundary, not the isolated block. The default algorithm is SHA256, configurable with &lt;code>--prefix-caching-hash-algo&lt;/code> among four values.&lt;/p>
&lt;p>Five things a router has to get right for its hashes to match the engine&amp;rsquo;s:&lt;/p>
&lt;p>The seed. Since 0.29, &lt;code>NONE_HASH&lt;/code> is deterministic from a fixed seed, so two different processes produce the same hashes with nothing configured. It is a silent change of semantics from earlier versions, which required setting &lt;code>PYTHONHASHSEED&lt;/code>. With the &lt;code>xxhash&lt;/code> algorithms it is still needed.&lt;/p>
&lt;p>The block size. By default 16 tokens, but the attention backend can override it if the user did not pass &lt;code>--block-size&lt;/code>: 256 with sliding-window sparse attention, 64 with ROCm&amp;rsquo;s AITER, 64 or more with FlashAttention on XPU. A router must not assume 16, it has to read it from the event.&lt;/p>
&lt;p>The hashing granularity, which is not necessarily the physical block size. There is &lt;code>--prefix-match-unit&lt;/code>, which with several cache groups resolves to the greatest common divisor of the cacheable groups&amp;rsquo; sizes.&lt;/p>
&lt;p>The extra keys, in their order: LoRA adapter name (by name, not by identifier), multimodal identifiers with their relative offset inside the block, &lt;code>cache_salt&lt;/code> only in block zero, and the hash of the prompt embeddings. Sampling parameters do not go in, which is correct: the prefix KV does not depend on the temperature.&lt;/p>
&lt;p>And truncation. By default the hashes travel over the wire as 64-bit integers truncated from a SHA256, not as the full bytes. It can be disabled with &lt;code>VLLM_KV_EVENTS_USE_INT_BLOCK_HASHES=0&lt;/code>. With the default value, a router has a theoretical collision risk that the engine does not have.&lt;/p>
&lt;h3 id="the-hits-are-invisible-by-default">The hits are invisible by default&lt;/h3>
&lt;p>This is the detail that takes the most effort to discover and has the most consequences. The events are incremental: only what gets newly cached is announced. A reused block generates no event.&lt;/p>
&lt;p>A router that only listens sees the additions and the removals, but does not see the hits. Its notion of what is recent and what is cold drifts away from the engine&amp;rsquo;s real LRU with nothing to warn it.&lt;/p>
&lt;p>A full mode exists, and it is per request. &lt;code>sampling_params.extra_args[&amp;quot;kv_cache_report_mode&amp;quot;] = &amp;quot;full&amp;quot;&lt;/code> makes &lt;code>BlockStored&lt;/code> events be emitted for blocks that were hits too. The function&amp;rsquo;s docstring says so plainly: it generates events so that external consumers, &amp;ldquo;for example a gateway&amp;rdquo;, know which blocks were reused. That is to say: for this to work, the router has to inject a parameter into every request it forwards.&lt;/p>
&lt;h3 id="the-metrics-and-what-broke">The metrics, and what broke&lt;/h3>
&lt;p>For aggregate measurement two counters remain: &lt;code>vllm:prefix_cache_queries_total&lt;/code> and &lt;code>vllm:prefix_cache_hits_total&lt;/code>, both in tokens and not in requests, with model and engine labels. There are two equivalents for the external connector, which allow a hit in HBM to be distinguished from one brought in from CPU or NVMe.&lt;/p>
&lt;p>And there are two removals that break existing configurations. &lt;code>vllm:gpu_prefix_cache_hit_rate&lt;/code> no longer exists: zero appearances in the repository. It was deprecated in 0.8, hidden in 0.9 and removed in 0.10. And &lt;code>vllm:gpu_cache_usage_perc&lt;/code> was renamed to &lt;code>vllm:kv_cache_usage_perc&lt;/code>, which breaks Grafana panels and autoscalers that depended on the old name.&lt;/p>
&lt;p>The hit rate has to be derived in PromQL with &lt;code>rate()&lt;/code> over the two counters. The one that appears in the text log is something else: it uses a window of the last thousand requests.&lt;/p>
&lt;p>And it has a bias you need to know about before taking decisions with it. When a request is evicted, its blocks return to the free queue keeping the hash, so the later reprefill finds them again and &lt;strong>counts as a hit&lt;/strong>. A high hit rate can mean useful reuse between users or it can mean the engine is evicting and recomputing in a loop. The internal statistics separate the two things with a &lt;code>preempted&lt;/code> field, which is not exposed as a Prometheus metric.&lt;/p>
&lt;h3 id="the-last-token">The last token&lt;/h3>
&lt;p>A small detail with an effect on long conversations: the maximum hit length is the request&amp;rsquo;s token count minus one. There is always something to recompute in order to get logits. Since the reservation requires alignment to the block size, in practice that can force recomputing the whole final block. With blocks of 16 it is noise. With a backend that prefers 256, it is up to 256 tokens recomputed per turn.&lt;/p>
&lt;h2 id="the-ones-that-do-do-it">The ones that do do it&lt;/h2>
&lt;h3 id="vllm-production-stack">vLLM production-stack&lt;/h3>
&lt;p>Eight routing logics in a single &lt;code>--routing-logic&lt;/code> selector: &lt;code>roundrobin&lt;/code>, &lt;code>session&lt;/code>, &lt;code>kvaware&lt;/code>, &lt;code>loadaware&lt;/code>, &lt;code>prefixaware&lt;/code>, &lt;code>disaggregated_prefill&lt;/code>, &lt;code>disaggregated_prefill_orchestrated&lt;/code> and &lt;code>priority&lt;/code>. Two of them, &lt;code>loadaware&lt;/code> and &lt;code>priority&lt;/code>, are not in the Helm chart&amp;rsquo;s enum and are only enabled through &lt;code>extraArgs&lt;/code>.&lt;/p>
&lt;p>The naming trap is that &lt;code>prefixaware&lt;/code> and &lt;code>kvaware&lt;/code> are radically different things.&lt;/p>
&lt;p>&lt;code>prefixaware&lt;/code> is a hash trie in the router&amp;rsquo;s process. It chunks the prompt string every 128 &lt;strong>characters&lt;/strong>, not tokens, and hashes each chunk with 64-bit xxhash. Its own docstring says so: the chunk size is &amp;ldquo;in number of characters&amp;rdquo;. That 128 is hardcoded, there is no flag or Helm value to change it.&lt;/p>
&lt;p>Four consequences come out of that. &lt;code>--prefix-min-match-length&lt;/code> is measured in characters and is quantised to multiples of 128. A shared prefix that differs by one character inside the first chunk breaks the whole match, because there is no partial match inside a chunk. For chat, it concatenates the content of all the messages with newlines and without applying the chat template, so the indexed string is not what the engine tokenises. And the tie-break between tied replicas is &lt;code>random.choice&lt;/code>, without looking at load.&lt;/p>
&lt;p>The trie has no notion of saturation. A popular prefix concentrates all the traffic on the same replica indefinitely. The docstring assumes the simplified model and declares it: &amp;ldquo;we assume there is no prefix cache eviction&amp;rdquo;.&lt;/p>
&lt;p>It has no pruning either. &lt;code>HashTrie&lt;/code> only has insertion and longest prefix lookup; there is no deletion method. When a replica disappears, its URLs stay forever in every node&amp;rsquo;s sets, and filtering happens at query time. It works, and the trie grows monotonically: each distinct prompt adds its length divided by 128 permanent nodes, with no bound, no TTL and no LRU. The router&amp;rsquo;s memory limit in the chart is 1,000 MiB. By contrast, the round-robin router in the same file does bound its caches to 1,024 entries.&lt;/p>
&lt;p>And it dies on every restart: it is in-process memory state, with no snapshot and no initial load.&lt;/p>
&lt;p>&lt;code>kvaware&lt;/code> and &lt;code>loadaware&lt;/code> are better and have a different price. They do not index themselves: they ask the LMCache controller, which knows about real tokens. That requires &lt;code>lmcache==0.3.11&lt;/code> and &lt;code>vllm==0.13.0&lt;/code> pinned exactly, and the official router image drags in the whole of vLLM. The limitation to read before planning anything: they only look at &lt;code>request_json.get(&amp;quot;prompt&amp;quot;, &amp;quot;&amp;quot;)&lt;/code>, with an open TODO for chat completions. With &lt;code>/v1/chat/completions&lt;/code>, both tokenise an empty string.&lt;/p>
&lt;p>&lt;code>loadaware&lt;/code> is the only one of the three that breaks affinity on saturation, with the formula &lt;code>score = relative_match - beta * relative_load&lt;/code> and &lt;code>beta = 1.0&lt;/code> by default. The authors&amp;rsquo; own reading: a replica at twice the average load loses the equivalent of a full cache hit. The guide suggests 0.25 to favour cache and 2.0 to favour balancing.&lt;/p>
&lt;p>On published results, the documentation&amp;rsquo;s benchmarking file literally says that the measurement platform is coming soon. The project blog&amp;rsquo;s two posts claim between three and ten times lower response latency, and only one declares a partial configuration: Llama 3.1 70B on four nodes with tensor parallelism 2 over 80 GB A100s, inputs of 9K tokens and outputs of 10, against AIBrix 0.2.0 and a flat deployment. Charts with no labelled points, no absolute figures and no declaration of which routing logic was used in each.&lt;/p>
&lt;h3 id="llm-d">llm-d&lt;/h3>
&lt;p>There has been a reorganisation here that invalidates a good part of the third-party documentation. The Endpoint Picker code left the Gateway API Inference Extension repository and was merged into llm-d&amp;rsquo;s, which has been renamed to llm-d Router. The Kubernetes project keeps the &lt;code>InferencePool&lt;/code> API and a lightweight reference EPP that does nothing about prefixes. If you look for KV-aware routing in the extension&amp;rsquo;s repository, today it is not there.&lt;/p>
&lt;p>The &lt;code>InferencePool&lt;/code> is at &lt;code>v1&lt;/code> and is GA. The other two APIs moved to the &lt;code>llm-d.ai&lt;/code> group at &lt;code>v1alpha2&lt;/code>, and when they coexist with the old ones the EPP prefers the new ones and ignores the old.&lt;/p>
&lt;p>Scoring is a weighted sum. The default weights, when no configuration is passed: queue 2, KV utilisation 2, prefix 3. Any scorer referenced without a weight counts as 1.0. There is no configuration through environment variables: the old &lt;code>ENABLE_*_SCORER&lt;/code> no longer exist, everything goes in an &lt;code>EndpointPickerConfig&lt;/code> in YAML.&lt;/p>
&lt;p>The prefix scorer consumes an attribute published by a producer, and there are two producers.&lt;/p>
&lt;p>The &lt;strong>approximate&lt;/strong> one computes the hashes in the EPP itself without talking to the engine, and keeps a local LRU per pod and block pair that is populated after the decision. It is an approximation by construction. It has a hard floor: a configured block size below 64 tokens is raised to 64 at request time, because the index stores one entry per pod and block and going down to 16 would multiply memory by four.&lt;/p>
&lt;p>The &lt;strong>exact&lt;/strong> one builds the real index from the engine&amp;rsquo;s ZMQ events, with one subscriber per pod that is installed and torn down with the endpoint&amp;rsquo;s registration and deregistration. It has adapters for vLLM and SGLang, deduplicates evictions by reference counting, and the replay buffer that allows the index to be rebuilt after an EPP restart requires vLLM 0.26.0 or above.&lt;/p>
&lt;p>The trap is in the tokeniser. The EPP tokenises through a plugin, and the default backend is &lt;code>estimate&lt;/code>: byte packing with no tokeniser, roughly 4 bytes per token, with identifiers that do not correspond to real engine tokens. The exact scorer needs real tokens, and for that you have to explicitly configure the &lt;code>vllm&lt;/code> backend, which does HTTP against the engine&amp;rsquo;s &lt;code>/v1/completions/render&lt;/code> endpoints. If it is omitted, the auto-created &lt;code>estimate&lt;/code> satisfies the dependency and the system &lt;strong>degrades silently&lt;/strong>.&lt;/p>
&lt;p>On maturity, the file itself declares it: all in-tree plugins are in alpha or beta, and they will be promoted to stable when the project approaches 1.0. An alpha plugin configured without the corresponding flag makes the EPP fail to start.&lt;/p>
&lt;p>And on replicas, the warning is explicit: active-active mode should be avoided with approximate prefix routing, because the EPP replicas do not share state and each one only sees the traffic it has served itself, which significantly degrades the hit rate. The exact index is safe in high availability, and even so the reference guides fix a single replica because the accounting of in-flight requests is local to the process.&lt;/p>
&lt;p>The cost is the best documented in the whole field, and it deserves a table. Simulator with Qwen3 8B, 100 pods, 100K input tokens:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Requests/s&lt;/th>
&lt;th>Cap on tokens to match&lt;/th>
&lt;th>Peak CPU&lt;/th>
&lt;th>Peak memory&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>5.0&lt;/td>
&lt;td>4,096&lt;/td>
&lt;td>1.19 cores&lt;/td>
&lt;td>0.26 GiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5.0&lt;/td>
&lt;td>100,000&lt;/td>
&lt;td>3.82 cores&lt;/td>
&lt;td>0.65 GiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>98.7&lt;/td>
&lt;td>4,096&lt;/td>
&lt;td>35.17 cores&lt;/td>
&lt;td>2.46 GiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>98.8&lt;/td>
&lt;td>100,000&lt;/td>
&lt;td>46.50 cores&lt;/td>
&lt;td>3.41 GiB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>With outputs of 10K tokens at 50 requests per second, 32.53 cores and 12.54 GiB. The maintainers&amp;rsquo; sizing rule is half a core to one core per request per second on large agentic loads. And idle consumption scales with the number of pods because of continuous scraping: with 100 pods, about 7.5 cores with no traffic.&lt;/p>
&lt;h3 id="nvidia-dynamo">NVIDIA Dynamo&lt;/h3>
&lt;p>A precise event-driven index, with a global radix tree in each frontend&amp;rsquo;s process. Since all the replicas consume the same event plane, there is no need to synchronise routers with each other for the prefix state.&lt;/p>
&lt;p>The cost function is explicit and weighted. It discounts from the prefill the blocks that overlap in GPU, host and disk with different weights (1.0 for the overlap credit, 0.75 for host, 0.25 for disk), and adds the potential decode blocks and the active requests.&lt;/p>
&lt;p>There is a parameter to look at before deploying: &lt;code>--router-kv-overlap-score-credit-decay&lt;/code> is at &lt;strong>0&lt;/strong> by default, that is, disabled. Its description says what it is for: preventing loaded, cache-rich replicas from winning again and again while the freshly autoscaled ones receive too little traffic. With the default value, a new pod has no cache, therefore never wins the scoring, therefore never generates cache.&lt;/p>
&lt;p>The approximate mode exists (&lt;code>--no-router-kv-events&lt;/code>, with a TTL of 120 seconds) and the documentation says it is not the recommended route in production.&lt;/p>
&lt;p>Dynamo is the most honest project in its measurement methodology: its comparison guide recommends contrasting &lt;code>--router-mode random&lt;/code> against &lt;code>kv&lt;/code> over the public Mooncake trace from FAST'25, with a declared cache ratio of 59 %, and it generates the table locally instead of publishing a number.&lt;/p>
&lt;h3 id="the-rest-of-the-field">The rest of the field&lt;/h3>
&lt;p>&lt;strong>SGLang&lt;/strong> has renamed its router to &lt;code>sgl-model-gateway&lt;/code>, version 0.3.2 within SGLang 0.5.19. Its &lt;code>cache_aware&lt;/code> policy is an approximate radix tree over raw characters, with switching to shortest queue when the system is unbalanced according to two combined thresholds. One detail that can cost an afternoon: the Rust defaults and the Python CLI defaults &lt;strong>do not match&lt;/strong>, and on the maximum tree size they differ by four orders of magnitude (10,000 nodes against 2^26).&lt;/p>
&lt;p>&lt;strong>AIBrix&lt;/strong> 0.7.0 offers a standard mode with a local hash table and a KV event synchronisation mode behind a flag and a build tag, with vLLM 0.7.0 or above.&lt;/p>
&lt;p>&lt;strong>Ray Serve LLM&lt;/strong> has &lt;code>PrefixCacheAffinityRouter&lt;/code> with an explicit alpha API warning, a character-based prefix tree in a decoupled actor, and its imbalance threshold at infinity by default, that is, out of the box it never breaks affinity on load.&lt;/p>
&lt;p>&lt;strong>Envoy AI Gateway&lt;/strong> 1.1.0 implements none of this and delegates to &lt;code>InferencePool&lt;/code> and the EPP. &lt;strong>KServe&lt;/strong> the same, with its &lt;code>LLMInferenceService&lt;/code> at &lt;code>v1alpha1&lt;/code>. &lt;strong>Mooncake Conductor&lt;/strong> is still an unimplemented proposal.&lt;/p>
&lt;h3 id="comparison-table">Comparison table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Project&lt;/th>
&lt;th>Version and date&lt;/th>
&lt;th>Index&lt;/th>
&lt;th>Where it lives&lt;/th>
&lt;th>Kubernetes&lt;/th>
&lt;th>Declared maturity&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>production-stack &lt;code>prefixaware&lt;/code>&lt;/td>
&lt;td>main branch, 09-Sep-2026&lt;/td>
&lt;td>Approximate, character trie, no eviction&lt;/td>
&lt;td>Router process&lt;/td>
&lt;td>Optional&lt;/td>
&lt;td>WIP in the README&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>production-stack &lt;code>kvaware&lt;/code>/&lt;code>loadaware&lt;/code>&lt;/td>
&lt;td>same&lt;/td>
&lt;td>Exact via LMCache controller&lt;/td>
&lt;td>LMCache controller&lt;/td>
&lt;td>Optional&lt;/td>
&lt;td>No chat completions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>llm-d Router approximate&lt;/td>
&lt;td>v0.10.0, 17-Aug-2026&lt;/td>
&lt;td>Approximate, LRU per pod&lt;/td>
&lt;td>EPP process&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Beta, active-active discouraged&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>llm-d Router exact&lt;/td>
&lt;td>same&lt;/td>
&lt;td>Exact through ZMQ events&lt;/td>
&lt;td>EPP process&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Beta&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NVIDIA Dynamo&lt;/td>
&lt;td>v1.4.2, 27-Aug-2026&lt;/td>
&lt;td>Exact, event-driven radix tree&lt;/td>
&lt;td>Frontend process&lt;/td>
&lt;td>No&lt;/td>
&lt;td>GA with experimental subsystems&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SGLang &lt;code>sgl-model-gateway&lt;/code>&lt;/td>
&lt;td>0.3.2, 03-Sep-2026&lt;/td>
&lt;td>Approximate, character radix&lt;/td>
&lt;td>Gateway process&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Default policy&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AIBrix&lt;/td>
&lt;td>v0.7.0, 16-Jun-2026&lt;/td>
&lt;td>Both&lt;/td>
&lt;td>Gateway plugin&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Synchronisation behind a flag&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ray Serve LLM&lt;/td>
&lt;td>Ray 2.58&lt;/td>
&lt;td>Approximate, characters&lt;/td>
&lt;td>Ray actor&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Declared alpha&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Envoy AI Gateway&lt;/td>
&lt;td>v1.1.0, 21-Aug-2026&lt;/td>
&lt;td>None of its own&lt;/td>
&lt;td>Delegates to EPP&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Stable 1.x API&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-much-it-really-improves">How much it really improves&lt;/h2>
&lt;p>Here two types of source have to be separated.&lt;/p>
&lt;p>&lt;strong>The projects&amp;rsquo; own numbers.&lt;/strong> Red Hat published the llm-d case in May: Qwen3 32B, eight vLLM pods over 16 H100s with tensor parallelism 2, synthetic shared-prefix load with 150 groups of five prompts, 6,000 tokens of system prompt, 1,200 of question and 1,000 of output. Result: up to 109 % more throughput and up to 99 % less TTFT, around 200 concurrent users within SLO against around 20. The repository&amp;rsquo;s result files give more detail: peak throughput from 6,986 to 14,892 output tokens per second, p90 TTFT from 135.5 seconds to 0.26.&lt;/p>
&lt;p>That same file publishes the regression, and that is why it is worth reading: p50 inter-token latency rises by 22.4 % with vLLM and by 45.4 % with SGLang. The trade-off is declared: routing by affinity concentrates more concurrent work on the pods with a warm cache.&lt;/p>
&lt;p>It is the best possible case. A synthetic shared prefix and a reference that is a flat Kubernetes &lt;code>Service&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The independent measurements.&lt;/strong> There are three and they say different things.&lt;/p>
&lt;p>&lt;em>CacheRoute&lt;/em>, from Meta, August 2026, is the most complete. Llama 3.3 70B in fp8, 30 targets with tensor parallelism 2 over 60 H100s, a semi-synthetic trace from production telemetry with 128,824 business keys, and all the alternatives reimplemented in the same harness instead of comparing against a dumb balancer. It obtains 176 requests per second with a p99 SLO of 3.5 seconds against 76 for the best competitor, and a hit rate of 93.2 % against 72.0 %.&lt;/p>
&lt;p>And it publishes two negative findings that nobody else publishes. There are load regimes where affinity &lt;strong>reduces&lt;/strong> capacity to between half and two thirds of the reference, to the point that the authors make a mirrored-traffic trial mandatory before deploying. And growing the fleet can cool a prefix even though total cache capacity grows, because the time between revisits stretches beyond the eviction window. Scaling horizontally can make the hit rate worse.&lt;/p>
&lt;p>&lt;em>GORGO&lt;/em>, June 2026, measures on a multi-region deployment with a dataset where intra-user prefix reuse is 89.4 % and the average prompt is almost 18,000 tokens. An improvement of 6.9 to 15.5 % in p95 TTFT. With extremely high reuse, the improvement is single-digit, because network latency and queuing dominate.&lt;/p>
&lt;p>&lt;em>LAAR&lt;/em>, from IBM Research Tokyo, April 2026, is the uncomfortable counterpoint: in its comparison, session affinity routing was the &lt;strong>worst&lt;/strong> of the baselines, and at 64K contexts a load-based routing gives lower absolute latency at the cost of fewer hits.&lt;/p>
&lt;p>The joint reading fits in three conditions. The big improvements require high, concentrated prefix reuse, long prompts, and a weak reference. Take away any of the three and the numbers deflate.&lt;/p>
&lt;h2 id="the-five-failure-modes">The five failure modes&lt;/h2>
&lt;p>&lt;strong>Hot spot from affinity.&lt;/strong> The busiest bucket inherits the load&amp;rsquo;s skew, and tail latency starts following the hottest target instead of the fleet average. Mitigations exist in the four main products and in three of the four they ship &lt;strong>switched off&lt;/strong>: Dynamo&amp;rsquo;s credit decay at 0, Ray&amp;rsquo;s imbalance threshold at infinity, and production-stack&amp;rsquo;s &lt;code>prefixaware&lt;/code> with none at all.&lt;/p>
&lt;p>&lt;strong>Starvation of the freshly scaled pod.&lt;/strong> A new pod has no cache, so it never wins the scoring, so it never warms up. Dynamo documents it as the reason for the decay parameter. I found no primary documentation from any of the projects about the specific interaction with KEDA.&lt;/p>
&lt;p>&lt;strong>Cost of the index.&lt;/strong> The llm-d numbers in the previous table are the best public reference that exists. Raising the cap on tokens to match from 16,384 to 400,000 can more than double the EPP&amp;rsquo;s CPU with little traffic.&lt;/p>
&lt;p>&lt;strong>Traffic with unique prefixes.&lt;/strong> Each project degrades to something different. SGLang routes to the smallest tree, which is not the same as the least loaded. Ray falls back to power of two choices. Dynamo has an explicit preset, &lt;code>--load-aware&lt;/code>, to say &amp;ldquo;I want the load model without reuse&amp;rdquo;. And production-stack&amp;rsquo;s &lt;code>prefixaware&lt;/code> keeps inserting into the trie, that is, keeps paying the cost while receiving nothing.&lt;/p>
&lt;p>&lt;strong>Sibling bursts.&lt;/strong> Parallel sampling, best of N, or an agent that opens five branches at once. With event-driven routing, no engine has yet emitted the block stored event, all the siblings score zero overlap, and the prefix is precomputed on every replica. Dynamo has a prediction TTL for this; the others do not.&lt;/p>
&lt;h2 id="the-architecture-that-comes-out-of-all-of-the-above">The architecture that comes out of all of the above&lt;/h2>
&lt;p>Two layers, with the responsibilities separated.&lt;/p>
&lt;p>On top, LiteLLM, with what it does do well: non-forgeable identity through the virtual key, budgets, spend logging, concurrency limits, fallback between providers and API unification. For the gateway, each model group has &lt;strong>a single destination&lt;/strong>: the URL of the router below.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">model_list&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://kv-router.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/VLLM_KEY&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">optional_pre_call_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;session_affinity&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">deployment_affinity_ttl_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_in_flight_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_queued_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">admission_queue_timeout_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Below, the KV-aware router, inside the engine&amp;rsquo;s domain, talking to the vLLM replicas and consuming their events.&lt;/p>
&lt;p>Four consequences of this separation that have to be accepted before building it.&lt;/p>
&lt;p>Per-replica visibility in LiteLLM is lost. For the gateway there is a single deployment, so its cooldowns, its per-deployment counters and its strategies stop meaning anything. Observability of which replica served what goes downstairs.&lt;/p>
&lt;p>Retries stack up. The client SDK retries, LiteLLM retries, and the router below may have its own failover. In &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the previous article&lt;/a> 45 calls per turn came out with three layers; with four it is worse. Lowering &lt;code>num_retries&lt;/code> in the gateway to 1, or to 0, is the sensible decision when there is a router below that already retries.&lt;/p>
&lt;p>LiteLLM&amp;rsquo;s session affinity stops making sense for the KV, because it no longer chooses a replica. It is kept if it is used for something else, such as pinning a conversation to a specific provider.&lt;/p>
&lt;p>And the choice of the router below conditions the rest of the cluster far more than the gateway&amp;rsquo;s does. llm-d requires Kubernetes 1.32 or above, Gateway API, the inference extension and a conformant Gateway. Dynamo does not require Kubernetes. production-stack has a Helm chart and works outside it. That decision is the expensive one, not the gateway&amp;rsquo;s.&lt;/p>
&lt;h2 id="when-not-to-build-this">When not to build this&lt;/h2>
&lt;p>Three cases where the correct answer is not to do it, at least not yet.&lt;/p>
&lt;p>&lt;strong>A single replica per model.&lt;/strong> With one replica, the engine&amp;rsquo;s prefix cache already does all the work and there is nothing to route. That is the case for a good part of the &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">four H100&lt;/a> deployments with one large model and tensor parallelism.&lt;/p>
&lt;p>&lt;strong>Low or scattered prefix reuse.&lt;/strong> Before building anything, the measurement is &lt;code>rate(vllm:prefix_cache_hits_total[5m]) / rate(vllm:prefix_cache_queries_total[5m])&lt;/code> per replica, with a mental correction for the bias from reprefills after eviction. If it is already high with blind distribution, reuse is concentrated inside each conversation, and there LiteLLM&amp;rsquo;s session affinity costs one line of configuration and resolves the case.&lt;/p>
&lt;p>&lt;strong>Traffic where the network dominates.&lt;/strong> The GORGO result is the warning: with enormous prompts and a distributed deployment, the improvement is eaten up by queuing and network latency.&lt;/p>
&lt;p>The case where it does pay off is specific: several replicas of the same model, a large system prompt shared between different users, or a RAG with a corpus of documents that repeat across requests. There the prefill saved is real and it is most of the work.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Measure the current hit rate per replica with the two vLLM counters before touching anything, and check whether the engine is evicting with &lt;code>vllm:num_preemptions_total&lt;/code>.&lt;/li>
&lt;li>Check that prefix caching is switched on. It is no longer &lt;code>--enable-prefix-caching&lt;/code> that switches it on: the default value is derived from the model and the log of the decision is at DEBUG level. What is actionable today is &lt;code>--no-enable-prefix-caching&lt;/code> to switch it off.&lt;/li>
&lt;li>Audit the prompts before routing: a timestamp or a session identifier at the start of the system prompt breaks the match from block zero and no routing layer fixes it.&lt;/li>
&lt;li>If the KV event stream is enabled, size the bandwidth by tokens per second and watch discarding at the high water mark. With data parallelism, subscribe to one port per rank.&lt;/li>
&lt;li>If an exact router is chosen, verify the vLLM version against the one the replay buffer requires.&lt;/li>
&lt;li>If llm-d is chosen, explicitly configure the &lt;code>vllm&lt;/code> tokenisation backend. The default &lt;code>estimate&lt;/code> does not fail, it degrades.&lt;/li>
&lt;li>If production-stack is chosen with &lt;code>kvaware&lt;/code> or &lt;code>loadaware&lt;/code>, check first whether the traffic goes through &lt;code>/v1/chat/completions&lt;/code>, because today it does not work there.&lt;/li>
&lt;li>Review the load-based affinity-breaking parameter, because in three of the four main projects it ships disabled.&lt;/li>
&lt;li>Lower &lt;code>num_retries&lt;/code> in LiteLLM when there is a router below that already does failover.&lt;/li>
&lt;li>Trial with mirrored traffic before changing production. It is the explicit recommendation of the only independent measurement that went looking for bad regimes, and found them.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>LiteLLM has no prefix routing in any version.&lt;/strong> Neither the open one nor the paid one. I got it wrong in June.&lt;/li>
&lt;li>&lt;strong>&lt;code>prompt_caching&lt;/code> does nothing if the client does not send &lt;code>cache_control&lt;/code>&lt;/strong>, and with the default automatic injection it fails on every turn because it marks the last message.&lt;/li>
&lt;li>&lt;strong>That pin&amp;rsquo;s TTL is 300 hardcoded seconds&lt;/strong>, incompatible with the one-hour caches that LiteLLM itself knows how to request.&lt;/li>
&lt;li>&lt;strong>production-stack&amp;rsquo;s &lt;code>prefixaware&lt;/code> indexes characters, not tokens&lt;/strong>, in non-configurable chunks of 128, and its trie is never purged and models no eviction.&lt;/li>
&lt;li>&lt;strong>production-stack&amp;rsquo;s &lt;code>kvaware&lt;/code> and &lt;code>loadaware&lt;/code> do not support chat completions.&lt;/strong> They tokenise an empty string.&lt;/li>
&lt;li>&lt;strong>The default tokeniser in llm-d&amp;rsquo;s EPP is a byte estimate&lt;/strong>, and with it the exact scorer degrades silently.&lt;/li>
&lt;li>&lt;strong>llm-d&amp;rsquo;s active-active mode is discouraged with approximate prefixes&lt;/strong>, and the reference guides fix one replica even with the exact one.&lt;/li>
&lt;li>&lt;strong>Dynamo&amp;rsquo;s overlap credit decay ships at zero&lt;/strong>, which can leave a freshly autoscaled replica with no traffic.&lt;/li>
&lt;li>&lt;strong>The SGLang router&amp;rsquo;s default values differ between the Rust and the Python CLI&lt;/strong>, by as much as four orders of magnitude.&lt;/li>
&lt;li>&lt;strong>&lt;code>vllm:gpu_prefix_cache_hit_rate&lt;/code> no longer exists&lt;/strong> and &lt;code>vllm:gpu_cache_usage_perc&lt;/code> is now called &lt;code>vllm:kv_cache_usage_perc&lt;/code>. Old panels and autoscalers are measuring nothing.&lt;/li>
&lt;li>&lt;strong>The hit rate counts the reprefill after eviction as a hit.&lt;/strong> A high number can be reuse or it can be thrashing.&lt;/li>
&lt;li>&lt;strong>KV events do not publish the hits by default&lt;/strong>, only the additions, unless full mode is requested request by request.&lt;/li>
&lt;li>&lt;strong>The hashes travel truncated to 64 bits&lt;/strong> unless it is disabled with an environment variable.&lt;/li>
&lt;li>&lt;strong>The default block size is 16, and the attention backend can change it&lt;/strong> without anyone asking.&lt;/li>
&lt;li>&lt;strong>The Endpoint Picker left the Gateway API Inference Extension repository.&lt;/strong> Looking for it there today leads nowhere.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>June&amp;rsquo;s error has a reading that goes beyond the erratum. It came from reading a product&amp;rsquo;s documentation and not its code, and from the fact that LiteLLM&amp;rsquo;s documentation uses the word &amp;ldquo;caching&amp;rdquo; in a pre-call check that does something else. The distance between what a name suggests and what a function does is, at this point in the track, the most repeated pattern of the whole series.&lt;/p>
&lt;p>The architectural conclusion is that KV-aware routing is not a gateway function. It is not one today in LiteLLM and probably should not be: it requires keeping an index synchronised with each engine&amp;rsquo;s memory, replicating its hash function, consuming an event stream and sizing CPU per request per second. That belongs to the engine&amp;rsquo;s domain, not to that of identity and budget.&lt;/p>
&lt;p>What remains is a two-part decision. The first is whether the traffic deserves it, and that is answered with two Prometheus counters and without installing anything. The second is which of the three routers fits the cluster you already have, and there the deciding variable is not the hit rate but whether you are willing to bring in Gateway API and the inference extension, or to pin vLLM 0.13.0, or to operate a Dynamo frontend.&lt;/p>
&lt;p>And the honesty the material demands: the only independent measurement that specifically went looking for regimes where this makes things worse found them, and they are not marginal. Half to two thirds of reference capacity is a serious regression. That no project publishes that side of the curve does not mean it does not exist in theirs.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">Humans and agents on the same gateway&lt;/a>, the session affinity and the pipeline order taken as read here, and the rest of what the gateway can do with two classes of traffic.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router&lt;/a>, the June article this one corrects, and the router&amp;rsquo;s other four functions that remain valid.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Prefix cache: hit rate engineering&lt;/a>, the template audit to do before even considering routing anything.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: fundamentals&lt;/a>, what exactly it is that you are trying not to recompute.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/long-context-kv-offloading-notebook-desk/">Long context and KV offloading&lt;/a>, LMCache and the memory hierarchy that &lt;code>kvaware&lt;/code> and Dynamo&amp;rsquo;s per-tier weights depend on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">Prefill optimisations in vLLM&lt;/a>, the specific work saved when routing gets it right.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The vLLM scheduler step&lt;/a>, the loop where the KV events are published, once per step.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">OTel instrumentation in vLLM&lt;/a>, the engine&amp;rsquo;s metrics, including the ones that have changed name.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">Choosing the switchboard&lt;/a>, why the gateway is chosen on licence and fit before features, which is exactly what this article illustrates again.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a>, the replica deployment that everything above operates on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a>, why without this article&amp;rsquo;s session affinity the capacity calculation describes a different system, with the prefix hit rate measured in production.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>Router - Load Balancing&lt;/em> (estrategias, comprobaciones previas, afinidad): &lt;a href="https://docs.litellm.ai/docs/routing">https://docs.litellm.ai/docs/routing&lt;/a>.&lt;/li>
&lt;li>LiteLLM, código: &lt;code>litellm/router.py&lt;/code>, &lt;code>litellm/router_utils/prompt_caching_cache.py&lt;/code>, &lt;code>litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py&lt;/code>, &lt;code>litellm/router_utils/pre_call_checks/deployment_affinity_check.py&lt;/code>, &lt;code>litellm/proxy/litellm_pre_call_utils.py&lt;/code>, &lt;code>litellm/proxy/middleware/admission_control_middleware.py&lt;/code>: &lt;a href="https://github.com/BerriAI/litellm">https://github.com/BerriAI/litellm&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 28427, TTL de afinidad por caché de prompts fijado a cinco minutos: &lt;a href="https://github.com/BerriAI/litellm/issues/28427">https://github.com/BerriAI/litellm/issues/28427&lt;/a>.&lt;/li>
&lt;li>vLLM, código: &lt;code>vllm/v1/core/kv_cache_utils.py&lt;/code>, &lt;code>vllm/v1/core/block_pool.py&lt;/code>, &lt;code>vllm/v1/core/kv_cache_manager.py&lt;/code>, &lt;code>vllm/distributed/kv_events.py&lt;/code>, &lt;code>vllm/config/kv_events.py&lt;/code>, &lt;code>vllm/v1/metrics/loggers.py&lt;/code>, &lt;code>vllm/v1/metrics/stats.py&lt;/code>: &lt;a href="https://github.com/vllm-project/vllm">https://github.com/vllm-project/vllm&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>KV offloading usage&lt;/em> (niveles CPU, filesystem y objeto; compartición entre procesos): &lt;a href="https://docs.vllm.ai/en/latest/features/kv_offloading_usage.html">https://docs.vllm.ai/en/latest/features/kv_offloading_usage.html&lt;/a>.&lt;/li>
&lt;li>vLLM, notas de la versión 0.29.0 (semilla determinista, seguimiento de propiedad en eventos, flags de admisión): &lt;a href="https://github.com/vllm-project/vllm/releases/tag/v0.29.0">https://github.com/vllm-project/vllm/releases/tag/v0.29.0&lt;/a>.&lt;/li>
&lt;li>vLLM, RFC 16669, publicación de bloques KV y métricas: &lt;a href="https://github.com/vllm-project/vllm/issues/16669">https://github.com/vllm-project/vllm/issues/16669&lt;/a>.&lt;/li>
&lt;li>vLLM production-stack, código: &lt;code>src/vllm_router/routers/routing_logic.py&lt;/code>, &lt;code>src/vllm_router/prefix/hashtrie.py&lt;/code>, &lt;code>src/vllm_router/parsers/parser.py&lt;/code>: &lt;a href="https://github.com/vllm-project/production-stack">https://github.com/vllm-project/production-stack&lt;/a>.&lt;/li>
&lt;li>LMCache Lab, resultados de production-stack frente a AIBrix (parte interesada): &lt;a href="https://blog.lmcache.ai/en/2025/03/06/open-source-llm-inference-cluster-performing-10x-faster-than-sota-oss-solution/">https://blog.lmcache.ai/en/2025/03/06/open-source-llm-inference-cluster-performing-10x-faster-than-sota-oss-solution/&lt;/a>.&lt;/li>
&lt;li>llm-d Router, código y documentación de operaciones (pesos por defecto, dimensionamiento del EPP, aviso de activo-activo): &lt;a href="https://github.com/llm-d/llm-d-router">https://github.com/llm-d/llm-d-router&lt;/a>.&lt;/li>
&lt;li>llm-d, guía de enrutado preciso por prefijo y resultados publicados (parte interesada): &lt;a href="https://github.com/llm-d/llm-d">https://github.com/llm-d/llm-d&lt;/a>.&lt;/li>
&lt;li>llm-d Router, incidencia 1290, estado de prefijo no compartido entre réplicas del EPP: &lt;a href="https://github.com/llm-d/llm-d-router/issues/1290">https://github.com/llm-d/llm-d-router/issues/1290&lt;/a>.&lt;/li>
&lt;li>Red Hat, &lt;em>Same 16 GPUs, twice the users&lt;/em> (parte interesada): &lt;a href="https://www.redhat.com/en/blog/same-16-gpus-twice-users-inference-aware-routing-llm-clusters">https://www.redhat.com/en/blog/same-16-gpus-twice-users-inference-aware-routing-llm-clusters&lt;/a>.&lt;/li>
&lt;li>Kubernetes SIG Network, Gateway API Inference Extension (traslado del EPP y del BBR): &lt;a href="https://github.com/kubernetes-sigs/gateway-api-inference-extension">https://github.com/kubernetes-sigs/gateway-api-inference-extension&lt;/a>.&lt;/li>
&lt;li>NVIDIA Dynamo, diseño y ajuste del router KV: &lt;a href="https://github.com/ai-dynamo/dynamo">https://github.com/ai-dynamo/dynamo&lt;/a>.&lt;/li>
&lt;li>SGLang, &lt;code>sgl-model-gateway&lt;/code>: &lt;a href="https://github.com/sgl-project/sglang/tree/main/sgl-model-gateway">https://github.com/sgl-project/sglang/tree/main/sgl-model-gateway&lt;/a>.&lt;/li>
&lt;li>AIBrix, enrutado por caché de prefijo: &lt;a href="https://github.com/vllm-project/aibrix/blob/main/pkg/plugins/gateway/algorithms/prefix_cache_readme.md">https://github.com/vllm-project/aibrix/blob/main/pkg/plugins/gateway/algorithms/prefix_cache_readme.md&lt;/a>.&lt;/li>
&lt;li>Ray Serve LLM, enrutado consciente de prefijo (API en alfa): &lt;a href="https://docs.ray.io/en/latest/serve/llm/user-guides/prefix-aware-routing.html">https://docs.ray.io/en/latest/serve/llm/user-guides/prefix-aware-routing.html&lt;/a>.&lt;/li>
&lt;li>Huang Cheng (Meta), &lt;em>CacheRoute&lt;/em>, agosto de 2026: &lt;a href="https://arxiv.org/html/2608.19677">https://arxiv.org/html/2608.19677&lt;/a>.&lt;/li>
&lt;li>Yuan et al., &lt;em>DualMap&lt;/em>, febrero de 2026: &lt;a href="https://arxiv.org/abs/2602.06502">https://arxiv.org/abs/2602.06502&lt;/a>.&lt;/li>
&lt;li>Ricci Toniolo et al., &lt;em>GORGO&lt;/em>, junio de 2026: &lt;a href="https://arxiv.org/html/2602.11688">https://arxiv.org/html/2602.11688&lt;/a>.&lt;/li>
&lt;li>Yoshimura, Chiba y van de Beek, &lt;em>Accuracy Is Speed&lt;/em> (EuroMLSys &amp;lsquo;26), abril de 2026: &lt;a href="https://arxiv.org/html/2604.15732v1">https://arxiv.org/html/2604.15732v1&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Humans and agents on the same gateway: why `priority` does not prioritise, the 429 that brings down the whole pool, and the two ways to separate traffic</title><link>https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/</link><pubDate>Thu, 10 Sep 2026 08:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/</guid><description>&lt;blockquote>
&lt;p>Fourth article in the operational track of the control layer. The &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">pair with Langfuse&lt;/a> covered observability, &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">the proxy&amp;rsquo;s day 2&lt;/a> covered availability and &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">virtual keys&lt;/a> covered governance. Here comes the new tenant: agents, which speak the same protocol as humans and behave in a different way. Everything that follows is verified against LiteLLM 1.100.1 and vLLM 0.29.0, both from the first ten days of September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The day a team connects an agent to the corporate gateway, the platform changes regime without anyone touching a YAML. These are the seven conclusions.&lt;/p>
&lt;p>&lt;strong>LiteLLM&amp;rsquo;s &lt;code>priority&lt;/code> parameter prioritises nothing under normal conditions.&lt;/strong> It is still marked as beta and its queue is only consulted when &lt;strong>all&lt;/strong> the deployments in the group are in cooldown. With free capacity, the only thing it produces is a response header. On top of that, the success branch does not pop the item off the queue, so the list grows indefinitely; and on &lt;code>/chat/completions&lt;/code> the value &lt;code>0&lt;/code>, which is the one used by all the documentation examples, is falsy in Python and disables queuing.&lt;/p>
&lt;p>&lt;strong>Separating traffic is done with two pools, not with priorities.&lt;/strong> The non-forgeable way is &lt;code>model_group_alias&lt;/code> at key or team level: the client always asks for &lt;code>qwen-30b&lt;/code> and the credential decides whether that resolves to &lt;code>qwen-30b-interactivo&lt;/code> or to &lt;code>qwen-30b-agentes&lt;/code>. The way that does not touch clients is &lt;code>tag_regex&lt;/code> against the &lt;code>User-Agent&lt;/code>, with a pattern like &lt;code>^User-Agent: claude-code\/&lt;/code>, which serves to classify load and not to control access, because that header is set by the client.&lt;/p>
&lt;p>&lt;strong>The useful admission control arrives in the 1.101 branch&lt;/strong>, with &lt;code>max_in_flight_requests_per_worker&lt;/code> and a per-process queue that returns 503 with &lt;code>retry-after&lt;/code>. It rejects before authenticating, so what it discards does not appear attributed to any key.&lt;/p>
&lt;p>&lt;strong>&lt;code>max_parallel_requests&lt;/code> is only applied at virtual key level.&lt;/strong> The team, user, end customer and organisation endpoints accept the field and store it in the database, and nobody reads it. &lt;code>global_max_parallel_requests&lt;/code> is a no-op unless the old limiter is reactivated, and &lt;code>general_settings.max_parallel_requests&lt;/code> is read by no code path.&lt;/p>
&lt;p>&lt;strong>vLLM with &lt;code>--scheduling-policy priority&lt;/code> does not push ahead anyone who is already generating.&lt;/strong> The only thing that changes is who it evicts when KV cache runs short. If the agents have filled &lt;code>max_num_seqs&lt;/code>, a chat with the highest priority waits just the same. And the two knobs that in 2025 allowed short prompts to get ahead of long ones, &lt;code>max_num_partial_prefills&lt;/code> and &lt;code>max_long_partial_prefills&lt;/code>, disappeared from the code in 0.27.&lt;/p>
&lt;p>&lt;strong>The retry product reaches 45 calls per agent turn.&lt;/strong> Three from the client SDK times three from the router times up to five fallback groups. The proxy sets &lt;code>max_retries=0&lt;/code> on the SDK it uses itself, but it cannot touch the client&amp;rsquo;s. The timeout is per attempt, not per request, and LiteLLM&amp;rsquo;s &lt;code>Timeout&lt;/code> comes out as a 408, which is retryable.&lt;/p>
&lt;p>&lt;strong>A single 429 takes a deployment out of the pool for five seconds&lt;/strong>, without going through the &lt;code>allowed_fails&lt;/code> threshold. With an agentic burst against a fleet of four replicas, the whole pool enters cooldown and the gateway returns a 429 of its own to everybody, humans included.&lt;/p>
&lt;h2 id="you-are-here-the-gateway-layer-with-two-classes-of-traffic">You are here: the gateway layer with two classes of traffic&lt;/h2>
&lt;p>In the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven-layer stack&lt;/a> this still lives in the gateway layer, with one difference compared to the previous three articles. There the variable was load: more requests, more teams, more budget to share out. Here the variable is the shape of the load.&lt;/p>
&lt;p>A gateway sized for three hundred engineers using a chat works. The same gateway, with thirty of those engineers running coding agents, serves a fraction of the human traffic with the previous latency and the rest degrades. The GPUs stay just as busy, the utilisation metrics stay good, and the support team gets complaints from people waiting nine seconds for a paragraph.&lt;/p>
&lt;h2 id="the-analogy-the-registry-desk-and-the-courier-with-two-hundred-files">The analogy: the registry desk and the courier with two hundred files&lt;/h2>
&lt;p>A registry office serves people in order of arrival. Citizens come in one at a time, present a piece of paper, and leave. The system is fair and the queue moves.&lt;/p>
&lt;p>One Tuesday a courier turns up with a trolley of two hundred files. He joins the queue like anyone else, because rules are rules, and when his turn comes he occupies the desk for forty minutes. Behind him are twelve people with one piece of paper each. Nobody has broken any rule. The courier has the same right as everyone else and uses the same procedure. What fails is that the office was designed assuming a certain size of transaction and another one three orders of magnitude bigger has turned up.&lt;/p>
&lt;p>The real solutions for an office are three, and all three have an exact equivalent in the platform. Open a desk for bulk deliveries, which is separating pools. Put up a capacity sign and send people back later, which is admission control with its 503. And say that files are handed in during the afternoon, which is the part that on an inference platform can almost never be applied, because the agent does not negotiate schedules.&lt;/p>
&lt;p>What does not work in the office is giving the citizen a priority number when the courier is already at the desk. That number does not interrupt the transaction in progress. It is, literally, what priority does in the two layers of the platform, and it is worth keeping in mind while reading the next section.&lt;/p>
&lt;h2 id="the-two-kinds-of-traffic-measured">The two kinds of traffic, measured&lt;/h2>
&lt;p>Before configuring anything, you have to be able to state the difference in numbers, because every later adjustment comes out of it.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Trait&lt;/th>
&lt;th>Human chat&lt;/th>
&lt;th>Agentic loop&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Input per request&lt;/td>
&lt;td>hundreds of tokens&lt;/td>
&lt;td>tens of thousands, and it grows with each turn&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Output per request&lt;/td>
&lt;td>hundreds of tokens&lt;/td>
&lt;td>from two tokens (a tool call) to several thousand&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Concurrency per user&lt;/td>
&lt;td>1&lt;/td>
&lt;td>3 to 20 requests in flight&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The metric that hurts&lt;/td>
&lt;td>time to first token&lt;/td>
&lt;td>total turn throughput&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tolerance for waiting&lt;/td>
&lt;td>seconds&lt;/td>
&lt;td>minutes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cancelled requests&lt;/td>
&lt;td>rare&lt;/td>
&lt;td>routine, by the agent&amp;rsquo;s own decision&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Retries&lt;/td>
&lt;td>the browser&amp;rsquo;s&lt;/td>
&lt;td>the SDK&amp;rsquo;s, plus the loop&amp;rsquo;s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Witness when it fails&lt;/td>
&lt;td>the user&lt;/td>
&lt;td>nobody&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two rows deserve comment because they contradict intuition.&lt;/p>
&lt;p>The first is the metric row. An agent does not perceive time to first token, so on a shared engine it is the natural candidate to give up that metric. The operational conclusion is that the agent pool can be configured for aggressive throughput, with large batches and deep queues, while the interactive pool is configured for the opposite.&lt;/p>
&lt;p>The second is the cancellations row. An agent that launches three speculative continuations and discards two generates two aborted streams per turn. At the gateway, that touches spend accounting, the release of concurrency counters and the open connection to the engine. LiteLLM 1.100 handles it reasonably well, and it did not six months ago.&lt;/p>
&lt;h2 id="what-does-not-work-priority-in-litellm">What does not work: &lt;code>priority&lt;/code> in LiteLLM&lt;/h2>
&lt;p>The first reaction of any operator is to look for a priority field. LiteLLM has one, documents it on its &lt;em>Request Prioritization&lt;/em> page, and that page carries the beta label and the sentence saying it is for testing. The label is correct and has to be taken literally, because behind it there are four things.&lt;/p>
&lt;p>The queue is only consulted when the whole group is in cooldown. The scheduler&amp;rsquo;s &lt;code>poll()&lt;/code> method returns true if there is any healthy deployment, without touching the queue. With available capacity, which is the normal situation, the request goes straight through and priority orders nothing. The only thing that changes is that the response carries the &lt;code>x-litellm-request-prioritization-used&lt;/code> header.&lt;/p>
&lt;p>The queue does not empty. In that success branch there is no &lt;code>heappop&lt;/code> and no call to the cleanup, which only happens in the expiry branch. The list under the key &lt;code>scheduler:queue:{model}&lt;/code> grows without bound, is serialised whole into Redis on every insertion, and there is no maximum size and no discard policy. When the group does enter cooldown, the head of the heap is occupied by fossil entries from requests that finished hours ago, and new ones compare their identifier against that head, never match, and wait until the timeout runs out.&lt;/p>
&lt;p>On &lt;code>/chat/completions&lt;/code>, the highest priority disables the feature. The code reads the value with a &lt;code>kwargs.get(&amp;quot;priority&amp;quot;) or self.default_priority&lt;/code>, and in Python &lt;code>0 or None&lt;/code> is &lt;code>None&lt;/code>. Since in this scheduler the lowest number is the highest priority, &lt;code>priority: 0&lt;/code>, which is the value in the documentation&amp;rsquo;s own examples, goes down the path without queuing. On &lt;code>/completions&lt;/code> the check is done with &lt;code>is not None&lt;/code> and it works; the two routes do not behave the same.&lt;/p>
&lt;p>And &lt;code>default_priority&lt;/code> in &lt;code>router_settings&lt;/code> breaks the request. If the client does not send a priority but the global setting is in place, the value is truthy, the scheduling branch is entered and a function is invoked that requires the positional argument nobody has injected. The setting has no associated test and does not appear in the &lt;code>router_settings&lt;/code> reference.&lt;/p>
&lt;p>There is also a &lt;code>/queue/chat/completions&lt;/code>, marked as experimental and hidden from the OpenAPI schema, which reads &lt;code>data[&amp;quot;priority&amp;quot;]&lt;/code> with no default value: a request without that field raises an exception that comes out as a 400 authentication error, with a message that has no relation to the cause.&lt;/p>
&lt;p>There is a priority feature that is solid, and it is a different thing. The &lt;strong>v3 dynamic limiter&lt;/strong> shares out token and request per minute quota between classes with weights, through &lt;code>litellm.priority_reservation&lt;/code> with values like &lt;code>{&amp;quot;premium&amp;quot;: 0.75, &amp;quot;standard&amp;quot;: 0.25}&lt;/code>. It shares out capacity, it does not order a queue: below the saturation threshold it lends free capacity and above it applies the weights. It never makes anyone wait, either it passes or it returns a 429. It requires Postgres and an enterprise licence, and it is still marked as beta.&lt;/p>
&lt;h2 id="what-does-work-two-pools">What does work: two pools&lt;/h2>
&lt;p>Effective separation is done earlier, by deciding which set of replicas each class of traffic goes to. LiteLLM offers two mechanisms with very different security properties.&lt;/p>
&lt;h3 id="by-credential-model_group_alias-at-key-or-team-level">By credential: &lt;code>model_group_alias&lt;/code> at key or team level&lt;/h3>
&lt;p>Router settings are resolved in the order key, team, global. Among those that accept that override is &lt;code>model_group_alias&lt;/code>, and there lies the piece:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">model_list&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b-interactivo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-chat.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">chat-01&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_input_tokens&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">32768&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b-agentes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-agentes.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes-01&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_input_tokens&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">131072&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And on the agent team&amp;rsquo;s key:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">curl -X POST &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$PROXY&lt;/span>&lt;span class="s2">/key/generate&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s2">&amp;#34;Authorization: Bearer &lt;/span>&lt;span class="nv">$MASTER_KEY&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;team_id&amp;#34;: &amp;#34;plataforma-agentes&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;models&amp;#34;: [&amp;#34;qwen-30b&amp;#34;],
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;router_settings&amp;#34;: {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;model_group_alias&amp;#34;: {&amp;#34;qwen-30b&amp;#34;: &amp;#34;qwen-30b-agentes&amp;#34;}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The client asks for &lt;code>qwen-30b&lt;/code> and knows nothing about any of it. The resolution is tied to the credential, so the caller cannot get around it by changing a header. The same list allows &lt;code>routing_strategy&lt;/code>, &lt;code>fallbacks&lt;/code>, &lt;code>context_window_fallbacks&lt;/code>, &lt;code>retry_policy&lt;/code>, &lt;code>cooldown_time&lt;/code> and &lt;code>allowed_fails&lt;/code> to be set per key or team, which gives different reliability policies for each class of traffic on the same proxy instance.&lt;/p>
&lt;p>It should not be confused with &lt;strong>access groups&lt;/strong> (&lt;code>model_info.access_groups&lt;/code>), which are permissions and not routing: they decide whether a key can invoke a model name, not which replica the request goes to.&lt;/p>
&lt;h3 id="by-user-agent-tag_regex">By &lt;code>User-Agent&lt;/code>: &lt;code>tag_regex&lt;/code>&lt;/h3>
&lt;p>When separate credentials cannot be issued, because the agentic client uses the same personal key as that person&amp;rsquo;s chat, LiteLLM classifies by regular expression against the request headers:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">model_list&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-agentes.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tag_regex&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;^User-Agent: claude-code\\/&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;^User-Agent: .*codex&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes-01}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-chat.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tags&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;default&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">chat-01}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enable_tag_filtering&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tag_filtering_match_any&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The proxy composes strings of the form &lt;code>User-Agent: &amp;lt;value&amp;gt;&lt;/code> and tests them with &lt;code>re.search&lt;/code>. Exact tag matching takes precedence over the regex. The documentation itself carries a warning that has to be respected: the &lt;code>User-Agent&lt;/code> is written by the client, so this is traffic classification and not a security boundary. As load sharing it is enough, because an agent that lies about its identity to sneak into the interactive pool is a governance problem, not a routing one.&lt;/p>
&lt;p>The behaviour when no deployment carries the requested tag has a nuance that comes as a surprise in production. If the tag is unknown to the group, the request falls through to the pool marked with &lt;code>tags: [&amp;quot;default&amp;quot;]&lt;/code>. If the tag exists in the group but no healthy deployment carries it at that moment, the request &lt;strong>fails&lt;/strong> with the tag configuration error, even though a default pool is available. To get deterministic fallthrough to the default pool you have to set &lt;code>model_info.allow_fail_open: true&lt;/code>.&lt;/p>
&lt;h3 id="a-different-strategy-per-pool-with-routing_groups">A different strategy per pool with &lt;code>routing_groups&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_groups&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">group_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">interactivo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">models&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">qwen-30b-interactivo]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">group_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">models&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">qwen-30b-agentes]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The recommendation to leave &lt;code>simple-shuffle&lt;/code> on both is not laziness, and sustaining it requires looking at what the alternatives do on a homogeneous vLLM fleet.&lt;/p>
&lt;p>&lt;code>least-busy&lt;/code> keeps a complete dictionary under a single key and updates it with non-atomic reads and writes, without using &lt;code>INCR&lt;/code>, so with several proxy replicas increments are lost. It has no TTL, so a request that never completes leaves the counter raised forever and that replica is permanently excluded. Counters can go negative. And when the minimum corresponds to an identifier that is no longer in the healthy list, it falls back to &lt;code>random.choice&lt;/code> without warning. The pattern of requests that do not complete is exactly that of an agent that cancels.&lt;/p>
&lt;p>&lt;code>latency-based-routing&lt;/code> measures seconds per output token, not wall-clock latency. A chat turn of twenty tokens looks extremely slow by that metric and an agent generation of two thousand tokens looks extremely fast, so mixing both kinds of traffic in the same group poisons the signal. It also seeds new deployments with zero latency, and with the buffer defaulting to zero, a freshly started replica takes all the traffic until its first success callback.&lt;/p>
&lt;p>&lt;code>usage-based-routing-v2&lt;/code> uses minute windows that reset all at once, and it accounts for tokens when the request completes, so long generations in flight are invisible to the router. With long outputs, it systematically underestimates the real load.&lt;/p>
&lt;p>&lt;code>cost-based-routing&lt;/code> sorts by the sum of the input and output unit prices and takes the first, with no random tie-break. With replicas of the same model at the same price, all the traffic always goes to the same place. And a model that is not in the price map gets a default cost of 5.0 per token, a figure designed to deprioritise it.&lt;/p>
&lt;h2 id="admission-control-the-new-piece-in-the-1101-branch">Admission control: the new piece in the 1.101 branch&lt;/h2>
&lt;p>When the agent pool saturates, what you want is to reject fast and with a code the client understands, not to accumulate requests inside the proxy until memory runs out. That arrives with per-worker admission control, added in the 1.101 branch, in release candidate as this is written.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_in_flight_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_queued_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">admission_queue_timeout_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It is a semaphore with a per-uvicorn-process queue, without Redis. When the queue fills or the wait expires it returns &lt;strong>503&lt;/strong> with &lt;code>retry-after: 1&lt;/code> and a body with &lt;code>&amp;quot;type&amp;quot;: &amp;quot;overloaded_error&amp;quot;&lt;/code>. It is observed through &lt;code>/health/backlog&lt;/code>, which exposes requests in flight, admitted, queued and rejected, and through the Prometheus &lt;code>litellm_admission_*&lt;/code> metrics, whose rejection counter distinguishes &lt;code>queue_full&lt;/code> from &lt;code>queue_timeout&lt;/code>.&lt;/p>
&lt;p>Two properties have to go into the runbook. The middleware &lt;strong>rejects before authenticating&lt;/strong>, so what is discarded is not attributed to any key in the spend records, and the real number of requests rejected per team cannot be reconstructed from the spend table. And the limits are read on the first request: changing them requires a restart.&lt;/p>
&lt;h2 id="concurrency-per-key-and-the-three-settings-that-do-nothing">Concurrency per key, and the three settings that do nothing&lt;/h2>
&lt;p>&lt;code>max_parallel_requests&lt;/code> is the right brake for a specific agent, and its configuration surface is full of dead ends.&lt;/p>
&lt;p>The only level where it is applied is the &lt;strong>virtual key&lt;/strong>. The v3 limiter, active by default since 1.94, has descriptors for user, team, team member, end customer, organisation, model per key, tag and agent, and all of them carry only request and token counters per window. The only reference to &lt;code>max_parallel_requests&lt;/code> is the one in the key descriptor. The old limiter says so in a comment: supporting it for model, user and team is pending.&lt;/p>
&lt;p>At the same time, &lt;code>/team/new&lt;/code>, &lt;code>/user/new&lt;/code>, &lt;code>/customer/new&lt;/code> and the organisation endpoints accept the field, validate it and write it to Postgres, and the documentation shows it in their examples. Nobody reads it afterwards. A team with &lt;code>max_parallel_requests: 20&lt;/code> in its row has no concurrency limit at all.&lt;/p>
&lt;p>&lt;code>global_max_parallel_requests&lt;/code> is injected into the request metadata and is only consulted by the old limiter, which is disabled unless &lt;code>LEGACY_MULTI_INSTANCE_RATE_LIMITING=true&lt;/code> is set. The server settings documentation still describes it as a global limit coordinated through Redis. And &lt;code>general_settings.max_parallel_requests&lt;/code> does not appear in any execution path: its only two appearances are the copy from the database and the type schema of the interface.&lt;/p>
&lt;p>What does work, in detail:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">curl -X POST &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$PROXY&lt;/span>&lt;span class="s2">/key/generate&amp;#34;&lt;/span> -H &lt;span class="s2">&amp;#34;Authorization: Bearer &lt;/span>&lt;span class="nv">$MASTER_KEY&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;{&amp;#34;team_id&amp;#34;:&amp;#34;plataforma-agentes&amp;#34;,&amp;#34;max_parallel_requests&amp;#34;:8,&amp;#34;rpm_limit&amp;#34;:600}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Counting is done with a sorted set in Redis scored by Redis&amp;rsquo;s own clock, with Lua scripts to acquire and release. Each slot expires after &lt;strong>3,600 seconds&lt;/strong>, which is at once the maximum request duration the meter can track and the time a leaked slot takes to heal by itself. Release is hooked to success, to failure and to client disconnection during streaming, which was the classic leak with clients that cancel.&lt;/p>
&lt;p>One live leak remains and it affects exactly the case in this article: &lt;strong>every tool call through the MCP gateway acquires a slot and does not release it&lt;/strong>. The MCP route invokes the pre-call hook, which acquires, and none of the release functions is called from that module. With an agent that makes twenty tool calls per turn and a key with eight slots, the 429 arrives on the first turn and lasts an hour.&lt;/p>
&lt;p>The order of evaluation matters for interpreting 429s. First the window counters, requests and tokens per minute, are checked, and only if they pass is the concurrency slot acquired. The code comment explains why: the other way round, every rate limit rejection would leave an orphan slot. In practice, with a low &lt;code>rpm&lt;/code> you always see the &lt;code>requests&lt;/code> type 429, and the &lt;code>max_parallel_requests&lt;/code> one only appears when the relationship between concurrency and duration makes it binding first. The message distinguishes the two cases with the &lt;code>Limit type:&lt;/code> field and the &lt;code>rate_limit_type&lt;/code> header.&lt;/p>
&lt;p>A minor detail with consequences on the client: the response to a concurrency rejection carries &lt;code>retry-after&lt;/code> and &lt;code>reset_at&lt;/code> computed from the window size, sixty seconds by default, when a concurrency meter has no window. A well-behaved client that respects that header will wait a minute when it might have had a free slot in two seconds.&lt;/p>
&lt;h2 id="session-affinity-the-kv-cache-that-gets-thrown-away">Session affinity: the KV cache that gets thrown away&lt;/h2>
&lt;p>An agent turn resends the whole conversation context, system prompt and tools included. If that request lands on a different replica from the previous turn&amp;rsquo;s, vLLM&amp;rsquo;s prefix cache does not have the prefix and the prefill is recomputed in full. With twenty turns and four replicas balanced blindly, the long prefill is paid for fifteen times over.&lt;/p>
&lt;p>LiteLLM has two mechanisms for this and they are not equally good.&lt;/p>
&lt;p>The &lt;code>prompt_caching&lt;/code> check computes a SHA-256 of the cacheable prefix, defined as everything up to the last block with &lt;code>cache_control&lt;/code>, and pins the deployment with that hash. It has two problems. The TTL is hardcoded to 300 seconds in two places in the code, with no way to configure it, something which is reported and open; with one-hour caches, affinity is lost after five minutes of inactivity. And the key is an exact hash, so a client that moves the cut point forward on every turn, which is exactly what agents do to extend the cached prefix, generates a new key every time.&lt;/p>
&lt;p>The &lt;code>session_affinity&lt;/code> check is the one to use:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">optional_pre_call_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;session_affinity&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">deployment_affinity_ttl_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It pins the deployment with an atomic Lua script in Redis, with a configurable TTL that defaults to one hour and that &lt;strong>is refreshed on every request&lt;/strong>, so it bounds the idle time between turns and not the duration of the conversation. If the pinned deployment enters cooldown, the request goes out through the normal strategy and keeps the pin so it can come back when it heals.&lt;/p>
&lt;p>The session identifier comes from a precedence chain that starts with &lt;code>x-litellm-trace-id&lt;/code>, continues with &lt;code>x-litellm-session-id&lt;/code> and then accepts &lt;strong>any header of the form &lt;code>x-&amp;lt;something&amp;gt;-session-id&lt;/code>&lt;/strong> whose value looks like an identifier. That includes the session header Claude Code already sends, with nothing configured on the client. Whatever matches populates both the session identifier and the trace identifier, which links this to the correlation from &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the first article in the track&lt;/a>.&lt;/p>
&lt;p>One dangerous interaction when combining it with tags: affinity is evaluated &lt;strong>before&lt;/strong> tag-based routing. If the pin reduces the list to a deployment that does not satisfy the request&amp;rsquo;s tag, the request fails instead of falling through to the default pool, unless &lt;code>allow_fail_open&lt;/code> has been enabled. Retagging a pool with live sessions breaks them.&lt;/p>
&lt;p>And a clarification that saves a pointless search: &lt;strong>LiteLLM does not do prefix routing&lt;/strong>. There is no prefix tree and nothing that directs the request to the vLLM replica that already has that prefix warm. What there is, is affinity by session or by key. For the other thing you need a specific router underneath the gateway, and that architecture has its own article pending.&lt;/p>
&lt;h2 id="the-engine-what-vllm-can-and-cannot-do">The engine: what vLLM can and cannot do&lt;/h2>
&lt;p>Everything above shares out requests between engines. Inside one engine, the question is what happens when a twenty-token request and a sixty-thousand-token one live side by side.&lt;/p>
&lt;p>&lt;strong>Priority exists and does less than it looks like.&lt;/strong> &lt;code>--scheduling-policy priority&lt;/code> turns the waiting queue into a heap ordered by priority, arrival time and identifier, with the lowest number first. The request can carry the &lt;code>priority&lt;/code> field in the body, and there is also the &lt;code>X-Vllm-Priority&lt;/code> header, which takes precedence over the body and is not documented anywhere.&lt;/p>
&lt;p>What priority does not do is push ahead of whoever is already generating. In the running queue loop, when KV cache runs short, the policy only changes &lt;strong>who is chosen as the victim&lt;/strong> of eviction: with &lt;code>priority&lt;/code> the one with the worst priority and arrival time is evicted, and with &lt;code>fcfs&lt;/code> the last in the queue. In the waiting queue loop, if there are no blocks available the iteration is cut short, without evicting anyone. And the &lt;code>max_num_seqs&lt;/code> cap is a hard cut. If the agents have filled that cap, a chat with maximum priority waits just the same. There has been an open issue since April and an unmerged pull request about this.&lt;/p>
&lt;p>That nuance about the victim is not negligible, because with &lt;code>fcfs&lt;/code> the victims are the most recently admitted, that is, the chats that have just come in. Switching the priority policy on already improves the situation even if it pushes nobody ahead.&lt;/p>
&lt;p>&lt;strong>Eviction is always by recomputation.&lt;/strong> The swap-to-host-memory mode and the enumeration that controlled it disappeared from the code; all that is left is freeing blocks and returning the request to the queue with the computed token counter at zero. An evicted request loses all its prefill, and the prefix cache recovers part of it, but the perceived time to first token restarts.&lt;/p>
&lt;p>Two admission settings help and do not appear in the narrative documentation. &lt;code>watermark&lt;/code> reserves a fraction of free KV blocks on admission, with the declared purpose of avoiding repeated eviction, and it ships disabled. And &lt;code>scheduler_reserve_full_isl&lt;/code>, active by default, checks that the full input length fits in the cache before admitting, instead of looking only at the first chunk.&lt;/p>
&lt;p>&lt;strong>Chunked prefill is active by default and prioritises decode.&lt;/strong> The scheduler walks the whole running request loop before touching the waiting queue, so a long agent prefill does not freeze a chat&amp;rsquo;s generation in progress. That protects the inter-token latency of what has already been admitted, and it does not protect the time to first token of what is waiting.&lt;/p>
&lt;p>Here there is a breaking change that forces a review of the manifests. &lt;strong>&lt;code>max_num_partial_prefills&lt;/code> and &lt;code>max_long_partial_prefills&lt;/code> were removed in 0.27.&lt;/strong> They were precisely the mechanism that allowed short prompts to get ahead of long ones. A deployment with those flags in its &lt;code>args&lt;/code> does not start. &lt;code>long_prefill_token_threshold&lt;/code> still exists, and its meaning changed: today it is a per-request chunk size cap, not a classification of long requests.&lt;/p>
&lt;p>The partial replacement arrives in 0.29 with &lt;code>max_num_queued_tokens&lt;/code> and &lt;code>max_num_queued_reqs&lt;/code>, admission valves that return &lt;strong>503&lt;/strong> when the pending prefill work exceeds the target. The code itself frames it as a quality of service mechanism for time to first token, with the formula of setting it to the latency target multiplied by prefill throughput. They are blind to the class of traffic: they reject a chat just the same as an agent.&lt;/p>
&lt;p>And the default value of &lt;code>--max-num-batched-tokens&lt;/code> depends on the hardware and on the start-up mode. With &lt;code>vllm serve&lt;/code> on H100 or H200 it is 8,192, on cards of 160 GiB or more it is 16,384, and on the rest 2,048. The 2,048 that appears in the scheduler configuration only applies to tests.&lt;/p>
&lt;p>The metrics for diagnosing coexistence, with the exact names from 0.29:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>What for&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>vllm:num_requests_waiting&lt;/code>&lt;/td>
&lt;td>queue depth, the autoscaling signal&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:num_requests_waiting_by_reason&lt;/code>&lt;/td>
&lt;td>separates waiting for capacity from waiting in a blocked state&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:num_preemptions_total&lt;/code>&lt;/td>
&lt;td>evictions, the signal that the two classes are treading on each other&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:kv_cache_usage_perc&lt;/code>&lt;/td>
&lt;td>cache pressure, the cause of the evictions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:request_queue_time_seconds&lt;/code>&lt;/td>
&lt;td>what a chat waits with the agents inside&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:prefix_cache_hits_total&lt;/code> and &lt;code>_queries_total&lt;/code>&lt;/td>
&lt;td>measured in tokens, not in requests&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>A warning for anyone with old alerts. The log warning about evicted sequences that appears in the optimisation documentation belongs to the previous version of the engine and &lt;strong>no longer exists in the code&lt;/strong>. Preemptions are only visible as a counter. An alert based on searching for that string in the logs has not fired for months, and not because there are no evictions.&lt;/p>
&lt;p>On isolation between classes inside one engine, the short answer is that it does not exist. There are two queues and neither is per class of traffic. The conclusion, which is the one that holds up this article&amp;rsquo;s architecture: effective isolation means &lt;strong>separate fleets&lt;/strong>, and on top of them the gateway deciding who goes to which.&lt;/p>
&lt;h2 id="retries-the-product-nobody-calculates">Retries: the product nobody calculates&lt;/h2>
&lt;p>A human who gets an error sees it and decides. An agentic loop retries, and beneath it there are two more layers that also retry.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Layer&lt;/th>
&lt;th>Default&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Client SDK&lt;/td>
&lt;td>2 retries, 3 attempts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM router&lt;/td>
&lt;td>2 retries, 3 attempts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fallbacks&lt;/td>
&lt;td>up to 5 groups&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The product is about &lt;strong>45 calls to the engine for one agent turn&lt;/strong>, before counting the loop&amp;rsquo;s own retry. LiteLLM does what it can: when the call comes from the router, it forces &lt;code>max_retries=0&lt;/code> on the SDK it itself uses against the engine. What it cannot touch is the client&amp;rsquo;s SDK. The only layer that breaks the product is that one, and it is broken by setting &lt;code>max_retries=0&lt;/code> on the client and delegating the retry to the gateway.&lt;/p>
&lt;p>The retryable errors are 408, 409, 429 and anything 500 or above. And there lies the problem with long reasoning: &lt;code>litellm.Timeout&lt;/code> is a subclass of OpenAI&amp;rsquo;s timeout exception and comes out with &lt;strong>code 408&lt;/strong>, which is retryable, so the most expensive request of all, the one that expired after ten minutes of generating, is launched again in full. Twice by the router, and as many times again by the client.&lt;/p>
&lt;p>On timeouts you have to be precise because the code comment is: the configured timeout is &lt;strong>per attempt, not per request&lt;/strong>. A &lt;code>request_timeout: 600&lt;/code> with two retries and two fallback groups gives a worst case of almost two hours before the client receives anything. The default value is still 6,000 seconds, as was already covered in &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">the day 2 article&lt;/a>.&lt;/p>
&lt;p>The retry storm has two specific causes and both are default settings.&lt;/p>
&lt;p>The first: &lt;strong>a single 429 takes the deployment out of the pool&lt;/strong>, without going through the &lt;code>allowed_fails&lt;/code> threshold. The cooldown logic has an explicit branch which, on a 429 in a group with more than one deployment, returns immediate cooldown. With an agentic burst against four replicas, all four leave the pool at once and the gateway answers everybody with its own 429 of &amp;ldquo;no deployments available&amp;rdquo;. The default cooldown is five seconds, which in a sustained burst gets renewed.&lt;/p>
&lt;p>The second: &lt;strong>backoff is zero while the pool looks healthy&lt;/strong>. The function that computes the wait returns 0 if the healthy deployment list is not empty. Exponential backoff with jitter, capped at eight seconds, only kicks in when there is nothing healthy left. Under partial degradation, the proxy hammers away without waiting and only slows down once everything has already fallen over.&lt;/p>
&lt;p>On top of that, the engine&amp;rsquo;s &lt;code>Retry-After&lt;/code> header is only respected if its value is between 1 and 60 seconds. A longer limit window is ignored and replaced by the proxy&amp;rsquo;s own backoff.&lt;/p>
&lt;p>The settings that cut the problem off:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">retry_after&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># real floor; the default 0 is hammering&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cooldown_time&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allowed_fails_policy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">RateLimitErrorAllowedFails&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># stops a 429 taking the deployment out&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">retry_policy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">TimeoutErrorRetries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># do not relaunch the expensive request&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">AuthenticationErrorRetries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">litellm_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">request_timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">900&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># per attempt&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">drop_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That last setting deserves an explanation, because it is a silent way of breaking an agent. With &lt;code>drop_params: true&lt;/code>, LiteLLM removes from the body any parameter the destination provider does not support, and in that branch it writes no warning at all. The candidates for disappearing are &lt;code>tools&lt;/code>, &lt;code>tool_choice&lt;/code>, &lt;code>response_format&lt;/code> and &lt;code>parallel_tool_calls&lt;/code>. An agent that has &lt;code>tools&lt;/code> taken away receives free text where it expected a function call, and interprets it as the model having decided not to use tools.&lt;/p>
&lt;p>Worse still is prompt-based emulation. For a non-OpenAI-compatible provider that does not support tools, LiteLLM does not fail: it turns the tools into text inside the prompt, forces JSON format, discards &lt;code>tool_choice&lt;/code>, and on the way switches on a &lt;strong>process-global variable&lt;/strong> that makes any other tool-less provider in the same process go to emulation instead of erroring.&lt;/p>
&lt;p>With that in mind, the warning about fallbacks is clear: &lt;strong>LiteLLM does not check capabilities before falling back&lt;/strong>. The function that knows whether a model supports function calling exists and is only used in an informational endpoint. The pre-call checks filter by context window, request limit and region, and nothing else. A fallback list that goes from a model with tools to one without them degrades the agent silently. There are guards for provider-scoped resources, files and batches, so the absence of the capability guard is an omission and not a design oversight.&lt;/p>
&lt;h2 id="a-months-budget-in-twenty-minutes">A month&amp;rsquo;s budget in twenty minutes&lt;/h2>
&lt;p>A runaway agent consumes in minutes what a team consumes in weeks, and the operational question is how long the gateway takes to find out.&lt;/p>
&lt;p>The answer has changed, and the old mental model, the one where spend consolidates every minute and until then there is no brake, is no longer correct. Since 1.84 there are two new pieces. An &lt;strong>optimistic reservation before the call&lt;/strong>, which estimates the maximum cost of the request and reserves it atomically in the counter before heading out to the engine, reconciling it with the real cost on completion. And a counter increment after the call that is explicitly awaited, with the comment that this way the counter is up to date before the next request goes through authentication. Budget checks read that counter, with Redis first and seeding from the database when cold.&lt;/p>
&lt;p>Batch writing to Postgres still exists, every 10 to 15 seconds, and it affects persistence and what is shown in the interface, not the cut-off. The production documentation&amp;rsquo;s recommendation to raise that interval to 60 does not relax the control, and it does delay by a minute what &lt;code>/key/info&lt;/code> shows, something which frequently generates operational panic. And the budget rescheduler&amp;rsquo;s ten minutes do not consolidate spend: they fire the job that resets expired budgets.&lt;/p>
&lt;p>The overspend window then comes down to the cost of the requests in flight, provided there is Redis. &lt;strong>Without Redis, the counters are per pod&lt;/strong>, and with N replicas you can spend up to N times the budget. That is the configuration failure that matters and it is the same Redis that &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">the day 2 article&lt;/a> declared non-optional for other reasons.&lt;/p>
&lt;p>Two settings that do not appear in the public documentation, only in the data model descriptions, and that decide behaviour at the worst moment:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">fail_closed_budget_enforcement: true # false by default&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">if the counter fails, it lets things through&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># disable_budget_reservation: do not set it to true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With the default value, if the counter backend does not respond, the proxy &lt;strong>fails open&lt;/strong> and carries on serving. For an agent in a loop that is exactly the opposite of what you want.&lt;/p>
&lt;p>Time for the honest warning: this route is still unstable. There are open issues in both directions, one where the key and user budget limit stops being applied while spend is recorded, and another where a 429 is returned for stale spend in the reservation counters while the key query shows spend below the limit. Project budgets are not on the atomic reservation route and concurrency overshoots them. The practical conclusion is not to trust the hard cut-off to the budget alone.&lt;/p>
&lt;p>On the manual cut-off, there is an asymmetry to know about before you need it. &lt;code>/key/block&lt;/code> invalidates the local cache, the Redis one, and notifies all workers by publish, so with Redis the effect is almost immediate. &lt;code>/customer/block&lt;/code> does the same. &lt;strong>&lt;code>/team/block&lt;/code> only writes to the database&lt;/strong>, without invalidating or notifying, so it takes however long the management object cache TTL takes, sixty seconds by default. To cut a team off dead you have to block its keys one by one, or lower &lt;code>user_api_key_cache_ttl&lt;/code>. And &lt;code>/user/block&lt;/code> no longer exists, even though the docstring of the function that replaced it still shows that route in its example.&lt;/p>
&lt;p>On alerts, two numbers. The default thresholds warn at &lt;strong>85 % and 95 %&lt;/strong> of the budget. And deduplication of those alerts is &lt;strong>24 hours&lt;/strong> per event and identifier, so crossing 85 % generates one warning and then silence for a day. For agentic traffic, where 85 % and 100 % can be twenty minutes apart, the second warning arrives when it is no longer any use.&lt;/p>
&lt;h2 id="attribution-who-the-user-is-when-the-caller-is-an-agent">Attribution: who the user is when the caller is an agent&lt;/h2>
&lt;p>With agents, the question of who the spend is charged to has two answers and both are needed: the agent that executes and the human on whose behalf it acts.&lt;/p>
&lt;p>The chain that extracts the end customer identifier tries the standard headers first, then the configurable header mapping, and only then the &lt;code>user&lt;/code> field of the body, the &lt;code>litellm_metadata.user&lt;/code> of the Anthropic format, the &lt;code>metadata.user_id&lt;/code> and the &lt;code>safety_identifier&lt;/code>. The last four are body fields and are therefore written by the client. The code itself warns about it: for untrusted callers the identifier has to be set from headers or from a middleware on the server, to avoid impersonation.&lt;/p>
&lt;p>Out of that comes the correct pattern, which is the inverse of the one people put together out of inertia: &lt;strong>the virtual key identifies the agent&lt;/strong> and is the non-forgeable part, and the header or the &lt;code>user&lt;/code> field identify the human and serve for attribution, not for control. An agent that lies about the human behind it is still bounded by the budget and the limits of its key.&lt;/p>
&lt;p>The spend table stores &lt;code>user&lt;/code>, &lt;code>end_user&lt;/code>, &lt;code>team_id&lt;/code>, &lt;code>organization_id&lt;/code>, &lt;code>request_tags&lt;/code>, &lt;code>session_id&lt;/code> and &lt;code>agent_id&lt;/code>, with daily aggregation per agent, so the detail is available once it is sent. The &lt;code>x-litellm-agent-id&lt;/code> header is what populates that last field.&lt;/p>
&lt;p>A note about the MCP gateway, which on a platform with agents ends up being the other entry door. Tool calls &lt;strong>do appear in the spend records&lt;/strong>, with call types of their own and a metadata block that stores tool name, arguments, result and server. What is missing is token counting: the cost is &lt;strong>0.0 by default&lt;/strong> and is only populated if the administrator configures a price per query, per server or per tool. It is the same pattern of silent zero cost that already came up with unpriced models in &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the first article in the track&lt;/a>.&lt;/p>
&lt;p>And the permission setting to review on day one: access control for MCP servers is &lt;strong>open by default&lt;/strong>. If no level of the hierarchy defines a list, the request reaches every configured server. &lt;code>general_settings.require_key_mcp_access_defined: true&lt;/code> inverts that inheritance, and it is not the default value.&lt;/p>
&lt;h2 id="a-reference-configyaml">A reference &lt;code>config.yaml&lt;/code>&lt;/h2>
&lt;p>All of the above, together, on a fleet split into two pools:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">model_list&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b-interactivo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-chat.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">input_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000018&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># without this, silent zero cost&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">output_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000072&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">chat-01&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_input_tokens&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">32768&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># without this there is no context filter&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b-agentes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-agentes.inferencia.svc:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">input_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000018&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">output_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000072&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tag_regex&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;^User-Agent: claude-code\\/&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes-01&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_input_tokens&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">131072&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allow_fail_open&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enable_pre_call_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enable_tag_filtering&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">optional_pre_call_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;session_affinity&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">deployment_affinity_ttl_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">retry_after&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cooldown_time&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allowed_fails_policy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">RateLimitErrorAllowedFails&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">retry_policy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">TimeoutErrorRetries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">AuthenticationErrorRetries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">fail_closed_budget_enforcement&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">require_key_mcp_access_defined&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_api_key_cache_ttl&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># 1.101 branch onwards:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_in_flight_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_queued_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">admission_queue_timeout_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">litellm_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">request_timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">900&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># per attempt&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">drop_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cache&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># Redis, not optional&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the start-up of the two engines, with the deliberate asymmetry:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># interactive pool: protect the first token&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen3-30B &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --scheduling-policy priority &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-seqs &lt;span class="m">64&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-model-len &lt;span class="m">32768&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-batched-tokens &lt;span class="m">4096&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># agent pool: throughput and deep queues&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen3-30B &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-seqs &lt;span class="m">256&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-model-len &lt;span class="m">131072&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-batched-tokens &lt;span class="m">8192&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>Issue separate keys for agents, with &lt;code>model_group_alias&lt;/code> at key or team level. It is the only separation the client cannot evade.&lt;/li>
&lt;li>Add &lt;code>tag_regex&lt;/code> over the &lt;code>User-Agent&lt;/code> as a safety net for agents that use personal credentials, with a &lt;code>default&lt;/code> pool and &lt;code>allow_fail_open: true&lt;/code>.&lt;/li>
&lt;li>Leave &lt;code>simple-shuffle&lt;/code> on both pools. The other four strategies have verifiable defects on homogeneous replicas.&lt;/li>
&lt;li>Enable &lt;code>session_affinity&lt;/code> with a one-hour TTL. Do not use &lt;code>prompt_caching&lt;/code> for this while its TTL stays hardcoded.&lt;/li>
&lt;li>Declare &lt;code>max_input_tokens&lt;/code> on every deployment. Without that value, the context window filter is not applied and it fails silently for models that are not in the price map.&lt;/li>
&lt;li>Declare per-token prices on every self-hosted model, or the agents&amp;rsquo; spend will be zero and their budget will never run out.&lt;/li>
&lt;li>&lt;code>max_parallel_requests&lt;/code> on the agent&amp;rsquo;s key, never on its team. On the team it is stored and not applied.&lt;/li>
&lt;li>Lower &lt;code>num_retries&lt;/code> to 1, put a floor on &lt;code>retry_after&lt;/code>, raise &lt;code>cooldown_time&lt;/code> and disable retry on timeout. On the client, &lt;code>max_retries=0&lt;/code>.&lt;/li>
&lt;li>&lt;code>fail_closed_budget_enforcement: true&lt;/code> and mandatory Redis. Without Redis, N replicas are N times the budget.&lt;/li>
&lt;li>Start the interactive engine with &lt;code>--scheduling-policy priority&lt;/code> even though it pushes nobody ahead: it changes who it evicts.&lt;/li>
&lt;li>Alert on &lt;code>vllm:num_preemptions_total&lt;/code> and &lt;code>vllm:num_requests_waiting_by_reason&lt;/code>, not on log strings that are no longer emitted.&lt;/li>
&lt;li>Prepare the cut-off: block by key, never by team, and verify the real propagation time in your own deployment.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>&lt;code>priority: 0&lt;/code> on &lt;code>/chat/completions&lt;/code> disables prioritisation.&lt;/strong> It is the value in the documentation examples and it is falsy in Python.&lt;/li>
&lt;li>&lt;strong>&lt;code>max_parallel_requests&lt;/code> on a team is stored and not applied.&lt;/strong> The field exists in the API, in the database and in the documentation; the limiter only reads it from the key descriptor.&lt;/li>
&lt;li>&lt;strong>&lt;code>global_max_parallel_requests&lt;/code> is a no-op&lt;/strong> with the default limiter, and the server settings documentation still describes it as active.&lt;/li>
&lt;li>&lt;strong>Every MCP call leaks a concurrency slot&lt;/strong>, which only heals when its hour expires. With agents calling tools in a loop, the 429 appears early and lasts a long time.&lt;/li>
&lt;li>&lt;strong>The TTL of prompt cache affinity is 300 hardcoded seconds.&lt;/strong> For agent conversations, session affinity is the right option.&lt;/li>
&lt;li>&lt;strong>Affinity is evaluated before tag-based routing.&lt;/strong> A pin to a deployment that stops satisfying the tag makes the request fail instead of falling through to the default pool.&lt;/li>
&lt;li>&lt;strong>LiteLLM&amp;rsquo;s timeout is per attempt.&lt;/strong> The worst case is the timeout multiplied by attempts and by fallback groups.&lt;/li>
&lt;li>&lt;strong>A 429 takes the deployment out of the pool without going through &lt;code>allowed_fails&lt;/code>&lt;/strong>, and backoff is zero while anything healthy remains.&lt;/li>
&lt;li>&lt;strong>The engine&amp;rsquo;s &lt;code>Retry-After&lt;/code> is ignored if it asks for more than 60 seconds.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>&lt;code>drop_params: true&lt;/code> removes &lt;code>tools&lt;/code> and &lt;code>response_format&lt;/code> without leaving a warning&lt;/strong>, and the fallback does not check whether the destination model supports function calling.&lt;/li>
&lt;li>&lt;strong>&lt;code>/team/block&lt;/code> does not invalidate the cache.&lt;/strong> It takes the full TTL; &lt;code>/key/block&lt;/code> does propagate by publish in Redis.&lt;/li>
&lt;li>&lt;strong>Access to MCP servers is open by default&lt;/strong> if no level defines a list.&lt;/li>
&lt;li>&lt;strong>&lt;code>max_num_partial_prefills&lt;/code> and &lt;code>max_long_partial_prefills&lt;/code> no longer exist in vLLM.&lt;/strong> A manifest with those flags does not start from 0.27 onwards.&lt;/li>
&lt;li>&lt;strong>vLLM&amp;rsquo;s priority does not push ahead of running requests&lt;/strong>, and with &lt;code>max_num_seqs&lt;/code> full it pushes nobody ahead.&lt;/li>
&lt;li>&lt;strong>The eviction log warning that appears in the vLLM documentation is no longer emitted.&lt;/strong> Only the Prometheus counter is left.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The coexistence of humans and agents on the same platform is not solved with a priority field, and that is the conclusion that takes the most effort to accept because the field exists and looks made for this. Neither the gateway nor the engine has a usable notion of class of service today: one orders a queue that is almost never consulted, the other picks who to evict. What they do have is the ability to direct each request to a different place, and that is where the win is.&lt;/p>
&lt;p>The architecture that comes out of all of the above fits in one sentence. Two vLLM fleets with opposite parameters, a credential that decides which one each request goes to, session affinity so as not to throw away the prefill work, concurrency limits on the agent&amp;rsquo;s key and not on its team, retries trimmed back on the three layers where they multiply, and budgets that fail closed.&lt;/p>
&lt;p>The rest is measurement. If &lt;code>vllm:num_preemptions_total&lt;/code> rises in the interactive pool, the two classes are treading on each other despite the separation. If chat queue time grows while total throughput holds, the partition is badly sized. And if the agents&amp;rsquo; spend comes out as zero, the problem is not coexistence but undeclared prices, which is where this track started.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits in LiteLLM&lt;/a>, the full hierarchy and the v3 limiter that this article&amp;rsquo;s limits rest on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">LiteLLM on day 2: high availability&lt;/a>, the Redis and the Postgres taken for granted here, and retries seen from the gateway side.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a>, the trace correlation that this article&amp;rsquo;s session identifier feeds, and the zero cost of unpriced models.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a>, where the figures that go into the budgets defended here come from.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key: isolating AI agents&lt;/a>, the other side of the problem, the agent as a security risk rather than a class of traffic.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/">The second cost vector of AI agents&lt;/a>, what a loop that fails halfway costs, and why durable execution changes that sum.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">Prefix routing: what LiteLLM does not do&lt;/a>, the continuation of this article&amp;rsquo;s session affinity, and the correction of what the gateway can do with the KV cache.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>, the agents&amp;rsquo; other entry door, including the correction about the default access this article took to be open.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router&lt;/a>, the router&amp;rsquo;s four functions and the prefix-aware routing LiteLLM does not do.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">Prefill optimisations in vLLM&lt;/a>, the work that is recomputed in full when a request is evicted.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The vLLM scheduler step&lt;/a>, the loop the priority policy and chunked prefill operate on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows: authentication with Keycloak&lt;/a>, the identity of the other entry door, the one agents use to call tools.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a>, the question before this one: how big the fleet and the gateway have to be, with the agentic load measurements published in 2026.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a>, the identity of the agent and of the human seen from outside the proxy.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>[BETA] Request Prioritization&lt;/em>: &lt;a href="https://docs.litellm.ai/docs/scheduler">https://docs.litellm.ai/docs/scheduler&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Router - Load Balancing&lt;/em> (estrategias, afinidad de sesión, comprobaciones previas): &lt;a href="https://docs.litellm.ai/docs/routing">https://docs.litellm.ai/docs/routing&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Tag Based Routing&lt;/em> (incluye &lt;code>tag_regex&lt;/code> y el ejemplo con clientes agénticos): &lt;a href="https://docs.litellm.ai/docs/proxy/tag_routing">https://docs.litellm.ai/docs/proxy/tag_routing&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Router Settings for Keys and Teams&lt;/em> (resolución clave, equipo, global): &lt;a href="https://docs.litellm.ai/docs/proxy/keys_teams_router_settings">https://docs.litellm.ai/docs/proxy/keys_teams_router_settings&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Server Tuning&lt;/em> (control de admisión por worker): &lt;a href="https://docs.litellm.ai/docs/proxy/server_tuning">https://docs.litellm.ai/docs/proxy/server_tuning&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Dynamic TPM/RPM Allocation&lt;/em> (reserva de prioridad, beta y enterprise): &lt;a href="https://docs.litellm.ai/docs/proxy/dynamic_rate_limit">https://docs.litellm.ai/docs/proxy/dynamic_rate_limit&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Budgets, Rate Limits&lt;/em>: &lt;a href="https://docs.litellm.ai/docs/proxy/users">https://docs.litellm.ai/docs/proxy/users&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Production Best Practices&lt;/em>: &lt;a href="https://docs.litellm.ai/docs/proxy/prod">https://docs.litellm.ai/docs/proxy/prod&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Reliability&lt;/em> (fallbacks, ventana de contexto, enfriamientos): &lt;a href="https://docs.litellm.ai/docs/proxy/reliability">https://docs.litellm.ai/docs/proxy/reliability&lt;/a>.&lt;/li>
&lt;li>LiteLLM, código: &lt;code>litellm/scheduler.py&lt;/code>, &lt;code>litellm/router_strategy/tag_based_routing.py&lt;/code>, &lt;code>litellm/router_utils/pre_call_checks/deployment_affinity_check.py&lt;/code>, &lt;code>litellm/proxy/hooks/parallel_request_limiter_v3.py&lt;/code>, &lt;code>litellm/proxy/middleware/admission_control_middleware.py&lt;/code>, &lt;code>litellm/router_utils/cooldown_handlers.py&lt;/code>: &lt;a href="https://github.com/BerriAI/litellm">https://github.com/BerriAI/litellm&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 28427, TTL de la afinidad por caché de prompts fijado a cinco minutos: &lt;a href="https://github.com/BerriAI/litellm/issues/28427">https://github.com/BerriAI/litellm/issues/28427&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 34534, fuga de slot de concurrencia en cada llamada MCP: &lt;a href="https://github.com/BerriAI/litellm/issues/34534">https://github.com/BerriAI/litellm/issues/34534&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 26672, presupuesto de clave y usuario no aplicado: &lt;a href="https://github.com/BerriAI/litellm/issues/26672">https://github.com/BerriAI/litellm/issues/26672&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 872, comprobación previa de soporte de llamada a funciones, cerrada sin implementar: &lt;a href="https://github.com/BerriAI/litellm/issues/872">https://github.com/BerriAI/litellm/issues/872&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Optimization and Tuning&lt;/em> (prefill troceado, prioridad de decodificación): &lt;a href="https://docs.vllm.ai/en/v0.29.0/configuration/optimization.html">https://docs.vllm.ai/en/v0.29.0/configuration/optimization.html&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Metrics design&lt;/em> (nombres, convención &lt;code>_total&lt;/code>, autoescalado como problema abierto): &lt;a href="https://docs.vllm.ai/en/v0.29.0/design/metrics.html">https://docs.vllm.ai/en/v0.29.0/design/metrics.html&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Data Parallel Deployment&lt;/em> (caché KV independiente por réplica): &lt;a href="https://docs.vllm.ai/en/v0.29.0/serving/data_parallel_deployment.html">https://docs.vllm.ai/en/v0.29.0/serving/data_parallel_deployment.html&lt;/a>.&lt;/li>
&lt;li>vLLM, código: &lt;code>vllm/v1/core/sched/scheduler.py&lt;/code>, &lt;code>vllm/v1/core/sched/request_queue.py&lt;/code>, &lt;code>vllm/config/scheduler.py&lt;/code>, &lt;code>vllm/engine/arg_utils.py&lt;/code>, &lt;code>vllm/v1/metrics/loggers.py&lt;/code>: &lt;a href="https://github.com/vllm-project/vllm">https://github.com/vllm-project/vllm&lt;/a>.&lt;/li>
&lt;li>vLLM, incidencia 40004, la prioridad no desaloja peticiones en ejecución: &lt;a href="https://github.com/vllm-project/vllm/issues/40004">https://github.com/vllm-project/vllm/issues/40004&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>LiteLLM on day 2: one worker per pod, six thousand seconds of timeout and the other five things to change before opening the traffic</title><link>https://blog.lo0.es/en/posts/litellm-day-2-high-availability/</link><pubDate>Tue, 08 Sep 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/litellm-day-2-high-availability/</guid><description>&lt;blockquote>
&lt;p>Second article in the operational track of the control layer. The &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">operational pair with Langfuse&lt;/a> covered the seam between the gateway and observability; here everything stays inside the gateway. The prior decisions, which gateway and why, are in &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">choosing the OSS gateway&lt;/a> and in &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the L7 inference router&lt;/a>.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>LiteLLM Proxy starts from a twenty-line &lt;code>config.yaml&lt;/code> and serves traffic that same afternoon. The operational work consists of correcting seven defaults designed for a scenario other than an in-house inference factory.&lt;/p>
&lt;p>&lt;strong>One uvicorn worker per pod.&lt;/strong> The project&amp;rsquo;s production guide asks for &lt;code>--num_workers 1&lt;/code> on Kubernetes and horizontal scaling, with &lt;strong>1 vCPU and 4 GiB per worker set at the same time as &lt;code>requests&lt;/code> and as &lt;code>limits&lt;/code>&lt;/strong>. The 4 GiB are a floor, not a target: the Prisma query engine marks a resident memory high-water mark that grows up to the largest statement it has executed and that glibc does not return to the system.&lt;/p>
&lt;p>&lt;strong>Autoscale on CPU, never on memory.&lt;/strong> A direct consequence of the above. Memory goes up and never down, so an HPA on memory scales and never scales back. The recommended target is &lt;code>targetCPUUtilizationPercentage: 60&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The Postgres connection arithmetic.&lt;/strong> &lt;code>database_connection_pool_limit&lt;/code> is 10, and the number of connections is instances times workers times the pool. The Helm charts ship &lt;code>maxReplicas: 100&lt;/code>, on the order of a thousand connections, far above what a stock Postgres accepts.&lt;/p>
&lt;p>&lt;strong>Redis stops being optional&lt;/strong> from around a thousand requests per second or ten instances. Without it, each instance applies its limits on its own, caches are not shared, and spend updates against the same rows produce locks and exhaust the connections with &lt;code>FATAL: sorry, too many clients already&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Three health endpoints and only two are usable as probes.&lt;/strong> &lt;code>/health/liveliness&lt;/code> and &lt;code>/health/readiness&lt;/code> require no authentication and answer fast. Bare &lt;code>/health&lt;/code> demands a key and fires a real request against every model in the catalogue, with its cost in tokens.&lt;/p>
&lt;p>&lt;strong>The resilience numbers are 2, 3 and 5&lt;/strong>: two retries, three allowed failures and five seconds of cooldown, the last one despite the function&amp;rsquo;s own documentation saying the default is 1.&lt;/p>
&lt;p>&lt;strong>&lt;code>request_timeout&lt;/code> is 6,000 seconds.&lt;/strong> A hundred minutes holding a connection against an engine that does not answer.&lt;/p>
&lt;p>And a warning about the performance figures: the project publishes 8 ms of p95 overhead on one benchmark and 257.7 ms of p99 on another. Both are theirs, and the difference is in the shape of the load.&lt;/p>
&lt;h2 id="you-are-here-the-gateway-layer-the-day-after">You are here: the gateway layer, the day after&lt;/h2>
&lt;p>In the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven-layer stack&lt;/a>, this article lives entirely in the gateway layer. The difference with the selection article is the one that separates buying from maintaining: there the question was which piece to put in front of the fleet, and here it is what happens when that piece has been serving for six months, the team has tripled the traffic and someone has added an agent that fires twenty calls per interaction.&lt;/p>
&lt;p>The gateway has an uncomfortable property worth keeping in mind: it sits on the critical path of every request and contributes not a single token. All it does is coordination, and any coordination that fails turns into an outage of the whole inference service, even with the GPUs perfectly healthy.&lt;/p>
&lt;h2 id="the-analogy-the-switchboard-and-its-notebook">The analogy: the switchboard and its notebook&lt;/h2>
&lt;p>A hospital switchboard does three jobs at once. It puts calls through, which is the urgent part. It writes down in a notebook who called, to which extension and for how long, which is what sustains the billing. And it consults a list of extensions to know where to put each call through.&lt;/p>
&lt;p>The three jobs compete. If the operator stops to write each line in the notebook before putting the next call through, the waiting queue grows. If there are three operators and each keeps her own notebook, the totals do not add up. And if the list of extensions is consulted in a filing cabinet on another floor, every call costs the trip.&lt;/p>
&lt;p>The three tensions have an exact equivalent in LiteLLM. The notebook is the &lt;code>SpendLogs&lt;/code> in Postgres, which is why the transaction buffer in Redis exists. The three operators with three notebooks are the pod replicas applying limits independently. And the filing cabinet on another floor is usage-based routing, which adds a Redis lookup inside the request path.&lt;/p>
&lt;h2 id="the-process-model-one-worker-per-pod">The process model: one worker per pod&lt;/h2>
&lt;p>The production documentation is explicit: on Kubernetes, &lt;code>--num_workers 1&lt;/code> and scale with replicas. On a virtual machine with no orchestrator, &lt;code>NUM_WORKERS&lt;/code> equal to the number of vCPUs.&lt;/p>
&lt;p>The reason is not ideological. An additional worker inside the same pod shares the container&amp;rsquo;s memory limit with the others, multiplies the database connections by the same factor, and registers a copy of every periodic job. With separate replicas, each one has its own resource budget, its own connection quota and a place where the scheduler can put it.&lt;/p>
&lt;p>The recommended sizing is &lt;strong>1 vCPU and 4 GiB per worker&lt;/strong>, and the part that gets overlooked is that these go as &lt;code>requests&lt;/code> and as &lt;code>limits&lt;/code> at the same time. Setting less than 4 GiB produces an OOM restart loop that shows up late, once some large request has grown the query engine&amp;rsquo;s footprint.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">, memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4Gi&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">, memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4Gi&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That footprint needs to be described precisely, because it determines the autoscaling policy. The Prisma query engine keeps a resident memory that behaves as a high-water mark: it grows up to the largest statement the process has executed and does not come back down, because glibc does not return that freed memory to the system. A pod that served a spike yesterday still shows the footprint of that spike today.&lt;/p>
&lt;p>Two rules come out of this. The first: &lt;strong>autoscale on CPU and never on memory&lt;/strong>. An HPA on memory sees a metric that only goes up, scales at the peak and never scales back. The recommended target is &lt;code>targetCPUUtilizationPercentage: 60&lt;/code>, lower than the 80 the charts ship by default, because the startup probe allows up to 300 seconds before passing the first readiness check and there needs to be headroom for the new pod to be ready before the saturated one goes down.&lt;/p>
&lt;p>The second: bound the process lifetime. &lt;code>--max_requests_before_restart 10000&lt;/code> recycles the worker before the high-water mark matters.&lt;/p>
&lt;h2 id="postgres-the-arithmetic-that-blows-up-when-you-scale">Postgres: the arithmetic that blows up when you scale&lt;/h2>
&lt;p>&lt;code>database_connection_pool_limit&lt;/code> is &lt;strong>10&lt;/strong> by default. The total number of connections the deployment opens is replicas times workers times pool, and it has to be compared against the Postgres &lt;code>max_connections&lt;/code>.&lt;/p>
&lt;p>The formula the documentation gives is the inverse, and it is the one to apply when sizing:&lt;/p>
&lt;pre tabindex="0">&lt;code>database_connection_pool_limit = MAX_DB_CONNECTIONS / (instances × workers)
&lt;/code>&lt;/pre>&lt;p>The detail that hurts in production is in the Helm charts, which ship &lt;code>autoscaling.maxReplicas&lt;/code> and &lt;code>keda.maxReplicas&lt;/code> at &lt;strong>100&lt;/strong>. With the default pool, that is on the order of a thousand connections at full scale-out, far above what an untouched Postgres accepts. And the moment the HPA reaches those replicas is exactly the moment of highest load, so connection exhaustion arrives when it can least be afforded.&lt;/p>
&lt;p>There are two more valves on the same path. Provider errors are written to the database by default, and under a sustained engine failure that inflates the spend table without contributing anything that is not already in the metrics. They are switched off like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">disable_error_logs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># stops writing provider errors&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># batches the spend writes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>disable_spend_logs: true&lt;/code> is the radical version, which removes the per-request detail from the interface and leaves the cost in Prometheus and in the tracing backend. Before going that far you have to decide whether that table is the system&amp;rsquo;s audit record, which is the discussion in the &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">previous article&lt;/a>: if it is, it cannot be switched off, and the way out is the Redis buffer.&lt;/p>
&lt;h2 id="redis-when-it-stops-being-optional">Redis: when it stops being optional&lt;/h2>
&lt;p>Without Redis, LiteLLM works. Each instance keeps its own in-memory cache and applies its own counters. The consequences are three, and all of them show up as you grow.&lt;/p>
&lt;p>Requests-per-minute limits are applied per instance, so a limit of 100 with five replicas is in practice a limit of 500. Cache hits are local, so the same repeated question hits once in five. And there is no leader election, so periodic jobs run in every process at once.&lt;/p>
&lt;p>The threshold the documentation gives for enabling the transaction buffer is &lt;strong>a thousand requests per second or ten instances&lt;/strong>. Below that, each instance updates the key, user and team rows directly; above it, they all write to the same rows, locks appear and Postgres starts rejecting with &lt;code>FATAL: sorry, too many clients already&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">use_redis_transaction_buffer&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The metrics to watch when that is enabled are &lt;code>litellm_in_memory_spend_update_queue_size&lt;/code>, &lt;code>litellm_redis_spend_update_queue_size&lt;/code> and &lt;code>litellm_pod_lock_manager_size&lt;/code>. If the first grows without the second coming down, the flush to Postgres is not keeping up.&lt;/p>
&lt;p>A note about the routing strategy, which interacts with this. &lt;code>simple-shuffle&lt;/code> consults nothing external. Usage-based routing does, and it adds a round trip to Redis inside the request path. On a homogeneous vLLM fleet behind the same model, the improvement it brings rarely pays for that latency; on a heterogeneous fleet, the conversation is different and &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the router article&lt;/a> covered it.&lt;/p>
&lt;h2 id="background-jobs-register-per-worker">Background jobs register per worker&lt;/h2>
&lt;p>This detail appears in no getting-started guide and produces considerable bewilderment when it is discovered. The proxy&amp;rsquo;s periodic jobs register &lt;strong>per uvicorn worker, not per pod&lt;/strong>. With &lt;code>--num_workers 4&lt;/code> and ten replicas that is forty copies of every job, all of them running with no coordination if there is no Redis for leader election.&lt;/p>
&lt;p>The variable that separates them is &lt;code>LITELLM_JOB_ROLE&lt;/code>: value &lt;code>serving&lt;/code> on the pods that serve traffic and a separate replica with value &lt;code>worker&lt;/code> for the jobs. With the one-worker-per-pod recommendation the problem shrinks, but it does not go away while there are several replicas.&lt;/p>
&lt;h2 id="probes-three-endpoints-and-only-two-are-usable">Probes: three endpoints and only two are usable&lt;/h2>
&lt;p>The three endpoints exist and do different things:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Endpoint&lt;/th>
&lt;th>Authentication&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>What it is for&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>/health/liveliness&lt;/code>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Returns &lt;code>I'm alive!&lt;/code>, or 503 during shutdown&lt;/td>
&lt;td>&lt;code>livenessProbe&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/health/readiness&lt;/code>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>State of the process and the database. &lt;strong>503 if the configured Postgres does not answer&lt;/strong>&lt;/td>
&lt;td>&lt;code>readinessProbe&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/health&lt;/code>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Fires a real request against &lt;strong>every model&lt;/strong> in the catalogue&lt;/td>
&lt;td>Manual diagnosis, never a probe&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The last row deserves insisting on. &lt;code>/health&lt;/code> spends tokens on every call, one per configured model. Set as a probe, with the Kubernetes default interval and a fleet of eight models, that is thousands of inference requests a day that serve nobody.&lt;/p>
&lt;p>There are more endpoints useful for diagnosis: &lt;code>/health/readiness/details&lt;/code>, &lt;code>/health/services?service=langfuse&lt;/code> to check one specific callback, &lt;code>/health/history&lt;/code> and &lt;code>/health/latest&lt;/code> for the history, and &lt;code>/health/backlog&lt;/code>, which returns the in-flight requests and shows up in the latency section. &lt;code>/health/drain&lt;/code> exists but returns 404 unless it is enabled with &lt;code>enable_drain_endpoint: true&lt;/code>, and it is protected with &lt;code>X-Drain-Token&lt;/code>.&lt;/p>
&lt;p>The periodic model checks are configured separately and not in the probe:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">background_health_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">health_check_interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">300&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># seconds&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">background_health_check_model_groups&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;llama-70b&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;qwen-30b&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And one setting with consequences in deployments with no Internet egress: &lt;code>allow_requests_on_db_unavailable: true&lt;/code> allows serving traffic with the database down, but leaves &lt;code>/health/readiness&lt;/code> always returning 200. With that set, the readiness probe stops detecting the very failure it was designed to detect, and budget and model errors keep blocking just the same.&lt;/p>
&lt;h2 id="retries-cooldowns-and-fallbacks-the-real-numbers">Retries, cooldowns and fallbacks: the real numbers&lt;/h2>
&lt;p>The defaults, read from the project&amp;rsquo;s code and not from the documentation:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Constant&lt;/th>
&lt;th>Value&lt;/th>
&lt;th>What it governs&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>DEFAULT_MAX_RETRIES&lt;/code>&lt;/td>
&lt;td>2&lt;/td>
&lt;td>Retries before moving on to fallbacks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>DEFAULT_ALLOWED_FAILS&lt;/code>&lt;/td>
&lt;td>3&lt;/td>
&lt;td>Failures before cooling down a deployment&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>DEFAULT_COOLDOWN_TIME_SECONDS&lt;/code>&lt;/td>
&lt;td>5&lt;/td>
&lt;td>Seconds a deployment stays out&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>SINGLE_DEPLOYMENT_TRAFFIC_FAILURE_THRESHOLD&lt;/code>&lt;/td>
&lt;td>1000&lt;/td>
&lt;td>Minimum requests before applying the cooldown logic with a single deployment&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>DEFAULT_REQUEST_TIMEOUT_SECONDS&lt;/code>&lt;/td>
&lt;td>6000.0&lt;/td>
&lt;td>Request timeout&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Besides the failure counter, there is a proportional criterion: a deployment enters cooldown if 50% of its requests fail in any given minute.&lt;/p>
&lt;p>On the cooldown there is a discrepancy worth knowing about before debugging blind. The docstring of the router function says &lt;code>cooldown_time&lt;/code> defaults to 1, and the code uses the constant of 5. The code wins.&lt;/p>
&lt;p>The fallbacks are three separate lists and are applied &lt;strong>after&lt;/strong> the retries are exhausted:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">fallbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">&amp;#34;llama-70b&amp;#34;: &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;qwen-30b&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>}&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">context_window_fallbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">&amp;#34;llama-70b&amp;#34;: &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;llama-70b-128k&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>}&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">content_policy_fallbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">&amp;#34;llama-70b&amp;#34;: &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;llama-70b-sin-guardrail&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>}&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A note on the behaviour when everything is in cooldown: if no deployment is left available in the group, LiteLLM falls back onto a specific &lt;code>model_info.id&lt;/code>, skipping the cooldown check. It is a deliberate degradation, serve something rather than return 503, and it is worth knowing it exists because it masks the real state of the fleet.&lt;/p>
&lt;p>And a capability lost along the way: since version 1.85.0, the &lt;code>mock_testing_fallbacks&lt;/code> parameter and its two siblings are stripped from requests arriving through the proxy and have no effect. Fallbacks can no longer be tested against the proxy with a doctored request; it has to be done against &lt;code>litellm.Router&lt;/code> directly, in a test. Anyone who had a smoke test built on that has it broken without warning.&lt;/p>
&lt;h3 id="the-six-thousand-second-timeout">The six thousand second timeout&lt;/h3>
&lt;p>It deserves its own section because it is the setting with the worst damage-to-effort ratio in the whole system. &lt;code>request_timeout&lt;/code> is 6,000 seconds. A hundred minutes. An inference engine that stops answering without closing the connection holds a worker for that long, and with one worker per pod that is an entire pod out of service per trapped request.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">request_timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Ten minutes is still generous for a long generation, and it bounds the damage.&lt;/p>
&lt;h2 id="overhead-the-projects-two-figures">Overhead: the project&amp;rsquo;s two figures&lt;/h2>
&lt;p>LiteLLM publishes two measurements of its own overhead that differ by a factor of thirty, and both are legitimate because they measure different loads.&lt;/p>
&lt;p>The benchmarks page, with Locust, a thousand users with think time and a fake endpoint, gives for four instances an &lt;code>x-litellm-overhead-duration-ms&lt;/code> of &lt;strong>median 2 ms, p95 8 ms and p99 13 ms&lt;/strong> at 1,170 requests per second. With two instances, median 12 ms, p95 29 ms and p99 43 ms. The page itself warns that those figures correspond to around 130 in-flight requests, and that a closed-loop client with no think time keeps a thousand in flight and sees on the order of eight times that latency at the same rate, by Little&amp;rsquo;s law.&lt;/p>
&lt;p>The AIGatewayBench benchmark, from July 2026, measures the Python proxy at &lt;strong>257.7 ms of added p99&lt;/strong> and 329.5 MB of peak memory, with no callbacks, no spend tracking and no persistence. The Rust variant, in beta, gives 0.7 ms.&lt;/p>
&lt;p>The reading for an in-house platform: the first figure is the best case with human clients, and the second is the order of magnitude to plan for agentic load, which arrives in a closed loop against fast responses. There is one reported case that resembles this scenario closely: an in-house OpenAI-compatible engine served around 16 requests per second directly and around 9 through LiteLLM, with the degradation growing with concurrency, on a 4 vCPU, 8 GB pod.&lt;/p>
&lt;h3 id="the-latency-gap-the-metrics-cannot-see">The latency gap the metrics cannot see&lt;/h3>
&lt;p>LiteLLM&amp;rsquo;s timers start when its handler starts. The time the request spends queued in the uvicorn event loop, before getting there, appears in none of its metrics. The example in its own troubleshooting guide is a case in which LiteLLM records 10 seconds and the user experiences 20.&lt;/p>
&lt;p>It is detected by comparing two sources: &lt;code>GET /health/backlog&lt;/code> or the &lt;code>litellm_in_flight_requests&lt;/code> gauge against the response time measured by the load balancer in front. If they diverge, the gap is in the input queue and the answer is more replicas, not more tuning.&lt;/p>
&lt;p>Two headers are always on and serve to watch it without instrumenting anything: &lt;code>x-litellm-overhead-duration-ms&lt;/code> and &lt;code>x-litellm-callback-duration-ms&lt;/code>. For the second, the documentation sets the suspicion threshold at 100 ms, above which the diagnosis is that the payloads are too large.&lt;/p>
&lt;p>And the first cause of latency that guide mentions is none of the above: &lt;code>LITELLM_LOG=DEBUG&lt;/code> serialises the payload with &lt;code>json.dumps(indent=4)&lt;/code> synchronously, and with payloads over 2 MB that alone can cost between 2 and 5 seconds per request. A log level set to debug a problem and forgotten there produces exactly the problem it was meant to debug.&lt;/p>
&lt;h2 id="the-failure-mode-to-put-in-the-runbook">The failure mode to put in the runbook&lt;/h2>
&lt;p>There is an open issue describing a cascade with a characteristic shape. Under sustained 429s from the engine upstream, the pods stopped answering readiness probes during startup, Kubernetes killed them on failed probes, and the restart put more pressure on an upstream that was already saturated. Peaks of 500 requests per second, average of 60, between two and five replicas with 1.3 CPU and 4 GB.&lt;/p>
&lt;p>The shape of the failure is what matters more than the specific case: &lt;strong>the engine&amp;rsquo;s saturation turns into a restart loop of the gateway&lt;/strong>, and from the outside it looks like the problem is the gateway. The defences are the ones already in this article, applied together: &lt;code>startupProbe&lt;/code> with a wide margin so readiness does not compete with startup, &lt;code>request_timeout&lt;/code> bounded so trapped requests get released, cooldowns that take the deployment returning 429 out of rotation, and CPU autoscaling at 60% so the capacity exists before it is needed. It fits with what was covered in &lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">incident response runbooks&lt;/a>.&lt;/p>
&lt;h2 id="upgrading-without-cutting-traffic">Upgrading without cutting traffic&lt;/h2>
&lt;p>The deployment is a normal &lt;code>Deployment&lt;/code> with &lt;code>RollingUpdate&lt;/code>, &lt;code>maxUnavailable: 0&lt;/code> and &lt;code>maxSurge: 1&lt;/code>, and there is no further mystery to it except on one point: &lt;strong>schema migrations&lt;/strong>. LiteLLM uses Prisma and applies migrations at startup. With several replicas coming up at once after a version change, the migration must be run by a single process, which is another reason for the dedicated replica with &lt;code>LITELLM_JOB_ROLE: worker&lt;/code>.&lt;/p>
&lt;p>Before a version bump, two checks that pay for themselves: read the release notes looking for changes in the metadata emitted to the callbacks, because a change like that breaks saved dashboards without touching the service, and verify that the new configuration starts with &lt;code>--detailed_debug&lt;/code> on a separate pod before applying it to the fleet.&lt;/p>
&lt;h2 id="go-live-checklist">Go-live checklist&lt;/h2>
&lt;ol>
&lt;li>&lt;code>--num_workers 1&lt;/code> and scaling by replicas.&lt;/li>
&lt;li>&lt;code>requests&lt;/code> equal to &lt;code>limits&lt;/code>, 1 vCPU and 4 GiB per worker.&lt;/li>
&lt;li>HPA on CPU at 60%. Never on memory.&lt;/li>
&lt;li>&lt;code>database_connection_pool_limit&lt;/code> computed from &lt;code>max_connections&lt;/code>, and the chart&amp;rsquo;s &lt;code>maxReplicas&lt;/code> lowered to something the database can take.&lt;/li>
&lt;li>Redis as soon as there is more than one replica, and &lt;code>use_redis_transaction_buffer&lt;/code> from ten instances or a thousand requests per second.&lt;/li>
&lt;li>&lt;code>livenessProbe&lt;/code> on &lt;code>/health/liveliness&lt;/code>, &lt;code>readinessProbe&lt;/code> on &lt;code>/health/readiness&lt;/code>, and &lt;code>/health&lt;/code> out of the probes.&lt;/li>
&lt;li>&lt;code>startupProbe&lt;/code> with a margin of up to 300 seconds.&lt;/li>
&lt;li>&lt;code>request_timeout: 600&lt;/code>.&lt;/li>
&lt;li>&lt;code>LITELLM_LOG&lt;/code> out of &lt;code>DEBUG&lt;/code>, and &lt;code>disable_error_logs: true&lt;/code>.&lt;/li>
&lt;li>A dashboard with &lt;code>x-litellm-overhead-duration-ms&lt;/code>, &lt;code>x-litellm-callback-duration-ms&lt;/code>, &lt;code>litellm_in_flight_requests&lt;/code> and the response time of the load balancer in front.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>The HPA scales up and never back down.&lt;/strong> It is measuring memory, which in this process only goes up.&lt;/p>
&lt;p>&lt;strong>The pods die in a loop right after deploying.&lt;/strong> Less than 4 GiB of limit. The footprint appears with the first large request, not at startup.&lt;/p>
&lt;p>&lt;strong>Postgres starts rejecting connections at the peak.&lt;/strong> The chart has &lt;code>maxReplicas: 100&lt;/code> and the pool defaults to 10.&lt;/p>
&lt;p>&lt;strong>The per-minute limits are enforced badly.&lt;/strong> There is no Redis, and each replica keeps its own count.&lt;/p>
&lt;p>&lt;strong>The health check consumes tokens.&lt;/strong> Someone set &lt;code>/health&lt;/code> as the readiness probe.&lt;/p>
&lt;p>&lt;strong>The readiness probe returns 200 with the database down.&lt;/strong> &lt;code>allow_requests_on_db_unavailable&lt;/code> is set.&lt;/p>
&lt;p>&lt;strong>A deployment keeps receiving traffic after failing.&lt;/strong> The cooldown is 5 seconds, not minutes, and with a single deployment in the group you have to pass a thousand requests for the logic to apply.&lt;/p>
&lt;p>&lt;strong>The fallback test no longer tests anything.&lt;/strong> The simulation parameters are stripped from requests arriving through the proxy since 1.85.0.&lt;/p>
&lt;p>&lt;strong>The p99 is ten times worse than the published benchmark.&lt;/strong> The benchmark has think time and the real load is agentic.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>None of these settings is complicated and all of them are one line. What unites them is that their defaults describe a small deployment, with one instance, a human client on the other side and a commercial provider taking care of capacity. An in-house inference factory is the opposite on all four axes: several replicas, automated clients, and an engine whose capacity is finite and known.&lt;/p>
&lt;p>Of the ten points in the checklist, three decide almost everything in an on-premise deployment: the process model with its sizing, the database connection arithmetic, and the request timeout. The other seven are the ones that avoid the Saturday phone call.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — the other half of day 2: cost per token on in-house models, trace correlation and the telemetry queues this post takes as given.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router: the L7 switchboard&lt;/a> — the router&amp;rsquo;s four functions and the routing strategies whose latency is discussed here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">Choosing the switchboard: which OSS gateway to put in front&lt;/a> — the prior decision, with verified licences.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks&lt;/a> — where the cascade of 429s and failed probes described here fits.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> — the complete journey, of which this post details the gateway leg.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/guidellm-validating-slo-under-load-sizing-knee/">GuideLLM and SLO validation under load&lt;/a> — how to measure the real saturation point before setting the limits in this article.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">The on-premise LLM inference stack in seven layers&lt;/a> — where the gateway lives in the complete building.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>Production best practices&lt;/em> (topología, workers, memoria, pool de conexiones, &lt;code>LITELLM_JOB_ROLE&lt;/code>, buffer de transacciones en Redis, &lt;code>request_timeout&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/proxy/prod">https://docs.litellm.ai/docs/proxy/prod&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Health checks&lt;/em> (los tres endpoints, comprobaciones de fondo, &lt;code>enable_drain_endpoint&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/proxy/health">https://docs.litellm.ai/docs/proxy/health&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Reliability&lt;/em> (fallbacks, reintentos, deprecación de los parámetros de simulación en la 1.85.0): &lt;a href="https://docs.litellm.ai/docs/proxy/reliability">https://docs.litellm.ai/docs/proxy/reliability&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Benchmarks&lt;/em> y &lt;em>Load test advanced&lt;/em> (las cifras de sobrecarga por número de instancias y la advertencia de la ley de Little): &lt;a href="https://docs.litellm.ai/docs/benchmarks">https://docs.litellm.ai/docs/benchmarks&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/load_test_advanced">https://docs.litellm.ai/docs/load_test_advanced&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Rust AI Gateway benchmarks&lt;/em> (los 257,7 ms de p99 del proxy en Python): &lt;a href="https://docs.litellm.ai/blog/rust-ai-gateway-benchmarks">https://docs.litellm.ai/blog/rust-ai-gateway-benchmarks&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Latency overhead&lt;/em> (el hueco de la cola de uvicorn, las cabeceras de diagnóstico, el coste de &lt;code>LITELLM_LOG=DEBUG&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/troubleshoot/latency_overhead">https://docs.litellm.ai/docs/troubleshoot/latency_overhead&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>DB info&lt;/em> (&lt;code>disable_spend_logs&lt;/code>, &lt;code>disable_error_logs&lt;/code> y qué se pierde con cada uno): &lt;a href="https://docs.litellm.ai/docs/proxy/db_info">https://docs.litellm.ai/docs/proxy/db_info&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/constants.py&lt;/code> (los valores por defecto de reintentos, fallos permitidos, enfriamiento y timeout): &lt;a href="https://github.com/BerriAI/litellm/blob/main/litellm/constants.py">https://github.com/BerriAI/litellm/blob/main/litellm/constants.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/router.py&lt;/code> (la discrepancia entre el docstring de &lt;code>cooldown_time&lt;/code> y la constante que usa el código): &lt;a href="https://github.com/BerriAI/litellm/blob/main/litellm/router.py">https://github.com/BerriAI/litellm/blob/main/litellm/router.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, issue #15526 — cascada de 429 sostenidos, sondas de readiness fallidas y bucle de reinicios: &lt;a href="https://github.com/BerriAI/litellm/issues/15526">https://github.com/BerriAI/litellm/issues/15526&lt;/a>.&lt;/li>
&lt;li>LiteLLM, issue #21046 — degradación de throughput contra un motor propio compatible con OpenAI: &lt;a href="https://github.com/BerriAI/litellm/issues/21046">https://github.com/BerriAI/litellm/issues/21046&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Crossplane: the control plane that turns your cluster into a private cloud API</title><link>https://blog.lo0.es/en/posts/crossplane-control-plane-private-cloud-api/</link><pubDate>Mon, 31 Aug 2026 09:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/crossplane-control-plane-private-cloud-api/</guid><description>&lt;blockquote>
&lt;p>Second article in the batch about rebuilding the cloud experience on-premise. The &lt;a href="https://blog.lo0.es/en/posts/public-cloud-to-private-what-you-lose-which-projects-fill-gap/">general map&lt;/a> placed the gaps for you; this one goes into the provisioning control plane, that API which on-premise does not come for free. The &lt;a href="https://blog.lo0.es/en/posts/kubevela-score-platform-cli-without-teaching-kubernetes/">third&lt;/a> moves up a layer, to application-centric self-service with KubeVela and Score.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Crossplane solves the &amp;ldquo;open a ticket and wait&amp;rdquo; gap for you. In the cloud you ask for a database through an API and it appears with permissions, backups and monitoring; on-premise you do that provisioning by hand or with scripts. Crossplane brings the Kubernetes model of declarative APIs to infrastructure: your platform team defines an API of its own (a database, an inference endpoint) and the developer consumes it with a &lt;code>kubectl apply&lt;/code> of a few fields, while controllers inside the cluster reconcile the desired state against reality and correct drift continuously. It graduated from the CNCF in November 2025, with more than three thousand contributors and adopters such as Nike, SAP, IBM or NASA&amp;rsquo;s science cloud.&lt;/p>
&lt;p>The difference from Terraform is one of nature, not of features: Terraform applies a plan on demand and leaves; Crossplane is a control plane that lives inside the cluster and never stops reconciling. That gives you drift self-correction and native GitOps, and in exchange it takes away the safety net of &lt;code>plan&lt;/code> and forces you to operate the control plane itself, with providers that create hundreds of resource definitions and state that lives in etcd. And a warning about something awkward up front: Crossplane is not an imperative &lt;code>aws ...&lt;/code> style CLI, it is declarative first. On the fit with GPU inference, which until recently was only a pattern with no public case, you now have a real reference, Modelplane, from the Crossplane team itself, although at version 0.1 and composing vLLM directly without going through KServe. I go into the detail at the end.&lt;/p>
&lt;h2 id="the-analogy-the-thermostat-against-the-light-switch">The analogy: the thermostat against the light switch&lt;/h2>
&lt;p>Think of it this way: Terraform is a light switch. You flip it, the light comes on, and it stays there until you touch it again. If somebody else changes it, the light obeys whoever touched it last, and you do not find out until you go and look. It is provisioning by one-off action: you run it, it applies, it finishes.&lt;/p>
&lt;p>Crossplane is a thermostat. You do not tell it &amp;ldquo;turn the heating on&amp;rdquo;, you tell it &amp;ldquo;I want twenty-one degrees&amp;rdquo;, and from then on a mechanism watches the temperature and acts on its own, continuously, to hold it. If somebody opens a window and the thermometer drops, the thermostat reacts without you intervening. That is continuous reconciliation: you declare the desired state once and a controller takes care of reality not straying from it.&lt;/p>
&lt;p>The analogy also warns you about the cost. A thermostat is more convenient than a switch, but it is one more device you have to install, power and fix when it fails, and if you program it badly it adjusts the temperature to something you did not want, without asking. A switch almost never breaks and you always know what state it is in. Crossplane gives you the convenience of the thermostat in exchange for you operating the thermostat.&lt;/p>
&lt;h2 id="what-crossplane-is-and-what-state-it-reaches-2026-in">What Crossplane is and what state it reaches 2026 in&lt;/h2>
&lt;p>Crossplane was born at Upbound in 2018 and followed the CNCF maturity path without shortcuts: sandbox in 2020, incubation in 2021 and &lt;strong>graduation on 6 November 2025&lt;/strong>. Graduation matters because it is the seal the CNCF reserves for projects it considers production-ready, the same level as Kubernetes or Prometheus. The figures that came with the announcement are solid: more than three thousand contributors from more than 450 organisations, more than a thousand pull request authors (which puts it in the top 10 % of the foundation&amp;rsquo;s projects by that metric), more than a hundred releases and two completed security audits. Among the more than seventy public adopters you have Nike, Nokia, Grafana, NASA&amp;rsquo;s science cloud, SAP and IBM.&lt;/p>
&lt;p>The project&amp;rsquo;s official description is &amp;ldquo;the cloud native control plane&amp;rdquo;, and that phrase sums up exactly what it does. The main vendor behind it is still Upbound, which at graduation framed the project&amp;rsquo;s future towards &amp;ldquo;AI-native&amp;rdquo; infrastructure, a point I come back to when talking about inference, with the caveat that it is a vendor&amp;rsquo;s positioning, not a proven case.&lt;/p>
&lt;p>The release cadence is quarterly, with a nine-month support window and three actively maintained versions at any time. As of writing, the v2 branch is the current one, with v1.20 as the last close of the v1 branch.&lt;/p>
&lt;h2 id="the-v2-turn-which-changes-how-you-think-about-it">The v2 turn (which changes how you think about it)&lt;/h2>
&lt;p>Crossplane 2.0 reached general availability in August 2025 and brings four structural changes you need to know about, because a good deal of the old material describes a model that is no longer the recommended one.&lt;/p>
&lt;p>The first is that &lt;strong>resources are now namespaced by default&lt;/strong>. Both composite and managed resources, which used to be cluster-scoped, now live in a namespace, which enables fine-grained access control and natural multi-tenancy, exactly what you need in a sovereign platform with several teams.&lt;/p>
&lt;p>The second is that &lt;strong>a composition can include any Kubernetes resource&lt;/strong>, not only Crossplane resources. Previously a composition was limited to Crossplane&amp;rsquo;s own managed resources; now it can include Deployments, Services or third-party CRDs, so you can compose abstractions mixing application and infrastructure in a single object. This change is what makes the inference pattern you will see viable.&lt;/p>
&lt;p>The third is that &lt;strong>Claims disappear&lt;/strong>. In the v1 model there was a duality between the composite resource, cluster-scoped, and the Claim, its namespaced face which the developer consumed. Now that the composite resource is itself namespaced, that duality is redundant: the developer consumes the composite resource with no intermediary. If you come across documentation talking about Claims, it belongs to the earlier model.&lt;/p>
&lt;p>The fourth is a new type, &lt;code>Operations&lt;/code>, for one-off or scheduled operational tasks that run a pipeline of functions to completion, like a Kubernetes Job but inside the Crossplane model.&lt;/p>
&lt;p>There is one removal that deserves its own paragraph, because it is the most surprising: in v2 native patch-and-transform was withdrawn, the classic patching mechanism of compositions. It was not relegated, it was removed. Everything now goes through composition functions, and I get to that in a moment. Migration from v1 to v2 is mostly non-breaking and the v1 APIs still work, so adopting v2 is optional, but the new model is the one you have to learn.&lt;/p>
&lt;h2 id="the-model-piece-by-piece">The model, piece by piece&lt;/h2>
&lt;p>Crossplane has dense jargon and I will take it apart calmly, because once the pieces are placed the whole thing is coherent.&lt;/p>
&lt;p>&lt;strong>A provider&lt;/strong> is a package that installs controllers and resource definitions to talk to an external API, be it AWS, GCP, a remote Kubernetes cluster, Helm or Terraform. It is installed declaratively, by applying a &lt;code>Provider&lt;/code> object. The providers for the big cloud providers are generated with Upjet, a framework that produces the code from the Terraform providers, which explains why there are so many of them and also why some are enormous.&lt;/p>
&lt;p>&lt;strong>A managed resource&lt;/strong> is the one-to-one representation, inside Kubernetes, of a resource from the external provider: a bucket, an instance, a database. It is the unit the controller reconciles. When you create a managed resource of type bucket, the provider&amp;rsquo;s controller creates the real bucket and then watches that it keeps existing with the configuration you declared.&lt;/p>
&lt;p>On those bricks you build the abstraction, which is where the real value is, with three concepts:&lt;/p>
&lt;p>The &lt;strong>CompositeResourceDefinition&lt;/strong>, or XRD, defines the schema of your own API: which fields the user can ask for. It is what turns &amp;ldquo;a database&amp;rdquo; into a resource type with its parameters.&lt;/p>
&lt;p>The &lt;strong>Composition&lt;/strong> is the template and the logic that, given one of those resources, materialises the N managed resources needed underneath. The documentation says it bluntly: a composition is a pipeline of composition functions.&lt;/p>
&lt;p>The &lt;strong>Composite Resource&lt;/strong>, or XR, is the concrete instance: the object the developer creates with &lt;code>kubectl apply&lt;/code>, with the few fields the XRD exposes.&lt;/p>
&lt;p>The complete pattern is this: your platform operator publishes the abstraction (the XRD plus the Composition, which say &amp;ldquo;this is what a database means here&amp;rdquo;), and the developer only applies a composite resource with four fields. How it is built underneath stops mattering to them, just as in the cloud you do not care how AWS assembles your managed database.&lt;/p>
&lt;h2 id="composition-functions-the-real-logic">Composition functions: the real logic&lt;/h2>
&lt;p>The composition mechanism in v2, and the only one, is &lt;strong>composition functions&lt;/strong>. A composition is a pipeline of functions that Crossplane invokes over gRPC; each function receives the observed state, the desired state, its input and a shared context, and chains its output to the next one. The YAML in the examples is illustrative, but underneath it is not processed as YAML: it is executed as functions.&lt;/p>
&lt;p>The available functions cover several styles. You have &lt;code>function-patch-and-transform&lt;/code>, which reimplements the old model now as just another function. There is &lt;code>function-go-templating&lt;/code> for Helm-style templates, &lt;code>function-kcl&lt;/code> for the KCL language, &lt;code>function-python&lt;/code> for logic in Python and &lt;code>function-cue&lt;/code> for CUE.&lt;/p>
&lt;p>The reason for the change is what you want to understand. Classic patch-and-transform had no loops or conditionals, so an operation as simple as transforming an array of strings turned into dozens of lines of patching, and the community went as far as calling it outright bad. Functions give you full programming logic (loops, conditionals, external calls) in a real language. The honest flip side is that the complexity does not disappear: it shifts from &amp;ldquo;writing verbose patches&amp;rdquo; to &amp;ldquo;writing and operating functions&amp;rdquo;, which is a different kind of work, not less work.&lt;/p>
&lt;h2 id="crossplane-against-terraform-opentofu-and-pulumi">Crossplane against Terraform, OpenTofu and Pulumi&lt;/h2>
&lt;p>This is the comparison everybody makes, and I will make it properly because the difference is one of category. According to Pulumi&amp;rsquo;s own documentation, which compares honestly, Crossplane is a declarative control plane with continuous reconciliation inside Kubernetes, while Terraform and Pulumi are infrastructure as code executed one-off, client-side and on demand.&lt;/p>
&lt;p>The practical consequences line up along a few axes. Faced with configuration drift, Crossplane corrects it continuously and automatically, while Terraform and Pulumi only detect it when you run a refresh. Crossplane needs a cluster to run on, because it is controllers; Terraform and Pulumi do not. Crossplane is GitOps-native, applied with kubectl and fitting with &lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">Flux&lt;/a> without an adapter; Pulumi needs its Kubernetes operator for the same thing. And as for language, Crossplane uses YAML and CRDs with logic in the functions, while Pulumi uses general-purpose programming languages.&lt;/p>
&lt;p>The point most often forgotten is that &lt;strong>you do not have to choose one or the other&lt;/strong>. There is a Terraform provider (and an OpenTofu one, recommended for new versions because the Terraform one was frozen at the last freely licensed version) that runs HCL &lt;em>inside&lt;/em> Crossplane, wrapping your existing modules as a managed resource. You can reuse your whole investment in Terraform and still gain continuous reconciliation for whatever needs it. If you already have Terraform in production, this hybrid pattern is usually your sensible route, not the rewrite.&lt;/p>
&lt;p>On comparative adoption you have to be sober: there is no official, reliable &amp;ldquo;Crossplane against Terraform&amp;rdquo; figure, and the most repeated documented criticism is that Crossplane lacks the mature module ecosystem Terraform has been accumulating for years. That is a real qualitative difference, not a detail.&lt;/p>
&lt;h2 id="the-awkward-question-is-it-an-aws-style-cli">The awkward question: is it an AWS-style CLI?&lt;/h2>
&lt;p>Since on this blog I have been pulling on the thread of CLI provisioning, I will answer directly: &lt;strong>no&lt;/strong>. Crossplane does not offer you an imperative &lt;code>aws s3 create-bucket&lt;/code> style experience. It is declarative first. You provision with a &lt;code>kubectl apply&lt;/code> of a resource, and the &lt;code>crossplane&lt;/code> binary is there to build, package, test and debug, not to &amp;ldquo;create a bucket&amp;rdquo; in one go.&lt;/p>
&lt;p>The subcommands make that clear. &lt;code>crossplane render&lt;/code> renders a composition and its functions locally, so you can test without a cluster. The &lt;code>crossplane xpkg build&lt;/code>, &lt;code>push&lt;/code>, &lt;code>install&lt;/code> family manages the OCI packages of providers, configurations and functions. And &lt;code>crossplane beta validate&lt;/code>, &lt;code>trace&lt;/code> and &lt;code>top&lt;/code> validate schemas, draw the resource tree and show consumption inside the cluster. It is the toolbox of whoever builds the platform, not of whoever consumes it day to day. Upbound&amp;rsquo;s &lt;code>up&lt;/code> CLI adds more, but oriented towards its commercial product.&lt;/p>
&lt;p>The conclusion matters for setting expectations: the imperative AWS-console-style experience, if you want it, gets built &lt;em>on top of&lt;/em> Crossplane, with Backstage, a portal or your own wrapper. Crossplane gives you the declarative API; the friendly face you put on separately. If what you are after is an imperative provisioning CLI, you fit better with the model of &lt;a href="https://blog.lo0.es/en/posts/kubevela-score-platform-cli-without-teaching-kubernetes/">KubeVela and Score&lt;/a>, which is precisely the subject of the next article.&lt;/p>
&lt;h2 id="on-premise-providers-provisioning-without-touching-aws">On-premise providers: provisioning without touching AWS&lt;/h2>
&lt;p>For a sovereign platform your question is what you can provision without setting foot in a public cloud, and the answer is more than it seems, with uneven maturity.&lt;/p>
&lt;p>The most versatile is &lt;code>provider-kubernetes&lt;/code>, which manages any Kubernetes object in remote clusters through an &lt;code>Object&lt;/code> resource. It is the basis of the multi-cluster on-premise pattern: from a central cluster you materialise resources in your secondary RKE2s. Alongside it, &lt;code>provider-helm&lt;/code> installs and manages Helm charts declaratively through a &lt;code>Release&lt;/code> resource, letting you deploy things like vLLM or KServe as part of a composition.&lt;/p>
&lt;p>For the identity and storage part you have concrete pieces: &lt;code>provider-keycloak&lt;/code> configures Keycloak realms, clients and roles declaratively, useful for the sovereign IAM we already set up in &lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">the article on authentication with Keycloak&lt;/a>; and &lt;code>provider-minio&lt;/code> manages buckets and policies of on-premise S3-compatible storage. For vSphere you do not have a first-class native provider, but you cover it by generating one from the Terraform provider, the usual pattern for VMware infrastructure.&lt;/p>
&lt;p>An honest warning: for KubeVirt, Harvester or CloudNativePG there are no mature, first-class native providers; in practice you manage them by applying their CRDs through &lt;code>provider-kubernetes&lt;/code> or by wrapping their Terraform. It works, but it is not the polished experience of a dedicated provider. With the combination of provider-kubernetes, provider-helm, provider-minio and provider-keycloak, plus provider-terraform for whatever is missing, you can assemble a one hundred per cent on-premise self-service catalogue, with the caveat that maturity varies piece by piece.&lt;/p>
&lt;h2 id="the-fit-with-gpu-inference-from-pattern-to-modelplane">The fit with GPU inference: from pattern to Modelplane&lt;/h2>
&lt;p>This is the section that in the first version of this text carried the most caution, because there was no public case of Crossplane orchestrating GPU and inference and it all remained a design pattern. Halfway through 2026 that changed, and the case was published by the very team that makes Crossplane.&lt;/p>
&lt;p>&lt;strong>Modelplane&lt;/strong> is an open source control plane for AI inference, Apache-licensed and with a first version 0.1 from June 2026, and the interesting thing is how it is built: entirely on Crossplane compositions and composition functions in Python, with no controllers of its own. It composes vLLM directly, with the &lt;code>vllm/vllm-openai&lt;/code> image, without going through KServe, and its self-service API for the ML teams is a &lt;code>ModelDeployment&lt;/code> resource where you declare the model, the engine, the topology and the hardware. Underneath, a &lt;code>ModelReplica&lt;/code> resource represents each replica, and the platform team declares its GPU pools with &lt;code>InferenceCluster&lt;/code> and &lt;code>InferenceClass&lt;/code> resources, using an attribute model and CEL expressions taken from Kubernetes dynamic resource allocation. Its most advanced piece is a two-level fleet scheduler written as a pure function, which places replicas across the whole infrastructure and delegates to each cluster&amp;rsquo;s scheduler. It is proof that the pattern works, with the warning that it is a version 0.1 under open development, not something to put into production blindly, and that adopting it ties your platform to its resource types.&lt;/p>
&lt;p>The other recent enabler is in KServe, which introduced a type dedicated to large models, &lt;code>LLMInferenceService&lt;/code>, still in alpha and built on the llm-d project, with prefill and decode separation and multi-node execution, and which runs vLLM as its runtime. It is different from the classic &lt;code>InferenceService&lt;/code>, reserved for predictive ML. That new type is your ideal brick if you prefer composing KServe instead of bare vLLM.&lt;/p>
&lt;p>With that, your own golden path stops being a hypothesis. You define an XRD, for example &lt;code>InferenceEndpoint&lt;/code>, with fields such as the model, the number and type of GPU and the replicas. Its composition, taking advantage of the fact that in v2 it can include any Kubernetes resource, materialises in a single pipeline the team&amp;rsquo;s namespace, the quota for &lt;code>nvidia.com/gpu&lt;/code> and a KServe &lt;code>LLMInferenceService&lt;/code> pointing at the model with vLLM runtime, and the developer only applies the &lt;code>InferenceEndpoint&lt;/code> with four fields, knowing nothing about KServe or the quotas underneath. KServe supports vLLM and GPU natively, as we saw in &lt;a href="https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/">the article on KServe&lt;/a>.&lt;/p>
&lt;p>The honest reading is that you are at the frontier, not on well-trodden ground. Outside Modelplane, made by Upbound, there is still no body of independent cases assembling Crossplane with vLLM, and the KServe type is in alpha. It is a coherent architecture, with a real reference backing it and plenty of room for you to be among the first to document it on your own iron.&lt;/p>
&lt;h2 id="what-it-costs-you-to-operate-your-own-control-plane">What it costs you to operate your own control plane&lt;/h2>
&lt;p>No tool that gives you back control is free, and Crossplane has well-documented costs you need to budget for beforehand, not discover afterwards.&lt;/p>
&lt;p>&lt;strong>The learning curve is steep&lt;/strong>, acknowledged even in the coverage of its graduation. Adopters report debugging difficulties when a composition or a provider misbehaves, and they miss Terraform&amp;rsquo;s module ecosystem.&lt;/p>
&lt;p>&lt;strong>Compositions tend towards complexity.&lt;/strong> There are practitioners describing definitions spanning thousands of lines with poor navigation, and the absence of an integrated testing framework. Functions improve this, but as I said, they shift the complexity rather than remove it.&lt;/p>
&lt;p>&lt;strong>There is no &lt;code>plan&lt;/code>.&lt;/strong> Unlike Terraform&amp;rsquo;s &lt;code>plan&lt;/code> and &lt;code>apply&lt;/code> cycle, Crossplane applies changes immediately. You lose the safety net of reviewing what is going to happen before it happens, with the risk of modifications you did not want.&lt;/p>
&lt;p>&lt;strong>Operating the control plane itself costs.&lt;/strong> Big providers create hundreds of resource definitions (the GCP one reached 347), with a risk of API server unavailability and of leaving your cluster hanging. State lives in etcd, which complicates disaster recovery and migrating control between clusters. And there is one specific danger: accidental deletion of cloud resources when upgrading XRDs, providers or compositions, because of the ownership Crossplane keeps over what it created.&lt;/p>
&lt;p>Some have decided not to adopt it. The Masterpoint team ruled it out for the lack of data sources and &lt;code>ignore_changes&lt;/code>, for incomplete coverage of AWS services, and for the cost of abandoning their accumulated Terraform experience. It is a legitimate decision, and quoting it to you is part of an honest article.&lt;/p>
&lt;h2 id="how-it-lives-alongside-what-you-already-have">How it lives alongside what you already have&lt;/h2>
&lt;p>If you already run a platform, your question is not Crossplane yes or no, but Crossplane with what. Three relationships matter to you.&lt;/p>
&lt;p>With &lt;strong>Backstage&lt;/strong> they are complementary, not rivals. Backstage is the portal and the templates that generate manifests or pull requests, day zero; Crossplane provisions and reconciles the real state, day two. The usual pattern, which some call the golden triangle, is Backstage as the interface, Crossplane as the control plane and Argo CD or Flux as GitOps. Each one does a layer.&lt;/p>
&lt;p>With &lt;strong>Flux&lt;/strong>, which you probably already use, the relationship is additive. Flux reconciles manifests towards the clusters, but it does not give you a platform abstraction API: it does not know what &amp;ldquo;a database&amp;rdquo; is, it only applies the YAML you hand it. Crossplane adds exactly that abstraction layer, and the two coexist, with Flux deploying the composite resources and Crossplane materialising them. If you already have Flux, Crossplane&amp;rsquo;s value is precisely that self-service API layer, in exchange for the cost of operating the control plane I described above.&lt;/p>
&lt;p>With &lt;strong>KubeVela and Score&lt;/strong>, the subject of the next article, the boundary is one of layer: Crossplane answers &amp;ldquo;give me a Postgres&amp;rdquo;, from the infrastructure side; KubeVela and Score answer &amp;ldquo;deploy my application&amp;rdquo;, from the workload side. They do not compete, they stack.&lt;/p>
&lt;h2 id="for-your-sovereign-inference-factory">For your sovereign inference factory&lt;/h2>
&lt;p>The practical decision boils down to this. Crossplane makes sense when you want to expose a self-service API of your own over your on-premise infrastructure and you are willing to operate a control plane to get it. If your platform is used by two teams who know each other and you provision with a tidy Flux repository, Crossplane is cost without return for now. The trigger is scale: when manual provisioning turns into a ticket bottleneck, the declarative API starts paying back its cost.&lt;/p>
&lt;p>Your sensible entry route is not rewriting everything. It is starting with a provider that already serves you (provider-kubernetes and provider-helm cover a lot), wrapping your existing Terraform with the corresponding provider instead of throwing it away, and modelling a single abstraction of clear value, probably the &lt;code>InferenceEndpoint&lt;/code> we discussed, as a golden path. From there, you grow on demand. And always with a clear account of having traded the safety net of &lt;code>plan&lt;/code> and a handful of scripts for a thermostat you have to keep switched on.&lt;/p>
&lt;p>The next article moves up a layer, from the infrastructure resource to the application workload, with &lt;a href="https://blog.lo0.es/en/posts/kubevela-score-platform-cli-without-teaching-kubernetes/">KubeVela and Score&lt;/a>, which is where the friendly CLI that Crossplane deliberately does not give you finally appears.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/public-cloud-to-private-what-you-lose-which-projects-fill-gap/">From the public cloud to the private one: the map&lt;/a> — where this control plane fits in the whole.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kubevela-score-platform-cli-without-teaching-kubernetes/">KubeVela and Score: self-service by CLI&lt;/a> — the application layer, the CLI Crossplane does not offer.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/backstage-self-service-portal-llm-platform/">Backstage as a self-service portal&lt;/a> — the interface built on top of the control plane.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">GitOps for the inference stack with Flux&lt;/a> — the engine Crossplane lives with, not competes with.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/">KServe and the open inference protocol&lt;/a> — what the inference golden path would materialise underneath.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>CNCF, &lt;em>Announces Graduation of Crossplane&lt;/em> (6-nov-2025) — &lt;a href="https://www.cncf.io/announcements/2025/11/06/cloud-native-computing-foundation-announces-graduation-of-crossplane/">https://www.cncf.io/announcements/2025/11/06/cloud-native-computing-foundation-announces-graduation-of-crossplane/&lt;/a>&lt;/li>
&lt;li>Crossplane Blog, &lt;em>Announcing Crossplane&amp;rsquo;s CNCF Graduation&lt;/em> — &lt;a href="https://blog.crossplane.io/crossplane-cncf-graduation/">https://blog.crossplane.io/crossplane-cncf-graduation/&lt;/a>&lt;/li>
&lt;li>Crossplane Blog, &lt;em>Announcing Crossplane 2.0&lt;/em> — &lt;a href="https://blog.crossplane.io/announcing-crossplane-2-0/">https://blog.crossplane.io/announcing-crossplane-2-0/&lt;/a>&lt;/li>
&lt;li>Crossplane Docs, &lt;em>What&amp;rsquo;s New in v2&lt;/em> — &lt;a href="https://docs.crossplane.io/latest/whats-new/">https://docs.crossplane.io/latest/whats-new/&lt;/a>&lt;/li>
&lt;li>Crossplane Docs, &lt;em>Compositions&lt;/em> — &lt;a href="https://docs.crossplane.io/latest/composition/compositions/">https://docs.crossplane.io/latest/composition/compositions/&lt;/a>&lt;/li>
&lt;li>Crossplane Docs, &lt;em>Release Cycle&lt;/em> — &lt;a href="https://docs.crossplane.io/latest/learn/release-cycle/">https://docs.crossplane.io/latest/learn/release-cycle/&lt;/a>&lt;/li>
&lt;li>Crossplane Docs, &lt;em>Crossplane CLI&lt;/em> — &lt;a href="https://docs.crossplane.io/latest/cli/">https://docs.crossplane.io/latest/cli/&lt;/a>&lt;/li>
&lt;li>Upbound, &lt;em>Crossplane Graduates From CNCF, Upbound Redefines AI-Native Infrastructure&lt;/em> — &lt;a href="https://www.upbound.io/blog/crossplane-graduates-from-cncf-upbound-redefines-ai-native-infrastructure">https://www.upbound.io/blog/crossplane-graduates-from-cncf-upbound-redefines-ai-native-infrastructure&lt;/a>&lt;/li>
&lt;li>InfoQ, &lt;em>Crossplane Reaches Production Maturity by Graduating CNCF&lt;/em> — &lt;a href="https://www.infoq.com/news/2025/11/crossplane-grad/">https://www.infoq.com/news/2025/11/crossplane-grad/&lt;/a>&lt;/li>
&lt;li>Pulumi Docs, &lt;em>Pulumi vs. Crossplane&lt;/em> — &lt;a href="https://www.pulumi.com/docs/iac/comparisons/crossplane/">https://www.pulumi.com/docs/iac/comparisons/crossplane/&lt;/a>&lt;/li>
&lt;li>Upbound Marketplace, &lt;em>provider-terraform&lt;/em> — &lt;a href="https://marketplace.upbound.io/providers/upbound/provider-terraform/latest">https://marketplace.upbound.io/providers/upbound/provider-terraform/latest&lt;/a>&lt;/li>
&lt;li>GitHub, &lt;em>provider-kubernetes&lt;/em> — &lt;a href="https://github.com/crossplane-contrib/provider-kubernetes">https://github.com/crossplane-contrib/provider-kubernetes&lt;/a>&lt;/li>
&lt;li>GitHub, &lt;em>provider-helm&lt;/em> — &lt;a href="https://github.com/crossplane-contrib/provider-helm">https://github.com/crossplane-contrib/provider-helm&lt;/a>&lt;/li>
&lt;li>GitHub, &lt;em>provider-keycloak&lt;/em> — &lt;a href="https://github.com/crossplane-contrib/provider-keycloak">https://github.com/crossplane-contrib/provider-keycloak&lt;/a>&lt;/li>
&lt;li>VSHN, &lt;em>provider-minio&lt;/em> — &lt;a href="https://github.com/vshn/provider-minio">https://github.com/vshn/provider-minio&lt;/a>&lt;/li>
&lt;li>Masterpoint, &lt;em>Crossplane: Why it Didn&amp;rsquo;t Work for Us&lt;/em> — &lt;a href="https://masterpoint.io/blog/passing-on-crossplane/">https://masterpoint.io/blog/passing-on-crossplane/&lt;/a>&lt;/li>
&lt;li>CECG, &lt;em>Crossplane: the good, the bad and the ugly&lt;/em> — &lt;a href="https://www.cecg.io/blog/crossplane-the-good-the-bad-the-ugly">https://www.cecg.io/blog/crossplane-the-good-the-bad-the-ugly&lt;/a>&lt;/li>
&lt;li>Taloflow, &lt;em>Backstage vs Crossplane for Platform Engineering&lt;/em> — &lt;a href="https://www.taloflow.ai/guides/comparisons/backstage-vs-crossplane-platform-engineering">https://www.taloflow.ai/guides/comparisons/backstage-vs-crossplane-platform-engineering&lt;/a>&lt;/li>
&lt;li>Crossplane Blog, &lt;em>Building Modelplane on Crossplane&lt;/em> — &lt;a href="https://blog.crossplane.io/building-modelplane/">https://blog.crossplane.io/building-modelplane/&lt;/a>&lt;/li>
&lt;li>Modelplane — &lt;a href="https://modelplane.ai">https://modelplane.ai&lt;/a> · &lt;a href="https://github.com/modelplaneai/modelplane">https://github.com/modelplaneai/modelplane&lt;/a>&lt;/li>
&lt;li>KServe Docs, &lt;em>Understanding LLMInferenceService&lt;/em> — &lt;a href="https://kserve.github.io/website/docs/model-serving/generative-inference/llmisvc/llmisvc-overview">https://kserve.github.io/website/docs/model-serving/generative-inference/llmisvc/llmisvc-overview&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Kubeflow in depth: which pieces are worth it in an on-premise LLM platform and which ones you already have</title><link>https://blog.lo0.es/en/posts/kubeflow-pieces-worth-it-on-premise-llm-platform/</link><pubDate>Mon, 31 Aug 2026 09:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/kubeflow-pieces-worth-it-on-premise-llm-platform/</guid><description>&lt;blockquote>
&lt;p>Second post in the platform and self-service run, after &lt;a href="https://blog.lo0.es/en/posts/backstage-self-service-portal-llm-platform/">Backstage&lt;/a> and before &lt;a href="https://blog.lo0.es/en/posts/knative-scale-to-zero-llm-inference/">Knative&lt;/a>. The question here is not what Kubeflow is, but which parts of Kubeflow still make sense once you have built half a platform on your own.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Kubeflow graduated in the CNCF on 17 August 2026, and that headline hides the important one: it is no longer a monolithic platform, it is an umbrella of independent subprojects, each with its own repository, its own version and its own life. That changes how you have to evaluate it entirely. The 2020 question, should I install Kubeflow?, has become the 2026 one, which piece of Kubeflow do I install, and at what cost?&lt;/p>
&lt;p>For a platform that already has RKE2, vLLM, &lt;a href="https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/">KServe&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">Flux&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/volcano-kueue-gang-scheduling-gpu-quotas-kubernetes/">Volcano or Kueue&lt;/a>, the honest answer is that Kubeflow as a whole &lt;strong>duplicates&lt;/strong> almost everything you already have. KServe no longer lives under Kubeflow: it left, and since November 2025 it has been a CNCF project in its own right, so anyone serving models with KServe does not need Kubeflow to serve. The Trainer leans on Volcano and Kueue, it does not replace them. And the full manifest drags in Istio, Dex and cert-manager as hard dependencies that clash with what you already operate. The only thing that may be worth installing on its own is Pipelines, as an orchestrator for data and training flows, and perhaps the Trainer for its fine-tuning templates. The rest you either already have, or do not need.&lt;/p>
&lt;h2 id="the-analogy-the-hypermarket-and-the-specialist-shop">The analogy: the hypermarket and the specialist shop&lt;/h2>
&lt;p>Kubeflow was born as a hypermarket: a single building with everything an ML team might need, from notebooks to the inference server, taking in pipelines and hyperparameter tuning along the way. It made sense in 2019, when assembling each piece separately was a project in itself and there were no standards.&lt;/p>
&lt;p>By 2026 the hypermarket has been converted into a street of specialist shops. The butcher&amp;rsquo;s counter, KServe, went independent, opened its own premises and is doing better alone. The fishmonger&amp;rsquo;s, the Trainer, now depends on the central market next door, Volcano and Kueue, instead of having its own cold store. And the shared building still demands that you put in its wiring, its plumbing and its security system (Istio, cert-manager, Dex) even though you already have your own.&lt;/p>
&lt;p>Anyone arriving today with a half-full fridge, which is the case for anyone with a platform already running, does not need the whole hypermarket. They need to know which shop to walk into for what they are missing, and to avoid buying twice what they already have at home.&lt;/p>
&lt;h2 id="what-kubeflow-is-in-2026">What Kubeflow is in 2026&lt;/h2>
&lt;p>Kubeflow was created by Google in 2017 and graduated in the CNCF on 17 August 2026, reaching the top level alongside Kubernetes or Prometheus. The graduation figures are serious: more than 6,600 contributors from over a thousand organisations, and adopters such as Bloomberg, NVIDIA, Red Hat or Spotify.&lt;/p>
&lt;p>The structural change is the one that matters for the decision. The full distribution, the &lt;em>Kubeflow Community Distribution&lt;/em>, is numbered by year and month (the current one is 26.03, from March 2026, on a six-monthly cadence), but &lt;strong>each component is by now a subproject with its own repository and its own cycle&lt;/strong>, usable independently. The distribution only packages them together. That means &amp;ldquo;installing Kubeflow&amp;rdquo; is no longer an atomic decision: it is a shopping list.&lt;/p>
&lt;h2 id="component-by-component">Component by component&lt;/h2>
&lt;h3 id="kubeflow-pipelines-the-piece-you-may-actually-want">Kubeflow Pipelines: the piece you may actually want&lt;/h3>
&lt;p>Pipelines orchestrates containerised ML workflows. They are written in Python with its SDK, which compiles to an intermediate YAML representation so they are portable, and underneath they run on &lt;strong>Argo Workflows&lt;/strong>. It is the piece most likely to add value to an existing platform: a flow of &amp;ldquo;ingest, embeddings, fine-tuning, evaluation, deployment&amp;rdquo; is exactly what you need to operate RAG and adapters repeatably.&lt;/p>
&lt;p>The small print has two parts. The first is that if you already use plain Argo Workflows, Pipelines adds a layer of SDK, interface and metadata &lt;strong>on top of the same engine&lt;/strong>: it does not bring a new scheduler, it brings the typed component model and the runs interface. The second is that its metadata and lineage system, MLMD, is &lt;strong>being removed&lt;/strong> from Pipelines itself, so it is not wise to build the platform&amp;rsquo;s traceability on that particular piece.&lt;/p>
&lt;h3 id="kubeflow-trainer-it-leans-on-your-scheduler-it-does-not-replace-it">Kubeflow Trainer: it leans on your scheduler, it does not replace it&lt;/h3>
&lt;p>The old Training Operator, the one with &lt;code>PyTorchJob&lt;/code> and &lt;code>TFJob&lt;/code>, has been rewritten as &lt;strong>Kubeflow Trainer&lt;/strong>, with a single &lt;code>TrainJob&lt;/code> API that unifies all frameworks. Version 2.2 positions itself explicitly for distributed training and &lt;strong>LLM fine-tuning&lt;/strong>, with support for PyTorch, DeepSpeed, HuggingFace and company.&lt;/p>
&lt;p>A common expectation needs correcting here. The Trainer &lt;strong>does not replace Volcano or Kueue&lt;/strong>: it leans on them. The &lt;code>TrainJob&lt;/code> carries a &lt;code>podGroupPolicy&lt;/code> field that automatically creates the Volcano &lt;code>PodGroup&lt;/code> objects for &lt;em>gang scheduling&lt;/em>, and it integrates with Kueue&amp;rsquo;s queues. If you already built that scheduler, as we saw in its post, the Trainer adds the &lt;code>TrainJob&lt;/code> abstraction and some fine-tuning templates on top of what you already have, not a new scheduling layer. It may be worth it for the templates; not for the scheduler.&lt;/p>
&lt;h3 id="katib-only-half-relevant">Katib: only half relevant&lt;/h3>
&lt;p>Katib does Kubernetes-native AutoML: hyperparameter optimisation with algorithms such as Bayesian optimisation, TPE or Hyperband, plus architecture search. In the LLM era it has repositioned itself, with official documentation for tuning fine-tuning hyperparameters and even for tuning RAG pipelines.&lt;/p>
&lt;p>The practical reading is lukewarm. Architecture search is irrelevant for anyone doing LoRA and RAG. Hyperparameter optimisation can be useful for learning-rate or LoRA-rank sweeps, but it competes with doing it from the SDK of the &lt;a href="https://blog.lo0.es/en/posts/qlora-runbook-dataset-to-served-adapter/">QLoRA runbook&lt;/a> itself or with Ray Tune, and it rarely justifies dragging in Katib for that alone.&lt;/p>
&lt;h3 id="kserve-the-piece-that-is-no-longer-in-kubeflow">KServe: the piece that is no longer in Kubeflow&lt;/h3>
&lt;p>This is the fact that settles half the evaluation. &lt;strong>KServe left Kubeflow.&lt;/strong> It was born inside the project in 2019, was donated to the Linux Foundation in 2022, was renamed from KFServing to KServe and graduated from Kubeflow that same year, and since November 2025 it has been a &lt;strong>CNCF incubating project in its own right&lt;/strong>. In the 2026 taxonomy it appears as an ecosystem project, external, not as a core component.&lt;/p>
&lt;p>The consequence is direct: &lt;strong>anyone already serving with KServe does not need Kubeflow to serve&lt;/strong>. And KServe is exactly where the LLM action is: its 0.15 release strengthened the vLLM backend and introduced an &lt;code>LLMInferenceService&lt;/code> resource with disaggregated serving, prefix caching, per-variant autoscaling and OpenAI-compatible APIs. If your serving layer is vLLM on KServe, you already have that layer complete and Kubeflow adds nothing to it.&lt;/p>
&lt;h3 id="model-registry-notebooks-and-the-rest">Model Registry, Notebooks and the rest&lt;/h3>
&lt;p>The &lt;strong>Model Registry&lt;/strong>, now reorganised under the name Kubeflow Hub, is an index of models, versions and metadata. It stores pointers and states, not the bytes. Against MLflow, it lacks the experiment and metric tracking that is MLflow&amp;rsquo;s strength, to the point that the guides recommend combining them rather than substituting one for the other; and against an OCI registry, it does not package the binary, which still lives wherever you put it. It is still a young component, below version 1.0.&lt;/p>
&lt;p>The &lt;strong>Notebooks&lt;/strong> live in two versions, the stable 1 and a 2 redesigned on CRDs that is still in alpha. The &lt;strong>Spark Operator&lt;/strong> remains in the core; &lt;strong>Feast&lt;/strong>, by contrast, has left the core and is now an ecosystem project, a nuance worth being clear about before counting on it.&lt;/p>
&lt;h3 id="multi-tenancy-powerful-and-tied-to-istio">Multi-tenancy: powerful and tied to Istio&lt;/h3>
&lt;p>Isolation between teams is done with &lt;strong>Profiles&lt;/strong>, a CRD that wraps a namespace and gives it RoleBindings, ServiceAccounts and Istio authorisation policies that validate an identity header derived from OIDC. It is a complete model, but it has an Achilles heel: &lt;strong>it depends entirely on the Istio sidecar and on that header&lt;/strong>. If traffic bypasses the mesh or someone forges the header, isolation breaks, and all multi-tenant security ends up coupled to Istio, which clashes head-on if you already operate another mesh or no &lt;em>service mesh&lt;/em> at all.&lt;/p>
&lt;h2 id="the-cost-of-installing-the-whole-thing">The cost of installing the whole thing&lt;/h2>
&lt;p>Here is the main reason not to install the full distribution on top of an existing platform. The 26.03 manifest drags in as &lt;strong>hard dependencies&lt;/strong>: Istio, cert-manager, Dex for OIDC, OAuth2-Proxy, and Knative Serving and Eventing for KServe. It recommends 16 GB of RAM and 8 vCPU as a minimum, and the aggregate of all components comes to around 4.4 CPU cores and 12 GB of memory for the control plane alone.&lt;/p>
&lt;p>The project itself acknowledges the problem. There is an open thread in its repository, bluntly titled &amp;ldquo;community feedback on Kubeflow&amp;rsquo;s complexity&amp;rdquo;, where it is admitted that Kubeflow practically forces a dedicated cluster because of the assumptions it makes about what is installed, that Istio and Dex should be swappable and not fixed dependencies, and that a lighter Helm that installs only what is needed is missing. For anyone already operating cert-manager, an ingress and GitOps with Flux, the full manifest does not coexist: it duplicates and tramples the base infrastructure.&lt;/p>
&lt;h2 id="kubeflow-against-the-alternatives">Kubeflow against the alternatives&lt;/h2>
&lt;p>The field is wide and almost all the comparative literature is written by vendors with a product of their own, so it has to be read with tweezers. With that caution:&lt;/p>
&lt;p>&lt;strong>Plain Argo Workflows&lt;/strong> is the engine Pipelines uses underneath; if all you need is container graphs, Pipelines is overhead. &lt;strong>MLflow&lt;/strong> is strong in experiment tracking and lightweight registry, installs in minutes, and does not orchestrate distributed training: it is complementary, and in fact we use it in the post on &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">prompt versioning&lt;/a>. &lt;strong>Flyte&lt;/strong> is a typed orchestrator that many perceive as lighter to operate than full Kubeflow. And &lt;strong>Ray on Kubernetes&lt;/strong> is the most serious competitor for LLM workloads, because with a single runtime it covers training, serving and tuning, overlapping at once with the Trainer, Katib and KServe.&lt;/p>
&lt;p>Where Kubeflow wins is in breadth and in governance: a CNCF-graduated umbrella with adoption by large companies. Where it loses is in operational weight, in hard dependencies, and in the fact that its best pieces are already independent projects that do not require the rest.&lt;/p>
&lt;h2 id="the-real-fit-with-llm-workloads">The real fit with LLM workloads&lt;/h2>
&lt;p>The project has moved towards GenAI, and that has to be acknowledged: there is an SDK with LLM fine-tuning templates, the Trainer adds support for reinforcement post-training methods, and new agent-oriented pieces have appeared. It is not empty marketing, there is real work there.&lt;/p>
&lt;p>But the question for a specific platform is not whether Kubeflow does LLM: it is which part I have not already duplicated. On a stack of RKE2, vLLM, KServe, Flux and Volcano or Kueue, the balance looks like this:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Does it add anything new to your stack?&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>KServe&lt;/td>
&lt;td>No: you already have it, and it is no longer Kubeflow&amp;rsquo;s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Trainer&lt;/td>
&lt;td>Only the fine-tuning templates; you already have the scheduler&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Katib&lt;/td>
&lt;td>Marginal: LoRA HP sweeps, little more&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pipelines&lt;/td>
&lt;td>Possibly yes, as a flow orchestrator, but it runs on Argo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Model Registry&lt;/td>
&lt;td>Optional: competes with MLflow and with your OCI registry&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Notebooks&lt;/td>
&lt;td>Depends on what you use for notebooks today&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-tenancy&lt;/td>
&lt;td>No, if you already isolate by namespace without tying everything to Istio&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="operational-traps-and-honest-scepticism">Operational traps and honest scepticism&lt;/h2>
&lt;p>&lt;strong>Do not install the full manifest on a platform that already works.&lt;/strong> You are going to duplicate Istio, cert-manager and the rest, and fight a dependency war with no prize at the end. If something in Kubeflow interests you, install it as a standalone subproject.&lt;/p>
&lt;p>&lt;strong>KServe is not Kubeflow.&lt;/strong> It is the most common category error of 2026. If someone justifies building all of Kubeflow &amp;ldquo;to serve models&amp;rdquo;, you already know they have not checked that KServe left long ago and lives on its own.&lt;/p>
&lt;p>&lt;strong>The Trainer does not save you the scheduler.&lt;/strong> It needs Volcano or Kueue underneath. If you expected it to solve GPU &lt;em>gang scheduling&lt;/em> by itself, it does not.&lt;/p>
&lt;p>&lt;strong>Multi-tenancy ties you to Istio.&lt;/strong> Before adopting Profiles, ask yourself whether you want security between teams to depend on one specific mesh. If you already isolate well by namespace and RBAC, you may not need that layer.&lt;/p>
&lt;p>&lt;strong>MLMD is on its way out.&lt;/strong> Do not build your pipelines&amp;rsquo; traceability on the metadata system the project itself is retiring.&lt;/p>
&lt;h2 id="for-an-inference-factory">For an inference factory&lt;/h2>
&lt;p>The conclusion is comfortable to apply. Kubeflow in 2026 is not a yes-or-no decision, it is a menu you order from à la carte. On a platform that already serves with KServe, schedules with Volcano and deploys with Flux, the menu shrinks a great deal.&lt;/p>
&lt;p>If you need to orchestrate data and training flows repeatably, and you do not want to build plain Argo Workflows with its ergonomics, &lt;strong>Pipelines&lt;/strong> is the piece worth evaluating, installed standalone. If you do distributed fine-tuning frequently and want a uniform abstraction on top of your scheduler, the &lt;strong>Trainer&lt;/strong> may save you some templates, knowing that it leans on the Volcano you already have. Everything else you either have, or a specialist piece covers better.&lt;/p>
&lt;p>The expensive mistake would be installing the whole hypermarket to buy bread. Kubeflow stopped being that; treat it as what it is, a street of shops, and walk only into the one you are missing. The next piece in this run, &lt;a href="https://blog.lo0.es/en/posts/knative-scale-to-zero-llm-inference/">Knative&lt;/a>, is precisely one of those specialist shops, the one that decides whether switching off the GPU when nobody is using it pays off or costs you dear.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/">KServe and the Open Inference Protocol&lt;/a> — the piece that left Kubeflow and holds up your serving.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/volcano-kueue-gang-scheduling-gpu-quotas-kubernetes/">Volcano and Kueue: gang scheduling and GPU quotas&lt;/a> — the scheduler the Trainer leans on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/qlora-runbook-dataset-to-served-adapter/">QLoRA runbook: from dataset to served adapter&lt;/a> — the fine-tuning that Pipelines and Trainer orchestrate.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/backstage-self-service-portal-llm-platform/">Backstage as a self-service portal&lt;/a> — the shop window that shows all this to the teams.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>CNCF, &lt;em>CNCF announces Kubeflow&amp;rsquo;s graduation&lt;/em> — &lt;a href="https://www.cncf.io/announcements/2026/08/17/cncf-announces-kubeflows-graduation-solidifying-the-standard-for-cloud-native-ai-operations/">https://www.cncf.io/announcements/2026/08/17/cncf-announces-kubeflows-graduation-solidifying-the-standard-for-cloud-native-ai-operations/&lt;/a>&lt;/li>
&lt;li>CNCF, &lt;em>Kubeflow unveils new cloud native innovations to supercharge AI&lt;/em> — &lt;a href="https://www.cncf.io/blog/2026/07/28/kubeflow-unveils-new-cloud-native-innovations-to-supercharge-ai/">https://www.cncf.io/blog/2026/07/28/kubeflow-unveils-new-cloud-native-innovations-to-supercharge-ai/&lt;/a>&lt;/li>
&lt;li>CNCF, &lt;em>KServe becomes a CNCF incubating project&lt;/em> — &lt;a href="https://www.cncf.io/blog/2025/11/11/kserve-becomes-a-cncf-incubating-project/">https://www.cncf.io/blog/2025/11/11/kserve-becomes-a-cncf-incubating-project/&lt;/a>&lt;/li>
&lt;li>Kubeflow Docs, &lt;em>Introduction / components&lt;/em> — &lt;a href="https://www.kubeflow.org/docs/started/introduction/">https://www.kubeflow.org/docs/started/introduction/&lt;/a>&lt;/li>
&lt;li>Kubeflow Docs, &lt;em>Pipelines overview&lt;/em> — &lt;a href="https://www.kubeflow.org/docs/components/pipelines/overview/">https://www.kubeflow.org/docs/components/pipelines/overview/&lt;/a>&lt;/li>
&lt;li>Kubeflow Trainer Docs, &lt;em>Volcano gang scheduling and Kueue&lt;/em> — &lt;a href="https://trainer.kubeflow.org/en/latest/operator-guides/job-scheduling/volcano.html">https://trainer.kubeflow.org/en/latest/operator-guides/job-scheduling/volcano.html&lt;/a>&lt;/li>
&lt;li>Kubeflow Docs, &lt;em>Migrating to Kubeflow Trainer v2&lt;/em> — &lt;a href="https://www.kubeflow.org/docs/components/trainer/operator-guides/migration/">https://www.kubeflow.org/docs/components/trainer/operator-guides/migration/&lt;/a>&lt;/li>
&lt;li>Kubeflow Docs, &lt;em>Katib overview&lt;/em> — &lt;a href="https://www.kubeflow.org/docs/components/katib/overview/">https://www.kubeflow.org/docs/components/katib/overview/&lt;/a>&lt;/li>
&lt;li>Kubeflow Docs, &lt;em>Hyperparameter optimization for LLM fine-tuning&lt;/em> — &lt;a href="https://www.kubeflow.org/docs/components/katib/user-guides/llm-hp-optimization/">https://www.kubeflow.org/docs/components/katib/user-guides/llm-hp-optimization/&lt;/a>&lt;/li>
&lt;li>Kubeflow Docs, &lt;em>Model Registry overview&lt;/em> — &lt;a href="https://www.kubeflow.org/docs/components/model-registry/overview/">https://www.kubeflow.org/docs/components/model-registry/overview/&lt;/a>&lt;/li>
&lt;li>Kubeflow Docs, &lt;em>Multi-tenancy design&lt;/em> — &lt;a href="https://www.kubeflow.org/docs/concepts/multi-tenancy/design/">https://www.kubeflow.org/docs/concepts/multi-tenancy/design/&lt;/a>&lt;/li>
&lt;li>GitHub, &lt;em>kubeflow/manifests (dependencias y recursos)&lt;/em> — &lt;a href="https://github.com/kubeflow/manifests">https://github.com/kubeflow/manifests&lt;/a>&lt;/li>
&lt;li>GitHub, &lt;em>kubeflow/manifests #2451: community feedback on complexity&lt;/em> — &lt;a href="https://github.com/kubeflow/manifests/issues/2451">https://github.com/kubeflow/manifests/issues/2451&lt;/a>&lt;/li>
&lt;li>InfoQ, &lt;em>Kubeflow expands AI capabilities as CNCF graduation nears&lt;/em> — &lt;a href="https://www.infoq.com/news/2026/08/kubeflow/">https://www.infoq.com/news/2026/08/kubeflow/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Backstage as a self-service portal: giving your teams the LLM platform without giving them the cluster</title><link>https://blog.lo0.es/en/posts/backstage-self-service-portal-llm-platform/</link><pubDate>Mon, 31 Aug 2026 09:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/backstage-self-service-portal-llm-platform/</guid><description>&lt;blockquote>
&lt;p>This article opens the platform and self-service layer: the one that decides how you hand everything underneath to the teams that are going to use it. The three articles in this batch are Backstage (the portal), &lt;a href="https://blog.lo0.es/en/posts/kubeflow-pieces-worth-it-on-premise-llm-platform/">Kubeflow&lt;/a> (the ML toolbox) and &lt;a href="https://blog.lo0.es/en/posts/knative-scale-to-zero-llm-inference/">Knative&lt;/a> (switching the GPU off when nobody is using it).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Backstage solves a real problem of a platform that has grown: nobody knows what is there, who maintains it, or how to ask for a new one without opening a ticket. Its answer is three pieces, a software &lt;strong>catalogue&lt;/strong> that models every component and its relations, &lt;strong>templates&lt;/strong> that create new resources through a form instead of through copy and paste, and &lt;strong>TechDocs&lt;/strong> to keep documentation next to the code. On top of those three sits the concept that holds up everything else, the &lt;strong>golden path&lt;/strong>: the supported route made also the easiest one, so that people take it because it is paved, not because they are forced onto it.&lt;/p>
&lt;p>The price is high and it should be said plainly. Backstage is not a product you install, it is a Node and React application that you compile and maintain as your own code, at a declared cost of two to three full-time engineers, and with upgrades as its biggest pain point. As of August 2026 it is still a CNCF &lt;strong>incubating&lt;/strong> project, not graduated, and it has no native entity type for models, datasets or inference endpoints: that gets modelled by hand, or you wait for a proposal that is still open. The decision is not about features, it is about scale: below a certain organisation size, a portal costs more than it saves.&lt;/p>
&lt;h2 id="the-analogy-the-industrial-estate-directory">The analogy: the industrial estate directory&lt;/h2>
&lt;p>A single unit needs no sign. You know what is inside because you built it. An estate with two hundred units, on the other hand, needs a directory at the entrance: which company sits on each plot, what it does, who is responsible, and an office where you can request a new plot with the utility connections already in place instead of negotiating each one separately.&lt;/p>
&lt;p>An LLM platform starts out as a single unit. One cluster, one model, the team that built it. Six months later it is an industrial estate: fifteen models served, three RAG pipelines, LoRA adapters everywhere, and a question that comes back in every meeting, who maintains this? Backstage is the directory for that estate, with two additions an ordinary directory does not have: the office that hands over turnkey plots, and the archive of blueprints for each unit. The catalogue is the directory, the templates are the office, TechDocs is the archive.&lt;/p>
&lt;p>The analogy also warns about the cost. The directory does not maintain itself. Somebody has to update it when a company moves, and a directory with half the plots mislabelled is worse than no directory at all, because people stop trusting it. In Backstage that failure has a name, &lt;em>catalog rot&lt;/em>, and it is the leading cause of a portal being abandoned.&lt;/p>
&lt;h2 id="what-backstage-is-and-what-state-it-reaches-2026-in">What Backstage is and what state it reaches 2026 in&lt;/h2>
&lt;p>Backstage was born inside Spotify and donated to the CNCF, which accepted it in September 2020 and moved it to incubating level in March 2022. That is where it still is: as of August 2026 it is &lt;strong>incubating, not graduated&lt;/strong>, a detail that matters when someone sells it as a de facto standard. It is by far the most widely adopted developer portal (the official 2025 review declares more than 3,400 organisations using it, 31,000 GitHub stars and more than 250 plugins), but adoption is not governance maturity, and a large share of those plugins is maintained by the community, not by the core.&lt;/p>
&lt;p>The architecture has kept changing pieces under the feet of whoever operates it. The &lt;strong>New Backend System&lt;/strong> stabilised in 2024 and is now the standard. The &lt;strong>New Frontend System&lt;/strong> became the default mode for new applications in version 1.49, of March 2026. The cadence is one minor release a month, and that rhythm is precisely the origin of the biggest operational problem, which we will see further down.&lt;/p>
&lt;h2 id="the-three-pieces">The three pieces&lt;/h2>
&lt;h3 id="the-software-catalogue">The software catalogue&lt;/h3>
&lt;p>The heart of Backstage is an entity catalogue. Every piece of the platform is described in a &lt;code>catalog-info.yaml&lt;/code> file that lives next to its code, and Backstage discovers it by crawling the repository. The entity types are fixed: &lt;strong>Component&lt;/strong> (something that runs or compiles), &lt;strong>API&lt;/strong> (a contract that a component exposes), &lt;strong>Resource&lt;/strong> (infrastructure that a component needs), &lt;strong>System&lt;/strong> (a set of components that form a unit), &lt;strong>Domain&lt;/strong> (a set of systems), plus &lt;strong>Group&lt;/strong> and &lt;strong>User&lt;/strong> for ownership.&lt;/p>
&lt;p>The power is in the &lt;strong>relations&lt;/strong> rather than in the entities, which Backstage computes and navigates: &lt;code>ownedBy&lt;/code> connects each thing to its team, &lt;code>providesApi&lt;/code> and &lt;code>consumesApi&lt;/code> draw who depends on whom, &lt;code>partOf&lt;/code> groups components into systems, &lt;code>dependsOn&lt;/code> traces the dependency graph. With that, the meeting question (who maintains this, and what breaks if we touch it?) stops being answered from memory and starts being answered by query.&lt;/p>
&lt;p>There is a design decision that conditions everything that follows: &lt;strong>Backstage advises against creating new entity types&lt;/strong>. Plugins are compiled expecting the usual types, and an invented type leaves them blind. The supported route is to reuse an existing type and distinguish it with the &lt;code>spec.type&lt;/code> field (for example a &lt;code>Resource&lt;/code> with &lt;code>type: llm-model&lt;/code>), adding whatever custom fields you need to the &lt;code>spec&lt;/code>.&lt;/p>
&lt;h3 id="the-templates">The templates&lt;/h3>
&lt;p>The second piece is the &lt;strong>Scaffolder&lt;/strong>, the template engine. A template is an entity of type &lt;code>Template&lt;/code> with three parts: the &lt;code>parameters&lt;/code> requested through a form, the &lt;code>steps&lt;/code> that get executed, and the &lt;code>actions&lt;/code> that do the real work (create a repository, write the &lt;code>catalog-info.yaml&lt;/code>, register the component, trigger the GitOps pipeline). The developer goes to &lt;code>/create&lt;/code>, picks a template, fills in the form, and in a single operation has the resource created, catalogued and deploying.&lt;/p>
&lt;p>This is what turns a directory into an office. Without templates, Backstage only lists what already exists. With templates, it is the door through which new things are created, and that door is where the golden path materialises.&lt;/p>
&lt;h3 id="techdocs">TechDocs&lt;/h3>
&lt;p>The third piece is &lt;strong>TechDocs&lt;/strong>, documentation as code. Documents are written in Markdown next to the code, generated with MkDocs in the CI pipeline, and served inside the portal itself, linked from each component&amp;rsquo;s page. The advantage is in the incentives: documentation that lives far from the code rots; documentation that lives next to it and gets reviewed in the same &lt;em>pull request&lt;/em> has some chance of staying true.&lt;/p>
&lt;h2 id="the-golden-path-which-is-what-all-of-this-is-about">The golden path, which is what all of this is about&lt;/h2>
&lt;p>Spotify coined the term &lt;em>golden path&lt;/em> for the recommended and supported way to build something. It has two halves: the blessed tooling, visible in the portal, and a step-by-step tutorial in the documentation. The idea, in the formulation that platform reports keep repeating, is that nobody forces you to use the paved road; people take it because it is paved.&lt;/p>
&lt;p>The data supports the golden path being the deciding variable. The 2026 &lt;em>State of Platform Engineering&lt;/em> report, covering 518 professionals, puts voluntary adoption above 80 % where there is a well-designed golden path and below 20 % where there is not, and estimates that around 70 % of platform initiatives fail to reach significant adoption without one. It is a figure that reorders priorities: a portal without golden paths is a pretty catalogue nobody uses.&lt;/p>
&lt;p>In Backstage, a golden path is implemented as a Scaffolder template. For an LLM platform, a typical golden path would be &amp;ldquo;serve a new model&amp;rdquo;: the form asks for the model, the GPU size, the owning team and the SLO; the template creates the repository with the &lt;code>InferenceService&lt;/code>, writes the &lt;code>catalog-info.yaml&lt;/code>, registers it under the right system, and opens the &lt;em>pull request&lt;/em> against the GitOps repository you already have with &lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">Flux&lt;/a>. What used to be half a day of copying a colleague&amp;rsquo;s YAML and guessing the values becomes a two-minute form with the correct defaults.&lt;/p>
&lt;h2 id="authentication-and-permissions">Authentication and permissions&lt;/h2>
&lt;p>For a sovereign platform the identity piece matters as much as the catalogue. Backstage ships connectors out of the box for GitHub, GitLab, Google, Microsoft, Okta, generic OIDC and SAML, among others. For on-premise the relevant one is &lt;strong>Keycloak&lt;/strong>, which fits in like any other OIDC provider, with one nuance worth keeping in mind: the Keycloak connector is maintained by the community, not by the core, so its maintenance and its tightness fall on a third-party plugin. Once you have set up Keycloak as we saw in &lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">the article on authenticating MCP&lt;/a>, the portal hooks into the same identity issuer.&lt;/p>
&lt;p>The permissions framework appears today as stable in the documentation and supports RBAC, ABAC and custom logic. The small print: by default the endpoints are not protected, and the permission policy is written by the integrator. The most widely used RBAC solution with a graphical interface comes with the Red Hat distribution, not with the core. In other words, serious access control means work on top of what comes in the box.&lt;/p>
&lt;h2 id="the-real-cost-no-frills">The real cost, no frills&lt;/h2>
&lt;p>This is where an honest article parts ways with the brochure. &lt;strong>Backstage is not an installable product, it is a framework that you compile and maintain as your own code.&lt;/strong> That has measurable consequences.&lt;/p>
&lt;p>The most quoted analysis, from the managed Backstage vendor Roadie, puts the effort at around three full-time engineers in the first year and two from the second onwards, with a time to production of six to twelve months in self-managed mode. The figure comes from a vendor with a commercial interest in you buying its managed version, so it should be taken as an order of magnitude and not as a neutral data point, but the direction is confirmed by independent sources: ongoing maintenance takes up around a fifth of engineering time, and &lt;strong>upgrades are the biggest pain point&lt;/strong>, cited by more than half of those who adopt it. With one minor release a month and architectural pieces shifting under your feet, keeping up to date is a permanent job.&lt;/p>
&lt;p>The other cost is about adoption, not engineering. A widely repeated critical analysis points out that many organisations settle at around 10 % internal usage, against the 99 % voluntary usage Spotify achieved at home, and attributes it to three recurring failures: the &lt;em>catalog rot&lt;/em> we already mentioned, the lack of a product owner treating the portal as a product, and the illusion that Backstage can be adopted over what already exists when in practice it only standardises new things well. The sentence that sums up the disenchantment is that Backstage is a free portal in the same way you would have a free car if somebody tipped all the parts of a Chevrolet onto your desk.&lt;/p>
&lt;h2 id="applied-to-an-llm-platform-what-is-there-and-what-is-not">Applied to an LLM platform: what is there and what is not&lt;/h2>
&lt;p>Here an expectation needs correcting. In 2026 &lt;strong>Backstage has no native entities for models, datasets or inference endpoints&lt;/strong>. There are three things and none of them is what you would hope for:&lt;/p>
&lt;p>An &lt;strong>open proposal&lt;/strong> (RFC 33060, from February 2026) to model model servers as entities of type &lt;code>API&lt;/code> with &lt;code>type: ai-model-server&lt;/code>. It is under discussion, not implemented.&lt;/p>
&lt;p>An official module that introduces an &lt;code>AiResource&lt;/code> type, which sounds like what we are after but &lt;strong>is for something else&lt;/strong>: governing the &lt;em>skills&lt;/em> and rules of coding assistants, not registering inference models.&lt;/p>
&lt;p>And one real pattern, the MLOps templates that AWS documents on top of SageMaker, where Backstage acts as the Scaffolder that creates environments and pipelines, not as the model registry. It is a cloud case, not on-premise, and it uses Backstage for its template half, not for its model catalogue.&lt;/p>
&lt;p>The practical conclusion for your own stack is twofold. First, what Backstage brings to an LLM platform today is above all the template half: the &amp;ldquo;create a new inference service&amp;rdquo; golden path wired into your GitOps. Second, if you want to catalogue models, today that is done by hand with custom &lt;code>spec.type&lt;/code> values, treating the model as a &lt;code>Resource&lt;/code> and its endpoint as an &lt;code>API&lt;/code>, until the proposal matures. Registering the model&amp;rsquo;s bytes remains the job of the &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/">OCI registry&lt;/a>, not of the catalogue.&lt;/p>
&lt;p>The infrastructure plugins that are worth it in this stack do exist and are reasonably alive: the Kubernetes one (core), the Flux one for seeing GitOps state from the portal, the Grafana one for embedding dashboards in a component&amp;rsquo;s page, and the ArgoCD one for anyone using Argo instead of Flux.&lt;/p>
&lt;h2 id="alternatives-when-not-to-set-up-backstage">Alternatives: when not to set up Backstage&lt;/h2>
&lt;p>The honest decision is not which portal, but whether a portal. The SaaS-type alternatives (Port, Cortex, OpsLevel, Humanitec) win on time to first value, measured in days instead of months, and on not having to maintain the framework, in exchange for a per-user fee that scales badly and for giving up pure on-premise. Roadie offers managed Backstage, which removes the framework maintenance while keeping its extensibility, in exchange for a per-developer fee and for taking your catalogue outside.&lt;/p>
&lt;p>For a sovereign platform, where on-premise and data ownership are requirements, pure SaaS is usually ruled out by design, which leaves the real choice between self-managed Backstage and no portal at all. And there the criterion is size. Below a handful of teams, a well-maintained &lt;code>README&lt;/code> and a tidy GitOps repository do the same job as the catalogue, without the cost of two engineers. The portal starts paying off when the cognitive load of knowing what is there and how to ask for it exceeds the cost of maintaining the tool that answers it.&lt;/p>
&lt;h2 id="operational-traps-and-honest-scepticism">Operational traps and honest scepticism&lt;/h2>
&lt;p>&lt;strong>The catalogue rots if it is not automated.&lt;/strong> A &lt;code>catalog-info.yaml&lt;/code> written by hand and never updated is lying within a few weeks. The only defence is generating and validating those files in the pipeline, not trusting people to maintain them.&lt;/p>
&lt;p>&lt;strong>Without a product owner, the portal dies.&lt;/strong> Backstage is not infrastructure you install and forget, it is an internal product with users you have to understand. Without somebody treating adoption as an objective, it ends up as a catalogue nobody looks at.&lt;/p>
&lt;p>&lt;strong>Upgrades are a recurring tax.&lt;/strong> One release a month and architectural pieces mid-migration mean the portal is never finished. That maintenance has to be budgeted from the start, not discovered in the second quarter.&lt;/p>
&lt;p>&lt;strong>Backstage deploys nothing.&lt;/strong> It is a portal, not an engine. Behind each template there has to be your GitOps, your operators and your cluster doing the real work. If that base is not solid, the portal just puts a pretty façade over chaos.&lt;/p>
&lt;p>&lt;strong>The data model for AI does not exist yet.&lt;/strong> Anyone setting up Backstage expecting a ready-to-use model catalogue is going to find themselves modelling by hand. It is doable, but it is work, and you should know that beforehand.&lt;/p>
&lt;h2 id="for-an-inference-factory">For an inference factory&lt;/h2>
&lt;p>Backstage fits at the high maturity level of a platform, not at the start. In the &lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">five maturity levels&lt;/a> it is a tool of the level where the platform already serves several teams and the bottleneck has stopped being technical and become one of coordination. Three concrete decisions follow from all of the above.&lt;/p>
&lt;p>The first is not setting it up too early. If the platform is used by two teams who know each other, the portal is cost without return. The trigger is cognitive load, not the calendar.&lt;/p>
&lt;p>The second is starting with the templates, not with the catalogue. The value you notice from day one is the golden path that turns half a day of copying YAML into a two-minute form. The complete catalogue, with all its relations, is a bigger job that can come later.&lt;/p>
&lt;p>The third is budgeting maintenance as a function, not as a project. Two part-time engineers indefinitely, an owner watching adoption, and automatic generation of the catalogue entries. Without that, the portal degrades until somebody asks why nobody uses it, and the answer is that it stopped being true.&lt;/p>
&lt;p>The portal is the face your platform shows to whoever uses it. It is worth it when there is enough platform behind it that showing it tidily saves more than maintaining the shop window costs. Before that, it is the shop window of a shop that has no stock yet. The other two articles in this batch go into that stock: the ML toolbox with &lt;a href="https://blog.lo0.es/en/posts/kubeflow-pieces-worth-it-on-premise-llm-platform/">Kubeflow&lt;/a> and the savings of switching the GPU off with &lt;a href="https://blog.lo0.es/en/posts/knative-scale-to-zero-llm-inference/">Knative&lt;/a>.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">GitOps for the inference stack with Flux&lt;/a> — the engine behind every portal template.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">The GPU cluster as a multi-tenant platform&lt;/a> — the base the portal presents to the teams.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels of the platform&lt;/a> — at which level a portal starts to pay off.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/">Model registry and distribution with OCI and ORAS&lt;/a> — where the bytes the catalogue only references live.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>CNCF, &lt;em>Backstage (project page)&lt;/em> — &lt;a href="https://www.cncf.io/projects/backstage/">https://www.cncf.io/projects/backstage/&lt;/a>&lt;/li>
&lt;li>Backstage, &lt;em>Backstage Wrapped 2025&lt;/em> — &lt;a href="https://backstage.io/blog/2025/12/30/backstage-wrapped-2025/">https://backstage.io/blog/2025/12/30/backstage-wrapped-2025/&lt;/a>&lt;/li>
&lt;li>Backstage Docs, &lt;em>Software Catalog: descriptor format&lt;/em> — &lt;a href="https://backstage.io/docs/features/software-catalog/descriptor-format/">https://backstage.io/docs/features/software-catalog/descriptor-format/&lt;/a>&lt;/li>
&lt;li>Backstage Docs, &lt;em>Well-known relations&lt;/em> — &lt;a href="https://backstage.io/docs/features/software-catalog/well-known-relations/">https://backstage.io/docs/features/software-catalog/well-known-relations/&lt;/a>&lt;/li>
&lt;li>Backstage Docs, &lt;em>Extending the model&lt;/em> — &lt;a href="https://backstage.io/docs/features/software-catalog/extending-the-model/">https://backstage.io/docs/features/software-catalog/extending-the-model/&lt;/a>&lt;/li>
&lt;li>Backstage Docs, &lt;em>Software Templates&lt;/em> — &lt;a href="https://backstage.io/docs/features/software-templates/">https://backstage.io/docs/features/software-templates/&lt;/a>&lt;/li>
&lt;li>Backstage Docs, &lt;em>TechDocs&lt;/em> — &lt;a href="https://backstage.io/docs/features/techdocs/">https://backstage.io/docs/features/techdocs/&lt;/a>&lt;/li>
&lt;li>Backstage Docs, &lt;em>Authentication&lt;/em> — &lt;a href="https://backstage.io/docs/auth/">https://backstage.io/docs/auth/&lt;/a>&lt;/li>
&lt;li>Backstage Docs, &lt;em>Permissions overview&lt;/em> — &lt;a href="https://backstage.io/docs/permissions/overview/">https://backstage.io/docs/permissions/overview/&lt;/a>&lt;/li>
&lt;li>Backstage GitHub, &lt;em>RFC #33060: modeling AI model servers in the catalog&lt;/em> — &lt;a href="https://github.com/backstage/backstage/issues/33060">https://github.com/backstage/backstage/issues/33060&lt;/a>&lt;/li>
&lt;li>Roadie, &lt;em>Backstage: how much does it really cost?&lt;/em> — &lt;a href="https://roadie.io/blog/backstage-how-much-does-it-really-cost/">https://roadie.io/blog/backstage-how-much-does-it-really-cost/&lt;/a>&lt;/li>
&lt;li>Earthly, &lt;em>Backstage is at the peak of its hype&lt;/em> — &lt;a href="https://earthly.dev/blog/backstage-is-at-peak-hype/">https://earthly.dev/blog/backstage-is-at-peak-hype/&lt;/a>&lt;/li>
&lt;li>AWS Prescriptive Guidance, &lt;em>Accelerate MLOps with Backstage and SageMaker templates&lt;/em> — &lt;a href="https://docs.aws.amazon.com/prescriptive-guidance/latest/patterns/accelerate-mlops-with-backstage-and-sagemaker-templates.html">https://docs.aws.amazon.com/prescriptive-guidance/latest/patterns/accelerate-mlops-with-backstage-and-sagemaker-templates.html&lt;/a>&lt;/li>
&lt;li>Spotify Engineering, &lt;em>How we use golden paths to solve fragmentation&lt;/em> — &lt;a href="https://engineering.atspotify.com/2020/08/how-we-use-golden-paths-to-solve-fragmentation-in-our-software-ecosystem">https://engineering.atspotify.com/2020/08/how-we-use-golden-paths-to-solve-fragmentation-in-our-software-ecosystem&lt;/a>&lt;/li>
&lt;li>The New Stack, &lt;em>DORA 2024: AI and platform engineering fall short&lt;/em> — &lt;a href="https://thenewstack.io/dora-2024-ai-and-platform-engineering-fall-short/">https://thenewstack.io/dora-2024-ai-and-platform-engineering-fall-short/&lt;/a>&lt;/li>
&lt;li>bex.co, &lt;em>State of Platform Engineering Report Vol. 4: golden path&lt;/em> — &lt;a href="https://bex.co/blog/2026/08/06/platform-engineering-report-vol-4-golden-path">https://bex.co/blog/2026/08/06/platform-engineering-report-vol-4-golden-path&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Model chain of trust (1/4): KServe and the Open Inference Protocol, the control plane your serving is missing</title><link>https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/</link><pubDate>Sun, 26 Jul 2026 20:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/</guid><description>&lt;p>This blog has devoted a good part of its 145 articles to what happens &lt;em>inside&lt;/em> the inference process and another good part to what happens around it. What is missing is a thread that cuts across all of that and answers a question that takes thirty seconds to ask in an audit and does not get answered in three weeks in production: when the endpoint returns a token, where did that model come from, who signed it, and who proves that the binary running on the GPU is the one that was approved?&lt;/p>
&lt;p>This series walks that chain backwards from the only visible point, the endpoint: the control plane and the API contract (this article), the registry and the distribution of the bytes (2/4), the signing and provenance that let you trust them (3/4), and the identity and isolation of whoever runs them (4/4). All of it with projects under the governance of CNCF, LF AI &amp;amp; Data or the Linux Foundation: a chain of trust that depends on a proprietary product is no better than a support contract.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>A vLLM &lt;code>Deployment&lt;/code> serves tokens perfectly well. What it does not give you is a contract: no model versioning, no rollout by percentage, no scale-to-zero, and no common surface for the thirty non-LLM artifacts of an inference factory.&lt;/li>
&lt;li>&lt;strong>KServe&lt;/strong> provides the vocabulary: &lt;code>InferenceService&lt;/code>, &lt;code>ServingRuntime&lt;/code>/&lt;code>ClusterServingRuntime&lt;/code>, storage initializer and &lt;code>storageUri&lt;/code>. It has been CNCF incubating since 29 September 2025, after seven years as KFServing and a spell in LF AI &amp;amp; Data. The cadence has accelerated: v0.18.0 on 29 April 2026 and v0.19.0 on 14 June 2026.&lt;/li>
&lt;li>The LLM branch is recent and different: the &lt;code>LLMInferenceService&lt;/code> CRD (&lt;code>serving.kserve.io/v1alpha1&lt;/code>) arrived in v0.16 in November 2025, on top of llm-d, with &lt;code>spec.prefill&lt;/code>, &lt;code>spec.worker&lt;/code>, &lt;code>spec.parallelism&lt;/code> and &lt;code>spec.router&lt;/code>.&lt;/li>
&lt;li>The &lt;strong>Open Inference Protocol (V2)&lt;/strong> is the contract: &lt;code>/v2/health/*&lt;/code>, &lt;code>/v2/models/&amp;lt;m&amp;gt;&lt;/code>, &lt;code>/v2/models/&amp;lt;m&amp;gt;/infer&lt;/code>, in REST and gRPC, implemented by Triton, OpenVINO Model Server, Seldon MLServer and AMD Inference Server. It is tensor-centric, and the LLM world standardised de facto on the OpenAI API: KServe serves both, with the OpenAI ones under the &lt;code>openai/&lt;/code> prefix.&lt;/li>
&lt;li>The honest position: the portability of an LLM today comes from the OpenAI API, not from V2. Accepting the dual track is correct; pretending there is a single standard is not.&lt;/li>
&lt;li>KServe is overkill if you serve one model on one node. It starts to pay off from the fifth heterogeneous artifact onwards, and it competes on a different axis from the LLM operators: contract and lifecycle versus engine and topology.&lt;/li>
&lt;/ul>
&lt;h2 id="the-analogy-the-station-master-and-the-track-gauge">The analogy: the station master and the track gauge&lt;/h2>
&lt;p>An inference factory is like a railway station with several lines.&lt;/p>
&lt;p>The &lt;strong>station master&lt;/strong> does not drive any train. His job is the service timetable: which formation leaves from which platform, how many units are coupled each hour, when an engine is pulled out for maintenance and, when new rolling stock arrives, how to divert ten per cent of the passengers to the test train before committing the whole service. That is the control plane. KServe does not generate a single token, but it decides what runs, with what configuration, with how many replicas and with what traffic split. The trains are the engines (vLLM, SGLang, TensorRT-LLM, Triton, TEI), and a competent station master does not marry a manufacturer.&lt;/p>
&lt;p>And then there is the &lt;strong>track gauge&lt;/strong>: as long as everybody builds to the same one, any locomotive rolls on any line and changing supplier is a commercial decision, not a civil engineering project. That gauge is the Open Inference Protocol, the contract that makes a client written against Triton work against OpenVINO Model Server without touching a line.&lt;/p>
&lt;p>The analogy has a twist that needs no explanation in Spain: the agreed gauge is not the gauge the new rolling stock runs on. The conventional network was built to Iberian gauge and the high-speed one to standard gauge, and the practical solution was not to convert the network but to install gauge changers at the boundary points. The same thing has happened in inference: V2 is the agreed gauge and the OpenAI API is the gauge everything generative runs on. What sustains interoperability is the changer, not a single standard. We will come back to it.&lt;/p>
&lt;h2 id="a-deployment-serves-tokens-but-offers-no-contract">A &lt;code>Deployment&lt;/code> serves tokens, but offers no contract&lt;/h2>
&lt;p>In &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a> we built a service with &lt;code>Deployment&lt;/code>, &lt;code>Service&lt;/code>, &lt;code>HPA&lt;/code> and an &lt;code>initContainer&lt;/code> that pulls the weights down. It works, and for one model on one node it is the right answer. What it does not give you, and cannot give you without reinventing it every time:&lt;/p>
&lt;p>&lt;strong>A stable API contract.&lt;/strong> The endpoint is &amp;ldquo;whatever the image you put there exposes&amp;rdquo;: moving from vLLM to TensorRT-LLM changes routes, error format and metric names, and the clients find out in production.&lt;/p>
&lt;p>&lt;strong>Versioning with semantics.&lt;/strong> The model is a path inside a volume; knowing which version a pod is serving requires &lt;code>kubectl exec&lt;/code>. There is no declarative field that says &amp;ldquo;this service serves artifact X&amp;rdquo; and can be audited from outside.&lt;/p>
&lt;p>&lt;strong>Progressive model rollout.&lt;/strong> A &lt;code>RollingUpdate&lt;/code> replaces pods; it does not split traffic between two versions by percentage. For that you need an L7 gateway in front, like the one in &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">canary, blue-green and shadow&lt;/a>, or a control plane that emits it.&lt;/p>
&lt;p>&lt;strong>Scale-to-zero.&lt;/strong> The &lt;code>HPA&lt;/code> does not go below one replica: for a model queried twelve times a day, that is an 80 GB GPU tied up twenty-four hours.&lt;/p>
&lt;p>&lt;strong>And, above all, a common surface for what is not an LLM.&lt;/strong> A real factory does not serve three LLMs: it serves two or three LLMs, four embedding models, a couple of rerankers, ticket classifiers, a PII detector, an OCR, industrial vision and three scikit-learn artifacts that nobody remembers training. Fifteen or thirty artifacts, each with its own runtime and its own way of saying &amp;ldquo;I am ready&amp;rdquo;. The cost is not in serving the first one: it is in making sure the thirtieth does not cost thirty times what the first one did.&lt;/p>
&lt;h2 id="kserve-from-kfserving-to-a-cncf-incubating-project">KServe: from KFServing to a CNCF incubating project&lt;/h2>
&lt;p>KServe was born in 2019 as &lt;strong>KFServing&lt;/strong>, inside Kubeflow, in a collaboration between Google, IBM, Bloomberg, NVIDIA and Seldon. In February 2022 it was donated to the LF AI &amp;amp; Data Foundation; in September 2022 it split from Kubeflow and was renamed. The relevant jump: on 29 September 2025 the CNCF accepted it directly at incubating level, without going through sandbox, with a public announcement on 11 November. The figures it declared then, more than 300 contributors, 19 maintainers, more than 30 adopting organisations (Bloomberg, Red Hat, Cloudera, Nutanix, SAP, NVIDIA), come from an interested party, but the list is verifiable and the 625 contributing organisations on the CNCF dashboard support the thesis of governance not captured by a vendor.&lt;/p>
&lt;p>On cadence you have to be precise, because the cliché of &amp;ldquo;KServe is slow&amp;rdquo; no longer describes reality:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Version&lt;/th>
&lt;th>Date&lt;/th>
&lt;th>What it brought&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>v0.15.0&lt;/td>
&lt;td>31 March 2025&lt;/td>
&lt;td>&lt;code>LocalModelCache&lt;/code>, multi-node inference, OpenAI-compatible embeddings API, Gateway API in raw mode, KEDA integration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>v0.16.0&lt;/td>
&lt;td>November 2025&lt;/td>
&lt;td>&lt;strong>&lt;code>LLMInferenceService&lt;/code>&lt;/strong>; rename of &lt;code>RawDeployment&lt;/code> to &lt;code>Standard&lt;/code> and of &lt;code>Serverless&lt;/code> to &lt;code>KNative&lt;/code>; progressive rollout in raw mode&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>v0.18.0&lt;/td>
&lt;td>29 April 2026&lt;/td>
&lt;td>WVA + KEDA/HPA autoscaling for LLMISvc, &lt;code>/v1/responses&lt;/code> route (OpenAI Responses API), &lt;code>restricted&lt;/code> Pod Security Standards by default on LLMISvc, namespace-scoped &lt;code>ModelCache&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>v0.19.0&lt;/td>
&lt;td>14 June 2026&lt;/td>
&lt;td>Routing by model name, static LoRA adapters, balancing across heterogeneous GPUs, dual REST/gRPC routing in Standard mode, automatic migration to &lt;code>InferencePool&lt;/code> v1&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two major releases in under two months, with the bulk of the changelog in the LLM branch. The project is not slow: it has pivoted from classic predictive serving to generative serving. The consequences run in both directions.&lt;/p>
&lt;p>The negative one is called &lt;strong>ModelMesh&lt;/strong>. It was the high-density mode (hundreds of small models sharing processes, with dynamic load and unload) and it was, on paper, the natural answer to the problem of thirty heterogeneous artifacts. Its last release was v0.12.0 on 9 July 2023, and the &lt;code>kserve/modelmesh-serving&lt;/code> repository was archived read-only on 14 April 2026. Red Hat published specific guidance for migrating to Standard mode. If somebody proposes ModelMesh in a design today, it is a sign that the documentation they read is three years old.&lt;/p>
&lt;h2 id="anatomy-of-the-control-plane">Anatomy of the control plane&lt;/h2>
&lt;h3 id="inferenceservice-predictor-transformer-explainer">&lt;code>InferenceService&lt;/code>: predictor, transformer, explainer&lt;/h3>
&lt;p>The core CRD is &lt;code>InferenceService&lt;/code> (&lt;code>serving.kserve.io/v1beta1&lt;/code>), and its contribution, the one that became the common vocabulary of the field, is to break an inference service down into three components each with its own contract:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>predictor&lt;/code>&lt;/strong>: mandatory. It declares &lt;code>modelFormat&lt;/code>, optionally &lt;code>runtime&lt;/code>, the &lt;code>storageUri&lt;/code> the weights come from and the &lt;code>protocolVersion&lt;/code> it exposes.&lt;/li>
&lt;li>&lt;strong>&lt;code>transformer&lt;/code>&lt;/strong>: optional. Pre- and post-processing. That it is a separate component, rather than code inside the server, is what allows it to be scaled separately (it is usually CPU-bound while the predictor is GPU-bound) and versioned separately.&lt;/li>
&lt;li>&lt;strong>&lt;code>explainer&lt;/code>&lt;/strong>: optional, and the one that has aged worst: the V2 protocol does not support the &lt;code>explain&lt;/code> endpoint, which only exists in V1. Anyone who needs explainability with a standard contract has a real gap here.&lt;/li>
&lt;/ul>
&lt;p>All three share &lt;code>ComponentExtensionSpec&lt;/code>: &lt;code>minReplicas&lt;/code>, &lt;code>maxReplicas&lt;/code>, &lt;code>scaleTarget&lt;/code>, &lt;code>scaleMetric&lt;/code>, &lt;code>containerConcurrency&lt;/code>, &lt;code>timeout&lt;/code> and &lt;code>canaryTrafficPercent&lt;/code>.&lt;/p>
&lt;h3 id="servingruntime-and-clusterservingruntime-the-catalogue-of-trains">&lt;code>ServingRuntime&lt;/code> and &lt;code>ClusterServingRuntime&lt;/code>: the catalogue of trains&lt;/h3>
&lt;p>A &lt;code>ServingRuntime&lt;/code> is a pod template that knows how to serve one or several formats. It declares &lt;code>supportedModelFormats&lt;/code> (with &lt;code>name&lt;/code>, &lt;code>version&lt;/code>, &lt;code>autoSelect&lt;/code> and &lt;code>priority&lt;/code>), &lt;code>protocolVersions&lt;/code> (&lt;code>v1&lt;/code>, &lt;code>v2&lt;/code>) and &lt;code>containers&lt;/code>, with &lt;code>args&lt;/code> parameterised by templates in the &lt;code>{{.Name}}&lt;/code> style that are substituted with metadata from the &lt;code>InferenceService&lt;/code>.&lt;/p>
&lt;p>Selection is what makes this scale. If the &lt;code>InferenceService&lt;/code> names a &lt;code>runtime&lt;/code>, the controller looks for it first in the namespace and then at cluster level; if it does not name one, it picks among those with &lt;code>autoSelect: true&lt;/code> for that &lt;code>modelFormat&lt;/code>, breaking ties by &lt;code>priority&lt;/code>. That is what lets a platform publish a curated catalogue (&amp;ldquo;here, &lt;code>huggingface&lt;/code> is served with this vLLM and these flags&amp;rdquo;) without the product teams having to know what &lt;code>--gpu-memory-utilization&lt;/code> is. In multi-tenant setups, the &lt;code>ClusterServingRuntime&lt;/code>s belong to the platform and the &lt;code>InferenceService&lt;/code>s to the teams: the cleanest separation-of-duties line KServe offers.&lt;/p>
&lt;h3 id="the-storage-initializer-and-storageuri">The storage initializer and &lt;code>storageUri&lt;/code>&lt;/h3>
&lt;p>The &lt;code>storageUri&lt;/code> field triggers the &lt;strong>storage initializer&lt;/strong>: an init container that downloads the artifact to &lt;code>/mnt/models&lt;/code> before the server starts. Schemes supported as of July 2026: &lt;code>s3://&lt;/code>, &lt;code>gs://&lt;/code>, &lt;code>https://&lt;/code>, &lt;code>pvc://&lt;/code>, &lt;code>hf://&lt;/code>, &lt;code>oci://&lt;/code> and git repositories; credentials are attached via a &lt;code>ServiceAccount&lt;/code> with annotated secrets. That &lt;code>oci://&lt;/code> connects with the second article of the series: the weights can live in the same registry as the images, with the same access control and the same signing capability.&lt;/p>
&lt;h3 id="the-deployment-modes">The deployment modes&lt;/h3>
&lt;p>This is where nearly everything operational is decided. It is selected with the &lt;code>serving.kserve.io/deploymentMode&lt;/code> annotation.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>&lt;strong>KNative&lt;/strong> (formerly Serverless)&lt;/th>
&lt;th>&lt;strong>Standard&lt;/strong> (formerly RawDeployment)&lt;/th>
&lt;th>&lt;strong>ModelMesh&lt;/strong>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Resources emitted&lt;/td>
&lt;td>Knative &lt;code>Service&lt;/code> + &lt;code>Revision&lt;/code>, &lt;code>VirtualService&lt;/code> with Istio&lt;/td>
&lt;td>&lt;code>Deployment&lt;/code>, &lt;code>Service&lt;/code>, &lt;code>HPA&lt;/code>, Gateway API &lt;code>HTTPRoute&lt;/code>&lt;/td>
&lt;td>Its own high-density runtime&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dependencies&lt;/td>
&lt;td>Knative Serving + Istio (or an alternative gateway)&lt;/td>
&lt;td>Kubernetes 1.32+, cert-manager 1.15.0+, Gateway API v1.2.1 and a controller&lt;/td>
&lt;td>Its own components&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Autoscaling&lt;/td>
&lt;td>KPA (concurrency / RPS)&lt;/td>
&lt;td>HPA or &lt;strong>KEDA&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Scale-to-zero&lt;/td>
&lt;td>Yes, with an activator that holds the request&lt;/td>
&lt;td>&lt;strong>No for HTTP requests&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>canaryTrafficPercent&lt;/code>&lt;/td>
&lt;td>Yes, by revision weights in Istio&lt;/td>
&lt;td>&lt;strong>Silently ignored&lt;/strong> (issue #5335, open since 2 April 2026)&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sidecars in the pod&lt;/td>
&lt;td>&lt;code>queue-proxy&lt;/code> (+ &lt;code>istio-proxy&lt;/code>)&lt;/td>
&lt;td>None&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Status as of July 2026&lt;/td>
&lt;td>Current&lt;/td>
&lt;td>Current and &lt;strong>the default mode of the LLM branch&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Archived on 14 April 2026&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>That table has an uncomfortable reading: the two most quoted KServe capabilities, scale-to-zero and canary by percentage, live in the mode that drags Knative and Istio along, while the mode the project has pivoted towards is the other one.&lt;/p>
&lt;div class="diagram" style="max-width:860px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 860 386" role="img" aria-label="A KServe InferenceService materialises into different resources depending on the deployment mode: KNative with scale-to-zero and canary, or Standard with Deployment, HPA or KEDA and Gateway API" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.4}.dsh{fill:none;stroke:currentColor;stroke-width:1.4;stroke-dasharray:5 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.4;marker-end:url(#kv)}&lt;/style>
&lt;defs>&lt;marker id="kv" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="430" y="22" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">What you declare versus what the controller emits&lt;/text>
&lt;rect class="bx" x="20" y="46" width="190" height="120" rx="6"/>
&lt;text x="115" y="68" text-anchor="middle" class="tl">InferenceService&lt;/text>
&lt;text x="115" y="88" text-anchor="middle" class="ts">predictor + transformer&lt;/text>
&lt;text x="115" y="105" text-anchor="middle" class="ts">storageUri + runtime&lt;/text>
&lt;text x="115" y="122" text-anchor="middle" class="ts">min/maxReplicas&lt;/text>
&lt;text x="115" y="139" text-anchor="middle" class="ts">canaryTrafficPercent&lt;/text>
&lt;text x="115" y="158" text-anchor="middle" class="ts">protocolVersion: v2&lt;/text>
&lt;rect class="dsh" x="20" y="186" width="190" height="72" rx="6"/>
&lt;text x="115" y="208" text-anchor="middle" class="tl">ClusterServingRuntime&lt;/text>
&lt;text x="115" y="228" text-anchor="middle" class="ts">image, args, probes&lt;/text>
&lt;text x="115" y="245" text-anchor="middle" class="ts">supportedModelFormats&lt;/text>
&lt;path class="ar" d="M210,150 L262,150"/>
&lt;path class="ar" d="M210,222 L262,182"/>
&lt;rect class="bx" x="262" y="120" width="150" height="90" rx="6"/>
&lt;text x="337" y="146" text-anchor="middle" class="tl">Controller&lt;/text>
&lt;text x="337" y="166" text-anchor="middle" class="ts">reconciles and decides&lt;/text>
&lt;text x="337" y="186" text-anchor="middle" class="ts">based on deploymentMode&lt;/text>
&lt;path class="ar" d="M412,150 L466,110"/>
&lt;path class="ar" d="M412,178 L466,246"/>
&lt;rect class="bx" x="466" y="52" width="374" height="112" rx="6"/>
&lt;text x="653" y="74" text-anchor="middle" class="tl">KNative mode&lt;/text>
&lt;text x="653" y="94" text-anchor="middle" class="ts">Knative Service + Revision, VirtualService (Istio)&lt;/text>
&lt;text x="653" y="112" text-anchor="middle" class="ts">KPA, activator, real scale-to-zero&lt;/text>
&lt;text x="653" y="130" text-anchor="middle" class="ts">canaryTrafficPercent by revision weights&lt;/text>
&lt;text x="653" y="152" text-anchor="middle" class="ts">cost: queue-proxy and istio-proxy in every pod&lt;/text>
&lt;rect class="bx" x="466" y="194" width="374" height="112" rx="6"/>
&lt;text x="653" y="216" text-anchor="middle" class="tl">Standard mode&lt;/text>
&lt;text x="653" y="236" text-anchor="middle" class="ts">Deployment + Service + HPA or KEDA&lt;/text>
&lt;text x="653" y="254" text-anchor="middle" class="ts">Gateway API v1.2.1 HTTPRoute&lt;/text>
&lt;text x="653" y="272" text-anchor="middle" class="ts">no scale-from-zero over HTTP&lt;/text>
&lt;text x="653" y="294" text-anchor="middle" class="ts">canaryTrafficPercent silently ignored&lt;/text>
&lt;text x="20" y="340" class="ts">The storage initializer downloads the artifact from storageUri to /mnt/models before starting the server.&lt;/text>
&lt;text x="20" y="360" class="ts">One platform ClusterServingRuntime serves N team InferenceServices: that is where the separation of duties lives.&lt;/text>
&lt;text x="20" y="380" class="ts">The API contract clients see does not change between modes: that is exactly what the control plane provides.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-llm-branch-llminferenceservice">The LLM branch: &lt;code>LLMInferenceService&lt;/code>&lt;/h2>
&lt;p>Since v0.16 KServe has a separate CRD for LLMs: &lt;code>LLMInferenceService&lt;/code>, in &lt;code>serving.kserve.io/v1alpha1&lt;/code>. It is not an extension of the classic &lt;code>InferenceService&lt;/code> and does not share its API version, and confusing the two is the first mistake made by anyone arriving from old documentation.&lt;/p>
&lt;p>It is built on &lt;strong>llm-d&lt;/strong>, the project that Red Hat, Google, IBM, CoreWeave and NVIDIA donated to the CNCF. The division of roles the project itself declares is clean: KServe is the control plane (lifecycle, scaling and operational governance) and llm-d contributes distributed scheduling, with GPU utilisation, queue depth, cache residency and SLA. In the analogy: llm-d decides which line each train takes; KServe decides which trains exist and when they are withdrawn.&lt;/p>
&lt;p>The fields that matter: &lt;code>spec.model&lt;/code> (URI, invocation name, scheduling criticality and LoRA adapters, with static adapter reconciliation from v0.19, which connects with &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">multi-LoRA serving&lt;/a>); &lt;code>spec.template&lt;/code> (single node, or the decode pool); &lt;code>spec.worker&lt;/code> (multi-node, which triggers LeaderWorkerSet); &lt;code>spec.prefill&lt;/code> (the disaggregation we analysed in &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a> turned into a declarative field); &lt;code>spec.parallelism&lt;/code> (tensor, data and expert parallelism); and &lt;code>spec.router&lt;/code> (&lt;code>gateway&lt;/code>, &lt;code>route&lt;/code>, &lt;code>scheduler&lt;/code>), where KServe leans on the &lt;strong>Gateway API Inference Extension&lt;/strong>, v1.5.0 of 19 April 2026, already GA, with automatic migration to &lt;code>InferencePool&lt;/code> v1 from v0.19.&lt;/p>
&lt;p>The endpoint it exposes is OpenAI-compatible: &lt;code>/v1/chat/completions&lt;/code> with streaming, and since v0.18 also &lt;code>/v1/responses&lt;/code>. That detail underpins the next section: the LLM branch of KServe does not speak V2, it speaks OpenAI.&lt;/p>
&lt;p>For multi-node there is the &lt;code>kserve-huggingfaceserver-multinode&lt;/code> runtime, based on vLLM over Ray, with &lt;code>workerSpec.tensorParallelSize&lt;/code> and &lt;code>workerSpec.pipelineParallelSize&lt;/code>. Its restrictions are hard: it requires a ReadWriteMany PVC, it only works in Standard mode, it does not support autoscaling and it requires exactly one head pod. A reference 4×H100 SXM node with NVLink needs none of this, because a 70B fits with tensor parallel 4 on a single node; you need it from there upwards.&lt;/p>
&lt;h2 id="the-open-inference-protocol-the-contract-itself">The Open Inference Protocol: the contract itself&lt;/h2>
&lt;h3 id="the-routes-and-the-payload">The routes and the payload&lt;/h3>
&lt;p>The &lt;strong>Open Inference Protocol&lt;/strong> (historically the &amp;ldquo;V2 Inference Protocol&amp;rdquo;) lives in &lt;code>kserve/open-inference-protocol&lt;/code>, with REST and gRPC specifications and versioning under SemVer 2.0. Its HTTP surface is deliberately small:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Resource&lt;/th>
&lt;th>Verb&lt;/th>
&lt;th>Route&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Server metadata&lt;/td>
&lt;td>GET&lt;/td>
&lt;td>&lt;code>/v2&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Liveness&lt;/td>
&lt;td>GET&lt;/td>
&lt;td>&lt;code>/v2/health/live&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Server readiness&lt;/td>
&lt;td>GET&lt;/td>
&lt;td>&lt;code>/v2/health/ready&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Model metadata&lt;/td>
&lt;td>GET&lt;/td>
&lt;td>&lt;code>/v2/models/&amp;lt;model&amp;gt;[/versions/&amp;lt;v&amp;gt;]&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Model readiness&lt;/td>
&lt;td>GET&lt;/td>
&lt;td>&lt;code>/v2/models/&amp;lt;model&amp;gt;[/versions/&amp;lt;v&amp;gt;]/ready&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Inference&lt;/td>
&lt;td>POST&lt;/td>
&lt;td>&lt;code>/v2/models/&amp;lt;model&amp;gt;[/versions/&amp;lt;v&amp;gt;]/infer&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The payload is tensorial: &lt;code>id&lt;/code>, a list of &lt;code>inputs&lt;/code> (each with &lt;code>name&lt;/code>, &lt;code>shape&lt;/code>, &lt;code>datatype&lt;/code> and &lt;code>data&lt;/code>) and optionally the desired &lt;code>outputs&lt;/code>. The types are &lt;code>BOOL&lt;/code>, signed and unsigned integers from 8 to 64 bits, 16-, 32- and 64-bit floating point, and &lt;code>BYTES&lt;/code>. In gRPC the same contract is &lt;code>GRPCInferenceService&lt;/code>, with six RPCs: &lt;code>ServerLive&lt;/code>, &lt;code>ServerReady&lt;/code>, &lt;code>ModelReady&lt;/code>, &lt;code>ServerMetadata&lt;/code>, &lt;code>ModelMetadata&lt;/code> and &lt;code>ModelInfer&lt;/code>. For high-volume embeddings, the serialisation difference is not cosmetic.&lt;/p>
&lt;h3 id="who-actually-implements-it">Who actually implements it&lt;/h3>
&lt;p>The specification repository declares KServe, NVIDIA Triton, Seldon MLServer and Core v2, OpenVINO Model Server, AMD Inference Server and TorchServe as adopters. Not all entries are worth the same as of July 2026:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Triton&lt;/strong> implements it in a documented way (&amp;ldquo;Triton exposes HTTP/REST and gRPC endpoints based on the standard inference protocols proposed by the KServe project&amp;rdquo;), plus its own extensions. It is the de facto reference implementation.&lt;/li>
&lt;li>&lt;strong>OpenVINO Model Server&lt;/strong> exposes the KServe API over gRPC and REST, the TensorFlow Serving one and OpenAI-compatible endpoints: the clearest example of a server that speaks both track gauges.&lt;/li>
&lt;li>&lt;strong>TorchServe&lt;/strong> forces you to be honest. Its official documentation carries the warning: &lt;em>&amp;ldquo;This project is no longer actively maintained. While existing releases remain available, there are no planned updates, bug fixes, new features, or security patches.&amp;rdquo;&lt;/em> An adopter on zero maintenance is not an adopter: it is a row nobody has updated.&lt;/li>
&lt;/ul>
&lt;p>On the health of the standard, the most eloquent data point is not in any press release: the specification repository does not have a single published release, has accumulated something like thirty commits and seventy-five stars, and its governance consists of monthly meetings and a Slack channel. There is also a &amp;ldquo;vendor neutral&amp;rdquo; &lt;em>fork&lt;/em> at &lt;code>open-inference/open-inference-protocol&lt;/code> with more commits than the original and no traction whatsoever. V2 is a stable contract, widely implemented and barely evolving. For a serialisation format that can be a virtue; for a standard aspiring to cover the generative world, it is a death sentence.&lt;/p>
&lt;h3 id="the-generate-extension-and-why-it-did-not-win">The &lt;code>generate&lt;/code> extension and why it did not win&lt;/h3>
&lt;p>The standard did try to cover generation: the specification contains a &lt;code>generate_rest.yaml&lt;/code> with two routes, &lt;code>/v2/models/&amp;lt;m&amp;gt;/versions/&amp;lt;v&amp;gt;/generate&lt;/code> and &lt;code>.../generate_stream&lt;/code>, and a minimalist schema: a request with &lt;code>text_input&lt;/code> and &lt;code>parameters&lt;/code> (&lt;code>temperature&lt;/code>, &lt;code>top_p&lt;/code>, &lt;code>max_tokens&lt;/code> defaulting to 20, &lt;code>stop&lt;/code>, &lt;code>details&lt;/code>); a response with &lt;code>text_output&lt;/code>, &lt;code>model_name&lt;/code>, &lt;code>model_version&lt;/code> and, if requested, &lt;code>finish_reason&lt;/code> and &lt;code>logprobs&lt;/code>; streaming over &lt;em>server-sent events&lt;/em>. It is clean, it is enough for a simple completion and nobody uses it: the KServe generative inference documentation, as of July 2026, lists four endpoints, and none of them is &lt;code>generate&lt;/code>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Task&lt;/th>
&lt;th>Endpoint in KServe&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Text generation&lt;/td>
&lt;td>&lt;code>openai/v1/completions&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Chat&lt;/td>
&lt;td>&lt;code>openai/v1/chat/completions&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Embeddings&lt;/td>
&lt;td>&lt;code>openai/v1/embeddings&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reranking&lt;/td>
&lt;td>&lt;code>openai/v1/rerank&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;code>openai/&lt;/code> prefix exists, according to the documentation itself, &amp;ldquo;to avoid confusion with the V1 API protocol&amp;rdquo;, and it is changed with &lt;code>KSERVE_OPENAI_ROUTE_PREFIX&lt;/code>. It is plumbing that says a lot: the in-house standard was relegated to a defensive prefix while the outside one takes all the traffic.&lt;/p>
&lt;h3 id="the-tension-unvarnished">The tension, unvarnished&lt;/h3>
&lt;p>V2 solved the problem of 2021, and it solved it well. A tensorial, typed contract with metadata and health checks, in REST and gRPC, is what you need to serve a classifier, a vision model, a ranker or an embedding, and it is still the right answer for that half of the catalogue: swapping an embedding server from MLServer to OpenVINO Model Server without touching clients is the track gauge doing its job.&lt;/p>
&lt;p>The LLM world standardised somewhere else, not by committee, but because the entire client ecosystem (SDKs, agent frameworks, gateways, evaluation) was written against the OpenAI API, and any engine that wanted to be used had to speak it. What that means for an architecture:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>There is not one contract: there are two, and both are legitimate.&lt;/strong> Designing as if there were one does violence to half the catalogue.&lt;/li>
&lt;li>&lt;strong>LLM portability is superficial.&lt;/strong> It covers chat and completion well; as soon as you use detailed logprobs, guided decoding, priority headers, prefix caching parameters or &lt;em>tool calling&lt;/em> with specific schemas, you leave the common subset and get tied back to the engine. It is the warning from &lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">structured output&lt;/a>: what is portable is the envelope, not always the contents.&lt;/li>
&lt;li>&lt;strong>The gauge changer is the gateway.&lt;/strong> What makes the set switchable is the translation and routing layer of &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">choosing an OSS LLM inference gateway&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the router as an L7 gateway&lt;/a>, not a single standard.&lt;/li>
&lt;li>&lt;strong>Crossing over loses the versioning.&lt;/strong> V2 has &lt;code>/versions/&amp;lt;v&amp;gt;&lt;/code> in the route; the OpenAI API only has the &lt;code>model&lt;/code> field. The version has to be encoded in the name (&lt;code>asistente-70b-2026-07&lt;/code>) or managed in the control plane. That is the difference between being able to audit which version answered and not being able to.&lt;/li>
&lt;/ol>
&lt;h2 id="four-manifests-you-can-actually-apply">Four manifests you can actually apply&lt;/h2>
&lt;p>Platform runtime for the blog&amp;rsquo;s reference node, 4×H100 SXM 80 GB with NVLink, with tensor parallel 4:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">serving.kserve.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ClusterServingRuntime&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-h100-tp4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus.kserve.io/port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;8080&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus.kserve.io/path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;/metrics&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">supportedModelFormats&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">huggingface&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">version&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">autoSelect&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">priority&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">protocolVersions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">v2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kserve-container&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># ALWAYS pin by digest in production; the tag is only for readability&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:v0.11.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;python3&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;-m&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;vllm.entrypoints.openai.api_server&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">port=8080&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">model=/mnt/models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">served-model-name={{.Name}}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">tensor-parallel-size=4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">max-model-len=32768&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">gpu-memory-utilization=0.92&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">enable-prefix-caching&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">kv-cache-dtype=fp8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&amp;#34;&lt;/span>&lt;span class="nt">, memory: 128Gi, nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&amp;#34;&lt;/span>&lt;span class="nt">, memory: 128Gi, nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># the probe that stops the pod dying while the model loads&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">startupProbe&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">path: /health, port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8080&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">15&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">failureThreshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">40&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># up to 10 minutes of loading&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">readinessProbe&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">path: /health, port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8080&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">livenessProbe&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">path: /health, port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8080&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>InferenceService&lt;/code> that consumes it, with KEDA on vLLM metrics:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">serving.kserve.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">InferenceService&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">asistente-70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inferencia&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serving.kserve.io/deploymentMode&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Standard&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serving.kserve.io/autoscalerClass&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;keda&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">predictor&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">runtime&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-h100-tp4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">modelFormat&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">huggingface }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># the OCI registry from article 2/4 of this series&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">storageUri&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;oci://registry.example.internal/modelos/asistente-70b:2026-07&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">autoScaling&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metrics&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">External&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">external&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metric&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">backend&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;prometheus&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serverAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;http://prometheus.monitoring.svc.cluster.local:9090&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;sum(vllm:num_requests_waiting{model_name=&amp;#34;asistente-70b&amp;#34;})&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">target&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Value&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;8&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The canary, with its small print:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">serving.kserve.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">InferenceService&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">asistente-70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inferencia&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># WATCH OUT: canaryTrafficPercent only takes effect in KNative mode.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># In Standard mode it is silently ignored (issue kserve/kserve#5335, open).&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serving.kserve.io/deploymentMode&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;KNative&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">predictor&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">canaryTrafficPercent&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">runtime&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-h100-tp4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">modelFormat&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">huggingface }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">storageUri&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;oci://registry.example.internal/modelos/asistente-70b:2026-08&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>KServe keeps three references in the status (&lt;code>LatestReadyRevision&lt;/code>, &lt;code>LatestRolledoutRevision&lt;/code> and &lt;code>PreviousRolledoutRevision&lt;/code>): promoting means setting the percentage to 100, reverting means pinning traffic on the previous one, and a revision that does not become &lt;em>ready&lt;/em> automatically receives zero traffic.&lt;/p>
&lt;p>And the two calls. Against the V2 contract, for a classifier served with MLServer or Triton:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># metadata: which model it is, what inputs it expects, what types&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl -s http://clasificador.inferencia.example/v2/models/clasificador-tickets &lt;span class="p">|&lt;/span> jq .
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># readiness of the specific model, not of the server&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl -s -o /dev/null -w &lt;span class="s1">&amp;#39;%{http_code}\n&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> http://clasificador.inferencia.example/v2/models/clasificador-tickets/ready
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl -s -X POST &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> http://clasificador.inferencia.example/v2/models/clasificador-tickets/infer &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s1">&amp;#39;Content-Type: application/json&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;{ &amp;#34;id&amp;#34;: &amp;#34;req-4711&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;inputs&amp;#34;: [ { &amp;#34;name&amp;#34;: &amp;#34;input-0&amp;#34;, &amp;#34;shape&amp;#34;: [1, 4], &amp;#34;datatype&amp;#34;: &amp;#34;FP32&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;data&amp;#34;: [5.1, 3.5, 1.4, 0.2] } ],
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;outputs&amp;#34;: [ { &amp;#34;name&amp;#34;: &amp;#34;output-0&amp;#34; } ] }&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Against the same control plane, but on the OpenAI-compatible surface of the LLM:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">curl -s -N -X POST &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> http://asistente-70b.inferencia.example/openai/v1/chat/completions &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s1">&amp;#39;Content-Type: application/json&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;{ &amp;#34;model&amp;#34;: &amp;#34;asistente-70b&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;messages&amp;#34;: [ {&amp;#34;role&amp;#34;: &amp;#34;user&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;content&amp;#34;: &amp;#34;Summarise last night&amp;#39;&lt;/span>&lt;span class="se">\&amp;#39;&lt;/span>&lt;span class="s1">&amp;#39;s incident report.&amp;#34;} ],
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;max_tokens&amp;#34;: 256,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;stream&amp;#34;: true }&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two contracts and two payload shapes, with the same &lt;code>InferenceService&lt;/code> as the unit of governance. That is the value proposition of a control plane: the contract is chosen by the model and the management is single.&lt;/p>
&lt;h2 id="autoscaling-and-the-problem-of-zero">Autoscaling and the problem of zero&lt;/h2>
&lt;p>Three autoscalers coexist, and choosing the wrong one is the most common cause of disappointment. &lt;strong>KPA&lt;/strong>, in KNative mode, scales by concurrency or RPS and is the only one that goes down to zero in a usable way, because Knative provides an activator that holds the request while the pod starts; its metric does not distinguish a 30-token request from an 8,000-token one. &lt;strong>HPA&lt;/strong>, in Standard, scales by CPU or memory: for LLMs it is useless, because the CPU of a saturated vLLM pod and that of an idle one look too much alike. &lt;strong>KEDA&lt;/strong>, in Standard, is the right option, the one we develop in &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">autoscaling with KEDA&lt;/a>: it is enabled with &lt;code>serving.kserve.io/autoscalerClass: &amp;quot;keda&amp;quot;&lt;/code> and &lt;code>autoScaling.metrics&lt;/code> accepts external metrics against Prometheus (&lt;code>vllm:num_requests_running&lt;/code> or &lt;code>vllm:num_requests_waiting&lt;/code>) or, with the OpenTelemetry add-on, pushed metrics. Since v0.18 there is also &lt;strong>Workload Variant Autoscaling&lt;/strong>, which uses HPA or KEDA as a backend.&lt;/p>
&lt;p>That leaves the serious problem, which no autoscaler solves: zero. A 70B model in fp8 is on the order of 70 GB to move into HBM, and that path, &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">from disk to HBM&lt;/a>, is measured in minutes: the first request after idling takes that long, or it fails.&lt;/p>
&lt;p>Worse: the installation documentation is explicit as of July 2026: &amp;ldquo;Scale from Zero is currently not supported in Standard mode for HTTP requests&amp;rdquo;. The mode the project has pivoted towards, and the only one that supports KEDA, does not know how to come up from zero on an HTTP request: KEDA can go down to zero (default cooldown of 300 seconds), but the way back requires an activator that Standard does not have. Anyone who wants real zero with HTTP activation still needs Knative.&lt;/p>
&lt;p>The levers, in order of effectiveness: do not scale the hot model to zero (&lt;code>minReplicas: 1&lt;/code> for anything with an SLA; zero is for the long tail); attack the load time, the only solution that is not a patch, with the techniques from &lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">speeding up cold start&lt;/a>; use &lt;code>LocalModelCache&lt;/code>, available since v0.15 and extended to &lt;code>LLMInferenceService&lt;/code> in v0.19, to preload artifacts into a node PVC; and set correct probes, which is what comes next.&lt;/p>
&lt;h2 id="operational-traps">Operational traps&lt;/h2>
&lt;p>&lt;strong>The probe that kills the pod while the model loads.&lt;/strong> The most frequent and the most expensive to diagnose, because the symptom is a &lt;code>CrashLoopBackOff&lt;/code> with no error in the server logs. Until early 2026 the KServe vLLM templates used &lt;code>initialDelaySeconds&lt;/code> of 120 to 300 seconds on &lt;em>liveness&lt;/em>, with the result described in issue #5062 (12 February 2026, now resolved): the fast models waited too long and the large ones died before finishing loading. The fix applied: a &lt;code>startupProbe&lt;/code> of up to ten minutes and probes with no initial delay. A legacy runtime has to be reviewed by hand. In KNative mode there is a worse variant (issue #3795, closed as &lt;em>not planned&lt;/em>): what dies is not the model container but the &lt;code>queue-proxy&lt;/code>, which does not respond OK within the deadline and takes the whole pod down with it.&lt;/p>
&lt;p>&lt;strong>&lt;code>canaryTrafficPercent&lt;/code> silently ignored.&lt;/strong> It deserves its own trap because there is no error and no condition in the status to give it away: in Standard mode the field does nothing. The team believes it is serving 10 % with the new version and it is serving 100 %. While issue #5335 remains open, in Standard the canary is done with &lt;code>HTTPRoute&lt;/code> weights by hand, or in the gateway.&lt;/p>
&lt;p>&lt;strong>Canary and scarce GPUs do not get along.&lt;/strong> A 10 % canary of a 70B with tensor parallel 4 costs a whole 4-GPU node, not 10 % of the resources, because the minimum granularity is a complete replica. During the window there are two versions occupying twice the hardware, or capacity has to be taken from the stable one. That is the difference between a microservice canary and a model canary, and the reason a &lt;em>shadow&lt;/em> over duplicated traffic often pays off better.&lt;/p>
&lt;p>&lt;strong>&lt;code>storageUri&lt;/code> and credentials.&lt;/strong> Storage initializer failures are always the same ones: a &lt;code>ServiceAccount&lt;/code> without the annotated secret, a KMS-encrypted bucket with a role that has no decryption permission, an S3-compatible but not exactly S3 endpoint, or intermittent 403s, with a specific fix in v0.18. It is diagnosed in the init container logs, not in the server&amp;rsquo;s. And one standing recommendation: no downloading over &lt;code>https://&lt;/code> from the internet in production; that is precisely the problem of article 2/4.&lt;/p>
&lt;p>&lt;strong>The model size against the &lt;code>emptyDir&lt;/code>.&lt;/strong> By default the artifact lands in an ephemeral volume on the node&amp;rsquo;s disk. Seventy gigabytes per replica fill a system disk with surprising ease, and then the kubelet evicts pods for disk pressure, including ones that have nothing to do with it. You have to set &lt;code>sizeLimit&lt;/code> or use a dedicated &lt;code>pvc://&lt;/code>. For multi-node the restriction is harder, with a mandatory ReadWriteMany PVC, which rules out a good part of local block storage and pushes you towards shared file systems, with the consequences covered in &lt;a href="https://blog.lo0.es/en/posts/storage-ai-era-availability/">storage for AI&lt;/a>.&lt;/p>
&lt;p>&lt;strong>The dependency on Knative and Istio.&lt;/strong> The real cost of KNative mode: two more control planes to version and correlate with Kubernetes, and two sidecars, &lt;code>queue-proxy&lt;/code> and &lt;code>istio-proxy&lt;/code>, in every pod, with their CPU and memory consumption and their network hop per request. On a platform that already has a mesh it is a marginal cost; on a cluster dedicated to inference it has to be justified by the capabilities you are actually going to use.&lt;/p>
&lt;p>&lt;strong>Observability: the name of the metric.&lt;/strong> The &lt;code>ServingRuntime&lt;/code> declares &lt;code>prometheus.kserve.io/port&lt;/code> and &lt;code>/path&lt;/code> annotations, but the metrics that come out are the engine&amp;rsquo;s, not KServe&amp;rsquo;s: &lt;code>vllm:num_requests_waiting&lt;/code>, &lt;code>vllm:time_to_first_token_seconds&lt;/code>, &lt;code>vllm:gpu_cache_usage_perc&lt;/code>. The control plane does not normalise names across runtimes, so a dashboard written against vLLM is no use for Triton, and correlating with &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">DCGM&lt;/a> is still your own work. v0.19 raises the HPA/KEDA scaling status into the service conditions and emits events on readiness transitions: exactly what was needed to alert on the control plane and not only on the engine.&lt;/p>
&lt;h2 id="decision-map-when-kserve-and-when-it-is-overkill">Decision map: when KServe, and when it is overkill&lt;/h2>
&lt;p>The useful comparison is not &amp;ldquo;KServe versus OME, vLLM Production Stack, Dynamo or llm-d&amp;rdquo;. Those four are covered in &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM inference operators&lt;/a> and compete on a different axis:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Axis&lt;/th>
&lt;th>What gets decided&lt;/th>
&lt;th>Who competes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Contract and control plane&lt;/strong>&lt;/td>
&lt;td>Which CRD describes a service, which API clients see, how the model is versioned, how the rollout is done, how LLM and non-LLM coexist&lt;/td>
&lt;td>&lt;strong>KServe&lt;/strong>, Seldon Core v2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Engine and topology&lt;/strong>&lt;/td>
&lt;td>Which engine runs, how prefill/decode is disaggregated, how routing is done with cache awareness, how multi-node tensor parallel is done&lt;/td>
&lt;td>vLLM Production Stack, OME, NVIDIA Dynamo, llm-d&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The axes are orthogonal, and &lt;code>LLMInferenceService&lt;/code> proves it: KServe did not reimplement distributed scheduling, it leaned on llm-d. Choosing KServe is not choosing &lt;em>instead of&lt;/em> an LLM operator: it is choosing the contract layer that operator lives on.&lt;/p>
&lt;p>&lt;strong>Choose KServe when&lt;/strong> the catalogue is heterogeneous, LLMs plus embeddings, rerankers, classifiers and vision, which is its decisive advantage and the one none of the four operators has: a service like the one in &lt;a href="https://blog.lo0.es/en/posts/serving-embeddings-rerankers-tei-production/">TEI in production&lt;/a> fits into the same vocabulary as the LLM. Also when you need a contract decoupled from the engine because you have clients you do not control; when you need declarative, auditable governance (which artifact, from which origin and with which runtime, in an object versionable in git and subject to admission policies), the piece that makes the rest of this series possible; and when you want a catalogue of platform runtimes separated from the teams&amp;rsquo; services.&lt;/p>
&lt;p>&lt;strong>KServe is overkill when&lt;/strong> you serve one model, on one node, with up to three replicas: the &lt;code>Deployment&lt;/code> from &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a> is the right answer. When your workload is LLM only and the priority is performance per GPU, because there the value is in the engine. When you are going to use neither scale-to-zero nor canary by revision, because what remains is a &lt;code>Deployment&lt;/code> with more YAML. And when you already have an LLM operator and only serve LLMs: putting KServe on top for completeness is adding CRDs without adding decisions.&lt;/p>
&lt;h2 id="for-an-inference-factory">For an inference factory&lt;/h2>
&lt;p>&lt;strong>First: separate the runtime catalogue from the service catalogue, and do it on day one.&lt;/strong> The &lt;code>ClusterServingRuntime&lt;/code>s belong to the platform; the &lt;code>InferenceService&lt;/code>s, to the teams. That boundary turns &amp;ldquo;serving the thirtieth model&amp;rdquo; into a five-line change instead of a negotiation about vLLM flags, and it fits with &lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">GitOps with Flux&lt;/a>: the runtime is reviewed as platform code, the service as product configuration.&lt;/p>
&lt;p>&lt;strong>Second: accept the two contracts and put the gauge changer where it belongs.&lt;/strong> V2 for the tensorial world, the OpenAI API for the generative one, and the translation in the gateway, not in every client. And since the OpenAI API does not version in the route, encode the version in the model name and make that name the same identifier that appears in the registry, in the signature and in the traces. That is what makes a request traceable back to the artifact.&lt;/p>
&lt;p>&lt;strong>Third: measure start-up before promising elasticity.&lt;/strong> The time from the pod being scheduled to the readiness probe going green, with the real model and from the real origin, decides whether scale-to-zero is a FinOps lever or an incident machine. That number, and not the price of the GPU-hour, is the one to take into the cost conversation.&lt;/p>
&lt;p>And here this piece falls short on purpose. KServe gives you the contract, the lifecycle and the rollout, but its whole trust model hangs off a text string: &lt;strong>&lt;code>storageUri&lt;/code>&lt;/strong>. The storage initializer downloads whatever is at that destination and puts it in front of the GPU without asking anything else; nothing in the &lt;code>InferenceService&lt;/code> guarantees that the artifact is the one that was approved, that it has not changed since the last audit, or that the origin is legitimate. The next article goes there: where the model bytes come from, and why an OCI registry with ORAS, Harbor, MLflow or the Kubeflow Model Registry is the difference between distributing models and scattering them.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM inference operators on Kubernetes: OME, vLLM Production Stack, NVIDIA Dynamo and llm-d&lt;/a> — the complementary axis: engine and topology versus the contract this article deals with.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes: the LLM inference piece that does scale&lt;/a> — the plain &lt;code>Deployment&lt;/code> that KServe is the next step from, and the point where KServe is still overkill.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes with KEDA&lt;/a> — the vLLM metrics that feed the predictor&amp;rsquo;s &lt;code>autoScaling&lt;/code> block.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow for LLM models&lt;/a> — what to do when &lt;code>canaryTrafficPercent&lt;/code> is not available, and why a model canary does not cost what it seems to.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up cold start: model loading with Tensorizer&lt;/a> — the only real way for scale-to-zero to stop being a promise.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/">Model chain of trust (2/4): model registry and distribution with OCI and ORAS&lt;/a> — where that &lt;code>storageUri&lt;/code> points, and how it becomes a governed artifact.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>CNCF, &lt;em>KServe becomes a CNCF incubating project&lt;/em> (11 de noviembre de 2025) — &lt;a href="https://www.cncf.io/blog/2025/11/11/kserve-becomes-a-cncf-incubating-project/">https://www.cncf.io/blog/2025/11/11/kserve-becomes-a-cncf-incubating-project/&lt;/a>&lt;/li>
&lt;li>CNCF, &lt;em>KServe project page&lt;/em> (incubating, aceptado el 29 de septiembre de 2025) — &lt;a href="https://www.cncf.io/projects/kserve/">https://www.cncf.io/projects/kserve/&lt;/a>&lt;/li>
&lt;li>CNCF TOC, &lt;em>Project Moving Levels Checklist: KServe joining CNCF at Incubation level&lt;/em> — &lt;a href="https://github.com/cncf/toc/issues/1905">https://github.com/cncf/toc/issues/1905&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Releases&lt;/em> (v0.15.0, v0.16.0, v0.18.0, v0.19.0) — &lt;a href="https://github.com/kserve/kserve/releases">https://github.com/kserve/kserve/releases&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Inference Protocol V2 (Open Inference Protocol)&lt;/em> — &lt;a href="https://kserve.github.io/website/docs/concepts/architecture/data-plane/v2-protocol">https://kserve.github.io/website/docs/concepts/architecture/data-plane/v2-protocol&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>open-inference-protocol&lt;/em> (especificación, adoptantes, gobernanza) — &lt;a href="https://github.com/kserve/open-inference-protocol">https://github.com/kserve/open-inference-protocol&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>open-inference-protocol — generate_rest.yaml&lt;/em> — &lt;a href="https://github.com/kserve/open-inference-protocol/blob/main/specification/protocol/generate_rest.yaml">https://github.com/kserve/open-inference-protocol/blob/main/specification/protocol/generate_rest.yaml&lt;/a>&lt;/li>
&lt;li>open-inference, &lt;em>Vendor neutral fork of kserve/open-inference-protocol&lt;/em> — &lt;a href="https://github.com/open-inference/open-inference-protocol">https://github.com/open-inference/open-inference-protocol&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Generative Inference — Runtime Overview&lt;/em> (endpoints con prefijo &lt;code>openai/&lt;/code>) — &lt;a href="https://kserve.github.io/website/docs/model-serving/generative-inference/overview">https://kserve.github.io/website/docs/model-serving/generative-inference/overview&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Understanding LLMInferenceService&lt;/em> — &lt;a href="https://kserve.github.io/website/docs/model-serving/generative-inference/llmisvc/llmisvc-overview">https://kserve.github.io/website/docs/model-serving/generative-inference/llmisvc/llmisvc-overview&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Cloud-Native AI Inference at Scale using KServe and llm-d&lt;/em> (5 de marzo de 2026) — &lt;a href="https://kserve.github.io/website/blog/cloud-native-ai-inference-kserve-llm-d">https://kserve.github.io/website/blog/cloud-native-ai-inference-kserve-llm-d&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Serving Runtime (ServingRuntime y ClusterServingRuntime)&lt;/em> — &lt;a href="https://kserve.github.io/website/docs/concepts/resources/servingruntime">https://kserve.github.io/website/docs/concepts/resources/servingruntime&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Kubernetes Deployment Installation Guide&lt;/em> (K8s 1.32+, cert-manager 1.15.0+, Gateway API v1.2.1, sin scale-from-zero en Standard) — &lt;a href="https://kserve.github.io/website/docs/admin-guide/kubernetes-deployment">https://kserve.github.io/website/docs/admin-guide/kubernetes-deployment&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Canary Rollout Strategy&lt;/em> — &lt;a href="https://kserve.github.io/website/docs/model-serving/predictive-inference/rollout-strategies/canary">https://kserve.github.io/website/docs/model-serving/predictive-inference/rollout-strategies/canary&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Autoscaling with KEDA&lt;/em> — &lt;a href="https://kserve.github.io/website/docs/model-serving/predictive-inference/autoscaling/keda-autoscaler">https://kserve.github.io/website/docs/model-serving/predictive-inference/autoscaling/keda-autoscaler&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Multi-node/Multi-GPU Inference&lt;/em> (PVC RWX, sin autoescalado) — &lt;a href="https://kserve.github.io/website/docs/model-serving/generative-inference/multi-node">https://kserve.github.io/website/docs/model-serving/generative-inference/multi-node&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>Storage Options for Model Artifacts&lt;/em> — &lt;a href="https://kserve.github.io/website/docs/model-serving/storage/overview">https://kserve.github.io/website/docs/model-serving/storage/overview&lt;/a>&lt;/li>
&lt;li>KServe, issue #5335 — &lt;em>Support canaryTrafficPercent for RawDeployment mode via Gateway API HTTPRoute weights&lt;/em> (abierta, 2 de abril de 2026) — &lt;a href="https://github.com/kserve/kserve/issues/5335">https://github.com/kserve/kserve/issues/5335&lt;/a>&lt;/li>
&lt;li>KServe, issue #5062 — &lt;em>Add startupProbe to vLLM main containers&lt;/em> (12 de febrero de 2026) — &lt;a href="https://github.com/kserve/kserve/issues/5062">https://github.com/kserve/kserve/issues/5062&lt;/a>&lt;/li>
&lt;li>KServe, &lt;em>modelmesh-serving&lt;/em> (última release v0.12.0 de julio de 2023; repositorio archivado el 14 de abril de 2026) — &lt;a href="https://github.com/kserve/modelmesh-serving">https://github.com/kserve/modelmesh-serving&lt;/a>&lt;/li>
&lt;li>Red Hat, &lt;em>Converting ModelMesh and Serverless InferenceServices to RawDeployment (Standard) Mode&lt;/em> — &lt;a href="https://access.redhat.com/articles/7134025">https://access.redhat.com/articles/7134025&lt;/a>&lt;/li>
&lt;li>NVIDIA, &lt;em>Triton Inference Server — Inference Protocols and APIs&lt;/em> — &lt;a href="https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/customization_guide/inference_protocols.html">https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/customization_guide/inference_protocols.html&lt;/a>&lt;/li>
&lt;li>OpenVINO, &lt;em>What is OpenVINO Model Server&lt;/em> (API KServe, TFS y endpoints compatibles con OpenAI) — &lt;a href="https://docs.openvino.ai/2026/model-server/ovms_what_is_openvino_model_server.html">https://docs.openvino.ai/2026/model-server/ovms_what_is_openvino_model_server.html&lt;/a>&lt;/li>
&lt;li>PyTorch, &lt;em>TorchServe — Notice: Limited Maintenance&lt;/em> — &lt;a href="https://docs.pytorch.org/serve/">https://docs.pytorch.org/serve/&lt;/a>&lt;/li>
&lt;li>Kubernetes SIG Network, &lt;em>Gateway API Inference Extension&lt;/em> (v1.5.0, 19 de abril de 2026) — &lt;a href="https://github.com/kubernetes-sigs/gateway-api-inference-extension">https://github.com/kubernetes-sigs/gateway-api-inference-extension&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>How we use our infrastructure to meet defence standards with AI</title><link>https://blog.lo0.es/en/posts/infrastructure-defence-ai-compliance/</link><pubDate>Thu, 18 Jun 2026 11:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/infrastructure-defence-ai-compliance/</guid><description>&lt;p>In defence, the challenge of putting AI into operation is not the model: it is security and compliance. The rules that apply, the allied interoperability standards, the data-centric security rules, the accreditation frameworks and the principles of responsible use, impose concrete requirements on access control, classification, confidentiality, traceability, supply chain and sovereignty. A demo on a laptop connected to the internet ignores all of that; an operable platform has to satisfy it, and has to be able to prove it to an accreditation body.&lt;/p>
&lt;p>This article is not a tour of technologies. It is the opposite: we take the requirements imposed by the standards of the sector and, for each one, we show how our infrastructure is used to meet it. The infrastructure is the same real platform we use in other sectors, two RKE2 clusters (Kubernetes v1.35) with Cilium as the network, GitOps with Flux, Keycloak as the identity server, inference on our own GPUs and, in pre-production, scheduling with Volcano, adapted to an environment with stricter security requirements. Every component is here because it solves a regulatory requirement, not because it looks good on a diagram. And, like all infrastructure, it is measured by efficiency and cost, which is what makes compliance sustainable.&lt;/p>
&lt;p>It is worth starting with an idea many people are unaware of: &lt;strong>standards for this already exist in the sector&lt;/strong>, and nothing has to be invented. There are allied interoperability standards, there is a defence-specific DevSecOps reference model on Kubernetes, there is a security framework (Zero Trust) and a supplier accreditation scheme, and there is a body of principles for the responsible use of military AI. Let us first lay out that map, and then walk the requirements one by one.&lt;/p>
&lt;h2 id="the-map-of-standards-in-the-sector">The map of standards in the sector&lt;/h2>
&lt;p>Four families of standards intersect in a defence AI platform, and understanding what each one covers avoids reinventing wheels.&lt;/p>
&lt;p>&lt;strong>Allied interoperability.&lt;/strong> In a coalition, systems have to understand each other. &lt;strong>Federated Mission Networking (FMN)&lt;/strong> defines how mission networks are federated across nations through successive &amp;ldquo;spirals&amp;rdquo;; the &lt;strong>MIP (Multilateral Interoperability Programme)&lt;/strong> and its JC3IEDM data model standardise command and control; Link-16 and formats such as NVG (NATO Vector Graphics) cover tactical and situational exchange. They are, in defence, the equivalent of what FHIR and DICOM are in healthcare: the common language without which AI is an isolated system that cannot be integrated into the operation.&lt;/p>
&lt;p>&lt;strong>Data-centric security: STANAG 4774 and 4778.&lt;/strong> This is the most distinctive piece of the sector. &lt;strong>STANAG 4774&lt;/strong> defines the syntax of a &lt;strong>confidentiality label&lt;/strong> (policy, classification, security categories), and &lt;strong>STANAG 4778&lt;/strong> defines the &lt;strong>binding mechanism&lt;/strong> that ties that label to the data throughout its whole life cycle and between the parties that share it. Instead of protecting only the perimeter or the system, the information object itself is protected: the classification travels with the data and the access decision is taken against the label. Both STANAGs are in the process of ratification by the nations and are moving from recommended practice to a requirement for mission-critical software.&lt;/p>
&lt;p>&lt;strong>Defence DevSecOps on Kubernetes.&lt;/strong> To build and deploy defence software there is a very concrete reference model: the &lt;strong>DoD Enterprise DevSecOps Reference Design&lt;/strong> on Kubernetes, embodied in platforms such as &lt;strong>Platform One&lt;/strong> and its &lt;strong>Big Bang&lt;/strong> product line (declarative continuous delivery of hardened packages on a Kubernetes cluster), supported by &lt;strong>Iron Bank&lt;/strong>, the repository of &lt;strong>hardened container images compliant with the DISA STIGs&lt;/strong>, continuously scanned. The associated philosophy is &lt;strong>cATO (Continuous Authority To Operate)&lt;/strong>: accreditation stops being a one-off milestone and becomes a state maintained through continuous monitoring. For defence, it is the equivalent of the Kubernetes-native standard that MONAI represents in medical imaging: an agreed way to package and operate software with guarantees about the substrate we already use.&lt;/p>
&lt;p>&lt;strong>Security and accreditation frameworks.&lt;/strong> The &lt;strong>DoD Zero Trust Reference Architecture&lt;/strong> (v2.0) sets the guiding principle, do not trust by default, always verify, with adoption targets towards 2027. Supplier accreditation is governed by &lt;strong>CMMC 2.0&lt;/strong> (whose Phase 2, with mandatory third-party certification for level 2, arrives in November 2026) over the 110 controls of &lt;strong>NIST SP 800-171&lt;/strong>. In Spain and the EU, the &lt;strong>Esquema Nacional de Seguridad&lt;/strong> (the Spanish National Security Framework) at high category and the &lt;strong>CCN-STIC&lt;/strong> guides of the Centro Criptológico Nacional (the Spanish National Cryptologic Centre) play an equivalent role for systems handling sensitive or classified information.&lt;/p>
&lt;p>&lt;strong>Responsible AI in defence.&lt;/strong> NATO adopted six &lt;strong>Principles of Responsible Use (PRU)&lt;/strong> for AI in defence: lawfulness, responsibility and accountability, explainability and traceability, reliability, governability and bias mitigation, reinforced by its AI Strategy revised in 2024 and by the &lt;strong>DARB (Data and AI Review Board)&lt;/strong>. An important legal nuance: the EU &lt;strong>AI Act expressly excludes&lt;/strong> (art. 2.3) AI systems intended exclusively for military, defence or national security purposes. That is, in defence the responsible AI framework is not the AI Act, but NATO&amp;rsquo;s PRU, national rules and, as a management system, ISO/IEC 42001.&lt;/p>
&lt;p>With this map on the table, let us walk the requirements.&lt;/p>
&lt;h2 id="requirement-coalition-interoperability-fmn-mipjc3iedm">Requirement: coalition interoperability (FMN, MIP/JC3IEDM)&lt;/h2>
&lt;p>In an allied operation, a system that does not integrate is useless however good it is. The requirement, derived from FMN, from the command and control data model (MIP/JC3IEDM) and from the tactical formats (Link-16, NVG), is that AI must consume and produce information in the common formats of the coalition, not in a dialect of its own.&lt;/p>
&lt;p>How we meet it: just as in other sectors we put an interoperability layer at the boundary, here the platform integrates through the agreed &lt;em>gateways&lt;/em> and formats, so that AI never talks directly to the mission systems but consumes and delivers information already normalised to the coalition standard. This keeps AI as a component that couples into the federated architecture, respecting its profiles and its data governance, instead of a silo that forces bespoke integrations at every deployment. Interoperability is also a precondition for traceability and labelling: only if the data enters and leaves in a known format can its confidentiality label be attached and preserved.&lt;/p>
&lt;h2 id="requirement-do-not-trust-by-default-zero-trust-and-verified-access-dod-zt-ra-nist-800-171">Requirement: do not trust by default, Zero Trust and verified access (DoD ZT RA, NIST 800-171)&lt;/h2>
&lt;p>The guiding principle of modern defence security is Zero Trust: no entity, user, service or node, is trusted because of its position in the network; every access is authenticated, authorised and continuously verified. NIST 800-171 details the access and identification controls that underpin it.&lt;/p>
&lt;p>How we meet it: a single identity server, &lt;strong>Keycloak&lt;/strong>, governs authorisation with OAuth 2.1 and OpenID Connect, issuing short-lived tokens and binding them to their recipient with audience validation (&lt;code>aud&lt;/code>), so that a credential issued for one service is useless against another. Between components handling sensitive information we require &lt;strong>mutual TLS (mTLS)&lt;/strong>: it is not only the user or the service that is authenticated, but the workload itself taking part in the exchange. And the network operates in &lt;strong>default-deny&lt;/strong> (next section), so that connectivity is not a right conferred by being inside the cluster, but an explicit permission. Workloads use &lt;em>service accounts&lt;/em> with no permissions by default, and administrator access to the Kubernetes API is federated against Keycloak over OIDC with scoped roles: technical access too is authenticated, limited and audited. Zero Trust is not a product you buy; it is a property that emerges from strong identity, least privilege and continuous verification, and that is how the platform is built. Verification, moreover, does not stop at sign-in: tokens are short-lived and are renewed, policies are re-evaluated on every request, and the posture of each workload, signed image, applied security policy, determines whether it can run and communicate. A component that stops complying with its policy stops being able to operate; it is granted no grace period for having been inside.&lt;/p>
&lt;h2 id="requirement-data-centric-security-and-labelling-stanag-47744778">Requirement: data-centric security and labelling (STANAG 4774/4778)&lt;/h2>
&lt;p>Unlike other sectors, in defence the classification travels with the data. The requirement, derived from STANAG 4774/4778, is that every information object carries its confidentiality label and that access decisions are taken against that label, not against the system hosting it.&lt;/p>
&lt;p>How we meet it: authorisation decisions are not limited to roles, but incorporate &lt;strong>attribute-based access control (ABAC)&lt;/strong> evaluating the classification label of the data against the clearance of the requester. The policy is expressed as code and evaluated at a central decision point (a policy engine of the OPA/Open Policy Agent kind) that the application layer and the &lt;em>gateways&lt;/em> consult before serving a resource. The label, in accordance with STANAG 4774, travels with the object, and the binding under STANAG 4778 is preserved across the platform, in the object metadata in storage and in the annotations that travel with the request. For an AI agent, this means that its token gives it access to a classification level, and that data above that level is simply not handed to it, regardless of where it is stored.&lt;/p>
&lt;p>This approach embodies the classic no-read-up rule: an entity with a given clearance does not access information above its level, and the decision is taken on the object&amp;rsquo;s label, not on assumptions made by the system. When a workflow requires moving information to a lower level, a declassification or sanitisation, it is an explicit, audited step, with its own policy, never a side effect of a query. The label is preserved in the object metadata in storage and in the annotations accompanying the request, so that the STANAG 4778 binding is not lost when crossing the components of the platform.&lt;/p>
&lt;h2 id="requirement-segregation-by-mission-and-level-multitenancy-nist-800-171-ens">Requirement: segregation by mission and level (multitenancy, NIST 800-171, ENS)&lt;/h2>
&lt;p>Different missions, different classification levels and different compartments cannot share a common space without controls. The requirement: strong isolation, so that what belongs to one mission or level is not accessible from another.&lt;/p>
&lt;p>How we meet it: each mission or compartment lives in its own Kubernetes &lt;strong>namespace&lt;/strong>, with scoped RBAC, ResourceQuota and LimitRange, dedicated storage and databases per operator, not shared behind an application filter, and its own network segmentation. Identity is segregated by realm in Keycloak. The whole definition lives in Git and is reconciled by Flux (GitOps), so that segregation is declarative and auditable through change history. Where the classification level demands it, separation goes beyond the cluster: operation runs on physically distinct clusters or sites, and exchange between classification domains, when necessary, is done through controlled cross-domain solutions, not by opening a hole between namespaces. At the highest levels, each security domain can be an independent cluster with its own chain of trust, its own identities and its own governance, preventing a single control plane from spanning classifications that must never be mixed. The choice between a separate namespace, cluster or site is itself an accreditation decision: it is documented, justified according to the level and compartment, and reflected declaratively in the repository.&lt;/p>
&lt;h2 id="requirement-confidentiality-encryption-and-exfiltration-prevention-nist-800-171-ens-zero-trust">Requirement: confidentiality, encryption and exfiltration prevention (NIST 800-171, ENS, Zero Trust)&lt;/h2>
&lt;p>NIST 800-171 (system and communications protection families) and the ENS require confidentiality, encryption of communications and prevention of information leakage. In defence, exfiltration is not an abstract risk: it is the adversary&amp;rsquo;s objective.&lt;/p>
&lt;p>How we meet it: the network is managed by &lt;strong>Cilium&lt;/strong> (eBPF) with a &lt;strong>default-deny&lt;/strong> model. A pod has no connectivity beyond what a policy grants it explicitly, flow by flow, which turns exfiltration into something blocked in the kernel. A component can only talk to strictly necessary destinations:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">CiliumNetworkPolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inferencia-egress-minimo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">mision-alfa&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpointSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">serving-inferencia&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">egressDeny&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">toEntities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">world] &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># no egress outside the cluster&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">egress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">toEndpoints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gateway-modelos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">toEndpoints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># internal DNS, without this it does not resolve&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">k8s:io.kubernetes.pod.namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kube-system&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">k8s-app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kube-dns&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">toPorts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;53&amp;#34;&lt;/span>&lt;span class="nt">, protocol&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">UDP }]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two nuances separate a correct policy from a leaky one: you have to explicitly allow DNS and filter by &lt;strong>endpoint identity&lt;/strong>, not by CIDR, because in an identity-based network filtering by IP ranges does not behave the way intuition suggests. On top of this we add flow observability with Hubble and runtime security with &lt;strong>Tetragon&lt;/strong> (eBPF), which watches process execution, file access and connections inside the container, making it possible to detect and block anomalous behaviour; an unexpected process in a sensitive workload is exactly the signal you want to see in real time. Encryption in transit is covered by cert-manager (TLS at the edges) and Cilium&amp;rsquo;s transparent node-to-node encryption. Added to this are the &lt;strong>Pod Security Standards&lt;/strong> in restricted profile (no privileges, no root) and disciplined management of secrets and keys, which do not live in plain manifests.&lt;/p>
&lt;h2 id="requirement-verifiable-software-supply-chain-iron-bankstig-sbom-cmmc">Requirement: verifiable software supply chain (Iron Bank/STIG, SBOM, CMMC)&lt;/h2>
&lt;p>The software supply chain is a first-order attack vector, and the sector treats it as such: images hardened in line with the STIGs, a component inventory (SBOM) and cryptographic verification of provenance. CMMC and NIST 800-171 require it in the supplier flow; Executive Order 14028 popularised the SBOM.&lt;/p>
&lt;p>How we meet it: we start from &lt;strong>hardened base images&lt;/strong> (in the spirit of Iron Bank and the DISA STIGs), we generate an SBOM (CycloneDX/SPDX) per artefact and we &lt;strong>sign images and SBOMs with Sigstore/Cosign&lt;/strong>, a graduated CNCF project, with keyless signing and a transparency log. In the cluster, an &lt;strong>admission control&lt;/strong> (Kyverno/policies) &lt;strong>rejects any image that is not signed&lt;/strong> by a trusted identity, so software whose provenance cannot be verified never runs. The rule is expressed as code and verifies the signature of every image before allowing it to run:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kyverno.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ClusterPolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">exigir-imagenes-firmadas&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">validationFailureAction&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Enforce&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rules&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">verificar-firma-cosign&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">match&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">any&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kinds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">Pod]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">verifyImages&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">imageReferences&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;registro.interno/*&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">attestors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">entries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">keys&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">publicKeys&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|-&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> -----BEGIN PUBLIC KEY-----
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> ...trusted key...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> -----END PUBLIC KEY-----&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>An image without a valid signature is rejected at admission, before it runs. Since the whole deployment is GitOps, the chain, from the commit to the signed image to the policy that admits it, is traceable end to end. This satisfies the supply chain control of NIST 800-171 and fits the cATO model: verification is continuous, not a one-off stamp.&lt;/p>
&lt;h2 id="requirement-sovereignty-and-disconnected-or-air-gapped-operation-classified-networks-ddil">Requirement: sovereignty and disconnected or air-gapped operation (classified networks, DDIL)&lt;/h2>
&lt;p>Defence networks frequently operate isolated from the internet (air-gapped) or under DDIL conditions, degraded, intermittent or low-bandwidth connectivity at the tactical edge. And the data cannot leave to an external API under any circumstances. The requirement: the platform has to work without internet and without taking the data outside.&lt;/p>
&lt;p>How we meet it: inference is served locally, on our own GPUs, with a high-performance serving engine (vLLM) and &lt;strong>open-weight models&lt;/strong> that can be hosted entirely inside the perimeter. Everything the platform needs, container images, models, dependencies, is mirrored into internal registries and repositories, so that the GitOps deployment reconciles from internal sources and requires no internet access. A &lt;strong>model gateway&lt;/strong> centralises access and reverses the burden: the default behaviour is local inference, and any egress is an exception that, besides having to be enabled explicitly, would collide with the default-deny network policies. Sovereignty does not rest on a promise, but on several mutually reinforcing controls, gateway, network policy and isolation, so that a configuration slip does not translate into a leak.&lt;/p>
&lt;p>One point that cannot be overlooked in defence: no component should &amp;ldquo;phone home&amp;rdquo;. Many tools send telemetry, check licences or download updates against external servers by default, and in a classified network that is unacceptable. That is why default-deny is not only a measure against the adversary, but also against the software itself: if an image attempts an undeclared telemetry connection, the policy blocks it and Tetragon logs it. The absence of phone-home is verified, not trusted.&lt;/p>
&lt;h2 id="requirement-resilience-and-operation-at-the-tactical-edge-ddil">Requirement: resilience and operation at the tactical edge (DDIL)&lt;/h2>
&lt;p>Much defence operation happens under DDIL conditions, degraded, intermittent or low-bandwidth connectivity, and even in total isolation. The requirement: the platform must remain useful when the link to the rear falls, and reconcile when it returns.&lt;/p>
&lt;p>How we meet it: the declarative GitOps model fits disconnected operation naturally. An edge site keeps its own copy of the desired state and reconciles against local registries and repositories, so it operates without depending on a permanent connection; when the link returns, it synchronises. Inference is served locally with models already hosted on the node, with no external calls. Traces and audit events are stored locally and forwarded (store-and-forward) when there is a link, without losing the record. The high-availability control plane and the separation into sites allow controlled degradation instead of collapsing as a block. Resilience is not an add-on: it is a property of the declarative design and of sovereign inference.&lt;/p>
&lt;h2 id="requirement-robustness-and-protection-of-the-ai-system-itself-pru-reliability">Requirement: robustness and protection of the AI system itself (PRU reliability)&lt;/h2>
&lt;p>NATO&amp;rsquo;s &lt;strong>reliability&lt;/strong> principle requires AI systems to have explicit and well-tested capabilities, and to be secure against manipulation. This adds a requirement that other sectors treat with less urgency: protecting the model itself, its weights and its behaviour, as a sensitive asset.&lt;/p>
&lt;p>How we meet it: model weights are treated as sensitive data, hosted inside the perimeter and with access controlled by the same identity and network policies as everything else. The model gateway is the point where input and output safeguards are applied (validation, limits, filtering) and where a model can be constrained or disconnected. The signed supply chain (SBOM and Cosign) extends to model artefacts, so that only what has verifiable provenance is executed. And traceability feeds continuous evaluation: measuring the model&amp;rsquo;s behaviour over time is what makes it possible to detect degradation or manipulation before it affects the operation. Robustness is built with the same controls, identity, network, supply chain, traces, now applied to the model as well. Added to this are safeguards against adversarial inputs, prompt injection, manipulated data, at the gateway, which validates and constrains what goes into and out of the model. In defence, assuming the adversary will try to manipulate the AI is not pessimism: it is the design&amp;rsquo;s starting assumption.&lt;/p>
&lt;h2 id="requirement-reliable-execution-of-mission-workloads-and-efficiency-availability-sustainability">Requirement: reliable execution of mission workloads and efficiency (availability, sustainability)&lt;/h2>
&lt;p>Serving AI in-house is only sustainable if the GPU, a scarce and expensive resource, is used well, and certain mission workloads must run reliably and to completion. The default Kubernetes scheduler schedules pod by pod, which for distributed workloads produces half-started jobs that hold GPUs without making progress.&lt;/p>
&lt;p>How we meet it: GPU allocation is governed by &lt;strong>Volcano&lt;/strong> (a CNCF incubating project), today in pre-production, with &lt;strong>gang scheduling&lt;/strong> (&lt;code>PodGroup&lt;/code> with &lt;code>minAvailable&lt;/code>: the job starts complete or does not start), &lt;strong>per-mission queues&lt;/strong> with quota and priority, &lt;strong>fair-share (DRF)&lt;/strong> to share out fairly, &lt;strong>pre-emption&lt;/strong> so that an urgent workload can overtake a deferrable one, and &lt;strong>topology awareness&lt;/strong> (NVLink/PCIe) to reduce fragmentation. This raises the effective utilisation of the GPU fleet, which is what makes sovereign inference viable. On the state of the art: Kubernetes 1.34 took &lt;strong>DRA (Dynamic Resource Allocation)&lt;/strong> to general availability, accelerator allocation by attributes instead of by count, and Kueue covers job quota management; both combine with a gang scheduler. It is managed with metrics: effective versus allocated utilisation, fragmentation, throughput per GPU and GPU-hours per mission. At the edge, where the GPU is even scarcer, these same techniques, together with quantised and smaller models, are what make it possible to serve useful inference with the hardware available instead of depending on a datacenter that may not be reachable.&lt;/p>
&lt;h2 id="requirement-explainability-traceability-and-governability-nato-pru-nist-au">Requirement: explainability, traceability and governability (NATO PRU, NIST AU)&lt;/h2>
&lt;p>Three of NATO&amp;rsquo;s six Principles of Responsible Use bear directly on infrastructure: &lt;strong>explainability and traceability&lt;/strong>, &lt;strong>governability&lt;/strong> and &lt;strong>responsibility/accountability&lt;/strong>. Plus the audit (AU) controls of NIST. The requirement: being able to reconstruct every AI decision, keep human control and be accountable.&lt;/p>
&lt;p>How we meet it: every inference is recorded as an &lt;strong>auditable trace&lt;/strong> with Langfuse, input, output, model and version, cost, latency and mission, stored in our own components locked behind network policies that prevent it from leaving. That trace provides the traceability required by the PRU and by the NIST AU controls, and is the basis for human oversight, which is exercised at the &lt;strong>gateway&lt;/strong> (review, limits and, crucially, the possibility of intervention and disconnection demanded by the &lt;strong>governability&lt;/strong> principle: an AI system in defence must be able to be deactivated or constrained by the operator). An audit log is only worth anything as evidence if it is intact and ordered in time: that is why events are kept in append-only storage and clocks are kept synchronised by NTP. Instrumentation converges on the OpenTelemetry GenAI semantic conventions.&lt;/p>
&lt;p>Accountability also requires being able to attribute a decision: which model, which version, which input and which operator were involved. That is why each trace links the inference to the identity that requested it and to the exact version of the model artefact that answered, so that the chain &amp;ldquo;who asked what, with which system and with what result&amp;rdquo; can be reconstructed. Without that attribution, NATO&amp;rsquo;s responsibility and accountability principle remains a statement of intent.&lt;/p>
&lt;h2 id="requirement-continuous-accreditation-and-reproducible-operation-cato-cmmc-ens">Requirement: continuous accreditation and reproducible operation (cATO, CMMC, ENS)&lt;/h2>
&lt;p>The sector has replaced one-off accreditation with &lt;strong>cATO&lt;/strong>: an authorisation that is maintained as long as it is continuously demonstrated that the controls remain in place. The ENS and CMMC likewise require configuration management and traceability of changes.&lt;/p>
&lt;p>How we meet it: the entire platform state, deployments, network policies, quotas, RBAC, identity, admission policies, is described in Git and applied by Flux over GitOps. Every change is a revision with an author, a date and a reviewer; every environment is reproducible from its repository. Security policies are code (Kyverno/OPA) evaluated continuously, not a list someone reviews once a year, and compliance monitoring is permanent. This is what cATO looks like in practice: an audit stops being a snapshot and becomes a query against a state that is maintained and verified without interruption, with the ability to reconstruct exactly what was deployed on a given date. A practical benefit is that compliance evidence generates itself: the declarative state in Git, the admission records, the image signatures and the traces are, together, the body of evidence an accreditation needs, produced continuously instead of gathered by hand the week before the inspection.&lt;/p>
&lt;h2 id="the-piece-that-ties-it-all-together-isoiec-42001-and-natos-principles">The piece that ties it all together: ISO/IEC 42001 and NATO&amp;rsquo;s principles&lt;/h2>
&lt;p>Everything above consists of technical controls that satisfy concrete requirements. But a management system is needed to order, evaluate and improve them, with defined roles and responsibilities. In defence, that governance has two complementary references: NATO&amp;rsquo;s &lt;strong>Principles of Responsible Use&lt;/strong>, which set the ethical and operational &amp;ldquo;what&amp;rdquo;, and &lt;strong>ISO/IEC 42001&lt;/strong>, the certifiable standard for AI management systems, which supplies the &amp;ldquo;how&amp;rdquo; of continuous management. Given that the AI Act excludes defence, this combination, PRU plus ISO 42001, is the governance backbone for responsible, demonstrable military AI. It is, precisely, the content of the AI Management Systems course we will publish shortly.&lt;/p>
&lt;h2 id="a-view-of-the-sector-as-of-june-2026">A view of the sector as of June 2026&lt;/h2>
&lt;p>It is worth pinning down the snapshot of the moment, because several currents have converged and they condition any platform decision.&lt;/p>
&lt;p>&lt;strong>An &amp;ldquo;AI-first&amp;rdquo; agenda and AI on classified networks.&lt;/strong> In January 2026, the US Department of Defense set an AI agenda at &amp;ldquo;war speed&amp;rdquo;, and through the spring agreements were closed to deploy generative AI models &lt;strong>on classified networks&lt;/strong>. The signal is clear: military AI is moving from pilot to operation, which raises the demands on the infrastructure that supports it.&lt;/p>
&lt;p>&lt;strong>The substrate is hardened Kubernetes.&lt;/strong> Platform One, Big Bang and Iron Bank consolidate the pattern: Kubernetes clusters with STIG-compliant images, a signed supply chain and cATO. Defence AI is deployed on that same cloud-native substrate, not on bespoke systems.&lt;/p>
&lt;p>&lt;strong>Zero Trust with a deadline.&lt;/strong> Zero Trust adoption targets sit around 2027, which turns strong identity, microsegmentation and continuous verification into requirements with a calendar, not aspirations.&lt;/p>
&lt;p>&lt;strong>Supplier accreditation is tightening.&lt;/strong> CMMC Phase 2, in November 2026, ends self-attestation for anyone handling controlled information: third-party certification against the 110 NIST 800-171 controls will be required. The software supply chain, SBOM and Sigstore signing, moves from good practice to a contractual condition.&lt;/p>
&lt;p>&lt;strong>Data-centric security, operational at last.&lt;/strong> With STANAG 4774/4778 advancing through ratification, confidentiality labelling bound to the data becomes a real requirement for mission software, and not just a recommendation.&lt;/p>
&lt;p>&lt;strong>Defence-specific AI governance.&lt;/strong> NATO&amp;rsquo;s Principles of Responsible Use and the DARB are maturing as the reference framework, occupying the space the AI Act leaves free by excluding defence. And GPU economics, together with the maturity of open-weight models, pushes compute towards in-house infrastructure, precisely where classified data and disconnected operation demand it.&lt;/p>
&lt;p>&lt;strong>Europe pushes its own autonomy.&lt;/strong> In parallel, the EU is strengthening its defence industrial and technological base, through the European Defence Fund and the European Defence Agency, with growing emphasis on technological sovereignty. For a European platform, this reinforces the bet on in-house infrastructure, locally hostable models and minimal dependence on external services: exactly the direction it is built in.&lt;/p>
&lt;p>Where we stand in that picture: with the platform on Kubernetes, eBPF as the network and security substrate, a signed supply chain, GPU scheduling maturing in pre-production, sovereign and disconnectable inference by design, traceability in auditable format and the management framework (PRU plus ISO 42001) as the next step. We are not chasing fashion; we are on the line the sector has decided to advance along.&lt;/p>
&lt;h2 id="summary-requirement-by-requirement">Summary: requirement by requirement&lt;/h2>
&lt;p>Each rule or standard in the sector imposes a requirement, and each requirement is satisfied by a concrete piece of the infrastructure. Zero Trust and verified access, with Keycloak, mTLS and default-deny. The data-centric security of STANAG 4774/4778, with ABAC over classification labels. Segregation by mission and level, with multitenancy and, where appropriate, physical separation and cross-domain. Confidentiality and leak prevention, with Cilium in default-deny and Tetragon. A verifiable supply chain, with STIG-hardened images, SBOM and Sigstore signing plus admission control. Sovereignty and disconnected operation, with local inference over open models and internal registries. Reliable execution and efficiency, with Volcano. The explainability, traceability and governability of NATO&amp;rsquo;s PRU, with auditable traces and human control at the gateway. Continuous accreditation (cATO) and reproducible operation, with GitOps and policy as code. And the governance that ties it all together, with NATO&amp;rsquo;s PRU and ISO 42001.&lt;/p>
&lt;p>It is not a PowerPoint: it is infrastructure that operates, that complies and that, being infrastructure, is measured by efficiency and cost. That, the cost control of an AI platform, is what the next article is about.&lt;/p>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://standards.globalspec.com/std/10266868/stanag-4774">NATO STANAG 4774 — Confidentiality Metadata Label Syntax&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.archtis.com/achieving-nato-stanag-4774-and-4778-compliance/">NATO STANAG 4778 — Metadata Binding Mechanism&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.nato.int/cps/en/natohq/news_227234.htm">NATO — Principios de Uso Responsable de la IA y Estrategia revisada (2024)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://dodcio.defense.gov/Portals/0/Documents/Library/DoD%20Enterprise%20DevSecOps%20Reference%20Design%20-%20CNCF%20Kubernetes%20w-DD1910_cleared_20211022.pdf">DoD Enterprise DevSecOps Reference Design — CNCF Kubernetes&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/DoD-Platform-One/bigbang">DoD — Big Bang / Platform One&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://dodcio.defense.gov/Portals/0/Documents/Library/(U)ZT_RA_v2.0(U)_Sep22.pdf">DoD Zero Trust Reference Architecture v2.0&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://dodcio.defense.gov/Portals/0/Documents/CMMC/CMMC-AlignmentNIST-Standards.pdf">CMMC 2.0 y NIST SP 800-171 (cadena de suministro de defensa)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.sigstore.dev/">Sigstore / Cosign — firma de imágenes y SBOM (CNCF)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://volcano.sh/en/docs/">Volcano — scheduling batch (CNCF)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://cilium.io/">Cilium y Tetragon (eBPF)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.iso.org/standard/81230.html">ISO/IEC 42001 — Sistemas de gestión de IA&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>How we use our infrastructure to meet healthcare standards with AI</title><link>https://blog.lo0.es/en/posts/infrastructure-healthcare-ai-compliance/</link><pubDate>Thu, 18 Jun 2026 10:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/infrastructure-healthcare-ai-compliance/</guid><description>&lt;p>In a healthcare setting, the challenge of putting AI into production is not the model: it is compliance. The rules that apply, both the technical interoperability standards and the law, impose concrete requirements on access control, confidentiality, traceability, data location and governance. A demo on a laptop ignores all of that; a real platform has to satisfy it, and has to be able to prove it to an auditor.&lt;/p>
&lt;p>This article is not a tour of technologies. It is the opposite: we take the requirements the rules impose and, for each one, we show how our infrastructure is used to meet it. The infrastructure is real, two RKE2 clusters (Kubernetes v1.35) with Cilium as the network, GitOps with Flux, Keycloak as the identity server, inference on our own GPUs and, in pre-production, scheduling with Volcano, and every component is here because it solves a regulatory requirement, not because it looks good on a diagram. Like all infrastructure, moreover, it is measured by efficiency and cost, which is what makes compliance sustainable instead of an expense nobody can bear.&lt;/p>
&lt;p>It is worth starting with an idea many people are unaware of: &lt;strong>standards for this already exist in the sector&lt;/strong>, and nothing has to be invented. There are clinical interoperability standards (HL7, FHIR, DICOM and the IHE profiles that combine them), there is a Kubernetes-native standard for packaging and running medical imaging AI (MONAI Deploy and its MONAI Application Package), and there is an emerging standard for what a cluster must look like to run AI with guarantees (the CNCF Kubernetes AI Conformance). Let us first lay out that map of standards, and then walk the requirements one by one.&lt;/p>
&lt;h2 id="the-map-of-standards-in-the-sector">The map of standards in the sector&lt;/h2>
&lt;p>Three families of standards intersect in a healthcare AI platform, and understanding what each one covers avoids reinventing wheels.&lt;/p>
&lt;p>&lt;strong>Clinical interoperability: HL7, FHIR, DICOM and IHE.&lt;/strong> The base formats are well known, HL7 v2 for legacy messaging, FHIR for modern clinical data over REST, DICOM/DICOMweb for imaging. What many people overlook is that there is a body, &lt;strong>IHE (Integrating the Healthcare Enterprise)&lt;/strong>, which does not invent formats but defines &lt;strong>profiles&lt;/strong>: concrete combinations of those standards to solve a use case in an interoperable way. Profiles such as XDS/XCA (document exchange), PIX/PDQ (patient identification and query) and, crucial for security, &lt;strong>ATNA (Audit Trail and Node Authentication)&lt;/strong>, which specifies the four pillars of a secure node: node authentication, user authentication, audit logging and encryption of communications. And it rests on a sibling profile, &lt;strong>Consistent Time (CT)&lt;/strong>, which synchronises the clocks of all nodes: without reliable time, an audit log is worthless as evidence, because what happened can be neither ordered nor correlated. ATNA has also been modernised with RESTful ATNA and the &lt;strong>BALP (Basic Audit Log Patterns)&lt;/strong> profile, which express auditing as &lt;strong>FHIR AuditEvent&lt;/strong> resources. IHE is, in practice, the standard that says &lt;em>how&lt;/em> FHIR and DICOM are combined securely and auditably; it is the natural reference for designing compliance controls.&lt;/p>
&lt;p>&lt;strong>Medical AI on Kubernetes: MONAI Deploy and the MAP.&lt;/strong> For the specific problem of packaging, distributing and running medical imaging AI applications there is a de facto standard: &lt;strong>MONAI Deploy&lt;/strong>, part of the MONAI project (an open source deep learning framework for healthcare imaging, from the PyTorch ecosystem), and its &lt;strong>MONAI Application Package (MAP)&lt;/strong>. A MAP is a container image that complies with a specification defined by the &lt;em>MONAI Deploy working group&lt;/em>, experts from more than a dozen medical imaging institutions, and, according to that specification, &lt;strong>a MAP must support deployment on Kubernetes&lt;/strong> and integrate with DICOM and FHIR for data exchange. That is, there is a standard format for &amp;ldquo;a medical AI app&amp;rdquo; that runs natively on Kubernetes, which is exactly our substrate. This matters for compliance because a MAP encapsulates the model, its dependencies and its input/output contract in a versioned, reproducible form. Around the MAP, MONAI Deploy also supplies the orchestration pieces a clinical environment needs: an &lt;em>Informatics Gateway&lt;/em> that receives DICOM studies and triggers inference, and a &lt;em>Workflow Manager&lt;/em> that chains the steps of a pipeline on Kubernetes. It is not just a packaging format: it is a standard way to take a medical imaging app from the registry to the PACS without bespoke integration at every hospital.&lt;/p>
&lt;p>&lt;strong>AI-ready clusters: CNCF Kubernetes AI Conformance.&lt;/strong> In November 2025 the CNCF launched the &lt;strong>Certified Kubernetes AI Conformance Program&lt;/strong>, which standardises how AI workloads run on Kubernetes through a set of requirements, the &lt;strong>KARs (Kubernetes AI Requirements)&lt;/strong>, covering GPU integration, volume management and job-level &lt;em>networking&lt;/em>, aligned with Kubernetes v1.35. And what is most relevant for healthcare: the programme announced that during 2026 it will be extended with a &lt;strong>Sovereign AI&lt;/strong> track, focused on hardened &lt;em>sandboxing&lt;/em> and data privacy. It is the industry-level formalisation that a cluster running serious AI must meet minimum guarantees, and that data sovereignty is now a first-class category, not a preference.&lt;/p>
&lt;p>With this map on the table, let us walk the requirements.&lt;/p>
&lt;h2 id="requirement-interoperability-on-standards-ehds-mdr-ihe">Requirement: interoperability on standards (EHDS, MDR, IHE)&lt;/h2>
&lt;p>The EHDS requires health data to be exchanged in standard formats, and its profiling is built on FHIR R4; the IHE profiles define how that exchange is done interoperably; the MDR requires data traceability when the software is a medical device. The requirement: the platform cannot speak a dialect of its own.&lt;/p>
&lt;p>How we meet it: we put an &lt;strong>interoperability layer&lt;/strong> as the boundary between the clinical domain and the inference domain. A FHIR server (R4, because it is the normative version and the one the EHDS profiles; R5 has marginal adoption and the sector is waiting for R6, in ballot since January 2026) exposes the clinical resources; a DICOMweb node serves imaging through QIDO-RS, WADO-RS and STOW-RS; and a gateway transforms incoming HL7 v2 into FHIR. On that base we apply the relevant IHE profiles: patient identification with PIX/PDQ so identities are not crossed between sources, and document exchange with XDS/XCA where appropriate. The AI model never touches the clinical database directly: it consumes FHIR and DICOMweb through this layer, which is where data is normalised, profiled and access-controlled. When we deploy an imaging AI application, the aim is to package it as a &lt;strong>MAP&lt;/strong>, so that its contract with DICOM/FHIR is the sector standard and not a bespoke integration.&lt;/p>
&lt;h2 id="requirement-ai-results-return-to-the-clinical-workflow-in-standard-form-dicom-fhir-mdr">Requirement: AI results return to the clinical workflow in standard form (DICOM, FHIR, MDR)&lt;/h2>
&lt;p>Clinical AI does not end when it produces an output: that output has to return to the system in a format the clinician and the record understand, and traceably (MDR). Returning a PDF or a proprietary JSON breaks interoperability and leaves the result outside the clinical record, where nobody audits or reuses it.&lt;/p>
&lt;p>How we meet it: results from imaging models are re-injected as standard DICOM objects, DICOM SR (Structured Report) for findings and measurements, DICOM SEG for segmentations, or &lt;em>Secondary Capture&lt;/em> for visual representations, through STOW-RS, so that the PACS and the viewer treat them like any other series in the study. Results unrelated to imaging are expressed as FHIR resources (&lt;code>Observation&lt;/code>, &lt;code>DiagnosticReport&lt;/code>) linked to the patient and the originating study. This way the AI result is a first-class citizen of the clinical record, referenceable, versioned and auditable, not a loose artefact on a disk. Packaging the application as a MAP helps here too: the output contract towards DICOM/FHIR is part of the package specification itself, not a different piece of glue script at every deployment.&lt;/p>
&lt;h2 id="requirement-minimal-authorised-access-gdpr-ens-ehds-ihe-atna">Requirement: minimal, authorised access (GDPR, ENS, EHDS, IHE ATNA)&lt;/h2>
&lt;p>The GDPR imposes minimisation (art. 5) and reinforced protection of health data as a special category (art. 9); the ENS (Esquema Nacional de Seguridad, the Spanish National Security Framework) requires identification and access control; the EHDS, control over who accesses data and for what; and IHE ATNA requires &lt;strong>node authentication&lt;/strong> and &lt;strong>user authentication&lt;/strong> as pillars of a secure node. Translated into infrastructure: every access to clinical data authenticated, authorised to the minimum and bound to a purpose; and every participating node identified.&lt;/p>
&lt;p>How we meet it: a single identity server, &lt;strong>Keycloak&lt;/strong>, governs all authorisation with OAuth 2.1 and OpenID Connect, profiled by &lt;strong>SMART on FHIR&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Resource- and purpose-oriented scopes.&lt;/strong> SMART distinguishes &lt;code>patient/&lt;/code>, &lt;code>user/&lt;/code> and &lt;code>system/&lt;/code>, and its version 2 separates permissions by operation (&lt;code>create&lt;/code>, &lt;code>read&lt;/code>, &lt;code>update&lt;/code>, &lt;code>delete&lt;/code>, &lt;code>search&lt;/code>). An AI agent receives the minimum, for example &lt;code>system/ImagingStudy.rs&lt;/code> (read and search studies only). It is GDPR minimisation implemented in the token, and the user authentication that ATNA demands.&lt;/li>
&lt;li>&lt;strong>Service identity without shared secrets.&lt;/strong> Unattended workloads use &lt;code>private_key_jwt&lt;/code> (an assertion signed with a private key).&lt;/li>
&lt;li>&lt;strong>Audience binding.&lt;/strong> The token carries the URL of the destination server in &lt;code>aud&lt;/code> and that server validates it, preventing lateral reuse of credentials; it relies on FHIR-specific Keycloak extensions.&lt;/li>
&lt;li>&lt;strong>Node authentication (ATNA) with mTLS.&lt;/strong> Between nodes handling clinical data we require mutual TLS, so that it is not only the user or the service that is authenticated, but the machine or service itself taking part in the exchange.&lt;/li>
&lt;/ul>
&lt;p>DICOMweb is protected with the same token and the same Keycloak. The result is a single identity plane for FHIR, DICOMweb and the MCP servers: one place to demonstrate who can access what.&lt;/p>
&lt;p>The same principle of least privilege is applied one level below, to the Kubernetes plane itself, which the ENS also reaches. Operators do not connect with a universal &lt;code>cluster-admin&lt;/code>: administrator access to the cluster API is federated against Keycloak over OIDC, with roles scoped by namespace, so that technical access too is authenticated, limited and audited. And workloads use &lt;em>service accounts&lt;/em> with no permissions by default, granted only the strict minimum. Whoever administers the platform is subject to the same identity regime as whoever consumes clinical data through it.&lt;/p>
&lt;h2 id="requirement-confidentiality-isolation-and-leak-prevention-gdpr-art-32-ens-atna-encryption">Requirement: confidentiality, isolation and leak prevention (GDPR art. 32, ENS, ATNA encryption)&lt;/h2>
&lt;p>The GDPR (art. 32) requires confidentiality, integrity, availability and resilience, with measures such as data separation and encryption; the ENS details segregation and communications protection controls; ATNA requires encryption of communications between nodes. In a multi-customer platform this translates into two things: that one centre&amp;rsquo;s data is not visible to another, and that a compromised component cannot get data out.&lt;/p>
&lt;p>How we meet it for tenant isolation: each tenant lives in its own &lt;strong>namespace&lt;/strong>, with scoped RBAC, ResourceQuota and LimitRange, a &lt;strong>dedicated database&lt;/strong> per operator (CloudNativePG), not a shared database behind an application filter, and its own object storage space. Identity is segregated by realm in Keycloak. Everything is defined in Git and reconciled by Flux (GitOps): separation is declarative and auditable through change history. Faced with &amp;ldquo;prove to me that centre A cannot see centre B&amp;rsquo;s data&amp;rdquo;, the answer is a commit.&lt;/p>
&lt;p>How we meet it for network confidentiality and exfiltration prevention: the network is managed by &lt;strong>Cilium&lt;/strong> (eBPF) with a &lt;strong>default-deny&lt;/strong> model. A pod has no connectivity beyond what a policy grants it explicitly, flow by flow, which turns exfiltration into something blocked in the kernel. For example, the traceability component can only talk to its analytical database, its PostgreSQL, the object storage, the cache and DNS:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">CiliumNetworkPolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">trazabilidad-egress-minimo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">trazabilidad&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpointSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">trazabilidad-web&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">egressDeny&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">toEntities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">world] &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># nothing leaves the cluster by default&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">egress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">toEndpoints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">postgres&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">toEndpoints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">analitica&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">toEndpoints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># DNS, without this the pod does not resolve&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">k8s:io.kubernetes.pod.namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kube-system&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">k8s-app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kube-dns&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">toPorts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;53&amp;#34;&lt;/span>&lt;span class="nt">, protocol&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">UDP }]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two nuances separate a correct policy from a broken or leaky one: you have to explicitly allow DNS (otherwise the pod does not work) and filter by &lt;strong>endpoint identity&lt;/strong>, not by CIDR, because in an identity-based network filtering by IP ranges does not behave the way intuition suggests. On top of this we add flow observability with Hubble and runtime security with &lt;strong>Tetragon&lt;/strong> (eBPF), which watches process execution, file access and connections inside the container, making it possible to detect and block anomalous behaviour in workloads handling sensitive data. For the encryption in transit required by art. 32 and ATNA, cert-manager manages TLS at the ingresses and Cilium can transparently encrypt traffic between nodes.&lt;/p>
&lt;p>To this are added two baseline controls that art. 32 and the ENS take for granted. First, the &lt;strong>Pod Security Standards&lt;/strong> in restricted profile, containers without privileges, not running as root and with a read-only filesystem where possible, which reduce the surface of a compromised container. Second, &lt;strong>disciplined management of secrets and keys&lt;/strong>: the &lt;code>private_key_jwt&lt;/code> private keys, the certificates and the database credentials do not live in plain manifests in Git, but are managed and rotated, with cert-manager issuing and renewing certificates automatically. A secret leaked into a repository is a potential data breach, and is treated as such from the design stage.&lt;/p>
&lt;h2 id="requirement-data-life-cycle-minimisation-retention-and-pseudonymisation-gdpr-ehds">Requirement: data life cycle, minimisation, retention and pseudonymisation (GDPR, EHDS)&lt;/h2>
&lt;p>The GDPR does not only limit who accesses data: it limits how much data is processed (minimisation, art. 5) and how long it is kept (storage limitation), and it promotes pseudonymisation as a protection measure (art. 32). The requirement: do not accumulate identifiable clinical data longer than necessary, and de-identify where the use case allows.&lt;/p>
&lt;p>How we meet it: the &lt;strong>interoperability layer&lt;/strong> is also the control point for the data life cycle. Pseudonymisation is applied there when the AI workflow does not need direct identity, for example evaluation or tuning over cohorts, keeping the re-identification table separate from the rest of the system and behind its own access policies. One principle we apply strictly: &lt;strong>traces and logs contain no clinical data&lt;/strong>. They record resource identifiers, model, cost, latency and tenant, not the content of the record or the image, so that the observability layer does not become a second store of sensitive data that has to be protected and audited twice over. And &lt;strong>retention policies&lt;/strong> are applied per data type, traces, caches, intermediate copies, with automatic expiry, so that the storage period is a declared, verifiable property, not an oversight accumulating risk on disks nobody reviews.&lt;/p>
&lt;h2 id="requirement-data-location-and-sovereignty-gdpr-ehds-cncf-sovereign-ai">Requirement: data location and sovereignty (GDPR, EHDS, CNCF Sovereign AI)&lt;/h2>
&lt;p>Processing special-category health data by sending it to a third party&amp;rsquo;s inference API, outside the controller&amp;rsquo;s control, is hard to justify under the GDPR and contrary to the spirit of the EHDS. And it is not only a legal requirement: the &lt;strong>Sovereign AI&lt;/strong> track of the CNCF AI Conformance raises it to a first-class technical category for 2026. The implicit requirement is that clinical data must not leave the perimeter.&lt;/p>
&lt;p>How we meet it: inference is served locally, on our own GPUs, with a high-performance serving engine (vLLM). Imaging AI applications are packaged, ideally, as &lt;strong>MAPs&lt;/strong>, which run natively in the cluster without taking data outside. In front of them we put a &lt;strong>model gateway&lt;/strong> through which all inference traffic passes: it is the point where it is decided what runs inside and where, so that clinical data does not leave by default and any exception is explicit and logged. Whether this is viable, and not a luxury, depends on efficiency: to serve in-house at a reasonable cost you have to squeeze the hardware, and that is where scheduling comes in. Sovereignty stops being a promise on a slide and becomes a property of how the inference is built.&lt;/p>
&lt;p>There is a nuance worth spelling out, because it marks the difference between &amp;ldquo;we say the data does not leave&amp;rdquo; and &amp;ldquo;the data cannot leave without a record&amp;rdquo;. The gateway reverses the burden: the default behaviour is local inference, and any egress towards an external service is an exception that has to be enabled explicitly, that is logged and that, in addition, collides with the default-deny network policies if the corresponding destination has not been opened. That is, sovereignty does not rest on a design decision alone, but on several mutually reinforcing controls, gateway, network policy and logging, so that a configuration slip does not translate into a silent leak. For special-category data, that difference between trust and guarantee is exactly what the GDPR asks for when it speaks of data protection &lt;em>by design and by default&lt;/em> (art. 25).&lt;/p>
&lt;h2 id="requirement-reliable-execution-of-critical-workloads-and-efficiency-ens-cncf-ai-conformance">Requirement: reliable execution of critical workloads and efficiency (ENS, CNCF AI Conformance)&lt;/h2>
&lt;p>Keeping inference in-house is only sustainable if the GPU is used well, and certain workloads (an overnight batch of image processing) must run reliably and to completion. The CNCF AI Conformance KARs also formalise GPU integration and job-level &lt;em>networking&lt;/em> requirements that a serious AI cluster must meet. The default Kubernetes scheduler schedules pod by pod, which for distributed workloads produces half-started jobs that hold GPUs without making progress.&lt;/p>
&lt;p>How we meet it: GPU allocation is governed by &lt;strong>Volcano&lt;/strong> (a CNCF incubating project), today in pre-production, with the mechanisms that matter for reliability and efficiency:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Gang scheduling&lt;/strong> (&lt;code>PodGroup&lt;/code> with &lt;code>minAvailable&lt;/code>): a distributed job starts complete or does not start, avoiding GPUs blocked while waiting.&lt;/li>
&lt;li>&lt;strong>Per-tenant queues&lt;/strong> with quota and priority, which make real the fair sharing between tenants that multitenancy only declares.&lt;/li>
&lt;li>&lt;strong>Fair-share (DRF)&lt;/strong> to distribute proportionally and pre-emption so that an urgent workload can overtake a deferrable batch.&lt;/li>
&lt;li>&lt;strong>Topology awareness&lt;/strong> (NVLink/PCIe) to reduce fragmentation.&lt;/li>
&lt;/ul>
&lt;p>This raises the effective utilisation of the GPU fleet, which is what makes sovereign inference economically viable. And it is managed with metrics, not impressions: effective versus allocated utilisation, percentage of time &lt;em>idle&lt;/em>, fragmentation, throughput per GPU (tokens per second) and GPU-hours consumed per tenant. These are the figures that let you decide whether more hardware is needed or whether there is spare capacity badly distributed, and the ones that turn a discussion about cost into a decision with data. On the state of the art: Kubernetes 1.34 took &lt;strong>DRA (Dynamic Resource Allocation)&lt;/strong> to general availability, accelerator allocation by attributes instead of by count, and Kueue covers job quota management; both combine with a gang scheduler such as Volcano. Efficiency is not a FinOps extra: here it is the condition that makes meeting the sovereignty requirement sustainable.&lt;/p>
&lt;p>It is also worth reading these components in the light of the &lt;strong>CNCF AI Conformance&lt;/strong>: its requirements (the KARs) verify that a cluster integrates GPUs correctly, manages the volumes AI workloads need and resolves job-level &lt;em>networking&lt;/em>. They are not ornaments: they are precisely the capabilities that the inference serving, the model and data storage, and the distributed scheduling we have just described rest on. Aligning with that standard is, in practice, a way to guarantee that the platform runs AI with the same properties the industry has agreed as the minimum acceptable, and to be able to prove it, which in a regulated environment is worth as much as complying.&lt;/p>
&lt;h2 id="requirement-activity-logging-and-human-oversight-ai-act-ehds-ihe-atnabalp">Requirement: activity logging and human oversight (AI Act, EHDS, IHE ATNA/BALP)&lt;/h2>
&lt;p>The AI Act, for high-risk systems, and clinical AI almost always is one, requires automatic logging of activity (art. 12) and meaningful human oversight (art. 14); the EHDS imposes traceability over the use of data; and IHE ATNA/BALP defines how that audit log must look, expressing it as &lt;strong>FHIR AuditEvent&lt;/strong> resources. The requirement: being able to reconstruct every access and every decision, in a standard, queryable format.&lt;/p>
&lt;p>How we meet it, on two planes. On the data access plane, audit events (who accessed which clinical resource, when, for what purpose) are emitted following the &lt;strong>FHIR AuditEvent&lt;/strong> pattern of BALP, so that auditing is interoperable and not a proprietary log. On the AI decision plane, each inference is recorded as an &lt;strong>auditable trace&lt;/strong> with Langfuse, prompt, response, model and version, cost, latency and tenant, stored in our own components locked behind network policies that prevent it from leaving. The trace satisfies the AI Act logging and the EHDS traceability, and at the same time serves as the basis for continuous evaluation and for human oversight, which is exercised at the gateway (review, limits, possibility of intervention). Instrumentation converges on the OpenTelemetry GenAI semantic conventions, correlatable with the rest of the observability.&lt;/p>
&lt;p>An audit log is only worth anything as evidence if it is intact and ordered in time. That is why events are kept in append-only storage (with no possibility of later editing) with their retention defined, and node clocks are kept synchronised, the IHE Consistent Time profile put into practice with NTP, so that the sequence of &amp;ldquo;who did what and when&amp;rdquo; is reconstructible and defensible. Without integrity and reliable time, a log is a note, not evidence.&lt;/p>
&lt;h2 id="requirement-availability-continuity-and-recovery-gdpr-art-32-ens">Requirement: availability, continuity and recovery (GDPR art. 32, ENS)&lt;/h2>
&lt;p>Art. 32 of the GDPR requires not only confidentiality and integrity, but &lt;strong>availability and resilience&lt;/strong>, and the ability to restore access to the data after an incident; the ENS details service continuity measures. A clinical system that goes down and does not recover is a breach of the rules, not just an operational incident.&lt;/p>
&lt;p>How we meet it: the platform runs across two sites (site01 and site02), which makes it possible to separate workloads and to have capacity available if a site goes down. The control plane of each cluster is highly available, with several control nodes and replicated etcd. The per-tenant databases are managed by an operator that automates replicas and backups to object storage, and the declarative state in Git allows &lt;strong>rebuilding a complete environment&lt;/strong> from its repository. Recovery does not depend on a particular server or on the memory of whoever set it up, but on re-applying what is versioned. The combination of data copies and configuration reproducibility is, in the terms of art. 32, precisely the ability to restore the service within a reasonable time.&lt;/p>
&lt;h2 id="requirement-reproducible-and-demonstrable-operation-ens-mdriso-13485-ai-act">Requirement: reproducible and demonstrable operation (ENS, MDR/ISO 13485, AI Act)&lt;/h2>
&lt;p>The rules do not only ask that the system do the right thing: they ask that you be able to prove it and reproduce it. The ENS requires configuration management and change traceability; the MDR (via ISO 13485) requires change control and reproducibility; the AI Act, risk management and technical documentation.&lt;/p>
&lt;p>How we meet it: the whole platform state, deployments, network policies, quotas, RBAC, identity configuration, is described in Git and applied by Flux over GitOps. Every change is a revision with an author, a date and a reviewer; every environment is reproducible from its repository. Model versions are recorded in the traces, and packaging the AI as a versioned &lt;strong>MAP&lt;/strong> reinforces reproducibility: you know exactly which artefact was executed. A configuration audit thus becomes a query against the history, with the ability to reconstruct what was deployed on a given date.&lt;/p>
&lt;h2 id="the-piece-that-ties-it-all-together-isoiec-42001">The piece that ties it all together: ISO/IEC 42001&lt;/h2>
&lt;p>Everything above consists of technical controls that satisfy concrete requirements. But the rules also require a management system to order, evaluate and continuously improve them, with defined roles and responsibilities. That piece is &lt;strong>ISO/IEC 42001&lt;/strong>, the certifiable standard for AI management systems: the one that turns &amp;ldquo;we have the controls&amp;rdquo; into &amp;ldquo;we have governed, demonstrable AI&amp;rdquo;. It is, precisely, the content of the AI Management Systems course we will publish shortly, designed to accompany the leap from technical implementation to certifiable governance.&lt;/p>
&lt;h2 id="a-view-of-the-sector-as-of-june-2026">A view of the sector as of June 2026&lt;/h2>
&lt;p>It is worth pinning down the snapshot of the moment, because several currents have converged right now and they condition any platform decision.&lt;/p>
&lt;p>&lt;strong>Everything in AI converges on Kubernetes.&lt;/strong> The CNCF itself described it in 2026 as &amp;ldquo;the great migration&amp;rdquo;: AI platforms, previously scattered across proprietary solutions, are consolidating on Kubernetes as a common substrate. The practical consequence is that capabilities once reserved for HPC systems, gang scheduling, topology awareness, integration with the network fabric, are now first-class citizens in Kubernetes.&lt;/p>
&lt;p>&lt;strong>Accelerator allocation is being standardised.&lt;/strong> With &lt;strong>DRA at general availability&lt;/strong> (Kubernetes 1.34) the old model of &amp;ldquo;counting GPUs&amp;rdquo; gives way to allocation by device attributes, with dynamic partitioning. It is a fundamental change in how expensive hardware is shared out, and our clusters (v1.35) are already on the branch where this is real.&lt;/p>
&lt;p>&lt;strong>Conformance marks for AI are appearing.&lt;/strong> The &lt;strong>CNCF Kubernetes AI Conformance&lt;/strong> (launched in November 2025, with the KARs) defines what makes a cluster &amp;ldquo;AI-ready&amp;rdquo;, and in 2026 it incorporates the &lt;strong>Sovereign AI&lt;/strong> track: hardened sandboxing and data privacy as formal requirements. For healthcare this is enormous, because it turns data sovereignty, which we had been defending out of legal obligation, into a certifiable property of the platform.&lt;/p>
&lt;p>&lt;strong>Medical imaging AI has its packaging standard.&lt;/strong> &lt;strong>MONAI Deploy&lt;/strong> and the &lt;strong>MAP&lt;/strong> are maturing as the standard way to build, validate and run medical imaging applications, with Kubernetes as the target and DICOM/FHIR as the contract. Anyone deploying serious imaging AI in 2026 looks towards the MAP, not towards bespoke integrations.&lt;/p>
&lt;p>&lt;strong>Regulation enters its application window.&lt;/strong> The &lt;strong>EHDS&lt;/strong> has been in force since March 2025 and its milestones are approaching: data access bodies towards 2027, cross-border primary use towards 2029, all profiled on FHIR R4. The &lt;strong>AI Act&lt;/strong> saw its high-risk deadlines postponed by the &lt;em>Digital Omnibus&lt;/em> (Annex III to December 2027, Annex I to August 2028), but its obligations, risk management, logging, human oversight, do not move. And the &lt;strong>FHIR R4 to R6&lt;/strong> transition is being planned skipping R5. The picture is of a sector where the technology (Kubernetes, eBPF, DRA, MAP) and the rules (EHDS, AI Act, IHE) are finally advancing at the same time.&lt;/p>
&lt;p>&lt;strong>GPU economics pushes in-house.&lt;/strong> The resource remains scarce and expensive, and that has two mutually reinforcing effects. On one hand, the pressure for efficiency, utilisation, fragmentation, density, is at its peak, which is why scheduling (Volcano) and attribute-based allocation (DRA) are at the centre of the debate. On the other, the maturing of &lt;strong>open-weight models&lt;/strong>, which perform better and better on modest hardware, has made it viable to serve quality inference locally without depending on an external API. The combination, competent open models plus efficiency tools on Kubernetes, is exactly what turns data sovereignty, in healthcare, from a costly aspiration into a reasonable option. The 2026 trend is not only &amp;ldquo;AI in the cloud&amp;rdquo;; it is also a pragmatic return of compute to in-house infrastructure where the data and the cost demand it.&lt;/p>
&lt;p>Where we stand in that picture: with the platform on Kubernetes, eBPF as the network and security substrate, GPU scheduling maturing in pre-production, sovereign inference by design, traceability in auditable format and the management framework (ISO 42001) as the next step. We are not chasing fashion; we are exactly on the line the sector has decided to advance along.&lt;/p>
&lt;h2 id="summary-requirement-by-requirement">Summary: requirement by requirement&lt;/h2>
&lt;p>The thesis is simple: each rule or standard in the sector imposes a requirement, and each requirement is satisfied by a concrete piece of the infrastructure. The interoperability of the EHDS and the IHE profiles, with a FHIR R4 plus DICOMweb layer and, for imaging AI, MAP packaging. The minimal, authorised access of the GDPR and the ENS and the authentication of ATNA, with Keycloak, SMART on FHIR and mTLS. The confidentiality and leak prevention of art. 32, with multitenancy, Cilium in default-deny and Tetragon. Data sovereignty (GDPR/EHDS and CNCF Sovereign AI), with local inference made viable by Volcano&amp;rsquo;s efficiency. The logging and oversight of the AI Act and the auditing of ATNA/BALP, with traceability in FHIR AuditEvent format. The reproducible operation of the ENS and the MDR, with GitOps and versioned MAPs. And the governance that ties it all together, with ISO 42001.&lt;/p>
&lt;p>It is not a PowerPoint: it is infrastructure that runs, that complies and that, being infrastructure, is measured by efficiency and cost. That, the cost control of a healthcare AI platform, is what the next article is about.&lt;/p>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.hl7.org/fhir/">HL7 FHIR — especificación y versiones&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://hl7.org/fhir/smart-app-launch/">SMART App Launch (SMART on FHIR) v2&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://profiles.ihe.net/ITI/TF/Volume1/ch-9.html">IHE — Audit Trail and Node Authentication (ATNA)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://profiles.ihe.net/ITI/BALP/index.html">IHE — Basic Audit Log Patterns (BALP) con FHIR AuditEvent&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.dicomstandard.org/using/dicomweb">DICOMweb — DICOM Standard&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/Project-MONAI/monai-deploy/blob/main/guidelines/monai-application-package.md">MONAI Deploy y MONAI Application Package (MAP)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/cncf/k8s-ai-conformance">CNCF — Certified Kubernetes AI Conformance&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://kubernetes.io/docs/concepts/scheduling-eviction/dynamic-resource-allocation/">Kubernetes — Dynamic Resource Allocation (DRA)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://volcano.sh/en/docs/">Volcano — scheduling batch (CNCF)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://cilium.io/">Cilium y Tetragon (eBPF)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://health.ec.europa.eu/ehealth-digital-health-and-care/european-health-data-space-regulation-ehds_en">Reglamento (UE) 2025/327 — EHDS&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://artificialintelligenceact.eu/">EU AI Act (Reglamento (UE) 2024/1689)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.iso.org/standard/81230.html">ISO/IEC 42001 — Sistemas de gestión de IA&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Volcano and Kueue: gang scheduling, queues and GPU quotas for distributed workloads on Kubernetes</title><link>https://blog.lo0.es/en/posts/volcano-kueue-gang-scheduling-gpu-quotas-kubernetes/</link><pubDate>Tue, 16 Jun 2026 13:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/volcano-kueue-gang-scheduling-gpu-quotas-kubernetes/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Volcano&lt;/strong> (volcano-sh, CNCF incubating) is a &lt;strong>complete batch scheduler&lt;/strong> that replaces or complements the kube-scheduler: it places pods with gang semantics (all-or-nothing via &lt;code>PodGroup&lt;/code>/&lt;code>minMember&lt;/code>), manages queues with priority, DRF fair-share and preemption between queues, and understands network and NUMA topology.&lt;/p>
&lt;p>&lt;strong>Kueue&lt;/strong> (kubernetes-sigs/kueue) is a &lt;strong>queue and quota manager at the Job level&lt;/strong>: it does NOT place pods (it delegates to the kube-scheduler or to Volcano), but it decides when a workload can be admitted according to available quota (&lt;code>ClusterQueue&lt;/code>/&lt;code>LocalQueue&lt;/code>/&lt;code>Cohort&lt;/code>), with fair sharing, borrowing between teams and preemption by priority. It natively integrates Job, JobSet, RayJob, all the Kubeflow operators and more.&lt;/p>
&lt;p>The winning combination in production for multi-tenant GPU workloads is: &lt;strong>Kueue for quota and queues + Volcano (or the sig-scheduler coscheduling plugin) for the job&amp;rsquo;s gang&lt;/strong>.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>Imagine a club with limited capacity and a dance floor inside.&lt;/p>
&lt;p>&lt;strong>Kueue is the doorman and the booking manager&lt;/strong>: it checks whether the team&amp;rsquo;s quota (its reserved capacity) allows the group in, applies the fair waiting list, borrows capacity from other teams if any are idle, and takes the space back when the owner needs it. But the doorman does not decide where each person sits inside the venue.&lt;/p>
&lt;p>&lt;strong>Volcano is the head waiter&lt;/strong>: once the group has permission to come in, he decides which tables they sit at, makes sure the whole group sits down at once or nobody comes in (gang), picks the tables according to topology (who needs to talk to whom) and throws out lower-priority groups to make room if necessary.&lt;/p>
&lt;p>Without a doorman (Kueue), the head waiter does not know how many groups he can take at once or whether a team is exceeding its capacity. Without a head waiter (Volcano), the doorman lets the group in but its members scatter across the available tables on their own, and the party of 8 that needs to sit together never manages it.&lt;/p>
&lt;hr>
&lt;h2 id="the-problem-neither-solves-by-default-the-kube-scheduler">The problem neither solves by default: the kube-scheduler&lt;/h2>
&lt;p>Kubernetes&amp;rsquo; &lt;code>kube-scheduler&lt;/code> is a pod scheduler, not a job scheduler. It assigns pods one by one to the most suitable node according to available resources and affinity constraints. For a distributed training workload that needs, say, 8 pods with 4 GPUs each (32 GPUs in total across 8 nodes of 4×H100), the standard scheduler does the following:&lt;/p>
&lt;ol>
&lt;li>It looks for a node with 4 available GPUs. It finds one. It schedules pod 1.&lt;/li>
&lt;li>It looks for another node with 4 GPUs. It finds one. It schedules pod 2.&lt;/li>
&lt;li>It carries on until it reaches pod 6 and it turns out there are no longer any nodes with 4 free GPUs: the cluster has exactly 32 GPUs and other workloads are using some of them.&lt;/li>
&lt;li>Pods 1–5 are &lt;code>Running&lt;/code>. Pods 6–8 are &lt;code>Pending&lt;/code>.&lt;/li>
&lt;li>Pods 1–5 can do nothing without the others: a distributed PyTorch job needs all the workers to start before the &lt;code>torchrun&lt;/code> process can begin. &lt;strong>It waits with the resources occupied. Deadlock.&lt;/strong>&lt;/li>
&lt;/ol>
&lt;p>This is not a bug, it is the design: the kube-scheduler has no concept of &amp;ldquo;schedule this group of pods only if you can schedule all of them&amp;rdquo;. As a result:&lt;/p>
&lt;ul>
&lt;li>The resources of pods 1–5 are locked up without producing work.&lt;/li>
&lt;li>Other jobs that could run with the partial resources also wait.&lt;/li>
&lt;li>If several jobs are in this situation, the cluster can end up with fragmented resources, no job running and everyone in circular deadlock.&lt;/li>
&lt;/ul>
&lt;p>On top of that, the kube-scheduler has no notion of:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Queues&lt;/strong> per team or project with relative priority.&lt;/li>
&lt;li>&lt;strong>Quotas&lt;/strong> of resources per team with the ability to borrow from idle teams.&lt;/li>
&lt;li>&lt;strong>Fair-share&lt;/strong>: if team A has spent weeks using 80 % of the cluster, it should wait longer than team B, which has spent weeks idle.&lt;/li>
&lt;li>&lt;strong>Inter-queue preemption&lt;/strong>: evicting a lower-priority job from another team to make room for this team&amp;rsquo;s urgent job.&lt;/li>
&lt;/ul>
&lt;p>Solving any of these problems requires adding a layer on top of the scheduler. Volcano and Kueue are the two dominant OSS solutions in 2026, with complementary architectural approaches.&lt;/p>
&lt;hr>
&lt;h2 id="volcano-the-batch-scheduler">Volcano: the batch scheduler&lt;/h2>
&lt;h3 id="what-it-is-and-what-it-replaces">What it is and what it replaces&lt;/h3>
&lt;p>Volcano (volcano-sh) is a &lt;strong>Kubernetes-native batch scheduler&lt;/strong> accepted by the CNCF as its first and only official container batch scheduling project (&lt;a href="https://volcano.sh/en/docs/">volcano.sh/en/docs&lt;/a>). At version v1.15.x as of June 2026, with CNCF incubating status.&lt;/p>
&lt;p>Volcano &lt;strong>is not an addon to the kube-scheduler&lt;/strong>: it is an alternative scheduler (or a complementary one, depending on the configuration) that places pods. It is installed as a deployment, and jobs that want to benefit from its capabilities must use the &lt;code>volcano&lt;/code> scheduler class in their pod spec (&lt;code>schedulerName: volcano&lt;/code>) or use the &lt;code>VolcanoJob&lt;/code> CRD.&lt;/p>
&lt;p>Volcano&amp;rsquo;s central value proposition is that it treats groups of pods as atomic scheduling units, not individual pods. That is what makes it possible to solve the deadlock described above.&lt;/p>
&lt;h3 id="gang-scheduling-via-podgroup">Gang scheduling via PodGroup&lt;/h3>
&lt;p>Volcano&amp;rsquo;s central mechanism is the &lt;code>PodGroup&lt;/code>: a CRD that groups a job&amp;rsquo;s pods and defines how many must be schedulable before Volcano starts any of them.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># PodGroup for a distributed PyTorch training job: 8 workers, minimum 8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">scheduling.volcano.sh/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PodGroup&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pytorch-train-pg&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ml-training&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minMember: 8 # all-or-nothing&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">if there is no room for 8, none starts&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minResources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;32&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 8 pods × 4 GPUs = 32 GPUs minimum in the cluster&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">queue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">team-datos &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># which Volcano queue this job is assigned to&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">priorityClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">high-priority&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>minMember&lt;/code> parameter implements &lt;strong>all-or-nothing&lt;/strong> semantics: Volcano only assigns nodes to the group&amp;rsquo;s pods when it can assign at least &lt;code>minMember&lt;/code> pods simultaneously. If the cluster does not have capacity for 8 GPU pods at this moment, no pod in the group moves out of &lt;code>Pending&lt;/code>. Nothing is blocked, nothing is fragmented.&lt;/p>
&lt;p>&lt;code>minMember&lt;/code> can be lower than the job&amp;rsquo;s total pod count: this allows &lt;strong>elastic gang scheduling&lt;/strong>, where the job can start with fewer workers and scale up, useful for jobs that tolerate a reduced worker count.&lt;/p>
&lt;p>For a job&amp;rsquo;s pods to be associated with the PodGroup, they carry the annotation:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># Pod spec of the PyTorch worker&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scheduling.volcano.sh/pod-group-name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pytorch-train-pg&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">schedulerName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">volcano&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">trainer&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pytorch/pytorch:2.5-cuda12.4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="queue-queues-with-quotas-and-priority">Queue: queues with quotas and priority&lt;/h3>
&lt;p>Volcano introduces the &lt;code>Queue&lt;/code> CRD to manage multiple tenants with independent quotas:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">scheduling.volcano.sh/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Queue&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">team-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">weight&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># relative weight for fair-share between queues (proportion plugin)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">capability&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># absolute ceiling of resources this queue can use&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">guarantee&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># guaranteed resource, never lent to other queues&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resource&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;8&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">reclaimable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># if true, others can reclaim the resources it lends when they need them&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">scheduling.volcano.sh/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Queue&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">team-ia&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">weight&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">capability&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;8&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">guarantee&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resource&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">reclaimable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>weight&lt;/code> field feeds the &lt;strong>proportion plugin&lt;/strong>: queues compete for the cluster&amp;rsquo;s available resources in proportion to their weight. A cluster with 32 GPUs and two queues of weight 4 and 2 splits the GPUs in a 4:2 ratio (≈21 and 11 GPUs respectively) when both are saturated.&lt;/p>
&lt;h3 id="scheduler-plugins-drf-binpack-topology-aware">Scheduler plugins: DRF, binpack, topology-aware&lt;/h3>
&lt;p>Volcano implements its scheduling logic as a pipeline of actions and plugins:&lt;/p>
&lt;p>&lt;strong>Actions&lt;/strong> (what the scheduler does in each cycle):&lt;/p>
&lt;ul>
&lt;li>&lt;code>enqueue&lt;/code>: moves jobs from the waiting queue to schedulable when quota is available.&lt;/li>
&lt;li>&lt;code>allocate&lt;/code>: assigns nodes to schedulable pods.&lt;/li>
&lt;li>&lt;code>preempt&lt;/code>: evicts lower-priority pods to make room for higher-priority ones within the same queue.&lt;/li>
&lt;li>&lt;code>reclaim&lt;/code>: evicts pods from other queues that are using more than their &lt;code>guarantee&lt;/code> to return resources to the owner.&lt;/li>
&lt;li>&lt;code>backfill&lt;/code>: fills idle resources with best-effort jobs that do not interfere with the rest.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Plugins&lt;/strong> relevant to GPU workloads:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Plugin&lt;/th>
&lt;th>What it does&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>gang&lt;/code>&lt;/td>
&lt;td>Implements the PodGroup&amp;rsquo;s all-or-nothing semantics&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>proportion&lt;/code>&lt;/td>
&lt;td>Fair-share by queue weight (proportional quota)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>capacity&lt;/code>&lt;/td>
&lt;td>Quotas with &lt;code>guarantee&lt;/code>/&lt;code>capability&lt;/code> and reclaim; a more expressive alternative to proportion&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>drf&lt;/code>&lt;/td>
&lt;td>Dominant Resource Fairness: multi-dimensional fair-share (CPU, memory, GPU)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>binpack&lt;/code>&lt;/td>
&lt;td>Packs pods onto the fullest nodes; reduces GPU fragmentation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>priority&lt;/code>&lt;/td>
&lt;td>Orders jobs by priority within the same queue&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>nodeorder&lt;/code>&lt;/td>
&lt;td>Node scoring according to multiple criteria (affinity, resources, spread)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>task-topology&lt;/code>&lt;/td>
&lt;td>Affinity between pods of the same job (inter-GPU communication)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>numa-aware&lt;/code>&lt;/td>
&lt;td>NUMA affinity: aligns pods with the node&amp;rsquo;s NUMA socket to reduce memory latency&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="network-and-numa-topology-v111">Network and NUMA topology (v1.11+)&lt;/h3>
&lt;p>Volcano v1.11 (February 2025) introduced &lt;strong>Network Topology Aware Scheduling&lt;/strong> as a first-class feature (&lt;a href="https://www.cncf.io/blog/2025/03/05/volcano-v1-11-released-a-new-era-of-cloud-native-scheduling-for-ai-and-big-data/">CNCF blog, March 2025&lt;/a>). Distributed training jobs in a datacenter with a hierarchical network structure (spine/leaf, blocks of nodes with NVSwitch) can declare topology constraints:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># VolcanoJob with a network topology constraint&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">batch.volcano.sh/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Job&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llm-pretrain&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ml-training&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minAvailable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">schedulerName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">volcano&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">queue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">team-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">plugins&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ssh&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">svc&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">networkTopology&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mode: hard # hard&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">the job MUST satisfy the constraint&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">highestTierAllowed&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">block &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># pods cannot span beyond a network &amp;#34;block&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tasks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">worker&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">trainer&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nvcr.io/nvidia/pytorch:25.01-py3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>highestTierAllowed: block&lt;/code> semantics instruct Volcano to place the 8 pods within the same network block (for example, all the nodes under the same access switch), minimising the inter-block traffic that degrades a distributed all-reduce.&lt;/p>
&lt;p>NUMA awareness works in a similar way: with the &lt;code>numa-aware&lt;/code> plugin, pods request a NUMA policy (&lt;code>single-numa-node&lt;/code>, &lt;code>restricted&lt;/code>, &lt;code>best-effort&lt;/code>) and Volcano selects nodes where the requested CPU, memory and GPU resources are in the same NUMA domain, avoiding the remote memory access overhead (NUMA-crossing) that can degrade training throughput by 15-30 % on multi-socket nodes.&lt;/p>
&lt;h3 id="gpu-virtualization-in-v111">GPU virtualization in v1.11+&lt;/h3>
&lt;p>Volcano v1.11 also introduces support for &lt;strong>dynamic MIG and vCUDA&lt;/strong>: instead of declaring &lt;code>nvidia.com/gpu: 1&lt;/code> for a whole GPU, workloads can declare &lt;code>nvidia.com/gpu-memory: 20Gi&lt;/code> and Volcano (with the corresponding device plugin) dynamically provisions the MIG instance or the vCUDA partition. This is [project marketing with no independent benchmarks published as of June 2026], but the feature&amp;rsquo;s architecture is documented in the code.&lt;/p>
&lt;h3 id="what-it-replaces-or-adds-to-the-default-scheduler">What it replaces or adds to the default scheduler&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Capability&lt;/th>
&lt;th>kube-scheduler&lt;/th>
&lt;th>Volcano&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Placing pods on nodes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes (it replaces it for workloads marked with &lt;code>schedulerName: volcano&lt;/code>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Gang scheduling (all-or-nothing)&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Yes (PodGroup + minMember)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Queues with priority&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Yes (Queue CRD)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Inter-queue fair-share&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Yes (DRF, proportion, capacity plugins)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Inter-queue preemption&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Yes (reclaim action)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Topology-aware (NUMA, network)&lt;/td>
&lt;td>Partial (node affinity)&lt;/td>
&lt;td>Yes (dedicated plugins)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Elastic gang (minMember &amp;lt; total)&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Best-effort backfill&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Framework integrations&lt;/td>
&lt;td>Partial&lt;/td>
&lt;td>MPI, PyTorch, Ray, TensorFlow, Spark, Flink, Horovod&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="kueue-the-queue-and-quota-manager">Kueue: the queue and quota manager&lt;/h2>
&lt;h3 id="what-it-is-and-what-it-does-not-do">What it is and what it does NOT do&lt;/h3>
&lt;p>Kueue (kubernetes-sigs/kueue) is a &lt;strong>Kubernetes-native system that manages quotas and how jobs consume them&lt;/strong> (&lt;a href="https://kueue.sigs.k8s.io/docs/overview/">kueue.sigs.k8s.io&lt;/a>). Kueue decides when a job must wait, when it should be admitted (pods can be created) and when it should be evicted (active pods must be deleted).&lt;/p>
&lt;p>Kueue&amp;rsquo;s central design principle is explicit in its documentation: &lt;strong>avoid duplicating mature functionality of Kubernetes components&lt;/strong>. Autoscaling is the cluster-autoscaler&amp;rsquo;s responsibility. Pod-to-node scheduling is the kube-scheduler&amp;rsquo;s responsibility. Job lifecycle management is the kube-controller-manager&amp;rsquo;s responsibility. Kueue replaces none of them: it sits on top as a layer of &lt;strong>admission control and quota management at the Job level&lt;/strong>.&lt;/p>
&lt;p>This is the fundamental distinction: &lt;strong>Kueue does not place pods on nodes&lt;/strong>. When Kueue admits a workload, it simply allows the corresponding job controller to create the pods, and those pods are scheduled by the kube-scheduler (or by Volcano, if it is configured as the scheduler).&lt;/p>
&lt;h3 id="the-four-core-objects">The four core objects&lt;/h3>
&lt;p>&lt;strong>ResourceFlavor&lt;/strong>: maps abstract resources to concrete groups of physical nodes.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kueue.x-k8s.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ResourceFlavor&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">h100-sxm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">accelerator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">h100-sxm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">node-pool&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gpu-training&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tolerations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;nvidia.com/gpu&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Exists&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">effect&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;NoSchedule&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>ClusterQueue&lt;/strong>: defines the resource quota per flavor for a tenant. A cluster-scoped object.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kueue.x-k8s.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ClusterQueue&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cq-team-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cohort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llm-platform &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># cohort it belongs to (can lend/borrow)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">queueingStrategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">BestEffortFIFO&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespaceSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kubernetes.io/metadata.name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ns-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resourceGroups&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">coveredResources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;nvidia.com/gpu&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;cpu&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;memory&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">flavors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">h100-sxm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;nvidia.com/gpu&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nominalQuota&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">16&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># GPUs guaranteed for this team&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">borrowingLimit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># can take up to 8 additional GPUs from the cohort&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">lendingLimit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># can lend up to 8 of its 16 nominal GPUs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;cpu&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nominalQuota&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;128&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;memory&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nominalQuota&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;512Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">preemption&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">reclaimWithinCohort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LowerPriority &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># reclaims lent quota by evicting lower-priority jobs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">borrowWithinCohort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LowerPriority&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">withinClusterQueue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LowerPriority&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>LocalQueue&lt;/strong>: the namespace-scoped entry point for a team&amp;rsquo;s workloads. Jobs point to their LocalQueue; Kueue maps them to the corresponding ClusterQueue.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kueue.x-k8s.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LocalQueue&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">lq-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ns-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">clusterQueue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cq-team-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Cohort&lt;/strong>: groups ClusterQueues that can lend quota to each other. It is not a standalone CRD; it is declared as a field in the ClusterQueue (&lt;code>spec.cohort: name&lt;/code>). Kueue aggregates the available quota of all the ClusterQueues in the cohort and lets any of them borrow what the others are not using, respecting the &lt;code>borrowingLimit&lt;/code> and &lt;code>lendingLimit&lt;/code>.&lt;/p>
&lt;h3 id="fair-sharing-and-preemption">Fair sharing and preemption&lt;/h3>
&lt;p>Kueue implements &lt;strong>Fair Sharing&lt;/strong> as the ordering policy for the queue of pending workloads (&lt;a href="https://kueue.sigs.k8s.io/docs/concepts/fair_sharing/">kueue.sigs.k8s.io/docs/concepts/fair_sharing&lt;/a>): when several workloads compete for quota in the cohort, those belonging to ClusterQueues with higher accumulated historical usage have lower admission priority. This implements equitable sharing without permanently blocking any team.&lt;/p>
&lt;p>Preemption in Kueue operates along two dimensions:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>reclaimWithinCohort&lt;/code>&lt;/strong>: the ClusterQueue that lends quota reclaims it by evicting workloads that are using it on loan, according to a priority policy.&lt;/li>
&lt;li>&lt;strong>&lt;code>withinClusterQueue&lt;/code>&lt;/strong>: within the same ClusterQueue, lower-priority workloads are evicted to make way for higher-priority workloads from the same team.&lt;/li>
&lt;/ul>
&lt;h3 id="gang-semantics-in-kueue-all-or-nothing-with-ready-pods">Gang semantics in Kueue: all-or-nothing with ready Pods&lt;/h3>
&lt;p>Kueue provides gang admission at the Job level: it admits the complete workload only when all the necessary quota is available. If a RayJob needs 8 GPUs (1 head + 7 workers), Kueue does not admit the workload until 8 GPUs are available in the ClusterQueue (or borrowed from the cohort). &lt;code>waitForPodsReady&lt;/code> with a timeout adds a second guarantee: if the created pods do not become &lt;code>Ready&lt;/code> within the configured time, Kueue re-queues the workload and releases the quota (&lt;a href="https://kueue.sigs.k8s.io/docs/tasks/manage/setup_wait_for_pods_ready/">kueue.sigs.k8s.io/docs/tasks/manage/setup_wait_for_pods_ready&lt;/a>).&lt;/p>
&lt;p>This is &lt;strong>gang semantics at the admission level&lt;/strong>, not at the pod placement level. It guarantees that the quota is available before creating the pods, but it does not guarantee that the kube-scheduler can place them all on concrete nodes at the same time. For that second guarantee you need Volcano or the coscheduling plugin.&lt;/p>
&lt;h3 id="topology-aware-scheduling-tas">Topology-Aware Scheduling (TAS)&lt;/h3>
&lt;p>Kueue v0.10+ introduces &lt;strong>Topology-Aware Scheduling&lt;/strong> (&lt;a href="https://kueue.sigs.k8s.io/docs/concepts/topology_aware_scheduling/">kueue.sigs.k8s.io/docs/concepts/topology_aware_scheduling&lt;/a>): it allows node topologies (blocks, subblocks, hosts) to be defined and workloads to request co-location levels. Kueue only admits the workload when it can satisfy the topological constraint, and it adds node selectors and taints at admission time so that the scheduler places the pods in the right topology.&lt;/p>
&lt;p>TAS is configured with the &lt;code>Topology&lt;/code> CRD:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kueue.x-k8s.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Topology&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">datacenter-topology&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">levels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">nodeLabel&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;topology.kubernetes.io/block&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">nodeLabel&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;topology.kubernetes.io/rack&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">nodeLabel&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;kubernetes.io/hostname&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the ResourceFlavor references the topology:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kueue.x-k8s.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ResourceFlavor&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">h100-sxm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">accelerator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">h100-sxm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">topologyName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">datacenter-topology&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="framework-integrations">Framework integrations&lt;/h3>
&lt;p>Kueue has built-in integration (no additional code) for the following workload types, activated with an annotation on the job:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kueue.x-k8s.io/queue-name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">lq-datos &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># points to the team&amp;#39;s LocalQueue&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The supported workload types include: &lt;code>batch/Job&lt;/code>, &lt;code>JobSet&lt;/code>, &lt;code>RayJob&lt;/code>, &lt;code>RayCluster&lt;/code>, &lt;code>PytorchJob&lt;/code>, &lt;code>TFJob&lt;/code>, &lt;code>MPIJob&lt;/code>, &lt;code>JAXJob&lt;/code>, &lt;code>PaddleJob&lt;/code>, &lt;code>XGBoostJob&lt;/code>, &lt;code>TrainJob&lt;/code>, &lt;code>AppWrapper&lt;/code>, &lt;code>LeaderWorkerSet&lt;/code>, &lt;code>Deployment&lt;/code>, &lt;code>StatefulSet&lt;/code>, and plain &lt;code>Pod&lt;/code>/&lt;code>PodGroup&lt;/code>.&lt;/p>
&lt;p>For LLM workloads, the directly relevant cases:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>RayJob&lt;/strong> (distributed training with Ray Train): Kueue admits the RayJob when there is quota for the whole Ray cluster (head + workers). Documented at &lt;a href="https://docs.ray.io/en/latest/cluster/kubernetes/examples/rayjob-kueue-gang-scheduling.html">docs.ray.io&lt;/a>.&lt;/li>
&lt;li>&lt;strong>PyTorchJob&lt;/strong> (Kubeflow Training Operator): gang admission of the complete job.&lt;/li>
&lt;li>&lt;strong>JobSet&lt;/strong>: for coordinated multi-replica jobs (LWS, multi-step pipelines).&lt;/li>
&lt;li>&lt;strong>Deployment/StatefulSet&lt;/strong>: for continuous inference, allowing inference GPU quota to be managed the same way as training quota.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="the-key-distinction-volcano-places-kueue-admits">The key distinction: Volcano places, Kueue admits&lt;/h2>
&lt;p>This table sums up the fundamental architectural difference:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dimension&lt;/th>
&lt;th>Volcano&lt;/th>
&lt;th>Kueue&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Main role&lt;/strong>&lt;/td>
&lt;td>Scheduler (places pods on nodes)&lt;/td>
&lt;td>Admission controller + quota manager (decides when to create pods)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Gang scheduling&lt;/strong>&lt;/td>
&lt;td>Yes, at the placement level (PodGroup/minMember)&lt;/td>
&lt;td>Yes, at the admission level (all-or-nothing on quota)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Multi-tenant quotas&lt;/strong>&lt;/td>
&lt;td>Yes (Queue with capability/guarantee)&lt;/td>
&lt;td>Yes (ClusterQueue with nominalQuota/borrowingLimit)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Cohorts / borrowing&lt;/strong>&lt;/td>
&lt;td>Partial (reclaimable between queues)&lt;/td>
&lt;td>Yes (Cohort with explicit lendingLimit/borrowingLimit)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Fair sharing&lt;/strong>&lt;/td>
&lt;td>Yes (DRF plugin)&lt;/td>
&lt;td>Yes (Fair Sharing based on historical usage)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Preemption&lt;/strong>&lt;/td>
&lt;td>Yes (preempt + reclaim actions)&lt;/td>
&lt;td>Yes (reclaimWithinCohort, withinClusterQueue)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Topology/NUMA&lt;/strong>&lt;/td>
&lt;td>Yes (dedicated plugins, network topology, NUMA-aware)&lt;/td>
&lt;td>Yes (TAS, topology levels in ResourceFlavor)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Framework integrations&lt;/strong>&lt;/td>
&lt;td>Volcano Job (MPI, PyTorch, Ray, TF, Spark, Flink)&lt;/td>
&lt;td>Native: Job, JobSet, RayJob, Kubeflow, LWS, AppWrapper, Deployment, StatefulSet&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Who places the pods&lt;/strong>&lt;/td>
&lt;td>Volcano&lt;/td>
&lt;td>kube-scheduler (or Volcano if configured)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Installation footprint&lt;/strong>&lt;/td>
&lt;td>Medium-high (its own scheduler, CRDs, webhook, metrics)&lt;/td>
&lt;td>Light (controller, CRDs, webhook; does not replace the scheduler)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Maturity / status&lt;/strong>&lt;/td>
&lt;td>CNCF incubating; v1.15 (June 2026); production at Huawei, Baidu, DiDi&lt;/td>
&lt;td>kubernetes-sigs; API v1beta2; production at Google GKE, Red Hat OpenShift 4.20, Runway ML&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Adoption curve&lt;/strong>&lt;/td>
&lt;td>Steeper (requires changing schedulerName or using the VolcanoJob CRD)&lt;/td>
&lt;td>Gentler (adds labels to existing jobs; does not change the scheduler)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="how-they-coexist-the-production-pattern">How they coexist: the production pattern&lt;/h3>
&lt;p>In production, Kueue and Volcano are &lt;strong>complementary, not mutually exclusive&lt;/strong>. The most common pattern in 2026 for multi-tenant GPU clusters is:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Kueue&lt;/strong> manages the global quota: which team can use how many GPUs, how much it can borrow, when a job goes into a queue versus being admitted.&lt;/li>
&lt;li>&lt;strong>Volcano&lt;/strong> does the gang scheduling at the pod level: once Kueue admits the job (the quota is available), Volcano places the pods, making sure they are all placed simultaneously on compatible nodes.&lt;/li>
&lt;/ol>
&lt;p>The integration is configured by specifying &lt;code>schedulerName: volcano&lt;/code> in the pod specs of the workloads managed by Kueue. Kueue sees the Job/RayJob/PyTorchJob and manages its quota; when it admits it, the pods are created and Volcano places them with gang semantics. Volcano&amp;rsquo;s PodGroups are created automatically by the Volcano Job controller or by the Kubeflow Training Operator itself when it detects that the scheduler is Volcano.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># PyTorchJob managed by Kueue (quota) + Volcano (gang placement)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kubeflow.org/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PyTorchJob&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llm-finetune-70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ns-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kueue.x-k8s.io/queue-name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">lq-datos &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># Kueue manages the quota&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scheduling.volcano.sh/queue-name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">team-datos &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># Volcano uses its own Queue&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pytorchReplicaSpecs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">Master&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">restartPolicy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">OnFailure&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">schedulerName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">volcano &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># Volcano does the placement&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pytorch&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nvcr.io/nvidia/pytorch:25.01-py3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">Worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">7&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">restartPolicy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">OnFailure&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">schedulerName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">volcano&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pytorch&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nvcr.io/nvidia/pytorch:25.01-py3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="the-third-way-the-sig-scheduler-coscheduling-plugin">The third way: the sig-scheduler coscheduling plugin&lt;/h3>
&lt;p>If you do not want to deploy a complete alternative scheduler but you need gang scheduling, there is the &lt;strong>coscheduling plugin&lt;/strong> from &lt;a href="https://github.com/kubernetes-sigs/scheduler-plugins/blob/master/pkg/coscheduling/README.md">kubernetes-sigs/scheduler-plugins&lt;/a>. This plugin extends the kube-scheduler with a PodGroup mechanism similar to Volcano&amp;rsquo;s, implemented as a scheduling framework plugin (permit plugin). The advantage is that it does not replace the scheduler; the disadvantage is that it has less functionality than Volcano (no DRF, no Queue/fair-share, no network topology). It is the right option for simple clusters that only need gang and do not want Volcano&amp;rsquo;s operational complexity. Kueue can also work alongside this plugin.&lt;/p>
&lt;hr>
&lt;h2 id="complete-yaml-examples">Complete YAML examples&lt;/h2>
&lt;h3 id="volcano-queue--podgroup--volcanojob">Volcano: Queue + PodGroup + VolcanoJob&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># 1. Queue for the data team (16 nominal GPUs, ceiling at 24)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">scheduling.volcano.sh/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Queue&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">team-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">weight&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">capability&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;24&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;192&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;768Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">guarantee&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resource&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">reclaimable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># 2. Queue for the AI team (8 nominal GPUs, ceiling at 16)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">scheduling.volcano.sh/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Queue&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">team-ia&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">weight&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">capability&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;64&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;256Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">guarantee&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resource&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;8&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">reclaimable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># 3. PodGroup: 70B fine-tuning job, 8 workers × 4 GPUs = 32 GPUs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">scheduling.volcano.sh/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PodGroup&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">finetune-70b-pg&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ns-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minMember&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minResources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;32&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">queue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">team-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">priorityClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">training-high&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># 4. VolcanoJob (wrapper that Volcano understands natively)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">batch.volcano.sh/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Job&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">finetune-70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ns-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minAvailable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">schedulerName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">volcano&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">queue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">team-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">priorityClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">training-high&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">plugins&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">svc&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policies&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">event&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PodEvicted&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">RestartJob&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tasks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">worker&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policies&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">event&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TaskCompleted&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">CompleteJob&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scheduling.volcano.sh/pod-group-name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">finetune-70b-pg&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">schedulerName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">volcano&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">trainer&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nvcr.io/nvidia/pytorch:25.01-py3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;torchrun&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;--nproc_per_node=4&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;--nnodes=8&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;--node_rank=$(RANK)&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;--master_addr=$(MASTER_ADDR)&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;--master_port=23456&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;train.py&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NCCL_DEBUG&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;INFO&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;64Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;64Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">restartPolicy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Never&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="kueue-resourceflavor--clusterqueue--localqueue--annotated-job">Kueue: ResourceFlavor + ClusterQueue + LocalQueue + annotated Job&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># 1. ResourceFlavor: nodes with H100 SXM&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kueue.x-k8s.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ResourceFlavor&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">h100-sxm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">accelerator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">h100-sxm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tolerations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;nvidia.com/gpu&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Exists&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">effect&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;NoSchedule&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># 2. ClusterQueue for the data team: 16 nominal GPUs, can borrow 8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kueue.x-k8s.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ClusterQueue&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cq-team-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cohort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llm-platform&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">queueingStrategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">BestEffortFIFO&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespaceSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kubernetes.io/metadata.name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ns-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resourceGroups&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">coveredResources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;nvidia.com/gpu&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;cpu&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;memory&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">flavors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">h100-sxm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;nvidia.com/gpu&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nominalQuota&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">16&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">borrowingLimit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">lendingLimit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;cpu&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nominalQuota&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;128&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;memory&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nominalQuota&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;512Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">preemption&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">reclaimWithinCohort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LowerPriority&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">borrowWithinCohort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LowerPriority&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">withinClusterQueue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LowerPriority&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># 3. ClusterQueue for the AI team: 8 nominal GPUs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kueue.x-k8s.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ClusterQueue&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cq-team-ia&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cohort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llm-platform&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">queueingStrategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">BestEffortFIFO&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespaceSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kubernetes.io/metadata.name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ns-ia&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resourceGroups&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">coveredResources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;nvidia.com/gpu&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;cpu&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;memory&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">flavors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">h100-sxm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;nvidia.com/gpu&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nominalQuota&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">borrowingLimit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">lendingLimit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;cpu&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nominalQuota&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;64&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;memory&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nominalQuota&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;256Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">preemption&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">reclaimWithinCohort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LowerPriority&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">withinClusterQueue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LowerPriority&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># 4. LocalQueue in the data team&amp;#39;s namespace&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kueue.x-k8s.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LocalQueue&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">lq-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ns-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">clusterQueue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cq-team-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># 5. LocalQueue in the AI team&amp;#39;s namespace&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kueue.x-k8s.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LocalQueue&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">lq-ia&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ns-ia&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">clusterQueue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cq-team-ia&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># 6. Batch inference RayJob managed by Kueue&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ray.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">RayJob&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">batch-eval-llama70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ns-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kueue.x-k8s.io/queue-name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">lq-datos &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># Kueue manages the admission&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">entrypoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;python batch_eval.py --model /models/llama-70b&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rayClusterSpec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">headGroupSpec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rayStartParams&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">num-gpus&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ray-head&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">rayproject/ray-ml:2.40.0-gpu&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;64Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">workerGroupSpecs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicas: 3 # gang&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Kueue does not admit if there is no quota for 3 workers&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">groupName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gpu-worker&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rayStartParams&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">num-gpus&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ray-worker&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">rayproject/ray-ml:2.40.0-gpu&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;64Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="kueue--volcano-together-pytorchjob-with-kueue-quota-and-volcano-gang-placement">Kueue + Volcano together: PyTorchJob with Kueue quota and Volcano gang placement&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># PyTorchJob: Kueue controls the quota, Volcano does the gang placement&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kubeflow.org/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PyTorchJob&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">distributed-finetune&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ns-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kueue.x-k8s.io/queue-name: lq-datos # Kueue&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">quota and admission&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Volcano automatically creates the PodGroup when schedulerName=volcano&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scheduling.volcano.sh/queue-name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">team-datos&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pytorchReplicaSpecs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">Master&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">restartPolicy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">OnFailure&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">schedulerName: volcano # Volcano&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gang placement&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pytorch&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nvcr.io/nvidia/pytorch:25.01-py3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;64Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">Worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">7&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">restartPolicy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">OnFailure&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">schedulerName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">volcano&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pytorch&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nvcr.io/nvidia/pytorch:25.01-py3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;64Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="for-llm-workloads-training-fine-tuning-and-batch-inference">For LLM workloads: training, fine-tuning and batch inference&lt;/h2>
&lt;h3 id="distributed-multi-gpu-training-and-fine-tuning">Distributed multi-GPU training and fine-tuning&lt;/h3>
&lt;p>Gang scheduling is &lt;strong>essential&lt;/strong> for any distributed training job that uses NCCL all-reduce (PyTorch DDP, FSDP, DeepSpeed ZeRO). If a single worker in the group does not start, the &lt;code>torchrun&lt;/code> coordinator waits indefinitely; with the standard kube-scheduler this scenario happens every time the cluster is under contention.&lt;/p>
&lt;p>In a generic cluster of 4 nodes of 4×H100 SXM (16 GPUs in total), a fine-tuning job for a 70B model typically requires 8 GPUs in tensor-parallel 8 (TP=8) or 16 GPUs in TP=4 plus data-parallel 4. With Volcano, the &lt;code>PodGroup&lt;/code> with &lt;code>minMember: 8&lt;/code> guarantees that either the 8 pods are placed at once or none blocks resources. With Kueue on top, the quota guarantees that the team does not exceed its 16 nominal GPUs and that other teams with available quota are not blocked by a waiting job.&lt;/p>
&lt;p>The cross-link with &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">inference capacity planning&lt;/a> is direct: the model&amp;rsquo;s VRAM budget (weights plus KV-cache) determines the minimum TP and therefore the PodGroup&amp;rsquo;s &lt;code>minMember&lt;/code>.&lt;/p>
&lt;h3 id="batch-inference-and-evaluations-evals">Batch inference and evaluations (evals)&lt;/h3>
&lt;p>Batch inference jobs, generating responses for an evaluation dataset, processing embeddings in bulk, offline re-ranking, are naturally parallel workloads that do not necessarily require strict gang scheduling (each request is independent), but they do benefit from quota and fair-share.&lt;/p>
&lt;p>For these workloads, &lt;strong>Kueue alone is enough&lt;/strong>: a &lt;code>batch/Job&lt;/code> with multiple independent pods is managed with the ClusterQueue&amp;rsquo;s quota with no need for Volcano. If several teams are submitting evaluation jobs simultaneously, Kueue orders the admission by fair-share and priority, and with cohort borrowing the jobs of teams with free quota do not have to wait for the quota of busy teams.&lt;/p>
&lt;p>The chargeback of these workloads connects directly with what is described in &lt;a href="https://blog.lo0.es/en/posts/gpu-chargeback-showback-multi-tenancy/">GPU chargeback and showback&lt;/a>: the ClusterQueue&amp;rsquo;s &lt;code>nominalQuota&lt;/code> is the expression of the GPU budget in Kubernetes, and OpenCost can attribute the cost per namespace or label for the monthly report.&lt;/p>
&lt;h3 id="multi-tenant-gpu-quota-and-chargeback">Multi-tenant GPU quota and chargeback&lt;/h3>
&lt;p>The alignment between Kueue and the chargeback system is direct:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>FinOps concept&lt;/th>
&lt;th>Kueue mechanism&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Guaranteed GPU budget&lt;/td>
&lt;td>&lt;code>nominalQuota&lt;/code> per ClusterQueue&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Maximum spending ceiling&lt;/td>
&lt;td>&lt;code>nominalQuota + borrowingLimit&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Lending idle capacity&lt;/td>
&lt;td>Cohort + &lt;code>lendingLimit&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reclaiming your own quota&lt;/td>
&lt;td>&lt;code>preemption.reclaimWithinCohort: LowerPriority&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fair-share between teams&lt;/td>
&lt;td>Fair Sharing policy in the ClusterQueue&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Chargeback of the loan&lt;/td>
&lt;td>borrowed GPU hours × cost per GPU-hour (OpenCost)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For the utilisation dimension as a FinOps lever, see &lt;a href="https://blog.lo0.es/en/posts/gpu-idle-cost-not-on-any-invoice/">GPU utilisation as a FinOps lever&lt;/a>: Kueue and Volcano together make it possible to maximise utilisation without sacrificing the quota guarantees, which is exactly the FinOps objective.&lt;/p>
&lt;p>Managing MIG partitions within this system (declaring &lt;code>nvidia.com/mig-4g.40gb&lt;/code> as a resource in the ClusterQueue) integrates naturally: the ResourceFlavor can map to nodes with a specific MIG profile, as explained in &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">sharing a GPU: time-slicing, MPS and MIG&lt;/a>.&lt;/p>
&lt;hr>
&lt;h2 id="diagram-the-flow-of-a-training-job-with-kueue--volcano">Diagram: the flow of a training job with Kueue + Volcano&lt;/h2>
&lt;div class="diagram" style="max-width:800px;margin:1rem auto;">
&lt;svg viewBox="0 0 800 380" role="img" aria-label="Admission and scheduling flow of a distributed training job with Kueue and Volcano on Kubernetes" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.4}.dsh{fill:none;stroke:currentColor;stroke-width:1.4;stroke-dasharray:5 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.4;marker-end:url(#arm)}&lt;/style>
&lt;defs>&lt;marker id="arm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="400" y="22" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">Distributed training job: Kueue (quota) + Volcano (gang placement)&lt;/text>
&lt;rect class="bx" x="20" y="40" width="140" height="52" rx="5"/>
&lt;text x="90" y="62" text-anchor="middle" class="tl">User / CI&lt;/text>
&lt;text x="90" y="78" text-anchor="middle" class="ts">kubectl apply PyTorchJob&lt;/text>
&lt;path class="ar" d="M160,66 L200,66"/>
&lt;rect class="bx" x="200" y="40" width="155" height="52" rx="5"/>
&lt;text x="277" y="62" text-anchor="middle" class="tl">Kueue controller&lt;/text>
&lt;text x="277" y="78" text-anchor="middle" class="ts">quota available in the CQ?&lt;/text>
&lt;path class="ar" d="M277,92 L277,130"/>
&lt;rect class="dsh" x="200" y="130" width="155" height="40" rx="5"/>
&lt;text x="277" y="146" text-anchor="middle" class="tl">Waiting queue&lt;/text>
&lt;text x="277" y="162" text-anchor="middle" class="ts">fair-share / priority&lt;/text>
&lt;path class="ar" d="M355,66 L400,66"/>
&lt;rect class="bx" x="400" y="40" width="155" height="52" rx="5"/>
&lt;text x="477" y="62" text-anchor="middle" class="tl">Admission (quota OK)&lt;/text>
&lt;text x="477" y="78" text-anchor="middle" class="ts">pods allowed; CQ reserves GPU&lt;/text>
&lt;path class="ar" d="M477,92 L477,140"/>
&lt;rect class="bx" x="400" y="140" width="155" height="52" rx="5"/>
&lt;text x="477" y="162" text-anchor="middle" class="tl">Volcano scheduler&lt;/text>
&lt;text x="477" y="178" text-anchor="middle" class="ts">PodGroup: minMember=8 gangs&lt;/text>
&lt;path class="ar" d="M477,192 L477,240"/>
&lt;rect class="bx" x="400" y="240" width="155" height="52" rx="5"/>
&lt;text x="477" y="262" text-anchor="middle" class="tl">4×H100 SXM nodes&lt;/text>
&lt;text x="477" y="278" text-anchor="middle" class="ts">8 pods × 4 GPU — all at once&lt;/text>
&lt;path class="ar" d="M555,166 L625,166"/>
&lt;rect class="dsh" x="625" y="140" width="150" height="52" rx="5"/>
&lt;text x="700" y="162" text-anchor="middle" class="tl">If there are no nodes&lt;/text>
&lt;text x="700" y="178" text-anchor="middle" class="ts">no pod is placed → it waits&lt;/text>
&lt;text x="90" y="320" class="ts">Kueue: manages quota, cohorts, fair-share, preemption between queues&lt;/text>
&lt;text x="90" y="338" class="ts">Volcano: gang placement (all-or-nothing), topology-aware, NUMA, DRF between Queues&lt;/text>
&lt;text x="90" y="356" class="ts">The two levels are orthogonal: Kueue sees no nodes, Volcano sees no team quota&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="full-comparison-table">Full comparison table&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Criterion&lt;/th>
&lt;th>Volcano&lt;/th>
&lt;th>Kueue&lt;/th>
&lt;th>Coscheduling plugin&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Role&lt;/strong>&lt;/td>
&lt;td>Scheduler (placement)&lt;/td>
&lt;td>Admission + quota (no placement)&lt;/td>
&lt;td>kube-scheduler plugin (placement)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Gang scheduling&lt;/strong>&lt;/td>
&lt;td>Yes, pod level (PodGroup/minMember)&lt;/td>
&lt;td>Yes, admission level (quota gang)&lt;/td>
&lt;td>Yes, pod level (PodGroup)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Multi-tenant quotas&lt;/strong>&lt;/td>
&lt;td>Yes (Queue capability/guarantee)&lt;/td>
&lt;td>Yes (ClusterQueue nominalQuota)&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Cohorts / borrowing&lt;/strong>&lt;/td>
&lt;td>Limited (reclaimable)&lt;/td>
&lt;td>Yes (Cohort with borrowingLimit/lendingLimit)&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Fair-share&lt;/strong>&lt;/td>
&lt;td>Yes (DRF, proportion)&lt;/td>
&lt;td>Yes (Fair Sharing by historical usage)&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Inter-queue preemption&lt;/strong>&lt;/td>
&lt;td>Yes (reclaim action)&lt;/td>
&lt;td>Yes (reclaimWithinCohort)&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Network / NUMA topology&lt;/strong>&lt;/td>
&lt;td>Yes (v1.11+, dedicated plugins)&lt;/td>
&lt;td>Yes (TAS, Topology CRD)&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Native integrations&lt;/strong>&lt;/td>
&lt;td>MPI, PyTorch, Ray, TF, Spark, Flink, Horovod&lt;/td>
&lt;td>Job, JobSet, RayJob, Kubeflow, LWS, AppWrapper, Deployment, StatefulSet&lt;/td>
&lt;td>Any job with a PodGroup&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Who places the pods&lt;/strong>&lt;/td>
&lt;td>Volcano&lt;/td>
&lt;td>kube-scheduler (or Volcano)&lt;/td>
&lt;td>kube-scheduler&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Elastic gang&lt;/strong>&lt;/td>
&lt;td>Yes (minMember &amp;lt; total replicas)&lt;/td>
&lt;td>Partial (partial admission in batch/Job)&lt;/td>
&lt;td>Limited&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Footprint&lt;/strong>&lt;/td>
&lt;td>Medium-high (its own scheduler)&lt;/td>
&lt;td>Light (an additional controller)&lt;/td>
&lt;td>Minimal (a scheduler plugin)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Compatibility with Kueue&lt;/strong>&lt;/td>
&lt;td>Yes (as the scheduler under Kueue)&lt;/td>
&lt;td>—&lt;/td>
&lt;td>Yes (a complementary option)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>CNCF status / maturity&lt;/strong>&lt;/td>
&lt;td>CNCF incubating, v1.15&lt;/td>
&lt;td>kubernetes-sigs, v1beta2, adopted in GKE/OpenShift&lt;/td>
&lt;td>kubernetes-sigs/scheduler-plugins, experimental&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>When to choose it&lt;/strong>&lt;/td>
&lt;td>HPC-like distributed training, NUMA, network topology, MPI&lt;/td>
&lt;td>Multi-tenancy with flexible quota, heterogeneous workloads, inference + batch together&lt;/td>
&lt;td>Simple clusters that only need gang without their own scheduler&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="operational-pitfalls-and-honest-scepticism">Operational pitfalls and honest scepticism&lt;/h2>
&lt;h3 id="1-deadlock-from-a-badly-configured-gang">1. Deadlock from a badly configured gang&lt;/h3>
&lt;p>The most frequent scenario: &lt;code>minMember&lt;/code> set equal to the total replica count in a cluster where several jobs compete for the same resources. If two jobs of 8 pods each try to use a cluster with 8 GPU nodes and job A has 4 pods placed (not 8, so Volcano holds them pending), and job B also has 4 pods held, &lt;strong>nobody moves forward&lt;/strong>. Volcano is doing its job properly: it places none of them until there is room for all 8. But if the queues&amp;rsquo; &lt;code>nominalQuota&lt;/code> values are badly sized against the cluster&amp;rsquo;s real capacity, this produces indefinite waits.&lt;/p>
&lt;p>Solution: size the queue quotas so that the sum of &lt;code>guarantee&lt;/code> does not exceed the real capacity, and so that the &lt;code>minMember&lt;/code> values of the active jobs fit inside the available quota. Node autoscaling with ProvisioningRequest (Kueue + cluster-autoscaler) helps, but it introduces provisioning latency that has to be taken into account in the job&amp;rsquo;s SLA.&lt;/p>
&lt;h3 id="2-quota-versus-real-capacity-the-silent-drift">2. Quota versus real capacity: the silent drift&lt;/h3>
&lt;p>Kueue&amp;rsquo;s &lt;code>nominalQuota&lt;/code> and Volcano&amp;rsquo;s &lt;code>guarantee&lt;/code> are administrative declarations. They do not guarantee that the nodes with those GPUs are available, healthy or that the device plugin has registered them correctly. A node in &lt;code>NotReady&lt;/code> with 4 GPUs reduces the real capacity without Kueue knowing: the ClusterQueue will carry on admitting workloads that then cannot be placed.&lt;/p>
&lt;p>Recommended monitoring: cross Kueue&amp;rsquo;s metrics (&lt;code>kueue_admitted_workloads_total&lt;/code>, &lt;code>kueue_pending_workloads&lt;/code>) with the cluster&amp;rsquo;s real capacity metrics (GPUs registered in the device plugin) to detect the drift. Kueue exposes native Prometheus metrics; so does Volcano.&lt;/p>
&lt;h3 id="3-gpu-resource-naming-mig-time-slicing-and-resourceflavor">3. GPU resource naming: MIG, time-slicing and ResourceFlavor&lt;/h3>
&lt;p>If the cluster uses MIG, the resources in the pod specs change from &lt;code>nvidia.com/gpu&lt;/code> to &lt;code>nvidia.com/mig-Xg.Ygb&lt;/code> (for example, &lt;code>nvidia.com/mig-3g.40gb&lt;/code>). Kueue&amp;rsquo;s ResourceFlavors and Volcano&amp;rsquo;s Queues must declare the right resource, or the quota will not match the pods. With time-slicing, the resource is still &lt;code>nvidia.com/gpu&lt;/code> but the device plugin advertises more instances than there are physical GPUs; the quota is expressed in virtual replicas, which can lead to over-admission if the VRAM budget is not taken into account (see &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">sharing a GPU: time-slicing, MPS and MIG&lt;/a>).&lt;/p>
&lt;h3 id="4-preemption-in-production-the-evicted-workload-loses-progress">4. Preemption in production: the evicted workload loses progress&lt;/h3>
&lt;p>When Kueue or Volcano evicts a training job that has been running for hours, that job loses its progress if it has no checkpointing configured. The preemption is correct from the quota point of view, but it destroys work if the job is not prepared for it. Before enabling aggressive preemption, verify that all training jobs have periodic checkpointing with automatic restoration. PyTorch plus Torchrun have native support; so does DeepSpeed. Stateless batch inference jobs do not have this problem.&lt;/p>
&lt;h3 id="5-volcano-as-the-sole-scheduler-versus-coexistence-with-the-kube-scheduler">5. Volcano as the sole scheduler versus coexistence with the kube-scheduler&lt;/h3>
&lt;p>Volcano can be configured as the cluster&amp;rsquo;s default scheduler (all pods go through it) or as an alternative scheduler (only pods with &lt;code>schedulerName: volcano&lt;/code>). The first option simplifies the configuration but breaks system pods that assume kube-scheduler behaviour. The second, the recommended one, requires ML jobs to explicitly set &lt;code>schedulerName: volcano&lt;/code>, which can be a non-trivial operator or chart change for existing workloads. Kueue solves this more transparently: it only requires a label on the job, with no change of scheduler.&lt;/p>
&lt;h3 id="6-real-operational-complexity-in-2026">6. Real operational complexity in 2026&lt;/h3>
&lt;p>Running Kueue plus Volcano plus the Training Operator plus the GPU Operator in production means four components with their own CRDs, webhooks, versions and release cycles. A Kubernetes upgrade may require updating all four in sequence. The operational debt is real. For a small team without the capacity to maintain this stack, a managed Kubernetes provider (GKE with native Kueue, OpenShift with the Red Hat build of Kueue) may be more pragmatic than assembling the full stack from scratch.&lt;/p>
&lt;p>The sig-scheduler coscheduling plugin is a deliberately simpler option when all you need is gang: fewer features, less complexity, fewer things to maintain.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-chargeback-showback-multi-tenancy/">GPU chargeback and showback in multi-tenancy&lt;/a> — how to connect Kueue&amp;rsquo;s &lt;code>nominalQuota&lt;/code> with the monthly OpenCost report per team.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-idle-cost-not-on-any-invoice/">GPU utilisation as a FinOps lever&lt;/a> — gang scheduling plus quotas as a tool for raising utilisation without idle capacity blocked by deadlocks.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU: time-slicing, MPS and MIG&lt;/a> — how to declare MIG resources in a Kueue ResourceFlavor and in a Volcano Queue.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — the model&amp;rsquo;s VRAM budget determines the PodGroup&amp;rsquo;s minMember and the minimum viable nominalQuota.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes with KEDA&lt;/a> — how to combine autoscaling of inference replicas with Kueue quotas so as not to exceed the GPU budget.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM operators on Kubernetes&lt;/a> — the Training Operator (Kubeflow) is the controller that creates the PyTorchJob/TFJob objects managed by Kueue + Volcano.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">H100 cluster: multi-tenant platform&lt;/a> — the complete architecture of a multi-tenant GPU cluster where Volcano and Kueue are pieces of the scheduling stack.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Volcano — Introducción y features (CNCF incubating, v1.15) — &lt;a href="https://volcano.sh/en/docs/">https://volcano.sh/en/docs/&lt;/a>&lt;/li>
&lt;li>Volcano — Volcano v1.11 Released: A New Era of Cloud-Native Scheduling (CNCF blog, marzo 2025) — &lt;a href="https://www.cncf.io/blog/2025/03/05/volcano-v1-11-released-a-new-era-of-cloud-native-scheduling-for-ai-and-big-data/">https://www.cncf.io/blog/2025/03/05/volcano-v1-11-released-a-new-era-of-cloud-native-scheduling-for-ai-and-big-data/&lt;/a>&lt;/li>
&lt;li>Volcano — Release v1.11.0 (GitHub) — &lt;a href="https://github.com/volcano-sh/volcano/releases/tag/v1.11.0">https://github.com/volcano-sh/volcano/releases/tag/v1.11.0&lt;/a>&lt;/li>
&lt;li>Volcano — Network Topology Aware Scheduling design doc — &lt;a href="https://github.com/volcano-sh/volcano/blob/master/docs/design/Network%20Topology%20Aware%20Scheduling.md">https://github.com/volcano-sh/volcano/blob/master/docs/design/Network%20Topology%20Aware%20Scheduling.md&lt;/a>&lt;/li>
&lt;li>Volcano — NUMA-aware scheduling design — &lt;a href="https://github.com/volcano-sh/volcano/blob/master/docs/design/numa-aware.md">https://github.com/volcano-sh/volcano/blob/master/docs/design/numa-aware.md&lt;/a>&lt;/li>
&lt;li>Volcano — Unified Scheduling (docs oficiales) — &lt;a href="https://volcano.sh/en/docs/unified_scheduling/">https://volcano.sh/en/docs/unified_scheduling/&lt;/a>&lt;/li>
&lt;li>Volcano — Capacity scheduling design — &lt;a href="https://github.com/volcano-sh/volcano/blob/master/docs/design/capacity-scheduling.md">https://github.com/volcano-sh/volcano/blob/master/docs/design/capacity-scheduling.md&lt;/a>&lt;/li>
&lt;li>NVIDIA Technical Blog — Practical Tips for Preventing GPU Fragmentation for Volcano Scheduler — &lt;a href="https://developer.nvidia.com/blog/practical-tips-for-preventing-gpu-fragmentation-for-volcano-scheduler/">https://developer.nvidia.com/blog/practical-tips-for-preventing-gpu-fragmentation-for-volcano-scheduler/&lt;/a>&lt;/li>
&lt;li>Kueue — Overview (kueue.sigs.k8s.io, actualizado febrero 2026) — &lt;a href="https://kueue.sigs.k8s.io/docs/overview/">https://kueue.sigs.k8s.io/docs/overview/&lt;/a>&lt;/li>
&lt;li>Kueue — Cluster Queue (nominalQuota, borrowingLimit, lendingLimit, preemption) — &lt;a href="https://kueue.sigs.k8s.io/docs/concepts/cluster_queue/">https://kueue.sigs.k8s.io/docs/concepts/cluster_queue/&lt;/a>&lt;/li>
&lt;li>Kueue — Cohort — &lt;a href="https://kueue.sigs.k8s.io/docs/concepts/cohort/">https://kueue.sigs.k8s.io/docs/concepts/cohort/&lt;/a>&lt;/li>
&lt;li>Kueue — Fair Sharing — &lt;a href="https://kueue.sigs.k8s.io/docs/concepts/fair_sharing/">https://kueue.sigs.k8s.io/docs/concepts/fair_sharing/&lt;/a>&lt;/li>
&lt;li>Kueue — Topology Aware Scheduling — &lt;a href="https://kueue.sigs.k8s.io/docs/concepts/topology_aware_scheduling/">https://kueue.sigs.k8s.io/docs/concepts/topology_aware_scheduling/&lt;/a>&lt;/li>
&lt;li>Kueue — Preemption — &lt;a href="https://kueue.sigs.k8s.io/docs/concepts/preemption/">https://kueue.sigs.k8s.io/docs/concepts/preemption/&lt;/a>&lt;/li>
&lt;li>Kueue — Setup All-or-nothing with ready Pods — &lt;a href="https://kueue.sigs.k8s.io/docs/tasks/manage/setup_wait_for_pods_ready/">https://kueue.sigs.k8s.io/docs/tasks/manage/setup_wait_for_pods_ready/&lt;/a>&lt;/li>
&lt;li>Kueue — GitHub (kubernetes-sigs/kueue) — &lt;a href="https://github.com/kubernetes-sigs/kueue">https://github.com/kubernetes-sigs/kueue&lt;/a>&lt;/li>
&lt;li>Ray — Gang Scheduling with RayJob and Kueue — &lt;a href="https://docs.ray.io/en/latest/cluster/kubernetes/examples/rayjob-kueue-gang-scheduling.html">https://docs.ray.io/en/latest/cluster/kubernetes/examples/rayjob-kueue-gang-scheduling.html&lt;/a>&lt;/li>
&lt;li>Kubeflow — Volcano scheduler integration — &lt;a href="https://www.kubeflow.org/docs/components/trainer/operator-guides/job-scheduling/volcano/">https://www.kubeflow.org/docs/components/trainer/operator-guides/job-scheduling/volcano/&lt;/a>&lt;/li>
&lt;li>kubernetes-sigs/scheduler-plugins — Coscheduling plugin README — &lt;a href="https://github.com/kubernetes-sigs/scheduler-plugins/blob/master/pkg/coscheduling/README.md">https://github.com/kubernetes-sigs/scheduler-plugins/blob/master/pkg/coscheduling/README.md&lt;/a>&lt;/li>
&lt;li>kubernetes/enhancements — KEP-583 Coscheduling (sig-scheduling) — &lt;a href="https://github.com/kubernetes/enhancements/tree/master/keps/sig-scheduling/583-coscheduling">https://github.com/kubernetes/enhancements/tree/master/keps/sig-scheduling/583-coscheduling&lt;/a>&lt;/li>
&lt;li>InfraCloud — Batch Scheduling on Kubernetes: Comparing YuniKorn, Volcano and Kueue — &lt;a href="https://www.infracloud.io/blogs/batch-scheduling-on-kubernetes/">https://www.infracloud.io/blogs/batch-scheduling-on-kubernetes/&lt;/a>&lt;/li>
&lt;li>Red Hat — Red Hat build of Kueue (OpenShift 4.20) — &lt;a href="https://docs.redhat.com/en/documentation/openshift_container_platform/4.20/html/ai_workloads/red-hat-build-of-kueue">https://docs.redhat.com/en/documentation/openshift_container_platform/4.20/html/ai_workloads/red-hat-build-of-kueue&lt;/a>&lt;/li>
&lt;li>AceCloud — Multi GPU Orchestration in Kubernetes 2026: Kueue, Volcano, DRA — &lt;a href="https://acecloud.ai/blog/multi-gpu-orchestration-kubernetes/">https://acecloud.ai/blog/multi-gpu-orchestration-kubernetes/&lt;/a>&lt;/li>
&lt;li>CloudOptimo — Kubernetes AI Infrastructure in 2026: GPU Scheduling and Production Realities — &lt;a href="https://www.cloudoptimo.com/blog/kubernetes-ai-infrastructure-in-2026-gpu-scheduling-and-production-realities/">https://www.cloudoptimo.com/blog/kubernetes-ai-infrastructure-in-2026-gpu-scheduling-and-production-realities/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Measuring energy in production: Kepler, DCGM and the practical stack (accuracy and overhead)</title><link>https://blog.lo0.es/en/posts/measuring-energy-in-production-kepler-dcgm-stack/</link><pubDate>Sun, 14 Jun 2026 05:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/measuring-energy-in-production-kepler-dcgm-stack/</guid><description>&lt;blockquote>
&lt;p>Notation: amounts in &lt;strong>euros (N €)&lt;/strong>, decimals with a point. The dollar sign is not used
(on this site it is a formula delimiter).&lt;/p>
&lt;/blockquote>
&lt;h2 id="what-this-article-covers">What this article covers&lt;/h2>
&lt;p>Third article of the energy track (C3). C1 made the conceptual inventory of the tools;
here we get practical: how the measurement stack is deployed on Kubernetes, where each
piece takes its figures from, how accurate it is against a power meter, how much
overhead it adds, and how energy is attributed per pod and per MIG instance. It is the
equivalent of B2/B3 for energy: to stop talking about what each tool measures and start
building the bench that measures it. With commands; no recommendations, only the mechanics.&lt;/p>
&lt;hr>
&lt;h2 id="the-measurement-stack-on-kubernetes">The measurement stack on Kubernetes&lt;/h2>
&lt;p>To measure energy per workload on a GPU cluster, the practical stack has four pieces, and the
good news is that three of them you already have if you do observability:&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 230" role="img" aria-label="Energy measurement stack on Kubernetes: DCGM exporter and Kepler export to Prometheus, which Grafana visualises, with the power meter as calibration" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.dsh{fill:none;stroke:currentColor;stroke-width:1.3;stroke-dasharray:5 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.3;marker-end:url(#em)}&lt;/style>
&lt;defs>&lt;marker id="em" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;rect class="bx" x="20" y="40" width="170" height="44" rx="6"/>&lt;text x="32" y="60" class="tl">DCGM exporter&lt;/text>&lt;text x="32" y="76" class="ts">GPU power&lt;/text>
&lt;rect class="bx" x="20" y="100" width="170" height="44" rx="6"/>&lt;text x="32" y="120" class="tl">Kepler (eBPF)&lt;/text>&lt;text x="32" y="136" class="ts">energy per pod/node&lt;/text>
&lt;path class="ar" d="M190,62 L250,90"/>
&lt;path class="ar" d="M190,122 L250,108"/>
&lt;rect class="bx" x="250" y="78" width="160" height="44" rx="6"/>&lt;text x="262" y="98" class="tl">Prometheus&lt;/text>&lt;text x="262" y="114" class="ts">time series&lt;/text>
&lt;path class="ar" d="M410,100 L470,100"/>
&lt;rect class="bx" x="470" y="78" width="160" height="44" rx="6"/>&lt;text x="482" y="98" class="tl">Grafana&lt;/text>&lt;text x="482" y="114" class="ts">panels · alerts&lt;/text>
&lt;rect class="dsh" x="650" y="78" width="110" height="44" rx="6"/>&lt;text x="662" y="98" class="tl">Power meter&lt;/text>&lt;text x="662" y="114" class="ts">calibration&lt;/text>
&lt;text x="20" y="180" class="ts">DCGM, Prometheus and Grafana are already there if you do GPU observability; Kepler adds per-pod attribution.&lt;/text>
&lt;text x="20" y="202" class="ts">The physical meter (point in time) calibrates the estimates: not part of the continuous stack, but the ground truth.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The new piece compared with a normal observability stack is Kepler, which adds &lt;strong>energy
attribution per pod&lt;/strong>. DCGM already gives GPU power; Prometheus stores it; Grafana draws it. It
is reused infrastructure, not a new system, in line with &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability via DCGM&lt;/a>.&lt;/p>
&lt;hr>
&lt;h2 id="dcgm-exporter-the-base-of-gpu-power">DCGM exporter: the base of GPU power&lt;/h2>
&lt;p>GPU power, the dominant source in LLM inference, comes from NVIDIA&amp;rsquo;s dcgm-exporter
(part of the GPU Operator), which exposes DCGM metrics to Prometheus. The key metric is
&lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code> (instantaneous power in W), alongside utilisation
(&lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code>) and memory. With that, energy is the integral of power over time
across the workload&amp;rsquo;s window. DCGM gives power at card level; to split it per pod or per
MIG instance you need a layer on top, which is Kepler.&lt;/p>
&lt;hr>
&lt;h2 id="kepler-in-depth-ebpf-and-the-sources">Kepler in depth: eBPF and the sources&lt;/h2>
&lt;p>Kepler (Kubernetes-based Efficient Power Level Exporter) is a Prometheus exporter that
measures energy at container, pod and node level, using eBPF to keep overhead low and
scientific methods to improve accuracy (&lt;a href="https://github.com/sustainable-computing-io/kepler">Kepler · GitHub&lt;/a>).
Its strength is that it combines several power sources:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Source&lt;/th>
&lt;th>What it covers&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>RAPL&lt;/strong>&lt;/td>
&lt;td>CPU and DRAM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>NVML&lt;/strong>&lt;/td>
&lt;td>NVIDIA GPU power&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>ACPI&lt;/strong>&lt;/td>
&lt;td>platform power management&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Redfish / IPMI&lt;/strong>&lt;/td>
&lt;td>platform power (BMC)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Regression models&lt;/strong>&lt;/td>
&lt;td>estimation when there is no real metric&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For the GPU, the &lt;code>dcgmEndpoint&lt;/code> parameter points at the dcgm-exporter metrics URL, which
enables power attribution per MIG instance (&lt;a href="https://github.com/sustainable-computing-io/kepler/blob/main/docs/user/configuration.md">Kepler · configuration&lt;/a>).
That is: DCGM gives the card&amp;rsquo;s power, Kepler splits it between the pods (and MIG partitions)
that use it, via eBPF and models. The result is per-pod energy metrics in Prometheus, ready
for Grafana, usable for scheduling or scaling, consumption reporting and visualisation
(&lt;a href="https://github.com/sustainable-computing-io/kepler">Kepler · GitHub&lt;/a>).&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 210" role="img" aria-label="Kepler combines RAPL, NVML/DCGM, ACPI and Redfish, and with eBPF splits the energy per pod and per MIG instance" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.3;marker-end:url(#km)}&lt;/style>
&lt;defs>&lt;marker id="km" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;rect class="bx" x="20" y="36" width="150" height="30" rx="5"/>&lt;text x="30" y="56" class="ts">RAPL (CPU/DRAM)&lt;/text>
&lt;rect class="bx" x="20" y="76" width="150" height="30" rx="5"/>&lt;text x="30" y="96" class="ts">NVML/DCGM (GPU)&lt;/text>
&lt;rect class="bx" x="20" y="116" width="150" height="30" rx="5"/>&lt;text x="30" y="136" class="ts">ACPI · Redfish/IPMI&lt;/text>
&lt;path class="ar" d="M170,51 L230,80"/>
&lt;path class="ar" d="M170,91 L230,91"/>
&lt;path class="ar" d="M170,131 L230,102"/>
&lt;rect class="bx" x="230" y="70" width="170" height="44" rx="6"/>&lt;text x="242" y="90" class="tl">Kepler (eBPF + model)&lt;/text>&lt;text x="242" y="106" class="ts">splits the power&lt;/text>
&lt;path class="ar" d="M400,92 L460,92"/>
&lt;rect class="bx" x="460" y="70" width="180" height="44" rx="6"/>&lt;text x="472" y="90" class="tl">Energy per pod / MIG&lt;/text>&lt;text x="472" y="106" class="ts">→ Prometheus&lt;/text>
&lt;text x="20" y="180" class="ts">DCGM gives the card's power; Kepler attributes it to each pod and MIG partition, as OpenCost attributes cost.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="deployment-a-daemonset-on-top-of-prometheus">Deployment: a DaemonSet on top of Prometheus&lt;/h2>
&lt;p>The deployment is a DaemonSet (one Kepler per node), pointing at the existing Prometheus
and at the dcgm-exporter:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Helm, pointing at the dcgm-exporter for GPU attribution&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">helm install kepler kepler/kepler &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --namespace kepler --create-namespace &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set &lt;span class="nv">dcgmEndpoint&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://dcgm-exporter:9400/metrics&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Prerequisites: a kernel with eBPF, the dcgm-exporter running (from the GPU Operator) and
Prometheus scraping. Once deployed, Kepler exposes metrics such as
&lt;code>kepler_container_joules_total&lt;/code> per pod, queried with PromQL and drawn in Grafana. Energy per
workload stops being a manual estimate and becomes a continuous time series, just like cost
with OpenCost.&lt;/p>
&lt;hr>
&lt;h2 id="promql-queries-energy-per-pod">PromQL queries: energy per pod&lt;/h2>
&lt;p>With Kepler exporting, these are the queries that build the panels. The exact names vary by
version; check your &lt;code>/metrics&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Cumulative energy per pod (joules)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">kepler_container_joules_total&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Mean power per namespace over the last hour (W)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">by&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">namespace&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">kepler_container_joules_total&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">1h&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># GPU power per node (from DCGM, W)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">by&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">node&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">DCGM_FI_DEV_POWER_USAGE&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Energy per token: power ÷ throughput (with a gateway/engine metric)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">kepler_container_joules_total&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm_generation_tokens_total&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The last one closes the circle with the cost track: it crosses Kepler&amp;rsquo;s energy with the
engine/gateway tokens to give J/token in real time, per workload. With that in Grafana you
have energy per pod, per namespace and per token, continuously, the energy equivalent of the
OpenCost cost report.&lt;/p>
&lt;hr>
&lt;h2 id="a-calibration-procedure">A calibration procedure&lt;/h2>
&lt;p>Since Kepler estimates, it is worth calibrating once per node type. A simple procedure:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Power meter at the node input&lt;/strong> (or a PDU/BMC reading), measuring the node&amp;rsquo;s real power.&lt;/li>
&lt;li>&lt;strong>Controlled load&lt;/strong>: a stable sweep at a known power (the engine at 80 % GPU, say).&lt;/li>
&lt;li>&lt;strong>Compare&lt;/strong>: the node&amp;rsquo;s sum of &lt;code>kepler_container_joules_total&lt;/code> plus idle against the meter
reading over the same window.&lt;/li>
&lt;li>&lt;strong>Adjust&lt;/strong> Kepler&amp;rsquo;s factor or model if the deviation is noticeable, and document the
residual error.&lt;/li>
&lt;li>&lt;strong>Repeat&lt;/strong> for each node type (calibration is not transferable between different hardware).&lt;/li>
&lt;/ol>
&lt;p>With that residual error documented, Kepler&amp;rsquo;s continuous figure has a known error bar,
which is exactly what a CSRD auditor or a technical committee will ask for. Measuring without
calibrating gives a number with no credibility; calibrating once turns the continuous estimate
into a defensible figure.&lt;/p>
&lt;hr>
&lt;h2 id="accuracy-estimation-vs-power-meter">Accuracy: estimation vs power meter&lt;/h2>
&lt;p>The data honesty of the energy track: Kepler estimates (eBPF + models + counters), it does
not measure with a power meter. Accuracy depends on the source: with NVML/DCGM and RAPL
available, the figure is fairly faithful (it reads hardware counters); when it falls back on
regression models (with no real metric), it is an estimate with more error. Good practice:
calibrate against a physical power meter at least once per node type, adjust, and use
Kepler as a continuous proxy. The meter is the point-in-time ground truth; Kepler, the
continuous series calibrated against it. For a CSRD report that an auditor may challenge, that
initial calibration is what gives the continuous figure credibility.&lt;/p>
&lt;hr>
&lt;h2 id="overhead-the-cost-of-measuring">Overhead: the cost of measuring&lt;/h2>
&lt;p>Measuring energy has its own compute cost, worth budgeting for. In a Kubernetes environment,
the overhead of the monitoring system is split between the Prometheus server and the targets:
the Prometheus server consumes ~150 % of CPU (1.5 cores) and ~15 GB of RAM, while the
targets (node exporter and dcgm-exporter) together use ~35 % of CPU and ~300 MB of RAM
(&lt;a href="https://arxiv.org/pdf/2504.10702">arXiv · Container-level Energy Observability&lt;/a>). Kepler uses
eBPF precisely to keep its own overhead low. The reading: the bulk of the cost of measuring is
in Prometheus (which you probably already have for observability), not in the energy
exporters. Adding Kepler to an existing observability stack is marginal; standing up
Prometheus just for this would be expensive. That is why measuring energy is nearly free if
you already observe, and a project if you do not.&lt;/p>
&lt;hr>
&lt;h2 id="zeus-for-the-bench-and-for-optimisation-not-for-continuous-production">Zeus: for the bench and for optimisation, not for continuous production&lt;/h2>
&lt;p>Zeus (ml.energy) plays a different role: it is not a continuous production exporter, it is
the bench tool for measuring and optimising the energy of one specific workload (a
benchmark, a training run), with power capping and frequency selection. It fits into the
benchmarking flow (alongside GuideLLM/AIPerf): during a sweep, Zeus measures the energy per
configuration and lets you test the effect of power capping on performance. The practical
division: Kepler/DCGM in production (continuous per-pod attribution), Zeus on the bench
(fine measurement + optimisation of one workload), CodeCarbon if you want carbon context
integrated into an ML pipeline.&lt;/p>
&lt;hr>
&lt;h2 id="codecarbon-and-scaphandre-in-practice">CodeCarbon and Scaphandre in practice&lt;/h2>
&lt;p>The other two pieces, with their specific role:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>CodeCarbon&lt;/strong> integrates inside the code of an ML pipeline (a decorator or a context
manager wrapping the training or the inference), and returns the energy and the CO₂eq of
that block, using &lt;code>nvidia-smi&lt;/code> for the GPU and estimating CPU/RAM. It fits when you want the
carbon figure per experiment or per ML job, integrated into the data flow, not at
infrastructure level. It is the closest to the power meter among the Python tools.&lt;/li>
&lt;li>&lt;strong>Scaphandre&lt;/strong> is a metrology agent in Rust that reads RAPL and gives process/VM power
with raw hardware accuracy. For CPU it is very accurate; for GPU it has to be combined with
NVML. It fits when you need the most faithful CPU metrology, or a lightweight agent
independent of Kubernetes.&lt;/li>
&lt;/ul>
&lt;p>The division with Kepler: Kepler attributes energy per pod in the cluster (the
infrastructure view); CodeCarbon gives carbon per ML job (the pipeline view);
Scaphandre gives raw CPU metrology. They do not compete; they cover different layers, and
a mature platform uses whichever matches the question.&lt;/p>
&lt;hr>
&lt;h2 id="choosing-the-stack-by-objective">Choosing the stack by objective&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Objective&lt;/th>
&lt;th>Tool&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Continuous per-pod attribution (production)&lt;/td>
&lt;td>&lt;strong>Kepler + DCGM&lt;/strong>&lt;/td>
&lt;td>eBPF, per pod/MIG, into Prometheus&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fine measurement + optimisation (bench)&lt;/td>
&lt;td>&lt;strong>Zeus&lt;/strong>&lt;/td>
&lt;td>measures and optimises (power capping)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Carbon in an ML pipeline&lt;/td>
&lt;td>&lt;strong>CodeCarbon&lt;/strong>&lt;/td>
&lt;td>integrated CO₂eq, close to the power meter&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Raw CPU accuracy (RAPL)&lt;/td>
&lt;td>&lt;strong>Scaphandre&lt;/strong>&lt;/td>
&lt;td>hardware metrology&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Calibration / ground truth&lt;/td>
&lt;td>&lt;strong>power meter&lt;/strong>&lt;/td>
&lt;td>the standard everything is calibrated against&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>It is not &amp;ldquo;one tool&amp;rdquo;, it is a stack by objective: continuous production (Kepler/DCGM),
bench (Zeus), reporting (CodeCarbon), calibration (power meter). Mixing them according to the
phase is what gives a complete and credible energy measurement.&lt;/p>
&lt;hr>
&lt;h2 id="from-figure-to-action">From figure to action&lt;/h2>
&lt;p>Measuring is the start; the value is in acting. With energy per pod in Prometheus:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Reporting (CSRD)&lt;/strong>: the carbon footprint per workload, calibrated, ready for regulatory
reporting.&lt;/li>
&lt;li>&lt;strong>Energy/carbon-aware scheduling&lt;/strong>: move deferrable load to hours or nodes of lower
intensity (connects with the hourly carbon of the Spanish grid).&lt;/li>
&lt;li>&lt;strong>Power capping&lt;/strong>: with Zeus, trim peak power with little performance loss, lowering the W in
the numerator of J/token.&lt;/li>
&lt;li>&lt;strong>Optimisation&lt;/strong>: identify pods with poor energy efficiency (a lot of energy per token) and
attack them with quantisation or batching.&lt;/li>
&lt;/ul>
&lt;p>The measurement stack is not a panel to look at: it is the input to scheduling, optimisation
and reporting decisions. Just as OpenCost closes the cost loop, Kepler+DCGM closes the energy
one.&lt;/p>
&lt;hr>
&lt;h2 id="from-energy-to-carbon-the-final-layer">From energy to carbon: the final layer&lt;/h2>
&lt;p>The energy figure (kWh) becomes carbon by multiplying by the grid intensity, and here the
practical stack connects with the hourly source. In Spain, the intensity is published by &lt;strong>Red
Eléctrica (esios) and aggregated by ElectricityMaps&lt;/strong>, hour by hour. The pattern:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Carbon per workload: energy (kWh) × grid intensity (gCO2/kWh)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">by&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">namespace&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="kr">increase&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">kepler_container_joules_total&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">1h&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mf">3.6&lt;/span>&lt;span class="nv">e6&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">on&lt;/span>&lt;span class="o">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">group_left&lt;/span>&lt;span class="o">()&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">grid_carbon_intensity_gco2_kwh&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>where &lt;code>grid_carbon_intensity_gco2_kwh&lt;/code> is a metric you feed from esios/ElectricityMaps (an
exporter or a periodic push). With that, the Grafana panel shows &lt;strong>gCO₂ per namespace and per
token, hour by hour&lt;/strong>, not an annual average. And since Spanish intensity varies a lot (from
~80 in solar hours to ~250 on gas-heavy nights), that hourly resolution is what enables
carbon-aware scheduling: moving the deferrable work to the clean hours lowers the measured
carbon. The measurement stack does not only report carbon; with the hourly figure, it &lt;strong>allows
it to be reduced&lt;/strong>.&lt;/p>
&lt;hr>
&lt;h2 id="energy-efficiency-kpis-to-watch">Energy efficiency KPIs to watch&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>KPI&lt;/th>
&lt;th>What it indicates&lt;/th>
&lt;th>Where from&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>J/token per model&lt;/strong>&lt;/td>
&lt;td>energy efficiency&lt;/td>
&lt;td>Kepler ÷ engine tokens&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Mean power per pod&lt;/strong>&lt;/td>
&lt;td>consumption per workload&lt;/td>
&lt;td>&lt;code>rate(kepler_container_joules_total)&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>gCO₂/token (hourly)&lt;/strong>&lt;/td>
&lt;td>real footprint&lt;/td>
&lt;td>energy × grid intensity&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Idle energy&lt;/strong>&lt;/td>
&lt;td>waste&lt;/td>
&lt;td>power of pods with no traffic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Effective PUE&lt;/strong>&lt;/td>
&lt;td>DC overhead&lt;/td>
&lt;td>total energy ÷ compute energy&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>These five turn the energy panel into a decision tool: which model is more efficient, which pod
wastes, when it pays to schedule. Without them the stack is a pretty panel; with them, it is
the input to optimisation and reporting.&lt;/p>
&lt;hr>
&lt;h2 id="example-energy-attribution-on-a-multi-tenant-cluster">Example: energy attribution on a multi-tenant cluster&lt;/h2>
&lt;p>With Kepler splitting per pod, an energy report per team on the example node (8×H100,
5.6 kW of boards, ~7.84 kW with PUE), over one month:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Team&lt;/th>
&lt;th>Energy/month (kWh)&lt;/th>
&lt;th>gCO₂ (ES grid ~160)&lt;/th>
&lt;th>Mean J/token&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>A · chat-prod&lt;/td>
&lt;td>~3,100&lt;/td>
&lt;td>~496 kg&lt;/td>
&lt;td>~2.2 J/tok&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>B · batch&lt;/td>
&lt;td>~1,400&lt;/td>
&lt;td>~224 kg&lt;/td>
&lt;td>~2.8 J/tok&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C · experimentation&lt;/td>
&lt;td>~900&lt;/td>
&lt;td>~144 kg&lt;/td>
&lt;td>~6.5 J/tok&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>idle&lt;/td>
&lt;td>~250&lt;/td>
&lt;td>~40 kg&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>What it reveals: A is efficient (FP8, high utilisation → 2.2 J/token); C is inefficient
(6.5 J/token, underused GPU), just as it dragged the idle cost in the FinOps track. The carbon
column takes the figure into the per-team CSRD report. And the idle row (~250 kWh, ~40 kg
CO₂) is pure wasted energy. Without Kepler, that split does not exist: you would only see the
node&amp;rsquo;s total consumption, with no idea whose each watt is. With it, energy is attributable and
actionable, exactly like cost with OpenCost. In fact, the two panels together (OpenCost cost +
Kepler energy) give the complete picture of efficiency per team.&lt;/p>
&lt;hr>
&lt;h2 id="energy-and-carbon-aware-scheduling">Energy and carbon-aware scheduling&lt;/h2>
&lt;p>The highest-return action the stack enables: scheduling by energy and carbon. With energy
per pod and hourly grid intensity in Prometheus, two policies:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>By carbon&lt;/strong>: move the deferrable load (training, ingestion, overnight batch) to the
hours of lowest grid intensity. In Spain, the hours of peak solar (~80 gCO₂/kWh) against
gas-heavy nights (~250). The same workload emits ~3× less when scheduled at the right hour.&lt;/li>
&lt;li>&lt;strong>By efficiency&lt;/strong>: consolidate workloads to eliminate idle (a powered GPU with no work is
wasted energy), and direct each workload to the hardware most efficient for it.&lt;/li>
&lt;/ul>
&lt;p>Both use the same data (energy per pod + hourly intensity) that the stack already exposes, and
they connect with cluster scheduling (Kueue/Volcano). It is the difference between
measuring carbon (and reporting it) and reducing it (by scheduling). The C3 stack
enables both.&lt;/p>
&lt;hr>
&lt;h2 id="deployment-checklist">Deployment checklist&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Step&lt;/th>
&lt;th>Action&lt;/th>
&lt;th>Verification&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>dcgm-exporter (GPU Operator)&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code> in Prometheus&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>Kepler DaemonSet, kernel with eBPF&lt;/td>
&lt;td>&lt;code>kepler_container_joules_total&lt;/code> per pod&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>&lt;code>dcgmEndpoint&lt;/code> pointing at the exporter&lt;/td>
&lt;td>GPU/MIG attribution works&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>Grid intensity metric (esios)&lt;/td>
&lt;td>&lt;code>grid_carbon_intensity&lt;/code> in Prometheus&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>Calibration vs power meter (once per node type)&lt;/td>
&lt;td>error bar documented&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>Grafana panels (J/token, carbon, idle)&lt;/td>
&lt;td>KPIs visible&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Step 5 (calibration) is the one most often skipped and the one that gives the figure
credibility; step 3 (&lt;code>dcgmEndpoint&lt;/code>) is the one most often forgotten, and without it the GPU is
not attributed.&lt;/p>
&lt;hr>
&lt;h2 id="energy-by-phase-prefill-vs-decode">Energy by phase: prefill vs decode&lt;/h2>
&lt;p>A level of detail the stack allows and that guides optimisation: power is not constant
during a request. Prefill (processing the prompt) is compute-bound and draws a lot of power
in a burst; decode (generating tokens) is memory-bound and draws a lower peak but for
longer. With fine DCGM sampling that bimodal profile is visible, and it has consequences:&lt;/p>
&lt;ul>
&lt;li>A workload with long prompts (a lot of prefill) has a different power profile from one with
long generation (a lot of decode), even if they produce the same tokens.&lt;/li>
&lt;li>&lt;strong>Power capping&lt;/strong> affects each phase differently: trimming peak power mostly touches the
prefill.&lt;/li>
&lt;/ul>
&lt;p>For most cases the mean J/token is enough; to optimise energy efficiency thoroughly,
separating prefill and decode consumption (aligning the DCGM window with each phase) reveals
where the spend is. It is the energy equivalent of separating TTFT and TPOT in benchmarking,
the same principle, measuring the two phases separately because they behave differently.&lt;/p>
&lt;hr>
&lt;h2 id="state-of-the-art-the-llm-resolution-gap">State of the art: the LLM resolution gap&lt;/h2>
&lt;p>An honest note on the limits of current tooling. The general-purpose tools (Kepler,
CodeCarbon, Scaphandre) measure well at machine, process and pod level, but they &lt;strong>lack
LLM-specific resolution&lt;/strong>: out of the box they do not distinguish prefill from decode energy,
nor energy per token by phase, nor the energy cost of a reasoning block against a normal
response. It is an open gap in the field: per-pod attribution exists, but attribution &lt;strong>per
token and per inference phase&lt;/strong> has to be built by crossing power (DCGM/Kepler) with engine
metrics (vLLM exposes tokens per phase). The 2026 state of the art is a mature stack for
infrastructure and an emerging one for LLM-specific resolution, exactly the crossing
that the reproducible harness (S4) has to build by hand. Acknowledging it avoids promising a
per-token accuracy that the general-purpose tools do not deliver on their own.&lt;/p>
&lt;hr>
&lt;h2 id="limits-and-traps-data-driven">Limits and traps (data-driven)&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Uncalibrated estimation.&lt;/strong> Kepler estimates; without calibrating against a power meter at
least once, the figure can carry a noticeable error, especially when it falls back on
regression models.&lt;/li>
&lt;li>&lt;strong>Forgetting the &lt;code>dcgmEndpoint&lt;/code>.&lt;/strong> Without pointing Kepler at the dcgm-exporter, GPU (and
MIG) attribution does not work; it is the key parameter for GPU.&lt;/li>
&lt;li>&lt;strong>Accounting for overhead badly.&lt;/strong> The bulk of the cost of measuring is in Prometheus; if
you stand it up just for energy, budget for it (1.5 cores, 15 GB).&lt;/li>
&lt;li>&lt;strong>Confusing bench and production.&lt;/strong> Zeus measures one workload finely; Kepler attributes
continuously in production. Using one for the other gives the wrong figure.&lt;/li>
&lt;li>&lt;strong>Misaligned windows.&lt;/strong> For energy per token, the power window and the token window must
coincide; if they do not, the J/token does not correspond.&lt;/li>
&lt;/ol>
&lt;p>With the measurement stack in place, the energy track has its instrumentation; the following
articles (C6 carbon, C8 TCO and regulation) build on this data. The foundation is this: a
continuous per-pod measurement, calibrated, on top of the observability infrastructure you
already have.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>Measuring the energy of an AI cluster in production does not require a new system: it requires
adding Kepler, per-pod attribution via eBPF, to the DCGM, Prometheus and Grafana stack you
already have for observability. That is the central idea of C3: the cost of measuring is almost
entirely in the Prometheus you already pay for, and Kepler turns it from &amp;ldquo;card power&amp;rdquo; into
&amp;ldquo;energy per pod, per team and per token&amp;rdquo;. With two cautions that separate a credible figure
from a decorative panel: calibrate once against a power meter (to have an error bar that
survives an audit) and point the &lt;code>dcgmEndpoint&lt;/code> (without which the GPU is not attributed).
Done that way, energy stops being the soft axis of sustainability and becomes an actionable
time series: the J/token per model that exposes inefficiency, the hourly carbon that enables
clean scheduling, the idle that gets recovered, and the CSRD report that holds up. For the
sovereign platform, the Kepler+DCGM stack closes the energy loop just as OpenCost closes the
cost one, and together, with GuideLLM&amp;rsquo;s goodput, they give the three faces of the dashboard
measured on the same iron, in euros, joules and grams of CO₂.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mlperf-power-energy-efficiency-benchmark/">MLPerf Power&lt;/a> — the standard measurement at the wall (PTDaemon), complementary to software instrumentation (DCGM/RAPL/Kepler).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/measuring-gpu-power-nvml-dcgm-sampling-errors/">Measuring the power of a GPU: NVML, DCGM and sampling errors&lt;/a> — the sensor metrology that feeds all these tools: averaging window, cumulative counter and what falls outside the counter.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-energy-leaderboards-wh-per-token/">LLM energy efficiency leaderboards&lt;/a> — where the J/token these tools produce end up: the public rankings and how to interpret them without being misled by the setup.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/watt-to-carbon-pue-grid-intensity-gco2-per-token/">From watt to carbon: PUE, grid intensity and the real cost of a token&lt;/a> — the conversion step from measured watts to gCO₂eq and to euros, applying datacenter PUE and the country&amp;rsquo;s electricity grid intensity.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/levers-fewer-watts-per-token-llm-inference/">Energy efficiency levers in LLM inference&lt;/a> — the optimisations that reduce the consumption these tools measure: quantization, batching, engine and power schedulers.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Kepler · GitHub (CNCF, eBPF, por pod/nodo) — &lt;a href="https://github.com/sustainable-computing-io/kepler">https://github.com/sustainable-computing-io/kepler&lt;/a>&lt;/li>
&lt;li>Kepler · configuración (fuentes, dcgmEndpoint para MIG) — &lt;a href="https://github.com/sustainable-computing-io/kepler/blob/main/docs/user/configuration.md">https://github.com/sustainable-computing-io/kepler/blob/main/docs/user/configuration.md&lt;/a>&lt;/li>
&lt;li>CNCF · explorando Kepler (consumo de aplicaciones cloud) — &lt;a href="https://www.cncf.io/blog/2023/10/11/exploring-keplers-potentials-unveiling-cloud-application-power-consumption/">https://www.cncf.io/blog/2023/10/11/exploring-keplers-potentials-unveiling-cloud-application-power-consumption/&lt;/a>&lt;/li>
&lt;li>Red Hat · Kepler: monitorización eficiente de energía para Kubernetes — &lt;a href="https://next.redhat.com/2023/08/22/introducing-kepler-efficient-power-monitoring-for-kubernetes/">https://next.redhat.com/2023/08/22/introducing-kepler-efficient-power-monitoring-for-kubernetes/&lt;/a>&lt;/li>
&lt;li>arXiv · Container-level Energy Observability in Kubernetes (overhead) — &lt;a href="https://arxiv.org/pdf/2504.10702">https://arxiv.org/pdf/2504.10702&lt;/a>&lt;/li>
&lt;li>NVIDIA · dcgm-exporter — &lt;a href="https://github.com/NVIDIA/dcgm-exporter">https://github.com/NVIDIA/dcgm-exporter&lt;/a>&lt;/li>
&lt;li>Zeus Project (ml.energy) — &lt;a href="https://ml.energy/zeus/">https://ml.energy/zeus/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>OpenCost in depth: how GPU cost is allocated in Kubernetes</title><link>https://blog.lo0.es/en/posts/opencost-gpu-cost-allocation-kubernetes/</link><pubDate>Sun, 14 Jun 2026 02:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/opencost-gpu-cost-allocation-kubernetes/</guid><description>&lt;blockquote>
&lt;p>Notation: amounts in &lt;strong>euros (N €)&lt;/strong>, decimals with a point. European references (OVH is among
the supported providers). The dollar sign is not used (on this site it is a formula
delimiter).&lt;/p>
&lt;/blockquote>
&lt;h2 id="what-this-article-covers">What this article covers&lt;/h2>
&lt;p>Second article in the &lt;strong>FinOps&lt;/strong> track (A2), and the first tool deep dive. The
&lt;a href="https://blog.lo0.es/en/posts/gpu-llm-finops-frameworks-metrics-state-of-the-art/">FinOps introduction&lt;/a> established that
OpenCost is the CNCF standard for cost allocation; here the box gets opened: how it allocates
cost internally, where it gets its data from, how on-prem prices are configured in euros, what its
API exposes, and where the traps are. Understanding the mechanics matters because &lt;strong>it defines what
any tool on top (Kubecost included) can and cannot do&lt;/strong>, and because a badly set base price
invalidates the whole split. No recommendations; only the mechanics and the methodology.&lt;/p>
&lt;hr>
&lt;h2 id="what-opencost-is">What OpenCost is&lt;/h2>
&lt;p>OpenCost is a vendor-neutral, Apache 2.0 project, originally built by Kubecost and donated to
the CNCF (incubating project). It is a cost allocation layer for Kubernetes: it reads
resource usage from the cluster, joins it with a price, and splits the cost across Kubernetes
dimensions down to the container (&lt;a href="https://github.com/opencost/opencost">OpenCost · GitHub&lt;/a>).
It supports dynamic pricing via the billing APIs of AWS, Azure and GCP, &lt;strong>and custom prices for
on-prem clusters&lt;/strong> (&lt;a href="https://opencost.io/docs/configuration/">OpenCost · configuration&lt;/a>).&lt;/p>
&lt;p>What it is &lt;strong>not&lt;/strong>: it is not an optimiser, nor a governance system, nor a product unit economics
tool. It is the piece that answers &amp;ldquo;how much does each namespace/pod/team cost?&amp;rdquo;, and leaves the
rest to the layers above. That is why it is the foundation of the FinOps track: without correct
allocation, there is no cost per token and no TCO model worth anything.&lt;/p>
&lt;hr>
&lt;h2 id="the-cost-model-node-level-allocation">The cost model: node-level allocation&lt;/h2>
&lt;p>The key to OpenCost is that the model works at node level. It starts from the node&amp;rsquo;s &lt;strong>resource
capacity&lt;/strong> (CPU, RAM, GPU, storage) and its &lt;strong>total price&lt;/strong>, and splits that price across the
resources. When the provider does not give explicit per-resource prices, OpenCost uses the &lt;strong>ratio
of a set of base prices&lt;/strong> (marginal rates, customisable) and &lt;strong>normalises them so that the sum of
the components equals the node&amp;rsquo;s total price&lt;/strong> (&lt;a href="https://opencost.io/docs/configuration/on-prem/">OpenCost · on-prem&lt;/a>).&lt;/p>
&lt;p>This is what makes it work on-prem: you declare the node cost (depreciated capex + opex), and
OpenCost splits it without needing a cloud invoice. The normalisation guarantees that no cost is
&amp;ldquo;lost&amp;rdquo; or &amp;ldquo;invented&amp;rdquo;: what you pay for the node is exactly what gets split across its resources.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 250" role="img" aria-label="OpenCost cost model: total node price split and normalised across CPU, GPU, RAM and disk, and then allocated by usage to each pod" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.dsh{fill:none;stroke:currentColor;stroke-width:1.3;stroke-dasharray:5 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.3;marker-end:url(#om)}&lt;/style>
&lt;defs>&lt;marker id="om" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;rect class="bx" x="20" y="40" width="170" height="60" rx="6"/>
&lt;text x="32" y="63" class="tl">Node price&lt;/text>
&lt;text x="32" y="81" class="ts">depreciated capex&lt;/text>
&lt;text x="32" y="95" class="ts">+ opex (€/hour)&lt;/text>
&lt;path class="ar" d="M190,70 L235,70"/>
&lt;rect class="bx" x="235" y="40" width="210" height="60" rx="6"/>
&lt;text x="247" y="63" class="tl">Split + normalisation&lt;/text>
&lt;text x="247" y="81" class="ts">CPU / GPU / RAM / disk&lt;/text>
&lt;text x="247" y="95" class="ts">sum = total price&lt;/text>
&lt;path class="ar" d="M445,70 L490,70"/>
&lt;rect class="bx" x="490" y="40" width="270" height="60" rx="6"/>
&lt;text x="502" y="63" class="tl">Allocation by USAGE&lt;/text>
&lt;text x="502" y="81" class="ts">GPU half used = half the cost&lt;/text>
&lt;text x="502" y="95" class="ts">per pod / namespace / team&lt;/text>
&lt;rect class="dsh" x="20" y="130" width="740" height="96" rx="6"/>
&lt;text x="34" y="152" class="tl">What OpenCost guarantees: no cost is lost and none is invented.&lt;/text>
&lt;text x="34" y="172" class="ts">· On-prem: you declare the node cost; OpenCost splits it (no cloud invoice needed).&lt;/text>
&lt;text x="34" y="190" class="ts">· GPU is allocated by USAGE, not presence: a GPU at 50 % charges half its cost.&lt;/text>
&lt;text x="34" y="208" class="ts">· Trap: if the default base price is wrong, the WHOLE split is wrong (typical on-prem under-pricing).&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="where-the-data-comes-from-prometheus">Where the data comes from: Prometheus&lt;/h2>
&lt;p>OpenCost instruments nothing of its own: it reads from Prometheus, which is a prerequisite
of the installation (&lt;a href="https://github.com/opencost/opencost">OpenCost · GitHub&lt;/a>). The sources:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Source&lt;/th>
&lt;th>What it provides&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>kube-state-metrics&lt;/code>&lt;/td>
&lt;td>state of K8s objects (pods, requests/limits)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>node-exporter&lt;/code>&lt;/td>
&lt;td>node resources and capacity&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>cAdvisor&lt;/code>&lt;/td>
&lt;td>real CPU/memory usage per container&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>dcgm-exporter&lt;/code> (NVIDIA)&lt;/td>
&lt;td>&lt;strong>GPU&lt;/strong> usage, memory and power&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>With those series, OpenCost automates the join between Kubernetes resource usage and the
provider price, reducing the custom PromQL you would otherwise write by hand (&lt;a href="https://grafana.com/docs/grafana-cloud/monitor-infrastructure/kubernetes-monitoring/manage-costs/">Grafana ·
manage costs&lt;/a>).
The &lt;strong>GPU&lt;/strong> signal comes from DCGM via the &lt;code>dcgm-exporter&lt;/code> (part of the NVIDIA GPU Operator),
the same base as &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability&lt;/a>. Without
Prometheus and without DCGM exporting, OpenCost does not see the GPU.&lt;/p>
&lt;hr>
&lt;h2 id="architecture-the-pieces">Architecture: the pieces&lt;/h2>
&lt;p>OpenCost is not a monolith; it is three responsibilities worth telling apart:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Function&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Cost model&lt;/strong>&lt;/td>
&lt;td>resolves the node price and splits/normalises it across resources&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Allocation API&lt;/strong>&lt;/td>
&lt;td>serves the allocated cost per dimension (&lt;code>/allocation&lt;/code> and endpoints)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Exporter&lt;/strong>&lt;/td>
&lt;td>publishes cost metrics on &lt;code>/metrics&lt;/code> for Prometheus/Grafana&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The data flow: Prometheus holds the usage series (from kube-state-metrics, cAdvisor,
dcgm-exporter); the cost model joins them with the resolved price; the Allocation API
serves them aggregated by whatever dimension you ask for; and the exporter returns them to
Prometheus so that Grafana can plot them. It is a cycle: metrics come in from Prometheus and cost
goes back to Prometheus enriched. That symmetry is what lets you reuse your observability stack
without standing up a new database: OpenCost lives on top of Prometheus, not beside it.&lt;/p>
&lt;p>An operational note: historical analysis depends on Prometheus retention. For cost reports
spanning months, a long-term backend behind it (Thanos, Mimir, VictoriaMetrics) is advisable, or
periodically exporting the allocations to a store of your own.&lt;/p>
&lt;hr>
&lt;h2 id="on-prem-prices-in-euros-the-piece-that-decides-everything">On-prem prices in euros: the piece that decides everything&lt;/h2>
&lt;p>On-prem, the most important figure, and the most neglected, is the &lt;strong>node price&lt;/strong>. It is configured
per provider; the options are &lt;code>alibaba&lt;/code>, &lt;code>aws&lt;/code>, &lt;code>azure&lt;/code>, &lt;code>gcp&lt;/code>, &lt;code>oracle&lt;/code>, &lt;strong>&lt;code>ovh&lt;/code>&lt;/strong> or &lt;strong>&lt;code>default&lt;/code>&lt;/strong>
(on-prem) (&lt;a href="https://opencost.io/docs/configuration/">OpenCost · configuration&lt;/a>).
For on-prem you use &lt;code>default&lt;/code>, supplying base prices in a &lt;code>default.json&lt;/code> (or by overriding the Helm
&lt;code>values.yaml&lt;/code>); anything you do not override uses the chart&amp;rsquo;s value (&lt;a href="https://opencost.io/docs/configuration/on-prem/">OpenCost · on-prem&lt;/a>).&lt;/p>
&lt;p>To set the node price in euros, the calculation is the TCO model one: depreciated capex + opex. An
example of an &lt;strong>8×H100&lt;/strong> node:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Calculation&lt;/th>
&lt;th>€/hour&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Capex (node ~240,000 €, 36 months)&lt;/td>
&lt;td>240,000 ÷ 26,280 h&lt;/td>
&lt;td>~9.13&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Energy (5.6 kW × PUE 1.4 × 0.058 €/kWh, France)&lt;/td>
&lt;td>~0.455 €/h&lt;/td>
&lt;td>~0.46&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Maintenance, networking, operations&lt;/td>
&lt;td>estimate&lt;/td>
&lt;td>~1.5&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Node total&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~11.1 €/h&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>That ~11 €/h is the number you put into OpenCost, and everything is split from it. And here is
the documented trap: with the default pricing configuration, OpenCost &lt;strong>under-prices CPU and
GPU on-prem&lt;/strong> (&lt;a href="https://github.com/opencost/opencost/issues/3781">issue #3781&lt;/a>).
If you do not adjust the base prices to your real cost, the split will be systematically low and
the cost per token you report will be unreal. &lt;strong>Configuring the node price is not optional: it is
the figure everything else hangs from.&lt;/strong>&lt;/p>
&lt;hr>
&lt;h3 id="configuring-the-price-an-example">Configuring the price: an example&lt;/h3>
&lt;p>The base price is given in the Helm &lt;code>values.yaml&lt;/code> (or a &lt;code>default.json&lt;/code>), in euros per hour and per
unit of resource, derived from the node&amp;rsquo;s total cost:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># values.yaml — on-prem prices in €/hour (8xH100 node, France)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">opencost&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">customPricing&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">provider&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">custom&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">costModel&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">description&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;On-prem 8xH100 node&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">CPU&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0.030&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># €/CPU-hour&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">RAM&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0.004&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># €/GB-hour&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">GPU&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1.30&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># €/GPU-hour ← the figure that moves cost/token the most&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">storage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0.0002&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># €/GB-hour&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>OpenCost normalises those values so that, multiplied by the node capacity, they add up to the
total price you declared (~11 €/h in the example). The number to be most careful with is the
&lt;strong>GPU&lt;/strong> one: at 1.30 €/GPU-hour, the 8 cards are ~10.4 €/h, the bulk of the node&amp;rsquo;s cost. A badly set
GPU base shifts the whole cost per token. Always review the resolved prices in
&lt;code>/allNodePricing&lt;/code> after configuring, because the default under-pricing bug bites exactly here.&lt;/p>
&lt;hr>
&lt;h2 id="allocation-by-usage-not-by-presence">Allocation: by usage, not by presence&lt;/h2>
&lt;p>The behaviour that has changed most and that matters most for GPU: in recent versions, &lt;strong>the cost
allocated to a GPU is determined by its usage, not its presence&lt;/strong>. If a GPU costing 100 €/month is
only half used, the allocated cost is ~50 € ([search]). This aligns allocation with reality,
you pay for what you use, but it has a consequence: the other half does not disappear, it is
&lt;strong>idle&lt;/strong> that somebody keeps paying for. OpenCost exposes it, and that is where the optimisation
lever comes from.&lt;/p>
&lt;p>OpenCost allocates to any Kubernetes dimension: &lt;strong>cluster, node, namespace, controller
(deployment, statefulset and so on), service, pod and container&lt;/strong>, and by &lt;strong>labels&lt;/strong> (team,
product). Access is through the Allocation API and several endpoints that expose the mechanics:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Endpoint&lt;/th>
&lt;th>What it returns&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>/allocation&lt;/code>&lt;/td>
&lt;td>cost allocated by the dimension you ask for&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/costDataModel&lt;/code>&lt;/td>
&lt;td>the resolved node price&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/allNodePricing&lt;/code>&lt;/td>
&lt;td>hourly prices per node&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/pricingSourceSummary&lt;/code>&lt;/td>
&lt;td>summary of the pricing source&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/metrics&lt;/code>&lt;/td>
&lt;td>cost metrics for Prometheus&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;code>/metrics&lt;/code> endpoint is what turns OpenCost into a Prometheus exporter: once there, you can
write PromQL to calculate the cost and efficiency of any Kubernetes concept and build panels in
Grafana (&lt;a href="https://opencost.io/docs/integrations/opencost-exporter/">OpenCost · exporter&lt;/a>).&lt;/p>
&lt;h3 id="allocation-api-parameters">Allocation API parameters&lt;/h3>
&lt;p>&lt;code>/allocation&lt;/code> is controlled by a handful of parameters worth knowing, because they define which
number you get:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Parameter&lt;/th>
&lt;th>What it controls&lt;/th>
&lt;th>Example&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>window&lt;/code>&lt;/td>
&lt;td>time range&lt;/td>
&lt;td>&lt;code>7d&lt;/code>, &lt;code>today&lt;/code>, specific dates&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>aggregate&lt;/code>&lt;/td>
&lt;td>aggregation dimension&lt;/td>
&lt;td>&lt;code>namespace&lt;/code>, &lt;code>label:team&lt;/code>, &lt;code>pod&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>accumulate&lt;/code>&lt;/td>
&lt;td>sum the range or break it per interval&lt;/td>
&lt;td>&lt;code>true&lt;/code> (total) / &lt;code>false&lt;/code> (series)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>idle&lt;/code>&lt;/td>
&lt;td>include idle cost or not&lt;/td>
&lt;td>&lt;code>true&lt;/code> / &lt;code>false&lt;/code> / &lt;code>separate&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>filter&lt;/code>&lt;/td>
&lt;td>filter by namespace, label and so on&lt;/td>
&lt;td>&lt;code>namespace:&amp;quot;llm-prod&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;strong>&lt;code>idle&lt;/code>&lt;/strong> parameter is the most revealing: with &lt;code>idle=separate&lt;/code>, OpenCost returns the idle
cost as a separate row, which lets you see at a glance how much is being paid for unused
capacity. A query with &lt;code>aggregate=label:team&lt;/code> and &lt;code>idle=separate&lt;/code> over &lt;code>window=30d&lt;/code> is, literally,
the monthly chargeback report with the waste highlighted.&lt;/p>
&lt;hr>
&lt;h2 id="gpu-in-depth-dcgm-usage-mig-and-idle">GPU in depth: DCGM, usage, MIG and idle&lt;/h2>
&lt;p>The GPU is the expensive resource, so its allocation deserves detail:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Usage signal&lt;/strong>: &lt;code>dcgm-exporter&lt;/code> gives &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code> (utilisation), &lt;code>DCGM_FI_DEV_FB_USED&lt;/code>
(memory), and power. OpenCost uses utilisation to allocate by usage.&lt;/li>
&lt;li>&lt;strong>MIG (Multi-Instance GPU)&lt;/strong>: an A100 is split into up to 7 isolated instances, which allows
cutting idle from 50 % to almost 0 % by sharing one card across small workloads
([search]; see &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">sharing the GPU: time-slicing, MPS and MIG&lt;/a>).
The cost allocation of each MIG instance follows the same usage logic.&lt;/li>
&lt;li>&lt;strong>Idle&lt;/strong>: the reliable detection pattern combines three pieces, DCGM metrics in Prometheus, an
alert that fires when &lt;code>DCGM_FI_DEV_GPU_UTIL &amp;lt; 10&lt;/code> for more than 15 minutes, and routing of
that alert to the team that owns the namespace ([search]).&lt;/li>
&lt;/ul>
&lt;p>An attribution nuance worth knowing: MIG creates isolated instances that Kubernetes sees as
distinct resources, so OpenCost allocates them cleanly, each one to its pod. Time-slicing, by
contrast, shares the same physical GPU across several pods with no compute isolation: there,
per-pod utilisation is harder to separate, and cost attribution becomes approximate (the GPU is
split across the pods sharing it, according to their measured usage). The practical rule: if you
need exact per-team chargeback on a shared GPU, MIG gives cleaner attribution than
time-slicing, at the price of rigid fixed partitions. It is a trade-off between cost accuracy and
flexibility that is better decided before, not after, building the split.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 220" role="img" aria-label="GPU allocation by usage in OpenCost: used cost charged to the pod, idle cost flagged to the namespace owner, and MIG splitting one card across workloads" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.dsh{fill:none;stroke:currentColor;stroke-width:1.3;stroke-dasharray:5 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.3;marker-end:url(#om2)}&lt;/style>
&lt;defs>&lt;marker id="om2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;rect class="bx" x="20" y="40" width="170" height="70" rx="6"/>
&lt;text x="32" y="63" class="tl">GPU (DCGM)&lt;/text>
&lt;text x="32" y="81" class="ts">util, memory, power&lt;/text>
&lt;text x="32" y="98" class="ts">→ to Prometheus&lt;/text>
&lt;path class="ar" d="M190,75 L235,60"/>
&lt;path class="ar" d="M190,80 L235,150"/>
&lt;rect class="bx" x="235" y="34" width="240" height="44" rx="6"/>
&lt;text x="247" y="54" class="tl">USED cost → pod / team&lt;/text>
&lt;text x="247" y="70" class="ts">charged by utilisation&lt;/text>
&lt;rect class="dsh" x="235" y="128" width="240" height="44" rx="6"/>
&lt;text x="247" y="148" class="tl">IDLE cost → namespace owner&lt;/text>
&lt;text x="247" y="164" class="ts">alert util &amp;lt; 10 % &amp;gt; 15 min&lt;/text>
&lt;rect class="bx" x="520" y="80" width="240" height="50" rx="6"/>
&lt;text x="532" y="100" class="tl">MIG: 1 A100 → up to 7&lt;/text>
&lt;text x="532" y="118" class="ts">splits the card, idle 50%→~0&lt;/text>
&lt;text x="20" y="200" class="ts">Usage allocation reveals idle; MIG and scheduling recover it. OpenCost measures; other layers act.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="efficiency-the-number-that-triggers-action">Efficiency: the number that triggers action&lt;/h2>
&lt;p>With cost allocated by usage, OpenCost lets you calculate efficiency: how much of the allocated
cost corresponds to useful work against the total. A GPU at 30 % utilisation has an efficiency of
30 %: the remaining 70 % is idle cost that somebody pays without receiving work. That figure, per
team and per namespace, is what turns a dashboard into a chargeback conversation: &amp;ldquo;your namespace
has 5 GPUs allocated at 25 % efficiency; either you raise utilisation or you release three cards&amp;rdquo;.
It connects with &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy&lt;/a>:
allocation locates the waste; scheduling and co-residency recover it.&lt;/p>
&lt;hr>
&lt;h2 id="worked-example-the-allocation-report">Worked example: the allocation report&lt;/h2>
&lt;p>On the ~11 €/h node (8×H100), three teams share the cluster for a month. What a
&lt;code>/allocation?aggregate=label:team&amp;amp;idle=separate&amp;amp;window=30d&lt;/code> query returns:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Team&lt;/th>
&lt;th>GPU-hours&lt;/th>
&lt;th>Avg. util.&lt;/th>
&lt;th>Allocated cost (€)&lt;/th>
&lt;th>Efficiency&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>A · chat-prod&lt;/td>
&lt;td>2,880 (4 GPU)&lt;/td>
&lt;td>78 %&lt;/td>
&lt;td>~3,744&lt;/td>
&lt;td>high&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>B · batch&lt;/td>
&lt;td>1,440 (2 GPU)&lt;/td>
&lt;td>55 %&lt;/td>
&lt;td>~1,872&lt;/td>
&lt;td>medium&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C · experimentation&lt;/td>
&lt;td>1,440 (2 GPU)&lt;/td>
&lt;td>22 %&lt;/td>
&lt;td>~1,872&lt;/td>
&lt;td>&lt;strong>low&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>idle&lt;/strong> (separate)&lt;/td>
&lt;td>—&lt;/td>
&lt;td>—&lt;/td>
&lt;td>&lt;strong>~1,100&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>What the report reveals: the cost allocated to A, B and C comes out of their GPU-hours and the node
price; but the &lt;strong>idle&lt;/strong> row (~1,100 €/month) is capacity nobody used and everyone pays for in the
pro rata. And the efficiency column points at C: 2 GPUs at 22 % is ~1,560 € a month of which
only ~340 € is useful work. That is the actionable figure: C does not have a model problem, it has
a utilisation problem. Without OpenCost, those ~1,100 € of idle and C&amp;rsquo;s inefficiency stay diluted
in an aggregate invoice nobody questions. With it, they are a named row and a number in euros.&lt;/p>
&lt;hr>
&lt;h2 id="useful-promql-queries">Useful PromQL queries&lt;/h2>
&lt;p>As a Prometheus exporter, OpenCost lets you build the panels by hand. Some reference queries (exact
metric names vary by version; check in your &lt;code>/metrics&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Cost per node and hour (resolved price)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">node_total_hourly_cost&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># GPU cost allocated per namespace (€/hour)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">by&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">namespace&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">container_gpu_allocation&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">on&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">node&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">group_left&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">node_gpu_hourly_cost&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Average GPU utilisation per namespace (for efficiency)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">avg&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">by&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">namespace&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">DCGM_FI_DEV_GPU_UTIL&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Idle GPUs: utilisation &amp;lt; 10 % sustained&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">DCGM_FI_DEV_GPU_UTIL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The last one, wrapped in an alert with &lt;code>for: 15m&lt;/code> and routed to the namespace owner, is the
standard idle detection pattern. With these series in Grafana you have cost per team, efficiency
and waste on one dashboard, the &amp;ldquo;Inform report&amp;rdquo; of the FinOps phase in real time.&lt;/p>
&lt;hr>
&lt;h2 id="dynamic-cloud-prices-vs-custom-on-prem-prices">Dynamic cloud prices vs custom on-prem prices&lt;/h2>
&lt;p>The difference is worth understanding, because it changes how reliable the figure is:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Cloud&lt;/strong> (aws/azure/gcp/oracle/ovh): OpenCost obtains the price dynamically from the
provider&amp;rsquo;s billing API, so the allocated cost reflects the real rate (including discounts, spot
and so on) without you declaring anything.&lt;/li>
&lt;li>&lt;strong>On-prem&lt;/strong> (&lt;code>default&lt;/code>/&lt;code>custom&lt;/code>): you declare the price, derived from your TCO model. The
advantage is total control; the risk is that a badly calculated price skews everything. That is
why the TCO model (depreciated capex + opex) is not an academic exercise: it is literally
OpenCost&amp;rsquo;s &lt;em>input&lt;/em>.&lt;/li>
&lt;/ul>
&lt;p>The &lt;strong>&lt;code>ovh&lt;/code>&lt;/strong> provider among those supported is relevant for a European platform: if part of the
workload goes to a European sovereign cloud, OpenCost can allocate its cost with dynamic pricing,
and mix it with on-prem in the same allocation view.&lt;/p>
&lt;hr>
&lt;h2 id="from-pod-cost-to-cost-per-token">From pod cost to cost per token&lt;/h2>
&lt;p>OpenCost gets as far as &amp;ldquo;this vLLM pod cost X €/hour&amp;rdquo;. The &lt;strong>cost per token&lt;/strong>, the metric that
compares on-prem against cloud, needs one more piece: the &lt;strong>gateway&lt;/strong> (LiteLLM) that counts tokens
per request and per team, as covered in the &lt;a href="https://blog.lo0.es/en/posts/gpu-llm-finops-frameworks-metrics-state-of-the-art/">FinOps introduction&lt;/a>.
The full chain is: OpenCost gives the pod cost by usage → the gateway gives the tokens per team →
dividing them gives the cost per token per team. OpenCost is the hardware half of that
equation; without it, you would know the tokens but not their real cost. That is why A2 (allocation)
is the foundation the whole cost per token of the proposal hangs from.&lt;/p>
&lt;hr>
&lt;h2 id="deployment-helm--prometheus">Deployment: Helm + Prometheus&lt;/h2>
&lt;p>The typical deployment is via Helm, with Prometheus as a prerequisite (for scraping and
series storage). Conceptual steps:&lt;/p>
&lt;ol>
&lt;li>Have Prometheus with &lt;code>kube-state-metrics&lt;/code>, &lt;code>node-exporter&lt;/code>, &lt;code>cAdvisor&lt;/code> and &lt;code>dcgm-exporter&lt;/code>.&lt;/li>
&lt;li>Install OpenCost with Helm, pointing it at your Prometheus.&lt;/li>
&lt;li>Configure the &lt;strong>node price&lt;/strong> in euros (&lt;code>default.json&lt;/code> / &lt;code>values.yaml&lt;/code>), the step most often
neglected.&lt;/li>
&lt;li>Expose &lt;code>/metrics&lt;/code> and build panels in Grafana with PromQL over the cost metrics.&lt;/li>
&lt;/ol>
&lt;p>As a Prometheus exporter, OpenCost lets you &lt;strong>write PromQL for the cost and efficiency of any
concept&lt;/strong> and create bespoke dashboards (&lt;a href="https://opencost.io/docs/integrations/opencost-exporter/">OpenCost · exporter&lt;/a>).
It reuses infrastructure a GPU cluster already has for observability (DCGM, Prometheus,
Grafana): OpenCost only adds the pricing and allocation layer.&lt;/p>
&lt;hr>
&lt;h2 id="opencost-vs-kubecost-just-the-essentials">OpenCost vs Kubecost (just the essentials)&lt;/h2>
&lt;p>OpenCost is the free base; Kubecost (IBM) is the commercial product built on it. Briefly,
what the commercial one adds:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Capability&lt;/th>
&lt;th>OpenCost&lt;/th>
&lt;th>Kubecost&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Cost allocation (CPU/GPU/mem/PV)&lt;/td>
&lt;td>✓&lt;/td>
&lt;td>✓&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GPU via DCGM&lt;/td>
&lt;td>✓&lt;/td>
&lt;td>✓ (3.0)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Long historical retention&lt;/td>
&lt;td>depends on your Prometheus&lt;/td>
&lt;td>included&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Automatic rightsizing&lt;/td>
&lt;td>—&lt;/td>
&lt;td>✓ (Turbonomic)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Governance, alerts, enterprise RBAC&lt;/td>
&lt;td>basic&lt;/td>
&lt;td>✓&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Commercial support&lt;/td>
&lt;td>community&lt;/td>
&lt;td>✓ (IBM)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For most on-prem clusters with a platform team, OpenCost covers allocation; Kubecost adds value
when you want automated optimisation and enterprise features without operating it yourself. The
detail of the comparison, and when paying is worth it, is article &lt;strong>A3&lt;/strong>.&lt;/p>
&lt;hr>
&lt;h2 id="implementation-checklist">Implementation checklist&lt;/h2>
&lt;p>For OpenCost to give a defensible per-team cost, order matters:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Step&lt;/th>
&lt;th>Action&lt;/th>
&lt;th>Verification&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>Prometheus with kube-state-metrics, node-exporter, cAdvisor&lt;/td>
&lt;td>series present&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>NVIDIA GPU Operator + dcgm-exporter&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code> in Prometheus&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>Install OpenCost (Helm) pointing at Prometheus&lt;/td>
&lt;td>&lt;code>/allocation&lt;/code> responds&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>&lt;strong>Configure the node price in €&lt;/strong> (capex+opex)&lt;/td>
&lt;td>&lt;code>/allNodePricing&lt;/code> shows the real one&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>Label pods/namespaces by team and product&lt;/td>
&lt;td>&lt;code>aggregate=label:team&lt;/code> splits correctly&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>Grafana panels for cost, efficiency and idle&lt;/td>
&lt;td>the waste is visible&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>Idle alert (&lt;code>util&amp;lt;10&lt;/code> &lt;code>for: 15m&lt;/code>) to the owner&lt;/td>
&lt;td>the alert reaches the team&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>(optional) long-retention backend&lt;/td>
&lt;td>months of history available&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Step 4 is the one most often skipped and the one that most invalidates the result. If you only do
one thing right, make it that one: without the correct node price in euros, steps 5 to 7 split a
wrong cost with great precision.&lt;/p>
&lt;hr>
&lt;h2 id="limits-and-traps-data-driven">Limits and traps (data-driven)&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Default under-pricing.&lt;/strong> With the default pricing config, OpenCost underestimates on-prem
CPU and GPU (&lt;a href="https://github.com/opencost/opencost/issues/3781">#3781&lt;/a>). Adjust the base prices
to your real cost or the whole split will come out low.&lt;/li>
&lt;li>&lt;strong>It depends on Prometheus and DCGM.&lt;/strong> Without the right series (especially &lt;code>dcgm-exporter&lt;/code>),
there is no GPU allocation. Input data quality rules.&lt;/li>
&lt;li>&lt;strong>Allocation by usage ≠ total cost.&lt;/strong> A GPU at 50 % charges half; the other half is idle that
still costs. Do not confuse &amp;ldquo;allocated cost&amp;rdquo; with &amp;ldquo;cost paid&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Prometheus retention.&lt;/strong> Historical cost analysis depends on how much your Prometheus retains;
for long series, plan a long-term backend.&lt;/li>
&lt;li>&lt;strong>Allocation, not per-token measurement.&lt;/strong> OpenCost reaches the pod; cost per token needs the
gateway (LiteLLM) above it, as covered in the FinOps introduction.&lt;/li>
&lt;/ol>
&lt;p>With OpenCost&amp;rsquo;s mechanics clear, the next article (A3) compares what Kubecost and the commercial
alternatives add, to decide which FinOps stack to adopt. But the foundation, correct allocation
with the node price properly set in euros, is this one.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>OpenCost looks like &amp;ldquo;installing a cost tool&amp;rdquo; and is, in reality, &lt;strong>declaring what your hardware is
worth and letting it split itself&lt;/strong>. That simplicity is its strength, it lives on top of the
Prometheus and DCGM you already have, with no new database, and also its trap: the split is only as
good as the node price you declare, and the default value under-prices on-prem GPU. Properly
configured in euros, OpenCost turns the cluster&amp;rsquo;s aggregate invoice into a split per team, per
product and per GPU, separates used cost from idle, and exposes the efficiency that triggers
chargeback conversations. It is the hardware half of the cost per token equation, the other half,
the tokens, is supplied by the gateway, and without this half there is no TCO model or on-prem vs
cloud comparison that holds up. For a sovereign architecture proposal, OpenCost is the piece that
makes cost stop being an intuition and become a table in euros, reproducible and auditable, with
the idle GPU named and identified. The &lt;em>Inform&lt;/em> phase of GPU FinOps starts here, and everything
else hangs on this number being right.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-chargeback-showback-multi-tenancy/">GPU chargeback and showback&lt;/a> — from OpenCost&amp;rsquo;s cost allocation to the per-team split with budgets (Kueue) and token attribution (LiteLLM).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-idle-cost-not-on-any-invoice/">GPU utilisation as a FinOps lever&lt;/a> — the cost of idle and how occupancy moves the allocated cost.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/full-tco-on-premise-gpu-cluster-capex-gpu-hour-break-even/">TCO of an on-premise GPU cluster: depreciation, energy and infrastructure&lt;/a> — the CAPEX, the depreciation and the €/GPU-hour that OpenCost needs as a base price when the node is not cloud but your own on-premise hardware.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>OpenCost · GitHub (CNCF, Apache 2.0) — &lt;a href="https://github.com/opencost/opencost">https://github.com/opencost/opencost&lt;/a>&lt;/li>
&lt;li>OpenCost · configuración (proveedores: aws/azure/gcp/oracle/ovh/default) — &lt;a href="https://opencost.io/docs/configuration/">https://opencost.io/docs/configuration/&lt;/a>&lt;/li>
&lt;li>OpenCost · configuración on-prem (precios personalizados) — &lt;a href="https://opencost.io/docs/configuration/on-prem/">https://opencost.io/docs/configuration/on-prem/&lt;/a>&lt;/li>
&lt;li>OpenCost · exportador de Prometheus — &lt;a href="https://opencost.io/docs/integrations/opencost-exporter/">https://opencost.io/docs/integrations/opencost-exporter/&lt;/a>&lt;/li>
&lt;li>OpenCost · issue #3781 (infra-precio CPU/GPU on-prem por defecto) — &lt;a href="https://github.com/opencost/opencost/issues/3781">https://github.com/opencost/opencost/issues/3781&lt;/a>&lt;/li>
&lt;li>NVIDIA · dcgm-exporter — &lt;a href="https://github.com/NVIDIA/dcgm-exporter">https://github.com/NVIDIA/dcgm-exporter&lt;/a>&lt;/li>
&lt;li>Grafana · manage costs (OpenCost + Kubernetes) — &lt;a href="https://grafana.com/docs/grafana-cloud/monitor-infrastructure/kubernetes-monitoring/manage-costs/">https://grafana.com/docs/grafana-cloud/monitor-infrastructure/kubernetes-monitoring/manage-costs/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>GitOps for the inference stack with Flux: operating the assistant as code</title><link>https://blog.lo0.es/en/posts/gitops-inference-stack-flux/</link><pubDate>Thu, 11 Jun 2026 10:20:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/gitops-inference-stack-flux/</guid><description>&lt;blockquote>
&lt;p>This post is part of the operational series on how to squeeze a generic on-premise 4×H100 SXM 80 GB LLM cluster. The sibling pieces: the &lt;a href="https://blog.lo0.es/en/posts/end-to-end-document-ingestion-pdf-to-indexed-chunk/">document ingestion pipeline for RAG&lt;/a> that fills the vector store we deploy here, the &lt;a href="https://blog.lo0.es/en/posts/serving-embeddings-rerankers-tei-production/">embeddings and reranker service with TEI&lt;/a> that is one of the services GitOps manages, and the &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">secrets hardening of the sovereign stack&lt;/a>, which goes deep into the secrets problem we only state here. The complete end-to-end assistant (LibreChat + LiteLLM + RAG) that orchestrates all of this will get its own post.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>An LLM assistant in production is a distributed system, not a binary. At a minimum it has an &lt;strong>inference engine&lt;/strong> (vLLM or similar), an &lt;strong>L7 gateway&lt;/strong> that routes per model and applies rate limiting, a &lt;strong>chat front end&lt;/strong>, a &lt;strong>vector store&lt;/strong> for RAG, an &lt;strong>embeddings/reranker&lt;/strong> service, and an &lt;strong>observability&lt;/strong> stack. Six substantial services, spread across three or four namespaces, with start-up dependencies (the front end is useless if the gateway is not there, the gateway is useless if the engine has not loaded the model, RAG does not answer if the vector store is empty). Operating it by hand, a &lt;code>kubectl apply&lt;/code> here and a &lt;code>helm upgrade&lt;/code> there, produces a cluster whose real state nobody can reconstruct: there is no record of what was applied, or when, or why. &lt;strong>GitOps&lt;/strong> solves this with four principles (&lt;a href="https://opengitops.dev/">OpenGitOps&lt;/a>): the desired state is &lt;strong>declarative&lt;/strong>, it is &lt;strong>versioned and immutable&lt;/strong> in git, it is &lt;strong>applied automatically&lt;/strong> by an agent (nobody SSHes in to deploy) and it is &lt;strong>continuously reconciled&lt;/strong> by comparing the actual state against the declared one. &lt;strong>Flux&lt;/strong> is that agent: six controllers (source, kustomize, helm, notification, image-reflector, image-automation) that clone the repo, render Kustomize/Helm, apply to the cluster, correct drift and, optionally, write back to git to bump the tag of a new image. The unit of work is the &lt;code>Kustomization&lt;/code> or the &lt;code>HelmRelease&lt;/code>, with &lt;code>interval&lt;/code> (how often it reconciles), &lt;code>prune&lt;/code> (deletes what is no longer in git), &lt;code>dependsOn&lt;/code> (orders the start-up) and health checks. The &lt;code>interval&lt;/code> sets the MTTR of drift: with &lt;code>interval=1m&lt;/code>, a manual edit to the cluster is reverted in ≤ 1 min on average. Secrets cannot sit in the clear in git, a chicken-and-egg problem, and it is solved with SOPS/age, sealed-secrets or External Secrets + Vault. Rollback is a &lt;code>git revert&lt;/code>. GitOps is not free: the learning curve is real, and debugging why a &lt;code>Kustomization&lt;/code> is not reconciling is a new skill.&lt;/p>
&lt;h2 id="the-analogy-the-master-plan-and-the-foreman-who-does-not-negotiate">The analogy: the master plan and the foreman who does not negotiate&lt;/h2>
&lt;p>Picture a large building site with a signed and filed &lt;strong>master plan&lt;/strong>, and a &lt;strong>foreman&lt;/strong> who has a single order: the site must be identical to the plan, at all times. The foreman does not improvise. Every so often he walks the site with the plan in hand and compares: if a wall is where the plan says, he leaves it; if someone moved a partition overnight without updating the plan, he puts it back; if the plan says there is a column and it does not exist, he builds it; if a column exists but no longer appears on the plan, he knocks it down. The plan is the &lt;strong>single source of truth&lt;/strong>: to change the site you do not touch the site, you change the plan, and the foreman takes care of the rest on his next round.&lt;/p>
&lt;p>That is exactly the mechanics of GitOps. &lt;strong>Git is the master plan&lt;/strong>: declarative (it describes the end state, not the steps), versioned (every change is signed into the history, with author and reason), immutable (a commit is not rewritten). &lt;strong>Flux is the foreman&lt;/strong>: every &lt;code>interval&lt;/code> it walks the cluster, compares against git and converges. And here is the lesson that separates GitOps from &amp;ldquo;keeping the YAML in a repo&amp;rdquo;: the foreman &lt;strong>undoes manual changes&lt;/strong>. If an operator comes in with &lt;code>kubectl edit&lt;/code> and raises the inference engine replicas from 2 to 4 at three in the morning to put out a fire, on the next round Flux takes it back to 2, because the plan says 2. This enrages anyone coming from the imperative world, and it is precisely the point: if you want 4 replicas permanently, you update the plan. The cluster stops being a system with its own opaque memory and becomes a &lt;strong>reproducible projection of git&lt;/strong>. Delete the entire cluster, point a fresh Flux at the same repo, and the site is rebuilt identically.&lt;/p>
&lt;p>The analogy also marks the boundary: the plan describes the building, not who holds the keys to the materials store. &lt;strong>Secrets&lt;/strong> (passwords, tokens, keys) cannot go into the public plan. That is the chicken-and-egg problem we cover further down and that the &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">hardening piece&lt;/a> develops in full.&lt;/p>
&lt;h2 id="the-problem-an-assistant-is-six-services-not-one">The problem: an assistant is six services, not one&lt;/h2>
&lt;p>Before Flux, it is worth fixing the size of the problem. A minimally serious sovereign LLM assistant, deployed on the generic reference cluster, has this service topology:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Service&lt;/th>
&lt;th>Function&lt;/th>
&lt;th>Typical namespace&lt;/th>
&lt;th>State&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Inference engine (vLLM)&lt;/td>
&lt;td>Serves the tokens of the general LLM and the code one&lt;/td>
&lt;td>&lt;code>llm-serving&lt;/code>&lt;/td>
&lt;td>Stateless (model from object store)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Embeddings + reranker (TEI)&lt;/td>
&lt;td>Vectorises queries and reorders candidates for RAG&lt;/td>
&lt;td>&lt;code>llm-serving&lt;/code>&lt;/td>
&lt;td>Stateless&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Vector store (Qdrant)&lt;/td>
&lt;td>Stores and searches document embeddings&lt;/td>
&lt;td>&lt;code>data&lt;/code>&lt;/td>
&lt;td>&lt;strong>Stateful&lt;/strong> (PVC)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>L7 gateway (Envoy AI Gateway / LiteLLM)&lt;/td>
&lt;td>Per-model routing, rate limiting, auth&lt;/td>
&lt;td>&lt;code>gateway&lt;/code>&lt;/td>
&lt;td>Semi-stateless&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Chat front end&lt;/td>
&lt;td>Assistant UI&lt;/td>
&lt;td>&lt;code>apps&lt;/code>&lt;/td>
&lt;td>Stateless&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Observability (Langfuse, OTel Collector, dashboards)&lt;/td>
&lt;td>LLM-aware traces, metrics and logs&lt;/td>
&lt;td>&lt;code>observability&lt;/code>&lt;/td>
&lt;td>Stateful (Langfuse Postgres)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Six services, four or five namespaces, two components with persistent state, and a real dependency graph: the front end calls the gateway, the gateway calls the engine and the embeddings service, RAG depends on the vector store being &lt;strong>full&lt;/strong>, and everything emits traces to observability. Deploying this by hand means remembering the order, the values of each &lt;code>helm install&lt;/code>, the &lt;code>ConfigMap&lt;/code>s, the &lt;code>Secret&lt;/code>s, the &lt;code>nodeSelector&lt;/code>s that pin the GPU pods to the right nodes. Do it twice (a staging environment and a production one) and the two diverge within days. That is exactly what GitOps eliminates.&lt;/p>
&lt;h2 id="the-six-flux-controllers">The six Flux controllers&lt;/h2>
&lt;p>Flux is not a monolithic binary but a set of cooperating controllers, the &lt;em>GitOps Toolkit&lt;/em>. A default installation brings four; the two image automation ones are added with &lt;code>--components-extra&lt;/code> (&lt;a href="https://fluxcd.io/flux/installation/">Flux installation&lt;/a>):&lt;/p>
&lt;ul>
&lt;li>&lt;strong>source-controller&lt;/strong>: clones and keeps the sources up to date, whether git repos (&lt;code>GitRepository&lt;/code>), Helm repos (&lt;code>HelmRepository&lt;/code>), buckets or OCI artifacts. It is the one &amp;ldquo;holding the plan&amp;rdquo;: it exposes the repo contents as an internal artifact that the others consume.&lt;/li>
&lt;li>&lt;strong>kustomize-controller&lt;/strong>: takes a source artifact, renders Kustomize (bases + overlays) and applies the result to the cluster. It is responsible for &lt;code>prune&lt;/code>, &lt;code>dependsOn&lt;/code> and the health checks of the &lt;code>Kustomization&lt;/code>s.&lt;/li>
&lt;li>&lt;strong>helm-controller&lt;/strong>: reconciles &lt;code>HelmRelease&lt;/code> objects, installing and updating Helm charts declaratively, without anybody running &lt;code>helm&lt;/code> from a terminal (&lt;a href="https://github.com/fluxcd/helm-controller">helm-controller&lt;/a>).&lt;/li>
&lt;li>&lt;strong>notification-controller&lt;/strong>: the bridge to the outside world in both directions. It receives webhooks (to reconcile instantly on every push, instead of waiting for the &lt;code>interval&lt;/code>) and emits events and alerts to Slack, an internal chat or an incident system.&lt;/li>
&lt;li>&lt;strong>image-reflector-controller&lt;/strong>: scans container registries and stores the tags it finds in an internal database. It is the &amp;ldquo;eyes&amp;rdquo; of image automation.&lt;/li>
&lt;li>&lt;strong>image-automation-controller&lt;/strong>: uses what the eyes see to write back to git, committing the new image tag into the manifests when a version appears that satisfies the policy.&lt;/li>
&lt;/ul>
&lt;p>A cluster without image automation does not need the last two. But for an inference stack where the model server is updated fairly often, they are what automates version promotion without touching anything by hand.&lt;/p>
&lt;div class="diagram" style="max-width:860px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 860 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="GitOps loop: git, source-controller, kustomize and helm controllers, cluster, drift and image automation writing back to git">
&lt;defs>
&lt;marker id="ga" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#64748b"/>&lt;/marker>
&lt;marker id="gb" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#8b5cf6"/>&lt;/marker>
&lt;marker id="gr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#ef4444"/>&lt;/marker>
&lt;/defs>
&lt;text x="430" y="26" text-anchor="middle" font-size="14" font-weight="700" fill="currentColor">The Flux reconciliation loop&lt;/text>
&lt;rect x="40" y="60" width="150" height="80" rx="8" fill="#3b82f6" fill-opacity="0.12" stroke="#3b82f6" stroke-width="1.6"/>
&lt;text x="115" y="92" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">git&lt;/text>
&lt;text x="115" y="112" text-anchor="middle" font-size="10" fill="currentColor">master plan&lt;/text>
&lt;text x="115" y="127" text-anchor="middle" font-size="10" fill="currentColor">desired state&lt;/text>
&lt;rect x="270" y="50" width="180" height="46" rx="6" fill="#22c55e" fill-opacity="0.12" stroke="#22c55e" stroke-width="1.6"/>
&lt;text x="360" y="70" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">source-controller&lt;/text>
&lt;text x="360" y="86" text-anchor="middle" font-size="9.5" fill="currentColor">clones the repo · artifact&lt;/text>
&lt;rect x="270" y="106" width="180" height="46" rx="6" fill="#22c55e" fill-opacity="0.12" stroke="#22c55e" stroke-width="1.6"/>
&lt;text x="360" y="126" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">kustomize-controller&lt;/text>
&lt;text x="360" y="142" text-anchor="middle" font-size="9.5" fill="currentColor">renders overlays · applies&lt;/text>
&lt;rect x="270" y="162" width="180" height="46" rx="6" fill="#22c55e" fill-opacity="0.12" stroke="#22c55e" stroke-width="1.6"/>
&lt;text x="360" y="182" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">helm-controller&lt;/text>
&lt;text x="360" y="198" text-anchor="middle" font-size="9.5" fill="currentColor">reconciles HelmRelease&lt;/text>
&lt;rect x="560" y="90" width="170" height="120" rx="8" fill="#f59e0b" fill-opacity="0.12" stroke="#f59e0b" stroke-width="1.6"/>
&lt;text x="645" y="118" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">cluster&lt;/text>
&lt;text x="645" y="138" text-anchor="middle" font-size="10" fill="currentColor">actual state&lt;/text>
&lt;text x="645" y="158" text-anchor="middle" font-size="9.5" fill="currentColor">engine · gateway&lt;/text>
&lt;text x="645" y="173" text-anchor="middle" font-size="9.5" fill="currentColor">front end · vector store&lt;/text>
&lt;text x="645" y="188" text-anchor="middle" font-size="9.5" fill="currentColor">observability&lt;/text>
&lt;path d="M190,90 L268,75" fill="none" stroke="#64748b" stroke-width="1.6" marker-end="url(#ga)"/>
&lt;path d="M450,129 L558,140" fill="none" stroke="#64748b" stroke-width="1.6" marker-end="url(#ga)"/>
&lt;path d="M450,185 L558,165" fill="none" stroke="#64748b" stroke-width="1.6" marker-end="url(#ga)"/>
&lt;path d="M645,210 C645,250 360,250 360,213" fill="none" stroke="#ef4444" stroke-width="1.8" stroke-dasharray="5 3" marker-end="url(#gr)"/>
&lt;text x="430" y="268" text-anchor="middle" font-size="10" fill="#ef4444">reconcile every interval: compares actual vs desired, fixes drift, prunes&lt;/text>
&lt;rect x="270" y="312" width="180" height="58" rx="6" fill="#8b5cf6" fill-opacity="0.12" stroke="#8b5cf6" stroke-width="1.6"/>
&lt;text x="360" y="334" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">image automation&lt;/text>
&lt;text x="360" y="350" text-anchor="middle" font-size="9.5" fill="currentColor">reflector scans registry&lt;/text>
&lt;text x="360" y="364" text-anchor="middle" font-size="9.5" fill="currentColor">policy picks tag · commit&lt;/text>
&lt;path d="M560,200 C500,300 460,320 452,335" fill="none" stroke="#8b5cf6" stroke-width="1.6" marker-end="url(#gb)"/>
&lt;text x="540" y="300" text-anchor="middle" font-size="9.5" fill="#8b5cf6">registry: new tag&lt;/text>
&lt;path d="M268,338 C160,330 115,250 115,142" fill="none" stroke="#8b5cf6" stroke-width="1.8" marker-end="url(#gb)"/>
&lt;text x="120" y="250" text-anchor="middle" font-size="9.5" fill="#8b5cf6">writes back&lt;/text>
&lt;text x="120" y="263" text-anchor="middle" font-size="9.5" fill="#8b5cf6">to git (commit)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The diagram has three paths. The &lt;strong>grey&lt;/strong> one is the main flow: git → source-controller → kustomize/helm-controller → cluster. The &lt;strong>dashed red&lt;/strong> one is the reconciliation loop that runs every &lt;code>interval&lt;/code>: it compares actual state against desired, fixes drift and prunes what is left over. The &lt;strong>purple&lt;/strong> one is image automation: the reflector sees a new tag in the registry, the policy decides whether it qualifies, and the automation-controller &lt;strong>writes back to git&lt;/strong>. That last path is the one that closes the circle and turns git into a system that updates itself.&lt;/p>
&lt;h2 id="repo-structure-clusters-infrastructure-apps">Repo structure: clusters, infrastructure, apps&lt;/h2>
&lt;p>The most widespread convention separates three levels of responsibility. It is not the only one, but it ages well:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">gitops-repo/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── clusters/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── prod/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── infrastructure.yaml # Kustomization → ./infrastructure
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── apps.yaml # Kustomization → ./apps (dependsOn infrastructure)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── infrastructure/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── controllers/ # ingress, cert-manager, GPU operator, KEDA...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── configs/ # ClusterIssuer, RuntimeClass, StorageClass...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── apps/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── base/ # manifests common to all environments
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── llm-engine/ # vLLM HelmRelease
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── gateway/ # L7 gateway HelmRelease
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── chat-front/ # Deployment + Service + Ingress
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── vector-store/ # Qdrant HelmRelease (+ PVC)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ └── observability/ # Langfuse + OTel HelmRelease
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── staging/ # overlay: low replicas, small model
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └── prod/ # overlay: high replicas, large model, MIG
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The key piece is Kustomize&amp;rsquo;s &lt;strong>base / overlay&lt;/strong> separation. The &lt;code>base/&lt;/code> describes the service once; each overlay (&lt;code>staging/&lt;/code>, &lt;code>prod/&lt;/code>) applies a &lt;code>patch&lt;/code> with the environment&amp;rsquo;s differences: number of replicas, model size, MIG profile, &lt;code>nodeSelector&lt;/code>, &lt;code>--gpu-memory-utilization&lt;/code>. That avoids duplicating complete manifests per environment. The &lt;code>clusters/&lt;/code> folder is the entry point Flux reconciles first: it holds the root &lt;code>Kustomization&lt;/code>s that point to &lt;code>infrastructure/&lt;/code> and &lt;code>apps/&lt;/code>, with a &lt;code>dependsOn&lt;/code> that guarantees the infrastructure (CRDs, operators, storage classes) is ready &lt;strong>before&lt;/strong> the applications.&lt;/p>
&lt;h3 id="how-many-manifests-the-calculation-that-justifies-overlays">How many manifests: the calculation that justifies overlays&lt;/h3>
&lt;p>This is where the numbers make the argument. Without overlays, each service needs a complete set of manifests per environment. With $N$ services and $M$ environments, the cost in files to maintain is:&lt;/p>
$$
\text{files}_{\text{naïve}} = N \times M \times k
$$
&lt;p>where $k$ is the average number of manifests per service (Deployment/HelmRelease + Service + ConfigMap + PVC + Ingress ≈ 5). For our assistant, $N=6$ services and $M=3$ environments (dev, staging, prod), with $k=5$:&lt;/p>
$$
6 \times 3 \times 5 = 90 \text{ complete files, each maintained separately.}
$$
&lt;p>With the base/overlay structure, the base is written once and each overlay contains only the &lt;code>patch&lt;/code> with the differences (typically 1 short file per service per environment):&lt;/p>
$$
\text{files}_{\text{overlay}} = \underbrace{N \times k}_{\text{base}} + \underbrace{N \times M}_{\text{patches}} = 6 \times 5 + 6 \times 3 = 30 + 18 = 48
$$
&lt;p>It is not just that there are fewer files (48 against 90): it is that &lt;strong>the bulk of a change happens in a single place&lt;/strong>. Changing the engine&amp;rsquo;s memory limit for every environment is one edit in &lt;code>base/&lt;/code>, not three synchronised edits. The saving factor grows with $M$: for 5 environments, the naïve version is 150 files and the overlay one is 60. Duplication is the enemy of auditability, and overlays attack it at the root.&lt;/p>
&lt;h2 id="the-reconciliation-loop-desired-vs-actual">The reconciliation loop: desired vs actual&lt;/h2>
&lt;p>The heart of Flux is a control loop that never ends: every &lt;code>interval&lt;/code> it reads the desired state (git), reads the actual state (cluster), computes the difference and applies it. It is the same principle as a &lt;strong>thermostat&lt;/strong>: it reads the target temperature (git), reads the actual temperature (cluster) and switches the boiler on or off until they match. It does not &amp;ldquo;deploy once&amp;rdquo;; it converges forever.&lt;/p>
&lt;p>A &lt;code>Kustomization&lt;/code> manifest for the applications, with the pieces that matter (&lt;a href="https://fluxcd.io/flux/components/kustomize/kustomizations/">Kustomization reference&lt;/a>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kustomize.toolkit.fluxcd.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Kustomization&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apps&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">flux-system&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">interval: 1m # reconciles every minute&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sets the drift MTTR&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">./apps/prod &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># production overlay&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prune&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># deletes from the cluster whatever is removed from git&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sourceRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GitRepository&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gitops-repo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">dependsOn&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">infrastructure &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># does not apply apps until infra is Ready&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">wait&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># waits for the resources to be healthy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">5m &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># fails the reconciliation if it does not converge in 5 min&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">healthChecks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apps/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Deployment&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gateway&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gateway&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Four fields do the heavy lifting:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>interval&lt;/code>&lt;/strong> defines how often the foreman&amp;rsquo;s round runs. It is the direct determinant of the drift MTTR (next section).&lt;/li>
&lt;li>&lt;strong>&lt;code>prune: true&lt;/code>&lt;/strong> switches on garbage collection: objects that were applied before but are no longer in the current git revision are deleted from the cluster automatically. Without &lt;code>prune&lt;/code>, a service withdrawn from the repo keeps running, and the plan and the site diverge silently.&lt;/li>
&lt;li>&lt;strong>&lt;code>dependsOn&lt;/code>&lt;/strong> orders the graph: Flux does not apply the manifests of a &lt;code>Kustomization&lt;/code> until every referenced one is in state &lt;code>Ready: True&lt;/code>. It is the mechanism that guarantees data → gateway → front end.&lt;/li>
&lt;li>&lt;strong>&lt;code>wait&lt;/code> + &lt;code>healthChecks&lt;/code>&lt;/strong>: with &lt;code>wait: true&lt;/code>, Flux monitors every applied resource and waits for them to be ready before marking the reconciliation as successful; &lt;code>healthChecks&lt;/code> lets you tune exactly which resources to watch. That is what makes a &lt;code>dependsOn&lt;/code> mean something: the dependency is not satisfied until it is &lt;strong>healthy&lt;/strong>, not merely applied.&lt;/li>
&lt;/ul>
&lt;h3 id="mttr-and-drift-detection-as-a-function-of-the-interval">MTTR and drift detection as a function of the interval&lt;/h3>
&lt;p>The &lt;code>interval&lt;/code> is the only parameter the operator chooses to govern the speed of the loop, and it translates directly into operational metrics. If a deviation (drift) happens at a random instant within the reconciliation period $T$, the waiting time until Flux detects it is uniformly distributed over $[0, T]$, with mean:&lt;/p>
$$
\mathbb{E}[t_{\text{detection}}] = \frac{T}{2}
$$
&lt;p>To that you add the correction time $t_c$ (render, diff, apply), normally a few seconds. The drift MTTR comes out as:&lt;/p>
$$
\text{MTTR}_{\text{drift}} = \frac{T}{2} + t_c
$$
&lt;p>With &lt;code>interval=1m&lt;/code> and $t_c \approx 10\text{s}$, drift is corrected in $\frac{60}{2} + 10 = 40$ s on average, with a worst case of $60 + 10 = 70$ s. With &lt;code>interval=10m&lt;/code>, the mean rises to $5\text{min}\;10\text{s}$ and the worst case to more than 10 min: a window in which a manual change stays active. The temptation is to set &lt;code>interval=10s&lt;/code> and forget about it, but there is a cost: every reconciliation consumes CPU and, above all, makes requests to the cluster API and to the registry. With dozens of &lt;code>Kustomization&lt;/code>s reconciling every 10 s, the &lt;code>kube-apiserver&lt;/code> and the image-reflector start to feel the pressure. The practical rule: a short &lt;code>interval&lt;/code> (1m) for critical applications whose drift hurts, a long &lt;code>interval&lt;/code> (10–30m) for stable infrastructure that almost never changes, and notification-controller &lt;strong>webhooks&lt;/strong> for instant reconciliation on every push, so that the &lt;code>interval&lt;/code> governs only drift and not deployment latency.&lt;/p>
&lt;h2 id="helm-as-code-the-inference-engine-helmrelease">Helm as code: the inference engine HelmRelease&lt;/h2>
&lt;p>For the engine, the gateway and the front end, the usual approach is to package them as Helm charts and declare them with &lt;code>HelmRelease&lt;/code>. The helm-controller reconciles them without anyone ever running &lt;code>helm&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">helm.toolkit.fluxcd.io/v2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">HelmRelease&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llm-engine&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llm-serving&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">10m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">chart&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">chart&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sourceRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">HelmRepository&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-charts&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tag&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0.8.4&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># managed by image automation (see below)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu.product&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">H100-SXM&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">extraArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;--gpu-memory-utilization=0.90&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;--tensor-parallel-size=1&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Everything that in the imperative world would be a &lt;code>vllm serve&lt;/code> flag, &lt;code>--gpu-memory-utilization&lt;/code> or &lt;code>--tensor-parallel-size&lt;/code>, now lives as versioned data. Changing the fraction of VRAM the engine reserves is a commit, reviewable in a PR, with history. The relationship with &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">sharing a GPU&lt;/a> is direct: the MIG profile and the &lt;code>--gpu-memory-utilization&lt;/code> are deployment parameters, and here they are code.&lt;/p>
&lt;h2 id="image-automation-bumping-the-tag-without-touching-anything-by-hand">Image automation: bumping the tag without touching anything by hand&lt;/h2>
&lt;p>The inference engine is updated fairly often (vLLM patches, security fixes). Without automation, every new version requires somebody to edit the &lt;code>tag&lt;/code> in the &lt;code>HelmRelease&lt;/code>. Flux image automation does it on its own, with three objects (&lt;a href="https://fluxcd.io/flux/guides/image-update/">automate image updates&lt;/a>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">image.toolkit.fluxcd.io/v1beta2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ImageRepository&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">flux-system&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">registry.example.local/inference/vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">5m &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># scans the registry tags every 5 min&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">image.toolkit.fluxcd.io/v1beta2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ImagePolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">flux-system&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">imageRepositoryRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">semver&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">range&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;&amp;gt;=0.8.0 &amp;lt;0.9.0&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># only patches and minors within 0.8.x–0.8.x&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">image.toolkit.fluxcd.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ImageUpdateAutomation&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">flux-system&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">5m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sourceRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GitRepository&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gitops-repo&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">git&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">commit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">author&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">fluxbot&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">email&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">fluxbot@example.local&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">messageTemplate&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;auto: bump vllm to {{ .NewTag }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The division of roles: &lt;strong>&lt;code>ImageRepository&lt;/code>&lt;/strong> scans all the tags of the image repository and stores them in the reflector&amp;rsquo;s internal database. &lt;strong>&lt;code>ImagePolicy&lt;/code>&lt;/strong> reads those tags and picks the &amp;ldquo;latest&amp;rdquo; according to the policy; the &lt;code>policy&lt;/code> field is mandatory and defines how the selection is made (&lt;a href="https://fluxcd.io/flux/components/image/imagepolicies/">ImagePolicy&lt;/a>). &lt;strong>&lt;code>ImageUpdateAutomation&lt;/code>&lt;/strong> takes the chosen tag, edits the manifest in the repo (wherever there is a &lt;code># {&amp;quot;$imagepolicy&amp;quot;: &amp;quot;flux-system:vllm&amp;quot;}&lt;/code> marker) and commits (&lt;a href="https://fluxcd.io/flux/components/image/imageupdateautomations/">ImageUpdateAutomation&lt;/a>).&lt;/p>
&lt;p>The choice of policy matters:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>semver&lt;/strong>: interprets tags as semantic versions and picks the highest one that satisfies the range (&lt;code>range: &amp;quot;&amp;gt;=0.8.0 &amp;lt;0.9.0&amp;quot;&lt;/code>). By default it excludes prereleases (&lt;code>0.8.0-rc.1&lt;/code> does not qualify) unless they are asked for explicitly. It is the right option for production: you stay inside a tested version range and do not jump to a new major by accident.&lt;/li>
&lt;li>&lt;strong>regex / alphabetical / numerical&lt;/strong>: for tag schemes that are not pure semver, for example &lt;code>main-&amp;lt;sha&amp;gt;-&amp;lt;timestamp&amp;gt;&lt;/code> from an internal pipeline. More flexible, but it forces you to trust that tag order reflects version order, which is fragile.&lt;/li>
&lt;/ul>
&lt;p>A safety guideline: instead of having &lt;code>ImageUpdateAutomation&lt;/code> push directly to &lt;code>main&lt;/code>, configure it to write to a &lt;strong>branch&lt;/strong> and open a &lt;strong>PR&lt;/strong>. That way the version bump goes through human review or a validation pipeline before reaching the cluster. Automation proposes; the human (or an automatic gate) disposes. And for production, the version bump is not the deployment: it is the trigger for the progressive rollout described in &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">canary, blue-green and shadow&lt;/a>.&lt;/p>
&lt;h2 id="secrets-in-gitops-the-chicken-and-egg-problem">Secrets in GitOps: the chicken-and-egg problem&lt;/h2>
&lt;p>GitOps demands that all the desired state is in git. But secrets, the Langfuse Postgres password, the registry token, the gateway API keys, cannot go into a repo in the clear, not even a private one: git history is immutable, and a secret committed once stays there forever. This is the fundamental tension: GitOps wants everything in git; security forbids secrets in git. There are three families of solution, all with the same underlying idea, that &lt;strong>only the encrypted secret goes into git and the cluster holds the key to decrypt it&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>SOPS + age/KMS&lt;/strong>: you encrypt the sensitive values of a manifest with &lt;a href="https://github.com/getsops/sops">SOPS&lt;/a> (it leaves the keys readable and encrypts only the values). The encrypted YAML goes to git; the kustomize-controller carries an age key or reaches a KMS to decrypt it at apply time. Simple, with no extra components in the cluster.&lt;/li>
&lt;li>&lt;strong>sealed-secrets&lt;/strong>: a controller in the cluster holds a private key. You encrypt the secret against its public key (with &lt;code>kubeseal&lt;/code>), the encrypted &lt;code>SealedSecret&lt;/code> goes to git, and the controller decrypts it into a normal &lt;code>Secret&lt;/code> inside the cluster. The private key never leaves the cluster.&lt;/li>
&lt;li>&lt;strong>External Secrets + Vault&lt;/strong>: not even the encrypted secret goes into git, only a &lt;strong>reference&lt;/strong> (&lt;code>ExternalSecret&lt;/code>) saying &amp;ldquo;the value of this key is in Vault, at this path&amp;rdquo;. The External Secrets operator resolves it at apply time. It is the cleanest pattern for many secrets and frequent rotation, at the cost of operating a Vault.&lt;/li>
&lt;/ul>
&lt;p>The choice depends on the volume of secrets and on whether you already have a central manager. For a small stack, SOPS+age is enough and has no dependencies. The &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">sibling hardening piece&lt;/a> goes into the detail of each option, key rotation and the threat model.&lt;/p>
&lt;h2 id="promotion-and-rollback-git-revert-is-the-panic-button">Promotion and rollback: git revert is the panic button&lt;/h2>
&lt;p>The most elegant consequence of having the cluster as a projection of git is that &lt;strong>rollback is a &lt;code>git revert&lt;/code>&lt;/strong>. If a deployment breaks production, the new engine gives worse latency or the gateway starts returning 5xx, there is no need to remember which version was there before or to rebuild the state by hand. You revert the commit that introduced the change, and on the next reconciliation (≤ &lt;code>interval&lt;/code>, or instantly with a webhook) Flux returns the cluster to the previous state. The git history is, literally, the deployment history: every commit is a restore point with author, date and reason.&lt;/p>
&lt;p>This fits with the promotion strategies of the &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">canary, blue-green and shadow&lt;/a> post: Flux manages &lt;strong>what is declared&lt;/strong>, and the progressive rollout tool manages &lt;strong>how traffic moves&lt;/strong> between the old version and the new one. The git revert is the last-resort rollback (it returns everything to a known state); the canary is the mechanism that avoids needing it. For services where the version change is delicate, the right approach is for image automation to do the bump on a branch, for the canary to validate the regression gates, and only then to promote the commit to &lt;code>main&lt;/code>.&lt;/p>
&lt;h2 id="the-seams-gitops-is-not-free">The seams: GitOps is not free&lt;/h2>
&lt;p>It would be dishonest to sell GitOps as magic. It has real costs that any team pays:&lt;/p>
&lt;p>&lt;strong>Learning curve.&lt;/strong> The declarative model is a change of mindset. The operator who has spent years fixing incidents with &lt;code>kubectl edit&lt;/code> has to unlearn the reflex: now the cluster reverts their changes and that, at first, feels like Flux &amp;ldquo;fighting them&amp;rdquo;. Understanding that the change is made in git, not in the cluster, takes weeks of discomfort.&lt;/p>
&lt;p>&lt;strong>Debugging the reconciler.&lt;/strong> When a &lt;code>Kustomization&lt;/code> does not apply, the error is not in the pod; it is in the reconciliation chain. Did the source-controller clone the right revision? Did the kustomize-controller render the overlay properly? Did a &lt;code>dependsOn&lt;/code> health check fail? Did the timeout fire before the large model finished loading? Diagnosing this needs &lt;code>flux get&lt;/code>, &lt;code>flux logs&lt;/code>, reading the &lt;code>status.conditions&lt;/code> of the Flux objects and understanding which link got stuck. It is a new skill, different from debugging plain Kubernetes.&lt;/p>
&lt;p>&lt;strong>Timeouts and slow loads.&lt;/strong> The &lt;code>timeout&lt;/code> of a &lt;code>Kustomization&lt;/code> with &lt;code>wait: true&lt;/code> has to be longer than the start-up time of the slowest service. An inference engine that takes 4 minutes to load a large model from the object store will fail a &lt;code>Kustomization&lt;/code> with &lt;code>timeout: 2m&lt;/code>, even though everything is fine. Calibrating timeouts per service is fine-grained work.&lt;/p>
&lt;p>&lt;strong>The drift you did want.&lt;/strong> Sometimes the operator &lt;em>needs&lt;/em> an urgent temporary change and Flux reverts it. The correct answer, suspending reconciliation of that &lt;code>Kustomization&lt;/code> with &lt;code>flux suspend&lt;/code>, making the change, then reflecting it in git and resuming, is discipline that has to be built. Without that discipline, people end up fighting the foreman at 3 in the morning.&lt;/p>
&lt;p>None of these costs cancels the benefit. But a team adopting GitOps expecting &amp;ldquo;everything to be easier from day one&amp;rdquo; gets frustrated. It is easier &lt;strong>from month two onwards&lt;/strong>, once auditability, reproducibility and trivial rollback have paid off the curve.&lt;/p>
&lt;h2 id="applied-to-the-generic-4h100-cluster">Applied to the generic 4×H100 cluster&lt;/h2>
&lt;p>On the reference cluster (4×H100 SXM 80 GB per GPU node, NVLink, plus CPU and control nodes), the assistant stack is managed entirely by GitOps. Hardware decisions become data in the overlays.&lt;/p>
&lt;p>&lt;strong>nodeSelectors and MIG profiles as code.&lt;/strong> The &lt;code>prod&lt;/code> overlay pins each service to the right node and declares the GPU profile. The general engine uses a whole GPU; embeddings and the small LLM fit in MIG slices, exactly the split from the &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">sharing a GPU&lt;/a> post, but now versioned:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># apps/prod/llm-engine-patch.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu.product&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">H100-SXM&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">extraArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;--gpu-memory-utilization=0.92&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;--tensor-parallel-size=1&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># apps/prod/embeddings-patch.yaml — on a MIG slice&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/mig.config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;3g.40gb&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/mig-3g.40gb&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The MIG profile (&lt;code>3g.40gb&lt;/code>), the VRAM fraction (&lt;code>0.92&lt;/code>), the tensor-parallel setting: all of it is text in a PR. Changing the GPU split between services is a reviewable commit, not a hand-run &lt;code>nvidia-smi mig&lt;/code> session that nobody records.&lt;/p>
&lt;p>&lt;strong>Start-up order with dependsOn.&lt;/strong> The assistant&amp;rsquo;s dependency graph is encoded with chained &lt;code>dependsOn&lt;/code>. The right order is &lt;strong>data → embeddings/engine → gateway → front end&lt;/strong>:&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 180" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="start-up order with dependsOn: data, engine and embeddings, gateway, front end">
&lt;defs>
&lt;marker id="da" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#64748b"/>&lt;/marker>
&lt;/defs>
&lt;text x="410" y="26" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">Assistant start-up order via dependsOn&lt;/text>
&lt;rect x="20" y="60" width="150" height="70" rx="8" fill="#8b5cf6" fill-opacity="0.12" stroke="#8b5cf6" stroke-width="1.6"/>
&lt;text x="95" y="88" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">data&lt;/text>
&lt;text x="95" y="106" text-anchor="middle" font-size="9.5" fill="currentColor">vector store&lt;/text>
&lt;text x="95" y="120" text-anchor="middle" font-size="9.5" fill="currentColor">Langfuse Postgres&lt;/text>
&lt;rect x="220" y="60" width="160" height="70" rx="8" fill="#f59e0b" fill-opacity="0.12" stroke="#f59e0b" stroke-width="1.6"/>
&lt;text x="300" y="84" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">engine + embeddings&lt;/text>
&lt;text x="300" y="102" text-anchor="middle" font-size="9.5" fill="currentColor">vLLM (whole GPU)&lt;/text>
&lt;text x="300" y="116" text-anchor="middle" font-size="9.5" fill="currentColor">TEI (MIG slice)&lt;/text>
&lt;rect x="430" y="60" width="150" height="70" rx="8" fill="#3b82f6" fill-opacity="0.12" stroke="#3b82f6" stroke-width="1.6"/>
&lt;text x="505" y="88" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">L7 gateway&lt;/text>
&lt;text x="505" y="106" text-anchor="middle" font-size="9.5" fill="currentColor">per-model routing&lt;/text>
&lt;text x="505" y="120" text-anchor="middle" font-size="9.5" fill="currentColor">rate-limit · auth&lt;/text>
&lt;rect x="630" y="60" width="150" height="70" rx="8" fill="#22c55e" fill-opacity="0.12" stroke="#22c55e" stroke-width="1.6"/>
&lt;text x="705" y="88" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">chat front end&lt;/text>
&lt;text x="705" y="106" text-anchor="middle" font-size="9.5" fill="currentColor">assistant UI&lt;/text>
&lt;text x="705" y="120" text-anchor="middle" font-size="9.5" fill="currentColor">exposed to the client&lt;/text>
&lt;path d="M170,95 L218,95" fill="none" stroke="#64748b" stroke-width="1.8" marker-end="url(#da)"/>
&lt;path d="M380,95 L428,95" fill="none" stroke="#64748b" stroke-width="1.8" marker-end="url(#da)"/>
&lt;path d="M580,95 L628,95" fill="none" stroke="#64748b" stroke-width="1.8" marker-end="url(#da)"/>
&lt;text x="194" y="150" text-anchor="middle" font-size="9" fill="currentColor">dependsOn&lt;/text>
&lt;text x="404" y="150" text-anchor="middle" font-size="9" fill="currentColor">dependsOn&lt;/text>
&lt;text x="604" y="150" text-anchor="middle" font-size="9" fill="currentColor">dependsOn&lt;/text>
&lt;/svg>
&lt;/div>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># apps/prod/gateway.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">dependsOn&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llm-engine &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># the gateway is useless without the engine&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">embeddings&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># ...&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># apps/prod/chat-front.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">dependsOn&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gateway &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># the front end is useless without the gateway&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With &lt;code>wait: true&lt;/code> on each &lt;code>Kustomization&lt;/code>, Flux does not mark &lt;code>llm-engine&lt;/code> as &lt;code>Ready&lt;/code> until the engine pod passes its health check, which in the case of vLLM means &lt;strong>model loaded and endpoint responding&lt;/strong>, not just pod started. Only then does it begin applying the gateway. This avoids the classic cascade of failures of manual deployment: bring up the front end first, see a 502 because the gateway is not there, bring up the gateway, see a 503 because the engine is still loading the model. With &lt;code>dependsOn&lt;/code> + &lt;code>wait&lt;/code>, the order is guaranteed by the reconciler, not by the operator&amp;rsquo;s memory.&lt;/p>
&lt;p>The result: deleting the assistant&amp;rsquo;s entire namespace and letting Flux rebuild it from git produces exactly the same stack, in the same order, with the same GPU configuration. The 4×H100 cluster stops having a configuration known only to whoever built it, and becomes a reproducible projection of a repo that anyone can audit.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>GitOps is not a tool, it is an &lt;strong>inversion of the direction of control&lt;/strong>: instead of pushing changes to the cluster, you declare the state in git and let an agent pull the cluster towards it. For an LLM assistant (six services, several namespaces, start-up dependencies, delicate GPU configuration) that inversion turns a fragile and opaque system into a reproducible and auditable one. Flux is the foreman who keeps the site identical to the plan and undoes any change that has not gone through the plan first. The price is a real learning curve and a new debugging skill. The prize is that the cluster stops keeping secrets about itself: everything it is, is written down, signed and reproducible in git.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets of the sovereign LLM stack&lt;/a> — the sibling piece: the detail of SOPS, sealed-secrets and External Secrets, key rotation and the threat model of the chicken-and-egg problem we only state here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">Seven deployment phases of an on-premise LLM platform&lt;/a> — GitOps is phase F3 of that journey; here we deploy it, there it is placed in the full sequence.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels of the on-premise LLM platform&lt;/a> — moving from &lt;code>kubectl apply&lt;/code> to git as the sole authority is the level jump this post operationalises.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes with KEDA&lt;/a> — the autoscaler coexists with GitOps: KEDA adjusts replicas by metric while Flux maintains the rest of the state; how they avoid fighting each other.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow for LLM models&lt;/a> — the progressive promotion that image automation triggers and that git revert backs up as a last-resort rollback.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">Kubelet resource managers in RKE2 and NUMA&lt;/a> — the &lt;code>nodeSelector&lt;/code>s and the topology of the GPU pods we declare here as code have their counterpart in kubelet policy.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing one GPU: time-slicing, MPS and MIG&lt;/a> — the MIG profiles and the &lt;code>--gpu-memory-utilization&lt;/code> that in this post are overlay data; there, the mechanics of why.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>OpenGitOps — GitOps principles — &lt;a href="https://opengitops.dev/">opengitops.dev&lt;/a>&lt;/li>
&lt;li>Flux installation — &lt;a href="https://fluxcd.io/flux/installation/">fluxcd.io/flux/installation&lt;/a>&lt;/li>
&lt;li>Flux Kustomization — &lt;a href="https://fluxcd.io/flux/components/kustomize/kustomizations/">fluxcd.io/flux/components/kustomize/kustomizations&lt;/a>&lt;/li>
&lt;li>helm-controller — &lt;a href="https://github.com/fluxcd/helm-controller">github.com/fluxcd/helm-controller&lt;/a>&lt;/li>
&lt;li>Flux Image Policies — &lt;a href="https://fluxcd.io/flux/components/image/imagepolicies/">fluxcd.io/flux/components/image/imagepolicies&lt;/a>&lt;/li>
&lt;li>Flux Image Update Automations — &lt;a href="https://fluxcd.io/flux/components/image/imageupdateautomations/">fluxcd.io/flux/components/image/imageupdateautomations&lt;/a>&lt;/li>
&lt;li>Automate image updates to Git — &lt;a href="https://fluxcd.io/flux/guides/image-update/">fluxcd.io/flux/guides/image-update&lt;/a>&lt;/li>
&lt;li>SOPS — &lt;a href="https://github.com/getsops/sops">github.com/getsops/sops&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Sharing one GPU between several workloads: time-slicing, MPS and MIG</title><link>https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/</link><pubDate>Thu, 11 Jun 2026 02:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/</guid><description>&lt;blockquote>
&lt;p>This post &lt;strong>opens an operational series&lt;/strong> on how to squeeze a generic on-premise 4×H100 SXM LLM cluster. The sibling pieces: &lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on one GPU: swap and sleep&lt;/a> (what to do when the models do not fit at the same time and have to take turns in memory), &lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">RAG on CPU: separating the data plane from generation&lt;/a> (moving retrieval off the GPU to free it up) and Sovereign end-to-end assistant with LibreChat, LiteLLM and RAG, the final assembly, a fourth instalment in preparation. Here we start with the most basic question: you have one GPU and you want to put several workloads on it. How do you split it?&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>You have one GPU, or a few, and several workloads that want to run on it: a chat model, an embeddings service, a reranker, a queue of dev jobs. The GPU is underused if it only runs one thing, but piling several on carelessly causes contention, OOM or cascading failures. There are &lt;strong>three mechanisms&lt;/strong> and they share out different things. &lt;strong>Time-slicing&lt;/strong> (replicas from the NVIDIA k8s device-plugin) multiplexes in time: it advertises the GPU as &amp;ldquo;N GPUs&amp;rdquo; and the processes take turns at the compute, but they share the full physical VRAM, with no memory isolation, no fault isolation and no QoS. Its trap is an OOM that does not show up in the Kubernetes scheduler but at run time, when the sum of VRAM allocations exceeds the real memory. &lt;strong>MPS&lt;/strong> (Multi-Process Service) multiplexes in space: it shares out the SMs between processes that execute kernels concurrently, reduces context-switch overhead and allows SM and memory limits per process. It raises throughput when there are many small kernels, but fault isolation is still weak. &lt;strong>MIG&lt;/strong> (Multi-Instance GPU) partitions in hardware: it cuts the Hopper GPU into up to seven instances with dedicated SMs, L2, memory and bandwidth, with real memory, fault and performance isolation; datacenter only (A100/H100/H200/B200), never on an RTX 5090. The rule: real isolation / multi-tenant / compliance → MIG (if it is Hopper); many small concurrent kernels and trust between workloads → MPS; dev, bursts, consumer GPU or no need to isolate → time-slicing. This post works it through with numbers: the VRAM budget of four vLLM instances on an H100 advertised as four replicas, and what fits into a 10 GB MIG instance.&lt;/p>
&lt;h2 id="the-analogy-a-shared-hob-one-kitchen-with-several-cooks-several-kitchens">The analogy: a shared hob, one kitchen with several cooks, several kitchens&lt;/h2>
&lt;p>Imagine you have a single professional hob and three orders to cook at the same time. There are three ways to organise it, and they are exactly the three mechanisms.&lt;/p>
&lt;p>&lt;strong>Time-slicing is one hob taken in turns, with no larder of your own.&lt;/strong> Each cook comes in, cooks their dish, leaves, and the next one comes in. The split is temporal: nobody cooks at the same time, they take turns. The problem is not the hob, which gets shared out fine, but the common larder: the ingredients sit in a single shared cupboard and nobody has their own. If the three cooks reserve more flour than there is in total, it is not that they wait their turn: it is that there is no flour. The service goes down for everyone. And if one cook leaves a pan burning and starts a fire, it burns the whole kitchen, not just their corner.&lt;/p>
&lt;p>&lt;strong>MPS is several cooks coordinated on the same worktop.&lt;/strong> Now they do cook at the same time, sharing out the space on the worktop (the SMs). A head chef (the MPS daemon) coordinates so they do not collide and so the worktop does not sit empty while one of them waits for water to boil. You can assign each cook a percentage of the worktop and a larder limit. They work faster as a group because the worktop does not sit idle between small tasks. But they still share the kitchen: if one starts a serious fire, the others notice.&lt;/p>
&lt;p>&lt;strong>MIG is several independent kitchens in the same building.&lt;/strong> A concrete wall separates each kitchen: its own hob, its own larder, its own door and its own electrical panel. What happens in kitchen 3, a fire, an empty larder, a slow cook, does not touch kitchen 1. It is the only split with genuine isolation. The price: you have to decide in advance how many kitchens and of what size, the walls are fixed, and only expensive buildings (datacenter) come ready to put them up.&lt;/p>
&lt;p>The rest of the post is, essentially, when you want cheap turns, when you want coordinated cooks and when you need concrete walls.&lt;/p>
&lt;h2 id="why-share-the-operational-problem">Why share: the operational problem&lt;/h2>
&lt;p>An H100 SXM 80 GB does not fill up with just any workload. A &lt;code>bge-reranker-v2-m3&lt;/code> reranker takes a few hundred MB and saturates a handful of SMs; a &lt;code>bge-m3&lt;/code> embeddings service is just as small; a 1B guardrail model in INT4 fits in a couple of GB. Dedicating 80 GB of HBM3 and 132 SMs to serving embeddings is using a hydraulic press to hammer in a drawing pin, the same argument as in &lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">mixed environments&lt;/a>, but now &lt;em>inside&lt;/em> the GPU instead of moving the workload to different silicon.&lt;/p>
&lt;p>The aim of sharing is to raise the useful utilisation of fixed capital. But sharing badly introduces three pathologies:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Compute contention&lt;/strong>: two workloads fight over the same SMs and both run slowly, with unpredictable latency jitter.&lt;/li>
&lt;li>&lt;strong>Memory contention&lt;/strong>: the sum of requested VRAM exceeds the physical amount and something dies with a &lt;code>CUDA out of memory&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Cascading failure&lt;/strong>: a workload that blows up (an illegal kernel, an OOM) can drag its neighbours down with it if they share a context.&lt;/li>
&lt;/ul>
&lt;p>The three mechanisms attack these pathologies to different depths. None of them solves all three except MIG, and MIG costs specific hardware. Let us look at them one by one.&lt;/p>
&lt;h2 id="time-slicing-compute-turns-shared-larder">Time-slicing: compute turns, shared larder&lt;/h2>
&lt;p>&lt;strong>Time-slicing&lt;/strong> is temporal multiplexing in software. On Kubernetes, the &lt;a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/gpu-sharing.html">NVIDIA GPU Operator&lt;/a> configures the device-plugin to &lt;strong>advertise N replicas&lt;/strong> of each physical GPU. An H100 declared with &lt;code>replicas: 4&lt;/code> appears to the scheduler as four &lt;code>nvidia.com/gpu&lt;/code> resources, and Kubernetes can place four pods on it. Internally, the GPU scheduler hands out compute turns to each process: it runs a bit of process A, switches to B, to C, to D, then back to A. It is the same &lt;em>time-sharing&lt;/em> an operating system does with the CPU.&lt;/p>
&lt;p>The key idea, and the one that causes most confusion, is this: &lt;strong>a replica is NOT a fraction of the GPU&lt;/strong>. It is a compute turn. NVIDIA&amp;rsquo;s documentation is explicit: unlike MIG, there is no memory or fault isolation between replicas. The four replicas of the H100 see the full 80 GB of VRAM, unpartitioned. There is no 20 GB per replica. There are 80 GB for all four, handed out in &lt;code>cudaMalloc&lt;/code> arrival order.&lt;/p>
&lt;p>This has three consequences you need to internalise:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>It does not isolate memory.&lt;/strong> If the sum of what the four processes reserve exceeds 80 GB, the fourth &lt;code>cudaMalloc&lt;/code> fails with OOM. The Kubernetes scheduler does not see it coming: it counted four available &lt;code>nvidia.com/gpu&lt;/code> resources and happily placed four pods. The OOM appears at run time, not at &lt;em>scheduling&lt;/em> time. This is trap number one of time-slicing.&lt;/li>
&lt;li>&lt;strong>It does not isolate faults.&lt;/strong> A process that triggers an unrecoverable CUDA error can leave the GPU context in a state that affects its neighbours. They share the same device with no barriers.&lt;/li>
&lt;li>&lt;strong>It gives no compute QoS.&lt;/strong> Under contention, the turn-taking does not guarantee a minimum fraction to anyone. Each workload&amp;rsquo;s latency suffers jitter proportional to how many active replicas are fighting over the GPU at that instant. A latency-sensitive workload (an interactive chat) can see its TTFT dance around depending on what its neighbours are doing.&lt;/li>
&lt;/ol>
&lt;p>What is it good for, then? For dev, bursts and low utilisation. If you have four developers who touch the GPU sporadically, advertising four replicas lets all four have access without fighting almost ever (they rarely overlap while active). For batch workloads that tolerate jitter. And, a decisive advantage, &lt;strong>it works on consumer GPUs&lt;/strong>: an RTX 5090 32 GB does not support MIG, but it does support time-slicing. It is the only &amp;ldquo;Kubernetes-native&amp;rdquo; way to share a 5090 between several pods.&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The three sharing strategies: temporal, spatial and hardware partitioning">
&lt;text x="410" y="24" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">How each mechanism shares out&lt;/text>
&lt;!-- TIME-SLICING -->
&lt;text x="140" y="56" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">Time-slicing&lt;/text>
&lt;text x="140" y="72" text-anchor="middle" font-size="11" fill="currentColor">split in TIME&lt;/text>
&lt;rect x="30" y="84" width="220" height="120" fill="none" stroke="currentColor" stroke-width="1.4"/>
&lt;!-- turns -->
&lt;rect x="40" y="96" width="200" height="20" fill="#3b82f6"/>
&lt;text x="140" y="111" text-anchor="middle" font-size="11" fill="#ffffff">turn A&lt;/text>
&lt;rect x="40" y="120" width="200" height="20" fill="#22c55e"/>
&lt;text x="140" y="135" text-anchor="middle" font-size="11" fill="#ffffff">turn B&lt;/text>
&lt;rect x="40" y="144" width="200" height="20" fill="#f59e0b"/>
&lt;text x="140" y="159" text-anchor="middle" font-size="11" fill="#ffffff">turn C&lt;/text>
&lt;rect x="40" y="168" width="200" height="20" fill="#ef4444"/>
&lt;text x="140" y="183" text-anchor="middle" font-size="11" fill="#ffffff">turn D&lt;/text>
&lt;text x="140" y="224" text-anchor="middle" font-size="11" fill="currentColor">shared VRAM: 80 GB for all 4&lt;/text>
&lt;text x="140" y="240" text-anchor="middle" font-size="11" fill="#ef4444">no isolation · OOM if sum &amp;gt; VRAM&lt;/text>
&lt;!-- MPS -->
&lt;text x="410" y="56" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">MPS&lt;/text>
&lt;text x="410" y="72" text-anchor="middle" font-size="11" fill="currentColor">split in SPACE (SMs)&lt;/text>
&lt;rect x="300" y="84" width="220" height="120" fill="none" stroke="currentColor" stroke-width="1.4"/>
&lt;rect x="310" y="96" width="60" height="92" fill="#3b82f6"/>
&lt;text x="340" y="146" text-anchor="middle" font-size="11" fill="#ffffff">A 40%&lt;/text>
&lt;rect x="372" y="96" width="45" height="92" fill="#22c55e"/>
&lt;text x="394" y="146" text-anchor="middle" font-size="11" fill="#ffffff">B 30%&lt;/text>
&lt;rect x="419" y="96" width="45" height="92" fill="#f59e0b"/>
&lt;text x="441" y="146" text-anchor="middle" font-size="10" fill="#ffffff">C 20%&lt;/text>
&lt;rect x="466" y="96" width="44" height="92" fill="#ef4444"/>
&lt;text x="488" y="146" text-anchor="middle" font-size="10" fill="#ffffff">D 10%&lt;/text>
&lt;text x="410" y="224" text-anchor="middle" font-size="11" fill="currentColor">concurrent kernels on different SMs&lt;/text>
&lt;text x="410" y="240" text-anchor="middle" font-size="11" fill="#f59e0b">SM and mem limit per process · weak fault&lt;/text>
&lt;!-- MIG -->
&lt;text x="680" y="56" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">MIG&lt;/text>
&lt;text x="680" y="72" text-anchor="middle" font-size="11" fill="currentColor">HARDWARE partitioning&lt;/text>
&lt;rect x="570" y="84" width="220" height="120" fill="none" stroke="currentColor" stroke-width="1.4"/>
&lt;rect x="580" y="96" width="98" height="44" fill="#3b82f6"/>
&lt;text x="629" y="122" text-anchor="middle" font-size="10" fill="#ffffff">1g.10gb&lt;/text>
&lt;rect x="684" y="96" width="98" height="44" fill="#22c55e"/>
&lt;text x="733" y="122" text-anchor="middle" font-size="10" fill="#ffffff">1g.10gb&lt;/text>
&lt;rect x="580" y="144" width="98" height="44" fill="#f59e0b"/>
&lt;text x="629" y="170" text-anchor="middle" font-size="10" fill="#ffffff">3g.40gb&lt;/text>
&lt;rect x="684" y="144" width="98" height="44" fill="#ef4444"/>
&lt;text x="733" y="170" text-anchor="middle" font-size="10" fill="#ffffff">…&lt;/text>
&lt;text x="680" y="224" text-anchor="middle" font-size="11" fill="currentColor">dedicated SMs, L2, VRAM and BW&lt;/text>
&lt;text x="680" y="240" text-anchor="middle" font-size="11" fill="#22c55e">real isolation · concrete walls&lt;/text>
&lt;!-- footer -->
&lt;text x="410" y="290" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">One hob in turns · several cooks on the worktop · several kitchens with their wall&lt;/text>
&lt;text x="410" y="312" text-anchor="middle" font-size="11" fill="currentColor">consumer + datacenter · datacenter (CUDA) · datacenter Hopper/Ampere/Blackwell only&lt;/text>
&lt;text x="410" y="332" text-anchor="middle" font-size="11" fill="currentColor">software · software (CUDA context) · hardware (physical fuses)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="the-vram-budget-in-time-slicing-the-calculation-that-avoids-the-oom">The VRAM budget in time-slicing (the calculation that avoids the OOM)&lt;/h3>
&lt;p>Here is the maths you have to do before deploying, because Kubernetes will not do it for you. Suppose an H100 80 GB advertised as 4 replicas and we want to run four vLLM instances on it, one per replica.&lt;/p>
&lt;p>vLLM reserves memory with the &lt;code>--gpu-memory-utilization&lt;/code> parameter, which is the fraction of the total physical VRAM that each instance keeps (for model weights plus KV-cache). The detail that kills: that fraction is computed over the 80 physical GB, &lt;strong>not&lt;/strong> over a supposed &amp;ldquo;20 GB of my replica&amp;rdquo;, because the replica does not have 20 GB. Remember, there is no memory partitioning. Each vLLM sees the 80 GB and reserves its fraction of them.&lt;/p>
&lt;p>The no-OOM constraint is therefore that the sum of fractions be less than 1:&lt;/p>
$$\sum_{i=1}^{N} g_i &lt; 1 \quad\Longleftrightarrow\quad \sum_{i=1}^{N} g_i \cdot V_{\text{HBM}} &lt; V_{\text{HBM}}$$
&lt;p>where $g_i$ is the &lt;code>--gpu-memory-utilization&lt;/code> of instance $i$ and $V_{\text{HBM}} = 80$ GB. It is wise to leave headroom (runtime overhead, fragmentation, CUDA context), so in practice you aim for the sum to stay comfortably below 1, say $\le 0.9$.&lt;/p>
&lt;p>&lt;strong>A case that works.&lt;/strong> Four vLLM instances at $g_i = 0.20$:&lt;/p>
$$\sum_{i=1}^{4} 0.20 = 0.80 \quad\Rightarrow\quad 0.80 \times 80\ \text{GB} = 64\ \text{GB} &lt; 80\ \text{GB} \quad\checkmark$$
&lt;p>Each instance reserves $0.20 \times 80 = 16$ GB. Four instances add up to 64 GB, leaving a 16 GB cushion. There is no OOM. Each vLLM has 16 GB for weights plus KV-cache: enough for a 7B–8B model in FP8/INT4 with a modest KV-cache.&lt;/p>
&lt;p>&lt;strong>A case that blows up.&lt;/strong> The same four instances, but somebody raises $g_i = 0.30$ thinking &amp;ldquo;I have four replicas, I can give each one more&amp;rdquo;:&lt;/p>
$$\sum_{i=1}^{4} 0.30 = 1.20 \quad\Rightarrow\quad 1.20 \times 80\ \text{GB} = 96\ \text{GB} > 80\ \text{GB} \quad\times$$
&lt;p>The first instances start up and reserve $0.30 \times 80 = 24$ GB each. Three instances are already at $72$ GB. The fourth tries to reserve another 24 GB, there is nothing left, and it dies with &lt;code>CUDA out of memory&lt;/code>. And worse: Kubernetes will reschedule it on the same GPU (it still sees four replicas), where it will die again, in a &lt;code>CrashLoopBackOff&lt;/code> that makes no sense if you only look at the pod manifest.&lt;/p>
&lt;p>The operational rule is brutally simple: &lt;strong>in time-slicing, you manage the VRAM budget by hand, adding up the &lt;code>--gpu-memory-utilization&lt;/code> values&lt;/strong>. The replica count controls how many pods &lt;em>fit in compute turns&lt;/em>, but it does not reserve a single byte of memory. Confusing the two is the recurring mistake.&lt;/p>
&lt;h2 id="mps-coordinated-cooks-on-the-same-worktop">MPS: coordinated cooks on the same worktop&lt;/h2>
&lt;p>The &lt;strong>Multi-Process Service&lt;/strong> (&lt;a href="https://docs.nvidia.com/deploy/mps/index.html">MPS&lt;/a>) attacks a different problem. By default, when several processes use the same GPU without MPS, each one has its own CUDA context, and the GPU alternates between contexts (time-slicing at driver level): they do not execute kernels at the same time, they take turns, with context-switch overhead. If your kernels are small and do not fill the GPU on their own, this leaves SMs idle: process A uses 30 % of the SMs during its turn and the other 70 % is wasted.&lt;/p>
&lt;p>MPS introduces a daemon that shares a single CUDA context between processes, so that their kernels can execute concurrently occupying different SMs at the same time. It is spatial sharing of compute: instead of taking turns at the whole worktop, each cook occupies a part and they work in parallel. This reduces context-switch overhead and &lt;strong>raises throughput when there are many small concurrent kernels&lt;/strong> that individually do not saturate the GPU.&lt;/p>
&lt;p>And, unlike pure time-slicing, MPS allows per-process limits, which gives you a form of QoS:&lt;/p>
&lt;ul>
&lt;li>&lt;code>CUDA_MPS_ACTIVE_THREAD_PERCENTAGE&lt;/code> limits the percentage of SMs an MPS client can use. By default each client gets $100 / \text{MaxSharedClients}$. Pinning it to, say, 40 % caps that process&amp;rsquo;s compute ceiling (&lt;a href="https://docs.nvidia.com/deploy/mps/appendix-tools-and-interface-reference.html">MPS docs&lt;/a>).&lt;/li>
&lt;li>&lt;code>CUDA_MPS_PINNED_DEVICE_MEM_LIMIT&lt;/code> imposes a memory cap per client (valid from CUDA 11.5). This is what time-slicing does &lt;strong>not&lt;/strong> have: a per-process VRAM limit that the runtime enforces.&lt;/li>
&lt;/ul>
&lt;p>These two limits turn MPS into a resource provisioning mechanism that mitigates the &lt;em>noisy neighbour&lt;/em>: you can guarantee that a process does not eat more than X % of the SMs or more than Y GB. The combination gives reasonable QoS, not perfect but real.&lt;/p>
&lt;p>The limitation MPS does &lt;strong>not&lt;/strong> solve: &lt;strong>fault isolation is weak&lt;/strong>. Since clients share the daemon&amp;rsquo;s CUDA context, a fatal error in one client can affect the daemon and therefore the other clients (historically, a client dying dirtily could require restarting the daemon). It is better than time-slicing in this respect, but a long way from hardware isolation. That is why MPS fits when there is trust between the workloads, processes from your own team, not third-party tenants.&lt;/p>
&lt;p>The canonical use case: many small concurrent inference requests that individually leave the GPU half empty. MPS overlaps them and raises aggregate throughput. Serving several small models, or several light replicas of the same model, on a datacenter GPU where you trust all the workloads, is MPS territory.&lt;/p>
&lt;h2 id="mig-concrete-walls">MIG: concrete walls&lt;/h2>
&lt;p>The &lt;strong>Multi-Instance GPU&lt;/strong> (&lt;a href="https://docs.nvidia.com/datacenter/tesla/mig-user-guide/">MIG&lt;/a>) is the only one of the three that gives genuine isolation, because it cuts the GPU in hardware. Available on modern datacenter GPUs, A100 (Ampere), H100/H200 (Hopper), B200 (Blackwell), and never on consumer ones: an RTX 5090 (consumer Blackwell) &lt;strong>does not support MIG&lt;/strong>, nor do GeForce cards in general.&lt;/p>
&lt;p>MIG divides the GPU into up to seven instances (&lt;em>GPU Instances&lt;/em>), and each instance gets a dedicated portion of:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>SMs&lt;/strong> (compute slices): compute is split into 7 &lt;em>slices&lt;/em>, each one roughly 1/7 of the SMs.&lt;/li>
&lt;li>&lt;strong>L2 cache and memory&lt;/strong>: each instance has its slab of HBM and its portion of L2 cache.&lt;/li>
&lt;li>&lt;strong>Memory bandwidth&lt;/strong>: dedicated, not shared.&lt;/li>
&lt;li>&lt;strong>Data paths and engines&lt;/strong>: with fault barriers between instances.&lt;/li>
&lt;/ul>
&lt;p>The result is that a MIG instance behaves like a smaller, independent GPU: whatever happens in one, an OOM, a kernel blowing up, a workload saturating its compute, &lt;strong>does not affect&lt;/strong> its neighbours. Memory, fault and performance (QoS) isolation, the three things time-slicing does not give and MPS only half gives.&lt;/p>
&lt;h3 id="the-h100-80gb-profiles">The H100 80GB profiles&lt;/h3>
&lt;p>MIG does not allow arbitrary sizes: it has &lt;strong>fixed profiles&lt;/strong>. On the H100 80GB, the &lt;a href="https://docs.nvidia.com/datacenter/tesla/mig-user-guide/supported-mig-profiles.html">profile catalogue&lt;/a> (notation &lt;code>&amp;lt;compute&amp;gt;g.&amp;lt;memory&amp;gt;gb&lt;/code>) is:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Profile&lt;/th>
&lt;th>Compute (slices)&lt;/th>
&lt;th>Memory&lt;/th>
&lt;th>Max. instances&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>1g.10gb&lt;/code>&lt;/td>
&lt;td>1/7&lt;/td>
&lt;td>10 GB&lt;/td>
&lt;td>7&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>1g.20gb&lt;/code>&lt;/td>
&lt;td>1/7&lt;/td>
&lt;td>20 GB&lt;/td>
&lt;td>4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>2g.20gb&lt;/code>&lt;/td>
&lt;td>2/7&lt;/td>
&lt;td>20 GB&lt;/td>
&lt;td>3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>3g.40gb&lt;/code>&lt;/td>
&lt;td>3/7&lt;/td>
&lt;td>40 GB&lt;/td>
&lt;td>2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>4g.40gb&lt;/code>&lt;/td>
&lt;td>4/7&lt;/td>
&lt;td>40 GB&lt;/td>
&lt;td>1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>7g.80gb&lt;/code>&lt;/td>
&lt;td>7/7&lt;/td>
&lt;td>80 GB&lt;/td>
&lt;td>1 (whole GPU)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>(There is also &lt;code>1g.10gb+me&lt;/code>, a variant with &lt;em>media engines&lt;/em> for video encoding.) The base memory unit on the H100 80GB is 10 GB per slice (80 GB / 8, with one slice reserved), and the compute unit is 1/7 of the SMs. The profiles combine these units. Note &lt;code>1g.20gb&lt;/code>: the same compute fraction as &lt;code>1g.10gb&lt;/code> (1/7 of the SMs) but twice the memory, useful when a workload needs more VRAM than compute.&lt;/p>
&lt;p>One important detail: MIG partitions do not mix freely. The GPU is divided following a valid geometry (the profiles fit together like pieces in a grid), and the profiles are fixed when you configure the GPU; changing them requires draining and repartitioning. They are concrete walls: solid, but they do not move while hot.&lt;/p>
&lt;h3 id="the-calculation-71g10gb-against-17g80gb">The calculation: 7×1g.10gb against 1×7g.80gb&lt;/h3>
&lt;p>Let us compare the two extremes. On the left, seven &lt;code>1g.10gb&lt;/code> instances: seven isolated 10 GB GPUs. On the right, a single &lt;code>7g.80gb&lt;/code>: the whole H100, unpartitioned.&lt;/p>
&lt;p>The operational question is what fits in 10 GB. An instance&amp;rsquo;s VRAM budget is split between model weights and KV-cache:&lt;/p>
$$V_{\text{inst}} = V_{\text{weights}} + V_{\text{KV}} + V_{\text{overhead}}$$
&lt;p>Take a model of 7B parameters in FP8 (1 byte/parameter):&lt;/p>
$$V_{\text{weights}} \approx 7 \times 10^9 \times 1\ \text{byte} = 7\ \text{GB}$$
&lt;p>On a &lt;code>1g.10gb&lt;/code> instance (10 GB), after the 7 GB of weights and subtracting roughly 0.5–1 GB of runtime overhead, about 2 GB are left for KV-cache. That is enough for a modest context window and low concurrency, fine for a guardrail service, a classifier or an extraction model that handles short prompts one at a time. A 7B in INT4 (~3.5 GB of weights) leaves ~5.5 GB of KV-cache, much roomier. But a 13B model in FP8 (~13 GB of weights) &lt;strong>does not fit&lt;/strong> in a 10 GB instance: not even the weights get in. For that you need &lt;code>1g.20gb&lt;/code>, &lt;code>2g.20gb&lt;/code> or larger.&lt;/p>
&lt;p>Against this, the &lt;code>7g.80gb&lt;/code> (whole GPU) gives you the 80 GB for one large model: a 70B in FP8 (~70 GB of weights) fits with a tight KV-cache, or a 70B with more room spread across several whole GPUs in &lt;em>tensor-parallel&lt;/em> (see &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">TP against replicas: one big one versus N small ones&lt;/a>).&lt;/p>
&lt;p>The reading is clear: &lt;strong>fine partitioning (7×1g.10gb) maximises the number of small isolated workloads; not partitioning (1×7g.80gb) maximises the model size that fits.&lt;/strong> The KV-cache available per instance shrinks proportionally as you partition, so fine-grained MIG serves many light isolated services, not one large model chopped up. If your workload is a single large model, MIG is not for you: use the whole GPU or several in TP.&lt;/p>
&lt;h2 id="the-decision-tree">The decision tree&lt;/h2>
&lt;p>The three questions, in order:&lt;/p>
&lt;pre tabindex="0">&lt;code>Do you need REAL isolation?
(multi-tenant, compliance, one workload failing must not touch another)
│
┌────┴────┐
YES NO
│ │
Is it Hopper/ Many SMALL concurrent kernels
Ampere/ AND you trust every workload?
Blackwell? │
│ ┌────┴────┐
┌──┴──┐ YES NO
YES NO │ │
│ │ MPS Dev / bursts / consumer GPU
MIG │ (spatial, / no need to isolate?
│ │ per-process │
│ no real QoS) YES
│ isolation: │
│ rethink TIME-SLICING
│ (move to (temporal, cheap,
│ CPU, another works on a 5090)
│ GPU, or accept
│ the risk with
│ time-slicing)
&lt;/code>&lt;/pre>&lt;p>And each branch in one sentence:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>MIG&lt;/strong> when isolation is a requirement (compliance, multi-tenant, hard SLA) and you have datacenter hardware that supports it. Concrete walls cost money, but if you need them there is no substitute.&lt;/li>
&lt;li>&lt;strong>MPS&lt;/strong> when you have many small concurrent workloads that leave the GPU half empty and you trust all of them (same team, not third-party tenants). You raise throughput with reasonable QoS, accepting imperfect fault isolation.&lt;/li>
&lt;li>&lt;strong>Time-slicing&lt;/strong> when it is dev, bursts, low utilisation, a consumer GPU, or you simply do not need to isolate anything. Cheap and universal, but manage the VRAM budget by hand.&lt;/li>
&lt;/ul>
&lt;p>One nuance the recent documentation records: &lt;strong>they can be combined&lt;/strong>. You can do time-slicing &lt;em>on top of&lt;/em> a MIG instance (hardware isolation at the instance boundary, software turns inside), or use MPS inside a MIG instance. The layers are not mutually exclusive; the tree picks the primary strategy.&lt;/p>
&lt;h2 id="applied-to-the-generic-4h100-cluster">Applied to the generic 4×H100 cluster&lt;/h2>
&lt;p>Let us get down to numbers with a generic on-premise cluster of 4×H100 SXM 80 GB with NVLink. It is common to have a heterogeneous menu of workloads: a large chat model, light services (embeddings, reranker, guardrails) and a dev/experimentation queue. Each type calls for a different mechanism. A reasoned split:&lt;/p>
&lt;p>&lt;strong>GPU 0 and GPU 1, large model in tensor-parallel (not shared).&lt;/strong> A 70B model in FP8 takes ~70 GB of weights; served comfortably with a generous KV-cache it needs more than one H100. We spread it in &lt;em>tensor-parallel&lt;/em> over two whole H100s joined by NVLink (intra-node bandwidth is what makes TP viable; the detail is in &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">TP against replicas&lt;/a>). Here we do not share: these two GPUs belong to the large model, full stop. Total isolation by dedication.&lt;/p>
$$V_{\text{available}} = 2 \times 80 = 160\ \text{GB};\quad V_{\text{weights}} \approx 70\ \text{GB};\quad V_{\text{KV}} \approx 80\ \text{GB of KV-cache}$$
&lt;p>There is memory to spare for a long request queue and high concurrency.&lt;/p>
&lt;p>&lt;strong>GPU 2, split with MIG into small instances for light services.&lt;/strong> Embeddings (&lt;code>bge-m3&lt;/code>), reranker (&lt;code>bge-reranker-v2-m3&lt;/code>) and a couple of guardrail models (1B–3B) are different workloads, potentially from different teams, and you want a failure or a spike in one not to touch the others. Light multi-tenancy with isolation → &lt;strong>MIG&lt;/strong>. A reasonable partitioning of the H100:&lt;/p>
$$\underbrace{3 \times \texttt{1g.10gb}}_{\text{30 GB, 3/7 SMs}} \;+\; \underbrace{1 \times \texttt{4g.40gb}}_{\text{40 GB, 4/7 SMs}}$$
&lt;p>The three &lt;code>1g.10gb&lt;/code> instances (10 GB, 1/7 of the SMs each) host embeddings, reranker and a 1B INT4 guardrail, each isolated, with no &lt;em>noisy neighbour&lt;/em>. The &lt;code>4g.40gb&lt;/code> (40 GB, 4/7 of the SMs) hosts an intermediate 7B–13B model with a decent KV-cache for a support service. Each service has its larder and its wall; if the reranker blows up, the chat never finds out.&lt;/p>
&lt;p>&lt;strong>GPU 3, time-slicing for dev and bursts.&lt;/strong> The developers touch the GPU sporadically: experiments, short fine-tunes, model trials. They do not need isolation (it is the same team) and they rarely overlap while active. We advertise it as 4 replicas via the device-plugin. Four dev pods fit in turns. VRAM budget with the formula above: if each dev brings up a vLLM at &lt;code>--gpu-memory-utilization 0.20&lt;/code>, they add up to $4 \times 16 = 64$ GB &amp;lt; 80 GB, with no OOM. If somebody needs more, lower the replica count or coordinate with the team. The cost of flexibility is manual discipline.&lt;/p>
&lt;p>&lt;strong>Summary of the split:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Resource&lt;/th>
&lt;th>Mechanism&lt;/th>
&lt;th>Workload&lt;/th>
&lt;th>Isolation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>GPU 0 + GPU 1&lt;/td>
&lt;td>Dedication (TP)&lt;/td>
&lt;td>70B chat in tensor-parallel&lt;/td>
&lt;td>total (dedicated)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GPU 2&lt;/td>
&lt;td>MIG (3×1g.10gb + 1×4g.40gb)&lt;/td>
&lt;td>embeddings, reranker, guardrails, 7B–13B&lt;/td>
&lt;td>real hardware&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GPU 3&lt;/td>
&lt;td>Time-slicing (4 replicas)&lt;/td>
&lt;td>dev, bursts, experiments&lt;/td>
&lt;td>none (trust)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The logic is always the same: &lt;strong>spend isolation (MIG) where you need it, spend cheap concurrency (time-slicing) where you do not, and reserve whole GPUs for what genuinely fills them.&lt;/strong> An H100 serving embeddings on a &lt;code>7g.80gb&lt;/code> would be as absurd as an RTX 5090 trying MIG: the tool does not match the workload.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>What happens when they do not even fit at the same time&lt;/strong>: if you have more models than VRAM and they have to take turns &lt;em>in memory&lt;/em> (loading/unloading weights, not just taking turns at compute), you enter &lt;em>swap&lt;/em> and &lt;em>sleep&lt;/em> territory, the sibling piece &lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on one GPU&lt;/a>.&lt;/li>
&lt;li>&lt;strong>NUMA-aware scheduling&lt;/strong>: on multi-socket nodes, which GPU goes with which CPU/memory matters for latency; see &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">Kubelet resource managers on RKE2&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Autoscaling the replicas&lt;/strong>: how many instances to bring up according to real load, with KEDA and queue metrics; see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLMs on Kubernetes with KEDA&lt;/a>.&lt;/li>
&lt;li>&lt;strong>A jitter benchmark under contention&lt;/strong>: how much TTFT really wobbles in time-slicing with 4 active replicas compared with MIG, material that deserves its own measurement, not an estimate.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy of the GPU cluster: who pays for what&lt;/a> — MIG as the basis for isolation and cost attribution between tenants.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">GitOps for the inference stack with Flux: operating the assistant as code&lt;/a> — how the GPU split (MIG, gpu-memory-utilization) is declared as code in GitOps.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on one GPU: swap and sleep&lt;/a> — the sibling piece in the series: when the models do not fit in VRAM at the same time and have to take turns in memory, not just in compute.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">Kubelet resource managers on RKE2: NUMA and topology&lt;/a> — GPU sharing gets more complicated with NUMA affinity; which GPU to assign to which socket so you do not pay interconnect latency.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">TP against replicas: one big one versus N small ones&lt;/a> — the decision to dedicate 2 whole H100s in tensor-parallel to the large model is exactly what we assume here in the cluster split.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — the VRAM budget (weights + KV-cache) we work through here per instance is the core of sizing the whole cluster.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLMs on Kubernetes with KEDA&lt;/a> — how many replicas (time-sliced or not) to bring up according to real load, instead of fixing them by hand.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels of an on-premise LLM platform&lt;/a> — going from &amp;ldquo;one GPU, one workload&amp;rdquo; to sharing with isolation is one of the maturity jumps the model marks out.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gpu-idle-cost-not-on-any-invoice/">GPU utilisation as a FinOps lever&lt;/a> — MIG, MPS and time-slicing as levers to raise occupancy and lower cost per token.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>NVIDIA — &lt;em>Time-Slicing GPUs in Kubernetes&lt;/em> (GPU Operator). &lt;a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/gpu-sharing.html">https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/gpu-sharing.html&lt;/a>&lt;/li>
&lt;li>NVIDIA — &lt;em>Multi-Process Service (MPS) Overview&lt;/em>. &lt;a href="https://docs.nvidia.com/deploy/mps/index.html">https://docs.nvidia.com/deploy/mps/index.html&lt;/a>&lt;/li>
&lt;li>NVIDIA — &lt;em>MPS: Tools and Interface Reference&lt;/em> (&lt;code>CUDA_MPS_ACTIVE_THREAD_PERCENTAGE&lt;/code>, &lt;code>CUDA_MPS_PINNED_DEVICE_MEM_LIMIT&lt;/code>). &lt;a href="https://docs.nvidia.com/deploy/mps/appendix-tools-and-interface-reference.html">https://docs.nvidia.com/deploy/mps/appendix-tools-and-interface-reference.html&lt;/a>&lt;/li>
&lt;li>NVIDIA — &lt;em>Multi-Instance GPU (MIG) User Guide&lt;/em>. &lt;a href="https://docs.nvidia.com/datacenter/tesla/mig-user-guide/">https://docs.nvidia.com/datacenter/tesla/mig-user-guide/&lt;/a>&lt;/li>
&lt;li>NVIDIA — &lt;em>Supported MIG Profiles&lt;/em> (H100 80GB catalogue). &lt;a href="https://docs.nvidia.com/datacenter/tesla/mig-user-guide/supported-mig-profiles.html">https://docs.nvidia.com/datacenter/tesla/mig-user-guide/supported-mig-profiles.html&lt;/a>&lt;/li>
&lt;li>NVIDIA — &lt;em>k8s-device-plugin&lt;/em> (time-slicing replicas). &lt;a href="https://github.com/NVIDIA/k8s-device-plugin">https://github.com/NVIDIA/k8s-device-plugin&lt;/a>&lt;/li>
&lt;li>vLLM — &lt;em>Engine Arguments&lt;/em> (&lt;code>--gpu-memory-utilization&lt;/code>). &lt;a href="https://docs.vllm.ai/en/latest/serving/engine_args.html">https://docs.vllm.ai/en/latest/serving/engine_args.html&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Runbook: caging the AI agent — bubblewrap on the client, Tetragon on the cluster</title><link>https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/</link><pubDate>Tue, 09 Jun 2026 17:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>Operational&lt;/strong> companion to &lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key&lt;/a>. That post explains the &lt;em>why&lt;/em> and the &lt;em>where&lt;/em>: the threat model, the five isolation families, which domain uses each. This one is the &lt;em>how&lt;/em>, with commands. If you have not read it, read it first: here I take for granted what the blast radius is, why &lt;code>bwrap&lt;/code> runs without root and what Tetragon watches. The procedure comes in two independent tracks, &lt;strong>client&lt;/strong> and &lt;strong>cluster&lt;/strong>, because, as the sibling post argues, the control extrapolates but the primitive gets rewritten.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Two reproducible procedures. &lt;strong>Client (workstation):&lt;/strong> install &lt;code>ai-jail&lt;/code> (it wraps &lt;code>bubblewrap&lt;/code>), generate the per-project &lt;code>.ai-jail&lt;/code>, audit with &lt;code>--dry-run&lt;/code>, fix the allowlists with &lt;code>--bootstrap&lt;/code>, use &lt;code>--lockdown&lt;/code> for anything you do not trust, and leave the agent without &lt;code>git push&lt;/code> permission. &lt;strong>Cluster (RKE2 with Cilium + Tetragon):&lt;/strong> put the pod baseline in place (unprivileged &lt;code>securityContext&lt;/code>, &lt;code>seccomp: RuntimeDefault&lt;/code>, default-deny &lt;code>NetworkPolicy&lt;/code>), put the untrusted agent&amp;rsquo;s pod in a microVM with &lt;code>runtimeClassName: kata&lt;/code>, and deploy the Tetragon &lt;code>TracingPolicy&lt;/code> files in &lt;strong>two phases&lt;/strong>: observe with &lt;code>action: Post&lt;/code> to raise the baseline, then promote to &lt;code>action: Sigkill&lt;/code> on &lt;code>tcp_connect&lt;/code> (egress) and &lt;code>security_file_open&lt;/code> (secret paths). The golden rule of the Tetragon phase: &lt;strong>adopt first, block later&lt;/strong>; never put a &lt;code>Sigkill&lt;/code> into production without having seen the events in observation mode first.&lt;/p>
&lt;h2 id="the-flow-of-the-two-tracks">The flow of the two tracks&lt;/h2>
&lt;div class="diagram" style="max-width:800px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 800 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Two operational tracks: client (install, configure, bootstrap, lockdown) and cluster (baseline, RuntimeClass, observe, enforce)">
&lt;defs>&lt;marker id="rm2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="400" y="22" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">CLIENT track — workstation&lt;/text>
&lt;rect x="20" y="36" width="150" height="46" rx="7" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="95" y="56" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#0d3a66">1 · Install&lt;/text>
&lt;text x="95" y="72" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#0d3a66">ai-jail + bwrap&lt;/text>
&lt;rect x="200" y="36" width="150" height="46" rx="7" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="275" y="56" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#0d3a66">2 · .ai-jail&lt;/text>
&lt;text x="275" y="72" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#0d3a66">--dry-run&lt;/text>
&lt;rect x="380" y="36" width="150" height="46" rx="7" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="455" y="56" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#0d3a66">3 · --bootstrap&lt;/text>
&lt;text x="455" y="72" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#0d3a66">allow/deny/ask&lt;/text>
&lt;rect x="560" y="36" width="150" height="46" rx="7" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="635" y="52" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#0d3a66">4 · lockdown&lt;/text>
&lt;text x="635" y="68" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#0d3a66">+ git with no push&lt;/text>
&lt;path d="M170,59 L198,59" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm2)"/>
&lt;path d="M350,59 L378,59" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm2)"/>
&lt;path d="M530,59 L558,59" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm2)"/>
&lt;line x1="20" y1="118" x2="780" y2="118" stroke="#ccc" stroke-width="1" stroke-dasharray="3 3"/>
&lt;text x="400" y="150" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">CLUSTER track — RKE2 + Cilium/Tetragon&lt;/text>
&lt;rect x="20" y="164" width="150" height="46" rx="7" fill="#e6d9f2" stroke="#5a2db0" stroke-width="1.4"/>
&lt;text x="95" y="184" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#42208a">1 · Baseline&lt;/text>
&lt;text x="95" y="200" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#42208a">secCtx+NetPol&lt;/text>
&lt;rect x="200" y="164" width="150" height="46" rx="7" fill="#e6d9f2" stroke="#5a2db0" stroke-width="1.4"/>
&lt;text x="275" y="184" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#42208a">2 · RuntimeClass&lt;/text>
&lt;text x="275" y="200" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#42208a">kata microVM&lt;/text>
&lt;rect x="380" y="164" width="150" height="46" rx="7" fill="#fde9d6" stroke="#a85a00" stroke-width="1.6"/>
&lt;text x="455" y="184" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#8a4a00">3 · Observe&lt;/text>
&lt;text x="455" y="200" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#8a4a00">Tetragon · Post&lt;/text>
&lt;rect x="560" y="164" width="150" height="46" rx="7" fill="#fbd4b8" stroke="#a85a00" stroke-width="1.8"/>
&lt;text x="635" y="184" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#8a4a00">4 · Enforce&lt;/text>
&lt;text x="635" y="200" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#8a4a00">Tetragon · Sigkill&lt;/text>
&lt;path d="M170,187 L198,187" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm2)"/>
&lt;path d="M350,187 L378,187" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm2)"/>
&lt;path d="M530,187 L558,187" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm2)"/>
&lt;text x="400" y="236" text-anchor="middle" font-family="sans-serif" font-size="10" font-style="italic" fill="#555">adopt first (observe), block later (enforce)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h1 id="track-a--client-the-developers-workstation">Track A — Client (the developer&amp;rsquo;s workstation)&lt;/h1>
&lt;h2 id="a0--install-ai-jail-and-bubblewrap">A0 — Install ai-jail and bubblewrap&lt;/h2>
&lt;p>&lt;code>ai-jail&lt;/code> wraps the sandbox; on Linux it needs &lt;code>bubblewrap&lt;/code> separately, on macOS it needs no extra dependency.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ai-jail (macOS and Linux)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">brew tap akitaonrails/tap &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> brew install ai-jail
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># or, with cargo:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">cargo install ai-jail
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># or, with mise:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">mise use -g ubi:akitaonrails/ai-jail
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># bubblewrap on Linux (pick your distro)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sudo pacman -S bubblewrap &lt;span class="c1"># Arch&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sudo apt install bubblewrap &lt;span class="c1"># Debian / Ubuntu&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sudo dnf install bubblewrap &lt;span class="c1"># Fedora&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Check the binary is there and that &lt;code>bwrap&lt;/code> runs without root (it must not ask for &lt;code>sudo&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">ai-jail --version
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">bwrap --ro-bind / / --unshare-all &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;bwrap ok without root&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If &lt;code>bwrap&lt;/code> fails asking for privileges, your kernel has &lt;em>unprivileged user namespaces&lt;/em> disabled; enable them (&lt;code>sysctl kernel.unprivileged_userns_clone=1&lt;/code> on older Debian/Ubuntu) before continuing.&lt;/p>
&lt;h2 id="a1--the-per-project-ai-jail-file">A1 — The per-project .ai-jail file&lt;/h2>
&lt;p>On the first launch inside the project, &lt;code>ai-jail&lt;/code> creates a &lt;code>.ai-jail&lt;/code> (TOML) that is &lt;strong>committable to the repo&lt;/strong>: any colleague who clones inherits the same policy.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nb">cd&lt;/span> ~/Projects/mi-app
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ai-jail claude &lt;span class="c1"># creates .ai-jail and launches Claude Code inside the sandbox&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The generated file:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-toml" data-lang="toml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># .ai-jail — sandbox configuration (commit it to the repo)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">command&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;claude&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">rw_maps&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="p">[]&lt;/span> &lt;span class="c"># extra writable directories&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">ro_maps&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="p">[]&lt;/span> &lt;span class="c"># extra read-only directories&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Before trusting the sandbox, audit it.&lt;/strong> &lt;code>--dry-run --verbose&lt;/code> prints every mount point, every isolation flag and the full &lt;code>bwrap&lt;/code> command, without executing anything:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">ai-jail --dry-run --verbose claude
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Read the output and confirm three things: that &lt;code>$HOME&lt;/code> is mounted as a tmpfs (not the real one), that &lt;code>~/.ssh&lt;/code>, &lt;code>~/.aws&lt;/code> and &lt;code>~/.gnupg&lt;/code> &lt;strong>do not appear&lt;/strong> among the mounts, and that the only writable directory is the project one. If you need an extra directory:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">ai-jail --rw-map ~/Projects/shared-lib claude &lt;span class="c1"># extra, writable&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ai-jail --map /opt/datasets claude &lt;span class="c1"># extra, read-only&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Other agents, same binary:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">ai-jail codex
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ai-jail opencode
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ai-jail bash &lt;span class="c1"># bare shell to debug the sandbox&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ai-jail -- python script.py &lt;span class="c1"># any command&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="a2--permission-allowlists-with---bootstrap">A2 — Permission allowlists with &amp;ndash;bootstrap&lt;/h2>
&lt;p>&lt;code>--bootstrap&lt;/code> generates each agent&amp;rsquo;s permission configuration, with sensible allow/deny/ask, and takes a backup before overwriting:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">ai-jail --bootstrap
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>What it produces, in summary:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Agent&lt;/th>
&lt;th>File&lt;/th>
&lt;th>Base policy&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Claude Code&lt;/td>
&lt;td>&lt;code>~/.claude/settings.json&lt;/code>&lt;/td>
&lt;td>&lt;strong>allow&lt;/strong>: &lt;code>git status/diff/log&lt;/code>, &lt;code>ls&lt;/code>, &lt;code>grep&lt;/code>, &lt;code>cargo&lt;/code>, &lt;code>npm&lt;/code>, &lt;code>python&lt;/code>, &lt;code>docker compose&lt;/code> · &lt;strong>ask&lt;/strong>: &lt;code>git push&lt;/code>, &lt;code>rm&lt;/code>, &lt;code>docker run&lt;/code> · &lt;strong>deny&lt;/strong>: &lt;code>rm -rf&lt;/code>, &lt;code>sudo&lt;/code>, &lt;code>chmod 777&lt;/code>, &lt;code>git push --force&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Codex&lt;/td>
&lt;td>&lt;code>~/.codex/config.toml&lt;/code>&lt;/td>
&lt;td>&lt;code>approval_policy = &amp;quot;on-request&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OpenCode&lt;/td>
&lt;td>&lt;code>~/.config/opencode/opencode.json&lt;/code>&lt;/td>
&lt;td>permissions for &lt;code>bash&lt;/code>, &lt;code>edit&lt;/code>, &lt;code>write&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The operational key: &lt;code>git push&lt;/code> is under &lt;strong>ask&lt;/strong>, not &lt;strong>allow&lt;/strong>, and &lt;code>git push --force&lt;/code> under &lt;strong>deny&lt;/strong>. The agent can commit, branch and rebase locally as much as it wants; none of that touches the remote. (If you use the Claude Code &lt;code>/sandbox&lt;/code>, also set &lt;code>&amp;quot;allowUnsandboxedCommands&amp;quot;: false&lt;/code> to close the &lt;code>dangerouslyDisableSandbox&lt;/code> &lt;em>escape hatch&lt;/em>, which is opt-out out of the box.)&lt;/p>
&lt;h2 id="a3--lockdown-for-anything-you-do-not-trust">A3 — Lockdown for anything you do not trust&lt;/h2>
&lt;p>To audit third-party code or run an agent over a project you do not know, &lt;code>--lockdown&lt;/code> goes further: project mounted &lt;strong>read-only&lt;/strong>, GPU/Docker/display disabled, &lt;code>--rw-map&lt;/code>/&lt;code>--map&lt;/code> ignored, &lt;code>$HOME&lt;/code> a pure tmpfs with no host dotfiles, network cut with &lt;code>--unshare-net&lt;/code> and the environment wiped with &lt;code>--clearenv&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">ai-jail --lockdown bash
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It is the most restrictive sandbox possible short of a VM. Use it as your mental default for anything that is not your own code on your own machine.&lt;/p>
&lt;h2 id="a4--the-safety-net-git-with-no-push">A4 — The safety net: git with no push&lt;/h2>
&lt;p>It is not a flag, it is a property of the environment that changes the risk arithmetic. If the project is in git with a remote, and the agent does &lt;strong>not&lt;/strong> have &lt;code>push&lt;/code> permission, the worst case, that it corrupts every file in the project, is reverted with:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">git checkout . &lt;span class="c1"># back to the last commit&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># and if it touched .git (unlikely): delete the dir and re-clone&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The remote was never touched. &lt;strong>Sandbox for the filesystem + git for the code + manual push&lt;/strong> is already a reasonable level for daily use: &lt;code>ai-jail&lt;/code> protects your data and the system, git protects the code, and the decision to publish is still yours.&lt;/p>
&lt;hr>
&lt;h1 id="track-b--cluster-rke2-with-cilium--tetragon">Track B — Cluster (RKE2 with Cilium + Tetragon)&lt;/h1>
&lt;p>The untrusted agent, or the inference that executes generated code, runs as a pod. The same principle as on the client, different primitives. We assume a generic RKE2 cluster with Cilium as CNI and Tetragon already deployed (the eBPF agent &lt;code>DaemonSet&lt;/code> on every node).&lt;/p>
&lt;h2 id="b0--the-pod-baseline">B0 — The pod baseline&lt;/h2>
&lt;p>Before any eBPF, the standard kit. Unprivileged &lt;code>securityContext&lt;/code>, read-only root, default seccomp:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Pod&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ai-agent&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ai-agent&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">securityContext&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">runAsNonRoot&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">runAsUser&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10001&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">seccompProfile&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">RuntimeDefault&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agent&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">registry.interno/ai-agent:pinned&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">securityContext&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allowPrivilegeEscalation&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">readOnlyRootFilesystem&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">capabilities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">drop&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ALL&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">name: work, mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/work } &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># the only writable one&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">work&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">emptyDir&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the default egress cut, the cluster twin of &lt;code>--unshare-net&lt;/code>. A default-deny egress NetworkPolicy in the namespace, opening only DNS and the bare essentials:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">networking.k8s.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NetworkPolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">default-deny-egress&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">podSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policyTypes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;Egress&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">egress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">to&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">namespaceSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">kubernetes.io/metadata.name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kube-system }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">protocol: UDP, port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">53&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">protocol: TCP, port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">53&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="b1--kata-runtimeclass-the-untrusted-pod-in-its-own-microvm">B1 — Kata RuntimeClass: the untrusted pod in its own microVM&lt;/h2>
&lt;p>For genuinely untrusted code, take it out of the shared kernel. With Kata deployed there is a &lt;code>RuntimeClass&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">node.k8s.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">RuntimeClass&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kata&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">handler&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kata&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the pod asks for it with one line, &lt;code>runtimeClassName: kata&lt;/code>, running in its own microVM with a dedicated kernel instead of sharing the node&amp;rsquo;s:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">runtimeClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kata &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># ← the pod runs in a microVM, not on the node&amp;#39;s kernel&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># ...the rest the same as B0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It is the cluster twin of isolation by construction: a kernel exploit inside the pod does not reach the node.&lt;/p>
&lt;h2 id="b2--tetragon-observation-phase-post">B2 — Tetragon, observation phase (Post)&lt;/h2>
&lt;p>Now the layer that separates a platform with runtime visibility from one without. &lt;strong>Observe first, never kill from the outset.&lt;/strong> A &lt;code>TracingPolicyNamespaced&lt;/code>, scoped to the namespace and to the agent&amp;rsquo;s label, that reports (does not kill) three things: process executions, network connections and opens of sensitive paths. &lt;code>action: Post&lt;/code> only emits the event.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicyNamespaced&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agente-observa&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">podSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ai-agent&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kprobes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># --- outbound connections ---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;tcp_connect&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sock&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># --- opens of sensitive files ---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;security_file_open&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;file&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Prefix&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/var/run/secrets&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/work/.git/config&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>(Process executions need no kprobe: Tetragon emits &lt;code>process_exec&lt;/code>/&lt;code>process_exit&lt;/code> natively.) Deploy it and watch the events live from the node&amp;rsquo;s Tetragon pod:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kubectl apply -f agente-observa.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># readable events, filtered by namespace:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl &lt;span class="nb">exec&lt;/span> -n kube-system ds/tetragon -c tetragon -- &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> tetra getevents -o compact --namespace agentes
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Leave this running for a typical working day of the agent. Note &lt;strong>which destinations&lt;/strong> it really connects to (your internal registry, your HF mirror, your vLLM endpoint) and &lt;strong>which paths&lt;/strong> it opens. That is your baseline: the list of what is legitimate. Without this step, a &lt;code>Sigkill&lt;/code> kills good work and generates an availability incident, precisely what ENS asks you to avoid.&lt;/p>
&lt;h2 id="b3--tetragon-enforcement-phase-sigkill">B3 — Tetragon, enforcement phase (Sigkill)&lt;/h2>
&lt;p>With the baseline in hand, promote to blocking. Two rules. The first: &lt;strong>kill any connection whose destination is not on the allowlist&lt;/strong> — &lt;code>NotDAddr&lt;/code> inverts the match: it fires for everything that is &lt;em>not&lt;/em> those networks. The second: &lt;strong>kill any attempt to open a secrets path&lt;/strong>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicyNamespaced&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agente-enforce&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">podSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ai-agent&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kprobes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># --- egress: kill everything that is NOT on the allowlist ---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;tcp_connect&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sock&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;NotDAddr&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;127.0.0.1&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;10.0.0.0/8&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># internal cluster network&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;172.16.10.20&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># internal registry (example)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Sigkill&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># --- reading secrets: kill the process ---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;security_file_open&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;file&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Prefix&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/var/run/secrets/kubernetes.io/serviceaccount/token&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/work/.ssh&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Sigkill&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kubectl apply -f agente-enforce.yaml
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now the agent can do whatever it likes inside the pod, but &lt;strong>the instant&lt;/strong> it tries to connect to a disallowed destination or read the service account token, Tetragon kills it in the kernel, before the packet leaves or the &lt;code>read&lt;/code> returns bytes. It is the cluster twin of the &lt;code>curl&lt;/code> blocklist and the unmounted &lt;code>~/.ssh&lt;/code>, but applied at runtime and over &lt;em>any&lt;/em> binary, not just the ones you know about.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Operational warning.&lt;/strong> Enforcement with &lt;code>Sigkill&lt;/code> requires a recent kernel with eBPF support for the action (5.10+ is safe). Deploy &lt;code>agente-enforce&lt;/code> in a test namespace first, and keep &lt;code>agente-observa&lt;/code> active in parallel: if the block fires, the &lt;code>Post&lt;/code> event tells you exactly what triggered it. Adopt first, block later.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-client--cluster-equivalence-table">The client ↔ cluster equivalence table&lt;/h2>
&lt;p>The same vector, the two primitives. This is &amp;ldquo;extrapolating the technology&amp;rdquo; made explicit:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Threat vector&lt;/th>
&lt;th>Client (workstation)&lt;/th>
&lt;th>Cluster (RKE2)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>$HOME&lt;/code> / writable root&lt;/td>
&lt;td>&lt;code>$HOME&lt;/code> as ephemeral tmpfs (&lt;code>bwrap&lt;/code>)&lt;/td>
&lt;td>&lt;code>readOnlyRootFilesystem: true&lt;/code> + &lt;code>emptyDir&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Arbitrary egress&lt;/td>
&lt;td>&lt;code>curl&lt;/code>/&lt;code>wget&lt;/code> blocklist · &lt;code>--unshare-net&lt;/code>&lt;/td>
&lt;td>default-deny NetworkPolicy + Tetragon &lt;code>NotDAddr&lt;/code>→&lt;code>Sigkill&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reading secrets&lt;/td>
&lt;td>&lt;code>~/.ssh&lt;/code>/&lt;code>~/.aws&lt;/code>/&lt;code>~/.gnupg&lt;/code> not mounted&lt;/td>
&lt;td>secrets outside the pod + Tetragon &lt;code>security_file_open&lt;/code>→&lt;code>Sigkill&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kernel escape&lt;/td>
&lt;td>Landlock (2nd VFS barrier)&lt;/td>
&lt;td>&lt;code>runtimeClassName: kata&lt;/code> (microVM, own kernel)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>No escape hatch&lt;/td>
&lt;td>process inside &lt;code>bwrap&lt;/code>, no way out&lt;/td>
&lt;td>no &lt;code>privileged&lt;/code>, &lt;code>drop ALL&lt;/code>, &lt;code>allowPrivilegeEscalation:false&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Damage to the code&lt;/td>
&lt;td>git remote with no &lt;code>push&lt;/code> → &lt;code>git checkout .&lt;/code>&lt;/td>
&lt;td>GitOps + PR review, the agent does not apply to &lt;code>main&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Visibility&lt;/td>
&lt;td>&lt;code>--dry-run --verbose&lt;/code> (static, pre-run)&lt;/td>
&lt;td>Tetragon &lt;code>tetra getevents&lt;/code> (dynamic, at runtime)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="checklist-of-gotchas">Checklist of gotchas&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Do not put in a &lt;code>Sigkill&lt;/code> without passing through &lt;code>Post&lt;/code>.&lt;/strong> The observation baseline is not optional: it is what separates &amp;ldquo;blocking a C2&amp;rdquo; from &amp;ldquo;killing your own fine-tuning job&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>The &lt;code>.ai-jail&lt;/code> gets committed; secrets do not.&lt;/strong> The TOML is policy, not credentials. Check you are not putting paths holding sensitive data into &lt;code>rw_maps&lt;/code>.&lt;/li>
&lt;li>&lt;strong>&lt;code>readOnlyRootFilesystem&lt;/code> breaks apps that write to &lt;code>/tmp&lt;/code>.&lt;/strong> Mount an &lt;code>emptyDir&lt;/code> at &lt;code>/tmp&lt;/code> as well as the work one.&lt;/li>
&lt;li>&lt;strong>A NetworkPolicy with no DNS rule leaves the pod blind.&lt;/strong> Open port 53 to &lt;code>kube-system&lt;/code> or nothing resolves.&lt;/li>
&lt;li>&lt;strong>Kata is not free.&lt;/strong> It adds startup latency and not every workload with special devices (GPU passthrough) fits; reserve it for the untrusted, not for everything.&lt;/li>
&lt;li>&lt;strong>The Claude Code &lt;code>/sandbox&lt;/code> does not cover MCP or hooks&lt;/strong> unless you enable &lt;code>sandbox-runtime&lt;/code>. If your agent uses MCP servers, assume they run with full permissions until you do.&lt;/li>
&lt;li>&lt;strong>&lt;code>NotDAddr&lt;/code> with literal IPs ages badly.&lt;/strong> Document the allowlist and review it when the registry or the inference endpoint changes; consider stable internal CIDRs instead of loose IPs.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets in the sovereign LLM stack: defence in depth&lt;/a> — the hardening of the whole cluster beyond agent isolation.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key: isolating AI agents from workstation to cluster&lt;/a> — the landscape this runbook executes: threat model, the five isolation families and why client and cluster use different primitives.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">The kitchen door the maître never checked: Cilium eBPF and DRANET&lt;/a> — the Cilium eBPF layer on which Tetragon hooks its kprobes; the datapath you already have in the cluster.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: ENS × ISO 42001 × EU AI Act&lt;/a> — Tetragon events as technical evidence for &lt;code>op.mon&lt;/code>/&lt;code>op.exp&lt;/code>; enforcement as a protection measure.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — mitigation on the content plane; this runbook, on the execution plane.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">Seven phases of deploying an on-premise LLM platform&lt;/a> — where runtime hardening fits in the deployment sequence (F4 identity/policies, F5 platform).&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>ai-jail (Fabio Akita), GPL-3.0: &lt;a href="https://github.com/akitaonrails/ai-jail">https://github.com/akitaonrails/ai-jail&lt;/a>&lt;/li>
&lt;li>bubblewrap: &lt;a href="https://github.com/containers/bubblewrap">https://github.com/containers/bubblewrap&lt;/a>&lt;/li>
&lt;li>Landlock LSM: &lt;a href="https://landlock.io">https://landlock.io&lt;/a>&lt;/li>
&lt;li>Tetragon — TracingPolicy: &lt;a href="https://tetragon.io/docs/concepts/tracing-policy/">https://tetragon.io/docs/concepts/tracing-policy/&lt;/a>&lt;/li>
&lt;li>Tetragon — enforcement (Sigkill/Override): &lt;a href="https://tetragon.io/docs/concepts/enforcement/">https://tetragon.io/docs/concepts/enforcement/&lt;/a>&lt;/li>
&lt;li>Kata Containers — Kubernetes RuntimeClass: &lt;a href="https://katacontainers.io">https://katacontainers.io&lt;/a>&lt;/li>
&lt;li>Kubernetes — Pod Security &amp;amp; seccomp: &lt;a href="https://kubernetes.io/docs/tutorials/security/seccomp/">https://kubernetes.io/docs/tutorials/security/seccomp/&lt;/a>&lt;/li>
&lt;li>Kubernetes — Network Policies: &lt;a href="https://kubernetes.io/docs/concepts/services-networking/network-policies/">https://kubernetes.io/docs/concepts/services-networking/network-policies/&lt;/a>&lt;/li>
&lt;li>Cilium: &lt;a href="https://cilium.io">https://cilium.io&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>The kitchen door the maître d' never looked at: network NUMA, Cilium eBPF and DRANET, the fourth leg of pinning</title><link>https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/</link><pubDate>Sat, 06 Jun 2026 12:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/</guid><description>&lt;blockquote>
&lt;p>Fourth instalment, a coda, of &amp;ldquo;under the engine&amp;rdquo;. The series closed with three legs of locality: the &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">cable between GPUs&lt;/a>, the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">host by hand&lt;/a> and the &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">declarative orchestration of the kubelet&lt;/a>. But the maître d&amp;rsquo; of the last post seated the group looking at CPU, memory and GPU, and never asked &lt;strong>which door the dishes come through&lt;/strong>. That door is the NIC. Here is the fourth leg.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">Topology Manager&lt;/a> admits a pod under &lt;code>single-numa-node&lt;/code> if its CPUs, its memory and its GPU fit on the &lt;strong>same&lt;/strong> NUMA node. The &lt;strong>NIC is not part of that count&lt;/strong>: the kubelet has no Hint Provider for the network card. On an inference node with the network at 200/400 Gb/s, the case of &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a>, where the KV-cache travels over RDMA between the prefill pool and the decode pool, a NIC on the wrong socket makes &lt;strong>every packet cross the UPI/QPI&lt;/strong>, exactly the &amp;ldquo;remote NUMA&amp;rdquo; the series fights on the compute side, but coming in through the network door. And there is a second front: the &lt;strong>softirq&lt;/strong> (&lt;code>NET_RX&lt;/code>) that processes the datapath runs on the CPU that services the NIC&amp;rsquo;s IRQ; if that CPU is one of the cores &lt;code>isolcpus&lt;/code>/&lt;code>reserved-cpus&lt;/code> gave exclusively to vLLM, the softirq steals cycles from it and injects jitter into the p99 tail. &lt;strong>Cilium eBPF&lt;/strong> replaces two RKE2 pieces, &lt;code>kube-proxy&lt;/code> (with eBPF/XDP load balancing) and the default CNI &lt;strong>Canal&lt;/strong> (with a native datapath), and its own tuning guide tells you to &lt;strong>kill &lt;code>irqbalance&lt;/code> and pin the NIC IRQs&lt;/strong>: a &lt;strong>fourth list&lt;/strong> to align alongside &lt;code>isolcpus&lt;/code> and &lt;code>reserved-cpus&lt;/code>. The 2026 state of the art closes the gap from above: &lt;strong>netkit&lt;/strong> (kernel ≥6.8, zero namespace overhead), &lt;strong>BIG TCP&lt;/strong> (192k super-packets for 100Gb/s+), &lt;strong>host-routing&lt;/strong> (iptables bypass), and above all &lt;strong>DRA/DRANET&lt;/strong>, the network driver that finally co-schedules &lt;strong>NUMA-local GPU and NIC on the same PCIe root&lt;/strong>, enabling GPUDirect RDMA with &lt;strong>+59.6% bus bandwidth in &lt;code>all_gather&lt;/code> and +58.1% in &lt;code>all_reduce&lt;/code>&lt;/strong>. On a generic RKE2 cluster with 4×H100 SXM nodes.&lt;/p>
&lt;h2 id="where-you-are-the-network-plane-the-trilogy-did-not-open">Where you are: the network plane the trilogy did not open&lt;/h2>
&lt;div class="diagram" style="max-width:560px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 560 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The fourth leg: the network plane under the same NUMA as CPU, memory and GPU">
&lt;text x="280" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">The vertical stack · the fourth leg of locality&lt;/text>
&lt;rect x="120" y="40" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="64" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Engine · vLLM pod (TP, batching, KV-cache)&lt;/text>
&lt;rect x="120" y="84" width="320" height="38" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="280" y="108" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Orchestration · kubelet: CPU/Mem/Topology Mgr (post 3)&lt;/text>
&lt;rect x="120" y="128" width="320" height="58" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="3"/>
&lt;text x="280" y="152" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#1f5c34">YOU ARE HERE · network plane: Cilium eBPF + DRA/NIC&lt;/text>
&lt;text x="280" y="170" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#2c6b42">NIC NUMA locality · IRQ · GPUDirect RDMA&lt;/text>
&lt;rect x="120" y="192" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="216" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Host · NUMA, hugepages, isolcpus (post 2)&lt;/text>
&lt;rect x="120" y="236" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="260" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">CUDA + NCCL + NVLink (post 1)&lt;/text>
&lt;rect x="120" y="280" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="304" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Hardware · 2 sockets, 4×H100 SXM, 400 Gb/s NIC&lt;/text>
&lt;text x="280" y="332" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="#777">CPU+memory+GPU are pinned by the kubelet; the NIC, until 2026, was pinned by nobody&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-door-the-dishes-come-through">The analogy: the door the dishes come through&lt;/h2>
&lt;p>Go back to the restaurant of the &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">previous post&lt;/a>. The maître d&amp;rsquo;, the Topology Manager, seated the party of eight at a single table (one NUMA node) because the diners (CPUs), the cutlery (memory) and the reserved bottle (the GPU) all fitted. A perfect table. But the maître d&amp;rsquo; &lt;strong>never looked at where the kitchen pass is&lt;/strong>: the door every dish comes in and goes out through.&lt;/p>
&lt;p>That door is the &lt;strong>NIC&lt;/strong>. The prompt comes in there, the tokens go out there, and, in disaggregated serving, the KV-cache that the prefill pool sends to the decode pool circulates there. If the table is in the left-hand room (socket 0) but the kitchen pass is in the right-hand one (socket 1), &lt;strong>every dish crosses the entire restaurant&lt;/strong> (the UPI/QPI), over and over, however impeccably the table is laid. The diner does not notice the perfect table: they notice the dish arriving late and cold.&lt;/p>
&lt;p>And there is a finer detail: the waiter who crosses the room with the dishes (the &lt;strong>softirq&lt;/strong> that processes the packets) is &lt;strong>one of the seated diners&lt;/strong>. If the maître d&amp;rsquo; gave them a chair of their own so they could eat in peace (a core isolated with &lt;code>isolcpus&lt;/code> for vLLM) but the restaurant also has them waiting on the far door, that diner does not eat: they spend the whole dinner crossing the room. The jitter shows up exactly where you thought you had bought calm.&lt;/p>
&lt;p>The trilogy levelled three legs of the table: the cable, the host and the orchestration. The fourth, &lt;strong>which door the dishes come through and who carries them&lt;/strong>, is levelled by no kubelet manager. Until 2026.&lt;/p>
&lt;h2 id="the-gap-why-the-topology-manager-does-not-look-at-the-nic">The gap: why the Topology Manager does not look at the NIC&lt;/h2>
&lt;p>The mechanism of &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">post 3&lt;/a> is a coordinator (Topology Manager) that consults three &lt;strong>Hint Providers&lt;/strong>: CPU Manager, Memory Manager and Device Manager (the GPU plugin). Each says on which NUMA node it can satisfy its part; the coordinator computes the intersection and admits or rejects.&lt;/p>
&lt;p>The problem is one of &lt;strong>census&lt;/strong>: the classic NIC is not a Device Manager &amp;ldquo;device&amp;rdquo;. A standard Ethernet/InfiniBand card is managed by the CNI and the kernel, it is not requested in the pod&amp;rsquo;s &lt;code>resources:&lt;/code> like &lt;code>nvidia.com/gpu&lt;/code>, and therefore &lt;strong>it emits no NUMA hint&lt;/strong>. The Topology Manager aligns CPU+memory+GPU and leaves the NIC wherever the hardware put it, which may be the other socket. The maître d&amp;rsquo; has three assistants and is missing the fourth: the one who knows which door the dishes come through.&lt;/p>
&lt;p>This did not matter when a node&amp;rsquo;s network was 10/25 Gb/s and the bottleneck was elsewhere. It matters &lt;strong>now&lt;/strong>, with two workloads that saturate the node&amp;rsquo;s network:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Disaggregated serving.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">KV-cache that travels between the prefill pool and the decode pool&lt;/a> moves over RDMA. These are large transfers, sensitive to latency and bandwidth, which in multi-node setups go out through the NIC.&lt;/li>
&lt;li>&lt;strong>Multi-node NCCL collectives.&lt;/strong> When &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">tensor/pipeline parallelism crosses the node boundary&lt;/a>, the &lt;code>all-reduce&lt;/code>/&lt;code>all-gather&lt;/code> operations no longer go over NVLink but over GPUDirect RDMA on the NIC.&lt;/li>
&lt;/ul>
&lt;p>In both, &lt;strong>where the NIC sits relative to the GPU and to the pod&amp;rsquo;s cores&lt;/strong> decides performance. And the kubelet, on its own, does not coordinate that.&lt;/p>
&lt;h2 id="the-network-datapath-under-numa-irq-softirq-and-dma">The network datapath under NUMA: IRQ, softirq and DMA&lt;/h2>
&lt;p>To see why NIC locality carries weight, you have to look at the path of an incoming packet:&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 820 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Path of a packet: NIC, IRQ, softirq and cross-NUMA DMA">
&lt;defs>&lt;marker id="nm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">Socket 0 (NIC here) vs Socket 1 (vLLM pod here): the crossing you do not see&lt;/text>
&lt;!-- Socket 0 -->
&lt;rect x="30" y="46" width="360" height="240" rx="10" fill="none" stroke="#4a6fa5" stroke-width="1.6" stroke-dasharray="5 3"/>
&lt;text x="210" y="66" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#4a6fa5">NUMA node 0 · PCIe root with the NIC&lt;/text>
&lt;rect x="60" y="80" width="120" height="44" rx="7" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.6"/>
&lt;text x="120" y="100" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">NIC 400 Gb/s&lt;/text>
&lt;text x="120" y="115" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">multi-queue, RSS&lt;/text>
&lt;rect x="60" y="150" width="120" height="44" rx="7" fill="#f7efda" stroke="#c79a32" stroke-width="1.6"/>
&lt;text x="120" y="170" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">IRQ + softirq&lt;/text>
&lt;text x="120" y="185" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">NET_RX on core 0..&lt;/text>
&lt;rect x="60" y="220" width="120" height="44" rx="7" fill="#dceede" stroke="#3c8c54" stroke-width="1.6"/>
&lt;text x="120" y="240" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">RAM node 0&lt;/text>
&lt;text x="120" y="255" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">packet DMA&lt;/text>
&lt;path d="M120,124 L120,150" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#nm)"/>
&lt;path d="M120,194 L120,220" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#nm)"/>
&lt;!-- Socket 1 -->
&lt;rect x="430" y="46" width="360" height="240" rx="10" fill="none" stroke="#a85454" stroke-width="1.6" stroke-dasharray="5 3"/>
&lt;text x="610" y="66" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#a85454">NUMA node 1 · the kubelet pinned the pod here&lt;/text>
&lt;rect x="550" y="150" width="160" height="44" rx="7" fill="#dceede" stroke="#3c8c54" stroke-width="2.2"/>
&lt;text x="630" y="170" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">vLLM pod + GPU&lt;/text>
&lt;text x="630" y="185" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">exclusive CPUs node 1&lt;/text>
&lt;!-- cross-numa arrow -->
&lt;path d="M180,242 C320,242 470,172 550,172" fill="none" stroke="#a85454" stroke-width="2.4" marker-end="url(#nm)"/>
&lt;text x="370" y="225" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#a85454">every packet crosses the UPI/QPI&lt;/text>
&lt;text x="370" y="240" text-anchor="middle" font-family="sans-serif" font-size="9" font-style="italic" fill="#a85454">+latency, +load on the inter-socket link&lt;/text>
&lt;text x="410" y="306" text-anchor="middle" font-family="sans-serif" font-size="10" font-style="italic" fill="#777">The Topology Manager did its job on node 1; the NIC stayed on node 0. Nobody aligned the two.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Three kernel facts the analogy compresses:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The IRQ has affinity.&lt;/strong> Each NIC queue raises an interrupt that the kernel services on a specific CPU (&lt;code>/proc/irq/&amp;lt;n&amp;gt;/smp_affinity&lt;/code>). The heavy processing is deferred to a &lt;strong>softirq&lt;/strong> (&lt;code>NET_RX&lt;/code>/&lt;code>NET_TX&lt;/code>), which runs on &lt;strong>that same CPU&lt;/strong>. If &lt;code>irqbalance&lt;/code> is left loose, it migrates them non-deterministically, poison for the p99.&lt;/li>
&lt;li>&lt;strong>The softirq competes with the pod.&lt;/strong> If the IRQ lands on a core that &lt;code>isolcpus&lt;/code> reserved for vLLM, that queue&amp;rsquo;s &lt;code>NET_RX&lt;/code> steals cycles from the model. The signal in &lt;code>/proc/softirqs&lt;/code>: a &lt;code>NET_RX&lt;/code> column that spikes on a single CPU. It is the same jitter from &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">post 2&lt;/a>, coming in through the network.&lt;/li>
&lt;li>&lt;strong>DMA has a NUMA origin.&lt;/strong> The NIC writes the packet by DMA into the RAM of the socket of its PCIe root. If the consumer (the pod&amp;rsquo;s thread) is on the other socket, it reads across the UPI/QPI. RFS (Receive Flow Steering) tries to bring the processing to the consumer&amp;rsquo;s CPU, but it cannot teleport the NIC to the other socket.&lt;/li>
&lt;/ol>
&lt;h3 id="one-number-with-its-caveat">One number, with its caveat&lt;/h3>
&lt;p>Take a 2-socket node, a &lt;strong>400 Gb/s = 50 GB/s&lt;/strong> NIC on the PCIe root of socket 0, and a decode pod pinned to socket 1. If the NIC saturates, those ~50 GB/s of receive traffic &lt;strong>cross the UPI&lt;/strong> towards socket 1. A UPI 2.0 link runs at around &lt;strong>20–40 GB/s&lt;/strong> per direction and link depending on generation; even with several links, 50 GB/s of network traffic &lt;strong>going against the grain&lt;/strong> eats a far from negligible fraction of the inter-socket budget, the same budget already contended by the pod&amp;rsquo;s remote memory accesses and, if there is multi-node traffic, the &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">KV-cache of the disaggregation&lt;/a>. I am not giving a firm &amp;ldquo;X% degradation&amp;rdquo; because it depends on CPU generation, number of UPI links, MTU and traffic pattern; without that methodology, any exact figure is marketing.&lt;/p>
&lt;p>What &lt;strong>is&lt;/strong> measured with public methodology is the aggregate effect of aligning GPU and NIC: the &lt;strong>DRANET&lt;/strong> project reports &lt;strong>+59.6% bus bandwidth in &lt;code>all_gather&lt;/code> and +58.1% in &lt;code>all_reduce&lt;/code>&lt;/strong> (NCCL collectives) when the assigned NIC is &lt;strong>NUMA-local to the GPU&lt;/strong> rather than not. That is the size of the gap the Topology Manager was leaving open.&lt;/p>
&lt;h2 id="what-rke2s-cilium-ebpf-replaces-and-why-it-touches-this-story">What RKE2&amp;rsquo;s Cilium eBPF replaces (and why it touches this story)&lt;/h2>
&lt;p>RKE2 ships &lt;strong>Canal&lt;/strong> (Flannel + Calico) by default as the CNI and &lt;strong>&lt;code>kube-proxy&lt;/code>&lt;/strong> (iptables/IPVS rules) for Service balancing. Switching to Cilium (&lt;code>cni: cilium&lt;/code> in &lt;code>/etc/rancher/rke2/config.yaml&lt;/code>) replaces both pieces with an eBPF datapath:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>RKE2 piece&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>What Cilium eBPF puts in&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>kube-proxy&lt;/code> (iptables/IPVS)&lt;/td>
&lt;td>Service balancing&lt;/td>
&lt;td>LB in eBPF; with &lt;code>kubeProxyReplacement=true&lt;/code>, and acceleration in &lt;strong>XDP&lt;/strong> (driver layer)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Canal (Flannel+Calico)&lt;/td>
&lt;td>VXLAN overlay + NetworkPolicy&lt;/td>
&lt;td>native datapath (&lt;code>routingMode=native&lt;/code>), L3/L4 and L7 NetworkPolicy in eBPF&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>veth per pod&lt;/td>
&lt;td>namespace interface pair&lt;/td>
&lt;td>&lt;strong>netkit&lt;/strong> (kernel ≥6.8): namespace overhead ~0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>host iptables traversal&lt;/td>
&lt;td>netfilter hooks&lt;/td>
&lt;td>eBPF &lt;strong>host-routing&lt;/strong>: bypasses iptables and the upper part of the stack&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Up to here this is pure networking and it &lt;strong>does not touch&lt;/strong> the kubelet&amp;rsquo;s resource managers: Cilium does not assign exclusive CPUs nor emit compute NUMA hints. The ten knobs of &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">post 3&lt;/a> stay identical whether you run Canal or Cilium.&lt;/p>
&lt;p>&lt;strong>But&lt;/strong> Cilium does enter the fourth leg through two doors. The first: its own &lt;a href="https://docs.cilium.io/en/stable/operations/performance/tuning/">tuning guide&lt;/a> recommends, literally, &lt;em>&amp;ldquo;kill &lt;code>irqbalance&lt;/code> and pin the NIC IRQs to specific CPUs for maximum workload isolation&amp;rdquo;&lt;/em>, along with the &lt;code>tuned network-latency&lt;/code> profile, the &lt;code>performance&lt;/code> governor and &lt;code>CONFIG_PREEMPT_NONE&lt;/code>. In other words: the eBPF datapath only really performs &lt;strong>if you coordinate IRQ affinity&lt;/strong>, and that affinity has to point at the &lt;strong>housekeeping&lt;/strong> cores (&lt;code>reserved-cpus&lt;/code>), never at the isolated ones. So a &lt;strong>fourth list&lt;/strong> appears that must be kept consistent with &lt;code>isolcpus&lt;/code> and &lt;code>reserved-cpus&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">isolcpus = 2-31,34-63 # cores exclusive to vLLM (host, post 2)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">reserved-cpus = 0-1,32-33 # kubelet housekeeping (post 3)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">IRQ affinity = 0-1,32-33 # NIC IRQs → housekeeping ONLY (this post)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> # never 2-31: there the softirq would steal from the model
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The second door: &lt;strong>netkit + host-routing + BIG TCP&lt;/strong> reduce how many times the packet crosses the stack and the namespace, which &lt;strong>cushions&lt;/strong> (does not eliminate) the cost of the NUMA crossing. BIG TCP assembles super-packets of up to 192k (against 64k) for 100Gb/s+; fewer stack traversals means less softirq work on the core, and therefore less pressure on the inter-socket budget. It is the &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a> analogy applied to the network stack: amortising a fixed cost over larger batches.&lt;/p>
&lt;h3 id="cilium-performance-profile-state-119-kernel-68">Cilium performance profile (state 1.19, kernel ≥6.8)&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Helm, recommended performance profile (summary of the tuning guide)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">helm install cilium cilium/cilium --version 1.19.4 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --namespace kube-system &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set &lt;span class="nv">routingMode&lt;/span>&lt;span class="o">=&lt;/span>native &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set bpf.datapathMode&lt;span class="o">=&lt;/span>netkit &lt;span class="se">\ &lt;/span> &lt;span class="c1"># namespace overhead ~0 (kernel &amp;gt;=6.8)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --set bpf.masquerade&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set &lt;span class="nv">kubeProxyReplacement&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="se">\ &lt;/span> &lt;span class="c1"># replaces RKE2&amp;#39;s kube-proxy&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --set &lt;span class="nv">enableIPv4BIGTCP&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="se">\ &lt;/span> &lt;span class="c1"># 192k super-packets (mlx5/ice NIC)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --set &lt;span class="nv">enableIPv6BIGTCP&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set bpf.distributedLRU.enabled&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="se">\#&lt;/span> per-CPU BPF maps: less spinlock contention
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --set bpf.mapDynamicSizeRatio&lt;span class="o">=&lt;/span>0.08 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set &lt;span class="nv">bpfClockProbe&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Verification inside a Cilium pod:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">cilium status --verbose &lt;span class="p">|&lt;/span> grep -E &lt;span class="s2">&amp;#34;Device Mode|Host Routing|BIG TCP|XDP&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Device Mode: netkit · Host Routing: BPF · IPv4 BIG TCP: enabled · XDP Acceleration: Native&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Sceptical caveat: netkit and BIG TCP are &lt;strong>beta&lt;/strong> and require kernel ≥6.8 and specific NICs (mlx4/mlx5/ice). They are not in-place: they change datapath fundamentals and force you to restart pods or, better, to apply them by &lt;em>per-node config&lt;/em> only on new nodes. For an ENS cluster in production, that is a maintenance window, not a blind &lt;code>helm upgrade&lt;/code>.&lt;/p>
&lt;h2 id="the-2026-state-of-the-art-dra-and-dranet-the-maître-d-who-finally-looks-at-the-door">The 2026 state of the art: DRA and DRANET, the maître d&amp;rsquo; who finally looks at the door&lt;/h2>
&lt;p>What closes the gap at the root is not Cilium, it is the &lt;strong>admission mechanism&lt;/strong> the kubelet did not have for the NIC: &lt;strong>Dynamic Resource Allocation (DRA)&lt;/strong>, beta since Kubernetes 1.32 and with advances in every release up to 1.36 (May 2026). DRA generalises the &amp;ldquo;devices&amp;rdquo; model beyond the GPU: a driver discovers the hardware, publishes &lt;code>ResourceSlices&lt;/code> with its attributes, including the &lt;strong>NUMA topology and the PCIe root&lt;/strong>, and the scheduler resolves &lt;code>ResourceClaims&lt;/code> that can demand affinity between devices.&lt;/p>
&lt;p>&lt;strong>DRANET&lt;/strong> (a &lt;code>kubernetes-sigs&lt;/code> project) is the network DRA driver. It discovers the NICs (including the RDMA-capable ones), advertises them as &lt;code>ResourceSlices&lt;/code>, and via &lt;strong>NRI&lt;/strong> injects them into the pod&amp;rsquo;s namespace, compatible with whatever CNI you already have, Cilium included. The key piece for this story: combined with the &lt;strong>NVIDIA GPU DRA driver&lt;/strong>, it allows &lt;strong>co-scheduling GPU and NIC that share a PCIe root&lt;/strong> (the relationship NVIDIA calls &lt;code>NODE&lt;/code>), which is exactly the condition for &lt;strong>GPUDirect RDMA&lt;/strong>. The maître d&amp;rsquo; finally has a fourth assistant: &lt;em>&amp;ldquo;is there a NIC NUMA-local to this GPU?&amp;rdquo;&lt;/em>.&lt;/p>
&lt;p>The &lt;code>ResourceClaimTemplate&lt;/code> uses CEL selectors to request exactly that alignment:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># Request an RDMA NIC NUMA-local to the assigned GPU (illustrative DRANET/DRA schema)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">resource.k8s.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ResourceClaimTemplate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gpu-nic-numa-aligned&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">devices&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">rdma-nic&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">deviceClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dra.net &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># NICs published by DRANET&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">constraints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;rdma-nic&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchAttribute&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;dra.net/pcieRoot&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># same PCIe root as the GPU&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># → enables GPUDirect RDMA over a NUMA-local path&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Why it matters for inference, not for abstract &amp;ldquo;AI training&amp;rdquo;: in &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a>, RDMA is what moves the KV-cache between the prefill pool and the decode pool with the latency that &lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">TTFT&lt;/a> demands; and in multi-node, GPUDirect RDMA replaces &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink&lt;/a> as the medium of the collective. Aligning GPU+NIC on the same PCIe root is what turns an &amp;ldquo;RDMA that works&amp;rdquo; into an &amp;ldquo;RDMA that performs&amp;rdquo;, the +60% bus bandwidth from DRANET.&lt;/p>
&lt;p>Status and caveats: DRA is &lt;strong>beta&lt;/strong> (gates to enable by hand), DRANET is young (a SIG project, still evolving) and the managed offering exists mostly in cloud (GKE managed DRANET in preview, AKS for RDMA). For on-premise ENS it is a &lt;strong>path, not a finished product&lt;/strong>: the value today is understanding that the fourth leg already has a standard OSS mechanism, and starting to pilot it on a lab node, not putting it into critical production this quarter.&lt;/p>
&lt;h2 id="how-it-connects-with-the-rest-of-the-stack">How it connects with the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With the host (post 2).&lt;/strong> The NIC&amp;rsquo;s IRQ affinity is a &lt;strong>third list&lt;/strong> to marry with &lt;code>isolcpus&lt;/code> and &lt;code>reserved-cpus&lt;/code>. The IRQs go to housekeeping; the isolated cores, untouched. Uncoordinating them lets in through the network door the jitter that &lt;code>isolcpus&lt;/code> threw out through the compute one.&lt;/p>
&lt;p>&lt;strong>With the orchestration (post 3).&lt;/strong> DRA is the natural extension of the Topology Manager: the same &amp;ldquo;admit only if it fits on the NUMA node&amp;rdquo; principle carried over to the NIC. Where the Device Manager left the network out of the census, DRANET puts it in.&lt;/p>
&lt;p>&lt;strong>With the interconnect (post 1).&lt;/strong> Inside the node NVLink rules; on crossing the node boundary, GPUDirect RDMA over the NIC is the medium of the collective. The kubelet&amp;rsquo;s NUMA policy guarantees that GPU and CPUs share a socket; &lt;strong>DRANET adds that the NIC does too&lt;/strong>, and only then does the RDMA take the short path.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving.&lt;/strong> The prefill→decode KV-cache is the traffic that punishes a badly placed NIC hardest. The fourth leg is what stops &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">separating prefill and decode&lt;/a> from being paid for in transfer latency.&lt;/p>
&lt;p>&lt;strong>With capacity planning.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Sizing&lt;/a> gains a dimension: &amp;ldquo;GPUs per node and cores per NUMA node&amp;rdquo; is not enough; you have to count &lt;strong>how many NICs NUMA-local to a GPU&lt;/strong> the chassis has. A node with 4 GPUs and a single NIC on socket 0 has two GPUs &amp;ldquo;far from the door&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>With observability.&lt;/strong> What confirms that the fourth leg is properly set is not an application dashboard: it is &lt;code>/proc/softirqs&lt;/code> (is &lt;code>NET_RX&lt;/code> concentrated on housekeeping?), &lt;code>nvidia-smi topo -m&lt;/code> (is the GPU↔NIC relationship &lt;code>NODE&lt;/code>/&lt;code>PHB&lt;/code>?) and the NIC counters. It fits with &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a>: the GPU &amp;ldquo;at 60% for no reason&amp;rdquo; may be the host waiting for packets that cross the socket.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>Believing that switching to Cilium &amp;ldquo;already optimises the network&amp;rdquo;.&lt;/strong> Cilium eBPF replaces kube-proxy and Canal and performs better out of the box, but the default deployment prioritises compatibility, not performance. Without &lt;code>irqbalance&lt;/code> disabled, without IRQs pinned to housekeeping and without netkit/host-routing, you leave most of the improvement on the table. Cilium&amp;rsquo;s docs say so; plenty of people do not read the tuning guide.&lt;/p>
&lt;p>&lt;strong>Pinning the NIC IRQs to isolated cores.&lt;/strong> The symmetric mistake to knob 6 of post 3: if you set IRQ affinity over &lt;code>isolcpus&lt;/code>, the &lt;code>NET_RX&lt;/code> softirq steals cycles from vLLM on exactly the cores you isolated so nobody would disturb it. The IRQs go to &lt;code>reserved-cpus&lt;/code>, always.&lt;/p>
&lt;p>&lt;strong>Assuming the Topology Manager already aligns the NIC.&lt;/strong> It does not: the classic NIC is not a Hint Provider. If you need NIC↔GPU locality, today the mechanism is DRA/DRANET, not a kubelet policy. Waiting for &lt;code>single-numa-node&lt;/code> to solve it is waiting for something that is not in its design.&lt;/p>
&lt;p>&lt;strong>Putting DRA/DRANET into ENS production this quarter.&lt;/strong> It is beta and young. The sensible move is to pilot it on a lab node, measure &lt;code>all_reduce&lt;/code>/&lt;code>all_gather&lt;/code> with and without alignment, and decide with data. The +60% figure is from a specific environment; reproduce it in yours before promising it.&lt;/p>
&lt;p>&lt;strong>BIG TCP / netkit without reading the requirements.&lt;/strong> Kernel ≥6.8, mlx4/mlx5/ice NICs, no tunnel or encryption for BIG TCP, and nothing in-place: it forces pod restarts or per-node config. On a cluster with IPsec or with unsupported NICs, part of this does not apply. Check &lt;code>cilium status --verbose&lt;/code> before taking for granted that it is active.&lt;/p>
&lt;p>&lt;strong>Confusing the eBPF datapath (kernel) with the Cilium agent (pod).&lt;/strong> &lt;code>cilium-agent&lt;/code> is a &lt;code>Burstable&lt;/code> DaemonSet that should live on housekeeping (covered by &lt;code>system-reserved&lt;/code>). But datapath processing runs in &lt;strong>softirq&lt;/strong>, governed by the host&amp;rsquo;s IRQ affinity, &lt;strong>not&lt;/strong> by &lt;code>reserved-cpus&lt;/code>. They are two different things; pinning the pod well does not pin the softirq.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>The &amp;ldquo;under the engine&amp;rdquo; series chased one idea: performance that looks like an engine problem (vLLM is slow) or a model problem (quantisation) is, far too often, a &lt;strong>locality&lt;/strong> problem one layer down. The trilogy covered three: the cable (NVLink not used), the host (remote NUMA, jitter) and the orchestration (pinning that never happened). The fourth was miss&lt;strong>ing&lt;/strong>: &lt;strong>the network&lt;/strong>. The Topology Manager seats the pod at a perfect NUMA table and never asks which door the dishes come through nor who carries them. On a node at 25 Gb/s it made no difference; on one at 400 Gb/s with the KV-cache crossing over RDMA, that door decides TTFT and the collective&amp;rsquo;s bandwidth. &lt;strong>Cilium eBPF&lt;/strong> replaces kube-proxy and Canal with a datapath that performs, if you coordinate IRQ affinity with &lt;code>isolcpus&lt;/code>/&lt;code>reserved-cpus&lt;/code>, a fourth list to align, and &lt;strong>DRA/DRANET&lt;/strong> finally provides the census that was missing: co-scheduling NUMA-local GPU and NIC on the same PCIe root, with an improvement of a size (+60% NCCL bus bandwidth) that measures how big the gap was. Going down a level is not snobbery: it is that the root cause lived, once again, one layer below where the dashboard looks.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets in the sovereign LLM stack: defence in depth&lt;/a> — the default-deny NetworkPolicies and mTLS with Cilium in the hardening of the stack.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">The corridors and the guard: PCIe, GPUDirect P2P and ACS&lt;/a> — the GPUDirect RDMA that DRANET places NUMA-locally is broken by ACS if it forces traffic through the root complex; the bus beneath NIC↔GPU locality.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">The maître d&amp;rsquo; who only seats you if you all fit at one table: resource managers on RKE2&lt;/a> — post 3, the direct parent of this one: the Topology Manager pins CPU+memory+GPU but not the NIC; here that fourth leg is opened.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA, hugepages and CPU isolation&lt;/a> — post 2; the NIC&amp;rsquo;s IRQ affinity is a third list to marry with &lt;code>isolcpus&lt;/code> and &lt;code>reserved-cpus&lt;/code>, and the &lt;code>NET_RX&lt;/code> softirq is the same jitter coming in through the network.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink, NVSwitch and NCCL&lt;/a> — post 1; on crossing the node, GPUDirect RDMA over the NIC replaces NVLink, and DRANET is what guarantees that RDMA takes the NUMA-local path.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode separated&lt;/a> — the case that punishes a badly placed NIC hardest: the prefill→decode KV-cache travels over RDMA and pays for every socket crossing.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">The on-premise LLM inference stack in seven layers&lt;/a> — the complete building; the network is the plane that holds up multi-node inference.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLMs on Kubernetes with KEDA&lt;/a> — every new replica does not only go through the kubelet&amp;rsquo;s NUMA admission; with DRA, also through the NIC &lt;code>ResourceClaim&lt;/code>&amp;rsquo;s.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise inference&lt;/a> — sizing gains a dimension: how many NICs NUMA-local to a GPU the chassis has, not just how many GPUs.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA + Intel environments&lt;/a> — NIC↔accelerator NUMA affinity gets complicated when the node mixes GPUs, accelerators and heterogeneous NICs.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a> — how to confirm, metric in hand, that the &amp;ldquo;GPU at 60%&amp;rdquo; is not the host waiting for packets crossing the socket.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — the same &amp;ldquo;get the CPU out of the way&amp;rdquo; principle that GPUDirect RDMA gives here, applied to disk with GPUDirect Storage to load weights straight from NVMe to HBM.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SM, CUDA streams and CUDA graphs&lt;/a> — one floor further down: once the data is in HBM, what happens in the silicon that executes it and why decode becomes launch-bound.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key: isolating AI agents from the workstation to the cluster&lt;/a> — the other use of this same kernel layer: on top of Cilium&amp;rsquo;s eBPF datapath, Tetragon hooks its kprobes to observe and kill what an AI agent does in the cluster. Its &lt;a href="https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/">runbook&lt;/a> brings the concrete &lt;code>TracingPolicy&lt;/code> objects.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Cilium, &lt;em>Tuning Guide&lt;/em> (netkit, host-routing, BIG TCP, XDP, pin the IRQs and kill irqbalance): &lt;a href="https://docs.cilium.io/en/stable/operations/performance/tuning/">https://docs.cilium.io/en/stable/operations/performance/tuning/&lt;/a>.&lt;/li>
&lt;li>Cilium 1.19 (February 2026), &lt;em>Cilium at Ten Years&lt;/em> — hardening of encryption, policies and observability: &lt;a href="https://www.infoq.com/news/2026/02/cilium-119/">https://www.infoq.com/news/2026/02/cilium-119/&lt;/a>.&lt;/li>
&lt;li>Isovalent, &lt;em>Cilium 1.18&lt;/em> (IPv6, encrypted overlay, ingress bandwidth, policy perf): &lt;a href="https://isovalent.com/blog/post/cilium-1-18/">https://isovalent.com/blog/post/cilium-1-18/&lt;/a>.&lt;/li>
&lt;li>RKE2, &lt;em>Network Options&lt;/em> (Canal by default; Cilium with kube-proxy replacement): &lt;a href="https://docs.rke2.io/networking/basic_network_options">https://docs.rke2.io/networking/basic_network_options&lt;/a>.&lt;/li>
&lt;li>Kubernetes, &lt;em>Dynamic Resource Allocation&lt;/em>: &lt;a href="https://kubernetes.io/docs/concepts/scheduling-eviction/dynamic-resource-allocation/">https://kubernetes.io/docs/concepts/scheduling-eviction/dynamic-resource-allocation/&lt;/a>.&lt;/li>
&lt;li>Kubernetes blog, &lt;em>v1.36: More Drivers, New Features, and the Next Era of DRA&lt;/em> (May 2026): &lt;a href="https://kubernetes.io/blog/2026/05/07/kubernetes-v1-36-dra-136-updates/">https://kubernetes.io/blog/2026/05/07/kubernetes-v1-36-dra-136-updates/&lt;/a>.&lt;/li>
&lt;li>DRANET (kubernetes-sigs), the network DRA driver and the paper &lt;em>The Kubernetes Network Driver Model&lt;/em> (+59.6% all_gather / +58.1% all_reduce): &lt;a href="https://github.com/kubernetes-sigs/dranet">https://github.com/kubernetes-sigs/dranet&lt;/a>.&lt;/li>
&lt;li>AKS Engineering, &lt;em>Optimizing RDMA performance for AI workloads on AKS with DRANET&lt;/em> (April 2026): &lt;a href="https://blog.aks.azure.com/2026/04/01/dranet-rdma-optimization-for-ai-on-aks">https://blog.aks.azure.com/2026/04/01/dranet-rdma-optimization-for-ai-on-aks&lt;/a>.&lt;/li>
&lt;li>Linux network tuning — IRQ affinity, RSS/RPS/RFS and NUMA softirq: &lt;a href="https://andreaskaris.github.io/blog/networking/rss-irq-affinity-and-rps/">https://andreaskaris.github.io/blog/networking/rss-irq-affinity-and-rps/&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The maître d' who only seats you if you all fit at one table: CPU, Memory and Topology Manager on RKE2</title><link>https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/</link><pubDate>Sat, 06 Jun 2026 08:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/</guid><description>&lt;blockquote>
&lt;p>The close of the &amp;ldquo;under the engine&amp;rdquo; series. We saw the &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">cable between GPUs&lt;/a> and the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">host: NUMA, hugepages and CPU isolation&lt;/a> done by hand. Here is the piece that makes it &lt;strong>declarative and at scale&lt;/strong>: how the RKE2 kubelet pins each vLLM pod to the right NUMA node without a single script.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Pinning NUMA with &lt;code>numactl&lt;/code>/&lt;code>isolcpus&lt;/code>/&lt;code>taskset&lt;/code>, the subject of the previous post, &lt;strong>does not scale&lt;/strong> to a cluster where pods come and go and there are dozens of nodes. The &lt;strong>kubelet&lt;/strong> automates it with three components that work as &lt;em>Hint Providers&lt;/em> for a central coordinator, the &lt;strong>Topology Manager&lt;/strong>: the &lt;strong>CPU Manager&lt;/strong> (assigns exclusive CPUs to containers of &lt;code>Guaranteed&lt;/code> pods with whole CPUs), the &lt;strong>Memory Manager&lt;/strong> (NUMA-local memory and hugepages) and the Device Manager/GPU plugin (which knows which GPU is on which NUMA node). With the &lt;strong>&lt;code>single-numa-node&lt;/code>&lt;/strong> policy, the Topology Manager only admits the pod if its CPUs, its memory and its GPU fit in the same NUMA domain; if they do not fit, it rejects the pod, strict admission, like the maître d&amp;rsquo; who will not seat a party of eight unless there is a table for eight. On RKE2 all of this is configured with &lt;code>kubelet-arg&lt;/code> in &lt;code>/etc/rancher/rke2/config.yaml&lt;/code>. This post explains the mechanism, gives the 10 knobs and takes apart the gotchas that break pinning silently: the &lt;code>cpu_manager_state&lt;/code> file you have to delete when changing policy, the QoS that has to be exactly &lt;code>Guaranteed&lt;/code>, and the &lt;code>reserved-cpus&lt;/code> that must match the host&amp;rsquo;s &lt;code>isolcpus&lt;/code>. On a generic RKE2 cluster with 4×H100 SXM nodes.&lt;/p>
&lt;h2 id="where-you-are-the-orchestration-that-materialises-the-host">Where you are: the orchestration that materialises the host&lt;/h2>
&lt;div class="diagram" style="max-width:560px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 560 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="You are at the orchestration layer: the kubelet materialises host pinning">
&lt;text x="280" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">The vertical stack · you are at orchestration&lt;/text>
&lt;rect x="120" y="40" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="64" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Engine · vLLM pod (TP, batching)&lt;/text>
&lt;rect x="120" y="84" width="320" height="58" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="3"/>
&lt;text x="280" y="108" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#1f5c34">YOU ARE HERE · kubelet: CPU/Memory/Topology Mgr&lt;/text>
&lt;text x="280" y="126" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#2c6b42">declarative pinning + NUMA admission&lt;/text>
&lt;rect x="120" y="150" width="320" height="38" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="280" y="174" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Host · NUMA, hugepages, isolcpus (post 2)&lt;/text>
&lt;rect x="120" y="194" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="218" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">CUDA + NCCL + NVLink (post 1)&lt;/text>
&lt;rect x="120" y="240" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="264" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Hardware · 2 sockets, 4×H100 SXM&lt;/text>
&lt;text x="280" y="300" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="#777">the kubelet turns declarative intent into the raw pinning below&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-maître-d-of-a-restaurant-whose-tables-cannot-be-pushed-together">The analogy: the maître d&amp;rsquo; of a restaurant whose tables cannot be pushed together&lt;/h2>
&lt;p>A restaurant has tables of different sizes and, house rule, &lt;strong>tables are never pushed together&lt;/strong>. A party of eight arrives. The maître d&amp;rsquo; looks for a single table where all eight fit. If there is one, he seats them; if only tables for four are left, &lt;strong>he does not accept them&lt;/strong>. He would rather turn the booking away than seat the group split across two separate tables, because he knows a split dinner goes badly.&lt;/p>
&lt;p>That maître d&amp;rsquo; is the &lt;strong>Topology Manager&lt;/strong> under the &lt;code>single-numa-node&lt;/code> policy. The &amp;ldquo;party&amp;rdquo; is an inference pod asking for CPUs, memory and a GPU. The &amp;ldquo;table&amp;rdquo; is a NUMA node. The maître d&amp;rsquo; asks three assistants (are there free CPUs on any node? the CPU Manager; is there free memory? the Memory Manager; is there a free GPU? the Device Manager) and only admits the pod if all three resources fit on the same node. If not, he rejects it (the pod ends up &lt;code>Failed&lt;/code> with &lt;code>TopologyAffinityError&lt;/code>), and the scheduler will try another node.&lt;/p>
&lt;p>The difference from the previous post: there you were the maître d&amp;rsquo;, seating each process by hand with &lt;code>numactl&lt;/code>. Here the maître d&amp;rsquo; is the kubelet, and it does it for &lt;strong>every pod, on every node, automatically, rejecting whatever does not fit&lt;/strong>. That is what turns artisanal pinning into a declarative property of the cluster.&lt;/p>
&lt;h2 id="the-mechanism-hint-providers-and-the-coordinator">The mechanism: Hint Providers and the coordinator&lt;/h2>
&lt;p>The Topology Manager does not assign resources; it coordinates the ones that do. The flow, when a &lt;code>Guaranteed&lt;/code> pod arrives at a node:&lt;/p>
&lt;div class="diagram" style="max-width:800px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 800 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Topology Manager coordinates CPU, Memory and Device Manager">
&lt;defs>&lt;marker id="km" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="400" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">Admission of a Guaranteed pod · single-numa-node&lt;/text>
&lt;rect x="320" y="44" width="160" height="50" rx="9" fill="#e6ddf3" stroke="#7a5aa5" stroke-width="2"/>
&lt;text x="400" y="66" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">Topology Manager&lt;/text>
&lt;text x="400" y="83" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">coordinator · admits/rejects&lt;/text>
&lt;!-- hint providers -->
&lt;rect x="60" y="150" width="180" height="64" rx="8" fill="#dceede" stroke="#3c8c54" stroke-width="1.6"/>
&lt;text x="150" y="174" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">CPU Manager&lt;/text>
&lt;text x="150" y="191" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">static: exclusive CPUs&lt;/text>
&lt;text x="150" y="205" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">hint: cores on node X?&lt;/text>
&lt;rect x="310" y="150" width="180" height="64" rx="8" fill="#f7efda" stroke="#c79a32" stroke-width="1.6"/>
&lt;text x="400" y="174" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">Memory Manager&lt;/text>
&lt;text x="400" y="191" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">Static: cpuset.mems&lt;/text>
&lt;text x="400" y="205" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">hint: RAM/hugepages?&lt;/text>
&lt;rect x="560" y="150" width="180" height="64" rx="8" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.6"/>
&lt;text x="650" y="174" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">Device Manager&lt;/text>
&lt;text x="650" y="191" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">NVIDIA GPU plugin&lt;/text>
&lt;text x="650" y="205" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">hint: GPU on node X?&lt;/text>
&lt;path d="M360,94 L170,150" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#km)"/>
&lt;path d="M400,94 L400,150" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#km)"/>
&lt;path d="M440,94 L630,150" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#km)"/>
&lt;text x="150" y="240" text-anchor="middle" font-family="sans-serif" font-size="9" font-style="italic" fill="#888">NUMA hint&lt;/text>
&lt;text x="400" y="240" text-anchor="middle" font-family="sans-serif" font-size="9" font-style="italic" fill="#888">NUMA hint&lt;/text>
&lt;text x="650" y="240" text-anchor="middle" font-family="sans-serif" font-size="9" font-style="italic" fill="#888">NUMA hint&lt;/text>
&lt;path d="M170,214 L380,255" fill="none" stroke="#999" stroke-width="1.2" marker-end="url(#km)" stroke-dasharray="3 2"/>
&lt;path d="M400,214 L400,255" fill="none" stroke="#999" stroke-width="1.2" marker-end="url(#km)" stroke-dasharray="3 2"/>
&lt;path d="M630,214 L420,255" fill="none" stroke="#999" stroke-width="1.2" marker-end="url(#km)" stroke-dasharray="3 2"/>
&lt;rect x="250" y="262" width="300" height="56" rx="9" fill="#f4f4f4" stroke="#444" stroke-width="1.6"/>
&lt;text x="400" y="284" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">Do the three hints meet on 1 node?&lt;/text>
&lt;text x="335" y="304" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="700" fill="#1f5c34">YES → admit&lt;/text>
&lt;text x="470" y="304" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="700" fill="#a85454">NO → reject&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The three managers are &lt;strong>Hint Providers&lt;/strong>: each one tells the Topology Manager on which NUMA node(s) it could satisfy its part. The Topology Manager computes the intersection and, depending on the policy, decides:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>none&lt;/code>&lt;/strong> (default): no coordination; each manager does its own thing without aligning. No NUMA guarantee.&lt;/li>
&lt;li>&lt;strong>&lt;code>best-effort&lt;/code>&lt;/strong>: it tries to align on one node; if it cannot, it admits anyway (on whichever node). Better than nothing, no guarantee.&lt;/li>
&lt;li>&lt;strong>&lt;code>restricted&lt;/code>&lt;/strong>: if it fails to align, it rejects the pod. Strict, but it allows multi-node affinity if the intersection provides it.&lt;/li>
&lt;li>&lt;strong>&lt;code>single-numa-node&lt;/code>&lt;/strong>: it requires everything to fit on a single NUMA node, or it rejects. The strictest one, and the one that genuinely guarantees the locality of the previous post.&lt;/li>
&lt;/ul>
&lt;p>And two &lt;strong>preconditions&lt;/strong> without which none of this kicks in:&lt;/p>
&lt;ol>
&lt;li>The pod has to be &lt;strong>QoS &lt;code>Guaranteed&lt;/code>&lt;/strong>: &lt;code>requests == limits&lt;/code> on CPU and memory, and whole CPUs (not &lt;code>500m&lt;/code>). Only then does the CPU Manager assign exclusive CPUs.&lt;/li>
&lt;li>The &lt;strong>CPU Manager&lt;/strong> has to be on the &lt;strong>&lt;code>static&lt;/code>&lt;/strong> policy (not &lt;code>none&lt;/code>).&lt;/li>
&lt;/ol>
&lt;p>Without those two, the Topology Manager has nothing to align and pinning &lt;strong>does not happen&lt;/strong>, even with the policy in place. That is gotcha no. 1.&lt;/p>
&lt;h2 id="how-it-is-configured-on-rke2">How it is configured on RKE2&lt;/h2>
&lt;p>RKE2 passes arguments to the kubelet with the &lt;strong>&lt;code>kubelet-arg&lt;/code>&lt;/strong> key in &lt;code>/etc/rancher/rke2/config.yaml&lt;/code>. The reference configuration for GPU inference nodes:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># /etc/rancher/rke2/config.yaml (on every agent node with GPUs)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kubelet-arg&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;cpu-manager-policy=static&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;topology-manager-policy=single-numa-node&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;topology-manager-scope=pod&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;memory-manager-policy=Static&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;reserved-cpus=0-1,64-65&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># housekeeping; must match the host&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;system-reserved=memory=8Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;kube-reserved=memory=4Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;reserved-memory=0:memory=4Gi;1:memory=4Gi&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># required by Memory Manager Static&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">node-label&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;fibercli.local/numa-pinned=true&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>After deploying it: &lt;code>systemctl restart rke2-agent&lt;/code>. &lt;strong>Critical gotcha&lt;/strong>: if the node has already run with &lt;code>cpu-manager-policy=none&lt;/code>, there is a state file &lt;code>/var/lib/kubelet/cpu_manager_state&lt;/code> that pins the old policy; changing the arg without deleting that file makes the kubelet fail to start or ignore the new policy. You have to stop the agent, &lt;code>rm /var/lib/kubelet/cpu_manager_state&lt;/code>, and start again. (The same applies to &lt;code>memory_manager_state&lt;/code>.)&lt;/p>
&lt;p>And the vLLM pod, to be eligible, &lt;strong>Guaranteed with whole CPUs&lt;/strong>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># whole, not a weird fractional &amp;#34;16000m&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;200Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;2&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># TP=2 → 2 GPUs on the same NUMA node&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">hugepages-1Gi&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># == requests → Guaranteed QoS&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;200Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;2&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">hugepages-1Gi&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With this, on a node with the config above, the kubelet assigns 16 exclusive CPUs from the NUMA node where the 2 requested GPUs live, its local memory and the hugepages, or it rejects the pod if they do not fit together. The artisanal pinning of the previous post, now declarative.&lt;/p>
&lt;h2 id="the-10-knobs-to-reach-for">The 10 knobs to reach for&lt;/h2>
&lt;p>Ordered by dependency (the first ones are a precondition for the later ones). The canonical reference is the &lt;a href="https://kubernetes.io/docs/tasks/administer-cluster/topology-manager/">Kubernetes Topology Manager documentation&lt;/a> and the &lt;a href="https://docs.rke2.io/install/configuration">RKE2 configuration&lt;/a>.&lt;/p>
&lt;h3 id="knob-1--cpu-manager-policystatic-the-foundation">Knob 1 — &lt;code>cpu-manager-policy=static&lt;/code>: the foundation&lt;/h3>
&lt;p>Without this there are no exclusive CPUs and nothing else kicks in.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">kubelet-arg&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;cpu-manager-policy=static&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Gotcha&lt;/strong>: changing it requires deleting &lt;code>/var/lib/kubelet/cpu_manager_state&lt;/code> and restarting the kubelet, or startup fails. It is cause no. 1 of &amp;ldquo;I set the policy and it does not pin&amp;rdquo;.&lt;/p>
&lt;h3 id="knob-2--guaranteed-qos--whole-cpus-the-pods-precondition">Knob 2 — &lt;code>Guaranteed&lt;/code> QoS + whole CPUs: the pod&amp;rsquo;s precondition&lt;/h3>
&lt;p>This is not node config, it is &lt;strong>pod&lt;/strong> config, but without it knob 1 does nothing for that pod. &lt;code>requests == limits&lt;/code> on CPU and memory, and whole CPUs. A &lt;code>cpu: 500m&lt;/code> or a &lt;code>requests != limits&lt;/code> downgrades the pod to &lt;code>Burstable&lt;/code> and it loses the pinning. Plenty of people set the node policy and forget the pod&amp;rsquo;s QoS.&lt;/p>
&lt;h3 id="knob-3--topology-manager-policysingle-numa-node-strict-admission">Knob 3 — &lt;code>topology-manager-policy=single-numa-node&lt;/code>: strict admission&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">kubelet-arg&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;topology-manager-policy=single-numa-node&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The strict maître d&amp;rsquo;. For GPU inference it is the right policy: it guarantees that CPU+memory+GPU share a node. &lt;code>best-effort&lt;/code> guarantees nothing (it admits misaligned); &lt;code>restricted&lt;/code> allows multi-node affinity. Start with &lt;code>single-numa-node&lt;/code> and drop to &lt;code>restricted&lt;/code> only if you have admission problems.&lt;/p>
&lt;h3 id="knob-4--topology-manager-scopepod-group-the-whole-pod">Knob 4 — &lt;code>topology-manager-scope=pod&lt;/code>: group the whole pod&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">kubelet-arg&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;topology-manager-scope=pod&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With &lt;code>container&lt;/code> scope (the default), each container is aligned separately; with &lt;code>pod&lt;/code> scope, the whole pod goes to the same node. For a vLLM pod with sidecars (metrics, proxy), &lt;code>pod&lt;/code> scope stops the sidecar from dragging the main container onto another node. Recommended for inference.&lt;/p>
&lt;h3 id="knob-5--memory-manager-policystatic--reserved-memory-numa-local-memory">Knob 5 — &lt;code>memory-manager-policy=Static&lt;/code> + &lt;code>reserved-memory&lt;/code>: NUMA-local memory&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">kubelet-arg&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;memory-manager-policy=Static&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;reserved-memory=0:memory=4Gi;1:memory=4Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Memory Manager Static forces &lt;code>cpuset.mems&lt;/code> so that the pod&amp;rsquo;s memory comes out of the right node (and so do the hugepages). It requires declaring &lt;code>reserved-memory&lt;/code> per node, or the kubelet does not start. It is the declarative equivalent of the previous post&amp;rsquo;s &lt;code>--membind&lt;/code>.&lt;/p>
&lt;h3 id="knob-6--reserved-cpus-the-housekeeping-cores-must-match-isolcpus">Knob 6 — &lt;code>reserved-cpus&lt;/code>: the housekeeping cores (must match &lt;code>isolcpus&lt;/code>)&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">kubelet-arg&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;reserved-cpus=0-1,64-65&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It reserves cores for the system and the daemons; the rest are left for exclusive pods. &lt;strong>Key point of the series&lt;/strong>: these &lt;code>reserved-cpus&lt;/code> must be the same cores you left out of &lt;code>isolcpus&lt;/code> on the host (previous post). If the host isolates 2-31 but RKE2 reserves 0-3, there is a mismatch: isolated cores that the kubelet assigns to pods without them actually being quiet. Coordinate the two layers.&lt;/p>
&lt;h3 id="knob-7--gpu-plugin-with-numa-topology-nvidia-gpu-operator">Knob 7 — GPU plugin with NUMA topology (NVIDIA GPU Operator)&lt;/h3>
&lt;p>The Device Manager can only give a correct NUMA hint if the &lt;strong>GPU plugin exposes which node each GPU is on&lt;/strong>. The NVIDIA device plugin / GPU Operator does this, but you have to verify that the topology information arrives (in some versions it requires flags). Without a GPU hint, the Topology Manager aligns CPU and memory but not the GPU, and GPU locality is precisely the one that matters most.&lt;/p>
&lt;h3 id="knob-8--hugepages-as-a-pod-resource">Knob 8 — hugepages as a pod resource&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">hugepages-1Gi&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16Gi&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># the node must have them pre-reserved (post 2, knob 4)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The hugepages you reserved at host boot (previous post) are requested as a resource. The Memory Manager assigns them NUMA-local. If you request them without having reserved them on the node, the pod does not get scheduled.&lt;/p>
&lt;h3 id="knob-9--system-reserved--kube-reserved-do-not-oversubscribe">Knob 9 — &lt;code>system-reserved&lt;/code> / &lt;code>kube-reserved&lt;/code>: do not oversubscribe&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">kubelet-arg&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;system-reserved=cpu=500m,memory=8Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;kube-reserved=cpu=500m,memory=4Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It reserves resources for the system and the K8s components so the node does not run out of air under load. Badly calibrated, either the node chokes (too little reserved) or you waste capacity (too much). It must be consistent with &lt;code>reserved-cpus&lt;/code>.&lt;/p>
&lt;h3 id="knob-10--labels--taints-so-vllm-lands-here-and-nothing-else-does">Knob 10 — Labels + taints: so vLLM lands here and nothing else does&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># GPU node: taint to repel anything that does not need a GPU&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">node-taint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;nvidia.com/gpu=present:NoSchedule&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">node-label&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;fibercli.local/numa-pinned=true&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Keep the NUMA-pinned nodes for inference and push out whatever does not need it (databases, &lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">the Langfuse backend&lt;/a>, runners). A ClickHouse stealing memory bandwidth from a carefully pinned vLLM pod throws away all the work of the previous nine knobs. Workload isolation is the closing move.&lt;/p>
&lt;h3 id="summary-table">Summary table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>Where&lt;/th>
&lt;th>Function&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>cpu-manager-policy=static&lt;/code>&lt;/td>
&lt;td>kubelet-arg&lt;/td>
&lt;td>exclusive CPUs (foundation)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>&lt;code>Guaranteed&lt;/code> QoS + whole CPUs&lt;/td>
&lt;td>pod spec&lt;/td>
&lt;td>pinning precondition&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>&lt;code>topology-manager-policy=single-numa-node&lt;/code>&lt;/td>
&lt;td>kubelet-arg&lt;/td>
&lt;td>strict NUMA admission&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>&lt;code>topology-manager-scope=pod&lt;/code>&lt;/td>
&lt;td>kubelet-arg&lt;/td>
&lt;td>group the whole pod&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>&lt;code>memory-manager-policy=Static&lt;/code>&lt;/td>
&lt;td>kubelet-arg&lt;/td>
&lt;td>NUMA-local memory/hugepages&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>&lt;code>reserved-cpus&lt;/code>&lt;/td>
&lt;td>kubelet-arg&lt;/td>
&lt;td>housekeeping (match isolcpus)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>GPU plugin with topology&lt;/td>
&lt;td>GPU Operator&lt;/td>
&lt;td>NUMA hint for the GPU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>&lt;code>hugepages-1Gi&lt;/code>&lt;/td>
&lt;td>pod spec&lt;/td>
&lt;td>hugepages as a resource&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>&lt;code>system/kube-reserved&lt;/code>&lt;/td>
&lt;td>kubelet-arg&lt;/td>
&lt;td>do not oversubscribe&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>taints + labels&lt;/td>
&lt;td>node config&lt;/td>
&lt;td>isolate GPU workloads&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="verifying-that-the-pinning-really-happened">Verifying that the pinning really happened&lt;/h2>
&lt;p>Do not trust that the config &amp;ldquo;is in place&amp;rdquo;. Check:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Is the active policy the one you set?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">cat /var/lib/kubelet/cpu_manager_state &lt;span class="p">|&lt;/span> jq .policyName &lt;span class="c1"># &amp;#34;static&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Which exclusive CPUs does the container have?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl &lt;span class="nb">exec&lt;/span> &amp;lt;pod&amp;gt; -- cat /sys/fs/cgroup/cpuset.cpus.effective
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Inside the pod: is the assigned GPU local to those cores?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl &lt;span class="nb">exec&lt;/span> &amp;lt;pod&amp;gt; -- nvidia-smi topo -m
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Were there topology rejections?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl describe pod &amp;lt;pod&amp;gt; &lt;span class="p">|&lt;/span> grep -i TopologyAffinityError
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A pod in &lt;code>Failed&lt;/code> with &lt;code>TopologyAffinityError&lt;/code> is not a bug: it is the maître d&amp;rsquo; &lt;strong>doing his job&lt;/strong>. That node did not have a table where CPU+memory+GPU fitted together. The answer is to review the sizing of the pod or the node, not to relax the policy casually.&lt;/p>
&lt;h2 id="how-it-connects-with-the-rest-of-the-stack">How it connects with the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With the host (previous post).&lt;/strong> This post is the &lt;strong>declarative automation&lt;/strong> of that one. &lt;code>cpu-manager-policy=static&lt;/code> materialises the &lt;code>taskset&lt;/code>; &lt;code>memory-manager-policy=Static&lt;/code> materialises the &lt;code>--membind&lt;/code>; &lt;code>reserved-cpus&lt;/code> must match &lt;code>isolcpus&lt;/code>. The two layers are one single decision seen from two places: the host executes it, the kubelet declares it. Uncoordinating them (isolcpus 2-31 vs reserved-cpus 0-3) breaks both.&lt;/p>
&lt;p>&lt;strong>With the interconnect (post 1).&lt;/strong> The Topology Manager pins the right GPU to the pod, but if you ask for 2 GPUs for TP=2, you will want those two to share NVLink. The NUMA policy guarantees they are on the same socket; that they are NVLink-connected is guaranteed by the baseboard hardware (post 1, knob 1). The two things together are what makes &lt;code>TP=2&lt;/code> perform.&lt;/p>
&lt;p>&lt;strong>With autoscaling.&lt;/strong> When &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">KEDA scales vLLM pods&lt;/a>, each new replica goes through Topology Manager admission. If the node has no free &amp;ldquo;table&amp;rdquo;, the pod stays pending. Pod autoscaling and node autoscaling (cluster-autoscaler) have to account for NUMA granularity, not just aggregate CPU/memory.&lt;/p>
&lt;p>&lt;strong>With capacity planning.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Sizing&lt;/a> changes: it is not &amp;ldquo;128 vCPU per machine&amp;rdquo;, it is &amp;ldquo;128 minus the reserved-cpus, in blocks that fit per NUMA node&amp;rdquo;. A machine with 2 sockets × 64 cores does not serve a pod asking for 80 cores under single-numa-node: they do not fit at one table. Planning has to reason per NUMA node, not per machine.&lt;/p>
&lt;p>&lt;strong>With service coexistence.&lt;/strong> The knob 10 taint is what keeps &lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse&lt;/a>, databases and runners &lt;strong>off&lt;/strong> the inference nodes. Without that boundary, all the fine-grained pinning gets eaten by a noisy neighbour. Observability goes on its own nodes; inference, pinned, on its own.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>Policy in place, QoS forgotten.&lt;/strong> The most common mistake: &lt;code>cpu-manager-policy=static&lt;/code> on the node but the pod is &lt;code>Burstable&lt;/code> (&lt;code>requests != limits&lt;/code> or fractional CPU). Pinning &lt;strong>does not happen&lt;/strong> and nobody warns you. &lt;code>Guaranteed&lt;/code> QoS with whole CPUs is a necessary condition.&lt;/p>
&lt;p>&lt;strong>Fossilised &lt;code>cpu_manager_state&lt;/code>.&lt;/strong> Changing policy without deleting &lt;code>/var/lib/kubelet/cpu_manager_state&lt;/code> (and &lt;code>memory_manager_state&lt;/code>) makes the kubelet fail or ignore the change. Stop agent → delete file → start.&lt;/p>
&lt;p>&lt;strong>&lt;code>reserved-cpus&lt;/code> ≠ &lt;code>isolcpus&lt;/code>.&lt;/strong> If the host isolates some cores and RKE2 reserves others, the managers assign cores to pods that are not really quiet, or they leave isolated cores idle. The two lists have to be consistent. It is the coordination failure between the previous post and this one.&lt;/p>
&lt;p>&lt;strong>GPU plugin without NUMA topology.&lt;/strong> If the device plugin does not expose each GPU&amp;rsquo;s NUMA node, the Topology Manager aligns CPU and memory but leaves the GPU to chance, and GPU locality is the one that weighs most. Verify that the GPU Operator publishes the topology.&lt;/p>
&lt;p>&lt;strong>&lt;code>single-numa-node&lt;/code> that rejects too much.&lt;/strong> If the pods ask for more resources than fit on one node (for example, more cores than a socket has), rejection is constant. The answer is not to drop to &lt;code>best-effort&lt;/code> (which silences the problem by serving misaligned), but to &lt;strong>size the pod so it fits at one table&lt;/strong>, or to accept &lt;code>restricted&lt;/code> with full knowledge of what you are doing.&lt;/p>
&lt;p>&lt;strong>Believing &lt;code>best-effort&lt;/code> &amp;ldquo;is nearly the same&amp;rdquo;.&lt;/strong> &lt;code>best-effort&lt;/code> admits the pod even if it fails to align: it gives you a false sense of NUMA-awareness while you serve from the wrong socket. For inference with a tail SLO, &lt;code>single-numa-node&lt;/code> or &lt;code>restricted&lt;/code>; &lt;code>best-effort&lt;/code> only if the alternative is scheduling nothing at all.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>The previous post pinned by hand; this one does it at scale and with a guarantee that artisanal &lt;code>numactl&lt;/code> did not give: &lt;strong>strict admission&lt;/strong>. The kubelet, via CPU Manager, Memory Manager and Topology Manager, acts as a maître d&amp;rsquo; who only seats the pod if its CPUs, its memory and its GPU fit at the same NUMA table, and who rejects what does not fit instead of serving a split dinner. Of the ten knobs, the first two, &lt;strong>&lt;code>cpu-manager-policy=static&lt;/code>&lt;/strong> and &lt;strong>&lt;code>Guaranteed&lt;/code> QoS with whole CPUs&lt;/strong>, are the precondition without which the other eight do nothing, and they are exactly the ones most often forgotten; the rest tune the policy, the memory, the hugepages and the coexistence. The thread that closes the series: the inference performance that looked like an engine problem (vLLM is slow) or a model problem (quantisation) is, far too often, a cable problem (NVLink not used), a host problem (remote NUMA, jitter) or an orchestration problem (pinning that never happened because the QoS was wrong). Going down a level is not infrastructure snobbery: it is where the root causes live that no application-layer dashboard is ever going to point out for you.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing one GPU between several workloads: time-slicing, MPS and MIG&lt;/a> — splitting the GPU (time-slicing/MPS/MIG) is the accelerator&amp;rsquo;s equivalent of what these managers do for CPU and memory.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA, hugepages and CPU isolation&lt;/a> — the previous post; the raw layer (numactl, isolcpus, membind) that this one automates declaratively. &lt;code>reserved-cpus&lt;/code> here must match &lt;code>isolcpus&lt;/code> there.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink, NVSwitch and NCCL&lt;/a> — the first of the series; the NUMA policy pins the right GPU, but whether the GPUs of a TP group share NVLink is decided by the baseboard hardware.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">The on-premise LLM inference stack in seven layers&lt;/a> — the whole building; orchestration is the control plane layer that holds inference up.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLMs on Kubernetes with KEDA&lt;/a> — every replica KEDA creates goes through Topology Manager admission; autoscaling has to account for NUMA granularity.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise inference&lt;/a> — why sizing starts being reasoned per NUMA node, not per machine: a pod does not fit if it asks for more than one table.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside: v3 architecture and the 10 backend knobs&lt;/a> — the kind of workload that the knob 10 taints keep &lt;strong>off&lt;/strong> the pinned inference nodes.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a> — how to confirm, with metrics in hand, that the pinning translates into a saturated GPU with no bubbles.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">The kitchen door the maître d&amp;rsquo; never looked at: network NUMA, Cilium eBPF and DRANET&lt;/a> — the coda: the Topology Manager pins CPU+memory+GPU but &lt;strong>not the NIC&lt;/strong>; that fourth leg (network locality, IRQ affinity, DRA/DRANET) is the one left out of the census here.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — the NVMe also hangs off a PCIe root under a socket; its NUMA locality is the fifth list to align, and cold start is the real ceiling on the elasticity this pinning holds up.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Kubernetes, &lt;em>Control Topology Management Policies on a node&lt;/em>: &lt;a href="https://kubernetes.io/docs/tasks/administer-cluster/topology-manager/">https://kubernetes.io/docs/tasks/administer-cluster/topology-manager/&lt;/a>.&lt;/li>
&lt;li>Kubernetes, &lt;em>Control CPU Management Policies on the Node&lt;/em>: &lt;a href="https://kubernetes.io/docs/tasks/administer-cluster/cpu-management-policies/">https://kubernetes.io/docs/tasks/administer-cluster/cpu-management-policies/&lt;/a>.&lt;/li>
&lt;li>Kubernetes, &lt;em>Control Memory Management Policies on a Node&lt;/em>: &lt;a href="https://kubernetes.io/docs/tasks/administer-cluster/memory-manager/">https://kubernetes.io/docs/tasks/administer-cluster/memory-manager/&lt;/a>.&lt;/li>
&lt;li>RKE2, &lt;em>Configuration Options&lt;/em> (kubelet-arg in config.yaml): &lt;a href="https://docs.rke2.io/install/configuration">https://docs.rke2.io/install/configuration&lt;/a>.&lt;/li>
&lt;li>RKE2, &lt;em>Advanced Options and Configuration&lt;/em>: &lt;a href="https://docs.rke2.io/advanced">https://docs.rke2.io/advanced&lt;/a>.&lt;/li>
&lt;li>rancher/rke2, discussion #3034 &lt;em>CPU Management Policies for RKE2&lt;/em> (the cpu_manager_state gotcha): &lt;a href="https://github.com/rancher/rke2/discussions/3034">https://github.com/rancher/rke2/discussions/3034&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Autoscaling LLM inference on Kubernetes: HPA with custom metrics and KEDA for vLLM</title><link>https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/</link><pubDate>Mon, 01 Jun 2026 16:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/</guid><description>&lt;blockquote>
&lt;p>This post complements those on &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> (where the metrics that feed the HPA come from), &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning&lt;/a> (what ceiling and what head-room the autoscaler assumes) and &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> (which explains why &lt;code>num_requests_waiting&lt;/code> is the primary metric).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Classic Kubernetes autoscaling, HPA on &lt;code>cpu&lt;/code> or &lt;code>memory&lt;/code>, &lt;strong>is no use for LLM inference&lt;/strong>. The reason: the vLLM pod consumes little CPU (the GPU does the work) and the process RSS is flat; both metrics can sit at 30 % while the GPU is saturated and the request queue grows unchecked. The four viable signals that do respond to the real load are: &lt;strong>&lt;code>vllm:num_requests_waiting&lt;/code>&lt;/strong> (the queue, the primary metric), &lt;strong>&lt;code>vllm:gpu_cache_usage_perc&lt;/code>&lt;/strong> (pressure on the KV cache pool), &lt;strong>TTFT P95&lt;/strong> via the &lt;code>vllm:time_to_first_token_seconds_bucket&lt;/code> histogram (the SLO guarantee) and the &lt;strong>batch fill ratio&lt;/strong> &lt;code>num_requests_running / max_num_seqs&lt;/code> (utilisation of the concurrency ceiling). For an HPA to consume Prometheus metrics you need an adapter; as of May 2026 there are two mature options: &lt;strong>&lt;code>prometheus-adapter&lt;/code>&lt;/strong> (cluster-wide sigma, static configuration, &lt;code>external.metrics.k8s.io&lt;/code> output) and &lt;strong>KEDA&lt;/strong> (&lt;code>ScaledObject&lt;/code> with a Prometheus trigger, configurable polling, optional scale to zero, cron integration). KEDA is the dominant option for LLM on a generic cluster because it solves the &amp;ldquo;warm pool + cron + engine metric&amp;rdquo; pattern in a single CRD. The dominant operational challenge is not the scaling logic but the &lt;strong>cold start&lt;/strong>: a vLLM pod with Llama 70B BF16 (140 GB) takes between &lt;strong>90 seconds&lt;/strong> (model pre-cached on a local PV) and &lt;strong>6 minutes&lt;/strong> (image pull + model download from object store) to serve the first token. The five levers that cut it down are an image pre-pulled via DaemonSet, the model cached on a regional PV or tmpfs, a &lt;strong>warm pool&lt;/strong> with &lt;code>minReplicaCount &amp;gt; 0&lt;/code>, &lt;strong>predictive scaling&lt;/strong> via KEDA cron when the traffic pattern is predictable (offices 9–18 h), and parallel model download. The three specific LLM scale-down pitfalls: cutting streaming SSE connections halfway through a response (graceful drain with &lt;code>terminationGracePeriodSeconds&lt;/code> ≥ 60 s), scale-out/in oscillation from a badly calibrated stabilization window, and forgetting that the HPA only scales pods; &lt;strong>GPU nodes&lt;/strong> are scaled with cluster-autoscaler over labelled nodepools. This post includes the minimal YAML manifests.&lt;/p>
&lt;h2 id="you-are-here-deploy">You are here: DEPLOY&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Deploy">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#7ad88f;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#asm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#asm)}&lt;/style>
&lt;defs>&lt;marker id="asm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: DEPLOY · autoscaling on the metrics OBSERVE measures&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box active"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-bakery-with-wood-fired-ovens">The analogy: the bakery with wood-fired ovens&lt;/h2>
&lt;p>An artisan bakery has three wood-fired ovens. Each oven takes 25 minutes to reach temperature from cold. Once hot, it bakes bread continuously with a run of 18 minutes per batch. The manager wants to maximise bread sold per day without burning wood for nothing, and she knows three things: that there is a demand peak at 7:30 every morning, that Mondays sell almost nothing, and that when the bread on the counter runs out the customers go to the supermarket next door.&lt;/p>
&lt;p>The cheap strategy, lighting ovens when there is a queue in the shop, &lt;strong>does not work&lt;/strong>. By the time the queue grows and the manager lights the second oven, that oven will not be ready until 25 minutes later; the customers in that window are lost. The &amp;ldquo;queue at the counter&amp;rdquo; signal arrives late.&lt;/p>
&lt;p>The smart strategy: light the second oven at 6:55, ahead of the predictable 7:30 peak, and leave it running until 10:00 even though the queue drops at 8:15. Keep the third oven off from Monday to Wednesday because demand does not arrive; light it proactively on Thursdays at 12:00 because historically it goes up. Keep a stock of pre-proved raw dough in the chiller so that when the oven is ready the bread goes in within 30 seconds and there is no two-hour proving wait.&lt;/p>
&lt;p>Autoscaling an LLM inference cluster works the same way:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Lighting ovens from cold&lt;/strong> = reactive scale-out when the queue grows (slow, loses customers).&lt;/li>
&lt;li>&lt;strong>Proactive cron&lt;/strong> = predictive scaling when the pattern is known (working hours, expected peaks).&lt;/li>
&lt;li>&lt;strong>Pre-proved dough&lt;/strong> = warm pool of replicas with the model loaded but under 0 load.&lt;/li>
&lt;li>&lt;strong>Switching off ovens with no bread inside&lt;/strong> = scale-down respecting the active streams (you do not shut the oven with bread in it).&lt;/li>
&lt;/ul>
&lt;p>The key metric, &amp;ldquo;how many customers are in the queue&amp;rdquo;, is called &lt;code>num_requests_waiting&lt;/code>. The metric that says &amp;ldquo;the oven is about to run out of dough for new loaves&amp;rdquo; is called &lt;code>gpu_cache_usage_perc&lt;/code>. And the quality-of-service metric, &amp;ldquo;how long the first loaf takes to come out when a new customer walks in&amp;rdquo;, is called TTFT.&lt;/p>
&lt;h2 id="why-hpa-on-cpu-is-no-use">Why HPA on CPU is no use&lt;/h2>
&lt;p>The classic Kubernetes HPA looks at the pod&amp;rsquo;s &lt;code>resource.cpu&lt;/code>. For a conventional HTTP service, Node.js or a REST API, CPU moves linearly with traffic and the HPA scales with reasonable accuracy. For a vLLM or SGLang pod on GPU, the pod&amp;rsquo;s CPU typically lives between 5 % and 15 % regardless of whether the GPU is at 30 % or 99 % load: the real work is done by the device, not the process. Result: the CPU-based HPA &lt;strong>never&lt;/strong> triggers scale-out even though the GPU is bursting, and clients pile up in the queue until TTFT P95 crosses the SLO. The operator discovers the problem through the TTFT alert, not through the HPA.&lt;/p>
&lt;p>&lt;code>memory&lt;/code> is no use either: the vLLM process RSS is flat after startup (model plus buffers loaded in one go); it does not reflect the real pressure on the GPU. The only things that rise and fall with the useful inference load are metrics the engine publishes explicitly: request queue, KV cache pool, SLO latencies. Without an adapter that makes them visible to the HPA, autoscaling is blind.&lt;/p>
&lt;h2 id="the-four-viable-signals">The four viable signals&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="four LLM autoscaling signals">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.p{fill:#dfe9f5;stroke:#356}.s{fill:#eef0d0;stroke:#7a3}.t{fill:#f4e3cf;stroke:#a63}.f{fill:#ead8f5;stroke:#634}.title{font:600 13px sans-serif;fill:#222}.h{font:700 12px sans-serif;fill:#222}.m{font:11px monospace;fill:#222}.n{font:italic 11px sans-serif;fill:#444}&lt;/style>
&lt;text x="410" y="20" text-anchor="middle" class="title">The four metrics that feed the LLM HPA&lt;/text>
&lt;rect x="30" y="40" width="370" height="120" class="b p"/>
&lt;text x="40" y="62" class="h">1 · QUEUE (PRIMARY)&lt;/text>
&lt;text x="40" y="82" class="m">vllm:num_requests_waiting&lt;/text>
&lt;text x="40" y="105" class="n">Any requests waiting to enter the batch?&lt;/text>
&lt;text x="40" y="125" class="n">Reacts instantly. Robust to model changes.&lt;/text>
&lt;text x="40" y="145" class="n">Typical HPA threshold: target = 5 (scale-out if &amp;gt; 5 sustained).&lt;/text>
&lt;rect x="420" y="40" width="370" height="120" class="b s"/>
&lt;text x="430" y="62" class="h">2 · KV CACHE POOL&lt;/text>
&lt;text x="430" y="82" class="m">vllm:gpu_cache_usage_perc&lt;/text>
&lt;text x="430" y="105" class="n">How much KV cache VRAM is in use?&lt;/text>
&lt;text x="430" y="125" class="n">Predictive: warns before the queue starts.&lt;/text>
&lt;text x="430" y="145" class="n">Typical threshold: target = 0.85 (scale-out if &amp;gt; 0.85).&lt;/text>
&lt;rect x="30" y="170" width="370" height="120" class="b t"/>
&lt;text x="40" y="192" class="h">3 · TTFT P95 (SLO)&lt;/text>
&lt;text x="40" y="212" class="m">histogram_quantile(0.95,&lt;/text>
&lt;text x="40" y="226" class="m"> rate(vllm:time_to_first_token_seconds_bucket[5m]))&lt;/text>
&lt;text x="40" y="246" class="n">The contractual guarantee to the customer.&lt;/text>
&lt;text x="40" y="266" class="n">Backup for the other two; reacts late but defends the SLO.&lt;/text>
&lt;rect x="420" y="170" width="370" height="120" class="b f"/>
&lt;text x="430" y="192" class="h">4 · BATCH FILL RATIO&lt;/text>
&lt;text x="430" y="212" class="m">vllm:num_requests_running&lt;/text>
&lt;text x="430" y="226" class="m"> / max_num_seqs (config)&lt;/text>
&lt;text x="430" y="246" class="n">Utilisation of the engine's concurrency ceiling.&lt;/text>
&lt;text x="430" y="266" class="n">Useful for scale-down: if ratio &amp;lt; 0.4 sustained, a replica is spare.&lt;/text>
&lt;text x="410" y="310" text-anchor="middle" class="n">Recommended policy: queue as primary, KV cache as secondary, TTFT as guardrail&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>&lt;strong>Signal 1, &lt;code>vllm:num_requests_waiting&lt;/code> (queue).&lt;/strong> It is the most direct metric: how many requests are waiting to enter the batch. It reacts the instant the target concurrency saturates. It is robust against model changes (the number of requests is the same concept whether it is Llama 7B or 70B). It is the &lt;strong>primary metric&lt;/strong> of the LLM HPA. Typical threshold: &lt;code>target = 5&lt;/code> requests waiting on average; if the queue grows above 5 sustained for 2 minutes, scale out.&lt;/p>
&lt;p>&lt;strong>Signal 2, &lt;code>vllm:gpu_cache_usage_perc&lt;/code> (KV pool).&lt;/strong> It moves &lt;strong>before&lt;/strong> the queue does: the KV pool fills up while the batch slots are still free, until the engine starts rejecting new requests for OOM prevention and the queue forms. It is therefore &lt;strong>predictive&lt;/strong>: it triggers scale-out before the client notices degradation. Typical threshold: &lt;code>target = 0.85&lt;/code> (85 % of pool used).&lt;/p>
&lt;p>&lt;strong>Signal 3, TTFT P95.&lt;/strong> The contractual guarantee. If TTFT P95 leaves the SLO, scale out even though the queue and the KV pool look reasonable (there may be a spike of long prompts). It is &lt;strong>reactive&lt;/strong>, it leaves the SLO before your HPA reacts, but it works as a final guardrail.&lt;/p>
&lt;p>&lt;strong>Signal 4, batch fill ratio.&lt;/strong> The &lt;code>num_requests_running / max_num_seqs&lt;/code> quotient (the latter is engine config, not a metric). Useful for &lt;strong>scale-down&lt;/strong>: if the ratio stays below 0.4 for 10 minutes, there is spare capacity and replicas can be reduced safely.&lt;/p>
&lt;p>The recommended policy combines all four: the queue and the KV pool trigger scale-out (whichever arrives first), TTFT confirms it as a guardrail, and the batch fill ratio manages scale-down. Implementing that in a single HPA demands external metrics; KEDA makes this manageable.&lt;/p>
&lt;h2 id="the-wiring-keda-as-the-prometheus-adapter">The wiring: KEDA as the Prometheus adapter&lt;/h2>
&lt;p>KEDA introduces two main CRDs: &lt;code>TriggerAuthentication&lt;/code> (how to authenticate against the source) and &lt;code>ScaledObject&lt;/code> (which deployment to scale with which triggers). For a vLLM deployment with Prometheus as the source:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">keda.sh/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ScaledObject&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b-scaler&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleTargetRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># warm pool&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">20&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pollingInterval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">15&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cooldownPeriod&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">300&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 5 min before scale-down&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">advanced&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">horizontalPodAutoscalerConfig&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">behavior&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleDown&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stabilizationWindowSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># large window to avoid oscillation&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policies&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Pods&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleUp&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stabilizationWindowSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policies&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Pods&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">triggers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serverAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://prometheus.observability.svc:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm_queue_depth&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;5&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> avg(vllm:num_requests_waiting{deployment=&amp;#34;vllm-llama70b&amp;#34;})&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serverAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://prometheus.observability.svc:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm_kv_cache&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0.85&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> avg(vllm:gpu_cache_usage_perc{deployment=&amp;#34;vllm-llama70b&amp;#34;})&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serverAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://prometheus.observability.svc:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm_ttft_p95&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1.5&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> histogram_quantile(0.95,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> sum by(le)(rate(vllm:time_to_first_token_seconds_bucket{deployment=&amp;#34;vllm-llama70b&amp;#34;}[5m])))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Three non-obvious operational details:&lt;/p>
&lt;p>&lt;strong>&lt;code>minReplicaCount: 2&lt;/code>.&lt;/strong> This is the warm pool. Keeping at least two replicas guarantees availability if a node is lost and absorbs spikes without waiting for the cold start of the first scale-up. Dropping it to 0 saves GPU off-peak but introduces 90 s–6 min of latency for the first new client.&lt;/p>
&lt;p>&lt;strong>&lt;code>stabilizationWindowSeconds: 600&lt;/code> on scale-down.&lt;/strong> Ten minutes. Models are not &lt;code>nginx&lt;/code>: if a replica closes prematurely and two minutes later there is another peak, the cold start of a new pod is what the client waits. Better to keep extra replicas around for twice as long as you would for a normal web service.&lt;/p>
&lt;p>&lt;strong>&lt;code>scaleUp: stabilizationWindowSeconds: 30&lt;/code>.&lt;/strong> Thirty seconds. Scale-out has to be fast: the new pod&amp;rsquo;s cold start adds its own delay, and if on top of that the HPA waits several more minutes before triggering, the SLO is already broken.&lt;/p>
&lt;h2 id="the-big-operational-problem-cold-start">The big operational problem: cold start&lt;/h2>
&lt;p>A vLLM pod loading Llama 70B goes through these phases before serving the first token:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Phase&lt;/th>
&lt;th>Typical time&lt;/th>
&lt;th>Can be accelerated with&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Image pull (4–6 GB)&lt;/td>
&lt;td>30–90 s&lt;/td>
&lt;td>DaemonSet pre-pull&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Model download (140 GB BF16)&lt;/td>
&lt;td>60–300 s&lt;/td>
&lt;td>Cached regional PV, S3 + multi-thread&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Loading the model into HBM&lt;/td>
&lt;td>30–90 s&lt;/td>
&lt;td>tmpfs or local NVMe&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CUDA graph capture&lt;/td>
&lt;td>20–60 s&lt;/td>
&lt;td>&lt;code>--enforce-eager&lt;/code> (slower at runtime but fast startup)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PagedAttention warmup&lt;/td>
&lt;td>5–15 s&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Health check ready&lt;/td>
&lt;td>10–30 s&lt;/td>
&lt;td>probe tuning&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Total without optimisation: 4–10 minutes.&lt;/strong> That is how long a new replica takes to absorb traffic. With all the levers combined: &lt;strong>45–90 seconds&lt;/strong>. The difference between those two numbers is the main platform work for LLM autoscaling.&lt;/p>
&lt;h3 id="the-five-levers">The five levers&lt;/h3>
&lt;p>&lt;strong>Lever 1, pre-pulled image.&lt;/strong> A trivial DaemonSet runs &lt;code>ctr image pull&lt;/code> (or &lt;code>crictl pull&lt;/code>) on the GPU nodes as soon as they join the cluster. The inference engine image stays on disk; new pods skip the 30–90 s of pull. Cost: around 6 GB of disk per node.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apps/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DaemonSet&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-image-warmer }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-warmer } }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-warmer } }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">workload&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gpu }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">initContainers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pull&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:v0.10.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;/bin/true&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pause&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">registry.k8s.io/pause:3.10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Lever 2, model on a regional PV.&lt;/strong> The model download (140 GB BF16 or 35 GB FP8) from central object storage is the dominant component of the cold start. Caching the model on a &lt;strong>zone or rack PV&lt;/strong>, Rook-Ceph RBD or local NVMe provisioned by the operator, cuts 60–300 s down to 5–15 s. The antipattern: downloading the model on every startup from external S3.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">model-cache&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">readOnly&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">model-cache&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">persistentVolumeClaim&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">claimName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama70b-fp8-pvc &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># RWX shared, filled offline&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Lever 3, warm pool.&lt;/strong> &lt;code>minReplicaCount &amp;gt; 0&lt;/code> keeps pre-loaded replicas idle. The cost is idle GPU; the benefit is 0 s of cold start for the first client of a peak. For production clusters with continuous traffic: a warm pool of 2–3 replicas. For nightly batch clusters with 0 traffic: warm pool 0 and accept the cold start, or KEDA with a cron that switches on 10 minutes earlier.&lt;/p>
&lt;p>&lt;strong>Lever 4, predictive scaling with cron.&lt;/strong> When the pattern is predictable (offices 9–18 h):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">triggers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cron&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timezone&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Europe/Madrid&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">start&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;30 8 * * 1-5&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 8:30 Monday–Friday&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">end&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0 19 * * 1-5&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 19:00&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">desiredReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;6&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Combined with reactive triggers. The HPA scales according to the maximum of the signals: if the cron asks for 6 and the queue asks for 10, the result is 10.&lt;/p>
&lt;p>&lt;strong>Lever 5, parallel download and an efficient format.&lt;/strong> For PVs that are not pre-loaded, tools such as &lt;code>nvidia-modelmanager&lt;/code>, &lt;code>s5cmd&lt;/code> or &lt;code>aria2c&lt;/code> parallelise the model download. Going from serial download (~150 MB/s) to parallel with 8 threads (~1.2 GB/s) divides the time by 8. And formats such as &lt;strong>safetensors&lt;/strong> load into HBM faster than the original &lt;strong>PyTorch pickle&lt;/strong>.&lt;/p>
&lt;h2 id="when-to-scale-nodes-not-just-pods">When to scale nodes, not just pods&lt;/h2>
&lt;p>The HPA scales &lt;strong>pods&lt;/strong>. If the cluster has no free GPU nodes, the new pod stays &lt;code>Pending&lt;/code> for lack of resources. To scale &lt;strong>nodes&lt;/strong>, you need cluster-autoscaler with a specific GPU nodepool, labelled:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># nodepool config (Karpenter or cluster-autoscaler equivalent)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">workload&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gpu&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">gpu-model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">h100-sxm-80gb&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">taints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nvidia.com/gpu&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">effect&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NoSchedule&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">min&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nodes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nodes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Without this, the HPA can ask for 10 replicas but the cluster only delivers those that fit on nodes already up. The cold start of a new node (bare metal or cloud provisioning, PXE, OS boot, NVIDIA drivers, cluster join) is &lt;strong>much&lt;/strong> longer than the cold start of a pod: typically 5–15 minutes on preconfigured bare metal, 30–60 minutes on real provisioning. For on-premise clusters, the nodepool must be &lt;strong>always sized to the expected maximum&lt;/strong>, and the &amp;ldquo;scaling&amp;rdquo; is only on the pod side. The concept of reactive node scale-out only applies to clouds; on-premise you have to buy for the peak.&lt;/p>
&lt;h2 id="three-pitfalls-specific-to-llm-scale-down">Three pitfalls specific to LLM scale-down&lt;/h2>
&lt;p>&lt;strong>Pitfall 1, cutting streaming SSE connections.&lt;/strong> When a replica enters &lt;code>Terminating&lt;/code>, Kubernetes sends SIGTERM to the pod and, by default, kills it 30 seconds later. For vLLM that means &lt;strong>cutting streaming SSE connections halfway through the response&lt;/strong>. The client gets a 502 error with the partial output lost. Solution: &lt;code>terminationGracePeriodSeconds: 120&lt;/code> plus a preStop hook that tells the engine not to accept new requests but to finish those in flight:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">terminationGracePeriodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">lifecycle&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">preStop&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/shutdown&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This requires the engine to expose a graceful shutdown endpoint; vLLM v1 supports it via &lt;code>--enable-graceful-shutdown&lt;/code>. Without it, scale-down breaks the SLO even though the metrics do not capture it (cut requests never enter the TTFT histogram).&lt;/p>
&lt;p>&lt;strong>Pitfall 2, scale-up/scale-down oscillation.&lt;/strong> If the scale-down &lt;code>stabilizationWindowSeconds&lt;/code> is short (~60 s default), the next drop in the queue triggers scale-down, and two minutes later the next peak triggers scale-up. The system oscillates, pays repeated cold starts, and never reaches a stable regime. Solution: scale-down with a window of 10 minutes minimum and conservative policies (&lt;code>type: Pods, value: 1, periodSeconds: 120&lt;/code>, at most one replica fewer every 2 minutes).&lt;/p>
&lt;p>&lt;strong>Pitfall 3, &lt;code>vllm:num_requests_waiting&lt;/code> with &lt;code>avg&lt;/code> when there is rebalancing.&lt;/strong> If two replicas are unbalanced (one with queue 20, the other with queue 0), &lt;code>avg&lt;/code> gives 10 and the HPA triggers scale-out when the right move would be to rebalance via the load balancer. To detect it: add an alert on &lt;code>stddev(vllm:num_requests_waiting)&lt;/code> per deployment. If the dispersion is high, the problem is not capacity but routing.&lt;/p>
&lt;h2 id="full-example-manifest">Full example manifest&lt;/h2>
&lt;p>For a vLLM deployment with Llama 70B FP8 on 4×H100 SXM per replica, KEDA with warm pool 2:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apps/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Deployment&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># managed by KEDA afterwards&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b } }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app: vllm-llama70b, deployment&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">terminationGracePeriodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">workload: gpu, gpu-model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">h100-sxm-80gb }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tolerations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nvidia.com/gpu&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Exists&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">effect&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NoSchedule&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:v0.10.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">model=/models/llama-3.3-70b-fp8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">tensor-parallel-size=4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">max-num-seqs=64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">enable-prefix-caching&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">enable-graceful-shutdown&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">name: http, containerPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">name: metrics, containerPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">200Gi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">readinessProbe&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">path: /health, port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">initialDelaySeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">failureThreshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># tolerates the warmup&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">lifecycle&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">preStop&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">path: /shutdown, port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">name: model-cache, mountPath: /models, readOnly&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">name: dshm, mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/dev/shm }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">model-cache&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">persistentVolumeClaim&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">claimName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama70b-fp8-pvc }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dshm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">emptyDir&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">medium: Memory, sizeLimit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">16Gi }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">monitoring.coreos.com/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PodMonitor&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name: vllm-llama70b-metrics, namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b } }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">podMetricsEndpoints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">15s&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">keda.sh/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ScaledObject&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name: vllm-llama70b-scaler, namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleTargetRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">20&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pollingInterval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">15&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cooldownPeriod&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">300&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">advanced&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">horizontalPodAutoscalerConfig&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">behavior&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleDown&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stabilizationWindowSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policies&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">type: Pods, value: 1, periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleUp&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stabilizationWindowSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">policies&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">type: Pods, value: 2, periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">triggers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serverAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://prometheus.observability.svc:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm_queue&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;5&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">avg(vllm:num_requests_waiting{deployment=&amp;#34;vllm-llama70b&amp;#34;})&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serverAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://prometheus.observability.svc:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm_kv&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0.85&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">avg(vllm:gpu_cache_usage_perc{deployment=&amp;#34;vllm-llama70b&amp;#34;})&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cron&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timezone&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Europe/Madrid&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">start&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;30 8 * * 1-5&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">end&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0 19 * * 1-5&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">desiredReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;6&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This set is the minimum viable one for LLM autoscaling on a generic cluster with the NVIDIA GPU Operator. Each team adapts it to its own SLO.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>For a generic cluster of &lt;strong>4×H100 SXM 80 GB per node, 4 GPU nodes&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>Each node hosts one vLLM replica with TP=4 and Llama 70B FP8 (one model per node, they are not shared).&lt;/li>
&lt;li>Warm pool of 2 replicas off-peak; the KEDA cron raises it to 4 during working hours.&lt;/li>
&lt;li>Cluster-autoscaler &lt;strong>does not apply&lt;/strong> (4 physical nodes bought; scaling is pods only). The number of concurrent replicas is at most the number of available nodes (if each replica uses all 4 GPUs of the whole node).&lt;/li>
&lt;li>If the sizing requires more simultaneous replicas than nodes, there are two routes: (a) lower the TP of each replica so two fit per node, (b) expand the physical nodepool. The decision is dictated by capacity planning, see &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>KEDA event volume: around 5 evaluations/min per ScaledObject. For 10 models served in parallel, 3,000 evaluations/h. Manageable with one KEDA operator per cluster.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Cluster-autoscaler for on-premise GPU nodes&lt;/strong>: how to orchestrate bare metal provisioning (Tinkerbell, Metal³) as a function of demand.&lt;/li>
&lt;li>&lt;strong>Multi-cluster autoscaling&lt;/strong>: scaling across clusters in different DCs for geographic resilience.&lt;/li>
&lt;li>&lt;strong>Cost-aware autoscaling&lt;/strong>: prioritising nodes by hourly energy cost (on clusters with indexed tariffs).&lt;/li>
&lt;li>&lt;strong>Predictive ML-based scaling&lt;/strong>: instead of a static cron, training a model that predicts demand 30 minutes ahead.&lt;/li>
&lt;li>&lt;strong>Multi-tenant quotas and fairness&lt;/strong>: KEDA with namespace quotas so that one tenant does not monopolise the HPA.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up model cold start: from minutes to seconds&lt;/a> — the cold start that makes scale-to-zero viable, or not.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">GitOps for the inference stack with Flux: operating the assistant as code&lt;/a> — how autoscaling is managed declaratively under GitOps.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> — source of the metrics that feed the HPA.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — what ceiling and what head-room the autoscaler assumes.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — explains &lt;code>num_requests_running&lt;/code>, &lt;code>num_requests_waiting&lt;/code> and &lt;code>gpu_cache_usage_perc&lt;/code>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> — governs the KV pool and therefore the thresholds.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels&lt;/a> — KEDA is a piece of level 4.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow&lt;/a> — the autoscaler coexists with the deployment strategy.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router&lt;/a> — the router consumes &lt;code>vllm:num_requests_running&lt;/code> and &lt;code>vllm:gpu_cache_usage_perc&lt;/code> (the same metrics as the autoscaler) to pick a replica with token-aware LB and prefix-aware routing; the two components share a cockpit but decide different things.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks for LLM with Keep + Kafka&lt;/a> — runbooks RB-01 (&lt;code>GpuHbmNearOom&lt;/code>) and RB-05 (&lt;code>VllmKvCachePoolNearFull&lt;/code>) use the autoscaler as an immediate mitigation lever.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">RKE2 resource managers: CPU, Memory and Topology Manager&lt;/a> — every replica the autoscaler creates goes through Topology Manager admission; if the node has no free NUMA &amp;ldquo;table&amp;rdquo;, the pod stays pending. Autoscaling has to account for NUMA granularity, not just aggregate CPU and memory.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — the real ceiling on elasticity is not available GPU, it is how long each new replica takes to load the model: 40 s of cold start make scale-to-zero unviable under a latency SLO.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>KEDA project — &lt;code>keda.sh&lt;/code> (documentación oficial de triggers Prometheus y cron).&lt;/li>
&lt;li>Kubernetes — &lt;em>Horizontal Pod Autoscaler walkthrough&lt;/em> (kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale).&lt;/li>
&lt;li>NVIDIA — &lt;em>GPU Operator on Kubernetes&lt;/em> (Helm chart oficial con DaemonSet de drivers y DCGM).&lt;/li>
&lt;li>vLLM project — &lt;code>production_monitoring/&lt;/code> (métricas Prometheus expuestas por el servidor).&lt;/li>
&lt;li>Karpenter — &lt;em>NodePool spec&lt;/em> (etiquetado y taints para nodepools GPU).&lt;/li>
&lt;li>Cluster Autoscaler — &lt;em>Scaling GPU nodes&lt;/em> (caveats de descubrimiento de recursos GPU).&lt;/li>
&lt;li>Kubernetes — &lt;em>Pod lifecycle and termination&lt;/em> (preStop, terminationGracePeriodSeconds).&lt;/li>
&lt;/ul></description></item><item><title>Seven greenfield deployment phases for an on-premise LLM platform: from hardware in the room to the first production token</title><link>https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/</link><pubDate>Sun, 31 May 2026 08:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The two previous posts in this architectural trilogy settled the pieces: &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">the seven layers of the LLM inference stack&lt;/a> describe the components above the cluster, and &lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">the five maturity levels of the platform&lt;/a> describe the strata below. This post settles the &lt;strong>when&lt;/strong>: in what order each thing gets deployed when you start from zero, hardware bought, racks installed, physical cabling done, and you want to reach a cluster serving the first production token to a customer. Seven nominal phases &lt;strong>F0 to F6&lt;/strong> with no calendar commitments, organised by &lt;strong>technical dependencies&lt;/strong> (you do not enter F3 without the F2 gate) and with an identifiable &lt;strong>critical path&lt;/strong>. F0 hardware inventory and electrical/network connectivity. F1 bare metal OS + drivers + container runtime. F2 Kubernetes cluster with CNI and Ceph storage operational. F3 GitOps and infrastructure observability. F4 identity, TLS, secrets and policies. F5 GPU platform with LLM-aware observability. F6 the LLM stack operational and open to production traffic. For each phase: what gets built, what has to be ready first (dependencies between phases), the &lt;strong>gate&lt;/strong> that validates its closure, and the typical trap that delays the critical path. The thesis: an on-premise LLM platform sinks far more often from &lt;strong>sequencing badly&lt;/strong> than from &lt;strong>choosing badly&lt;/strong>. The tools have all been invented; the order is the only thing every team rediscovers.&lt;/p>
&lt;h2 id="you-are-here-the-seven-phases-and-their-dependencies">You are here: the seven phases and their dependencies&lt;/h2>
&lt;p>The phases do not run in pure series. F2 and F3 can be started at the same time to speed things up (installing Kubernetes and preparing the GitOps repo in parallel). F4 can overlap with the tail end of F3. F5 waits for F4 to close because GPU pods demand NetworkPolicy and RBAC from day one. F6 is &lt;strong>a single atomic step&lt;/strong>: the cluster goes into production or it does not.&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="dag of phases F0 to F6 with dependencies and critical path">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.f0{fill:#f6e2e2;stroke:#a33}.f1{fill:#f4e3cf;stroke:#a63}.f2{fill:#eef0d0;stroke:#7a3}.f3{fill:#dfe9f5;stroke:#356}.f4{fill:#d8eecf;stroke:#373}.f5{fill:#f5e3d8;stroke:#763}.f6{fill:#ead8f5;stroke:#634}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#222}.tiny{font:600 10px sans-serif;fill:#222}.note{font:italic 10px sans-serif;fill:#555}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}.crit{stroke:#c33;stroke-width:2.4;fill:none;marker-end:url(#ac)}&lt;/style>
&lt;defs>&lt;marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;marker id="ac" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#c33"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" class="lbl">Phase DAG · critical path marked in red&lt;/text>
&lt;rect x="40" y="50" width="110" height="48" class="b f0"/>&lt;text x="95" y="68" text-anchor="middle" class="tiny">F0&lt;/text>&lt;text x="95" y="84" text-anchor="middle" class="sm">Hardware&lt;/text>&lt;text x="95" y="98" text-anchor="middle" class="note">Inventory · network&lt;/text>
&lt;rect x="180" y="50" width="110" height="48" class="b f1"/>&lt;text x="235" y="68" text-anchor="middle" class="tiny">F1&lt;/text>&lt;text x="235" y="84" text-anchor="middle" class="sm">Bare metal&lt;/text>&lt;text x="235" y="98" text-anchor="middle" class="note">OS · drivers&lt;/text>
&lt;rect x="320" y="50" width="110" height="48" class="b f2"/>&lt;text x="375" y="68" text-anchor="middle" class="tiny">F2&lt;/text>&lt;text x="375" y="84" text-anchor="middle" class="sm">k8s cluster&lt;/text>&lt;text x="375" y="98" text-anchor="middle" class="note">Cilium · Ceph&lt;/text>
&lt;rect x="460" y="50" width="110" height="48" class="b f3"/>&lt;text x="515" y="68" text-anchor="middle" class="tiny">F3&lt;/text>&lt;text x="515" y="84" text-anchor="middle" class="sm">GitOps + obs&lt;/text>&lt;text x="515" y="98" text-anchor="middle" class="note">Flux · VM/Loki&lt;/text>
&lt;rect x="600" y="50" width="110" height="48" class="b f4"/>&lt;text x="655" y="68" text-anchor="middle" class="tiny">F4&lt;/text>&lt;text x="655" y="84" text-anchor="middle" class="sm">Identity&lt;/text>&lt;text x="655" y="98" text-anchor="middle" class="note">OIDC · Kyverno&lt;/text>
&lt;rect x="320" y="170" width="110" height="48" class="b f5"/>&lt;text x="375" y="188" text-anchor="middle" class="tiny">F5&lt;/text>&lt;text x="375" y="204" text-anchor="middle" class="sm">GPU plane&lt;/text>&lt;text x="375" y="218" text-anchor="middle" class="note">NVIDIA op · DCGM&lt;/text>
&lt;rect x="600" y="170" width="110" height="48" class="b f6"/>&lt;text x="655" y="188" text-anchor="middle" class="tiny">F6&lt;/text>&lt;text x="655" y="204" text-anchor="middle" class="sm">LLM stack live&lt;/text>&lt;text x="655" y="218" text-anchor="middle" class="note">7 layers active&lt;/text>
&lt;path class="crit" d="M150,74 L180,74"/>
&lt;path class="crit" d="M290,74 L320,74"/>
&lt;path class="crit" d="M430,74 L460,74"/>
&lt;path class="crit" d="M570,74 L600,74"/>
&lt;path class="arr" d="M655,98 L655,170"/>
&lt;path class="arr" d="M375,98 L375,170"/>
&lt;path class="crit" d="M430,194 L600,194"/>
&lt;text x="410" y="262" text-anchor="middle" class="sm" fill="#c33">Critical path: F0 → F1 → F2 → F3 → F4 → F5 → F6&lt;/text>
&lt;text x="410" y="282" text-anchor="middle" class="note">Possible overlaps: F2 ↔ F3 (prepare repo while the cluster is built) · F3 ↔ F4 (policies in audit before enforce)&lt;/text>
&lt;text x="410" y="304" text-anchor="middle" class="note">Not overlappable: F4 before F5 (GPU without RBAC = a bomb) · F5 before F6 (the LLM stack does not start without a GPU plane)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The red arrows are the &lt;strong>critical path&lt;/strong>: the sequential bottleneck that no parallelism can shorten. The grey arrows are dependencies that admit partial overlap. Recognising where to overlap and where not is the difference between a three-month deployment and a six-month one for the same perimeter.&lt;/p>
&lt;h2 id="the-analogy-the-expedition-to-an-eight-thousand-metre-summit">The analogy: the expedition to an eight-thousand-metre summit&lt;/h2>
&lt;p>An expedition to a high alpine summit is not a long trek. It is a series of &lt;strong>camps&lt;/strong> that are set up in order, each with its altitude, its function and its validation gate: if you do not acclimatise properly at base camp, you cannot go up to C1 without risk; if C2 does not have its kitchen and its radio running, you cannot send people higher; if the summit push is attempted without porters in the high camps, there is no safe descent.&lt;/p>
&lt;p>Greenfield deployment of an LLM platform works identically. &lt;strong>F0&lt;/strong> is the arrival of the material at base camp: boxes, sponsors, permits, first review. &lt;strong>F1&lt;/strong> is setting up an operational base camp: kitchen, tents, generator. &lt;strong>F2&lt;/strong> is the climb to C1: there is real altitude now (a k8s cluster running) and you breathe differently. &lt;strong>F3&lt;/strong> is C2: it adds communications, planning and operational acclimatisation. &lt;strong>F4&lt;/strong> is C3, the last night before the push: team roped up, oxygen ready, every protocol verified. &lt;strong>F5&lt;/strong> is the day of the summit push, intense effort, thin margins. &lt;strong>F6&lt;/strong> is the summit and the start of a safe descent: from here on the expedition is in day-to-day operation, no longer in construction.&lt;/p>
&lt;p>The analogy holds two useful lessons: &lt;strong>you do not skip a camp&lt;/strong> (going straight from base camp to the summit kills the team), and &lt;strong>the gates are technical, not emotional&lt;/strong> (if the barometer says storm, you do not set out, however much enthusiasm there is). The platform team that follows those two rules reaches the summit. The one that negotiates them does not.&lt;/p>
&lt;h2 id="f0--hardware-in-the-room-base-camp">F0 — Hardware in the room: base camp&lt;/h2>
&lt;p>&lt;strong>What gets built in this phase.&lt;/strong> An inventory of the hardware received (servers, switches, PDUs, BMC), racks assembled, electrical and data cabling terminated, physical labelling of each machine (rack/U/function), connectivity to the corporate network, management IPs assigned, BMC reachable over VPN with MFA, first ping to each node from the bastion.&lt;/p>
&lt;p>&lt;strong>Dependencies.&lt;/strong> None technical, this is the phase &lt;strong>before the software&lt;/strong>. There are procurement dependencies (servers bought, switches bought), civil works dependencies (a room with enough cooling, a raised floor) and administrative ones (datacenter access for the technicians).&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F0.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Every node appears in the inventory with &lt;code>(hostname, MAC, management IP, data IP, rack, U, function, owner)&lt;/code>.&lt;/li>
&lt;li>Each node&amp;rsquo;s BMC responds to &lt;code>ipmitool power status&lt;/code> and to the HTTPS UI from the management VPN.&lt;/li>
&lt;li>The top-of-rack switch has its configuration versioned in git (even if there is no cluster GitOps yet, the switch configs are).&lt;/li>
&lt;li>A command &lt;code>for h in $(cat hosts); do ping -c1 -W1 $h.mgmt; done&lt;/code> returns 100 % success.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> Cabling &amp;ldquo;however it comes out&amp;rdquo; with no physical labelling and no diagram. When F4 arrives and a NetworkPolicy has to be troubleshot, not knowing which physical interface carries which VLAN doubles the diagnosis time of every incident for ever.&lt;/p>
&lt;p>&lt;strong>Why F0 does not overlap with F1.&lt;/strong> Until every server has a management IP and a live BMC, OS bootstrap cannot be automated. Every hour invested in F0 saves hours in every later phase. It is the phase with the best ROI in the project and the only one that admits no shortcuts.&lt;/p>
&lt;h2 id="f1--bare-metal-the-operational-base-camp">F1 — Bare metal: the operational base camp&lt;/h2>
&lt;p>&lt;strong>What gets built.&lt;/strong> The operating system image (stable Debian or Ubuntu LTS) provisioned via PXE or cloud-init with the &lt;code>cloud-config&lt;/code> versioned in git. Each node has: a coherent hostname, LVM partitions, kernel ≥ 6.6, the &lt;code>containerd&lt;/code> container runtime, NVIDIA drivers on the GPU nodes, &lt;code>chrony&lt;/code> synchronising against your own servers, the operator&amp;rsquo;s SSH key as the only way in, and &lt;code>nvidia-smi&lt;/code> passing a smoke test on the GPU nodes.&lt;/p>
&lt;p>&lt;strong>Dependencies.&lt;/strong> F0 closed. It needs the management network working so PXE responds and so the bastion can reach every node.&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F1.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;code>ansible -i inventory all -m ping&lt;/code> returns 100 % success (or the equivalent with Salt / Pulumi / etc).&lt;/li>
&lt;li>Every GPU node passes &lt;code>nvidia-smi&lt;/code> showing the expected GPUs with a consistent driver across nodes.&lt;/li>
&lt;li>Each node&amp;rsquo;s clock drifts by less than 50 ms from the reference NTP.&lt;/li>
&lt;li>A physical reboot of a node leaves it in exactly the same state after boot (idempotency).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> NVIDIA drivers installed by hand with &lt;code>apt install&lt;/code> or with NVIDIA&amp;rsquo;s &lt;code>.run&lt;/code> script. It works on day one and breaks the day of the first kernel update. The operational rule already established in &lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">the post on the five levels&lt;/a>: the drivers end up managed by the GPU Operator in F5; whatever is done now is only so that &lt;code>nvidia-smi&lt;/code> passes the smoke test, not for production.&lt;/p>
&lt;p>&lt;strong>Possible overlap.&lt;/strong> F1 can start for some nodes while F0 is still being finished on others (real greenfield rarely delivers every server on the same day). The F1 gate is per cluster, not per individual node.&lt;/p>
&lt;h2 id="f2--kubernetes-cluster-operational">F2 — Kubernetes cluster operational&lt;/h2>
&lt;p>&lt;strong>What gets built.&lt;/strong> RKE2 installed with three HA control plane nodes, all workers joined (CPU and GPU), Cilium as CNI with &lt;code>kubeProxyReplacement&lt;/code> enabled and a BGP control plane pointing at the ToR switches from F0, Rook-Ceph deployed on the storage nodes to cover block (RBD), filesystem (CephFS) and object (S3-compatible RGW), &lt;code>kubectl get nodes&lt;/code> returning every node &lt;code>Ready&lt;/code>, a first test pod with a PVC mounting and data persisting after the pod restarts.&lt;/p>
&lt;p>&lt;strong>Dependencies.&lt;/strong> F1 closed (drivers + container runtime). Switches with BGP configured (closed in F0). NVMe disks partitioned or available raw for Ceph OSDs.&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F2.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;code>kubectl get nodes -o wide&lt;/code> shows every node &lt;code>Ready&lt;/code> with the expected Kubernetes version.&lt;/li>
&lt;li>A Deployment with replicas=3 and per-node antiAffinity starts and the pods land on different nodes.&lt;/li>
&lt;li>An RWO PVC (RBD) creates a volume, the pod writes data, the pod is deleted, another pod mounts it and reads the data.&lt;/li>
&lt;li>An RWX PVC (CephFS) does the same with two pods writing simultaneously.&lt;/li>
&lt;li>An RGW bucket via &lt;code>s3cmd&lt;/code> or &lt;code>mc&lt;/code> accepts &lt;code>put&lt;/code> and &lt;code>get&lt;/code> over TLS.&lt;/li>
&lt;li>Hubble (the read side of the CNI) shows flow logs between two pods in different namespaces.&lt;/li>
&lt;li>Chaos test: draining a non-GPU worker node; the workloads are rescheduled automatically.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> Starting to &lt;code>kubectl apply&lt;/code> real workloads in F2 without GitOps. The backlog of things-applied-by-hand grows faster than the capacity to migrate it to git afterwards. The rule: in F2 only the cluster &lt;strong>prerequisites&lt;/strong> are applied (CNI, CSI, default storage class). Any application workload waits for F3.&lt;/p>
&lt;p>&lt;strong>Possible overlap.&lt;/strong> F2 ↔ F3. While the cluster is being built, the GitOps repo is prepared in parallel (directory structure, first Helm releases). When F2 closes, Flux is plugged into the repo and everything that was going to be &lt;code>kubectl apply&lt;/code> is already a reconciled manifest.&lt;/p>
&lt;h2 id="f3--gitops-and-infrastructure-observability">F3 — GitOps and infrastructure observability&lt;/h2>
&lt;p>&lt;strong>What gets built.&lt;/strong> Forgejo deployed first (it is a prerequisite for everything that follows). A &lt;code>gitops-infra&lt;/code> repo with the initial structure (&lt;code>apps/&lt;/code>, &lt;code>infrastructure/&lt;/code>, &lt;code>tenants/&lt;/code>, &lt;code>clusters/&lt;/code>). Flux installed and reconciling that repo. The prerequisite workloads applied by hand in F2 are moved to the repo and reconciled (no operational &lt;code>kubectl apply&lt;/code> remains). VictoriaMetrics + vmagent scraping metrics. Grafana with initial dashboards (USE/RED + cluster + Ceph + Cilium). Loki receiving logs via vector/fluent-bit. Alertmanager + Keep routing alerts to a chat channel. Barman Cloud backups for Postgres (future CNPG) and scheduled Ceph snapshots.&lt;/p>
&lt;p>&lt;strong>Dependencies.&lt;/strong> F2 closed. An RGW bucket to store backups (covered by the Ceph from F2).&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F3.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>A change applied to the repo is reflected in the cluster in under 5 minutes with no manual intervention.&lt;/li>
&lt;li>A change applied with &lt;code>kubectl edit&lt;/code> directly to the cluster is detected by Flux and reverted (binding drift detection, not merely observational).&lt;/li>
&lt;li>Grafana shows dashboards for the cluster, Ceph, Cilium and GPU nodes (DCGM does not arrive until F5, but basic node metrics do).&lt;/li>
&lt;li>A test alert sent to Alertmanager reaches the chat channel in under 1 minute.&lt;/li>
&lt;li>Restoring a Postgres backup on a temporary cluster returns coherent data (the test defines the real RPO).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> Having Helm charts in git but still applying them with &lt;code>helm install&lt;/code> from the terminal. That is level 1 dressed up as level 2. F3 only closes when Flux is &lt;strong>the only authority&lt;/strong> that applies changes and humans edit the repo, not the cluster.&lt;/p>
&lt;p>&lt;strong>Possible overlap.&lt;/strong> F3 ↔ F4. While F3 is being closed, the Defguard and cert-manager manifests can be prepared in the repo. When they reconcile they have somewhere to land.&lt;/p>
&lt;h2 id="f4--identity-certificates-secrets-policies">F4 — Identity, certificates, secrets, policies&lt;/h2>
&lt;p>&lt;strong>What gets built.&lt;/strong> Defguard deployed with its dedicated Postgres (CNPG). An initial realm with the platform operators enrolled with MFA and WireGuard. OIDC integrated into kube-apiserver (&lt;code>--oidc-issuer-url&lt;/code>, &lt;code>--oidc-client-id&lt;/code>, &lt;code>--oidc-username-claim&lt;/code>), into Forgejo, into Grafana, into Alertmanager, one single SSO. cert-manager installed with an internal CA issuing internal certs for mTLS and with Let&amp;rsquo;s Encrypt ACME for edge certs. SOPS configured with a KMS (it can be a physical HSM, an age key in a vault, or an external Vault) and External Secrets Operator syncing secrets into the cluster. Kyverno deployed with initial policies in &lt;code>audit&lt;/code> mode for a week, then promoted to &lt;code>enforce&lt;/code>. A default-deny NetworkPolicy applied to every existing namespace. Tetragon enabled for runtime security. The kube-apiserver audit log sent to Loki with long retention.&lt;/p>
&lt;p>&lt;strong>Dependencies.&lt;/strong> F3 closed (Flux applies the manifests, VM/Loki ingest metrics and logs).&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F4.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;code>kubectl&lt;/code> with a shared admin &lt;code>kubeconfig&lt;/code> &lt;strong>stops working&lt;/strong>; every operator uses their own OIDC token with MFA.&lt;/li>
&lt;li>A secret in plain &lt;code>data:&lt;/code> in a commit is rejected by the pre-commit hook (or by Kyverno admission).&lt;/li>
&lt;li>A pod without &lt;code>securityContext.runAsNonRoot=true&lt;/code> is rejected by Kyverno at admission.&lt;/li>
&lt;li>A deliberately wrong NetworkPolicy (allow-all) in a tenant namespace is rejected.&lt;/li>
&lt;li>An audit of the last day returns the complete list of actors and changes (minimum regulatory footprint).&lt;/li>
&lt;li>Basic internal pen-test: an attacker with a forged &lt;code>kubeconfig&lt;/code> fails MFA and is recorded.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> Kyverno in permanent &lt;code>audit&lt;/code> mode because &amp;ldquo;we do not want to break production workloads&amp;rdquo;. F4 closes when the policies are in &lt;code>enforce&lt;/code>. Until then, you are still in F3 wearing an F4 face.&lt;/p>
&lt;p>&lt;strong>Why F4 does not overlap with F5.&lt;/strong> F5 introduces GPU pods that move a lot of VRAM and a lot of compute. Without default-deny NetworkPolicy, without OIDC RBAC, without Kyverno blocking insecure configurations, the GPU pods are the juiciest attack surface in the cluster. Any compromise in F5 with F4 not closed is near-total access to the expensive hardware.&lt;/p>
&lt;h2 id="f5--gpu-platform-with-llm-aware-observability">F5 — GPU platform with LLM-aware observability&lt;/h2>
&lt;p>&lt;strong>What gets built.&lt;/strong> NVIDIA GPU Operator via Flux with the driver version decided in F1 (no longer touched by hand). DCGM Exporter exposing GPU metrics to VictoriaMetrics. MIG manager configured for the nodes where it makes sense (for example, on a 4×H100 SXM cluster: two GPUs with full passthrough for the general LLM at TP=4, two GPUs partitioned into 2×3g.40gb each for small LLMs and embeddings). Topology Manager with the &lt;code>single-numa-node&lt;/code> policy. KEDA with the Prometheus scaler installed and an example ScaledObject pointing at a vLLM metric (&lt;code>vllm:num_requests_running&lt;/code>). OpenTelemetry Collector with OTLP receivers, &lt;code>attributes&lt;/code> processors (enriching spans with &lt;code>tenant_id&lt;/code>, &lt;code>priority_tier&lt;/code>), exporters to Langfuse and to Tempo. LeaderWorkerSet API enabled for tensor parallel topologies. OME (Operator Model Engine) or vLLM Production Stack deployed as a controller, still with no models loaded.&lt;/p>
&lt;p>&lt;strong>Dependencies.&lt;/strong> F4 closed (the GPU pods inherit default-deny NetworkPolicy, OIDC RBAC and Kyverno policies).&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F5.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>A test pod requesting &lt;code>nvidia.com/gpu: 1&lt;/code> is scheduled on the right node and &lt;code>nvidia-smi&lt;/code> from inside the container sees the right GPU (whole or a MIG slice).&lt;/li>
&lt;li>DCGM Exporter exposes metrics in Grafana (utilisation, VRAM, temperature, NVLink bandwidth) for every GPU.&lt;/li>
&lt;li>A test vLLM Deployment starts with a small model (for example, a 7B FP16) loaded from Ceph RGW.&lt;/li>
&lt;li>An OpenTelemetry span generated by that vLLM reaches Langfuse with correct &lt;code>gen_ai.*&lt;/code> attributes.&lt;/li>
&lt;li>KEDA scales the test Deployment from 1 to N replicas under synthetic load and back to 1 when it stops.&lt;/li>
&lt;li>A GPU Operator upgrade to a new version drains and reschedules the GPU pods with no loss of service.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> Loading the large model &amp;ldquo;just to try&amp;rdquo; before DCGM and OTel are green. When something fails, there will be no metrics to distinguish OOM, thermal throttling, a driver mismatch or a network problem, and you diagnose blind. The rule: &lt;strong>small model first&lt;/strong>, golden path green, &lt;strong>then&lt;/strong> the large model.&lt;/p>
&lt;p>&lt;strong>Possible overlap.&lt;/strong> None with F6. F6 is atomic.&lt;/p>
&lt;h2 id="f6--llm-stack-in-production">F6 — LLM stack in production&lt;/h2>
&lt;p>&lt;strong>What gets built.&lt;/strong> The &lt;strong>seven layers of the inference stack&lt;/strong> described in the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">corresponding post&lt;/a>, deployed in this order:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Vector store + relational data&lt;/strong> (Qdrant, PostgreSQL CNPG, Ceph RGW for weights and adapters, CephFS for datasets). Some components already existed from F3 as data; here they specialise for RAG with their initial collections and schemas.&lt;/li>
&lt;li>&lt;strong>Embeddings + reranker&lt;/strong> (Infinity with &lt;code>multilingual-e5-large&lt;/code>, TEI with &lt;code>bge-reranker-v2-m3&lt;/code>). This is the layer that must be green before any large model, because RAG depends on it.&lt;/li>
&lt;li>&lt;strong>LLM inference&lt;/strong> (vLLM Production Stack with the general LLM and the code LLM). It loads models from Ceph RGW. The initial multi-LoRA pool is empty.&lt;/li>
&lt;li>&lt;strong>Gateway&lt;/strong> (Envoy AI Gateway) with Defguard OAuth, routing by &lt;code>body.model&lt;/code>, rate-limit per tenant. This is the point that &lt;strong>opens traffic to the outside&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>LLM-aware observability&lt;/strong> (Langfuse plugged into the OTel from F5).&lt;/li>
&lt;li>&lt;strong>GitOps control plane&lt;/strong> and &lt;strong>dependency tracking&lt;/strong> were already active since F3 and F4 respectively; here you simply add the catalogue of the new LLM services to them.&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>Dependencies.&lt;/strong> All the previous phases closed.&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F6.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>A curl to the public endpoint with a Defguard bearer token receives a correct chat completion response in technical Spanish, with &lt;code>trace_id&lt;/code> propagated.&lt;/li>
&lt;li>The trace appears in Langfuse with complete &lt;code>gen_ai.*&lt;/code> attributes, a latency breakdown and its own &lt;code>tenant_id&lt;/code>.&lt;/li>
&lt;li>A 5 % traffic canary to the new model over 24 h degrades neither quality nor latency metrics.&lt;/li>
&lt;li>A controlled traffic burst triggers KEDA, the replicas scale, and P95 latency stays within budget.&lt;/li>
&lt;li>A deliberate failure of one vLLM pod does not affect endpoint availability (replicas + reschedule).&lt;/li>
&lt;li>The internal operator demonstrates the complete path for revoking a tenant&amp;rsquo;s access in under 5 minutes (Defguard → Kyverno → closing the NetworkPolicy).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> Opening real customer traffic before having the incident runbook signed off, the SLO negotiated and the continuity plan tested. F6 is technically closed; operationally, the platform is still an experiment until the first real postmortem proves the team knows how to respond.&lt;/p>
&lt;h2 id="the-maths-that-matter-relative-weight-of-effort-per-phase">The maths that matter: relative weight of effort per phase&lt;/h2>
&lt;p>Without committing to calendar weeks, we can quantify the &lt;strong>relative weight&lt;/strong> of the engineering effort per phase in a typical greenfield. The curve is not uniform:&lt;/p>
$$
\text{effort}_{F_i} \approx \text{base}_i \cdot (1 + \epsilon_i)
$$
&lt;p>where $\text{base}_i$ is the nominal effort and $\epsilon_i$ is the &lt;strong>surprises&lt;/strong> factor (wrong cabling, incompatible drivers, badly issued certificates, version conflicts). The table below gives the nominal relative weight and the typical surprise factor observed:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Phase&lt;/th>
&lt;th>Nominal weight&lt;/th>
&lt;th>Typical surprise factor ε&lt;/th>
&lt;th>Mean effective weight&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>F0 — Hardware&lt;/td>
&lt;td>8 %&lt;/td>
&lt;td>0.5 (1× to 2×)&lt;/td>
&lt;td>&lt;strong>12 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>F1 — Bare metal&lt;/td>
&lt;td>6 %&lt;/td>
&lt;td>0.3&lt;/td>
&lt;td>8 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>F2 — k8s cluster&lt;/td>
&lt;td>12 %&lt;/td>
&lt;td>0.4&lt;/td>
&lt;td>17 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>F3 — GitOps + obs&lt;/td>
&lt;td>14 %&lt;/td>
&lt;td>0.5&lt;/td>
&lt;td>21 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>F4 — Identity + policies&lt;/td>
&lt;td>18 %&lt;/td>
&lt;td>0.7&lt;/td>
&lt;td>&lt;strong>31 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>F5 — GPU plane&lt;/td>
&lt;td>10 %&lt;/td>
&lt;td>0.4&lt;/td>
&lt;td>14 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>F6 — LLM stack live&lt;/td>
&lt;td>8 %&lt;/td>
&lt;td>0.3&lt;/td>
&lt;td>10 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Buffer / integration&lt;/td>
&lt;td>24 %&lt;/td>
&lt;td>—&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two operational observations. &lt;strong>F4 concentrates more surprises than any other phase&lt;/strong> (OIDC federation across four or five apps with different configurations, Kyverno policies that knock over legitimate workloads, secrets broken by poorly tested encryption). &lt;strong>F0 has a high surprise coefficient relative to its size&lt;/strong> because any cabling or labelling error is discovered late and paid for dearly. The two practical consequences: plan &lt;strong>F4 with generous margin&lt;/strong> and do not skimp on time in &lt;strong>F0&lt;/strong>, because every hour saved there costs five later.&lt;/p>
&lt;p>&lt;strong>Critical path and slack.&lt;/strong> The critical path is linear F0 → F1 → F2 → F3 → F4 → F5 → F6. The only real slack is the overlaps already identified:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>F2 ↔ F3 (slack ~30 %)&lt;/strong>: prepare the repo and initial dashboards while the cluster is being built.&lt;/li>
&lt;li>&lt;strong>F3 ↔ F4 (slack ~20 %)&lt;/strong>: identity manifests ready as F3 closes, applied immediately.&lt;/li>
&lt;li>&lt;strong>Within F4&lt;/strong>: policies in &lt;code>audit&lt;/code> mode running in parallel with the Defguard setup.&lt;/li>
&lt;/ul>
&lt;p>Nothing shortens the critical path by more than about 15 % of the total. Anyone promising a productive greenfield in half a reasonable timescale is selling something else: probably skipping F4, or loading F6 with an F5 that is green-but-not-validated.&lt;/p>
&lt;h2 id="final-diagram-the-complete-deployment-schedule">Final diagram: the complete deployment schedule&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="complete phased deployment schedule with pieces and gates">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.bg{fill:#fafafa;stroke:#bbb;rx:8}.f0{fill:#f6e2e2;stroke:#a33}.f1{fill:#f4e3cf;stroke:#a63}.f2{fill:#eef0d0;stroke:#7a3}.f3{fill:#dfe9f5;stroke:#356}.f4{fill:#d8eecf;stroke:#373}.f5{fill:#f5e3d8;stroke:#763}.f6{fill:#ead8f5;stroke:#634}.gate{fill:#fffbe0;stroke:#a90}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#222}.tiny{font:600 10px sans-serif;fill:#222}.note{font:italic 10px sans-serif;fill:#555}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}&lt;/style>
&lt;defs>&lt;marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" class="lbl">Complete schedule: pieces per phase and validation gates&lt;/text>
&lt;rect x="40" y="40" width="740" height="68" class="b f0"/>&lt;text x="60" y="58" class="tiny">F0 · HARDWARE IN THE ROOM&lt;/text>
&lt;text x="60" y="76" class="sm">Inventory · cabling · BMC TLS+MFA · management IPs · BGP switches versioned&lt;/text>
&lt;text x="60" y="92" class="note">Gate: `for h in hosts; ping HOST.mgmt` 100 % success · complete inventory&lt;/text>
&lt;rect x="40" y="116" width="740" height="68" class="b f1"/>&lt;text x="60" y="134" class="tiny">F1 · BARE METAL&lt;/text>
&lt;text x="60" y="152" class="sm">PXE/cloud-init · LTS OS · kernel ≥6.6 · containerd · NVIDIA drivers · chrony · LVM&lt;/text>
&lt;text x="60" y="168" class="note">Gate: `ansible all -m ping` 100 % · `nvidia-smi` smoke OK · idempotent reboot&lt;/text>
&lt;rect x="40" y="192" width="740" height="68" class="b f2"/>&lt;text x="60" y="210" class="tiny">F2 · KUBERNETES CLUSTER&lt;/text>
&lt;text x="60" y="228" class="sm">RKE2 HA · Cilium (kube-proxy replacement + BGP) · Rook-Ceph (RBD + CephFS + RGW)&lt;/text>
&lt;text x="60" y="244" class="note">Gate: RWO/RWX PVCs OK · RGW bucket OK · node drain with no downtime&lt;/text>
&lt;rect x="40" y="268" width="740" height="68" class="b f3"/>&lt;text x="60" y="286" class="tiny">F3 · GITOPS + INFRA OBSERVABILITY&lt;/text>
&lt;text x="60" y="304" class="sm">Forgejo · Flux · VictoriaMetrics + Grafana + Loki · Alertmanager + Keep · backups&lt;/text>
&lt;text x="60" y="320" class="note">Gate: repo change → cluster in &amp;lt;5min · drift reverted · backup restore OK&lt;/text>
&lt;rect x="40" y="344" width="740" height="68" class="b f4"/>&lt;text x="60" y="362" class="tiny">F4 · IDENTITY + POLICIES&lt;/text>
&lt;text x="60" y="380" class="sm">Defguard OIDC+MFA+WG · cert-manager · SOPS+ESO · Kyverno enforce · NP default deny · Tetragon&lt;/text>
&lt;text x="60" y="396" class="note">Gate: shared admin kubeconfig no longer works · policies in enforce · complete audit log&lt;/text>
&lt;rect x="40" y="420" width="740" height="68" class="b f5"/>&lt;text x="60" y="438" class="tiny">F5 · GPU PLATFORM + LLM-AWARE OBSERVABILITY&lt;/text>
&lt;text x="60" y="456" class="sm">NVIDIA GPU Operator · DCGM · MIG manager · KEDA with vLLM metrics · OTel gen_ai.* · OME&lt;/text>
&lt;text x="60" y="472" class="note">Gate: GPU pod scheduled · DCGM green · vLLM smoke with a small model · KEDA scales&lt;/text>
&lt;rect x="40" y="496" width="740" height="38" class="b f6"/>&lt;text x="60" y="514" class="tiny">F6 · LLM STACK LIVE&lt;/text>
&lt;text x="60" y="528" class="sm">7 layers active · first model green · canary OK · runbook signed · first customer with an SLA&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The schedule is not decorative: each row defines what gets built in its phase &lt;strong>and the gate that closes it&lt;/strong>. A phase is not considered finished until its gate is green. A phase with a yellow gate drags every later one; trying to jump to the next with a partially met gate is what produces, several weeks later, the incident that forces you to &amp;ldquo;go back to F4 with production running&amp;rdquo;, the most expensive situation in the whole cost matrix of the &lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">post on the five levels&lt;/a>.&lt;/p>
&lt;h2 id="typical-planning-mistakes">Typical planning mistakes&lt;/h2>
&lt;p>Patterns that delay or sink the greenfield deployment, regardless of the tools chosen:&lt;/p>
&lt;p>&lt;strong>1. Buying the LLM before the cluster.&lt;/strong> Starting the project with &amp;ldquo;which model are we going to serve&amp;rdquo; instead of &amp;ldquo;what platform can sustain any reasonable model&amp;rdquo;. The model is an interchangeable parameter; the platform is not.&lt;/p>
&lt;p>&lt;strong>2. Underestimating F0.&lt;/strong> &amp;ldquo;The network team does that.&amp;rdquo; Yes, but the output of F0 is consumed by every later phase. If the network team delivers late, the whole project is late, and nobody had marked it as the critical path.&lt;/p>
&lt;p>&lt;strong>3. Overlapping F4 with F5 &amp;ldquo;to save time&amp;rdquo;.&lt;/strong> It is the one dependency with no slack. If you try to overlap, F5 ends up operating with policies in permanent &lt;code>audit&lt;/code> (you are not in F4) or without OIDC integrated (operators with a shared kubeconfig touching GPUs). Both antipatterns stay in production.&lt;/p>
&lt;p>&lt;strong>4. Skipping the small model smoke test in F5.&lt;/strong> &amp;ldquo;Let&amp;rsquo;s go straight for the 70B.&amp;rdquo; When something fails (and something will), there will be no baseline to diagnose against.&lt;/p>
&lt;p>&lt;strong>5. Treating F6 as &amp;ldquo;turning on vLLM&amp;rdquo;.&lt;/strong> F6 includes gateway, LLM-aware observability, runbook, SLO, continuity plan. Turning on vLLM takes five minutes; closing F6 is weeks of validation and sign-off.&lt;/p>
&lt;p>&lt;strong>6. Not defining gates in writing.&lt;/strong> If the gates are not written down, they are negotiable after the fact. &amp;ldquo;This already counts as F4&amp;rdquo; is the sentence that precedes the next six months of retrofit.&lt;/p>
&lt;p>&lt;strong>7. Assigning a phase to a single owner.&lt;/strong> Every phase needs at least two people who understand it. Staff turnover on long projects destroys knowledge; written gates + cross review preserve it.&lt;/p>
&lt;p>&lt;strong>8. Forgetting the descent route.&lt;/strong> The post focuses on going up. Day-to-day operation (the descent, in the analogy) is another story that also deserves planning: runbooks, on-call, upgrade capacity, end-of-life plan. Teams that only plan the ascent reach the summit and stay there with no oxygen.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware-4h100-sxm">Applied to typical on-premise hardware: 4×H100 SXM&lt;/h2>
&lt;p>On the generic reference cluster (4×H100 SXM 80 GB, NVLink, 640 GB RAM per GPU node, 3 control plane nodes, 3-5 CPU worker nodes, 2 GPU worker nodes), the &lt;strong>temporal&lt;/strong> split of the work is distributed like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">F0 (hardware)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ 8 physical servers racked + switches + BMC + management IPs
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├─ 3 nodes cp-01..03 — control plane (no GPU)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├─ 3 nodes worker-cpu-01..03 — CPU plane (Forgejo, Ceph, observability)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─ 2 nodes worker-gpu-01..02 — GPU plane (4×H100 SXM each)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">F1 (bare metal)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ OS + drivers + containerd on all 8 nodes alike
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> (NVIDIA drivers only on the 2 GPU nodes, `nvidia-smi` smoke)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">F2 (k8s cluster)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ RKE2 control plane on cp-01..03 (HA with embedded etcd)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> workers joining: 3 CPU + 2 GPU
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> Ceph OSDs on the 3 CPU worker nodes
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> default pools: RBD-replicated-3, CephFS-replicated-3, RGW
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">F3 (GitOps + obs)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ Forgejo + Flux + VM/Grafana/Loki + Keep on the CPU plane
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> first `gitops-infra` repo reconciles what came from F2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">F4 (identity)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ Defguard on the CPU plane (StatefulSet with Postgres CNPG)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> OIDC in kube-apiserver, Forgejo, Grafana, Alertmanager
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> Kyverno as a Deployment on the control plane
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">F5 (GPU plane)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ NVIDIA GPU Operator targets the GPU workers
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> MIG manager: 1st GPU MIG 7g.80gb (= passthrough), 2nd 2×3g.40gb
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> OTel Collector as a DaemonSet on the GPU plane + CPU plane
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> first vLLM with a 7B FP16 model green
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">F6 (LLM stack)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ The 7 layers reconcile via Flux from a second repo `gitops-llm`
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> first public endpoint with Defguard OAuth
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> first production customer enrolled under an SLA
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The physical distribution of the cluster exploits the isolation between planes defined in F0: the control plane never touches a GPU, the CPU plane concentrates relevant state (Forgejo, Ceph, Postgres CNPG, Langfuse, Defguard) and the GPU plane specialises as much as possible. That separation, decided in F0 before the first server is installed, conditions the success of the remaining phases. It is another reminder of why F0 matters more than it looks.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;p>This post walks the &lt;strong>ascent route&lt;/strong> to the summit. Some pieces still deserve an article of their own:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The safe descent&lt;/strong>: day-to-day operation, runbooks per component, on-call, continuous capacity planning, the cluster upgrade cycle with no downtime.&lt;/li>
&lt;li>&lt;strong>Multi-site (the second summit)&lt;/strong>: how two clusters are federated with Cilium Cluster Mesh and what extra phases that introduces. F3.5 (Cluster Mesh) and F4.5 (cross-site replication) are the missing phases.&lt;/li>
&lt;li>&lt;strong>The brownfield route&lt;/strong>: what changes when there is already a cluster with workloads. The phases stay the same, but the gates are applied retroactively and every step requires migration planning.&lt;/li>
&lt;li>&lt;strong>The real calendar cost&lt;/strong>: typical ranges in weeks for a platform team of 2-3 people, split by phase, with uncertainty bands.&lt;/li>
&lt;li>&lt;strong>The handoff to operations&lt;/strong>: how the platform is handed from the deployment team to the operations team, which documents are signed, what is inherited and what is renegotiated.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">GitOps for the inference stack with Flux: operating the assistant as code&lt;/a> — how to turn these phases into a reproducible, reconciled GitOps deployment.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Anatomy of an on-premise LLM inference stack&lt;/a> — the seven layers built in F6. The components, not the schedule.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels of the platform under the LLM&lt;/a> — the levels corresponding to phases F1→F5. The strata, not the sequence.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages&lt;/a> — individual cards for the pieces cited here.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the OTel of F5 with detail on the &lt;code>gen_ai.*&lt;/code> semantic conventions.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — when F6 closes, the immediate operational question is how many replicas fit and how many customers they support; the sizing that ends the conversation with procurement.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> — the concrete cockpit of DCGM and vLLM that F5 enables: the twelve metrics that decide whether the cluster is operated or operated on by intuition.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes&lt;/a> — KEDA and HPA with custom metrics, the first real deliverable of F6 when traffic starts to move.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow for LLM models&lt;/a> — the mechanics of promoting models with no downtime that F6 must support from day one.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router&lt;/a> — the last piece F6 closes: catalogue, L7 traffic splitting, cross-cutting policy, failover and prefix-aware routing. Without the router, F6 does not finish even with all seven layers running.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">Isolating AI agents: from workstation to cluster&lt;/a> — the runtime hardening that fits into F4 (identity, policies) and F5 (platform): Kata &lt;code>RuntimeClass&lt;/code> and Tetragon &lt;code>TracingPolicy&lt;/code> for agent workloads; its &lt;a href="https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/">runbook&lt;/a> brings the files.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>RKE2 Documentation — &lt;a href="https://docs.rke2.io/">docs.rke2.io&lt;/a>&lt;/li>
&lt;li>Cilium documentation — &lt;a href="https://docs.cilium.io/">docs.cilium.io&lt;/a>&lt;/li>
&lt;li>Rook documentation — &lt;a href="https://rook.io/">rook.io/docs&lt;/a>&lt;/li>
&lt;li>Flux GitOps toolkit — &lt;a href="https://fluxcd.io/">fluxcd.io&lt;/a>&lt;/li>
&lt;li>Forgejo — &lt;a href="https://forgejo.org/">forgejo.org&lt;/a>&lt;/li>
&lt;li>cert-manager — &lt;a href="https://cert-manager.io/">cert-manager.io&lt;/a>&lt;/li>
&lt;li>External Secrets Operator — &lt;a href="https://external-secrets.io/">external-secrets.io&lt;/a>&lt;/li>
&lt;li>Kyverno — &lt;a href="https://kyverno.io/">kyverno.io&lt;/a>&lt;/li>
&lt;li>NVIDIA GPU Operator — &lt;a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/">docs.nvidia.com/datacenter/cloud-native/gpu-operator&lt;/a>&lt;/li>
&lt;li>DCGM Exporter — &lt;a href="https://github.com/NVIDIA/dcgm-exporter">github.com/NVIDIA/dcgm-exporter&lt;/a>&lt;/li>
&lt;li>KEDA — &lt;a href="https://keda.sh/">keda.sh&lt;/a>&lt;/li>
&lt;li>LeaderWorkerSet API — &lt;a href="https://github.com/kubernetes-sigs/lws">github.com/kubernetes-sigs/lws&lt;/a>&lt;/li>
&lt;li>OpenTelemetry Semantic Conventions for GenAI — &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">opentelemetry.io/docs/specs/semconv/gen-ai&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Five maturity levels of the platform under the LLM: from a Linux server to a cluster ready for vLLM</title><link>https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/</link><pubDate>Sun, 31 May 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The post on &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">the seven layers of the LLM inference stack&lt;/a> took many pieces for granted: an operational Kubernetes cluster, GitOps reconciling, identities resolved, GPUs visible to the scheduler, observability capable of carrying &lt;code>gen_ai.*&lt;/code>. Before vLLM makes any sense, &lt;strong>you have to reach that starting point&lt;/strong>, and you reach it by levels. This post defines &lt;strong>five maturity levels&lt;/strong> of the platform that lives under the LLM, from a bare metal server with Linux installed (level 0) to a cluster ready to run the inference layer (level 4) and the handoff to the previous post (level 5). Each level &lt;strong>unlocks a concrete capability&lt;/strong>, running containers reproducibly, rebuilding the cluster from git, authenticating humans via OIDC, scheduling GPUs with MIG and DCGM metrics, demonstrating compliance with no manual intervention, and each has a &lt;strong>validation test&lt;/strong> that decides whether you are really there or just telling yourself you are. For each level: which OSS pieces cover it in 2026 (Cilium, RKE2, Flux, cert-manager, Defguard, NVIDIA GPU Operator, KEDA, Trivy, Kyverno…), &lt;strong>the deployment order&lt;/strong> within the level, the decisions that are expensive to skip, and the antipatterns that drop you a level when you thought you were higher. The thesis: &lt;strong>moving up a level costs little effort if you do it in time, and a lot of refactoring if you try to skip it&lt;/strong>. LLM inference demands at least level 4; anyone trying to serve LLMs from level 1 or 2 ends up paying in night-time incidents what they saved on the platform.&lt;/p>
&lt;h2 id="you-are-here-the-five-levels-at-a-glance">You are here: the five levels at a glance&lt;/h2>
&lt;p>Before the detail, the ladder. Each rung adds a capability absent from the one below. The level&amp;rsquo;s test is the question whose honest answer says whether you are already there.&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="five maturity levels of the platform under the LLM">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.l0{fill:#f6e2e2;stroke:#a33}.l1{fill:#f4e3cf;stroke:#a63}.l2{fill:#eef0d0;stroke:#7a3}.l3{fill:#d8eecf;stroke:#373}.l4{fill:#dfe9f5;stroke:#356}.l5{fill:#ead8f5;stroke:#634}.title{font:600 13px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#222}.tiny{font:600 10px sans-serif;fill:#222}.note{font:italic 10px sans-serif;fill:#555}&lt;/style>
&lt;text x="410" y="20" text-anchor="middle" class="title">Five maturity levels (plus the handoff to the LLM stack at level 5)&lt;/text>
&lt;rect x="40" y="38" width="740" height="44" class="b l0"/>&lt;text x="60" y="56" class="tiny">LEVEL 0 · CHAOTIC&lt;/text>&lt;text x="60" y="74" class="sm">Bare metal with Linux · ad-hoc docker / podman · no orchestrator · manual changes over SSH&lt;/text>
&lt;rect x="40" y="90" width="740" height="44" class="b l1"/>&lt;text x="60" y="108" class="tiny">LEVEL 1 · REPEATABLE&lt;/text>&lt;text x="60" y="126" class="sm">k8s cluster installed (RKE2 / kubeadm) · CNI · CSI · kubectl apply / Helm from the terminal · pods running&lt;/text>
&lt;rect x="40" y="142" width="740" height="44" class="b l2"/>&lt;text x="60" y="160" class="tiny">LEVEL 2 · DEFINED&lt;/text>&lt;text x="60" y="178" class="sm">GitOps (Flux) · internal registry · infra observability · backups · the cluster rebuilds from the repo&lt;/text>
&lt;rect x="40" y="194" width="740" height="44" class="b l3"/>&lt;text x="60" y="212" class="tiny">LEVEL 3 · MANAGED&lt;/text>&lt;text x="60" y="230" class="sm">OIDC + RBAC · cert-manager · External Secrets · Kyverno · NetworkPolicy default deny · auditing&lt;/text>
&lt;rect x="40" y="246" width="740" height="44" class="b l4"/>&lt;text x="60" y="264" class="tiny">LEVEL 4 · OPTIMISED FOR GPU&lt;/text>&lt;text x="60" y="282" class="sm">NVIDIA GPU Operator · DCGM Exporter · MIG / time-slicing · KEDA with LLM metrics · OTel ready for gen_ai.*&lt;/text>
&lt;rect x="40" y="298" width="740" height="32" class="b l5"/>&lt;text x="60" y="318" class="sm">&lt;tspan font-weight="700">LEVEL 5 · HANDOFF&lt;/tspan> — the cluster is ready for the LLM stack (the 7 layers) to make sense&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The levels are &lt;strong>not interchangeable&lt;/strong>. A cluster at level 2 cannot run LLMs in production with any guarantee: technically the vLLM pod loads, but at the first night-time incident you discover there is no TLS, no identities, no alerting, no GPU metrics, and no way to know who changed what. Moving up a level after LLMs are already in production costs &lt;strong>orders of magnitude&lt;/strong> more than moving up while the cluster is still empty.&lt;/p>
&lt;h2 id="the-analogy-from-the-street-stall-to-the-starred-restaurant">The analogy: from the street stall to the starred restaurant&lt;/h2>
&lt;p>Picture the scale of a hospitality business. &lt;strong>Level 0&lt;/strong> is the street stall: a griddle, a gas bottle, a cook improvising. It can sell food, it works, but anything that deviates from a normal day (a health inspection, an allergic customer, an order for 200 portions) sinks the business. &lt;strong>Level 1&lt;/strong> is the tapas bar: a properly sized kitchen, a short repeatable menu, several shifts. The cook no longer improvises daily; he works from a written menu, even if the recipes live in the head of the boss. &lt;strong>Level 2&lt;/strong> is the restaurant with a set lunch menu: written procedures, fixed suppliers, stock control, an incident book. If the head cook falls ill, the second can run the service without carnage. &lt;strong>Level 3&lt;/strong> is the restaurant with an à la carte menu and formal service: traceability of every ingredient, allergens on the menu, health certification, contracts with suppliers, mandatory staff training. &lt;strong>Level 4&lt;/strong> is the kitchen specialised in a complex product (sushi, haute cuisine, artisan bakery): specific tools the normal restaurant does not need (wood-fired oven, special knives, proving chamber), calibrated processes, quality metrics. &lt;strong>Level 5&lt;/strong> is the restaurant with a Michelin star: the whole system works, &lt;strong>the dish is the result of the organisation, not of one person&amp;rsquo;s talent&lt;/strong>.&lt;/p>
&lt;p>The analogy holds to the end, including the most interesting detail: &lt;strong>you can operate at any level&lt;/strong>, but the promises you can keep are different. The street stall cannot promise a consistent experience to 80 diners with a booking. The cluster at level 1 cannot promise productive multi-tenant LLM service with an SLA. In both cases the problem is not the &lt;strong>technical capability of the last component&lt;/strong> (the griddle cooks; the pod starts); it is the &lt;strong>organisational capability of the whole system&lt;/strong>.&lt;/p>
&lt;p>Let us go level by level.&lt;/p>
&lt;h2 id="level-0--chaotic-the-server-with-linux-and-nothing-else">Level 0 — Chaotic: the server with Linux and nothing else&lt;/h2>
&lt;p>&lt;strong>The capability it gives.&lt;/strong> Running containers with &lt;code>docker&lt;/code>/&lt;code>podman&lt;/code>, running binaries, connecting the server to the network. The operator can SSH in, do things, and see results.&lt;/p>
&lt;p>&lt;strong>The test for the level.&lt;/strong> &lt;em>&amp;ldquo;If I reinstall the server from scratch, can I leave it identical to how it was in one afternoon, using only saved notes?&amp;rdquo;&lt;/em>. If the answer is no (because the steps are in the head of whoever built it, in &lt;code>.bash_history&lt;/code>, in an out-of-date wiki), you are at level 0.&lt;/p>
&lt;p>&lt;strong>Minimum pieces to settle before moving up to level 1.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Suggested decision in 2026&lt;/th>
&lt;th>Why it matters when moving up&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Linux distribution&lt;/td>
&lt;td>Stable Debian or Ubuntu LTS&lt;/td>
&lt;td>Long, predictable support, recent kernel available&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kernel&lt;/td>
&lt;td>Recent LTS (≥ 6.6) with BPF and modern schedulers&lt;/td>
&lt;td>Cilium/eBPF and recent NVIDIA drivers demand it&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NVIDIA drivers&lt;/td>
&lt;td>The version that matches the CUDA of the LLM engine you will serve&lt;/td>
&lt;td>A driver/CUDA mismatch blocks vLLM before you start&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Container runtime&lt;/td>
&lt;td>&lt;code>containerd&lt;/code>&lt;/td>
&lt;td>CNCF standard, integrated with RKE2/kubeadm&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Root filesystem&lt;/td>
&lt;td>XFS or ext4 + LVM thin pools&lt;/td>
&lt;td>Snapshots, online expansion&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Time synchronisation&lt;/td>
&lt;td>&lt;code>chrony&lt;/code> with your own servers&lt;/td>
&lt;td>TLS, correlated logs and short certificates demand it&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Management network&lt;/td>
&lt;td>Dedicated VLAN, ACLs on the switch&lt;/td>
&lt;td>Isolate the control plane from workload traffic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cluster network&lt;/td>
&lt;td>LACP + jumbo frames + BGP (if you are going with Cilium)&lt;/td>
&lt;td>Intra-node NVLink does not rescue the service network&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>BMC / IPMI&lt;/td>
&lt;td>Out-of-band access with TLS and MFA&lt;/td>
&lt;td>Recovery when the operating system does not boot&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Antipatterns that pin you at level 0.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Pet&lt;/strong> servers (with their own names, configured by hand, not replaceable).&lt;/li>
&lt;li>Changes applied with &lt;code>vi&lt;/code> directly over &lt;code>/etc/...&lt;/code> with no commit to a repo.&lt;/li>
&lt;li>Deployment with &lt;code>docker-compose&lt;/code> with no healthchecks and no automatic restart.&lt;/li>
&lt;li>An inventory that lives in an Excel sheet nobody updates.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Deployment order within the level.&lt;/strong> System image from PXE/cloud-init with the initial configuration (LVM, hostname, network, SSH key, chrony) → bastion/jump host bootstrap → inventory in Ansible (or an equivalent declarative tool, even if it is later replaced) → NVIDIA drivers + container runtime → smoke test (a CUDA container passes &lt;code>nvidia-smi&lt;/code>). At this point, the server is ready for Kubernetes to come in.&lt;/p>
&lt;h2 id="level-1--repeatable-an-operational-kubernetes-cluster">Level 1 — Repeatable: an operational Kubernetes cluster&lt;/h2>
&lt;p>&lt;strong>The capability it gives.&lt;/strong> Scheduling containers with a scheduler, network abstraction between pods, persistent volumes, workload lifecycle, manual horizontal scaling.&lt;/p>
&lt;p>&lt;strong>The test for the level.&lt;/strong> &lt;em>&amp;ldquo;Can I lose a node and have the workloads rescheduled without human intervention?&amp;rdquo;&lt;/em>. If yes, you are at level 1. If not, because the pods are pinned to nodes, because there are no replicas, because the PVCs do not reattach, you are still at 0 with Kubernetes on top.&lt;/p>
&lt;p>&lt;strong>Minimum pieces for the level.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Suggested decision in 2026&lt;/th>
&lt;th>Main alternative&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>k8s distribution&lt;/td>
&lt;td>&lt;strong>RKE2&lt;/strong> (CIS-hardened by default, no commercial surcharge)&lt;/td>
&lt;td>k3s for very small edge, plain kubeadm for custom cases&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CNI&lt;/td>
&lt;td>&lt;strong>Cilium&lt;/strong> with kube-proxy replacement, BGP, Gateway API&lt;/td>
&lt;td>Calico (without BGP it does not compete with Cilium in 2026)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CSI block + filesystem + object&lt;/td>
&lt;td>&lt;strong>Rook-Ceph&lt;/strong> (RBD + CephFS + S3-compatible RGW)&lt;/td>
&lt;td>OpenEBS Mayastor + Garage for small deployments&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ingress&lt;/td>
&lt;td>Cilium Gateway API (better to unify with the CNI)&lt;/td>
&lt;td>NGINX Ingress, Traefik&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Basic certs&lt;/td>
&lt;td>Self-signed bootstrap&lt;/td>
&lt;td>(cert-manager comes in at level 3)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Workload handling&lt;/td>
&lt;td>&lt;code>kubectl apply&lt;/code> + Helm from the terminal&lt;/td>
&lt;td>No GitOps yet&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Container registry&lt;/td>
&lt;td>Any internal registry (or a trusted external one) with TLS&lt;/td>
&lt;td>(a managed internal registry comes in at level 2)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Antipatterns that drop you to level 0.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Services deployed with &lt;code>kubectl apply&lt;/code> from one person&amp;rsquo;s terminal and &lt;strong>with the YAML saved nowhere&lt;/strong>.&lt;/li>
&lt;li>Persistent volumes with no backup policy.&lt;/li>
&lt;li>A &amp;ldquo;single-node cluster&amp;rdquo; as permanent production, an architectural single point of failure.&lt;/li>
&lt;li>A CNI with no NetworkPolicy available or no BGP when the network requires it.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Deployment order within the level.&lt;/strong> RKE2 installed on at least three nodes for an HA control plane → Cilium installed in kube-proxy replacement mode + BGP control plane → Rook-Ceph on at least three nodes covering block (RBD) + filesystem (CephFS) + object (S3-compatible RGW) with 3× replication or Erasure Coding depending on the pool → smoke test (a Deployment with a PVC starts, the pods reschedule when a node is cordoned, the data persists).&lt;/p>
&lt;h2 id="level-2--defined-the-cluster-rebuilds-from-git">Level 2 — Defined: the cluster rebuilds from git&lt;/h2>
&lt;p>&lt;strong>The capability it gives.&lt;/strong> The cluster state lives in a repository. Every change goes through a commit. Anyone can rebuild the cluster (or an equivalent one) from the repo and the backups. Basic observability warns when something breaks.&lt;/p>
&lt;p>&lt;strong>The test for the level.&lt;/strong> &lt;em>&amp;ldquo;If I lose the whole cluster, can I recreate it in X hours from the repo + the backups, with no manual intervention beyond the bootstrap?&amp;rdquo;&lt;/em>. The hours are negotiable; what defines the level is that &lt;strong>the repo + the backups are enough&lt;/strong>, not that the person-who-knows is available.&lt;/p>
&lt;p>&lt;strong>Minimum pieces for the level.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Suggested decision in 2026&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Forge&lt;/td>
&lt;td>&lt;strong>Forgejo&lt;/strong> (or Gitea, GitLab CE)&lt;/td>
&lt;td>Self-hosted OSS, community fork of Gitea, open governance&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GitOps reconciler&lt;/td>
&lt;td>&lt;strong>Flux&lt;/strong>&lt;/td>
&lt;td>CNCF graduated, native multi-tenancy, lightweight&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Image registry&lt;/td>
&lt;td>&lt;strong>Forgejo Container Registry&lt;/strong>&lt;/td>
&lt;td>Next to the code, no extra piece&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Metrics TSDB&lt;/td>
&lt;td>&lt;strong>VictoriaMetrics&lt;/strong> + vmagent&lt;/td>
&lt;td>Higher throughput than plain Prometheus, long retention, PromQL compatible&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Visualisation&lt;/td>
&lt;td>&lt;strong>Grafana&lt;/strong>&lt;/td>
&lt;td>The de facto standard&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Logs&lt;/td>
&lt;td>&lt;strong>Loki&lt;/strong> or &lt;strong>Vector&lt;/strong>&lt;/td>
&lt;td>OSS, integrated with Grafana&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Alerting&lt;/td>
&lt;td>&lt;strong>Alertmanager&lt;/strong> + &lt;strong>Keep&lt;/strong> (OSS orchestrator)&lt;/td>
&lt;td>Keep adds multi-channel routing with no lock-in&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DB backups&lt;/td>
&lt;td>&lt;strong>Barman Cloud&lt;/strong> (Postgres)&lt;/td>
&lt;td>The standard for CNPG&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Object / dataset backups&lt;/td>
&lt;td>&lt;strong>Ceph RGW multisite&lt;/strong> + CephFS snapshots&lt;/td>
&lt;td>Cross-pool and cross-site&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Antipatterns that drop you to level 1.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;code>kubectl apply&lt;/code> run in production &lt;strong>outside&lt;/strong> the repo (undetected drift).&lt;/li>
&lt;li>&lt;code>main&lt;/code> branches with write permissions for humans without review.&lt;/li>
&lt;li>A monolithic repo with no tenant/infra/apps separation (cross changes that cannot be audited).&lt;/li>
&lt;li>Metrics kept no longer than 7 days (no SLO observable over a month).&lt;/li>
&lt;li>Alerting that fires for everything (fatigue) or for nothing (silence).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Deployment order within the level.&lt;/strong> Forgejo deployed first (it is a prerequisite for everything else) → Flux installed and pointing at the manifests repo → an initial repository with the Cilium and Rook-Ceph Helm releases reconciled by Flux (replacing the level 1 &lt;code>kubectl apply&lt;/code>) → VictoriaMetrics + Grafana + Loki via Helm/Flux → Postgres backups and Ceph snapshots scheduled → smoke test (tear the cluster down, restore from repo + backup, the services come back).&lt;/p>
&lt;h2 id="level-3--managed-identities-certificates-secrets-and-policies">Level 3 — Managed: identities, certificates, secrets and policies&lt;/h2>
&lt;p>&lt;strong>The capability it gives.&lt;/strong> Every human operating the cluster does so with their own identity (no shared &lt;code>kubeconfig&lt;/code>), with MFA and with limited permissions. Automatic internal TLS. Encrypted versioned secrets. Policies that &lt;strong>reject&lt;/strong> insecure configurations before they reach the cluster. A complete audit of who did what.&lt;/p>
&lt;p>&lt;strong>The test for the level.&lt;/strong> &lt;em>&amp;ldquo;If an attacker gets hold of an administrator&amp;rsquo;s laptop, what can they do in production?&amp;rdquo;&lt;/em>. At level 3 the answer is &lt;em>&amp;ldquo;little&amp;rdquo;&lt;/em>: MFA blocks the second factor, Kyverno policies block destructive changes without approval, the NetworkPolicies prevent lateral movement, the secrets are encrypted with an external KMS, and the audit log remains. At level 2, &lt;em>&amp;ldquo;everything&amp;rdquo;&lt;/em>.&lt;/p>
&lt;p>&lt;strong>Minimum pieces for the level.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Suggested decision in 2026&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>IdP / OIDC&lt;/td>
&lt;td>&lt;strong>Defguard&lt;/strong>&lt;/td>
&lt;td>Spanish OSS, WireGuard + OIDC + 2FA, multi-org&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Federation with the cluster&lt;/td>
&lt;td>OIDC in kube-apiserver, OIDC in Forgejo, OIDC in Grafana&lt;/td>
&lt;td>Consistent SSO&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Internal PKI&lt;/td>
&lt;td>&lt;strong>cert-manager&lt;/strong> + Trust Manager&lt;/td>
&lt;td>De facto standard, ACME and internal CA&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>External ACME&lt;/td>
&lt;td>Let&amp;rsquo;s Encrypt for edge certs&lt;/td>
&lt;td>No cost, automated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Secrets in git&lt;/td>
&lt;td>&lt;strong>SOPS&lt;/strong> + age or an external KMS&lt;/td>
&lt;td>Versionable, encrypted in the repo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Secret syncing&lt;/td>
&lt;td>&lt;strong>External Secrets Operator&lt;/strong>&lt;/td>
&lt;td>Pull from KMS / Vault into the cluster&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Policy as code&lt;/td>
&lt;td>&lt;strong>Kyverno&lt;/strong> (or OPA Gatekeeper)&lt;/td>
&lt;td>Kyverno has a shallower learning curve&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NetworkPolicy&lt;/td>
&lt;td>&lt;strong>Cilium NetworkPolicy&lt;/strong> + L7&lt;/td>
&lt;td>Default deny per namespace&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Runtime security&lt;/td>
&lt;td>&lt;strong>Tetragon&lt;/strong> (Cilium)&lt;/td>
&lt;td>eBPF, complements NetworkPolicy with detection&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Vulnerability scanning&lt;/td>
&lt;td>&lt;strong>Trivy&lt;/strong> in the CI pipeline + admission&lt;/td>
&lt;td>SBOM per image, blocking of critical CVEs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Audit log&lt;/td>
&lt;td>kube-apiserver with &lt;code>--audit-policy-file&lt;/code> sent to Loki&lt;/td>
&lt;td>Regulatory traceability&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Minimum Kyverno policies to keep alive.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Deny &lt;code>:latest&lt;/code> images or images without a sha digest.&lt;/li>
&lt;li>Deny pods without &lt;code>securityContext.runAsNonRoot=true&lt;/code>.&lt;/li>
&lt;li>Deny pods without &lt;code>resources.limits&lt;/code> (CPU + memory).&lt;/li>
&lt;li>Deny Services without an &lt;code>owner=&amp;lt;team&amp;gt;&lt;/code> label.&lt;/li>
&lt;li>Deny changes in critical namespaces (&lt;code>kube-system&lt;/code>, &lt;code>flux-system&lt;/code>) without an approval label.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Antipatterns that drop you to level 2.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>A &lt;code>kubeconfig&lt;/code> shared between administrators.&lt;/li>
&lt;li>Secrets in plain &lt;code>data:&lt;/code> in a manifest committed to the repo.&lt;/li>
&lt;li>NetworkPolicy absent from new namespaces by default (implicit allow-all).&lt;/li>
&lt;li>&lt;code>kubectl edit&lt;/code> or &lt;code>kubectl patch&lt;/code> in production without going through the repo.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Deployment order within the level.&lt;/strong> Defguard deployed and enrolled with WireGuard / OIDC → OIDC integration with kube-apiserver, Forgejo and Grafana → cert-manager installed and issuing internal certificates (your own CA for mTLS, Let&amp;rsquo;s Encrypt for the edge) → SOPS configured and External Secrets Operator installed → migration of secrets from plain to encrypted → Kyverno with initial policies in &lt;em>audit&lt;/em> mode, then &lt;em>enforce&lt;/em> → default-deny NetworkPolicy per namespace → Tetragon enabled → smoke test (try to bypass each policy and check that admission rejects it).&lt;/p>
&lt;h2 id="level-4--optimised-for-gpu-the-cluster-now-knows-what-an-h100-is">Level 4 — Optimised for GPU: the cluster now knows what an H100 is&lt;/h2>
&lt;p>&lt;strong>The capability it gives.&lt;/strong> The Kubernetes scheduler sees the GPUs, distinguishes them, can partition them (MIG) or multiplex them (time-slicing), expose DCGM metrics, autoscale with KEDA using metrics from the LLM workload itself (&lt;code>vllm:num_requests_running&lt;/code>, &lt;code>vllm:gpu_cache_usage_perc&lt;/code>), and carry traces with the GenAI semantic conventions. Everything needed for the LLM inference stack to rest on a platform that understands its nature.&lt;/p>
&lt;p>&lt;strong>The test for the level.&lt;/strong> &lt;em>&amp;ldquo;If I deploy a pod asking for &lt;code>nvidia.com/gpu: 1&lt;/code>, does it get scheduled on the right GPU, with the right slice, with DCGM metrics exposed, with GenAI observability ready to receive spans?&amp;rdquo;&lt;/em>. If yes, you are at level 4. If the answer requires &amp;ldquo;it depends which node and who deploys it&amp;rdquo;, not yet.&lt;/p>
&lt;p>&lt;strong>Minimum pieces for the level.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Suggested decision in 2026&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>GPU device plugin&lt;/td>
&lt;td>&lt;strong>NVIDIA GPU Operator&lt;/strong>&lt;/td>
&lt;td>Deploys drivers, container toolkit, DCGM and MIG manager with one operator&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>HW partitioning&lt;/td>
&lt;td>&lt;strong>MIG&lt;/strong> (Multi-Instance GPU) on the H100 where it applies&lt;/td>
&lt;td>Real hardware isolation, not time-slicing&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GPU metrics&lt;/td>
&lt;td>&lt;strong>DCGM Exporter&lt;/strong>&lt;/td>
&lt;td>SM utilisation, VRAM, temperature, throttling, NVLink bandwidth&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LLM metrics&lt;/td>
&lt;td>&lt;strong>vLLM Prometheus&lt;/strong> endpoint + scrape&lt;/td>
&lt;td>TTFT, TPOT, KV cache, prefix hit rate&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Autoscaling&lt;/td>
&lt;td>&lt;strong>KEDA&lt;/strong> with a Prometheus ScaledObject&lt;/td>
&lt;td>Scales on LLM metrics, not on CPU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LLM operators&lt;/td>
&lt;td>&lt;strong>vLLM Production Stack&lt;/strong> / &lt;strong>OME&lt;/strong> (Operator Model Engine)&lt;/td>
&lt;td>Declarative handling of models / adapters&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Traces&lt;/td>
&lt;td>&lt;strong>OpenTelemetry Collector&lt;/strong> with OTLP receivers + processors + exporters&lt;/td>
&lt;td>&lt;code>gen_ai.*&lt;/code> semantic conventions (&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">post&lt;/a>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LeaderWorkerSet&lt;/td>
&lt;td>LeaderWorkerSet API (k8s 1.30+)&lt;/td>
&lt;td>Tensor parallel topology coherent with NVLink&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Topology Manager&lt;/td>
&lt;td>enabled with &lt;code>single-numa-node&lt;/code>&lt;/td>
&lt;td>Pinning GPU pods to the right NUMA node&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>The key decision: MIG, time-slicing or passthrough.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>MIG&lt;/strong> divides an H100 into 1g.10gb, 2g.20gb, 3g.40gb, 7g.80gb (slices with real HW isolation). Useful for serving several small models or reserving capacity per tenant with a guarantee. Limitation: up to 7 instances per GPU, predefined profiles.&lt;/li>
&lt;li>&lt;strong>Time-slicing&lt;/strong> shares a GPU between several pods with no HW isolation. Useful for dev/test, not for multi-tenant production with an SLA.&lt;/li>
&lt;li>&lt;strong>Passthrough&lt;/strong> assigns the whole GPU to one pod. Useful for tensor parallel across several GPUs on the same node (a large LLM with TP=4).&lt;/li>
&lt;/ul>
&lt;p>For a productive LLM platform, the practical rule: &lt;strong>passthrough for the large models with TP&lt;/strong>, &lt;strong>MIG for embeddings and small models that cohabit&lt;/strong>, &lt;strong>never time-slicing in production&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>Antipatterns that drop you to level 3.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Installing NVIDIA drivers by hand outside the GPU Operator (silent breakage when Kubernetes is upgraded).&lt;/li>
&lt;li>Serving an LLM with &lt;code>requests.gpu: 1&lt;/code> without having decided on MIG / passthrough (you end up with idle GPUs from fragmentation or pods stepping on each other).&lt;/li>
&lt;li>KEDA autoscaling on CPU (the classic &lt;code>HorizontalPodAutoscaler&lt;/code>) on pods that are almost always at 10 % CPU but 95 % KV cache.&lt;/li>
&lt;li>OpenTelemetry deployed but without the &lt;code>gen_ai.*&lt;/code> semantic conventions (the traces are not LLM-aware).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Deployment order within the level.&lt;/strong> NVIDIA GPU Operator installed via Helm/Flux with the driver version that matches the chosen LLM engine → DCGM Exporter enabled and metrics visible in Grafana (NVIDIA dashboards imported) → MIG manager configured for the nodes where it makes sense (a typical mix on a 4×H100 SXM cluster: two GPUs with full passthrough for the general LLM at TP=4, two GPUs partitioned into 2×3g.40gb each for small LLMs + embeddings) → OpenTelemetry Collector with &lt;code>attributes&lt;/code> processors to enrich spans with your own labels (&lt;code>tenant_id&lt;/code>, &lt;code>priority_tier&lt;/code>) + exporters to Langfuse and to Tempo → KEDA installed with an example ScaledObject pointing at &lt;code>vllm:num_requests_running&lt;/code> → vLLM Production Stack or OME to declare models as a CRD → smoke test (a vLLM Deployment declared via CRD starts, serves a token, exposes metrics, the trace reaches Langfuse, KEDA scales under synthetic load).&lt;/p>
&lt;h2 id="level-5--handoff-the-cluster-is-an-llm-platform-the-seven-layers-go-on-top">Level 5 — Handoff: the cluster is an LLM platform, the seven layers go on top&lt;/h2>
&lt;p>Once at level 4, the cluster meets the contract that the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">post on the seven layers&lt;/a> assumed as its starting point. Level 5 adds no infrastructure: it adds the &lt;strong>LLM stack&lt;/strong> proper. For completeness, the seven components of level 5 are:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Gateway&lt;/strong> (Envoy AI Gateway) — it comes first, directing traffic to LLM inference and embeddings.&lt;/li>
&lt;li>&lt;strong>LLM inference&lt;/strong> (vLLM Production Stack or OME with vLLM) — on the GPUs already discovered by the level 4 GPU Operator.&lt;/li>
&lt;li>&lt;strong>Embeddings + reranker&lt;/strong> (Infinity, TEI) — a pod separate from the LLM, already covered in the previous post.&lt;/li>
&lt;li>&lt;strong>Vector store + relational data&lt;/strong> (Qdrant, PostgreSQL CNPG, Ceph RGW for weights and adapters, CephFS for datasets) — most of it already existed at level 2 as data; now it specialises for RAG.&lt;/li>
&lt;li>&lt;strong>LLM-aware observability&lt;/strong> (Langfuse) — it plugs into the level 4 OTel chain.&lt;/li>
&lt;li>&lt;strong>GitOps control plane&lt;/strong> — the one from level 2 remains the only legitimate authority.&lt;/li>
&lt;li>&lt;strong>Dependency tracking&lt;/strong> (Hubble flows + Otterize) — on the Cilium that already existed at level 1.&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>The criterion for promoting from level 4 to level 5&lt;/strong> is not technical: it is contractual. The cluster already supports LLMs; the decision is when to open real customer traffic. Promotion demands: a green golden eval of the model, a signed incident runbook, negotiated SLOs, a continuity plan, and mapping to ENS / NIS2 / 42001 where it applies.&lt;/p>
&lt;h2 id="the-maths-that-matter-what-skipping-a-level-costs">The maths that matter: what skipping a level costs&lt;/h2>
&lt;p>To quantify the thesis of the post, an order-of-magnitude estimate of the &lt;strong>cost of moving up each level in time&lt;/strong> versus &lt;strong>moving up after production is running&lt;/strong>. The figures are engineering time with a small platform team (2-3 people), assuming templates and prior experience.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Level&lt;/th>
&lt;th>Time to build on an empty cluster&lt;/th>
&lt;th>Time to retrofit with production running&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>0 → 1&lt;/td>
&lt;td>1-2 weeks&lt;/td>
&lt;td>1-2 weeks (little downstream refactoring)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1 → 2&lt;/td>
&lt;td>2-3 weeks&lt;/td>
&lt;td>4-8 weeks (migrating everything to git)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2 → 3&lt;/td>
&lt;td>2-4 weeks&lt;/td>
&lt;td>8-16 weeks (image rebuilds, secret migration, retroactive RBAC)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3 → 4&lt;/td>
&lt;td>1-2 weeks&lt;/td>
&lt;td>4-8 weeks (reconfiguring GPUs, moving models to MIG, instrumenting &lt;code>gen_ai.*&lt;/code>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4 → 5&lt;/td>
&lt;td>1-2 weeks&lt;/td>
&lt;td>2-4 weeks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Total 0 → 5&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~10-15 weeks&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~20-40 weeks if done in the wrong order&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The typical multiplier observable in practice: &lt;strong>2× to 3×&lt;/strong> the cost if it is done in the wrong order. And that assumes it &lt;strong>gets done at all&lt;/strong>, since many projects never get past level 2 because &amp;ldquo;the identity thing&amp;rdquo; can always wait for another sprint. By the time the incident arrives, it is too late to start.&lt;/p>
&lt;p>Beyond time, the &lt;strong>operational&lt;/strong> cost (night-time incidents, security escapes, invisible debt) grows exponentially with the gap between the actual level and the required level. A cluster at level 2 serving productive LLMs to regulated customers is a time bomb: technically it works, organisationally it does not.&lt;/p>
&lt;h2 id="final-diagram-the-complete-ladder-with-pieces">Final diagram: the complete ladder with pieces&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 520" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="final diagram of the five levels with their OSS pieces and the handoff to the LLM stack">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.bg{fill:#fafafa;stroke:#bbb;rx:8}.l0{fill:#f6e2e2;stroke:#a33}.l1{fill:#f4e3cf;stroke:#a63}.l2{fill:#eef0d0;stroke:#7a3}.l3{fill:#d8eecf;stroke:#373}.l4{fill:#dfe9f5;stroke:#356}.l5{fill:#ead8f5;stroke:#634}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#222}.tiny{font:600 10px sans-serif;fill:#222}.note{font:italic 10px sans-serif;fill:#555}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}&lt;/style>
&lt;defs>&lt;marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" class="lbl">Five maturity levels · OSS pieces · handoff to the LLM stack&lt;/text>
&lt;rect x="40" y="38" width="740" height="76" class="b l0"/>&lt;text x="60" y="56" class="tiny">LEVEL 0 · CHAOTIC · a server with Linux&lt;/text>
&lt;text x="60" y="74" class="sm">Debian / Ubuntu LTS · kernel ≥6.6 · containerd · NVIDIA drivers · LVM · chrony · BMC TLS+MFA&lt;/text>
&lt;text x="60" y="92" class="sm">Network: management VLAN, LACP, jumbo frames, BGP on the switch&lt;/text>
&lt;text x="60" y="108" class="note">Test: can I rebuild the server from notes?&lt;/text>
&lt;rect x="40" y="122" width="740" height="76" class="b l1"/>&lt;text x="60" y="140" class="tiny">LEVEL 1 · REPEATABLE · operational Kubernetes cluster&lt;/text>
&lt;text x="60" y="158" class="sm">RKE2 (CIS-hardened) · Cilium (kube-proxy replacement + BGP) · Rook-Ceph (RBD + CephFS + RGW)&lt;/text>
&lt;text x="60" y="176" class="sm">Gateway API · kubectl/Helm from the terminal · pods running with HA&lt;/text>
&lt;text x="60" y="192" class="note">Test: does losing a node require no human action?&lt;/text>
&lt;rect x="40" y="206" width="740" height="76" class="b l2"/>&lt;text x="60" y="224" class="tiny">LEVEL 2 · DEFINED · the cluster rebuilds from git&lt;/text>
&lt;text x="60" y="242" class="sm">Forgejo + Flux · Forgejo Container Registry · VictoriaMetrics + Grafana + Loki&lt;/text>
&lt;text x="60" y="260" class="sm">Barman Cloud backups + Ceph snapshots/RGW multisite · Alertmanager + Keep&lt;/text>
&lt;text x="60" y="276" class="note">Test: can I recreate the cluster from repo + backups?&lt;/text>
&lt;rect x="40" y="290" width="740" height="76" class="b l3"/>&lt;text x="60" y="308" class="tiny">LEVEL 3 · MANAGED · identity, certs, secrets, policies&lt;/text>
&lt;text x="60" y="326" class="sm">Defguard (OIDC + WireGuard) · cert-manager · SOPS + ESO · Kyverno · Trivy&lt;/text>
&lt;text x="60" y="344" class="sm">NetworkPolicy default deny · Tetragon · audit log&lt;/text>
&lt;text x="60" y="360" class="note">Test: what can an attacker do with an admin laptop?&lt;/text>
&lt;rect x="40" y="374" width="740" height="86" class="b l4"/>&lt;text x="60" y="392" class="tiny">LEVEL 4 · OPTIMISED FOR GPU · the scheduler understands the H100&lt;/text>
&lt;text x="60" y="410" class="sm">NVIDIA GPU Operator · DCGM Exporter · MIG manager · Topology Manager NUMA&lt;/text>
&lt;text x="60" y="428" class="sm">KEDA with vLLM metrics · OTel Collector with gen_ai.* · LeaderWorkerSet · OME&lt;/text>
&lt;text x="60" y="446" class="sm">Decision: passthrough TP=4 for the large LLM, MIG for small LLMs + embeddings&lt;/text>
&lt;text x="60" y="460" class="note" fill="#373">Test: does a pod with nvidia.com/gpu:1 schedule with metrics and traces ready?&lt;/text>
&lt;rect x="40" y="468" width="740" height="44" class="b l5"/>&lt;text x="60" y="486" class="tiny">LEVEL 5 · HANDOFF&lt;/text>&lt;text x="60" y="504" class="sm">The LLM stack (7 layers from the previous post) goes on top · gateway, vLLM, embeddings, Qdrant, Langfuse...&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The ladder is not decorative: each level &lt;strong>enables&lt;/strong> the next. You cannot have LLM-aware observability (level 4) without OTel deployed via Flux (level 2). You cannot have automatic internal TLS (level 3) without a root PKI that lives somewhere (registry and certificates managed from level 2). You cannot have KEDA scaling on vLLM metrics (level 4) without Prometheus / VictoriaMetrics scraping (level 2). The levels are &lt;strong>not a conceptual hierarchy&lt;/strong>: they are a hierarchy of &lt;strong>installation dependencies&lt;/strong>.&lt;/p>
&lt;h2 id="typical-design-decisions-that-break-progress">Typical design decisions that break progress&lt;/h2>
&lt;p>Mistakes seen repeatedly that drag the cluster back a level:&lt;/p>
&lt;p>&lt;strong>1. Jumping from level 1 straight to level 4.&lt;/strong> &amp;ldquo;We are in a hurry to serve the LLM, we will do the identity and GitOps stuff later.&amp;rdquo; Later is always two orders of magnitude more expensive and always arrives after the first incident.&lt;/p>
&lt;p>&lt;strong>2. Confusing Helm with GitOps.&lt;/strong> Having Helm charts is not level 2. It is level 1 with templates. Level 2 demands that a reconciler (Flux/ArgoCD) &lt;strong>applies&lt;/strong> the charts from a repo, &lt;strong>detects drift&lt;/strong> and &lt;strong>warns&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>3. cert-manager with no usage policy.&lt;/strong> Having auto-renewed certificates but using TLS only at the ingress, with no internal mTLS between services, leaves the TLS promise lame and turns level 3 into a cosplay of level 3.&lt;/p>
&lt;p>&lt;strong>4. NVIDIA drivers by hand.&lt;/strong> It works on day one and breaks the day of the first kernel upgrade. The rule: drivers &lt;strong>always via the GPU Operator&lt;/strong>, never operating system packages.&lt;/p>
&lt;p>&lt;strong>5. Prometheus metrics but 7 days of retention.&lt;/strong> Without long retention (≥ 90 days) there is no honest SLO. VictoriaMetrics with a year of retention costs little more than Prometheus with 7 days, and it unlocks compliance and serious postmortems.&lt;/p>
&lt;p>&lt;strong>6. OIDC only for kube-apiserver.&lt;/strong> If Forgejo, Grafana, Defguard and vLLM each have their own auth system, you do not have SSO, you have islands. An honest level 3 demands &lt;strong>federation&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>7. Kyverno in permanent &lt;em>audit&lt;/em> mode.&lt;/strong> Policies that do not reject are not policies, they are alerts. At some point you have to move to &lt;em>enforce&lt;/em>. Until then, you are still at level 2 wearing a level 3 face.&lt;/p>
&lt;p>&lt;strong>8. MIG with no conscious decision on the profile.&lt;/strong> Configuring MIG with the default profile without having measured the size of the models that will cohabit leaves fragmented GPUs with slices nobody uses. The rule: MIG only if you have measured and decided the profiles in advance.&lt;/p>
&lt;p>They all share one root: &lt;strong>declaring the level without passing the level&amp;rsquo;s test&lt;/strong>. Saying &amp;ldquo;we already did GitOps&amp;rdquo; when things are still applied with &lt;code>kubectl edit&lt;/code> in prod. Saying &amp;ldquo;we already did identity&amp;rdquo; when there is a shared admin &lt;code>kubeconfig&lt;/code>. Saying &amp;ldquo;we are ready for LLMs&amp;rdquo; when there is no DCGM Exporter and no Langfuse plugged in.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware-a-4h100-sxm-cluster">Applied to typical on-premise hardware: a 4×H100 SXM cluster&lt;/h2>
&lt;p>On the generic reference cluster (4×H100 SXM 80 GB, NVLink, 640 GB RAM), a reasonable setup after passing the five levels distributes the components like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">control plane (3 nodes without GPU, hostnames cp-01..03)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── kube-apiserver, etcd, controller-manager, scheduler
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── Flux, Forgejo, cert-manager, External Secrets, Kyverno
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── Tetragon (DaemonSet here too)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">worker plane (≥ 3 nodes without GPU, hostnames worker-cpu-01..03)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── Cilium agent (DaemonSet)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── Rook-Ceph OSDs + MONs + MDS (CephFS) + RGW (S3)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── VictoriaMetrics + Grafana + Loki
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── Defguard (StatefulSet)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── Langfuse + OTel Collector
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">GPU worker plane (≥ 2 nodes with 4×H100 SXM, hostnames worker-gpu-01..02)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── NVIDIA GPU Operator (driver + container toolkit)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── DCGM Exporter (DaemonSet)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── MIG manager (configuring the chosen profile)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── vLLM (Deployment) — general LLM TP=4 occupies 4 GPUs (passthrough)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── vLLM (Deployment) — code LLM TP=2 occupies 2 GPUs
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── Infinity (embeddings) — 2 replicas cohabit on 2 MIG slices
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── KEDA scaler listening to vLLM metrics
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The operational rule: &lt;strong>the control plane and the CPU plane are separated from the GPU plane&lt;/strong>. An incident on the GPU plane must not take the control plane with it (the control plane is what recovers the cluster). And the CPU plane concentrates everything that moves relevant state (Forgejo, Rook-Ceph, Postgres CNPG, Langfuse): it is the heart to protect.&lt;/p>
&lt;p>The GPU hardware specialises as much as possible: GPU pods run &lt;strong>only&lt;/strong> on GPU nodes, and GPU nodes run &lt;strong>nothing&lt;/strong> CPU-bound beyond the operational overhead (Cilium, GPU Operator, DCGM). This is enforced with &lt;code>nodeSelector&lt;/code> + taints/tolerations + a Kyverno policy that rejects pods with no GPU requests being scheduled onto GPU nodes.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;p>This post walks the vertical path upwards. Some horizontal and cross-cutting pieces still deserve an article of their own:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Multi-site active/standby&lt;/strong>: how two clusters are federated with Cilium Cluster Mesh and what changes at each level when there are two sites instead of one.&lt;/li>
&lt;li>&lt;strong>Migration between levels with real traffic&lt;/strong>: how a cluster already in production is retrofitted to the next level with no downtime.&lt;/li>
&lt;li>&lt;strong>Day-to-day operation&lt;/strong>: runbooks per level, which dashboards to look at each morning, which SLOs to define per component.&lt;/li>
&lt;li>&lt;strong>The cost plane&lt;/strong>: what each level costs in hardware, energy, engineering hours, optional OSS licences (commercial support from Rancher, Cilium Enterprise, etc.) and when each expense is justified.&lt;/li>
&lt;li>&lt;strong>Operationalised compliance&lt;/strong>: how levels 3 and 4 map to ENS High, NIS2 and ISO/IEC 42001 controls without turning the cluster into a paperwork exercise.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">GitOps for the inference stack with Flux: operating the assistant as code&lt;/a> — the maturity level at which the platform is operated as code with Flux.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Anatomy of an on-premise LLM inference stack&lt;/a> — what gets built &lt;strong>on top&lt;/strong> of a level 4 cluster. This post is its architectural prequel.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages&lt;/a> — individual cards for many of the pieces cited here.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the OTel of level 4 with detail on the &lt;code>gen_ai.*&lt;/code> semantic conventions.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the operational framework that lives above a level 5 cluster.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> — the twelve DCGM and vLLM metrics that turn level 4 into a real cockpit.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes&lt;/a> — KEDA with custom metrics is the piece that activates level 4&amp;rsquo;s &amp;ldquo;OPTIMISED FOR GPU&amp;rdquo; when the first traffic peak arrives.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow for LLM models&lt;/a> — progressive rollout is the first thing level 5 must be able to do without the operator having to watch the screen.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router&lt;/a> — the piece that appears from level 3 onwards (it needs level 3&amp;rsquo;s OIDC + cert-manager + NetworkPolicy to make sense) and that connects level 4&amp;rsquo;s pools to external clients.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>RKE2 Documentation — &lt;a href="https://docs.rke2.io/">docs.rke2.io&lt;/a>&lt;/li>
&lt;li>Cilium documentation — &lt;a href="https://docs.cilium.io/">docs.cilium.io&lt;/a>&lt;/li>
&lt;li>Rook-Ceph — &lt;a href="https://rook.io/">rook.io&lt;/a>&lt;/li>
&lt;li>Flux GitOps toolkit — &lt;a href="https://fluxcd.io/">fluxcd.io&lt;/a>&lt;/li>
&lt;li>Forgejo — &lt;a href="https://forgejo.org/">forgejo.org&lt;/a>&lt;/li>
&lt;li>cert-manager — &lt;a href="https://cert-manager.io/">cert-manager.io&lt;/a>&lt;/li>
&lt;li>External Secrets Operator — &lt;a href="https://external-secrets.io/">external-secrets.io&lt;/a>&lt;/li>
&lt;li>Kyverno — &lt;a href="https://kyverno.io/">kyverno.io&lt;/a>&lt;/li>
&lt;li>NVIDIA GPU Operator — &lt;a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/">docs.nvidia.com/datacenter/cloud-native/gpu-operator&lt;/a>&lt;/li>
&lt;li>DCGM Exporter — &lt;a href="https://github.com/NVIDIA/dcgm-exporter">github.com/NVIDIA/dcgm-exporter&lt;/a>&lt;/li>
&lt;li>KEDA — &lt;a href="https://keda.sh/">keda.sh&lt;/a>&lt;/li>
&lt;li>LeaderWorkerSet API — &lt;a href="https://github.com/kubernetes-sigs/lws">github.com/kubernetes-sigs/lws&lt;/a>&lt;/li>
&lt;li>vLLM Production Stack — &lt;a href="https://docs.vllm.ai/">docs.vllm.ai/en/latest/serving/production_stack.html&lt;/a>&lt;/li>
&lt;li>OpenTelemetry Semantic Conventions for GenAI — &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">opentelemetry.io/docs/specs/semconv/gen-ai&lt;/a>&lt;/li>
&lt;li>CIS Kubernetes Benchmark&lt;/li>
&lt;li>NIST SP 800-207 — Zero Trust Architecture&lt;/li>
&lt;/ul></description></item><item><title>Anatomy of an on-premise LLM inference stack: the seven layers that have to hold each other up</title><link>https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/</link><pubDate>Sat, 30 May 2026 16:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The &lt;strong>on-premise LLM inference stack is not a model server&lt;/strong>: it is a building of &lt;strong>seven layers&lt;/strong> that hold each other up. The inference layer (vLLM / SGLang) serves tokens, but without a dedicated embeddings layer RAG does not work; without the gateway layer the client couples its SDK to the specific engine; without an LLM-aware observability layer (Langfuse + OpenTelemetry GenAI) any quality degradation goes unnoticed; without a GitOps control plane layer (Flux + Forgejo) any manual change leaves invisible debt; and without a &lt;strong>dependency tracking&lt;/strong> layer (Hubble + intent-based policies) decommissioning a Service silently breaks applications nobody remembers were using it. This post is born from a concrete incident: a pipeline that reported &lt;code>status: completed&lt;/code> and &lt;code>matched_jobs: 0&lt;/code> for days because it kept calling an Ollama that had already been scaled to zero, while a badly written &lt;code>except&lt;/code> labelled the generic &lt;code>ConnectError&lt;/code> as &amp;ldquo;ChromaDB indexing error&amp;rdquo; and the vector store, innocent, took the blame. Each of the three symptoms was the cry of a layer that was missing or badly designed. The body of the post walks the seven layers with their canonical OSS piece, the design decisions that break them, the sizing maths on a generic &lt;strong>4×H100 SXM cluster (320 GB of VRAM, NVLink)&lt;/strong> and a final diagram of the connected stack. The thesis: a stack that passes the incident test is not measured by its peak throughput, it is measured by &lt;strong>how long it takes to shout when something drifts from the design&lt;/strong>.&lt;/p>
&lt;h2 id="you-are-here-the-seven-layers-seen-from-above">You are here: the seven layers seen from above&lt;/h2>
&lt;p>Before going into detail, the map. The seven layers are not seven servers: they are seven responsibilities the stack has to cover. A layer can collapse into one pod (gateway) or spread across several components (observability = traces + metrics + flow logs). What it cannot do is &lt;strong>be missing&lt;/strong>.&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="the seven layers of the on-premise LLM inference stack">&lt;style>
.layer{stroke:#333;stroke-width:1.4;rx:6}
.l1{fill:#ffd9b8;stroke:#a44}
.l2{fill:#ffe6c2;stroke:#a55}
.l3{fill:#fff0d0;stroke:#a66}
.l4{fill:#dfe9f5;stroke:#356}
.l5{fill:#d8eecf;stroke:#373}
.l6{fill:#f5e3d8;stroke:#763}
.l7{fill:#ead8f5;stroke:#634}
.title{font:600 13px sans-serif;fill:#222}
.sm{font:11px sans-serif;fill:#222}
.tiny{font:600 10px sans-serif;fill:#222}
.note{font:italic 10px sans-serif;fill:#555}
.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}
&lt;/style>
&lt;defs>&lt;marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" class="title">On-premise LLM inference stack — seven layers, one responsibility each&lt;/text>
&lt;rect x="40" y="40" width="740" height="38" class="layer l1"/>
&lt;text x="60" y="64" class="sm">&lt;tspan font-weight="700">1 · Gateway&lt;/tspan> — Envoy AI Gateway · Cilium Gateway API · LiteLLM &lt;tspan class="note">OpenAI-compatible · JWT auth · routing by model · rate limit&lt;/tspan>&lt;/text>
&lt;rect x="40" y="86" width="365" height="50" class="layer l2"/>
&lt;text x="60" y="106" class="sm">&lt;tspan font-weight="700">2 · LLM inference&lt;/tspan>&lt;/text>
&lt;text x="60" y="122" class="tiny">vLLM · SGLang · TensorRT-LLM &lt;tspan class="note" fill="#555">multi-model · multi-LoRA · FP8 KV&lt;/tspan>&lt;/text>
&lt;rect x="415" y="86" width="365" height="50" class="layer l3"/>
&lt;text x="435" y="106" class="sm">&lt;tspan font-weight="700">3 · Embeddings&lt;/tspan>&lt;/text>
&lt;text x="435" y="122" class="tiny">Infinity · TEI · OVMS · sentence-transformers &lt;tspan class="note" fill="#555">fixed dim · separate from the LLM&lt;/tspan>&lt;/text>
&lt;rect x="40" y="144" width="740" height="50" class="layer l4"/>
&lt;text x="60" y="164" class="sm">&lt;tspan font-weight="700">4 · Vector store + relational data&lt;/tspan>&lt;/text>
&lt;text x="60" y="180" class="tiny">Qdrant · pgvector · MinIO (weights · adapters · corpus) &lt;tspan class="note" fill="#555">collection versioned by dim · bucket per tenant&lt;/tspan>&lt;/text>
&lt;rect x="40" y="202" width="740" height="50" class="layer l5"/>
&lt;text x="60" y="222" class="sm">&lt;tspan font-weight="700">5 · LLM-aware observability + infrastructure + network&lt;/tspan>&lt;/text>
&lt;text x="60" y="238" class="tiny">Langfuse · OTel GenAI · VictoriaMetrics · Grafana · Hubble · DCGM &lt;tspan class="note" fill="#555">end-to-end trace_id&lt;/tspan>&lt;/text>
&lt;rect x="40" y="260" width="740" height="40" class="layer l6"/>
&lt;text x="60" y="284" class="sm">&lt;tspan font-weight="700">6 · GitOps control plane&lt;/tspan> — Flux · Forgejo · cert-manager · External Secrets &lt;tspan class="note" fill="#555">binding drift detection&lt;/tspan>&lt;/text>
&lt;rect x="40" y="308" width="740" height="40" class="layer l7"/>
&lt;text x="60" y="332" class="sm">&lt;tspan font-weight="700">7 · Dependency tracking&lt;/tspan> — Hubble flow logs · Otterize · NetworkPolicy as code &lt;tspan class="note" fill="#555">who calls what, observed and declared&lt;/tspan>&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Layers 1–4 sit on the &lt;strong>request path&lt;/strong>: if they fall, the client notices in seconds. Layers 5–7 sit on the path of &lt;strong>design and operations&lt;/strong>: if they fall, there is no immediately visible error, and that is why they are the ones that produce silent incidents, which is how most serious incidents end up. This post argues that &lt;strong>the quality of the stack is measured in layers 5, 6 and 7&lt;/strong>, because 1–4 are commodities where everyone picks roughly the same pieces.&lt;/p>
&lt;h2 id="the-analogy-the-office-building-with-shared-services">The analogy: the office building with shared services&lt;/h2>
&lt;p>Picture a twelve-storey office building with several tenants. It has a &lt;strong>concierge desk&lt;/strong> (gateway: filters who comes in), it has &lt;strong>lifts&lt;/strong> (LLM inference: they move the heavy load), it has &lt;strong>stairs and a goods lift&lt;/strong> (embeddings: light but constant traffic, hardly anyone notices them until they break), it has &lt;strong>plumbing and water tanks&lt;/strong> (vector store: what actually holds the state), it has a &lt;strong>distribution board and sensors&lt;/strong> (observability: what warns you when something is drawing more than planned), it has a &lt;strong>building manager&lt;/strong> (GitOps control plane: the only legitimate authority to move anything), and it has a &lt;strong>tenant register&lt;/strong> (dependency tracking: who is connected to which shared service).&lt;/p>
&lt;p>When a new client moves into the third floor and asks to install a server that needs more amperage than planned, the problem is not electrical: it is &lt;strong>administrative&lt;/strong>. If the client can go straight to the board and plug in whatever they like, the building survives for a while and then a breaker trips at three in the morning. If the client has to go through the manager, the manager consults the tenant register (is anyone else hanging off that same circuit?), reviews the electrical planning (are we at the limit?) and either authorises or redirects. The building stays standing not because of its electrical installation but because of the &lt;strong>discipline of going through the manager&lt;/strong>.&lt;/p>
&lt;p>The LLM inference stack works identically. The physical layers (1–4) are the visible ones, the ones marketing people put on the slide. The governance layers (5–7) are the ones that distinguish a platform from a pile of pods with good luck.&lt;/p>
&lt;p>Now to the incident that motivates the whole post.&lt;/p>
&lt;h2 id="the-hook-the-log-that-lied-for-six-days">The hook: the log that lied for six days&lt;/h2>
&lt;p>The application is called &lt;em>jobhunter&lt;/em> internally. It is a cron pipeline that every six hours sweeps public job-ad sources, filters by EU geography, embeds the new ads, indexes them in a vector store and matches them against search profiles. The last step fires notifications.&lt;/p>
&lt;p>For six days the pipeline reported the same thing on every run:&lt;/p>
&lt;pre tabindex="0">&lt;code>status: completed
total_found: 756
new: 23
matched_jobs: 0 ← zero, run after run
&lt;/code>&lt;/pre>&lt;p>And in the logs:&lt;/p>
&lt;pre tabindex="0">&lt;code>[INFO] httpx: POST chromadb:8000/api/v2/.../collections/&amp;lt;id&amp;gt;/delete → 200 OK
[INFO] matcher: Purged 12 expired jobs from ChromaDB
[ERROR] pipeline: ChromaDB indexing error: All connection attempts failed
[INFO] httpx: POST chromadb:8000/api/v2/.../collections/&amp;lt;id&amp;gt;/get → 200 OK
[ERROR] pipeline: Matching error: All connection attempts failed
&lt;/code>&lt;/pre>&lt;p>This is a log that invites you to blame ChromaDB. And in fact the first post-mortem written internally pointed at a version incompatibility between the Python client and the v2 server. A reasonable hypothesis, technically plausible, completely false.&lt;/p>
&lt;p>The real cause: weeks earlier the general LLM on the platform had been migrated from &lt;strong>Ollama&lt;/strong> to &lt;strong>vLLM&lt;/strong>. The migration was clean for the two applications that depended directly on the large model, their manifests were pointed at the new endpoint. What nobody did was look at who else was calling the Service &lt;code>ollama.ollama.svc:11434&lt;/code>. &lt;em>jobhunter&lt;/em> called it to generate the embeddings for the ads. When the Ollama deployment was scaled to zero, the Service was left empty, and any outbound connection received a generic &lt;code>ConnectError(&amp;quot;All connection attempts failed&amp;quot;)&lt;/code> from &lt;code>httpcore&lt;/code>. The &lt;code>try/except&lt;/code> wrapping the whole matching stage caught the exception and labelled it &lt;em>&amp;ldquo;ChromaDB indexing error&amp;rdquo;&lt;/em>, because that was the lexical wrapper of the block, not because ChromaDB had anything to do with it. ChromaDB was answering 200 to &lt;em>delete&lt;/em> and &lt;em>get&lt;/em> in those same logs.&lt;/p>
&lt;p>Three factors kept the incident alive for six days:&lt;/p>
&lt;ol>
&lt;li>The &lt;strong>&lt;code>except&lt;/code> named the wrapper rather than the stage that failed&lt;/strong>. The log said &lt;em>&amp;ldquo;ChromaDB indexing error&amp;rdquo;&lt;/em> when the error was an embeddings call against a nonexistent Service.&lt;/li>
&lt;li>The pipeline returned &lt;strong>&lt;code>status: completed&lt;/code>&lt;/strong> even when there were errors. No alerting based on &lt;code>status&lt;/code> fired. The metric that would have fired (matched_jobs stuck at zero) was not instrumented.&lt;/li>
&lt;li>The pipeline image ran as &lt;strong>&lt;code>:latest&lt;/code> with no versioning&lt;/strong>, no SBOM, no reproducibility. When it started failing, it was impossible to know which Ollama client it had been built with.&lt;/li>
&lt;/ol>
&lt;p>Each of the three symptoms is the cry of a layer that was missing. The first asks for &lt;strong>LLM-aware observability&lt;/strong> that distinguishes stages (layer 5). The second asks for the &lt;strong>pipeline logic&lt;/strong> and the Prometheus metrics to behave like contracts (layer 5, SLI/SLO dimension). The third asks for &lt;strong>GitOps with pinned images&lt;/strong> and an auditable SBOM (layer 6). And the whole incident, decommissioning a Service without knowing who consumes it, shouts &lt;strong>dependency tracking&lt;/strong> (layer 7).&lt;/p>
&lt;p>The rest of the post walks the seven layers through that lens: what each one solves, which OSS piece implements it in 2026, and which typical design decision breaks it.&lt;/p>
&lt;h2 id="layer-1--gateway-the-client-sdk-must-not-couple-to-the-engine">Layer 1 — Gateway: the client SDK must not couple to the engine&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> That the client sends &lt;code>POST /v1/chat/completions&lt;/code> with the standard OpenAI SDK and never learns which engine (vLLM, SGLang, TensorRT-LLM), which specific model, which LoRA adapter or even which GPU pool is serving the request. Authentication, rate limit, routing by &lt;code>body.model&lt;/code> and by tenant, header injection for tracing.&lt;/p>
&lt;p>&lt;strong>Canonical OSS piece in 2026.&lt;/strong> &lt;strong>Envoy AI Gateway&lt;/strong> (Envoy with GenAI extensions: routing by model, token-based rate limit, fallback chains) or &lt;strong>Cilium Gateway API&lt;/strong> with your own filters. For cases where the client wants multi-provider without distinguishing on-prem from SaaS, &lt;strong>LiteLLM Proxy&lt;/strong> is the lightweight equivalent.&lt;/p>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Exposing the inference engine endpoint directly. If clients call &lt;code>http://vllm-prod.svc:8000&lt;/code>, any change of engine, model or pool forces you to touch the code of every app. The rule: &lt;strong>the engine changes, the contract does not&lt;/strong>. The OpenAI SDK is standard; the routing behind the gateway is where design freedom lives.&lt;/p>
&lt;p>&lt;strong>Where &lt;em>jobhunter&lt;/em> failed.&lt;/strong> There was no gateway. The app called &lt;code>ollama.ollama.svc:11434&lt;/code> directly. When Ollama died, there was no intermediate layer that could answer with a &lt;em>fallback&lt;/em>, a &lt;em>retry against another pool&lt;/em>, or at least a &lt;em>503 error with a descriptive body&lt;/em>.&lt;/p>
&lt;h2 id="layer-2--llm-inference-vllm-as-the-default-choice-sglang-when-prefix-caching-rules">Layer 2 — LLM inference: vLLM as the default choice, SGLang when prefix caching rules&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> Serving tokens with throughput and latency under control: continuous batching, PagedAttention so the KV cache does not fragment the VRAM, FP8 so a 32B model fits with room to spare, multi-LoRA for per-tenant customisation without replicating the base, structured output for function calling with schema guarantees.&lt;/p>
&lt;p>&lt;strong>Canonical OSS piece in 2026.&lt;/strong> &lt;strong>vLLM&lt;/strong> is the default choice: it covers the state of the art (&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">PagedAttention&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">FP8 quantisation&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">multi-LoRA&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">structured output&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">speculative decoding&lt;/a>). &lt;strong>SGLang&lt;/strong> comes in when the workload has high prefix caching (long chat with large system prompts, agents with repeated instructions), because its RadixAttention composes better than standard prefix caching. For very specialised inference with proprietary NVIDIA kernels, &lt;strong>TensorRT-LLM&lt;/strong>, accepting the hardware lock-in.&lt;/p>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Assuming a single model &amp;ldquo;does everything&amp;rdquo;. A 32B chat LLM does not serve &lt;code>/v1/embeddings&lt;/code>; if you try, vLLM answers &lt;code>BadRequestError: &amp;quot;The model does not support Embeddings API&amp;quot;&lt;/code>. Assuming it did was one of the concrete wounds of the incident: the app expected an endpoint the model did not implement.&lt;/p>
&lt;p>&lt;strong>The design decision that costs more later.&lt;/strong> Serving the LLM with aggressive quantisation (INT4) without a quality eval calibrated for your corpus. INT4 with AWQ or GPTQ saves VRAM, but it degrades answers in technical or legal Spanish in a measurable way. The rule: any quantisation change goes through the same golden eval as a model change.&lt;/p>
&lt;h2 id="layer-3--embeddings-separate-from-the-llm-fixed-dimension-a-life-of-its-own">Layer 3 — Embeddings: separate from the LLM, fixed dimension, a life of its own&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> Generating dense vectors for the RAG corpus and for retrieval queries. The embeddings model is &lt;strong>a different thing&lt;/strong> from the chat LLM: different architecture (encoder, not decoder), different size (hundreds of millions of parameters, not tens of thousands of millions), different API (an &lt;code>/embeddings&lt;/code> endpoint that takes text and returns a fixed-dimension vector).&lt;/p>
&lt;p>&lt;strong>Canonical OSS piece in 2026.&lt;/strong> &lt;strong>Infinity&lt;/strong> or &lt;strong>Hugging Face Text Embeddings Inference (TEI)&lt;/strong> to serve models of the &lt;code>bge-*&lt;/code>, &lt;code>multilingual-e5-*&lt;/code>, &lt;code>nomic-embed-*&lt;/code> families with high throughput and multi-model support. &lt;strong>OpenVINO Model Server&lt;/strong> when Intel hardware is available. &lt;strong>sentence-transformers&lt;/strong> as a fallback embedded in the application itself when the corpus is small and the deployment is constrained.&lt;/p>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Treating it as &amp;ldquo;the LLM does that too&amp;rdquo;. It does not if it is chat-only; and even when it does (some models have a dual endpoint), mixing chat and embeddings serving in the same process punishes the throughput of both. The physical separation &lt;strong>is&lt;/strong> the design.&lt;/p>
&lt;p>&lt;strong>The technical fact people forget.&lt;/strong> The &lt;strong>vector store and the embeddings model form an indivisible unit&lt;/strong>. Changing the model from &lt;code>multilingual-e5-large&lt;/code> (1024 dim) to &lt;code>multilingual-e5-small&lt;/code> (384 dim) is &lt;strong>not a substitution&lt;/strong>: it is creating a new collection (&lt;code>my_corpus_v2&lt;/code>) and re-embedding the whole corpus. If you &lt;code>upsert&lt;/code> into the old collection, you hit a dim mismatch at runtime that takes the pod down. This seems obvious and is violated constantly, because the embeddings model is chosen once and then forgotten.&lt;/p>
&lt;h2 id="layer-4--vector-store--relational-data--storage-what-actually-holds-the-state">Layer 4 — Vector store + relational data + storage: what actually holds the state&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> Persisting the vectors with efficient filters (&lt;code>tenant_id&lt;/code>, &lt;code>created_at&lt;/code>, &lt;code>source&lt;/code>), persisting the relational metadata (users, configs, versioned prompts, traces), and persisting the model weights, the LoRA adapters, the datasets and the original corpora.&lt;/p>
&lt;p>&lt;strong>Canonical OSS pieces in 2026.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Qdrant&lt;/strong> for large collections (&amp;gt;200 k vectors), payload-aware filters, multi-tenancy via collection or via field.&lt;/li>
&lt;li>&lt;strong>pgvector&lt;/strong> for small collections with mandatory relational joins (wanting to do &lt;code>WHERE doc.author = ... AND vector &amp;lt;=&amp;gt; $1&lt;/code> in the same SQL).&lt;/li>
&lt;li>&lt;strong>PostgreSQL operated by CloudNativePG (CNPG)&lt;/strong> for the relational side: Barman Cloud backups, replication, connection via pooler.&lt;/li>
&lt;li>&lt;strong>MinIO&lt;/strong> for S3-compatible objects: bucket per tenant, cross-site replication, weights and adapters versioned by sha256.&lt;/li>
&lt;li>&lt;strong>Redis&lt;/strong> for queues, rate-limit counters and a cache of frequent retrievals.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Assuming the vector store is &lt;em>stateless ephemeral&lt;/em>. It is exactly the opposite: it is the component where losing state costs the most. Without verified backups of the vector store, an index corruption forces you to re-embed the whole corpus, and on a corpus of millions of documents that is hours or days of GPU.&lt;/p>
&lt;p>&lt;strong>The design decision that pays later.&lt;/strong> Forgetting to version the collection by &lt;strong>dimension&lt;/strong> and &lt;strong>embeddings model&lt;/strong>. Suggested convention: &lt;code>my_corpus__embed-multie5l__1024d__v3&lt;/code>. The name carries metadata; any change in any of the three attributes forces a new collection. It is ugly but it protects against an accidental &lt;code>upsert&lt;/code> with the wrong dim.&lt;/p>
&lt;h2 id="layer-5--observability-llm-aware-traces--infrastructure-metrics--flow-logs">Layer 5 — Observability: LLM-aware traces + infrastructure metrics + flow logs&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> That any inference can be recovered from its &lt;code>trace_id&lt;/code>, with all the &lt;code>gen_ai.*&lt;/code> attributes (&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">semantic conventions&lt;/a>) plus your own attributes (&lt;code>tenant_id&lt;/code>, &lt;code>adapter_id&lt;/code>, &lt;code>priority_tier&lt;/code>), a breakdown of latency (queue → prefill → decode → network), tokens consumed, tools invoked, exact model and adapter. And in parallel: Prometheus metrics from vLLM (&lt;code>vllm:num_requests_running&lt;/code>, &lt;code>vllm:gpu_cache_usage_perc&lt;/code>, &lt;code>vllm:prefix_cache_hit_rate&lt;/code>), from the GPU (DCGM Exporter), from the network (Hubble flow logs with drops and NetworkPolicy enforcement).&lt;/p>
&lt;p>&lt;strong>Canonical OSS pieces in 2026.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>OpenTelemetry Collector&lt;/strong> as the single transport for traces, metrics and logs, with OTLP receivers and separate exporters per destination.&lt;/li>
&lt;li>&lt;strong>Langfuse&lt;/strong> self-hosted for the LLM-aware side: tracing, prompt versioning, evals with LLM-as-judge (&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">post&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">post&lt;/a>).&lt;/li>
&lt;li>&lt;strong>VictoriaMetrics + Grafana&lt;/strong> for high-throughput TSDB metrics with long retention.&lt;/li>
&lt;li>&lt;strong>Hubble&lt;/strong> (Cilium) for L3/L4/L7 flow logs and NetworkPolicy visualisation.&lt;/li>
&lt;li>&lt;strong>DCGM Exporter&lt;/strong> for GPU metrics.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Wrapping the whole pipeline stage in a single &lt;code>try/except&lt;/code> that labels the exception with the wrapper&amp;rsquo;s name. The operational rule: &lt;strong>one &lt;code>try/except&lt;/code> per stage&lt;/strong>, with the stage label in the message and a Prometheus metric with &lt;code>labels={&amp;quot;stage&amp;quot;: &amp;quot;&amp;lt;name&amp;gt;&amp;quot;}&lt;/code>. That way &lt;em>&amp;ldquo;ChromaDB indexing error&amp;rdquo;&lt;/em> would never have been the log for an embeddings failure; it would have been &lt;em>&amp;ldquo;Embeddings call failed: ConnectError(ollama:11434)&amp;rdquo;&lt;/em>.&lt;/p>
&lt;p>&lt;strong>Complementary rule.&lt;/strong> The pipeline returns &lt;code>status: completed&lt;/code> if and only if &lt;strong>there were no errors&lt;/strong>. With errors it returns &lt;code>completed_with_errors&lt;/code> or &lt;code>failed&lt;/code>, and the metric &lt;code>pipeline_errors_total{stage}&lt;/code> is incremented. An alert based on &lt;code>increase(pipeline_errors_total[1h]) &amp;gt; 0&lt;/code> fires before the second failed run. Without this discipline, observability exists but does not warn.&lt;/p>
&lt;h2 id="layer-6--gitops-control-plane-the-only-legitimate-authority">Layer 6 — GitOps control plane: the only legitimate authority&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> That the cluster state is the state declared in git. That any divergence between git and the cluster is visible and, in critical components, &lt;strong>auto-reconciled or auto-alerted&lt;/strong>. That every deployed image has an immutable tag (sha digest or semver pin), an SBOM (Trivy) and traceability back to the commit that introduced it.&lt;/p>
&lt;p>&lt;strong>Canonical OSS pieces in 2026.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Flux&lt;/strong> (or ArgoCD) as the reconciler.&lt;/li>
&lt;li>&lt;strong>Forgejo&lt;/strong> (or Gitea, GitLab CE) as the self-hosted OSS forge.&lt;/li>
&lt;li>&lt;strong>cert-manager + Trust Manager&lt;/strong> for internal PKI.&lt;/li>
&lt;li>&lt;strong>External Secrets Operator + SOPS&lt;/strong> for encrypted versioned secrets.&lt;/li>
&lt;li>&lt;strong>Kyverno&lt;/strong> (or OPA Gatekeeper) for binding policies: deny &lt;code>:latest&lt;/code> images, deny pods without a NetworkPolicy, deny Services without an owner label.&lt;/li>
&lt;li>&lt;strong>Trivy&lt;/strong> for SBOM and vulnerability scanning of images in the CI pipeline.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Images with a mutable tag (&lt;code>:latest&lt;/code>, &lt;code>:main&lt;/code>). Any &lt;code>kubectl edit&lt;/code> in production that is not reflected in git. &lt;code>main&lt;/code> branches with write permissions for humans without review. The rule: &lt;strong>if a human can mutate the cluster without going through a signed commit, you do not have GitOps, you have somebody&amp;rsquo;s whiteboard&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>How it applies to the incident.&lt;/strong> If the pipeline image had been pinned to a sha digest (&lt;code>registry.interno.local/jobhunter@sha256:9af2...&lt;/code>), the team could have audited immediately which Ollama client it carried. With &lt;code>:latest&lt;/code>, not even that.&lt;/p>
&lt;h2 id="layer-7--dependency-tracking-the-layer-the-incident-exposed">Layer 7 — Dependency tracking: the layer the incident exposed&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> Knowing who calls which Service, both &lt;strong>declaratively&lt;/strong> (what the gitops repo says) and &lt;strong>observed&lt;/strong> (what has been seen crossing the network over the last N days). And, on mature platforms, propagating that information as &lt;strong>policy&lt;/strong>: if nobody declares and nobody observes traffic to the Service &lt;code>ollama.ollama.svc&lt;/code>, decommissioning it is safe; if somebody declares it or uses it, decommissioning it opens a ticket.&lt;/p>
&lt;p>&lt;strong>Canonical OSS pieces in 2026.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Hubble&lt;/strong> (Cilium) for observed flow logs: &lt;code>hubble observe --to-namespace ollama --since 14d&lt;/code> gives the list of source namespaces that have talked to Ollama in the last two weeks.&lt;/li>
&lt;li>&lt;strong>Otterize&lt;/strong> for intent-based policy: each Deployment declares &lt;em>&amp;ldquo;I need to talk to &lt;code>ollama-svc&lt;/code>&amp;rdquo;&lt;/em>, and the operator generates the corresponding NetworkPolicy and maintains a browsable catalogue of who is trying to talk to what.&lt;/li>
&lt;li>&lt;strong>manual kubectl-grep&lt;/strong> as a fallback: &lt;code>kubectl get deployments,cronjobs,statefulsets -A -o yaml | grep -E 'ollama[.-]'&lt;/code> produces the declarative list.&lt;/li>
&lt;li>&lt;strong>NetworkPolicy as code&lt;/strong> reviewed in CI: every PR that touches a Service requires the associated policy to be kept or explicitly updated.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>A pre-decom checklist&lt;/strong> that the incident suggests codifying as a CI hook:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">SVC&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;ollama.ollama.svc.cluster.local&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># (a) declarative grep in the gitops repo&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">git -C &lt;span class="nv">$GITOPS_REPO&lt;/span> grep -l &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$SVC&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;OK declarative&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># (b) observed grep in Hubble (last 14 days)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">hubble observe --to-fqdn &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$SVC&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> --since 336h --output json &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="p">|&lt;/span> jq -r &lt;span class="s1">&amp;#39;.source.namespace&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span> sort -u &lt;span class="o">||&lt;/span> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;OK observed&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># (c) live grep in the cluster&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl get all -A -o yaml &lt;span class="p">|&lt;/span> grep -E &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$SVC&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;OK live&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If all three return empty, the decom is safe. If any of them has content, there is downstream debt still open. The &lt;em>jobhunter&lt;/em> incident is exactly what happens when this check does not exist: the team that decommissioned Ollama looked at the list of applications it knew depended on it directly; nobody looked at the list of those that depended on it silently.&lt;/p>
&lt;h2 id="the-maths-that-matter-sizing-the-stack-on-4h100-sxm-320-gb">The maths that matter: sizing the stack on 4×H100 SXM (320 GB)&lt;/h2>
&lt;p>The generic reference cluster for everything that follows: &lt;strong>4×H100 SXM 80 GB&lt;/strong>, &lt;strong>NVLink&lt;/strong> between the four, &lt;strong>640 GB&lt;/strong> of system RAM, &lt;strong>2×NVMe NVMe-oF&lt;/strong> for local storage of weights and caches, &lt;strong>redundant 25/100 GbE&lt;/strong> to the switch. Aggregate VRAM: &lt;strong>320 GB&lt;/strong>.&lt;/p>
&lt;p>The VRAM budget is not free. A first reasonable split for a stack that serves one large general LLM, a medium code-specialised model, embeddings and a reranker, with room for multi-LoRA and for the KV cache:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Reference model&lt;/th>
&lt;th>Quant&lt;/th>
&lt;th>Model weights&lt;/th>
&lt;th>Reserved KV cache&lt;/th>
&lt;th>Total VRAM&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>General LLM (TP=4)&lt;/td>
&lt;td>70B-instruct&lt;/td>
&lt;td>FP8 W8A8&lt;/td>
&lt;td>70 GB&lt;/td>
&lt;td>60 GB&lt;/td>
&lt;td>&lt;strong>130 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Code LLM (TP=2)&lt;/td>
&lt;td>32B-coder&lt;/td>
&lt;td>FP8 W8A8&lt;/td>
&lt;td>32 GB&lt;/td>
&lt;td>28 GB&lt;/td>
&lt;td>&lt;strong>60 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Embeddings&lt;/td>
&lt;td>multilingual-e5-large&lt;/td>
&lt;td>FP16&lt;/td>
&lt;td>1.3 GB&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>&lt;strong>8 GB&lt;/strong> (×2 replicas)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reranker&lt;/td>
&lt;td>bge-reranker-v2-m3&lt;/td>
&lt;td>FP16&lt;/td>
&lt;td>0.6 GB&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>&lt;strong>4 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-LoRA pool (over the general LLM)&lt;/td>
&lt;td>up to 16 adapters&lt;/td>
&lt;td>bf16&lt;/td>
&lt;td>16 × 0.4 GB ≈ 6 GB&lt;/td>
&lt;td>reuses the LLM&amp;rsquo;s KV&lt;/td>
&lt;td>&lt;strong>6 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reserved for fragmentation + overhead&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~30 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Total committed&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~238 GB / 320 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Free headroom&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~82 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The 26 % of free headroom is not waste: it is what lets the vLLM scheduler avoid preempting requests under moderate pressure, lets continuous batching group large batches without aborting, and lets a failover from the other site promote a standby without OOM.&lt;/p>
&lt;p>&lt;strong>Expected throughput&lt;/strong>, with the 70B general LLM in FP8 and tensor parallel 4, on an H100 SXM with continuous batching active and prefix caching at 35–55 % (typical in multi-turn chat with shared system prompts):&lt;/p>
$$
\text{aggregate tokens/second} \approx 1500 \text{ to } 2500
$$
&lt;p>for concurrency between 32 and 64 requests, with sub-second TTFT P95 on short prompts (&amp;lt;2k tokens) and TPOT P95 around &lt;strong>40–60 ms/token&lt;/strong> as perceived by the client. These numbers are &lt;strong>reasonable orders of magnitude&lt;/strong>, not guarantees: real throughput depends on the prompt mix, on whether speculative decoding (EAGLE-3) is active and burnt-in, and on the network cost between gateway and inference pods.&lt;/p>
&lt;p>&lt;strong>Embeddings throughput&lt;/strong> on two replicas of &lt;code>multilingual-e5-large&lt;/code> with dynamic batching:&lt;/p>
$$
\text{embeddings/second} \approx 3000 \text{ to } 6000 \quad (\text{optimal batch} \sim 64)
$$
&lt;p>Enough to reindex a corpus of &lt;strong>1 million documents&lt;/strong> in roughly an hour, assuming chunks of 512 tokens and two chunks per document on average. For corpora of tens of millions of documents, re-embedding is done by delta via CDC over the source (covered in &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a>), not by full sweep.&lt;/p>
&lt;p>&lt;strong>Retrieval latency&lt;/strong> on Qdrant with HNSW (M=16, ef_construct=200) over a collection of 5 million 1024-dim vectors filtered by &lt;code>tenant_id&lt;/code>:&lt;/p>
$$
\text{P95 latency retrieve top-50} \approx 8 \text{ to } 25 \text{ ms}
$$
&lt;p>Below the cost of cross-encoder reranking (&lt;code>bge-reranker-v2-m3&lt;/code> over top-50 = roughly 30–60 ms more), and below any call to the LLM. The bottleneck in a well sized RAG pipeline is never the vector store: it is the LLM&amp;rsquo;s decoding.&lt;/p>
&lt;h2 id="final-diagram-the-complete-connected-stack">Final diagram: the complete connected stack&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="complete diagram of the on-premise LLM inference stack with its seven layers connected">&lt;style>
.b{stroke:#333;stroke-width:1.4;rx:6}
.gw{fill:#ffd9b8;stroke:#a44}
.llm{fill:#ffe6c2;stroke:#a55}
.emb{fill:#fff0d0;stroke:#a66}
.data{fill:#dfe9f5;stroke:#356}
.obs{fill:#d8eecf;stroke:#373}
.ctrl{fill:#f5e3d8;stroke:#763}
.dep{fill:#ead8f5;stroke:#634}
.bg{fill:#fafafa;stroke:#bbb;rx:8}
.lbl{font:600 12px sans-serif;fill:#222}
.sm{font:11px sans-serif;fill:#222}
.tiny{font:600 10px sans-serif;fill:#222}
.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}
.otel{stroke:#1a73e8;stroke-width:1.4;fill:none;stroke-dasharray:3 2;marker-end:url(#ab)}
.ctrlarr{stroke:#c66;stroke-width:1.2;fill:none;stroke-dasharray:5 3;marker-end:url(#ac)}
&lt;/style>
&lt;defs>
&lt;marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>
&lt;marker id="ab" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#1a73e8"/>&lt;/marker>
&lt;marker id="ac" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#c66"/>&lt;/marker>
&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" class="lbl">On-premise LLM inference stack — seven layers, stable contracts between them&lt;/text>
&lt;!-- Client -->
&lt;rect x="340" y="36" width="140" height="32" class="b gw"/>
&lt;text x="410" y="56" text-anchor="middle" class="sm">Client · OpenAI SDK&lt;/text>
&lt;path class="arr" d="M410,68 L410,82"/>
&lt;!-- Gateway -->
&lt;rect x="80" y="86" width="660" height="40" class="b gw"/>
&lt;text x="100" y="103" class="tiny">LAYER 1 · GATEWAY&lt;/text>
&lt;text x="410" y="120" text-anchor="middle" class="sm">Envoy AI Gateway · routing by body.model · JWT (Defguard) · rate-limit per tenant · trace_id injection&lt;/text>
&lt;!-- Inference + Embeddings + Reranker -->
&lt;rect x="80" y="146" width="320" height="100" class="b llm"/>
&lt;text x="100" y="163" class="tiny">LAYER 2 · LLM INFERENCE&lt;/text>
&lt;rect x="100" y="174" width="280" height="28" class="b" fill="#fff4e0"/>
&lt;text x="240" y="192" text-anchor="middle" class="sm">vLLM · general LLM 70B FP8 · TP=4 · multi-LoRA&lt;/text>
&lt;rect x="100" y="208" width="280" height="28" class="b" fill="#fff4e0"/>
&lt;text x="240" y="226" text-anchor="middle" class="sm">vLLM · code LLM 32B FP8 · TP=2 · structured out.&lt;/text>
&lt;rect x="420" y="146" width="320" height="100" class="b emb"/>
&lt;text x="440" y="163" class="tiny">LAYER 3 · EMBEDDINGS + RERANKER&lt;/text>
&lt;rect x="440" y="174" width="280" height="28" class="b" fill="#fffae8"/>
&lt;text x="580" y="192" text-anchor="middle" class="sm">Infinity · multilingual-e5-large 1024d · ×2&lt;/text>
&lt;rect x="440" y="208" width="280" height="28" class="b" fill="#fffae8"/>
&lt;text x="580" y="226" text-anchor="middle" class="sm">TEI · bge-reranker-v2-m3 (top-50 rerank)&lt;/text>
&lt;path class="arr" d="M250,126 L250,146"/>
&lt;path class="arr" d="M570,126 L570,146"/>
&lt;!-- Vector store + storage -->
&lt;rect x="80" y="266" width="660" height="80" class="b data"/>
&lt;text x="100" y="283" class="tiny">LAYER 4 · VECTOR STORE + RELATIONAL DATA + STORAGE&lt;/text>
&lt;rect x="100" y="294" width="160" height="40" class="b" fill="#eef3fb"/>
&lt;text x="180" y="319" text-anchor="middle" class="sm">Qdrant (HNSW)&lt;/text>
&lt;rect x="280" y="294" width="160" height="40" class="b" fill="#eef3fb"/>
&lt;text x="360" y="319" text-anchor="middle" class="sm">PostgreSQL (CNPG)&lt;/text>
&lt;rect x="460" y="294" width="160" height="40" class="b" fill="#eef3fb"/>
&lt;text x="540" y="319" text-anchor="middle" class="sm">MinIO (weights · adapters)&lt;/text>
&lt;rect x="640" y="294" width="100" height="40" class="b" fill="#eef3fb"/>
&lt;text x="690" y="319" text-anchor="middle" class="sm">Redis&lt;/text>
&lt;path class="arr" d="M580,246 L580,266"/>
&lt;!-- Observability -->
&lt;rect x="80" y="366" width="660" height="60" class="b obs"/>
&lt;text x="100" y="383" class="tiny">LAYER 5 · OBSERVABILITY (LLM-aware traces · metrics · flow logs)&lt;/text>
&lt;rect x="100" y="394" width="150" height="24" class="b" fill="#eaf5e2"/>
&lt;text x="175" y="411" text-anchor="middle" class="sm">Langfuse&lt;/text>
&lt;rect x="260" y="394" width="150" height="24" class="b" fill="#eaf5e2"/>
&lt;text x="335" y="411" text-anchor="middle" class="sm">OTel Collector&lt;/text>
&lt;rect x="420" y="394" width="150" height="24" class="b" fill="#eaf5e2"/>
&lt;text x="495" y="411" text-anchor="middle" class="sm">VictoriaMetrics + Grafana&lt;/text>
&lt;rect x="580" y="394" width="160" height="24" class="b" fill="#eaf5e2"/>
&lt;text x="660" y="411" text-anchor="middle" class="sm">Hubble + DCGM Exporter&lt;/text>
&lt;!-- OTel lines from layers to observability -->
&lt;path class="otel" d="M740,106 L770,106 L770,396 L740,396"/>
&lt;path class="otel" d="M400,196 L770,196" opacity="0.6"/>
&lt;path class="otel" d="M400,310 L770,310" opacity="0.6"/>
&lt;!-- GitOps control plane -->
&lt;rect x="80" y="446" width="380" height="40" class="b ctrl"/>
&lt;text x="100" y="463" class="tiny">LAYER 6 · GITOPS CONTROL PLANE&lt;/text>
&lt;text x="270" y="479" text-anchor="middle" class="sm">Forgejo → Flux → cert-manager · External Secrets · Kyverno&lt;/text>
&lt;!-- Dependency tracking -->
&lt;rect x="480" y="446" width="260" height="40" class="b dep"/>
&lt;text x="500" y="463" class="tiny">LAYER 7 · DEPENDENCY TRACKING&lt;/text>
&lt;text x="610" y="479" text-anchor="middle" class="sm">Hubble flows · Otterize intents&lt;/text>
&lt;!-- Control plane reconciliation → all layers -->
&lt;path class="ctrlarr" d="M270,446 L270,430 L40,430 L40,106 L80,106"/>
&lt;path class="ctrlarr" d="M610,446 L610,430 L780,430 L780,106 L740,106"/>
&lt;text x="60" y="505" class="tiny">Request flow&lt;/text>
&lt;line x1="155" y1="502" x2="180" y2="502" stroke="#666" stroke-width="1.4"/>
&lt;text x="240" y="505" class="tiny">OTel telemetry&lt;/text>
&lt;line x1="335" y1="502" x2="360" y2="502" stroke="#1a73e8" stroke-width="1.4" stroke-dasharray="3 2"/>
&lt;text x="430" y="505" class="tiny">GitOps reconciliation&lt;/text>
&lt;line x1="555" y1="502" x2="580" y2="502" stroke="#c66" stroke-width="1.2" stroke-dasharray="5 3"/>
&lt;/svg>
&lt;/div>
&lt;p>The solid lines are the request path: client → gateway → inference engine → vector store/embeddings → response. The dashed blue lines are telemetry: each component emits OTel to the collector, which routes traces to Langfuse, metrics to VictoriaMetrics and logs to Loki. The dashed red lines are reconciliation: the GitOps control plane keeps any layer in its declared state and warns of divergence.&lt;/p>
&lt;p>The diagram is not decorative: every arrow is a &lt;strong>stable contract&lt;/strong> between two layers. If one layer changes (vLLM → SGLang, multilingual-e5 → bge-m3, Qdrant → pgvector), the arrows hold. That stability of contracts is the architectural property that lets a team migrate components without breaking downstream apps.&lt;/p>
&lt;h2 id="typical-design-decisions-that-break-the-stack">Typical design decisions that break the stack&lt;/h2>
&lt;p>A short list of mistakes seen repeatedly in stacks that looked well designed on paper:&lt;/p>
&lt;p>&lt;strong>1. Coupling the client SDK to the inference engine.&lt;/strong> Dropping the gateway because &amp;ldquo;vLLM already speaks OpenAI-compatible&amp;rdquo; works on day one and hurts the day you have to add a fallback, a canary or a second model.&lt;/p>
&lt;p>&lt;strong>2. Sharing the LLM and embeddings endpoint.&lt;/strong> A &lt;code>qwen2.5-32b-Instruct&lt;/code> is chat-only; &lt;code>BadRequestError: &amp;quot;The model does not support Embeddings API&amp;quot;&lt;/code> is the cry of a design that confused the two layers.&lt;/p>
&lt;p>&lt;strong>3. Reusing the vector store collection when changing the embeddings model.&lt;/strong> Different dimensions do not allow &lt;code>upsert&lt;/code>. Versioning the collection by &lt;code>(model, dim, version)&lt;/code> is ugly but it saves the day of the change.&lt;/p>
&lt;p>&lt;strong>4. A &lt;code>try/except&lt;/code> wrapping an entire pipeline with the wrapper&amp;rsquo;s label.&lt;/strong> The log lies because the label is lexical, not causal. Every stage in its own &lt;code>try/except&lt;/code> with its own label and its own metric.&lt;/p>
&lt;p>&lt;strong>5. &lt;code>status: completed&lt;/code> with errors.&lt;/strong> The pipeline has to distinguish &lt;code>completed&lt;/code>, &lt;code>completed_with_errors&lt;/code> and &lt;code>failed&lt;/code>, and the alerting has to fire on the last two. Without this, observability exists in theory and does not warn in practice.&lt;/p>
&lt;p>&lt;strong>6. Images with a mutable tag.&lt;/strong> &lt;code>:latest&lt;/code> and &lt;code>:main&lt;/code> are not tags, they are aliases. Without a sha digest there is no reproducibility and no auditable SBOM.&lt;/p>
&lt;p>&lt;strong>7. Decommissioning a Service without a pre-decom check.&lt;/strong> The three-grep check (declarative + observed + live) takes two minutes and costs six days of incident when it is skipped.&lt;/p>
&lt;p>&lt;strong>8. Default &lt;code>limits.memory&lt;/code> on pods that load models.&lt;/strong> A sidecar loading &lt;code>sentence-transformers + torch + tokenizer&lt;/code> needs 2–4 GB; with &lt;code>limits.memory: 1Gi&lt;/code> you hit OOM on the first pod restart, and sometimes with no alert because the liveness probe answers through another route.&lt;/p>
&lt;p>All of them are variants of the same principle: the stack does not fail in its most expensive layer (inference, where nobody underestimates the cost), it fails in the cheap, boring layers (gateway, observability, GitOps, dependency tracking) where it is tempting to save.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware-a-4h100-sxm-cluster">Applied to typical on-premise hardware: a 4×H100 SXM cluster&lt;/h2>
&lt;p>On the generic reference cluster (4×H100 SXM 80 GB, NVLink, 640 GB RAM), the suggested split into pods:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">gpu-node-01 (4×H100 SXM, intra-node NVLink)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── vllm-llm-general (TP=4) ~130 GB VRAM (4 GPUs)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── (shares GPUs with the multi-LoRA pool on the same deployment)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">gpu-node-02 (4×H100 SXM, second node)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── vllm-llm-code (TP=2) ~60 GB VRAM (2 GPUs)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── infinity-embeddings (×2) ~16 GB VRAM (shared on 1 GPU with optional MIG)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── tei-reranker ~4 GB VRAM (cohabitant)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── failover reserve ~120 GB VRAM free for canary / standby
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Not everything fits comfortably on a single node; two nodes with two H100 SXM each would be enough for the conservative setup, and the rest of the cluster (CPU-bound: gateway, vector store, observability, control plane) runs on GPU-free nodes.&lt;/p>
&lt;p>The operational rule: &lt;strong>inference concentrates&lt;/strong>, the rest of the stack distributes. Concentrating inference maximises the use of NVLink (cross-GPU tensor parallel without going through PCIe); distributing the rest prevents an event on the GPU node from taking the control plane down with it.&lt;/p>
&lt;p>An even more conservative configuration, for SMEs with a single 4×H100 SXM node as a starting point, serves the general LLM (TP=4) and embeddings/reranker cohabiting with MIG (Multi-Instance GPU to partition an H100 into hardware-isolated slices). The code LLM is deferred to a second phase. It is viable and cost-aware; what is not viable is doing without layers 5, 6 and 7.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;p>This post focuses on the &lt;strong>static design&lt;/strong> of the stack. Some pieces still deserve an article of their own:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The multi-site active/standby plane&lt;/strong>: Cilium Cluster Mesh, cross-cluster Qdrant replication, realistic RTO/RPO, when active-active pays off and when it does not.&lt;/li>
&lt;li>&lt;strong>The continuous fine-tuning plane&lt;/strong>: how the LoRA pipeline closes the loop from production feedback to a promoted adapter, in the spirit of the &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain post&lt;/a>.&lt;/li>
&lt;li>&lt;strong>The safety/guardrails plane&lt;/strong>: where Llama Guard fits, Presidio for PII and XGrammar for guaranteed structured output, connected to layer 1, the gateway.&lt;/li>
&lt;li>&lt;strong>The cost plane&lt;/strong>: &lt;code>gen_ai.usage.*&lt;/code> instrumentation at tenant and model level, tokens/euro dashboards, GPU elasticity decisions via KEDA.&lt;/li>
&lt;li>&lt;strong>The compliance plane&lt;/strong>: how the stack maps to ENS High, NIS2 and ISO/IEC 42001 without turning the deployment into a compliance exercise that paralyses delivery.&lt;/li>
&lt;li>&lt;strong>Below the engine&lt;/strong>: the mini-series that opens the stack&amp;rsquo;s basement — &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">the interconnect (NVLink/NCCL)&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">the host (NUMA, hugepages, CPU isolation)&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">the RKE2 resource managers&lt;/a> that pin each pod to the right NUMA node.&lt;/li>
&lt;/ul>
&lt;p>Each one falls into a different series on the blog and will be covered with the same discipline: a concrete piece, a justified decision, the typical mistake seen in practice.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the general framework this stack operates in.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages&lt;/a> — a card per tool in the stack.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> — the same architecture seen from the perspective of an individual request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">OSS vs hyperscalers in LLMOps&lt;/a> — a comparison with the AWS/Azure/GCP stacks.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the backbone of layer 5.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a> — the piece that gives per-tenant customisation without replicating the base.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">Reranker and hybrid retrieval&lt;/a> — everything that goes into layers 3-4 so that RAG does not degrade.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for LLM inference&lt;/a> — the reason for the FP8 W8A8 in the sizing.&lt;/li>
&lt;/ul>
&lt;h3 id="relevant-official-documentation">Relevant official documentation&lt;/h3>
&lt;ul>
&lt;li>vLLM Production Stack — &lt;a href="https://docs.vllm.ai/">docs.vllm.ai&lt;/a>&lt;/li>
&lt;li>SGLang RadixAttention — &lt;a href="https://github.com/sgl-project/sglang">github.com/sgl-project/sglang&lt;/a>&lt;/li>
&lt;li>Envoy AI Gateway — &lt;a href="https://aigateway.envoyproxy.io/">aigateway.envoyproxy.io&lt;/a>&lt;/li>
&lt;li>Langfuse self-hosted — &lt;a href="https://langfuse.com/docs/self-hosting">langfuse.com/docs/self-hosting&lt;/a>&lt;/li>
&lt;li>OpenTelemetry Semantic Conventions for GenAI — &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">opentelemetry.io/docs/specs/semconv/gen-ai&lt;/a>&lt;/li>
&lt;li>Hubble flow observability — &lt;a href="https://docs.cilium.io/en/stable/observability/hubble/">docs.cilium.io/en/stable/observability/hubble&lt;/a>&lt;/li>
&lt;li>Otterize intent-based access — &lt;a href="https://docs.otterize.com/">docs.otterize.com&lt;/a>&lt;/li>
&lt;li>Flux GitOps toolkit — &lt;a href="https://fluxcd.io/">fluxcd.io&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>The GPU cluster as a platform: turning a shared cluster into a multi-tenant service your teams can consume</title><link>https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/</link><pubDate>Thu, 21 May 2026 07:15:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Having an expensive GPU cluster and many different workloads that want to use it is not an infrastructure problem: it is an internal product problem. What separates &amp;ldquo;we have a cluster&amp;rdquo; from &amp;ldquo;we have an inference platform&amp;rdquo; are four layers the market consolidated in 2026: a gateway layer that centralises authentication, routing and policies (LiteLLM, Portkey, Kong AI Gateway); a GPU isolation model appropriate to the tenants&amp;rsquo; profile (MIG hardware isolation for untrusted multi-tenant, MPS for processes from the same team, time-slicing only for dev); a quota and rate limiting system with budgets per tenant/team/project (LiteLLM does this in its core at team/user/api-key level with descriptive 429s); and a multi-tenant observability plane that enables real cost attribution (showback as an intermediate step, chargeback as the destination), per-tenant tracing and differentiated dashboards. Applied to a typical mid-scale GPU cluster (one node with 4-8 H100 SXM and NVLink, a common place to start in production), this translates into concrete decisions: with ~640 GB of aggregate VRAM across 8 GPUs and two typical production models (a large 70B+ model with tensor parallel and a replicated mid-sized model), the cluster serves between &lt;strong>tens and low hundreds of simultaneous sessions&lt;/strong> depending on the mix; GPU isolation is usually resolved with MIG on smaller workloads and per-model dedication on large ones; and the platform&amp;rsquo;s success metric is effective utilisation, which in typical production sits at 30-40% and where the reasonable optimisation target is to push it to 60-70% without degrading SLAs.&lt;/p>
&lt;blockquote>
&lt;p>This is the fifth post in the MLOps for LLMs series. It is the most operationally oriented one and cuts across several pipeline stages (Deploy + Observe plus cross-cutting concerns). The &amp;ldquo;you are here&amp;rdquo; marker flags the two active stages because the notion of a multi-tenant platform does not live in just one.&lt;/p>
&lt;/blockquote>
&lt;h2 id="you-are-here-deploy--observe-the-cluster-as-a-product">You are here: Deploy + Observe (the cluster as a product)&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Deploy + Observe">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#7adb7a;stroke-width:3}.active2{fill:#c47aff;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#mt1)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#mt1)}&lt;/style>
&lt;defs>&lt;marker id="mt1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: DEPLOY + OBSERVE · the cluster as a platform with tenants&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box active"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box active2"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-question-that-changes-the-framing">The question that changes the framing&lt;/h2>
&lt;p>When a platform team buys expensive GPU hardware and starts building inference, the first version is almost always single-purpose: one model, one client, one target latency. It works. When the second team turns up asking for the same resource, that single purpose becomes internal politics: how many replicas do we give them? What do we do if the SLAs clash? Who pays for the tokens of team B&amp;rsquo;s experiment? And when the third one arrives, what was an SRE project becomes an internal product project.&lt;/p>
&lt;p>The distinction is not technical, it is one of framing. A cluster is infrastructure. A platform is a service with customers, contracts and success metrics. The change of framing implies:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Identifiable customers&lt;/strong> (tenants), not anonymous users.&lt;/li>
&lt;li>&lt;strong>Contracts&lt;/strong> (latency SLA, guaranteed throughput, available models), not &amp;ldquo;whatever we manage&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Success metrics&lt;/strong> that are not technical but product ones: adoption, satisfaction, cost per query per tenant, time to the first &amp;ldquo;hello world&amp;rdquo;.&lt;/li>
&lt;/ul>
&lt;p>This post walks through how that change of framing is carried out. It lands it on a mid-scale cluster (4-8 H100 SXM with NVLink in a single node), the usual configuration when starting with serious LLM inference; but the principles generalise to any topology, from a single node with two GPUs to multi-node clusters with InfiniBand.&lt;/p>
&lt;h2 id="the-four-layers-of-a-multi-tenant-inference-platform">The four layers of a multi-tenant inference platform&lt;/h2>
&lt;p>The canonical architecture that settled in 2026 has four layers that any serious multi-tenant platform implements, from the outside in:&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 410" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Four layers of a multi-tenant platform">
&lt;style>.title{font:700 13px sans-serif;fill:#222}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#555}.tiny{font:10px sans-serif;fill:#666}.layer{stroke:#444;stroke-width:1.5;rx:6}.gw{fill:#ffe9d6}.pol{fill:#d6eaff}.iso{fill:#d9f5d6}.obs{fill:#e9d6f5}.cluster{stroke:#666;stroke-dasharray:4 2;fill:none}.tenant{stroke:#888;stroke-width:1.4;fill:#fffce6;rx:4}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#pm1)}&lt;/style>
&lt;defs>&lt;marker id="pm1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="22" text-anchor="middle" class="title">The four layers of the multi-tenant platform&lt;/text>
&lt;rect x="40" y="50" width="100" height="40" class="tenant"/>&lt;text x="90" y="68" text-anchor="middle" class="sm">Tenant A&lt;/text>&lt;text x="90" y="82" text-anchor="middle" class="tiny">support chat&lt;/text>
&lt;rect x="160" y="50" width="100" height="40" class="tenant"/>&lt;text x="210" y="68" text-anchor="middle" class="sm">Tenant B&lt;/text>&lt;text x="210" y="82" text-anchor="middle" class="tiny">legal RAG&lt;/text>
&lt;rect x="280" y="50" width="100" height="40" class="tenant"/>&lt;text x="330" y="68" text-anchor="middle" class="sm">Tenant C&lt;/text>&lt;text x="330" y="82" text-anchor="middle" class="tiny">code agent&lt;/text>
&lt;rect x="400" y="50" width="100" height="40" class="tenant"/>&lt;text x="450" y="68" text-anchor="middle" class="sm">Tenant D&lt;/text>&lt;text x="450" y="82" text-anchor="middle" class="tiny">data extr.&lt;/text>
&lt;rect x="520" y="50" width="100" height="40" class="tenant"/>&lt;text x="570" y="68" text-anchor="middle" class="sm">Tenant E&lt;/text>&lt;text x="570" y="82" text-anchor="middle" class="tiny">batch ETL&lt;/text>
&lt;rect x="640" y="50" width="100" height="40" class="tenant"/>&lt;text x="690" y="68" text-anchor="middle" class="sm">notebooks&lt;/text>&lt;text x="690" y="82" text-anchor="middle" class="tiny">research&lt;/text>
&lt;rect x="40" y="120" width="700" height="60" class="layer gw"/>
&lt;text x="390" y="144" text-anchor="middle" class="lbl">Layer 1 · AI Gateway&lt;/text>
&lt;text x="55" y="166" class="sm">Auth (OIDC/API keys) · Routing by model · Failover · Caching · Logging · OTel emission · Rate limiting&lt;/text>
&lt;rect x="40" y="195" width="700" height="60" class="layer pol"/>
&lt;text x="390" y="219" text-anchor="middle" class="lbl">Layer 2 · Policy &amp;amp; Quota Plane&lt;/text>
&lt;text x="55" y="241" class="sm">RPS/TPM quotas per tenant · Monthly budgets · Model whitelist · Priority classes · Admission control&lt;/text>
&lt;rect x="40" y="270" width="700" height="60" class="layer iso"/>
&lt;text x="390" y="294" text-anchor="middle" class="lbl">Layer 3 · Isolation Plane&lt;/text>
&lt;text x="55" y="316" class="sm">MIG / MPS / time-slicing · K8s namespaces · NetworkPolicies · ResourceQuotas · Priority + preemption&lt;/text>
&lt;rect x="40" y="345" width="700" height="55" class="layer obs"/>
&lt;text x="390" y="369" text-anchor="middle" class="lbl">Layer 4 · Observability Plane (multi-tenant)&lt;/text>
&lt;text x="55" y="391" class="sm">Traces with tenant_id · Labelled metrics · Cost attribution · Per-tenant dashboards · Audit logs&lt;/text>
&lt;path class="arr" d="M90,90 L390,120"/>
&lt;path class="arr" d="M450,90 L390,120"/>
&lt;path class="arr" d="M690,90 L390,120"/>
&lt;/svg>
&lt;/div>
&lt;p>Each layer solves a concrete problem. Let us take them one by one.&lt;/p>
&lt;h2 id="layer-1--ai-gateway-the-single-front-door">Layer 1 — AI Gateway: the single front door&lt;/h2>
&lt;p>The AI Gateway is the component your tenants see. It is an HTTP/gRPC API compatible with OpenAI (typically &lt;code>/v1/chat/completions&lt;/code>, &lt;code>/v1/embeddings&lt;/code>, &lt;code>/v1/models&lt;/code>) that centralises everything that happens before touching the inference backends.&lt;/p>
&lt;h3 id="why-centralise">Why centralise&lt;/h3>
&lt;p>Without a gateway, tenants connect directly to vLLM or whatever the model is. Every change (rotating an endpoint, adding a model, changing credentials, applying a policy) means notifying every tenant. Every tenant has its own retry logic, its own logging, its own auth model. It is unworkable from the third customer onwards.&lt;/p>
&lt;p>With a gateway, the change is made in one place. Tenants have a stable URL and a set of credentials; the rest is the gateway&amp;rsquo;s problem.&lt;/p>
&lt;h3 id="the-three-dominant-options-in-2026">The three dominant options in 2026&lt;/h3>
&lt;p>&lt;strong>&lt;a href="https://docs.litellm.ai/">LiteLLM&lt;/a>&lt;/strong> is the most popular OSS option, Python-first, deployed as a proxy. It supports 100+ providers (OpenAI, Anthropic, Bedrock, self-hosted vLLM, Ollama, and so on) behind a unified OpenAI-compatible API. It has a native multi-tenant hierarchy with Organizations → Teams → Users → API Keys, each level with its own budget. The Apache 2.0 version covers the basics; RBAC, SSO, audit logs and team-level enforcement require the paid Enterprise version. Deployed on K8s with the official Helm chart.&lt;/p>
&lt;p>&lt;strong>&lt;a href="https://portkey.ai/">Portkey&lt;/a>&lt;/strong> is the most mature commercial / SaaS option. A single control plane that enforces budgets, quotas, permissions and compliance. Real-time spending tracking with alerting. RBAC, audit, workspaces and SSO included. The trade-off: dependency on an external service and a per-request pricing model.&lt;/p>
&lt;p>&lt;strong>&lt;a href="https://konghq.com/blog/enterprise/llm-cost-management-ai-showback-and-chargeback">Kong AI Gateway&lt;/a>&lt;/strong> is the option for organisations that already have Kong as their API gateway. An AI plug-in on top of the existing Kong gateway, integrated with its plugin, consumer and rate-limit model. If your platform team already runs Kong, it is the lowest friction.&lt;/p>
&lt;h3 id="when-to-choose-each-one">When to choose each one&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Situation&lt;/th>
&lt;th>Gateway&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Pure OSS, self-hosted, Python-first team&lt;/td>
&lt;td>&lt;strong>LiteLLM&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>You need RBAC, SSO, audit log out of the box, and have budget&lt;/td>
&lt;td>&lt;strong>Portkey&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>You already run Kong as a corporate API gateway&lt;/td>
&lt;td>&lt;strong>Kong AI Gateway&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Greenfield enterprise with strict compliance&lt;/td>
&lt;td>Portkey (probably)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mid-sized OSS-first company without regulated compliance&lt;/td>
&lt;td>LiteLLM (typically)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="the-minimum-the-gateway-has-to-do">The minimum the gateway has to do&lt;/h3>
&lt;p>Whichever option you pick, this is what any serious deployment must enforce:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Auth and identity&lt;/strong>: every request carries an API key resolvable to a tenant + user + team.&lt;/li>
&lt;li>&lt;strong>Routing by model&lt;/strong>: the tenant asks for &lt;code>model: &amp;quot;gpt-4o&amp;quot;&lt;/code>; the gateway decides whether it goes to OpenAI, to Azure OpenAI, or to your vLLM with Qwen3 32B (a cheaper fallback), according to policy.&lt;/li>
&lt;li>&lt;strong>Rate limiting&lt;/strong>: RPS per tenant, TPM (tokens per minute), concurrency limits.&lt;/li>
&lt;li>&lt;strong>Caching of identical responses&lt;/strong>: 5-30% of RAG queries are repeats; caching saves latency and cost.&lt;/li>
&lt;li>&lt;strong>OTel emission&lt;/strong>: every call produces a span with &lt;code>gen_ai.*&lt;/code> semantic conventions and &lt;code>tenant_id&lt;/code> as an attribute. Covered in the &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals post&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Failover&lt;/strong>: if vLLM goes down, the gateway redirects to the OpenAI API. If OpenAI rate-limits, the gateway falls back to Anthropic. Configurable policy.&lt;/li>
&lt;/ul>
&lt;h3 id="example-of-a-multi-tenant-litellm-configuration">Example of a multi-tenant LiteLLM configuration&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># litellm-config.yaml — simplified example&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">model_list&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama-3-70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">openai/llama-3-70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-llama3-70b.inference/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/VLLM_API_KEY&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen3-32b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">openai/qwen3-32b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-qwen3-32b.inference/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/VLLM_API_KEY&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gpt-4o&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">openai/gpt-4o&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/OPENAI_API_KEY&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">router_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">usage-based-routing-v2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">fallbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">llama-3-70b&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">qwen3-32b, gpt-4o] &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># if vLLM goes down, fall back to the external one&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">master_key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/LITELLM_MASTER_KEY&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">database_url&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/DATABASE_URL &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># Postgres for budgets/keys&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># Hierarchy: Organizations → Teams → Users → API Keys&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># Created via the API, not in static YAML&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Creating a team with a budget:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">curl -X POST http://litellm/team/new &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s2">&amp;#34;Authorization: Bearer &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nv">LITELLM_MASTER_KEY&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;team_alias&amp;#34;: &amp;#34;soporte-chat&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;max_budget&amp;#34;: 500, # 500 USD/month
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;budget_duration&amp;#34;: &amp;#34;30d&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;tpm_limit&amp;#34;: 100000, # 100K tokens/min
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;rpm_limit&amp;#34;: 1000, # 1000 requests/min
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;models&amp;#34;: [&amp;#34;llama-3-70b&amp;#34;, &amp;#34;qwen3-32b&amp;#34;] # access to these
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the team&amp;rsquo;s API key:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">curl -X POST http://litellm/key/generate &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s2">&amp;#34;Authorization: Bearer &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nv">LITELLM_MASTER_KEY&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;team_id&amp;#34;: &amp;#34;&amp;lt;team-id&amp;gt;&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;duration&amp;#34;: &amp;#34;30d&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;metadata&amp;#34;: {&amp;#34;environment&amp;#34;: &amp;#34;production&amp;#34;, &amp;#34;app&amp;#34;: &amp;#34;support-bot&amp;#34;}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That API key is what the tenant uses. Every request made with it consumes from the team&amp;rsquo;s budget. When it runs out, LiteLLM returns HTTP 429 with a description.&lt;/p>
&lt;h2 id="layer-2--policy--quota-plane-what-each-tenant-can-do">Layer 2 — Policy &amp;amp; Quota Plane: what each tenant can do&lt;/h2>
&lt;p>The gateway is where enforcement happens. Policy is what gets enforced. Five axes of multi-tenant policy:&lt;/p>
&lt;h3 id="technical-quotas">Technical quotas&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>TPM&lt;/strong> (tokens per minute): the hard consumption limit. For a Llama 3 70B at TP=5, ~3000 sustained output tokens/s = 180K aggregate TPM. If you have 10 tenants, assign 18K each as the ceiling.&lt;/li>
&lt;li>&lt;strong>RPS / RPM&lt;/strong>: load control, not consumption control. A 4K-token session counts as one request; so does a batch of 100 mini-completions. Useful against abuse.&lt;/li>
&lt;li>&lt;strong>Concurrency&lt;/strong>: how many simultaneous active requests per tenant. Important for latency SLAs: 100 RPS with concurrency=50 works out at 2 seconds per request.&lt;/li>
&lt;/ul>
&lt;h3 id="economic-budgets">Economic budgets&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Monthly per tenant&lt;/strong>: a hard cap in USD.&lt;/li>
&lt;li>&lt;strong>Daily and hourly&lt;/strong>: soft caps to avoid a runaway in a single day.&lt;/li>
&lt;li>&lt;strong>Per project / API key&lt;/strong>: fine granularity within a single tenant.&lt;/li>
&lt;/ul>
&lt;p>LiteLLM has a &lt;code>max_budget&lt;/code> field at every level of the hierarchy (organization, team, user, api key). Budgets are inherited and constrained downwards.&lt;/p>
&lt;h3 id="model-whitelist-and-blacklist">Model whitelist and blacklist&lt;/h3>
&lt;p>Tenants with critical workloads → only stable models (&lt;code>llama-3-70b&lt;/code>, &lt;code>gpt-4o&lt;/code>). Research tenants → access to experimental models as well.&lt;/p>
&lt;h3 id="priority-classes">Priority classes&lt;/h3>
&lt;p>Not all requests are equal. Three typical classes:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Guaranteed&lt;/strong>: workloads with an SLA, latency respected even under pressure.&lt;/li>
&lt;li>&lt;strong>Best-effort&lt;/strong>: normal workloads without a strict SLA.&lt;/li>
&lt;li>&lt;strong>Spot&lt;/strong>: batches that can wait, evictable if a guaranteed one arrives.&lt;/li>
&lt;/ul>
&lt;p>The &lt;a href="https://arxiv.org/abs/2603.00356">Token Management in Multi-Tenant AI Inference Platforms&lt;/a> paper (2026) formalises this with a model of token pools per priority class that has started to be adopted in production. It maintains guaranteed P99 latency for guaranteed workloads even under overload, with selective throttling of spot.&lt;/p>
&lt;h3 id="admission-control">Admission control&lt;/h3>
&lt;p>Before accepting a request: is there capacity? If not, return 429 immediately instead of queueing and degrading everyone. It is the most underrated operational discipline. A cluster with admission control done properly has predictable latency; without it, catastrophic degradation when the peak arrives.&lt;/p>
&lt;h3 id="the-typical-pattern-in-2026">The typical pattern in 2026&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># Conceptual policy for a &amp;#34;soporte-chat&amp;#34; tenant&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">tenant&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">soporte-chat&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">quotas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tpm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">50000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rpm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">500&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_concurrency&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">budget&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">monthly_usd&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">800&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">alert_thresholds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="m">0.5&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.8&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.95&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># warn when you get there&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">models_allowed&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">llama-3-70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">qwen3-32b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">priority&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">guaranteed&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">fallback_on_overload&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">qwen3-32b &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># if guaranteed fills up, fall back&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">gpt-4o-mini &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># last resort, external model&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="layer-3--isolation-plane-isolating-workloads-physically">Layer 3 — Isolation Plane: isolating workloads physically&lt;/h2>
&lt;p>This is the technically densest layer. You have a node with several H100 SXM GPUs interconnected by NVLink. How do you partition them between tenants?&lt;/p>
&lt;h3 id="three-nvidia-mechanisms-for-sharing-a-gpu">Three NVIDIA mechanisms for sharing a GPU&lt;/h3>
&lt;p>&lt;strong>MIG (Multi-Instance GPU)&lt;/strong> is the strongest isolation. It partitions the GPU into up to 7 instances with physically separate HBM memory and dedicated compute units (SMs). Tenants in different MIG instances cannot touch each other: one workload does not consume memory another needs, one does not degrade another&amp;rsquo;s throughput. Hardware isolation. Available on A100, H100, B100 and B200.&lt;/p>
&lt;p>&lt;strong>MPS (Multi-Process Service)&lt;/strong> is soft. Several processes share the GPU concurrently and NVIDIA distributes SMs according to usage. Good performance if all the processes are yours and you trust them. Worse for multi-tenant setups between customers who do not know each other, because a noisy process can degrade the others.&lt;/p>
&lt;p>&lt;strong>Time-slicing&lt;/strong> is the simplest: the GPU is assigned alternately, slot by slot, to different processes. Much worse latency (waits between slots); not recommended for production workloads with an SLA.&lt;/p>
&lt;h3 id="the-choice-for-multi-tenant-in-2026">The choice for multi-tenant in 2026&lt;/h3>
&lt;p>According to the enterprise adoption survey: 80% use MIG for untrusted multi-tenant (different customers who do not know each other) and MPS for trusted environments (processes from the same team) where you want to maximise throughput. Time-slicing is only used in dev/staging so that every developer can touch a GPU without the cost of exclusivity.&lt;/p>
&lt;p>An important limitation of MIG: it isolates compute and HBM memory, but the PCIe path remains shared. For PCIe-bound workloads (a lot of host↔device traffic), tenants in different MIG instances can still affect each other. For LLM inference the main path is HBM, so this is rarely a problem. But it is worth knowing.&lt;/p>
&lt;h3 id="mig-partitions-on-the-h100">MIG partitions on the H100&lt;/h3>
&lt;p>An H100 (80GB HBM3) can be partitioned into fixed profiles:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Profile&lt;/th>
&lt;th>SM&lt;/th>
&lt;th>Memory&lt;/th>
&lt;th>Max instances per GPU&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1g.10gb&lt;/td>
&lt;td>14&lt;/td>
&lt;td>10 GB&lt;/td>
&lt;td>7&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1g.20gb&lt;/td>
&lt;td>14&lt;/td>
&lt;td>20 GB&lt;/td>
&lt;td>4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2g.20gb&lt;/td>
&lt;td>28&lt;/td>
&lt;td>20 GB&lt;/td>
&lt;td>3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3g.40gb&lt;/td>
&lt;td>42&lt;/td>
&lt;td>40 GB&lt;/td>
&lt;td>2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7g.80gb&lt;/td>
&lt;td>98&lt;/td>
&lt;td>80 GB&lt;/td>
&lt;td>1 (the whole GPU)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For a mid-scale cluster with NVLink, MIG has a fundamental problem: when you partition with MIG, NVLink between GPUs is disabled. An H100 in MIG mode does &lt;strong>not&lt;/strong> take part in multi-GPU tensor parallel. If you are going to serve a large model with tensor parallel (Llama 3 70B with TP=4 or TP=8, for instance), those GPUs have to be whole, without MIG.&lt;/p>
&lt;p>That defines the architectural decision. There are two main approaches:&lt;/p>
&lt;h3 id="approach-a--a-shared-large-model-with-quotas-at-the-gateway">Approach A — A shared large model with quotas at the gateway&lt;/h3>
&lt;p>All the node&amp;rsquo;s GPUs serve a single large model with tensor parallel spanning the whole node. All tenants share that instance. Isolation happens at the gateway layer (quotas, rate limiting) and the policy layer (priority classes). The cluster&amp;rsquo;s kernel is one enormous vLLM instance with &lt;code>--max-num-seqs=128&lt;/code> or similar; internally, vLLM shares GPU time between the active requests with continuous batching.&lt;/p>
&lt;p>&lt;strong>Advantages&lt;/strong>: you use all the GPUs to the full, NVLink stays active, better KV cache utilisation.
&lt;strong>Disadvantages&lt;/strong>: soft isolation. A tenant that saturates does not degrade the others directly (vLLM batches), but it does compete for batch slots. You need serious priority classes.&lt;/p>
&lt;h3 id="approach-b--dedicating-gpus-per-model--tenant">Approach B — Dedicating GPUs per model / tenant&lt;/h3>
&lt;p>You split the GPUs into pools dedicated to different models. Examples on an 8-GPU node:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>4 GPUs&lt;/strong>: a large 70B model with TP=4.&lt;/li>
&lt;li>&lt;strong>2 GPUs&lt;/strong>: a mid-sized 32B model replicated (2 independent instances) for tenants with a strict SLA.&lt;/li>
&lt;li>&lt;strong>2 GPUs&lt;/strong>: miscellaneous workloads (smaller models, experimentation).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Advantages&lt;/strong>: physical isolation between critical models / tenants.
&lt;strong>Disadvantages&lt;/strong>: worse aggregate utilisation; some GPUs idle while others saturate.&lt;/p>
&lt;h3 id="approach-c-advanced--mig-on-some-gpus-plus-dedication-for-the-rest">Approach C (advanced) — MIG on some GPUs plus dedication for the rest&lt;/h3>
&lt;p>If you have small workloads (4B, 7B models), you can use MIG on 1-2 GPUs to serve them and dedicate the rest to tensor parallel for the large model. It combines strong isolation for the small workloads with full use of NVLink for the large model.&lt;/p>
&lt;h3 id="the-operational-choice-start-with-a-move-up-to-c-if-you-need-to">The operational choice: start with A, move up to C if you need to&lt;/h3>
&lt;p>In most deployments, Approach A (a shared large model plus quotas) is the right starting point. Utilisation is better, operation is simpler, and the gateway&amp;rsquo;s soft isolation works for reasonable workloads.&lt;/p>
&lt;p>When there is a tenant with a strict SLA that cannot tolerate competing with others, you move to Approach B for that tenant in particular (dedicating GPUs to an instance of the model just for them), keeping the rest of the cluster shared.&lt;/p>
&lt;p>Approach C is for when you have 10+ tenants with very heterogeneous profiles.&lt;/p>
&lt;h3 id="isolation-at-the-kubernetes-level">Isolation at the Kubernetes level&lt;/h3>
&lt;p>Independently of GPU isolation, pod-level isolation is applied in K8s:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Namespaces per tenant&lt;/strong>: &lt;code>tenant-soporte&lt;/code>, &lt;code>tenant-legal&lt;/code>, and so on.&lt;/li>
&lt;li>&lt;strong>ResourceQuotas and LimitRanges&lt;/strong>: CPU/memory limits per namespace.&lt;/li>
&lt;li>&lt;strong>NetworkPolicies&lt;/strong>: tenant A cannot talk to tenant B&amp;rsquo;s namespaces.&lt;/li>
&lt;li>&lt;strong>K8s PriorityClasses&lt;/strong>: classes with a numeric value defining preemption order if a more critical pod arrives.&lt;/li>
&lt;li>&lt;strong>PodDisruptionBudgets&lt;/strong>: how many pods of each deployment can go down simultaneously.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-4--observability-plane-seeing-what-happens-per-tenant">Layer 4 — Observability Plane: seeing what happens per tenant&lt;/h2>
&lt;p>The fourth layer: observability with a tenant dimension. Without it you cannot do cost attribution, you cannot debug incidents for a single tenant, you cannot show dashboards to stakeholders.&lt;/p>
&lt;h3 id="the-four-mandatory-properties">The four mandatory properties&lt;/h3>
&lt;p>&lt;strong>1. tenant_id on every span&lt;/strong>. The AI gateway resolves the API key and attributes a &lt;code>tenant_id&lt;/code>. That ID is propagated via &lt;code>params._meta&lt;/code> or OTel headers to every downstream component (vLLM, retrieval, MCP servers, tools). Any span in any system carries that label. It is what lets you reconstruct tenant-specific traces.&lt;/p>
&lt;p>&lt;strong>2. Metrics labelled per tenant&lt;/strong>. &lt;code>gen_ai.usage.input_tokens{tenant=&amp;quot;soporte-chat&amp;quot;}&lt;/code> or equivalents. Prometheus, Grafana, groupable by tenant.&lt;/p>
&lt;p>&lt;strong>3. Real cost attribution&lt;/strong>. The sum of tokens × cost/token per tenant gives the cost. For self-hosted vLLM, the cost is per GPU hour plus the proportional share of tokens (you can compute an equivalent cost per 1k tokens).&lt;/p>
&lt;p>&lt;strong>4. An immutable audit log&lt;/strong>. Every API key used, every model invoked, every quota change, every budget exceeded. For compliance.&lt;/p>
&lt;h3 id="showback-vs-chargeback">Showback vs chargeback&lt;/h3>
&lt;p>An important FinOps distinction that gained clarity in 2026:&lt;/p>
&lt;p>&lt;strong>Showback&lt;/strong>: visibility without consequence. &amp;ldquo;Support team, you have consumed 623 USD this month on LLMs&amp;rdquo;. Information, not an invoice. It lets you spot abuse without penalising anyone before the team understands what is going on.&lt;/p>
&lt;p>&lt;strong>Chargeback&lt;/strong>: the cost is charged to the team&amp;rsquo;s budget. When it runs out, it runs out. It changes behaviour.&lt;/p>
&lt;p>The practice that works: 6-18 months on showback while tags are calibrated, misattributions are identified and teams are trained. Then chargeback, once the numbers are credible. Launching chargeback on day 1 when the costs are still dirty creates an immediate political fight; launching showback prepares the ground so that chargeback lands in an orderly way.&lt;/p>
&lt;p>&lt;a href="https://spendark.com/blog/kubernetes-cost-allocation/">Only 14% of organisations have chargeback active&lt;/a> according to a recent survey, which indicates this is still mostly showback in real production.&lt;/p>
&lt;h3 id="tools">Tools&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://www.kubecost.com/">Kubecost&lt;/a>&lt;/strong>: cost allocation per namespace, deployment and pod on Kubernetes. For the cost of the shared GPU, allocate proportionally to the tokens consumed per tenant.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://www.finout.io/">Finout&lt;/a>&lt;/strong>: a FinOps platform that combines cloud bills and LLM API costs in a unified view with virtual tagging.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://langfuse.com/">Langfuse&lt;/a>&lt;/strong>: already covered. Cost tracking per trace, groupable by user or session metadata.&lt;/li>
&lt;li>&lt;strong>LiteLLM native tracking&lt;/strong>: LiteLLM&amp;rsquo;s master DB keeps running spend per team, user and API key, accessible via the API or the UI.&lt;/li>
&lt;/ul>
&lt;h3 id="the-minimum-multi-tenant-dashboard">The minimum multi-tenant dashboard&lt;/h3>
&lt;p>Any platform should have:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Summary per tenant&lt;/strong>: monthly spend, current RPS, TPM consumed, % of budget spent, active sessions.&lt;/li>
&lt;li>&lt;strong>Top users&lt;/strong> within each tenant (for detecting internal abuse).&lt;/li>
&lt;li>&lt;strong>p95 latency per tenant&lt;/strong>: SLA tracking.&lt;/li>
&lt;li>&lt;strong>429 / 503 errors&lt;/strong>: how many requests are being rate-limited or rejected because of overload.&lt;/li>
&lt;li>&lt;strong>Cost trend&lt;/strong>: the monthly trajectory with a projection.&lt;/li>
&lt;li>&lt;strong>Drift per tenant&lt;/strong> (from the post-tracing series): if a tenant starts getting worse results, raise an alert.&lt;/li>
&lt;/ol>
&lt;h2 id="sizing-on-mid-scale-gpu-clusters-concrete-decisions">Sizing on mid-scale GPU clusters: concrete decisions&lt;/h2>
&lt;p>Let us get down to hardware. As a reference we take a node with N H100 SXM (between 4 and 8) with NVLink/NVSwitch, 80 GB HBM3 each. That gives between 320 GB and 640 GB of aggregate VRAM. Inter-GPU connectivity is 900 GB/s (NVLink 4) or 600 GB/s (NVLink 3) depending on generation. HBM bandwidth per GPU is 3.35 TB/s.&lt;/p>
&lt;h3 id="default-decisions">Default decisions&lt;/h3>
&lt;p>Start with Approach A: all the node&amp;rsquo;s GPUs serving a single large 70B model in BF16 with tensor parallel = N. Expected real capacity (computed for a standard 8-GPU HGX node as an example; it scales roughly linearly with N):&lt;/p>
&lt;ul>
&lt;li>Model VRAM (70B BF16): ~140 GB (≈ 17.5 GB/GPU at TP=8).&lt;/li>
&lt;li>vLLM overhead + activations VRAM: ~10 GB/GPU.&lt;/li>
&lt;li>VRAM free for KV cache: ~52 GB/GPU. On an 8-GPU node that is &lt;strong>~416 GB aggregate&lt;/strong>; on a 4-GPU one, ~210 GB.&lt;/li>
&lt;li>With &lt;code>--kv-cache-dtype=fp8&lt;/code> and a 70B GQA model: ~320 KB/token.&lt;/li>
&lt;li>Aggregate cache capacity (8-GPU node): &lt;strong>~1.3M tokens&lt;/strong> to distribute between simultaneous sessions.&lt;/li>
&lt;/ul>
&lt;p>That translates into throughput and concurrency (indicative figures for an 8-GPU node):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:right">Simultaneous sessions&lt;/th>
&lt;th style="text-align:right">Average context per session&lt;/th>
&lt;th style="text-align:right">Aggregate throughput (tokens/s)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:right">32&lt;/td>
&lt;td style="text-align:right">16K&lt;/td>
&lt;td style="text-align:right">~5000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">64&lt;/td>
&lt;td style="text-align:right">8K&lt;/td>
&lt;td style="text-align:right">~8000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">128&lt;/td>
&lt;td style="text-align:right">4K&lt;/td>
&lt;td style="text-align:right">~12000&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Typical latencies: TTFT ~150ms at low traffic, TPOT ~15-20 ms/tok. Under high concurrency, TTFT rises to ~500ms if the queue is saturated.&lt;/p>
&lt;h3 id="an-example-tenant-scheme">An example tenant scheme&lt;/h3>
&lt;p>A cluster with 4 tenants and a research pool:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tenant&lt;/th>
&lt;th style="text-align:right">TPM cap&lt;/th>
&lt;th style="text-align:right">RPM cap&lt;/th>
&lt;th style="text-align:right">Concurrency&lt;/th>
&lt;th style="text-align:right">Budget&lt;/th>
&lt;th>Priority&lt;/th>
&lt;th>Models&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Support chat&lt;/td>
&lt;td style="text-align:right">80K&lt;/td>
&lt;td style="text-align:right">800&lt;/td>
&lt;td style="text-align:right">50&lt;/td>
&lt;td style="text-align:right">1500 USD/month&lt;/td>
&lt;td>Guaranteed&lt;/td>
&lt;td>llama-3-70b, qwen3-32b&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Legal RAG&lt;/td>
&lt;td style="text-align:right">30K&lt;/td>
&lt;td style="text-align:right">200&lt;/td>
&lt;td style="text-align:right">15&lt;/td>
&lt;td style="text-align:right">600 USD/month&lt;/td>
&lt;td>Guaranteed&lt;/td>
&lt;td>llama-3-70b&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Code agent&lt;/td>
&lt;td style="text-align:right">50K&lt;/td>
&lt;td style="text-align:right">300&lt;/td>
&lt;td style="text-align:right">25&lt;/td>
&lt;td style="text-align:right">1200 USD/month&lt;/td>
&lt;td>Best-effort&lt;/td>
&lt;td>llama-3-70b, qwen-coder&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Data extr. batch&lt;/td>
&lt;td style="text-align:right">40K&lt;/td>
&lt;td style="text-align:right">1000&lt;/td>
&lt;td style="text-align:right">40&lt;/td>
&lt;td style="text-align:right">400 USD/month&lt;/td>
&lt;td>Spot&lt;/td>
&lt;td>llama-3-70b, qwen3-32b&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Research / notebooks&lt;/td>
&lt;td style="text-align:right">10K&lt;/td>
&lt;td style="text-align:right">100&lt;/td>
&lt;td style="text-align:right">5&lt;/td>
&lt;td style="text-align:right">200 USD/month&lt;/td>
&lt;td>Spot&lt;/td>
&lt;td>all&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>TPM total: 210K. Aggregate cluster capacity: ~180K sustained TPM. It is overcommitted by about 15%, assuming not every tenant hits the ceiling at the same time. That is normal and desirable; if they all do it at once, the priority classes degrade in an orderly way.&lt;/p>
&lt;h3 id="when-to-add-hardware">When to add hardware&lt;/h3>
&lt;p>Signals that the node has become too small:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Sustained p95 TTFT &amp;gt; 500 ms&lt;/strong> during peak hours → the queue is building up.&lt;/li>
&lt;li>&lt;strong>&lt;code>vllm:num_requests_waiting&lt;/code> constantly &amp;gt; 20&lt;/strong> → admission control starting to reject.&lt;/li>
&lt;li>&lt;strong>Sustained GPU utilisation &amp;gt; 80% at critical hours&lt;/strong> without dropping in quiet hours → there is no headroom.&lt;/li>
&lt;li>&lt;strong>429 rate on guaranteed tenants &amp;gt; 1%&lt;/strong> → the platform is breaking its SLA in production.&lt;/li>
&lt;/ul>
&lt;p>When several of these hold, the natural next step is to add another HGX node with internal NVLink and stand up a second vLLM instance of the same model. The gateway load-balances between the two instances. Aggregate throughput doubles; latency stays the same.&lt;/p>
&lt;h2 id="common-operational-traps">Common operational traps&lt;/h2>
&lt;h3 id="a-gateway-without-auth-a-backdoor-into-the-cluster">A gateway without auth: a backdoor into the cluster&lt;/h3>
&lt;p>Your vLLM sits on a ClusterIP Service and the main app talks to it. Some tenant discovers the endpoint directly and hits it without going through the gateway. Quotas and costs are silently evaded. Use a strict NetworkPolicy: only the gateway can talk to the vLLM Services; the rest of the cluster cannot.&lt;/p>
&lt;h3 id="mig-and-nvlink-are-incompatible">MIG and NVLink are incompatible&lt;/h3>
&lt;p>You enable MIG on a GPU thinking you will get isolation plus multi-GPU; you discover that MIG disables NVLink. Any large model with TP becomes unusable. Decide MIG vs NVLink globally per cluster, not per individual GPU.&lt;/p>
&lt;h3 id="quotas-pinned-to-the-clusters-ceiling">Quotas pinned to the cluster&amp;rsquo;s ceiling&lt;/h3>
&lt;p>You add up the TPM of all the tenants and it comes to exactly the cluster&amp;rsquo;s capacity. When two tenants peak at the same time, both wait or one gets rejected. An overcommit of 10-20% is healthy (it assumes they do not all peak at once); more than that is dangerous.&lt;/p>
&lt;h3 id="no-multi-tenant-observability-from-day-1">No multi-tenant observability from day 1&lt;/h3>
&lt;p>You launch with quotas and isolation but without tenant_id on spans. Three months later, your CFO asks &amp;ldquo;how much does the support agent cost versus the legal one?&amp;rdquo; and you cannot answer. OTel with a mandatory tenant_id from the first version, even if there are no dashboards yet; having the data is worth more than having perfect dashboards with no data.&lt;/p>
&lt;h3 id="showback-that-never-reaches-chargeback">Showback that never reaches chargeback&lt;/h3>
&lt;p>You have been on showback for 18 months, the teams know the numbers, nobody changes behaviour. Without the pressure of real chargeback, the incentive dilutes. Set an explicit calendar for the transition to chargeback, with an owner and a deadline.&lt;/p>
&lt;h3 id="non-whitelisted-models-eating-the-budget">Non-whitelisted models eating the budget&lt;/h3>
&lt;p>A team discovers that LiteLLM has &lt;code>gpt-4o&lt;/code> configured. They use it without permission. The budget burns on an external API when the idea was to use the cheap self-hosted one. Keep an explicit per-team whitelist of accessible models.&lt;/p>
&lt;h3 id="badly-calibrated-priority-classes">Badly calibrated priority classes&lt;/h3>
&lt;p>Everyone declares themselves &amp;ldquo;guaranteed&amp;rdquo;. At the first peak, there is nothing left to degrade and everything suffers. Priority classes only for critical cases, with justification. The majority should be best-effort.&lt;/p>
&lt;h3 id="no-failover-from-the-gateway">No failover from the gateway&lt;/h3>
&lt;p>Your vLLM goes down. The gateway has no fallback configured and returns 503 to every tenant. Configure a fallback to another model, ideally an external one (OpenAI) for guaranteed workloads, even if it costs more per hour. Availability is worth more than cost per hour.&lt;/p>
&lt;h2 id="an-operational-roadmap-for-getting-started">An operational roadmap for getting started&lt;/h2>
&lt;p>If you start from scratch with an empty GPU node, the minimum order is as follows. Each milestone is a day&amp;rsquo;s work with slack, not a tight one:&lt;/p>
&lt;p>&lt;strong>Day 1-2 — Base K8s infrastructure&lt;/strong>. NVIDIA GPU Operator + nvidia-device-plugin + dcgm-exporter + cluster-default NetworkPolicies. Validation: a basic pod with &lt;code>nvidia.com/gpu: 1&lt;/code> gets scheduled.&lt;/p>
&lt;p>&lt;strong>Day 3 — vLLM with a large model and tensor parallel across the whole node&lt;/strong>. The vLLM Production Stack Helm chart (or bare vLLM manifests). Model weights on a shared PVC (CephFS or NFS). Validation: a &lt;code>curl&lt;/code> request against the internal Service responds.&lt;/p>
&lt;p>&lt;strong>Day 4 — AI Gateway: LiteLLM&lt;/strong>. Helm chart, Postgres for budgets, master key, a first model_list pointing at vLLM. Validation: an OpenAI-compatible request through LiteLLM responds with the same content as vLLM directly.&lt;/p>
&lt;p>&lt;strong>Day 5 — Basic multi-tenancy&lt;/strong>. Create teams, API keys, budgets, model whitelists. Test with two teams. Validation: the second team, using the model it does not have whitelisted, gets a 403.&lt;/p>
&lt;p>&lt;strong>Day 6 — Minimum observability&lt;/strong>. Prometheus + Grafana scraping vLLM and LiteLLM. A dashboard with TTFT, TPOT, throughput, num_requests_waiting and budget_consumed_per_team. Validation: visible in Grafana with real data.&lt;/p>
&lt;p>&lt;strong>Day 7-8 — Pilot customer&lt;/strong>. One real tenant (ideally a controlled internal one) starts using it. Measure real latencies, discover the first operational incidents.&lt;/p>
&lt;p>&lt;strong>Day 9-10 — Tuning&lt;/strong>. Adjust &lt;code>--max-num-seqs&lt;/code>, &lt;code>--gpu-memory-utilization&lt;/code>, priority classes and quotas based on what the pilot taught you.&lt;/p>
&lt;p>&lt;strong>Day 11-14 — Onboarding the second tenant plus iteration&lt;/strong>. Repeat. Every new tenant onboarded reveals new cases.&lt;/p>
&lt;p>After two weeks you have an operational platform with two real tenants and data to decide whether it is ready for more. The line of progress from here on is horizontal (more tenants) until you saturate; from then on, vertical (more hardware).&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Continuous fine-tuning in production&lt;/strong> (post 6, already decided): LoRA/QLoRA/DPO, dataset curation, eval gates, A/B versioning with real traffic between model versions.&lt;/li>
&lt;li>&lt;strong>Constitutional AI and alignment at runtime&lt;/strong>: an option still on the table.&lt;/li>
&lt;li>&lt;strong>Edge LLMs&lt;/strong>: when an H100 cluster is too expensive for a specific workload, distilled models running on NPUs or consumer GPUs.&lt;/li>
&lt;li>&lt;strong>GPU networking deep dive&lt;/strong>: NCCL, InfiniBand, GPUDirect, RDMA. For multi-node clusters with cross-host tensor parallel.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>Multi-tenancy and GPU isolation:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.linuxoperatingsystem.net/multitenant-gpu-infrastructure-4-powerful-design-rules/">Multitenant GPU Infrastructure: 4 Powerful Design Rules&lt;/a> — survey of enterprise patterns.&lt;/li>
&lt;li>&lt;a href="https://www.spheron.network/blog/run-multiple-llms-one-gpu-mig-time-slicing-guide/">Run Multiple LLMs on One GPU: MIG, Time-Slicing, and MPS Guide (Spheron)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://sagar-parmar.medium.com/a-practical-guide-to-gpu-partitioning-with-mig-on-on-prem-servers-and-kubernetes-797ccea7e1c7">A Practical Guide to GPU Partitioning with MIG (Medium)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.suse.com/c/kubecon-eu-2026-nvidia-mig-suse-virtualization/">GPU Partitioning for AI Workloads: NVIDIA MIG with SUSE Virtualization (KubeCon EU 2026)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2508.20274">Predictable LLM Serving on GPU Clusters (arxiv 2508.20274)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2603.00356">Token Management in Multi-Tenant AI Inference Platforms (arxiv 2603.00356)&lt;/a> — paper on priority + admission control.&lt;/li>
&lt;/ul>
&lt;p>AI Gateways:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://docs.litellm.ai/docs/proxy/multi_tenant_architecture">LiteLLM — Multi-Tenant Architecture&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://docs.litellm.ai/docs/proxy/users">LiteLLM — Budgets and Rate Limits&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://portkey.ai/">Portkey AI Gateway&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://konghq.com/blog/enterprise/llm-cost-management-ai-showback-and-chargeback">Kong AI Gateway — LLM Cost Management&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.spheron.network/blog/ai-gateway-litellm-portkey-kong-gpu-cloud/">AI Gateway Setup 2026: LiteLLM, Portkey, Kong (Spheron)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://techsy.io/en/blog/best-llm-gateway-tools">Stop Juggling LLM APIs: 8 Gateways Ranked 2026 (TECHSY)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Multi-tenant FinOps:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.digiusher.com/blog/the-death-of-cost-allocation-why-chargeback-models-are-failing-in-the-kubernetes-and-ai-era/">The Death of Chargeback in the Kubernetes and AI Era (DigiUsher)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://medium.com/@nicholasthoni/how-to-actually-track-kubernetes-costs-in-2026-a-practical-guide-to-showback-chargeback-and-the-6a4c23f9cf51">How to Actually Track Kubernetes Costs in 2026 (Medium)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://konghq.com/blog/enterprise/llm-cost-management-ai-showback-and-chargeback">LLM Cost Management: AI Showback and Chargeback (Kong)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.kubecost.com/">Kubecost — cost allocation&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.finout.io/">Finout — FinOps + AI costs&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Cross-references:&lt;/p>
&lt;ul>
&lt;li>Previous posts in series 4: &lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">MLOps for LLMs overview&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage pipeline&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant&lt;/a>.&lt;/li>
&lt;li>Relevant posts from the inference series: &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a> — the multi-GPU HGX node scenario we develop here. &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM operators on K8s&lt;/a> — the vLLM Production Stack and OME that the gateway can direct.&lt;/li>
&lt;li>Observability: &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF + drift&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Hubble: eBPF network observability, state of the art 2026 and the new frontier with AI agents</title><link>https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/</link><pubDate>Tue, 19 May 2026 06:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;a href="https://github.com/cilium/hubble">Hubble&lt;/a> is &lt;strong>Cilium&amp;rsquo;s native network observability&lt;/strong>, built on the same eBPF programs Cilium uses for enforcement. It does not duplicate the datapath or instrument the kernel its own way: it &lt;strong>listens&lt;/strong> to the hooks Cilium already has and produces structured flow logs with Kubernetes context included, covering pod, namespace, labels, service, policy verdict and L7 payload where applicable. It is what happens when someone decides that &lt;code>tcpdump&lt;/code> with &lt;code>grep&lt;/code> does not scale to 10,000 pods and builds a distributed system of their own (a Hubble server per node, plus Hubble Relay as the aggregator, plus a CLI and a UI) with practically zero overhead, because the capture was already happening. In 2026 it is at version 1.19.3 (April 2026), with Cilium 1.19 marking the project&amp;rsquo;s tenth anniversary; IP options tracing has arrived, along with filtering by encryption status, the drop event tagged with the exact NetworkPolicy that caused it (direct attribution), a stabilised field mask API, and the first wave of ML anomaly detection applied to flows for predictive security in IoT/5G clusters. And, most interesting of all for 2026: a new frontier appears where the same eBPF observes AI agents such as Claude Code, Gemini CLI and MCP agents, intercepting SSL/TLS and stdio without instrumenting the code, which turns the Cilium + Hubble + Tetragon + AgentSight stack into a complete set of tools for understanding what an agentic system does inside a cluster.&lt;/p>
&lt;blockquote>
&lt;p>This article is part 3 of the eBPF series. Part 1: &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium: how the kernel learned to skip its own TCP/IP stack&lt;/a>. Part 2: &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon: Cilium&amp;rsquo;s security cousin that sees every syscall in the kernel&lt;/a>. Here we complete the observability quadrant: network with Hubble, process with Tetragon, AI agent with AgentSight.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-tcpdump-that-speaks-kubernetes">The analogy: tcpdump that speaks Kubernetes&lt;/h2>
&lt;p>If you have administered networks over the last twenty years, &lt;code>tcpdump&lt;/code> and Wireshark have been your daily bread. They capture packets on an interface, parse them, and let you filter with &lt;code>tcp.port == 443 and host 10.0.0.5&lt;/code>. They work, they have been working since the 90s, and they are the first thing you open when something smells wrong.&lt;/p>
&lt;p>Now stick &lt;code>tcpdump&lt;/code> on a Kubernetes cluster with 10,000 pods. The problems show up in order:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>One &lt;code>tcpdump&lt;/code> session per node&lt;/strong>. You wanted to &amp;ldquo;see the traffic between the frontend and the API&amp;rdquo;; you need SSH to every node, tcpdump on every NIC, synchronised timestamps, manual aggregation.&lt;/li>
&lt;li>&lt;strong>There is no K8s context&lt;/strong>. You see a packet from &lt;code>10.244.5.7&lt;/code> to &lt;code>10.244.8.42&lt;/code>. Which pod was that? Which namespace? Which label? You have to correlate with &lt;code>kubectl get pod -A -o wide&lt;/code> every single time.&lt;/li>
&lt;li>&lt;strong>No L7 understanding&lt;/strong>. You see a POST to HTTPS, and you cannot know the method and path because it is encrypted on the wire. If there is mTLS between pods, worse.&lt;/li>
&lt;li>&lt;strong>High cost&lt;/strong>: full packet capture with a copy to userspace slows down the datapath. Under dense traffic, you notice it.&lt;/li>
&lt;/ol>
&lt;p>Hubble is &lt;strong>tcpdump redesigned for all of that&lt;/strong>. It reuses the eBPF programs that are already processing every packet (Cilium puts them there for enforcement) and, while they take their allow/deny decision, they emit a flow event with all the context: source and destination pod identity, namespace, labels, protocol, verdict, and, if Cilium has done L7 parsing via Envoy, the HTTP method, path, status code, DNS query, Kafka topic. That event travels through a ringbuffer to userspace, it is received by the Hubble server that lives inside the node&amp;rsquo;s Cilium agent, and it is exposed over gRPC. A separate service, Hubble Relay, aggregates the streams from all nodes and gives you a single cluster-wide API. On top of that: a CLI (&lt;code>hubble&lt;/code>) and a web UI with a real-time service graph.&lt;/p>
&lt;p>Zero additional copies. Zero duplicated parsing. And the result is understandable by anyone who knows what a Pod is.&lt;/p>
&lt;h2 id="architecture-four-pieces-visible-from-outside">Architecture: four pieces visible from outside&lt;/h2>
&lt;p>Hubble is made up of four logical components, all optional depending on what you want to do:&lt;/p>
&lt;h3 id="1-hubble-server-embedded-in-every-cilium-agent">1. Hubble Server (embedded in every Cilium agent)&lt;/h3>
&lt;p>It lives inside the Cilium agent process (it is not a separate binary). Each node exposes a gRPC endpoint locally on the Unix socket &lt;code>/var/run/cilium/hubble.sock&lt;/code>. The server listens to the events the eBPF programs emit to the ringbuffer, enriches them with Kubernetes metadata (which the agent already has in memory), and makes them available to consumers.&lt;/p>
&lt;p>Enabling it: &lt;code>--set hubble.enabled=true&lt;/code> in Cilium&amp;rsquo;s Helm chart. By default, the server is only accessible locally; if you want to consume it from another node, it has to be exposed (which is what Hubble Relay does).&lt;/p>
&lt;h3 id="2-hubble-relay-aggregator">2. Hubble Relay (aggregator)&lt;/h3>
&lt;p>It is a separate Deployment (typically 1 replica, scalable) that connects to every Hubble server in the cluster and aggregates their streams into a single API. When your CLI or UI asks for &amp;ldquo;the last 1000 flows in the cluster&amp;rdquo;, the Relay collects them in parallel from all nodes and returns the union.&lt;/p>
&lt;p>Enabling it: &lt;code>--set hubble.relay.enabled=true&lt;/code>. Without the Relay you only see the traffic of the node you are connected to, which is useful for local debugging but not for a cluster-wide view.&lt;/p>
&lt;h3 id="3-hubble-cli-hubble">3. Hubble CLI (&lt;code>hubble&lt;/code>)&lt;/h3>
&lt;p>A Go binary that speaks gRPC with the Relay (or with a local Hubble server). It supports two main modes:&lt;/p>
&lt;ul>
&lt;li>&lt;code>hubble observe&lt;/code>: a real-time stream of flows, with very expressive filters (by namespace, pod, port, verdict, protocol, label).&lt;/li>
&lt;li>&lt;code>hubble status&lt;/code>: the state of the Hubble cluster (how many nodes connected, lag, flow rate).&lt;/li>
&lt;/ul>
&lt;p>And the equivalent of &lt;code>tcpdump&lt;/code>&amp;rsquo;s pcap dump: &lt;code>hubble observe --output jsonpb &amp;gt; flows.json&lt;/code> to process afterwards with &lt;code>jq&lt;/code> or other tools.&lt;/p>
&lt;h3 id="4-hubble-ui">4. Hubble UI&lt;/h3>
&lt;p>A web frontend that connects to Hubble Relay and shows:&lt;/p>
&lt;ul>
&lt;li>A service graph in real time (which Pod talks to which Service, which protocols it uses, which verdict).&lt;/li>
&lt;li>A filterable list of flows.&lt;/li>
&lt;li>&lt;strong>L7 details&lt;/strong> where there are any (HTTP method/path/status, DNS query/response).&lt;/li>
&lt;/ul>
&lt;p>Enabling it: &lt;code>--set hubble.ui.enabled=true&lt;/code>. Useful for presentations to non-CLI teams; it does not replace the CLI for serious debugging.&lt;/p>
&lt;div class="diagram" style="max-width:720px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 720 290" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Hubble architecture">
&lt;style>.title{font:600 13px sans-serif;fill:#222}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#555}.box{stroke:#444;stroke-width:1.4}.k{fill:#ffe9d6}.s{fill:#d6eaff}.r{fill:#d9f5d6}.c{fill:#e9d6f5}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#hh)}&lt;/style>
&lt;defs>&lt;marker id="hh" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="360" y="18" text-anchor="middle" class="title">Hubble architecture: 4 pieces, eBPF as the only data source&lt;/text>
&lt;rect x="30" y="40" width="200" height="50" rx="6" class="box k"/>
&lt;text x="130" y="60" text-anchor="middle" class="lbl">eBPF (kernel)&lt;/text>
&lt;text x="130" y="78" text-anchor="middle" class="sm">Cilium programs&lt;/text>
&lt;rect x="30" y="115" width="200" height="50" rx="6" class="box s"/>
&lt;text x="130" y="135" text-anchor="middle" class="lbl">Hubble Server (node)&lt;/text>
&lt;text x="130" y="153" text-anchor="middle" class="sm">local grpc, inside the agent&lt;/text>
&lt;rect x="270" y="115" width="180" height="50" rx="6" class="box s"/>
&lt;text x="360" y="135" text-anchor="middle" class="lbl">Hubble Server (node N)&lt;/text>
&lt;text x="360" y="153" text-anchor="middle" class="sm">one per node&lt;/text>
&lt;rect x="490" y="115" width="200" height="50" rx="6" class="box s"/>
&lt;text x="590" y="135" text-anchor="middle" class="lbl">Hubble Server (node …)&lt;/text>
&lt;text x="590" y="153" text-anchor="middle" class="sm">N agents = N servers&lt;/text>
&lt;rect x="220" y="190" width="280" height="50" rx="6" class="box r"/>
&lt;text x="360" y="210" text-anchor="middle" class="lbl">Hubble Relay (Deployment)&lt;/text>
&lt;text x="360" y="228" text-anchor="middle" class="sm">aggregates gRPC streams from all nodes&lt;/text>
&lt;rect x="80" y="245" width="160" height="35" rx="6" class="box c"/>
&lt;text x="160" y="266" text-anchor="middle" class="lbl">Hubble CLI&lt;/text>
&lt;rect x="290" y="245" width="160" height="35" rx="6" class="box c"/>
&lt;text x="370" y="266" text-anchor="middle" class="lbl">Hubble UI&lt;/text>
&lt;rect x="500" y="245" width="180" height="35" rx="6" class="box c"/>
&lt;text x="590" y="266" text-anchor="middle" class="lbl">Prometheus / OTLP&lt;/text>
&lt;path class="arr" d="M130,90 L130,115"/>
&lt;path class="arr" d="M130,165 L290,190"/>
&lt;path class="arr" d="M360,165 L360,190"/>
&lt;path class="arr" d="M590,165 L430,190"/>
&lt;path class="arr" d="M340,240 L200,245"/>
&lt;path class="arr" d="M360,240 L370,245"/>
&lt;path class="arr" d="M380,240 L560,245"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="what-you-see-the-hubble-flow-log-from-the-inside">What you see: the Hubble flow log from the inside&lt;/h2>
&lt;p>A Hubble flow in JSON format looks roughly like this (simplified):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;time&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2026-05-19T03:12:45.182Z&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;verdict&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;FORWARDED&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;source&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;ID&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">5482&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;identity&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">24871&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;namespace&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;prod-api&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;labels&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;app=checkout&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;team=payments&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;pod_name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;checkout-7c9f-x8j2&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;workloads&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;checkout&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;kind&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Deployment&amp;#34;&lt;/span>&lt;span class="p">}]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;destination&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;ID&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">12041&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;identity&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">18356&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;namespace&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;prod-db&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;labels&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;app=postgres&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;tier=primary&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;pod_name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;postgres-0&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;Type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;L3_L4&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;l4&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;TCP&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;source_port&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">41982&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;destination_port&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">5432&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;flags&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;SYN&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;node_name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;rke2-worker-03&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;Summary&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;TCP Flags: SYN&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>When L7 parsing is active (via embedded Envoy or Hubble&amp;rsquo;s lightweight parser), the same flow adds:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="s2">&amp;#34;l7&amp;#34;&lt;/span>&lt;span class="err">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;REQUEST&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;http&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;code&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">200&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;method&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;GET&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;url&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;/api/v1/cart/items&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;protocol&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;HTTP/1.1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;headers&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="nt">&amp;#34;key&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user-agent&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;value&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;checkout/1.4.2&amp;#34;&lt;/span>&lt;span class="p">}]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The protocols natively supported for L7 parsing:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>HTTP/1.1 and HTTP/2&lt;/strong> (including gRPC over HTTP/2).&lt;/li>
&lt;li>&lt;strong>DNS&lt;/strong> (queries and responses, with domains, types, response codes).&lt;/li>
&lt;li>&lt;strong>Kafka&lt;/strong> (topics, API keys).&lt;/li>
&lt;li>&lt;strong>TLS handshake&lt;/strong> (SNI, not the encrypted payload by default).&lt;/li>
&lt;li>&lt;strong>MySQL, Cassandra&lt;/strong> (with optional modules).&lt;/li>
&lt;/ul>
&lt;p>For HTTP and gRPC, Cilium can enable the embedded Envoy proxy for the flows you want to inspect (not all of them; it is selective via &lt;code>CiliumNetworkPolicy&lt;/code> with L7 rules). Without Envoy there is lightweight parsing, but less detailed.&lt;/p>
&lt;h2 id="verdict-and-drop-attribution">Verdict and drop attribution&lt;/h2>
&lt;p>Every flow has a &lt;code>verdict&lt;/code>: &lt;code>FORWARDED&lt;/code>, &lt;code>DROPPED&lt;/code>, &lt;code>ERROR&lt;/code>, &lt;code>AUDIT&lt;/code>, &lt;code>REDIRECTED&lt;/code>, &lt;code>TRACED&lt;/code>, &lt;code>TRANSLATED&lt;/code>. For the &lt;code>DROPPED&lt;/code> case, Hubble includes a structured reason (&lt;code>drop_reason&lt;/code>) and, since Cilium 1.19, &lt;strong>the exact NetworkPolicy&lt;/strong> that caused it.&lt;/p>
&lt;p>That last point changes day-to-day operations. Before, when a pod could not talk to another one, the debug flow was:&lt;/p>
&lt;ol>
&lt;li>See the dropped flow in Hubble.&lt;/li>
&lt;li>Look at every CiliumNetworkPolicy in the namespace.&lt;/li>
&lt;li>Reason manually about which one, with which labels, is blocking it.&lt;/li>
&lt;/ol>
&lt;p>With Cilium 1.19&amp;rsquo;s attribution, the &lt;code>policy_match_info&lt;/code> field tells you directly &amp;ldquo;it was dropped by the &lt;code>frontend-egress&lt;/code> policy, rule 3&amp;rdquo;. You go from &amp;ldquo;Sherlock Holmes for 20 minutes&amp;rdquo; to &amp;ldquo;kubectl get -o yaml of that specific policy&amp;rdquo;.&lt;/p>
&lt;h2 id="prometheus-metrics-and-grafana-dashboards">Prometheus metrics and Grafana dashboards&lt;/h2>
&lt;p>Hubble also exposes aggregated metrics in Prometheus format, separate from the gRPC flow stream. Enabling it: &lt;code>--set hubble.metrics.enabled=true&lt;/code> (Helm) plus a list of the set you want to export.&lt;/p>
&lt;p>The usual metric groups:&lt;/p>
&lt;ul>
&lt;li>&lt;code>flow&lt;/code>: total flows by verdict, source/dest, protocol.&lt;/li>
&lt;li>&lt;code>http&lt;/code>: requests by method, response code, latency (histogram).&lt;/li>
&lt;li>&lt;code>dns&lt;/code>: queries, response codes, top-N domains.&lt;/li>
&lt;li>&lt;code>tcp&lt;/code>: handshakes, retransmissions, congestion window.&lt;/li>
&lt;li>&lt;code>drop&lt;/code>: drops by reason, with NetworkPolicy attribution.&lt;/li>
&lt;li>&lt;code>port-distribution&lt;/code>: histogram of active ports.&lt;/li>
&lt;li>&lt;code>policy&lt;/code>: hits by policy and verdict.&lt;/li>
&lt;/ul>
&lt;p>These metrics have rich K8s labels (&lt;code>source_workload&lt;/code>, &lt;code>destination_workload&lt;/code>, &lt;code>namespace&lt;/code>, and so on) that make them pivotable in Grafana. There are &lt;a href="https://grafana.com/grafana/dashboards/?search=hubble">prebuilt dashboards on Grafana Labs&lt;/a> covering the common cases; importing one and having immediate visibility costs five minutes.&lt;/p>
&lt;p>Cost: metrics with many K8s labels can &lt;strong>blow up cardinality&lt;/strong> in Prometheus. For large clusters (&amp;gt;1,000 pods), it is worth reviewing which set you export and using drop rules in Prometheus to keep a lid on it.&lt;/p>
&lt;h2 id="deployment-helm-on-one-screen">Deployment: Helm on one screen&lt;/h2>
&lt;p>The canonical Cilium install with full Hubble:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># values.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">hubble&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metrics&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">dns:query;ignoreAAAA&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">drop&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">tcp&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">flow&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">port-distribution&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">icmp&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">httpV2:exemplars=true;labelsContext=source_ip,source_namespace,source_workload,destination_ip,destination_namespace,destination_workload,traffic_direction&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serviceMonitor&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># auto-discovered by kube-prometheus-stack&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">relay&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rollOutPods&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ui&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rollOutPods&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ingress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">className&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">hosts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">hubble.example.local&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the install itself:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">helm upgrade --install cilium cilium/cilium -n kube-system -f values.yaml
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>After installing, validate with &lt;code>cilium status&lt;/code> (the Cilium CLI) that the Hubble section shows OK, and try your first flow with:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">cilium hubble observe --namespace prod-api --pod checkout-7c9f-x8j2
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="state-of-the-art-in-2026">State of the art in 2026&lt;/h2>
&lt;p>&lt;a href="https://www.infoq.com/news/2026/02/cilium-119/">Cilium 1.19 was released in February 2026&lt;/a>, marking the project&amp;rsquo;s tenth anniversary. Hubble reached version 1.19.3 on 22 April 2026. The relevant news:&lt;/p>
&lt;h3 id="direct-attribution-of-drops-to-a-networkpolicy">Direct attribution of drops to a NetworkPolicy&lt;/h3>
&lt;p>Already covered above; it is probably the most valuable operational change in the release. Any dropped flow carries the name, namespace and specific rule of the policy responsible. It is also available via Prometheus metrics, which allows alerts of the form &amp;ldquo;policy X is dropping &amp;gt;N requests/second&amp;rdquo;.&lt;/p>
&lt;h3 id="tracing-with-ip-options">Tracing with IP options&lt;/h3>
&lt;p>Hubble can now trace individual packets with IP options enabled. It is a mechanism similar to traceroute, but at L3: you put a mark on the packet and Cilium reports it every time the packet crosses a node or an eBPF decision. Useful for debugging multi-cluster paths, fabric mesh, or NetworkPolicies applied at different layers.&lt;/p>
&lt;h3 id="filtering-by-encryption-status">Filtering by encryption status&lt;/h3>
&lt;p>A new CLI flag: &lt;code>hubble observe --encryption-status=encrypted&lt;/code> (or &lt;code>unencrypted&lt;/code>). Useful for validating deployments with WireGuard or IPsec enabled pod-to-pod: you confirm that the traffic that &lt;strong>should&lt;/strong> be encrypted is, and you spot regressions quickly.&lt;/p>
&lt;h3 id="hubble-field-mask-api-stabilised">Hubble field mask API stabilised&lt;/h3>
&lt;p>The &lt;code>field_mask&lt;/code> lets you ask for only the parts of the flow you care about, hugely reducing bandwidth and processing when all you need is, say, source/dest and verdict. It used to be experimental; now it is stable and it is default-on in the CLI.&lt;/p>
&lt;h3 id="ai-driven-anomaly-detection-predictive-security">AI-driven anomaly detection (predictive security)&lt;/h3>
&lt;p>This is the most talked-about addition of 2026. Cilium 1.19 adds hooks so that an external consumer, typically an ML system, can process flows in streaming and detect statistical anomalies: pods that suddenly talk to new destinations, latency spikes on an API, odd DNS sequences. The detection part happens outside the Cilium agent (nobody wants heavy ML in the datapath), but Cilium exposes the flows with the pre-computed features the model needs. The published use cases focus on IoT and 5G, where traffic is high in volume and low in variety, ideal conditions for anomaly detection.&lt;/p>
&lt;h3 id="scaling-to-10000-pods">Scaling to 10,000+ pods&lt;/h3>
&lt;p>Cilium 1.19 has done serious work on scalability: Hubble Relay can now aggregate streams from hundreds of nodes without saturating; the default field_mask reduces inter-node bandwidth; and flows can be sampled under high load if your use is statistical analysis rather than forensic debugging.&lt;/p>
&lt;h3 id="cilium-120-in-development">Cilium 1.20 in development&lt;/h3>
&lt;p>&lt;a href="https://docs.cilium.io/en/latest/operations/upgrade/">Cilium 1.20&lt;/a> is in the development branch. The most relevant bits for Hubble:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Unification of &lt;code>preferIpv6&lt;/code>&lt;/strong>: the &lt;code>hubble.preferIpv6&lt;/code> flag was deprecated in favour of the global &lt;code>preferIpv6&lt;/code> that applies to every Cilium component.&lt;/li>
&lt;li>&lt;strong>&lt;code>tetragon-python&lt;/code> SDK&lt;/strong>: although it belongs to Tetragon, not Hubble, it sets a trend, namely eBPF policies written in Python instead of YAML. Hubble will probably follow a similar path.&lt;/li>
&lt;/ul>
&lt;h2 id="the-new-frontier-ebpf-and-ai-agents">The new frontier: eBPF and AI agents&lt;/h2>
&lt;p>So far, the classic Hubble content. But there is a 2026 twist worth covering, because it closes the loop with the other series on this blog.&lt;/p>
&lt;p>When a Kubernetes cluster starts running AI agents, such as Claude Code, Gemini CLI, or LangGraph-based agents calling APIs and MCP servers, the observability problem changes shape. It is no longer enough to know &amp;ldquo;which pod talked to which pod&amp;rdquo; (that is Hubble) or &amp;ldquo;which process ran what&amp;rdquo; (that is Tetragon). You need to know:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Which external APIs the agent is calling&lt;/strong> and with which prompts.&lt;/li>
&lt;li>&lt;strong>Which MCP tools it is invoking&lt;/strong>, with which arguments.&lt;/li>
&lt;li>&lt;strong>How many tokens it consumes&lt;/strong>, which model it picks, how much it costs.&lt;/li>
&lt;li>&lt;strong>Whether the agent drifts&lt;/strong> from expected behaviour (out-of-policy queries, jailbreak attempts, secret leakage).&lt;/li>
&lt;/ul>
&lt;p>The traditional solutions, instrumenting the agent&amp;rsquo;s code with OpenTelemetry or parsing structured logs, do not work well when the agent is a third-party binary (Anthropic&amp;rsquo;s Claude Code, Google&amp;rsquo;s Gemini CLI) or when the MCP servers live in other languages with stdio as their transport.&lt;/p>
&lt;h3 id="agentsight-zero-instrumentation-for-llm-agents">AgentSight: zero-instrumentation for LLM agents&lt;/h3>
&lt;p>&lt;a href="https://github.com/eunomia-bpf/agentsight">AgentSight&lt;/a> (a project from the &lt;code>eunomia-bpf&lt;/code> group, the same ecosystem behind several high-profile eBPF runtimes) attacks this problem with the same philosophy as Hubble: do not instrument; listen. It puts eBPF hooks at two critical points:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>uprobes on SSL/TLS libraries&lt;/strong> (&lt;code>libssl&lt;/code>, &lt;code>boringssl&lt;/code>, &lt;code>rustls&lt;/code>). It captures the plaintext before encryption on send and after decryption on recv. For an HTTP call to &lt;code>https://api.anthropic.com/v1/messages&lt;/code>, AgentSight sees the full JSON of the prompt and the response without decrypting anything in transit, simply because it has reached the syscall level before the TLS layer does its work.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>&lt;code>stdiocap&lt;/code> BPF&lt;/strong>: it captures &lt;code>read&lt;/code>, &lt;code>write&lt;/code> and &lt;code>dup&lt;/code> on a process&amp;rsquo;s stdin/stdout/stderr file descriptors. This is what makes it possible to observe MCP servers that speak stdio with their client, the usual pattern for local MCP servers. You capture the JSON-RPC going back and forth without either the client or the server knowing.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>Reported overhead: &amp;lt;3% CPU, comparable to Hubble in its own regime.&lt;/p>
&lt;h3 id="how-it-fits-with-hubble-and-tetragon">How it fits with Hubble and Tetragon&lt;/h3>
&lt;p>The three complement each other cleanly:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Hubble&lt;/strong> tells you: &amp;ldquo;the agent&amp;rsquo;s pod opened a TCP connection to &lt;code>api.anthropic.com:443&lt;/code> with verdict ALLOW&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Tetragon&lt;/strong> tells you: &amp;ldquo;the &lt;code>claude-code&lt;/code> process with PID 1843 did a &lt;code>connect()&lt;/code> to that IP&amp;rdquo; (plus the binary, the arguments, the pod namespace).&lt;/li>
&lt;li>&lt;strong>AgentSight&lt;/strong> tells you: &amp;ldquo;the HTTPS content of that connection was a prompt &lt;code>messages=[{role:'user', content:'analyze this repo and modify the firewall config'}]&lt;/code> and the response included a tool call to &lt;code>read_file&lt;/code> with argument &lt;code>/etc/passwd&lt;/code>&amp;rdquo;.&lt;/li>
&lt;/ul>
&lt;p>It is the difference between flow, process and semantics. For a security team that wants to watch AI agents in production, all three are necessary. For anyone who wants to understand cost, all three are useful (Hubble for network latency, Tetragon for resource usage, AgentSight for tokens and chosen model).&lt;/p>
&lt;h3 id="emerging-use-cases">Emerging use cases&lt;/h3>
&lt;p>The patterns consolidating in 2026:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Agent audit trail&lt;/strong>: recording every LLM call and every tool call for compliance, especially in regulated sectors.&lt;/li>
&lt;li>&lt;strong>Jailbreak and prompt injection detection&lt;/strong>: applying rules over the prompts captured by AgentSight (similar to Tetragon&amp;rsquo;s TracingPolicy, but over semantic content).&lt;/li>
&lt;li>&lt;strong>Cost accountability&lt;/strong>: seeing which team/agent consumes which tokens, without instrumenting.&lt;/li>
&lt;li>&lt;strong>Replay and debugging&lt;/strong>: reproducing an agent&amp;rsquo;s reasoning in production without asking it to run again (which is non-deterministic).&lt;/li>
&lt;/ul>
&lt;p>It is a young field, AgentSight is months old rather than years, but the &amp;ldquo;eBPF as zero-instrumentation observability&amp;rdquo; pattern is very clearly spreading beyond network and process. The coming year will see consolidation and, probably, native integration with Hubble.&lt;/p>
&lt;h2 id="common-hubble-use-cases">Common Hubble use cases&lt;/h2>
&lt;p>Back to Hubble proper, the cases in which any organisation deploys it:&lt;/p>
&lt;h3 id="1-networkpolicy-debugging">1. NetworkPolicy debugging&lt;/h3>
&lt;p>The classic use: &amp;ldquo;this pod cannot reach this Service&amp;rdquo;. Without Hubble, it meant SSH, tcpdump, comparing rules. With Hubble:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">hubble observe --from-pod prod-api/checkout --to-pod prod-db/postgres --verdict DROPPED
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If there are drops, you see the policy responsible (Cilium 1.19+). If there are no drops, the problem is not policy: it is DNS, routing or the target service.&lt;/p>
&lt;h3 id="2-auditing-inter-namespace-communication">2. Auditing inter-namespace communication&lt;/h3>
&lt;p>For compliance: validating that isolated namespaces are not communicating against what was declared.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">hubble observe --from-namespace prod-payments --to-namespace &lt;span class="s1">&amp;#39;NOT prod-db&amp;#39;&lt;/span> --output json
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="3-exfiltration-detection">3. Exfiltration detection&lt;/h3>
&lt;p>Outbound traffic to suspicious public destinations. Hubble detects them by IP/SNI, not by payload (which is encrypted):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">hubble observe --to-fqdn &lt;span class="s1">&amp;#39;NOT *.example.com&amp;#39;&lt;/span> --to-fqdn &lt;span class="s1">&amp;#39;NOT *.internal&amp;#39;&lt;/span> --protocol tcp
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Combined with Prometheus metrics and Grafana alerts, this gives you an exfiltration radar at zero cost.&lt;/p>
&lt;h3 id="4-real-time-service-slos">4. Real-time service SLOs&lt;/h3>
&lt;p>The &lt;code>hubble:http:response_time_seconds&lt;/code> metrics with &lt;code>source_workload&lt;/code>, &lt;code>destination_workload&lt;/code>, &lt;code>method&lt;/code> and &lt;code>status_code&lt;/code> labels allow SLO dashboards without needing to instrument the apps. The SRE sees the p95 latency of &lt;code>checkout → catalog&lt;/code> directly.&lt;/p>
&lt;h3 id="5-performance-debugging">5. Performance debugging&lt;/h3>
&lt;p>&lt;code>hubble:tcp:retransmissions_total&lt;/code> and &lt;code>hubble:tcp:flags_total{flag=&amp;quot;RST&amp;quot;}&lt;/code> are early signals of network problems. A rise correlated with a latency regression points you at something in the infrastructure (NIC, switch, MTU) before you go down to investigate the app.&lt;/p>
&lt;h3 id="6-post-incident-forensics">6. Post-incident forensics&lt;/h3>
&lt;p>Configuring Hubble to export flows to persistent storage (via OTLP to Tempo/Loki, or &lt;code>hubble observe --output jsonpb&lt;/code> to S3) gives you forensic capability: if at T+30 days you detect that something was wrong at T, you can reconstruct the traffic.&lt;/p>
&lt;h2 id="hubble-and-the-rest-of-the-observability-stack">Hubble and the rest of the observability stack&lt;/h2>
&lt;p>Hubble does not replace Prometheus, Loki, Tempo or Jaeger; it complements them:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Prometheus&lt;/strong>: receives Hubble&amp;rsquo;s aggregated metrics. Hubble exports a native Prometheus endpoint.&lt;/li>
&lt;li>&lt;strong>Loki&lt;/strong>: receives the structured flow logs if you export them as logs. Hubble has no native Loki exporter, but a Fluent Bit with an OTLP plugin or a custom one bridges it easily.&lt;/li>
&lt;li>&lt;strong>Tempo / Jaeger&lt;/strong>: the Cilium Operator has an OTLP exporter of flows in trace format (each HTTP/gRPC flow is a span). It integrates with Tempo or any other OTLP tracing backend.&lt;/li>
&lt;li>&lt;strong>Grafana&lt;/strong>: there are already public Hubble dashboards. Combined with Prometheus, Loki and Tempo, they give you a unified panel: metrics, logs, traces, all correlated by K8s labels.&lt;/li>
&lt;/ul>
&lt;p>The full-stack pile seen in production in 2026 (described in &lt;a href="https://dev.to/x4nent/building-a-production-ebpf-observability-security-stack-for-kubernetes-in-2026-5051">Building a Production eBPF Observability &amp;amp; Security Stack for Kubernetes in 2026&lt;/a>):&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Data&lt;/strong>: Cilium + Hubble (network), Tetragon (process), AgentSight (AI agent).&lt;/li>
&lt;li>&lt;strong>Pipeline&lt;/strong>: OTLP Collector as the single router.&lt;/li>
&lt;li>&lt;strong>Storage&lt;/strong>: Prometheus (metrics), Loki (logs), Tempo (traces).&lt;/li>
&lt;li>&lt;strong>UI&lt;/strong>: Grafana with domain-specific dashboards.&lt;/li>
&lt;li>&lt;strong>Alerting&lt;/strong>: AlertManager with rules over the Hubble + Tetragon metrics.&lt;/li>
&lt;/ul>
&lt;h2 id="comparison-with-the-alternatives">Comparison with the alternatives&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>System&lt;/th>
&lt;th>Layer&lt;/th>
&lt;th>Focus&lt;/th>
&lt;th>Model&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Hubble&lt;/strong>&lt;/td>
&lt;td>L3-L7 network&lt;/td>
&lt;td>K8s cluster with Cilium&lt;/td>
&lt;td>eBPF, pull metrics, push flows gRPC&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>GKE Dataplane v2 obs&lt;/strong>&lt;/td>
&lt;td>L3-L7 network&lt;/td>
&lt;td>GKE managed&lt;/td>
&lt;td>eBPF (Cilium-based, managed)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Tigera Calico Whisker&lt;/strong>&lt;/td>
&lt;td>L3-L7 network&lt;/td>
&lt;td>Cluster with Calico&lt;/td>
&lt;td>eBPF + pcap, own UI&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Tetragon&lt;/strong>&lt;/td>
&lt;td>Process/syscall&lt;/td>
&lt;td>K8s cluster&lt;/td>
&lt;td>eBPF, push events gRPC&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Falco&lt;/strong>&lt;/td>
&lt;td>Process/syscall&lt;/td>
&lt;td>K8s cluster&lt;/td>
&lt;td>eBPF in userspace or kernel module&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>AgentSight&lt;/strong>&lt;/td>
&lt;td>LLM agent&lt;/td>
&lt;td>Agentic systems&lt;/td>
&lt;td>eBPF (SSL uprobes + stdio)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Beyla&lt;/strong> (Grafana)&lt;/td>
&lt;td>Application&lt;/td>
&lt;td>App L7 + tracing&lt;/td>
&lt;td>eBPF (uprobes on libs)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Pixie&lt;/strong>&lt;/td>
&lt;td>App + system&lt;/td>
&lt;td>Broad cluster visibility&lt;/td>
&lt;td>eBPF + PXL script&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Parca&lt;/strong>&lt;/td>
&lt;td>CPU/mem profiling&lt;/td>
&lt;td>Performance&lt;/td>
&lt;td>eBPF profile sampling&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>If your CNI is Cilium, Hubble is the natural entry point and it does not compete with the rest: it complements them. For Calico clusters, Whisker is the equivalent. For profiling, Parca. For AI agents, AgentSight. The era of &amp;ldquo;one tool for everything&amp;rdquo; is passing: the modern stack combines several specialised pieces, all eBPF-based, exposed via OTLP.&lt;/p>
&lt;h2 id="operational-traps">Operational traps&lt;/h2>
&lt;h3 id="cardinality-in-prometheus">Cardinality in Prometheus&lt;/h3>
&lt;p>Hubble metrics with all the K8s labels can blow up Prometheus. &lt;strong>Measure cardinality before exporting everything.&lt;/strong> The most prolific metrics are &lt;code>flow&lt;/code> and &lt;code>httpV2&lt;/code>; start with &lt;code>drop&lt;/code> and &lt;code>port-distribution&lt;/code> and add the rest incrementally.&lt;/p>
&lt;h3 id="l7-visibility-costs-cpu">L7 visibility costs CPU&lt;/h3>
&lt;p>Enabling L7 parsing via embedded Envoy adds load to the agent (not to the base datapath, but yes to the node&amp;rsquo;s envoy proxy). For heavy HTTP traffic, measure. For flows where you only need L4, leave Envoy disabled.&lt;/p>
&lt;h3 id="hubble-relay-without-ha">Hubble Relay without HA&lt;/h3>
&lt;p>A single Relay replica is a single point of failure for the CLI and the UI (not for the local agent, which keeps working). For production, deploy with &lt;code>replicas: 2+&lt;/code> and &lt;code>topologySpreadConstraints&lt;/code> so both do not go down together.&lt;/p>
&lt;h3 id="encryption-status-reporting-depends-on-the-cilium-config">Encryption status reporting depends on the Cilium config&lt;/h3>
&lt;p>The new &lt;code>--encryption-status&lt;/code> filter only gives real data if Cilium has encryption enabled (WireGuard or IPsec). Without that, everything is &lt;code>unencrypted&lt;/code> and the filter contributes nothing.&lt;/p>
&lt;h3 id="ui-exposed-without-auth">UI exposed without auth&lt;/h3>
&lt;p>Hubble UI has no native auth. If you expose it through Ingress, &lt;strong>there has to be authentication in front of it&lt;/strong>: OIDC via oauth2-proxy, mTLS, IP allowlist. It is not optional.&lt;/p>
&lt;h3 id="storage-that-does-not-scale">Storage that does not scale&lt;/h3>
&lt;p>If you keep flows for days for forensics, the volume is serious. For a cluster with 100 active pods, easily 1-10 GB/day of flow logs. Plan the lifecycle (compaction, retention, cold storage) before enabling it.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Mesh / multi-cluster Hubble&lt;/strong>: aggregating flows from several Cilium clusters into a single Relay. Use case: a cross-cluster view, debugging a distributed service mesh.&lt;/li>
&lt;li>&lt;code>hubble export&lt;/code>: local persistence on the agent&amp;rsquo;s disk for forensics with low retention.&lt;/li>
&lt;li>&lt;strong>Anomaly detection with your own models&lt;/strong>: how to connect the gRPC stream to a custom ML consumer.&lt;/li>
&lt;li>&lt;strong>AgentSight in depth&lt;/strong>: the project deserves its own article. Next instalment.&lt;/li>
&lt;li>&lt;strong>eBPF for LLM serving profiling&lt;/strong>: how to measure vLLM&amp;rsquo;s TTFT, TPOT and throughput without instrumenting, using uprobes on libcudart.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>Hubble and Cilium:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/cilium/hubble">Hubble GitHub&lt;/a> — main repo.&lt;/li>
&lt;li>&lt;a href="https://docs.cilium.io/en/stable/observability/hubble/">Hubble — Network Observability (Cilium docs)&lt;/a> — official reference.&lt;/li>
&lt;li>&lt;a href="https://www.infoq.com/news/2026/02/cilium-119/">Cilium 1.19 release notes (InfoQ, feb 2026)&lt;/a> — tenth anniversary and 1.19 news.&lt;/li>
&lt;li>&lt;a href="https://github.com/cilium/cilium/releases">Cilium releases&lt;/a> — all releases.&lt;/li>
&lt;li>&lt;a href="https://grafana.com/grafana/dashboards/19423-hubble-l7-http-metrics-by-workload/">Hubble L7 HTTP Metrics — Grafana dashboard 19423&lt;/a> — ready to import.&lt;/li>
&lt;li>&lt;a href="https://cloud-cod.com/index.php/2026/03/03/end-to-end-l7-visibility-with-cilium-hubble/">End‑to‑end L7 Visibility with Cilium Hubble (cloud-cod.com, mar 2026)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.youngju.dev/blog/cilium/cilium_hubble_observability.en">Cilium Hubble Observability Platform Internal Analysis (Young-ju)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://johal.in/ciliumnetworkpolicy-python-hubble-l7-visibility-2026/">CiliumNetworkPolicy Python Hubble: L7 Visibility 2026&lt;/a> — one of the threads on the Python SDK.&lt;/li>
&lt;/ul>
&lt;p>State of the art 2026 and the full stack:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://dev.to/x4nent/building-a-production-ebpf-observability-security-stack-for-kubernetes-in-2026-5051">Building a Production eBPF Observability &amp;amp; Security Stack for Kubernetes in 2026 (DEV)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.cloudraft.io/blog/ebpf-based-network-observability-using-cilium-hubble">eBPF-Based Network Observability: Exploring Cilium Hubble and Alternatives (CloudRaft)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>eBPF + AI agents:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/eunomia-bpf/agentsight">AgentSight (GitHub eunomia-bpf)&lt;/a> — the referenced project.&lt;/li>
&lt;li>&lt;a href="https://klizosolutions.medium.com/harnessing-ebpf-for-high-performance-llm-workloads-a-cloud-native-guide-efb7d73e19ed">Harnessing eBPF for High‑Performance LLM Workloads (Klizo Solutions)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Cross-references:&lt;/p>
&lt;ul>
&lt;li>Part 1: &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a>.&lt;/li>
&lt;li>Part 2: &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon: Cilium&amp;rsquo;s security cousin&lt;/a>.&lt;/li>
&lt;li>LLM inference series: &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on K8s&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM K8s Operators&lt;/a> — where the traffic Hubble observes carries the prompts AgentSight inspects.&lt;/li>
&lt;/ul></description></item><item><title>Tetragon: Cilium's security cousin that sees every syscall in the kernel</title><link>https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/</link><pubDate>Tue, 19 May 2026 05:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;a href="https://tetragon.io/">Tetragon&lt;/a> is the runtime security and observability engine that the Cilium project published as a companion to the CNI. Its job is not to route packets, Cilium is already there for that, but to &lt;strong>observe what happens inside the node&amp;rsquo;s processes in real time&lt;/strong>: which binary runs in each pod, which files it opens, which syscalls it invokes, which capabilities it asks for, which network connections it establishes, which kernel modules get loaded. It does this by loading eBPF programs into the kernel&amp;rsquo;s hook points (kprobes, tracepoints, uprobes, LSM hooks) and filtering the relevant events &lt;strong>inside the kernel itself&lt;/strong> with a declarative language expressed as a CRD (&lt;code>TracingPolicy&lt;/code> and &lt;code>TracingPolicyNamespaced&lt;/code>). The result is a stream of events enriched with Kubernetes metadata (pod, namespace, labels) that costs less than 1% of CPU and, the thing that sets Tetragon apart from the competition, can &lt;strong>block actions inside the kernel&lt;/strong>, killing the process with &lt;code>SIGKILL&lt;/code> or overwriting a syscall&amp;rsquo;s return value, &lt;strong>before they finish executing&lt;/strong>, with no race conditions. Against Falco (which parses syscalls in userspace, 5-10% overhead, detection-only), Tetragon is &amp;ldquo;cheaper and with enforcement&amp;rdquo;; against the bare kernel, it is &amp;ldquo;a declarative layer your operations colleague can read&amp;rdquo;. This article is the extensive introduction you need to take it on seriously: architecture, all the hooks and selectors, the operating modes, a guide to use cases (exec auditing, sensitive file access, container escape, cryptomining, rootkit detection, network observability) and the traps you see in production.&lt;/p>
&lt;blockquote>
&lt;p>This article is part 2 of the eBPF series. Part 1, &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium: how the kernel learned to skip its own TCP/IP stack&lt;/a>, covered basic eBPF, the networking hooks (XDP, TC, sock_ops), how Cilium implements the datapath and the BGP Control Plane v2 CRDs. Here we take those same eBPF hooks and use them for something different: &lt;strong>observing and, if needed, stopping&lt;/strong> what the cluster&amp;rsquo;s processes do.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-auditd-on-steroids-in-ebpf">The analogy: auditd on steroids in eBPF&lt;/h2>
&lt;p>Anyone who has spent a few years administering Linux has used &lt;code>auditd&lt;/code>. It is the classic kernel subsystem for auditing syscalls: you configure a rule with &lt;code>auditctl&lt;/code> (for instance, &amp;ldquo;monitor any &lt;code>open&lt;/code> on &lt;code>/etc/shadow&lt;/code>&amp;rdquo;) and the kernel sends events to a userspace daemon that persists them. It works, but it has two limitations that weigh heavily on modern Kubernetes clusters:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>No Kubernetes context.&lt;/strong> auditd reports processes by PID and UID. Knowing which pod, which namespace, which image, which labels, the information that actually matters when responding to an incident, requires correlating afterwards with data from cri-o or containerd. It is operationally miserable.&lt;/li>
&lt;li>&lt;strong>No granular enforcement.&lt;/strong> auditd can generate events, but it cannot take the decision to kill the offending process before the syscall finishes. You leave that to a higher layer that reads the events, processes them and kills the process… if it gets there in time. A race by design.&lt;/li>
&lt;/ol>
&lt;p>Tetragon is &lt;strong>auditd on steroids&lt;/strong>: the same conceptual ideas, hooks on syscalls and events to userspace, but implemented with modern eBPF, with filtering inside the kernel so you do not pay the cost of waking the daemon for every irrelevant syscall, with Kubernetes metadata injected by an agent that knows the cluster, and with actions executed inside the kernel itself without waiting for userspace to decide. If the rule says &amp;ldquo;kill any process that opens &lt;code>/etc/shadow&lt;/code> from the &lt;code>prod&lt;/code> namespace&amp;rdquo;, the decision is taken in the kernel kprobe and &lt;code>SIGKILL&lt;/code> is delivered before the &lt;code>open&lt;/code> completes. There is no race; there is no window between detection and action.&lt;/p>
&lt;h2 id="what-tetragon-is-architecturally">What Tetragon is, architecturally&lt;/h2>
&lt;p>Tetragon is an agent deployed as a &lt;code>DaemonSet&lt;/code> (one pod per node) and a set of CRDs that define the policies to apply. The agent has four responsibilities:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Load eBPF programs&lt;/strong> into the hook points the active TracingPolicies demand.&lt;/li>
&lt;li>&lt;strong>Maintain a cache of Kubernetes metadata&lt;/strong> (pods, namespaces, labels) by reading the API server, so it can enrich every event with the right context.&lt;/li>
&lt;li>&lt;strong>Collect the events&lt;/strong> the eBPF programs emit (via ring buffers) and serialise them.&lt;/li>
&lt;li>&lt;strong>Export the events&lt;/strong> to configurable destinations: &lt;code>stdout&lt;/code> JSON (typical in sidecars or log-collection agents), gRPC streaming (to consume them from Hubble or another consumer), a file, or Fluentd/Loki/SIEM.&lt;/li>
&lt;/ol>
&lt;p>The eBPF programs are not written by the user. Tetragon generates the bytecode from the TracingPolicies: it reads the declarative policy, decides which hooks to attack, which arguments to read from the kernel, which filters to apply inline and which actions to execute. The user only writes &lt;strong>YAML&lt;/strong>.&lt;/p>
&lt;div class="diagram" style="max-width:720px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 720 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Tetragon architecture">
&lt;style>.title{font:600 13px sans-serif;fill:#222}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#555}.box{stroke:#444;stroke-width:1.4}.k{fill:#ffe9d6}.u{fill:#d6eaff}.p{fill:#d9f5d6}.api{fill:#e9d6f5}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#h)}&lt;/style>
&lt;defs>&lt;marker id="h" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="360" y="20" text-anchor="middle" class="title">Tetragon: control and data planes on a node&lt;/text>
&lt;rect x="40" y="50" width="200" height="70" rx="6" class="box k"/>
&lt;text x="140" y="70" text-anchor="middle" class="lbl">eBPF programs&lt;/text>
&lt;text x="140" y="90" text-anchor="middle" class="sm">kprobes, tracepoints,&lt;/text>
&lt;text x="140" y="105" text-anchor="middle" class="sm">uprobes, LSM&lt;/text>
&lt;rect x="40" y="160" width="200" height="60" rx="6" class="box u"/>
&lt;text x="140" y="183" text-anchor="middle" class="lbl">Tetragon agent&lt;/text>
&lt;text x="140" y="203" text-anchor="middle" class="sm">reads events from the ring buffer&lt;/text>
&lt;rect x="290" y="160" width="180" height="60" rx="6" class="box api"/>
&lt;text x="380" y="183" text-anchor="middle" class="lbl">Kubernetes API&lt;/text>
&lt;text x="380" y="203" text-anchor="middle" class="sm">pods, namespaces, labels&lt;/text>
&lt;rect x="510" y="50" width="180" height="70" rx="6" class="box p"/>
&lt;text x="600" y="70" text-anchor="middle" class="lbl">TracingPolicy CRDs&lt;/text>
&lt;text x="600" y="90" text-anchor="middle" class="sm">declarative YAML&lt;/text>
&lt;text x="600" y="105" text-anchor="middle" class="sm">cluster or namespaced&lt;/text>
&lt;rect x="510" y="160" width="180" height="60" rx="6" class="box u"/>
&lt;text x="600" y="183" text-anchor="middle" class="lbl">Exporters&lt;/text>
&lt;text x="600" y="203" text-anchor="middle" class="sm">stdout, gRPC, file, SIEM&lt;/text>
&lt;path class="arr" d="M510,80 L240,80"/>
&lt;text x="375" y="74" text-anchor="middle" class="sm">policies → bytecode&lt;/text>
&lt;path class="arr" d="M140,120 L140,160"/>
&lt;text x="160" y="143" text-anchor="middle" class="sm">events&lt;/text>
&lt;path class="arr" d="M290,190 L240,190"/>
&lt;text x="265" y="184" text-anchor="middle" class="sm">enrich&lt;/text>
&lt;path class="arr" d="M240,180 L510,180"/>
&lt;text x="375" y="174" text-anchor="middle" class="sm">enriched events&lt;/text>
&lt;text x="360" y="255" text-anchor="middle" class="sm">The arrows show data flow. TracingPolicies are compiled into eBPF programs;&lt;/text>
&lt;text x="360" y="270" text-anchor="middle" class="sm">events travel kernel → agent → exporter, decorated with K8s metadata along the way.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>This separation of declarative policy → generated eBPF bytecode is what makes Tetragon usable. Writing eBPF programs by hand is a specialist&amp;rsquo;s job; writing a &lt;code>TracingPolicy&lt;/code> is the job of an SRE with a good example in front of them.&lt;/p>
&lt;h2 id="the-two-crds-tracingpolicy-and-tracingpolicynamespaced">The two CRDs: TracingPolicy and TracingPolicyNamespaced&lt;/h2>
&lt;p>Tetragon exposes exactly two main CRDs:&lt;/p>
&lt;ul>
&lt;li>&lt;code>TracingPolicy&lt;/code> (cluster-scoped, &lt;code>cilium.io/v1alpha1&lt;/code>): applies to the whole cluster, every node, every pod. Suitable for platform policies (the whole cluster must be audited the same way): for example, &amp;ldquo;log every &lt;code>execve&lt;/code> in every pod&amp;rdquo; or &amp;ldquo;kill any process that tries to load a kernel module&amp;rdquo;.&lt;/li>
&lt;li>&lt;code>TracingPolicyNamespaced&lt;/code> (namespaced, same group and version): defined inside a namespace and applied only to the pods of that namespace. Suitable for policies with per-tenant autonomy: for example, &amp;ldquo;in the &lt;code>prod-payments&lt;/code> namespace, kill any outbound &lt;code>connect&lt;/code> to an IP outside the corporate range&amp;rdquo;.&lt;/li>
&lt;/ul>
&lt;p>Both CRDs have exactly the same internal structure. The difference is one of scope. The distinction was introduced precisely to allow multi-tenancy: the central security team defines cluster-wide &lt;code>TracingPolicy&lt;/code> objects and each tenant can add its own with &lt;code>TracingPolicyNamespaced&lt;/code> without needing cluster-admin permissions.&lt;/p>
&lt;h2 id="anatomy-of-a-tracingpolicy">Anatomy of a TracingPolicy&lt;/h2>
&lt;p>A policy is made up of:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Hook points&lt;/strong>: which kernel events to observe.&lt;/li>
&lt;li>&lt;strong>Arguments&lt;/strong>: which data to read when the hook fires.&lt;/li>
&lt;li>&lt;strong>Selectors&lt;/strong>: filters evaluated inside the kernel to discard irrelevant events and, optionally, execute actions when they match.&lt;/li>
&lt;/ol>
&lt;h3 id="supported-hook-points">Supported hook points&lt;/h3>
&lt;p>The official documentation lists five families of hook points:&lt;/p>
&lt;ul>
&lt;li>&lt;code>kprobes&lt;/code>: hook a kernel function. Syscalls are a particular case (when &lt;code>syscall: true&lt;/code>) because their ABI differs from that of internal functions. Typical examples: &lt;code>sys_open&lt;/code>, &lt;code>sys_openat&lt;/code>, &lt;code>sys_connect&lt;/code>, &lt;code>tcp_connect&lt;/code>, &lt;code>do_mount&lt;/code>, &lt;code>commit_creds&lt;/code>. It is the most versatile hook and the one used 80% of the time.&lt;/li>
&lt;li>&lt;code>tracepoints&lt;/code>: hook static tracepoints compiled into the kernel. More stable across kernel versions than kprobes (they do not depend on function names that can change). Examples: &lt;code>syscalls/sys_enter_openat&lt;/code>, &lt;code>sched/sched_process_exec&lt;/code>.&lt;/li>
&lt;li>&lt;code>uprobes&lt;/code>: hook functions in userspace libraries or binaries. They serve to observe runtime primitives such as libssl functions, libc, the Go runtime, the JVM.&lt;/li>
&lt;li>&lt;strong>USDT &lt;code>tracepoints&lt;/code>&lt;/strong> (User Statically Defined Tracepoints): static tracepoints defined in userspace binaries (like those MySQL, PostgreSQL and OpenJDK expose). Useful for application observability.&lt;/li>
&lt;li>&lt;code>lsmHooks&lt;/code> (LSM, Linux Security Module): hooks of the LSM subsystem, where SELinux/AppArmor plug in. They allow security policies very similar to traditional MAC but programmable with eBPF. Example: &lt;code>file_open&lt;/code>, &lt;code>inode_unlink&lt;/code>, &lt;code>socket_bind&lt;/code>.&lt;/li>
&lt;/ul>
&lt;h3 id="arguments">Arguments&lt;/h3>
&lt;p>Every hook can read the arguments of the function it is attached to. The supported types cover the primitives (&lt;code>int&lt;/code>, &lt;code>uint64&lt;/code>, &lt;code>bool&lt;/code>, &lt;code>string&lt;/code>, &lt;code>char_buf&lt;/code>) and higher abstractions (&lt;code>file&lt;/code>, &lt;code>path&lt;/code>, &lt;code>sock&lt;/code>, &lt;code>linux_binprm&lt;/code>, &lt;code>capability&lt;/code>, &lt;code>bpf_attr&lt;/code>, &lt;code>cred&lt;/code>). The high-level types are pointers to kernel structures that Tetragon knows how to parse; instead of having to read an offset, you write &lt;code>type: file&lt;/code> and Tetragon gives you the full path of the descriptor&amp;rsquo;s file.&lt;/p>
&lt;p>There is an important capability detail: on kernels ≥ 5.4, Tetragon can read &lt;strong>up to 327,360 bytes&lt;/strong> of an argument if the large buffers flag is enabled. That is the difference between being able to audit &lt;code>execve&lt;/code> with all of its long argv complete versus truncating them at 256 bytes and losing context.&lt;/p>
&lt;h3 id="selectors-filtering-in-the-kernel">Selectors: filtering in the kernel&lt;/h3>
&lt;p>Selectors are what make Tetragon cheap. Without them, every syscall on the node would fire an event that would travel kernel → ring buffer → agent → processed → filtered → discarded. With selectors, the filtering happens &lt;strong>inside the eBPF program itself, in the kernel&lt;/strong>, and only the events that matter reach userspace.&lt;/p>
&lt;p>The available selectors include:&lt;/p>
&lt;ul>
&lt;li>&lt;code>matchArgs&lt;/code>: filters by an argument&amp;rsquo;s value. Operators: &lt;code>Equal&lt;/code>, &lt;code>NotEqual&lt;/code>, &lt;code>Prefix&lt;/code>, &lt;code>Postfix&lt;/code>, &lt;code>GreaterThan&lt;/code>, &lt;code>LessThan&lt;/code>, &lt;code>Mask&lt;/code>, &lt;code>SPort&lt;/code> (source port), &lt;code>DPort&lt;/code> (dest port), &lt;code>Family&lt;/code> (AF_INET vs AF_INET6), &lt;code>State&lt;/code> (socket state).&lt;/li>
&lt;li>&lt;code>matchPIDs&lt;/code>: filters by PID; useful for targeted observation.&lt;/li>
&lt;li>&lt;code>matchBinaries&lt;/code>: filters by the binary executing the syscall (absolute path), with &lt;code>Operator: In&lt;/code>, &lt;code>NotIn&lt;/code>, &lt;code>Prefix&lt;/code>. Essential for avoiding noise from legitimate system processes.&lt;/li>
&lt;li>&lt;code>matchNamespaces&lt;/code>: filters by Linux namespace (Pid, Mnt, Net, Ipc, Cgroup, User). It allows policies specific to processes in containers versus the host.&lt;/li>
&lt;li>&lt;code>matchCapabilities&lt;/code>: filters by the process&amp;rsquo;s effective capabilities. Block actions requiring &lt;code>CAP_SYS_ADMIN&lt;/code> that run in pods that should not have them.&lt;/li>
&lt;li>&lt;code>matchNamespaceChanges&lt;/code>: detects namespace changes (typical of container escape).&lt;/li>
&lt;li>&lt;code>matchCapabilityChanges&lt;/code>: detects capability changes (privilege escalation).&lt;/li>
&lt;li>&lt;code>matchActions&lt;/code>: the actions executed when all the preceding matchers hit.&lt;/li>
&lt;/ul>
&lt;h3 id="actions-from-a-simple-post-to-sigkill">Actions: from a simple Post to Sigkill&lt;/h3>
&lt;p>When a selector matches, an &lt;code>action&lt;/code> is executed. Tetragon defines several:&lt;/p>
&lt;ul>
&lt;li>&lt;code>Post&lt;/code>: emits an event to userspace (the observability case). It supports &lt;code>rateLimit&lt;/code> to avoid flooding the agent if the condition fires a thousand times per second. The syntax accepts &lt;code>5&lt;/code> for 5 seconds, &lt;code>5m&lt;/code> for 5 minutes, &lt;code>1h&lt;/code> for 1 hour.&lt;/li>
&lt;li>&lt;code>Sigkill&lt;/code>: sends &lt;code>SIGKILL&lt;/code> to the offending process from inside the kernel, &lt;strong>before the syscall completes&lt;/strong>. This is the only thing that guarantees enforcement without a race.&lt;/li>
&lt;li>&lt;code>Override&lt;/code>: overwrites the syscall&amp;rsquo;s return value. Useful for making the process believe the syscall failed (&lt;code>Override -EPERM&lt;/code>) without killing it. A better experience for apps that can handle errors; worse for apps that assume success.&lt;/li>
&lt;li>&lt;code>Signal&lt;/code>: sends any arbitrary signal (not just &lt;code>SIGKILL&lt;/code>).&lt;/li>
&lt;li>&lt;code>NoPost&lt;/code>: does not emit an event, useful when combined with another selector that does emit and you only want the action without duplicated telemetry.&lt;/li>
&lt;li>&lt;strong>&lt;code>FollowFD&lt;/code> and &lt;code>UnfollowFD&lt;/code>&lt;/strong>: mark a file descriptor to follow its lifecycle and enrich subsequent events with the original path. Useful for auditing &amp;ldquo;which process read this file after opening it&amp;rdquo;.&lt;/li>
&lt;li>&lt;code>TrackSock&lt;/code> and &lt;code>UntrackSock&lt;/code>: the same for sockets.&lt;/li>
&lt;li>&lt;strong>&lt;code>GetUrl&lt;/code> and &lt;code>DnsLookup&lt;/code>&lt;/strong>: make HTTP requests or DNS resolutions from the kernel. Designed for integrations with external systems (security webhooks, IP reputation lookups).&lt;/li>
&lt;li>&lt;code>NotifyEnforcer&lt;/code> and &lt;code>CleanupEnforcerNotification&lt;/code>: communication with Tetragon&amp;rsquo;s enforcement subsystem for complex actions.&lt;/li>
&lt;/ul>
&lt;h2 id="modes-detection-vs-enforcement">Modes: detection vs enforcement&lt;/h2>
&lt;p>A policy can be declared in one of two explicit modes:&lt;/p>
&lt;ul>
&lt;li>&lt;code>enforce&lt;/code>: enforcement actions (&lt;code>Sigkill&lt;/code>, &lt;code>Override&lt;/code>, &lt;code>Signal&lt;/code>) are active. This is production.&lt;/li>
&lt;li>&lt;code>monitoring&lt;/code>: enforcement actions are ignored; only &lt;code>Post&lt;/code> events are emitted. This is the &amp;ldquo;let us see what would happen if this were switched on&amp;rdquo; mode, critical for testing policies without breaking applications.&lt;/li>
&lt;/ul>
&lt;p>Control is done with the &lt;code>spec.options[].name: policy-mode&lt;/code> field and &lt;code>value: monitoring&lt;/code> or &lt;code>enforce&lt;/code>. It is the best practice: start in &lt;code>monitoring&lt;/code>, collect events for days, tune the selectors until no false positives come out, and only then switch to &lt;code>enforce&lt;/code>.&lt;/p>
&lt;h2 id="full-example-blocking-writes-to-etcpasswd-in-the-prod-namespace">Full example: blocking writes to &lt;code>/etc/passwd&lt;/code> in the prod namespace&lt;/h2>
&lt;p>A realistic policy, commented line by line:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicyNamespaced&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">block-passwd-write&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prod&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kprobes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;fd_install&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># kernel function, not a syscall&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">int &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># file descriptor&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;file&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># struct file*&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Equal&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/etc/passwd&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Sigkill &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># kills the process&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rateLimit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1m&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># max once per minute&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">options&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">policy-mode&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">enforce &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># enforcement mode active&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>fd_install&lt;/code> runs every time a process obtains a new file descriptor; the second argument is the file&amp;rsquo;s &lt;code>file&lt;/code> struct. Tetragon knows how to resolve it to its absolute path. The &lt;code>matchArgs&lt;/code> compares that path with &lt;code>/etc/passwd&lt;/code>. If it matches, &lt;code>Sigkill&lt;/code> kills the process before the descriptor even becomes usable. &lt;code>rateLimit: 1m&lt;/code> stops the agent from saturating if a malicious application tries it in a loop.&lt;/p>
&lt;h2 id="common-use-cases">Common use cases&lt;/h2>
&lt;p>Now to real use. These are the six cases that show up in any serious Tetragon deployment in 2026.&lt;/p>
&lt;h3 id="1-execution-auditing-execve">1. Execution auditing (&lt;code>execve&lt;/code>)&lt;/h3>
&lt;p>The most basic use case and, even so, the most valuable. Which binaries are running in each pod? In a container that is supposed to run only &lt;code>nginx&lt;/code>, suddenly seeing an &lt;code>sh&lt;/code> or a &lt;code>wget&lt;/code> is almost always a red flag.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">audit-execve&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tracepoints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">subsystem&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sched&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">event&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sched_process_exec&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">linux_binprm &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># struct linux_binprm*&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># events only, no enforcement&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With no filters: every &lt;code>execve&lt;/code> in the cluster generates an event. With K8s metadata, the event includes pod, namespace, container, image, labels. You turn it into a stream of events towards your SIEM and set up rules: &amp;ldquo;alert if I see &lt;code>sh&lt;/code>, &lt;code>bash&lt;/code>, &lt;code>nc&lt;/code>, &lt;code>curl&lt;/code>, &lt;code>wget&lt;/code> or &lt;code>python&lt;/code> running in any pod of the &lt;code>prod-api&lt;/code> namespace&amp;rdquo;.&lt;/p>
&lt;p>An enforcement variant: instead of &lt;code>Post&lt;/code>, use &lt;code>matchBinaries&lt;/code> with &lt;code>Operator: NotIn&lt;/code> and a whitelist, plus &lt;code>Sigkill&lt;/code> if the binary is not on the list. A very rigid box, but effective in pods that are &amp;ldquo;single-binary&amp;rdquo; (like a Go microservice).&lt;/p>
&lt;h3 id="2-sensitive-file-access">2. Sensitive file access&lt;/h3>
&lt;p>Detecting (or blocking) reads and writes on critical files: &lt;code>/etc/shadow&lt;/code>, &lt;code>/etc/kubernetes/&lt;/code>, Secret mounts, &lt;code>/var/run/docker.sock&lt;/code>, &lt;code>/proc/*/cmdline&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sensitive-file-access&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kprobes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;security_file_open&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># LSM-ish via kprobe&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;file&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Prefix&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/etc/shadow&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/var/run/secrets/&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/var/run/docker.sock&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchBinaries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;NotIn&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/usr/bin/kubelet&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># legitimate kubelet access&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>matchBinaries: NotIn&lt;/code> is important: kubelet and other legitimate node agents access these paths constantly and would generate noise. We filter those out in the kernel.&lt;/p>
&lt;p>In enforcement: swap &lt;code>Post&lt;/code> for &lt;code>Override&lt;/code> with &lt;code>argError: -1&lt;/code> (&lt;code>EPERM&lt;/code>), so that the open fails but the offending process stays alive and produces the error so that tracing tools pick it up.&lt;/p>
&lt;h3 id="3-unauthorised-outbound-network-connections">3. Unauthorised outbound network connections&lt;/h3>
&lt;p>Detecting outbound connections to destinations outside the corporate range. Useful for spotting data exfiltration or malware command-and-control.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicyNamespaced&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">block-external-egress&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prod&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kprobes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;tcp_connect&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sock&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;NotDAddr&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># destination NOT in these CIDRs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;10.0.0.0/8&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;192.168.0.0/16&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;172.16.0.0/12&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Sigkill&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">options&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">policy-mode&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">enforce&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This kills any attempt at a TCP connection to an IP that is not in the corporate CIDRs, in the &lt;code>prod&lt;/code> namespace. Cilium already does this with NetworkPolicy, but Tetragon has two complementary advantages:&lt;/p>
&lt;ul>
&lt;li>It gives you the process that attempted the connection, not just &amp;ldquo;pod X tried to connect to Y&amp;rdquo;.&lt;/li>
&lt;li>It also works for exotic protocols where NetworkPolicy is less expressive.&lt;/li>
&lt;/ul>
&lt;h3 id="4-container-escape-detection">4. Container escape detection&lt;/h3>
&lt;p>Container escape is the operational nightmare: a process inside a container manages to break the isolation (via a kernel exploit, a badly set capability, a misconfigured &lt;code>hostPath&lt;/code> mount) and gain access to the host. Three typical signals:&lt;/p>
&lt;ul>
&lt;li>A namespace change on the process (it leaves the container&amp;rsquo;s &lt;code>pid&lt;/code> namespace).&lt;/li>
&lt;li>&lt;strong>&lt;code>setns&lt;/code> or &lt;code>unshare&lt;/code>&lt;/strong> in non-init processes.&lt;/li>
&lt;li>&lt;strong>Access to &lt;code>/proc/1/root&lt;/code> or &lt;code>/dev/&lt;/code>&lt;/strong> from a container.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">detect-container-escape&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kprobes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;__x64_sys_setns&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">int&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">int&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchNamespaces&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Pid&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NotIn&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;host_ns&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># only processes NOT in the host pid namespace&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Sigkill&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;__x64_sys_unshare&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">int&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchNamespaceChanges&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">unshare&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>__x64_sys_setns&lt;/code> targeting the host namespace from a process in a container is practically always malicious (legitimate containers do not need this at runtime).&lt;/p>
&lt;h3 id="5-cryptomining">5. Cryptomining&lt;/h3>
&lt;p>Mining processes have fairly recognisable profiles:&lt;/p>
&lt;ul>
&lt;li>Processes with names like &lt;code>xmrig&lt;/code>, &lt;code>minerd&lt;/code>, &lt;code>cgminer&lt;/code>, or legitimate processes such as &lt;code>python&lt;/code> running CPU-intensive scripts.&lt;/li>
&lt;li>Outbound connections to known mining pools (a public list of IPs and domains).&lt;/li>
&lt;li>Anomalous use of &lt;code>/dev/cpu_dma_latency&lt;/code> to avoid throttling.&lt;/li>
&lt;/ul>
&lt;p>A combined policy:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">detect-cryptomining&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tracepoints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">subsystem&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sched&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">event&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sched_process_exec&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">linux_binprm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Postfix&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/xmrig&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/minerd&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/cgminer&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Sigkill&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kprobes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;tcp_connect&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sock&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;DPort&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;3333&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;5555&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;7777&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;14444&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># common pool ports&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># log only&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The double policy: kill binaries with the classic names (belt) and log connections to pool ports (braces), so you also get an alert when someone renames &lt;code>xmrig&lt;/code> to &lt;code>nginx-helper&lt;/code> or uses exotic ports.&lt;/p>
&lt;h3 id="6-detecting-rootkits-and-suspicious-kernel-modules">6. Detecting rootkits and suspicious kernel modules&lt;/h3>
&lt;p>Modern rootkits load kernel modules to patch functions (hide processes, hide network connections, hide files). Detecting them:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kernel-module-load&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kprobes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;do_init_module&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;security_kernel_read_file&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;file&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">int&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchArgs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Equal&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;READING_MODULE&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In a &amp;ldquo;well configured&amp;rdquo; Kubernetes cluster no new kernel modules get loaded at runtime; any event here is highly suspicious. Combine with enforcement on machines where modules should be fixed: &lt;code>Sigkill&lt;/code> for whoever tries to load one.&lt;/p>
&lt;h3 id="bonus-detecting-third-party-modification-of-ebpf-maps">Bonus: detecting third-party modification of eBPF maps&lt;/h3>
&lt;p>As a 2025-2026 trend: loading malicious eBPF programs to hide presence. Tetragon can observe the bpf syscall:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">audit-bpf-syscalls&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kprobes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;__x64_sys_bpf&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">int &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># bpf cmd&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bpf_attr&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selectors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchBinaries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;NotIn&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/usr/bin/cilium-agent&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/usr/bin/tetragon&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;/usr/bin/bpftool&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchActions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Any process that is &lt;strong>not&lt;/strong> one of the legitimate agents loading eBPF programs: you want to know about it.&lt;/p>
&lt;h2 id="comparison-with-falco">Comparison with Falco&lt;/h2>
&lt;p>&lt;a href="https://falco.org/">Falco&lt;/a> is the closest competitor: it is also runtime security for Kubernetes, also originally based on eBPF (and before that on kernel modules), also with declarative policies. Three years ago they were functionally similar. In 2026 the divergence is clear:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dimension&lt;/th>
&lt;th>Tetragon&lt;/th>
&lt;th>Falco&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Philosophy&lt;/td>
&lt;td>Cilium-native, integrated&lt;/td>
&lt;td>Standalone, generic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Filtering&lt;/td>
&lt;td>In the kernel (eBPF)&lt;/td>
&lt;td>Parsing in userspace&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Typical overhead&lt;/td>
&lt;td>&lt;strong>&amp;lt;1% CPU&lt;/strong>&lt;/td>
&lt;td>5-10% CPU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Enforcement&lt;/td>
&lt;td>&lt;strong>Yes, in-kernel (Sigkill, Override)&lt;/strong>&lt;/td>
&lt;td>Not native (depends on plugins)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Race conditions&lt;/td>
&lt;td>No (action atomic with the syscall)&lt;/td>
&lt;td>Yes, in enforcement via plugins&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>False positive rate&lt;/td>
&lt;td>Low (K8s context in the kernel)&lt;/td>
&lt;td>Higher (parsing afterwards)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Detection latency&lt;/td>
&lt;td>5-26 ms&lt;/td>
&lt;td>~10 ms (more constant)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ecosystem maturity&lt;/td>
&lt;td>Young, growing&lt;/td>
&lt;td>Mature, plenty of material&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Community&lt;/td>
&lt;td>Cilium / CNCF Incubating&lt;/td>
&lt;td>CNCF Graduated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Integrations&lt;/td>
&lt;td>Hubble native&lt;/td>
&lt;td>Falcosidekick, many&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CRDs per policy&lt;/td>
&lt;td>TracingPolicy / Namespaced&lt;/td>
&lt;td>No CRDs; rules in YAML&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>When to choose each one&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tetragon&lt;/strong> if you already use Cilium, if you need enforcement in the kernel (not detection-only), if overhead matters to you (workloads with many syscalls), and if you value the Hubble integration. Container escape and cryptomining detection is where its advantage over Falco shows up most.&lt;/li>
&lt;li>&lt;strong>Falco&lt;/strong> if you want a tool independent of the CNI, if you need the catalogue of ready-made rules and the wide community, if your cluster is not Cilium, or if the integration with SIEMs and ready-made notifiers (Falcosidekick) saves you work.&lt;/li>
&lt;li>&lt;strong>Both&lt;/strong> if the organisation is large: Falco for breadth of detection, Tetragon for surgical enforcement on critical workloads. That is what you see most in companies that have spent years with Falco and add Tetragon for specific cases.&lt;/li>
&lt;/ul>
&lt;h2 id="hubble--tetragon-unified-observability">Hubble + Tetragon: unified observability&lt;/h2>
&lt;p>&lt;a href="https://docs.cilium.io/en/stable/observability/hubble/">Hubble&lt;/a> is Cilium&amp;rsquo;s traffic observability component: it shows L3-L7 flow logs with zero impact on latency. Tetragon exposes its events over gRPC with the same format and vocabulary as Hubble, which allows you to:&lt;/p>
&lt;ul>
&lt;li>See them in the same UI (Hubble UI shows Tetragon events as one more &amp;ldquo;layer&amp;rdquo;).&lt;/li>
&lt;li>Correlate network events (Hubble) with process events (Tetragon) on the same timeline.&lt;/li>
&lt;li>Export them together to Loki/Tempo/SIEM as a single stream.&lt;/li>
&lt;/ul>
&lt;p>The key synergy: Hubble tells you &amp;ldquo;this pod made a TCP connection to 1.2.3.4:80&amp;rdquo;. Tetragon tells you &amp;ldquo;this pod ran &lt;code>curl 1.2.3.4&lt;/code> from a &lt;code>bash&lt;/code> binary launched by &lt;code>pid 1234&lt;/code>&amp;rdquo;. Together they give you the full story.&lt;/p>
&lt;h2 id="deployment-and-operation">Deployment and operation&lt;/h2>
&lt;h3 id="helm">Helm&lt;/h3>
&lt;p>The canonical install with Helm:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">helm repo add cilium https://helm.cilium.io
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">helm install tetragon cilium/tetragon &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --namespace kube-system &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set tetragon.exportFilename&lt;span class="o">=&lt;/span>/var/log/tetragon/tetragon.log &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set tetragon.exportFileMaxSizeMB&lt;span class="o">=&lt;/span>&lt;span class="m">50&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set tetragon.exportFileRotationInterval&lt;span class="o">=&lt;/span>24h
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Tetragon deploys its &lt;code>DaemonSet&lt;/code>, its CRDs and a service for Hubble. By default, it exposes the events on the agent pod&amp;rsquo;s &lt;code>stdout&lt;/code> (any cluster log aggregator picks them up).&lt;/p>
&lt;h3 id="the-tetra-cli">The &lt;code>tetra&lt;/code> CLI&lt;/h3>
&lt;p>Tetragon ships a CLI called &lt;code>tetra&lt;/code> for interactive investigation:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># real-time stream of the node&amp;#39;s events&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">tetra getevents -o compact --pods &amp;lt;pod-name&amp;gt;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># structured JSON events to process with jq&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">tetra getevents -o json --since 5m --namespace prod
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># view loaded policies&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">tetra tracingpolicy list
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It is the best tool for debugging policies in &lt;code>monitoring&lt;/code> before moving them to &lt;code>enforce&lt;/code>.&lt;/p>
&lt;h3 id="exporting-to-a-siem">Exporting to a SIEM&lt;/h3>
&lt;p>Three usual routes:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>stdout + log aggregator&lt;/strong>: the agent writes JSON to stdout, Fluent Bit/Vector picks it up and sends it to Splunk/Datadog/Elastic. Simple, works with any logging infrastructure.&lt;/li>
&lt;li>&lt;strong>gRPC streaming&lt;/strong>: for low-latency integrations. A gRPC consumer of your own or Hubble Relay.&lt;/li>
&lt;li>&lt;strong>File + rotation&lt;/strong>: for air-gapped environments or regulatory audits that require persistent logs with controlled rotation.&lt;/li>
&lt;/ul>
&lt;h3 id="performance">Performance&lt;/h3>
&lt;p>Published benchmarks consistently place Tetragon at &lt;strong>&amp;lt;1% of the node&amp;rsquo;s CPU&lt;/strong> under real workloads, compared with &lt;strong>5-10% for Falco&lt;/strong> under the same workloads. The reason is the architectural separation: Tetragon filters in the kernel and only carries the events that actually matter to userspace; Falco carries every syscall to userspace and filters there. On clusters with thousands of pods making hundreds of thousands of syscalls per second, the difference shows up on the bill.&lt;/p>
&lt;h2 id="common-operational-traps">Common operational traps&lt;/h2>
&lt;h3 id="permanent-monitoring">Permanent &lt;code>monitoring&lt;/code>&lt;/h3>
&lt;p>The biggest trap is &lt;strong>never reaching &lt;code>enforce&lt;/code>&lt;/strong>: starting well with policies in monitoring, collecting events, tuning selectors, and then never switching. The result: you have detection without prevention, exactly what Falco gave you without paying Tetragon&amp;rsquo;s complexity. If you are going to use Tetragon, plan the road to enforce for the critical policies.&lt;/p>
&lt;h3 id="selectors-that-are-too-lax">Selectors that are too lax&lt;/h3>
&lt;p>A policy with a single &lt;code>matchActions: Post&lt;/code> and no specific selectors generates events for &lt;strong>every&lt;/strong> syscall of the chosen hook. On a serious node that means &lt;strong>tens of thousands per second&lt;/strong>, which fill logs, saturate exporters and hide the signal in the noise. Always start with strict filters (&lt;code>matchBinaries&lt;/code>, &lt;code>matchNamespaces&lt;/code>, &lt;code>matchPIDs&lt;/code>) and open up once you know what you are looking for.&lt;/p>
&lt;h3 id="a-kernel-that-is-too-old">A kernel that is too old&lt;/h3>
&lt;p>Tetragon needs modern eBPF features. Kernels &amp;lt; 5.4 do not have the large buffer support (needed for &lt;code>execve&lt;/code> with complete argv). Kernels &amp;lt; 5.10 do not have many of the LSM hooks. &lt;strong>Kernel 5.15+ is the recommended minimum for production&lt;/strong> and 6.1+ to have every feature.&lt;/p>
&lt;h3 id="hooks-on-renamed-kernel-functions">Hooks on renamed kernel functions&lt;/h3>
&lt;p>kprobes are tied to kernel function names that &lt;strong>can change between versions&lt;/strong>. A policy that uses &lt;code>__x64_sys_setns&lt;/code> can fail silently on a kernel where the function is called &lt;code>__do_sys_setns&lt;/code>. Solutions: use static tracepoints where they are available (more stable), or keep alternative policies with several &lt;code>call&lt;/code> entries for compatibility.&lt;/p>
&lt;h3 id="sigkill-in-critical-namespaces">&lt;code>Sigkill&lt;/code> in critical namespaces&lt;/h3>
&lt;p>Applying &lt;code>Sigkill&lt;/code> to processes in &lt;code>kube-system&lt;/code> or &lt;code>cilium-system&lt;/code> can break the cluster. Enforcement policies must &lt;strong>explicitly exclude&lt;/strong> the platform namespaces with &lt;code>matchNamespaces&lt;/code> Operator: &lt;code>NotIn&lt;/code>, or limit the scope with &lt;code>TracingPolicyNamespaced&lt;/code> to make sure they do not act on systems they should not.&lt;/p>
&lt;h3 id="missing-ratelimit">Missing &lt;code>rateLimit&lt;/code>&lt;/h3>
&lt;p>A policy without a rateLimit on &lt;code>Post&lt;/code> can suffer a catastrophic fan-out if the condition is met millions of times in an instant (typical in attack loops or application bugs). The agent saturates, events are lost, logs overflow. &lt;strong>Always put a sensible &lt;code>rateLimit&lt;/code> on detection policies&lt;/strong>, especially on high-frequency hooks such as &lt;code>tcp_connect&lt;/code> or &lt;code>execve&lt;/code>.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>eBPF LSM hooks&lt;/strong> in depth: how they relate to SELinux/AppArmor and when Tetragon is the right tool versus classic MAC.&lt;/li>
&lt;li>&lt;strong>Hubble UI with a Tetragon overlay&lt;/strong>: configuring the UI to show process observability and network observability on the same timeline.&lt;/li>
&lt;li>&lt;strong>Integration with OPA/Kyverno&lt;/strong>: how Tetragon complements admission policy engines (Kyverno validates at admission; Tetragon validates at runtime).&lt;/li>
&lt;li>&lt;strong>Forensics with eBPF&lt;/strong>: combining Tetragon with tools such as Beyla or OpenTelemetry to trace the full chain of an incident from the initial connection to the final syscall.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>Official documentation (May 2026):&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tetragon.io/">Tetragon — official site&lt;/a> — entry point.&lt;/li>
&lt;li>&lt;a href="https://tetragon.io/docs/concepts/tracing-policy/">Tetragon docs — Tracing Policy&lt;/a> — conceptual reference.&lt;/li>
&lt;li>&lt;a href="https://tetragon.io/docs/concepts/tracing-policy/hooks/">Tetragon docs — Hook points&lt;/a> — kprobes, tracepoints, uprobes, LSM, USDT.&lt;/li>
&lt;li>&lt;a href="https://tetragon.io/docs/concepts/tracing-policy/selectors/">Tetragon docs — Selectors&lt;/a> — full filter reference.&lt;/li>
&lt;li>&lt;a href="https://tetragon.io/docs/concepts/tracing-policy/mode/">Tetragon docs — Enforcement Mode&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://tetragon.io/docs/concepts/tracing-policy/k8s-filtering/">Tetragon docs — Kubernetes Identity Aware Policies&lt;/a> — &lt;code>TracingPolicyNamespaced&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://github.com/cilium/tetragon">Tetragon GitHub&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Comparisons and analysis:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.scitepress.org/Papers/2025/142727/142727.pdf">Comparative Analysis of eBPF-Based Runtime Security Monitoring (SciTePress paper 2025)&lt;/a> — a benchmark with independent numbers.&lt;/li>
&lt;li>&lt;a href="https://www.armosec.io/blog/best-ebpf-security-solutions-runtime-protection/">Best eBPF Security Solutions for Kubernetes (ARMO, 2026)&lt;/a> — Falco vs Tetragon vs KubeArmor comparison.&lt;/li>
&lt;li>&lt;a href="https://medium.com/@mughal.asim/falco-vs-tetragon-a-runtime-security-showdown-for-kubernetes-a0e9fb9f30a0">Falco vs. Tetragon (Asim Mirza, Medium)&lt;/a> — analysis with use cases.&lt;/li>
&lt;li>&lt;a href="https://asecurityengineer.com/posts/deep-dive-into-tetragon/">Deep Dive into Tetragon (A Security Engineer)&lt;/a> — a walk through the inside of the agent.&lt;/li>
&lt;li>&lt;a href="https://medium.com/@mughal.asim/tetragon-series-part-2-enforcing-sensitive-file-access-with-a-namespaced-tracingpolicy-3c2f617ec912">Tetragon Series, Part 2: Enforcing Sensitive File Access (Medium)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Ecosystem:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://docs.cilium.io/en/stable/observability/hubble/">Cilium Hubble — network observability&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://falco.org/">Falco — official site&lt;/a> — the other big name in the field.&lt;/li>
&lt;li>&lt;a href="https://kubearmor.io/">KubeArmor&lt;/a> — the third option, with AppArmor + eBPF.&lt;/li>
&lt;/ul>
&lt;p>Cross-references:&lt;/p>
&lt;ul>
&lt;li>Part 1 of the series: &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium: how the kernel learned to skip its own TCP/IP stack&lt;/a> — the eBPF fundamentals we take as read here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kubernetes-cilium-bgp-services-without-ingress/">Kubernetes with Cilium BGP: services reachable without Ingress&lt;/a> — the starting point of the Cilium ecosystem on this blog.&lt;/li>
&lt;/ul></description></item><item><title>eBPF from zero to Cilium: how the kernel learned to skip its own TCP/IP stack</title><link>https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/</link><pubDate>Tue, 19 May 2026 04:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>eBPF is &lt;strong>a sandboxed virtual machine inside the Linux kernel&lt;/strong> that runs verified code at well-defined hooks: kprobes, tracepoints, socket events, network drivers. Before eBPF, changing kernel behaviour meant recompiling it or loading an arbitrary module; with eBPF, you load a small program that passes a formal verifier and runs at native speed with memory safety. In networking, this translates into the fact that &lt;strong>the packet does not have to travel through the traditional TCP/IP stack&lt;/strong>: an eBPF program in the NIC driver (XDP) can drop, forward or rewrite the packet before the kernel has done its first alloc; a program on cgroup hooks (sock_ops) can redirect connections to another socket without the packet ever leaving the machine. Cilium is the CNI that has taken this to its logical conclusion: it &lt;strong>replaces kube-proxy with pure eBPF&lt;/strong> (O(1) instead of the O(N) of iptables), routes pod-to-pod without VXLAN where it can, evaluates Network Policies with BPF maps, and since 1.16 it has remade its BGP control plane with a new set of CRDs, &lt;code>CiliumBGPClusterConfig&lt;/code>, &lt;code>CiliumBGPPeerConfig&lt;/code>, &lt;code>CiliumBGPAdvertisement&lt;/code>, &lt;code>CiliumBGPNodeConfigOverride&lt;/code>, which replace the monolithic &lt;code>CiliumBGPPeeringPolicy&lt;/code> that is already deprecated. This post goes down the three layers (basic eBPF → eBPF networking → Cilium) and ends with the operational CRDs.&lt;/p>
&lt;h2 id="the-analogy-signed-plugins-for-the-kernel">The analogy: signed plugins for the kernel&lt;/h2>
&lt;p>Think of the browser. Twenty years ago, extending a browser meant compiling a native binary and loading it: any extension could crash it, corrupt memory, read your bank cookies. Today, extensions are &lt;strong>JavaScript in a sandbox&lt;/strong> with a manifest that declares permissions, a runtime that enforces the isolation and a store that signs the code. The extension does not touch the browser binary; it lives in a controlled world and can only talk to the browser through defined APIs. Result: massive extensibility with a bounded attack surface.&lt;/p>
&lt;p>eBPF is exactly that for the Linux kernel. Loading a classic &lt;code>.ko&lt;/code> module means loading native code with full access to kernel memory: one bug and the system is gone. eBPF is &lt;strong>a bytecode VM&lt;/strong> with a static verifier, a controlled allocator, JIT to native hardware after passing the verifier, and a set of kernel &amp;ldquo;helpers&amp;rdquo; it can call. The eBPF program can read the kernel memory the verifier allows it to read, and only that. It cannot enter infinite loops (the verifier demands that it terminate). It cannot jump to arbitrary addresses. It cannot dereference pointers without having validated them first. And, most importantly: &lt;strong>it is user code, loaded at runtime, executing inside the kernel at native speed&lt;/strong>.&lt;/p>
&lt;p>The consequences are visible from miles away. Before, observing traffic in production meant patching the kernel or loading a risky module. Today, &lt;code>bpftrace -e 'tracepoint:net:net_dev_xmit { @[args-&amp;gt;dev-&amp;gt;name] = count(); }'&lt;/code> gives you a histogram of packets per interface in three lines and zero downtime. Before, replacing iptables with something faster meant rewriting the netfilter subsystem. Today, Cilium loads 60 KB of eBPF bytecode into XDP and unseats iptables with a hash map.&lt;/p>
&lt;h2 id="basic-ebpf-what-it-is-and-what-it-is-not">Basic eBPF: what it is and what it is not&lt;/h2>
&lt;h3 id="the-origin-and-the-scope">The origin and the scope&lt;/h3>
&lt;p>The name comes from &lt;strong>Berkeley Packet Filter&lt;/strong>, a 1992 idea (McCanne and Jacobson) for filtering packets with a mini-bytecode that &lt;code>tcpdump&lt;/code> used internally. In 2014, Alexei Starovoitov renamed it &lt;strong>eBPF&lt;/strong> and extended it enormously: 11 64-bit registers instead of 2 32-bit ones, a 512-byte stack, maps as structures shared with userspace, JIT to native hardware, and a far more sophisticated formal verifier. From being a packet filter, it became &lt;strong>a generic kernel extensibility mechanism&lt;/strong>.&lt;/p>
&lt;p>Today eBPF is used for four things:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Networking&lt;/strong>: XDP, TC, cgroup hooks, socket ops, lightweight tunnels.&lt;/li>
&lt;li>&lt;strong>Observability&lt;/strong>: kprobes, uprobes, tracepoints, USDT. The basis of projects like &lt;code>bpftrace&lt;/code>, &lt;code>bcc&lt;/code>, Pixie, Parca.&lt;/li>
&lt;li>&lt;strong>Security&lt;/strong>: BPF LSM (Linux Security Module in eBPF), syscall blocking with seccomp-bpf. Falco, Tetragon, Tracee.&lt;/li>
&lt;li>&lt;strong>Scheduling&lt;/strong>: sched_ext (kernel 6.12+), process schedulers written entirely in eBPF. Still at a very early stage.&lt;/li>
&lt;/ol>
&lt;h3 id="the-vm">The VM&lt;/h3>
&lt;p>An eBPF program is compiled from C (or Rust, or Go with cilium/ebpf) to eBPF bytecode, not to x86/arm64 directly. The kernel loader (via the &lt;code>bpf()&lt;/code> syscall) passes that bytecode through &lt;strong>the verifier&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>It reconstructs the control flow graph.&lt;/li>
&lt;li>It performs static analysis of every possible path: every instruction has to be reachable, every memory access has to be within known bounds, every pointer has to have been validated.&lt;/li>
&lt;li>It rejects loops without a known upper bound. Recent kernels admit bounded loops (the &lt;code>bpf_loop&lt;/code> helper), but the counter is always finite.&lt;/li>
&lt;li>It rejects calls to helpers or kfuncs that the hook&amp;rsquo;s program type does not allow.&lt;/li>
&lt;/ul>
&lt;p>If the verifier accepts the program, the JIT translates it to the host&amp;rsquo;s native code (x86, arm64, etc.) and it stays attached to its hook. From then on it runs every time the hook&amp;rsquo;s event occurs, &lt;strong>with no context switch to userspace&lt;/strong>, &lt;strong>with no syscall cost&lt;/strong>. Latencies on the order of hundreds of nanoseconds per invocation.&lt;/p>
&lt;h3 id="maps-the-bridge-to-userspace">Maps: the bridge to userspace&lt;/h3>
&lt;p>An isolated eBPF program is not much use. What makes it useful are &lt;strong>maps&lt;/strong>: data structures shared between the kernel program and userspace. There are several types:&lt;/p>
&lt;ul>
&lt;li>&lt;code>BPF_MAP_TYPE_HASH&lt;/code>, &lt;code>BPF_MAP_TYPE_LRU_HASH&lt;/code>: hash tables with or without LRU eviction.&lt;/li>
&lt;li>&lt;code>BPF_MAP_TYPE_ARRAY&lt;/code>, &lt;code>BPF_MAP_TYPE_PERCPU_ARRAY&lt;/code>: arrays, optionally per-CPU to avoid contention.&lt;/li>
&lt;li>&lt;code>BPF_MAP_TYPE_RINGBUF&lt;/code>, &lt;code>BPF_MAP_TYPE_PERF_EVENT_ARRAY&lt;/code>: channels for streaming events to userspace.&lt;/li>
&lt;li>&lt;code>BPF_MAP_TYPE_PROG_ARRAY&lt;/code>: arrays of eBPF programs for tail calls (chaining programs without returning to the base kernel).&lt;/li>
&lt;/ul>
&lt;p>Userspace reads and writes these maps via &lt;code>bpf()&lt;/code> syscalls; the kernel program reads and writes them directly. It is the basis of any eBPF system: the kernel program collects data into a map, the userspace daemon reads it. Cilium does exactly this: the userland agent (Go) manages the policy and translates it into map entries; the eBPF programs living in XDP/TC read the maps and apply the decisions.&lt;/p>
&lt;h3 id="co-re-compile-once-run-on-any-kernel">CO-RE: compile once, run on any kernel&lt;/h3>
&lt;p>A classic nightmare of kernel modules: they are tied to the exact kernel version they were compiled against. Distributing a precompiled module for a fleet of machines with different distros was impossible.&lt;/p>
&lt;p>eBPF solves this with &lt;strong>CO-RE (Compile Once, Run Everywhere)&lt;/strong>: the bytecode includes &lt;strong>relocations&lt;/strong> that the loader resolves on each specific kernel by consulting &lt;strong>BTF (BPF Type Format)&lt;/strong>, a representation of the kernel&amp;rsquo;s struct layouts that the kernel itself publishes. Result: a single eBPF binary works on kernels 5.10, 5.15, 6.1 and 6.8 without recompiling, because the loader adjusts the struct access offsets at runtime.&lt;/p>
&lt;p>This is what has allowed productive eBPF distributions to exist. Without CO-RE, every kernel would be a porting project.&lt;/p>
&lt;h2 id="ebpf-in-networking-the-hooks-that-matter">eBPF in networking: the hooks that matter&lt;/h2>
&lt;p>Inside the Linux network subsystem, eBPF has several hooks. The ones relevant to CNIs:&lt;/p>
&lt;h3 id="xdp--express-data-path">XDP — eXpress Data Path&lt;/h3>
&lt;p>XDP is &lt;strong>the earliest hook&lt;/strong>: it runs in the NIC driver, &lt;strong>before the packet enters the kernel proper&lt;/strong>. There is no &lt;code>sk_buff&lt;/code> (the struct the rest of the kernel uses to represent packets); there is only a pointer to a RAM buffer with the received bytes.&lt;/p>
&lt;p>The actions an XDP program can return:&lt;/p>
&lt;ul>
&lt;li>&lt;code>XDP_DROP&lt;/code>: discard the packet immediately. The driver drops it and frees the buffer. Cost: nanoseconds. Use case: DDoS mitigation. Cloudflare processed &lt;strong>more than 8 million packets/second per CPU&lt;/strong> with XDP for dropping SYN floods.&lt;/li>
&lt;li>&lt;code>XDP_PASS&lt;/code>: let the packet continue to the normal kernel. It becomes an &lt;code>sk_buff&lt;/code> and enters the traditional stack.&lt;/li>
&lt;li>&lt;code>XDP_TX&lt;/code>: send it back out on the same interface after possible modifications. Useful for L4 load balancers that rewrite the destination and return it.&lt;/li>
&lt;li>&lt;code>XDP_REDIRECT&lt;/code>: send the packet to another interface or to a map (to forward to userspace via AF_XDP, or to another NIC, or to a pod&amp;rsquo;s veth).&lt;/li>
&lt;li>&lt;code>XDP_ABORTED&lt;/code>: error (increments a counter, drops).&lt;/li>
&lt;/ul>
&lt;p>Real use cases:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Cloudflare L3 DDoS protection&lt;/strong>: XDP rules that drop millions of packets/s.&lt;/li>
&lt;li>&lt;strong>Facebook Katran&lt;/strong>: an L4 load balancer that rewrites the destination IP and returns it on the same interface. Handles 10× more connections per server than classic IPVS.&lt;/li>
&lt;li>&lt;strong>Cilium XDP acceleration&lt;/strong>: Service load balancing at the lowest layer possible.&lt;/li>
&lt;/ul>
&lt;h3 id="tc-traffic-control--clsact-with-bpf">TC (Traffic Control) — clsact with BPF&lt;/h3>
&lt;p>XDP is very fast but limited: the packet does not have an &lt;code>sk_buff&lt;/code> yet and many decisions (conntrack, NAT, encapsulation with metadata) are easier when it does. The &lt;strong>TC clsact with BPF&lt;/strong> hook runs &lt;strong>after&lt;/strong> building the &lt;code>sk_buff&lt;/code> but &lt;strong>before&lt;/strong> the routing and netfilter decisions. Actions:&lt;/p>
&lt;ul>
&lt;li>&lt;code>TC_ACT_OK&lt;/code>: the packet continues through the stack.&lt;/li>
&lt;li>&lt;code>TC_ACT_SHOT&lt;/code>: drop.&lt;/li>
&lt;li>&lt;code>TC_ACT_REDIRECT&lt;/code>: redirect to another interface.&lt;/li>
&lt;li>&lt;code>TC_ACT_PIPE&lt;/code>, &lt;code>TC_ACT_STOLEN&lt;/code>: pipeline control for combining with other qdiscs.&lt;/li>
&lt;/ul>
&lt;p>Use cases:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Stateful network policy&lt;/strong>: Cilium evaluates L3-L7 policies in TC with the full &lt;code>sk_buff&lt;/code> and conntrack available.&lt;/li>
&lt;li>&lt;strong>Marking and QoS&lt;/strong>: traffic marking so the scheduler applies priorities.&lt;/li>
&lt;li>&lt;strong>Overlay encapsulation&lt;/strong>: adding VXLAN/Geneve headers when the mode is tunnel.&lt;/li>
&lt;/ul>
&lt;p>XDP and TC combine: &lt;strong>XDP for the cheap and early stuff&lt;/strong> (DDoS, simple LB), &lt;strong>TC for what needs an &lt;code>skb&lt;/code> and state&lt;/strong>.&lt;/p>
&lt;h3 id="cgroup-hooks-sock_ops-and-cgroup_sock_addr">Cgroup hooks: sock_ops and CGROUP_SOCK_ADDR&lt;/h3>
&lt;p>The most radical conceptual step: hooks that are not in the network layer but &lt;strong>in the socket layer&lt;/strong>. Relevant types:&lt;/p>
&lt;ul>
&lt;li>&lt;code>BPF_PROG_TYPE_CGROUP_SOCK_ADDR&lt;/code>: invoked when a process in a cgroup calls &lt;code>connect()&lt;/code>, &lt;code>bind()&lt;/code>, &lt;code>sendto()&lt;/code>. The eBPF program can &lt;strong>rewrite the destination address&lt;/strong> before the connection goes out. This is what lets Cilium do Service load balancing &lt;strong>without the packet entering the network stack&lt;/strong>: if the client tries to connect to &lt;code>10.96.0.1:443&lt;/code> (a ClusterIP), an eBPF program on this hook rewrites the destination to the real IP of the backend pod before the syscall continues.&lt;/li>
&lt;li>&lt;code>BPF_PROG_TYPE_SOCK_OPS&lt;/code>: invoked on TCP events (creation, established, retransmission). It allows tuning socket parameters at runtime and, most importantly, &lt;strong>pairing local sockets&lt;/strong> via &lt;code>bpf_sk_assign&lt;/code> to shortcut without the packet travelling over the network.&lt;/li>
&lt;/ul>
&lt;p>This is the &amp;ldquo;third layer&amp;rdquo; of the bypass: it is not just faster, it is &lt;strong>conceptually different&lt;/strong>. The packet is not built, not serialised, does not traverse the IP layer or the TCP layer. It is the difference between speeding up a road and discovering that for some journeys you do not need to take the car at all.&lt;/p>
&lt;h2 id="the-long-road-what-the-traditional-tcpip-stack-looks-like">The long road: what the traditional TCP/IP stack looks like&lt;/h2>
&lt;p>To appreciate what eBPF saves, it is worth tracing a packet&amp;rsquo;s full journey through the Linux stack. Take the case &amp;ldquo;packet arrives on a NIC, goes to a local process&amp;rdquo;:&lt;/p>
&lt;pre tabindex="0">&lt;code>NIC (DMA into the driver&amp;#39;s ring buffer)
↓
driver: napi_schedule, poll, allocates sk_buff
↓
[XDP hook] ← if there is an XDP program, it is decided here
↓
netif_receive_skb
↓
__netif_receive_skb_core
↓
[TC ingress clsact + BPF] ← if there is a TC ingress program
↓
packet_type handlers (IP, ARP...)
↓
ip_rcv → ip_rcv_core
↓
[netfilter NF_INET_PRE_ROUTING] ← iptables PREROUTING
↓
routing decision (FIB lookup)
↓
[netfilter NF_INET_LOCAL_IN] or [NF_INET_FORWARD]
↓
tcp_v4_rcv → tcp_v4_do_rcv
↓
tcp_rcv_established
↓
sk_data_ready
↓
process reads with recv()/read()
&lt;/code>&lt;/pre>&lt;p>Every arrow is a function call with a measurable cost. Every netfilter hook walks all the registered iptables/nftables rules. With kube-proxy in iptables mode and 5,000 Services × 10 endpoints each, there are on the order of &lt;strong>150,000 rules&lt;/strong> evaluated sequentially at &lt;code>NF_INET_PRE_ROUTING&lt;/code>. Published benchmarks show latencies of &lt;strong>tens of microseconds per packet&lt;/strong> in large Kubernetes clusters in the netfilter step alone, &lt;strong>before&lt;/strong> the application receives anything.&lt;/p>
&lt;p>And that is the normal path. On the way out the same thing happens in reverse: &lt;code>tcp_sendmsg → ip_output → NF_INET_LOCAL_OUT → routing → NF_INET_POSTROUTING → dev_queue_xmit → driver → NIC&lt;/code>.&lt;/p>
&lt;h2 id="how-cilium-skips-this-stack">How Cilium skips this stack&lt;/h2>
&lt;p>Cilium does not eliminate the TCP/IP stack; it is still there for the cases that need it. What it does is &lt;strong>shortcuts&lt;/strong> at the points where it hurts.&lt;/p>
&lt;h3 id="shortcut-1--xdp-for-the-service-datapath">Shortcut 1 — XDP for the Service datapath&lt;/h3>
&lt;p>For a cluster with 5,000 Services, kube-proxy iptables has an O(N) cost in evaluating rules (even with &lt;code>iptables-restore --noflush&lt;/code> and tricks, it is still linear in the number of chains the packet traverses).&lt;/p>
&lt;p>Cilium replaces it like this:&lt;/p>
&lt;ul>
&lt;li>Every Service and its endpoints live in &lt;strong>an eBPF hash map&lt;/strong>.&lt;/li>
&lt;li>When a packet comes in destined for a ClusterIP, Cilium&amp;rsquo;s XDP program does &lt;strong>an O(1) lookup&lt;/strong> in that map and obtains the backend endpoint.&lt;/li>
&lt;li>It rewrites the destination and does &lt;code>XDP_TX&lt;/code> (returns it on the same interface towards the backend) or &lt;code>XDP_REDIRECT&lt;/code> (sends it to the corresponding local pod&amp;rsquo;s veth).&lt;/li>
&lt;/ul>
&lt;p>This means the cost does not grow with the number of Services. 100 Services or 100,000, &lt;strong>constant lookup in the map&lt;/strong>. Published benchmarks show latency reductions of &lt;strong>30-50%&lt;/strong> in clusters with many Services compared with kube-proxy iptables, and of an order of magnitude compared with IPVS in some cases.&lt;/p>
&lt;h3 id="shortcut-2--socket-lb-the-packet-is-never-built">Shortcut 2 — socket-LB: the packet is never built&lt;/h3>
&lt;p>Cilium 1.6+ introduced &lt;strong>socket-level load balancing&lt;/strong>, based on cgroup hooks. It works like this:&lt;/p>
&lt;ul>
&lt;li>When a pod calls &lt;code>connect(10.96.0.1:443)&lt;/code> (a Service&amp;rsquo;s ClusterIP), the syscall enters the kernel.&lt;/li>
&lt;li>Before the kernel builds anything network-related, &lt;strong>an eBPF program on &lt;code>CGROUP_SOCK_ADDR/connect4&lt;/code>&lt;/strong> intercepts it and &lt;strong>rewrites the destination address&lt;/strong> to the real IP of the backend pod.&lt;/li>
&lt;li>The kernel carries on with the &lt;code>connect&lt;/code> as if the client had written &lt;code>10.0.0.42:8080&lt;/code> directly.&lt;/li>
&lt;/ul>
&lt;p>Why does it matter? Because when the backend pod is &lt;strong>on the same node&lt;/strong>, this shortcut turns a call that would have involved:&lt;/p>
&lt;pre tabindex="0">&lt;code>syscall connect → kernel stack → veth → bridge → veth → kernel stack → syscall accept
&lt;/code>&lt;/pre>&lt;p>into:&lt;/p>
&lt;pre tabindex="0">&lt;code>syscall connect (with the destination rewritten) → direct loopback
&lt;/code>&lt;/pre>&lt;p>The TCP/IP stack is literally avoided. There is no encapsulated packet, no journey through veth pairs, no netfilter. L7 pod-to-pod latencies on the same node drop to &lt;strong>local communication&lt;/strong> levels (~5-15 µs instead of ~30-50 µs for services with kube-proxy iptables and traditional veth).&lt;/p>
&lt;h3 id="shortcut-3--pod-to-pod-direct-routing">Shortcut 3 — pod-to-pod direct routing&lt;/h3>
&lt;p>The traditional overlay mode (Flannel, Calico VXLAN) encapsulates every pod-to-pod packet in VXLAN/Geneve. Every packet carries an extra 50-byte header, requires encap/decap, and consumes MTU.&lt;/p>
&lt;p>Cilium supports &lt;strong>direct routing&lt;/strong>: the pod CIDRs are advertised to the underlying fabric (with BGP, which is where the control plane we will look at comes in) and the physical routers route the pod-to-pod packets &lt;strong>without encapsulating&lt;/strong>. The packet leaves a pod with its original IP as source and the destination pod&amp;rsquo;s IP as dest, the node&amp;rsquo;s NIC hands it to the network, the network routes it, it arrives at the destination node and is delivered to the pod. Zero encap, full MTU, minimal latency.&lt;/p>
&lt;p>Cilium does this &lt;strong>via eBPF programs in TC&lt;/strong> that rewrite the necessary headers and decide whether the packet goes via encap or direct according to the policy configured per node.&lt;/p>
&lt;h3 id="shortcut-4--network-policy-in-tc-with-maps">Shortcut 4 — Network Policy in TC with maps&lt;/h3>
&lt;p>Network Policies in classic CNIs are usually translated into iptables rules, another factor that explodes linearly. Cilium evaluates them in eBPF programs that read &lt;strong>identity maps&lt;/strong>: every workload has a numeric identifier computed from its labels, and the policy is a map &lt;code>(src_identity, dst_identity, port, proto) → allow|deny&lt;/code>. One hash map lookup per packet.&lt;/p>
&lt;p>This also enables Cilium&amp;rsquo;s &lt;strong>L7 policies&lt;/strong> (HTTP, gRPC, Kafka filtering): the eBPF program recognises the L7 handshake, selectively redirects to the embedded Envoy proxy (which lives as a sidecar of the datapath, not as a pod sidecar) and only on that subset does it pay the cost of the L7 proxy. All the L3/L4 traffic stays on the eBPF fast path.&lt;/p>
&lt;h2 id="cilium-the-architecture">Cilium: the architecture&lt;/h2>
&lt;p>Cilium combines two planes:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Agent (Go)&lt;/strong>: lives as a DaemonSet on every node. It is the &amp;ldquo;slow&amp;rdquo; part: it translates the intent expressed in CRDs (CiliumNetworkPolicy, CiliumBGPClusterConfig, etc.) into entries in eBPF maps. It talks to the Kubernetes API server to discover endpoints, services, pods. It embeds a GoBGP for the BGP control plane. It embeds an Envoy for L7 policies.&lt;/li>
&lt;li>&lt;strong>Datapath (eBPF)&lt;/strong>: the programs loaded into XDP, TC, cgroup hooks. They are the &amp;ldquo;fast&amp;rdquo; part: they see every packet, read the maps the agent maintains, and decide in nanoseconds.&lt;/li>
&lt;/ul>
&lt;p>This separation is what makes Cilium operationally comfortable: the intent is expressed in YAML, the agent materialises it into maps, the maps are read by the datapath. If the agent goes down temporarily, the datapath keeps working with the last loaded configuration. As in any well-built control/data plane system.&lt;/p>
&lt;h2 id="bgp-control-plane-v2-the-crds-you-have-to-know">BGP Control Plane v2: the CRDs you have to know&lt;/h2>
&lt;p>Cilium has had BGP support for several years. The first version used a single monolithic CRD, &lt;code>CiliumBGPPeeringPolicy&lt;/code>, which mixed node configuration, peers, timers and advertisements into a single object. Since &lt;strong>Cilium 1.16&lt;/strong> there is &lt;strong>BGP Control Plane v2&lt;/strong>, which breaks that configuration into separate CRDs with clear responsibilities. &lt;code>CiliumBGPPeeringPolicy&lt;/code> (the &lt;code>cilium.io/v2alpha1&lt;/code> API) is &lt;strong>deprecated&lt;/strong> and migration warnings appear in the operator logs if you still use it.&lt;/p>
&lt;p>The new CRDs (the &lt;code>cilium.io/v2&lt;/code> API):&lt;/p>
&lt;h3 id="1-ciliumbgpclusterconfig">1. &lt;code>CiliumBGPClusterConfig&lt;/code>&lt;/h3>
&lt;p>Defines &lt;strong>BGP instances&lt;/strong> and the peers they connect to, from the cluster&amp;rsquo;s perspective. Which nodes apply this configuration is selected with a &lt;code>nodeSelector&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">CiliumBGPClusterConfig&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium-bgp-cluster&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bgp-policy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">rack-1 &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># only nodes with this label&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bgpInstances&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">instance-65000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">localASN&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">65000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">peers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">top-of-rack-1a&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">peerASN&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64512&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">peerAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10.0.1.1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">peerConfigRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">tor-shared-config &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># → reference to CiliumBGPPeerConfig&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">top-of-rack-1b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">peerASN&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64512&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">peerAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10.0.1.2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">peerConfigRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">tor-shared-config&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A BGP instance is the abstraction &amp;ldquo;this node takes part in BGP with this local ASN and these peers&amp;rdquo;. Several can coexist on the same node (multi-instance for multi-VRF).&lt;/p>
&lt;h3 id="2-ciliumbgppeerconfig">2. &lt;code>CiliumBGPPeerConfig&lt;/code>&lt;/h3>
&lt;p>Defines the &lt;strong>shared parameters&lt;/strong> of the peering: timers, address families, transport, MD5 password, graceful restart, etc. It is referenced from &lt;code>CiliumBGPClusterConfig&lt;/code> via &lt;code>peerConfigRef&lt;/code>. This avoids repeating the same configuration for every peer when there are dozens of them.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">CiliumBGPPeerConfig&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">tor-shared-config&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">holdTimeSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">keepAliveTimeSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">connectRetryTimeSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">gracefulRestart&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">restartTimeSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">families&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">afi&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ipv4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">safi&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">unicast&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">advertisements&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">advertise&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bgp &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># → binds to CiliumBGPAdvertisement&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">afi&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ipv6&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">safi&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">unicast&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">advertisements&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">advertise&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bgp&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">authentication&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">password&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bgp-md5-secret &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># Secret with the MD5 password&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">password&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A single &lt;code>CiliumBGPPeerConfig&lt;/code> can be referenced by &lt;strong>many different peers&lt;/strong>. You change timers or families in one place.&lt;/p>
&lt;h3 id="3-ciliumbgpadvertisement">3. &lt;code>CiliumBGPAdvertisement&lt;/code>&lt;/h3>
&lt;p>Declares &lt;strong>which prefixes are advertised&lt;/strong>: the node&amp;rsquo;s pod CIDRs, the ClusterIPs and ExternalIPs of Services, the IPs assigned by &lt;code>CiliumLoadBalancerIPPool&lt;/code> for type=LoadBalancer Services. They are bound to &lt;code>CiliumBGPPeerConfig&lt;/code> via labels.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">CiliumBGPAdvertisement&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">services-and-pods&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">advertise&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bgp &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># ← the label the PeerConfig matches&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">advertisements&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">advertisementType&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PodCIDR &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># advertises the node&amp;#39;s pod CIDR&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">attributes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">communities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">standard&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;65000:100&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">advertisementType&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Service &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># advertises ClusterIPs / LoadBalancer IPs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">service&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">addresses&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">LoadBalancerIP&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">ClusterIP&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">ExternalIP&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bgp-advertise&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;true&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># only Services with this label&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">attributes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">communities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">standard&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;65000:200&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">localPreference&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">200&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The granularity is very fine: you can advertise different types of prefixes with different BGP communities, different local-preference, different path attributes, and filter Services with label selectors. This was literally impossible with &lt;code>CiliumBGPPeeringPolicy&lt;/code> v1.&lt;/p>
&lt;h3 id="4-ciliumbgpnodeconfig-auto-generated">4. &lt;code>CiliumBGPNodeConfig&lt;/code> (auto-generated)&lt;/h3>
&lt;p>This CRD is not configured by hand. The &lt;strong>Cilium operator&lt;/strong> generates one per node from the &lt;code>CiliumBGPClusterConfig&lt;/code> that applies to that node. It is the materialised per-node state that each node&amp;rsquo;s agent reads to bring up its peerings. If you want to see what BGP configuration is actually running on a node, &lt;code>kubectl get ciliumbgpnodeconfig &amp;lt;nodename&amp;gt; -o yaml&lt;/code> shows you.&lt;/p>
&lt;h3 id="5-ciliumbgpnodeconfigoverride">5. &lt;code>CiliumBGPNodeConfigOverride&lt;/code>&lt;/h3>
&lt;p>Optional. It allows &lt;strong>overriding the generated configuration&lt;/strong> for a specific node when you need something non-standard. Use cases:&lt;/p>
&lt;ul>
&lt;li>Pinning the BGP router-id to a specific IP (useful when the node has several interfaces).&lt;/li>
&lt;li>Specifying the peer&amp;rsquo;s local address when there are several outgoing interfaces.&lt;/li>
&lt;li>Changing timers only for one problematic node.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">CiliumBGPNodeConfigOverride&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">node-rack1-master01 &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># the name must match the node&amp;#39;s&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bgpInstances&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">instance-65000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routerID&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10.0.1.10&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># router-id override&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">peers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">top-of-rack-1a&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">localAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10.0.1.10&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># specific local interface&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="relationship-diagram">Relationship diagram&lt;/h3>
&lt;div class="diagram" style="max-width:720px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 720 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Relationship diagram between Cilium BGP v2 CRDs">
&lt;style>.title{font:600 13px sans-serif;fill:#222}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#555}.box{stroke:#444;stroke-width:1.4}.c1{fill:#ffe9d6}.c2{fill:#d6eaff}.c3{fill:#d9f5d6}.c4{fill:#e9d6f5}.c5{fill:#eee;stroke-dasharray:4 2}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#h)}.dashed{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 3;marker-end:url(#h)}&lt;/style>
&lt;defs>&lt;marker id="h" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="360" y="22" text-anchor="middle" class="title">Cilium BGP Control Plane v2 CRDs and their relationships&lt;/text>
&lt;rect x="40" y="50" width="220" height="60" rx="6" class="box c1"/>
&lt;text x="150" y="74" text-anchor="middle" class="lbl">CiliumBGPClusterConfig&lt;/text>
&lt;text x="150" y="94" text-anchor="middle" class="sm">nodeSelector + bgpInstances&lt;/text>
&lt;rect x="290" y="50" width="180" height="60" rx="6" class="box c2"/>
&lt;text x="380" y="74" text-anchor="middle" class="lbl">CiliumBGPPeerConfig&lt;/text>
&lt;text x="380" y="94" text-anchor="middle" class="sm">timers, families, auth&lt;/text>
&lt;rect x="500" y="50" width="180" height="60" rx="6" class="box c3"/>
&lt;text x="590" y="74" text-anchor="middle" class="lbl">CiliumBGPAdvertisement&lt;/text>
&lt;text x="590" y="94" text-anchor="middle" class="sm">pod CIDR, Service IPs&lt;/text>
&lt;rect x="40" y="180" width="220" height="60" rx="6" class="box c5"/>
&lt;text x="150" y="204" text-anchor="middle" class="lbl">CiliumBGPNodeConfig&lt;/text>
&lt;text x="150" y="224" text-anchor="middle" class="sm">auto-generated by the operator&lt;/text>
&lt;rect x="290" y="180" width="220" height="60" rx="6" class="box c4"/>
&lt;text x="400" y="204" text-anchor="middle" class="lbl">CiliumBGPNodeConfigOverride&lt;/text>
&lt;text x="400" y="224" text-anchor="middle" class="sm">optional, by node name&lt;/text>
&lt;path class="arr" d="M260,80 L290,80"/>&lt;text x="275" y="74" text-anchor="middle" class="sm">peerConfigRef&lt;/text>
&lt;path class="dashed" d="M380,110 L380,150 L470,180"/>&lt;text x="425" y="155" text-anchor="middle" class="sm">binds via labels&lt;/text>
&lt;path class="arr" d="M590,110 L590,150 L500,180"/>&lt;text x="545" y="155" text-anchor="middle" class="sm">advertisements&lt;/text>
&lt;path class="arr" d="M150,110 L150,180"/>&lt;text x="165" y="150" text-anchor="middle" class="sm">operator&lt;/text>
&lt;path class="dashed" d="M290,210 L260,210"/>&lt;text x="275" y="205" text-anchor="middle" class="sm">override&lt;/text>
&lt;text x="360" y="290" text-anchor="middle" class="sm">solid arrows: direct YAML references. Dashed: links by label selector or lateral coordination.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="ciliumloadbalancerippool-it-complements-it-is-not-bgp">CiliumLoadBalancerIPPool: it complements, it is not BGP&lt;/h3>
&lt;p>Although it is not strictly a BGP CRD, it is worth mentioning: &lt;strong>&lt;code>CiliumLoadBalancerIPPool&lt;/code>&lt;/strong> is the CRD that supplies IPs to type=LoadBalancer Services. It defines a range (&lt;code>10.20.0.0/24&lt;/code>, for example) that Cilium assigns automatically to LoadBalancer Services. Combined with a &lt;code>CiliumBGPAdvertisement&lt;/code> that advertises &lt;code>LoadBalancerIP&lt;/code>, it gives the complete cycle: new Service → IP assigned from the pool → BGP advertisement to the routers → IP routable from the corporate network, with no external balancer.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v2alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">CiliumLoadBalancerIPPool&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">lb-pool-rack1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">blocks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">start&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;10.20.0.10&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stop&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;10.20.0.250&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serviceSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">lb-pool&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">rack1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="full-manifest-pod-cidrs--loadbalancer-services-advertised-to-a-redundant-tor-pair">Full manifest: pod CIDRs + LoadBalancer Services advertised to a redundant ToR pair&lt;/h2>
&lt;p>A realistic example of a cluster with two top-of-rack switches as BGP peers, both in the same AS (64512), Cilium in AS 65000:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># 1. CiliumBGPPeerConfig — shared config for both ToRs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">CiliumBGPPeerConfig&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">tor-peers&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">holdTimeSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">keepAliveTimeSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">gracefulRestart&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">restartTimeSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">families&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">afi&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ipv4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">safi&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">unicast&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">advertisements&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">advertise&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bgp&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># 2. CiliumBGPAdvertisement — what gets advertised&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">CiliumBGPAdvertisement&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">pods-and-lb&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">advertise&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bgp&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">advertisements&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">advertisementType&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PodCIDR&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">advertisementType&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Service&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">service&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">addresses&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">LoadBalancerIP]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchExpressions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- {&lt;span class="w"> &lt;/span>&lt;span class="nt">key: io.kubernetes.service.namespace, operator: NotIn, values&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">kube-system] }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># 3. CiliumBGPClusterConfig — which nodes talk to which peers&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">CiliumBGPClusterConfig&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cluster-bgp&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bgp&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">enabled&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bgpInstances&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">instance-65000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">localASN&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">65000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">peers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">tor-a&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">peerASN&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64512&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">peerAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10.0.1.1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">peerConfigRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">tor-peers }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">tor-b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">peerASN&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64512&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">peerAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10.0.1.2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">peerConfigRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">tor-peers }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># 4. CiliumLoadBalancerIPPool — range of LB IPs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v2alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">CiliumLoadBalancerIPPool&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">lb-corporate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">blocks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">cidr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;10.20.0.0/24&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Four objects. Before, in v1, it was a single &lt;code>CiliumBGPPeeringPolicy&lt;/code> that mixed everything together and turned out to be hard to maintain in clusters of more than 5 nodes with heterogeneous configuration. The new separation is longer but clearly factorable: one &lt;code>PeerConfig&lt;/code> per peer type, one &lt;code>Advertisement&lt;/code> per advertisement policy, one &lt;code>ClusterConfig&lt;/code> connecting nodes with peers.&lt;/p>
&lt;h2 id="common-operational-traps">Common operational traps&lt;/h2>
&lt;h3 id="routingmode-tunnel-mode-with-bgp">&lt;code>routingMode: tunnel&lt;/code> mode with BGP&lt;/h3>
&lt;p>BGP only makes sense with &lt;strong>direct routing&lt;/strong> (&lt;code>routingMode: native&lt;/code>). If you have tunnel mode (VXLAN/Geneve) and configure BGP, you will advertise pod CIDRs but the packets will still go out encapsulated, producing confusing behaviour (sometimes via tunnel, sometimes direct depending on routes). Configure &lt;code>routingMode: native&lt;/code> and disable the tunnel.&lt;/p>
&lt;h3 id="ebpf-host-routing-vs-kube-proxy-replacement">eBPF host routing vs &lt;code>kube-proxy replacement&lt;/code>&lt;/h3>
&lt;p>They are two different things. &lt;code>kubeProxyReplacement: true&lt;/code> enables the replacement of kube-proxy (the Services). &lt;code>bpf.hostRouting: true&lt;/code> enables the host&amp;rsquo;s iptables bypass (the node&amp;rsquo;s routing decisions are made with eBPF instead of the traditional FIB). The second needs kernel 5.10+ with all the bpf features enabled; if you do not have that kernel, it falls back to legacy mode and the performance is only &amp;ldquo;almost as good&amp;rdquo;.&lt;/p>
&lt;h3 id="aggressive-bgp-timers-over-flapping-nics">Aggressive BGP timers over flapping NICs&lt;/h3>
&lt;p>With &lt;code>holdTimeSeconds: 9 / keepAliveSeconds: 3&lt;/code>, a NIC that blinks for 5 seconds breaks the BGP session and all the advertised routes disappear from the fabric. That node&amp;rsquo;s pods become unreachable until the session is re-established. For clusters on hardware with suspect NICs, use the conservative values (&lt;code>holdTime: 30, keepAlive: 10&lt;/code>) and consider &lt;strong>graceful restart&lt;/strong> explicitly (it is already in the example above).&lt;/p>
&lt;h3 id="advertising-clusterip-to-the-corporate-network">Advertising ClusterIP to the corporate network&lt;/h3>
&lt;p>Advertising &lt;code>ClusterIP&lt;/code> to external routers is &lt;strong>rarely what you want&lt;/strong>: these are internal Service IPs, not designed to be reached from outside the cluster. For external exposure, use &lt;code>LoadBalancerIP&lt;/code> from a &lt;code>CiliumLoadBalancerIPPool&lt;/code>. Advertising &lt;code>ClusterIP&lt;/code> only makes sense in very specific topologies (multi-cluster mesh with shared service discovery).&lt;/p>
&lt;h3 id="mixing-v2alpha1-ciliumbgppeeringpolicy-and-v2-ciliumbgpclusterconfig">Mixing v2alpha1 (&lt;code>CiliumBGPPeeringPolicy&lt;/code>) and v2 (&lt;code>CiliumBGPClusterConfig&lt;/code>)&lt;/h3>
&lt;p>It does not work well. The operator emits warnings in the logs about the use of the deprecated API, and conflicts between what the peering policy defines and what the cluster config defines can produce strange states. Migrate from one to the other in a single pass; do not run both.&lt;/p>
&lt;h3 id="md5-password-and-mtu">MD5 password and MTU&lt;/h3>
&lt;p>If you configure an MD5 password in &lt;code>CiliumBGPPeerConfig.authentication&lt;/code>, the TCP header is larger. On links with a tight MTU (1500 - 50 for the upstream fabric&amp;rsquo;s VXLAN, for example), the BGP handshake can fragment and die silently. Either use MTU 9000 between nodes and ToR, or make sure the MSS values are negotiated correctly.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Cilium Cluster Mesh&lt;/strong>: federation of several Cilium clusters so their Services can see each other. It fits with BGP when you want native routing between clusters; it has its own CRDs.&lt;/li>
&lt;li>&lt;strong>L7 Policies and the embedded Envoy&lt;/strong>: HTTP/gRPC/Kafka policy. Another layer of eBPF + proxy that deserves its own post.&lt;/li>
&lt;li>&lt;strong>Hubble&lt;/strong>: eBPF-based traffic observability that Cilium exposes. Flow log dashboards with zero impact on latency.&lt;/li>
&lt;li>&lt;strong>Transparent WireGuard&lt;/strong>: pod-to-pod encryption without sidecars, controlled by Cilium via eBPF redirect to a kernel WireGuard dataplane.&lt;/li>
&lt;li>&lt;strong>Gateway API in Cilium&lt;/strong>: the successor to Ingress, with first-class support since Cilium 1.16+.&lt;/li>
&lt;li>&lt;strong>eBPF for LLM serving&lt;/strong>: the natural connection with the previous inference series. There is recent work using eBPF for multi-tenant fairness on GPUs and for token tracking; paper territory, not production yet.&lt;/li>
&lt;/ul>
&lt;p>In other words, there is material left for another three posts in today&amp;rsquo;s series. Let us take them in order.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;p>Conceptual and project:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://ebpf.io/">eBPF.io&lt;/a> — canonical documentation of the eBPF ecosystem.&lt;/li>
&lt;li>&lt;a href="https://github.com/iovisor/bcc">The BPF Compiler Collection (bcc)&lt;/a> and &lt;a href="https://github.com/bpftrace/bpftrace">bpftrace&lt;/a> — tools to get started with.&lt;/li>
&lt;li>&lt;a href="https://www.programming-helper.com/tech/ebpf-2026-extended-berkeley-packet-filter-observability-security">eBPF en 2026: How Extended Berkeley Packet Filter Became the Engine of Linux Observability and Networking&lt;/a> — state of the art.&lt;/li>
&lt;/ul>
&lt;p>XDP, TC and firewalling:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/10/html/configuring_firewalls_and_packet_filters/getting-started-with-xdp-and-ebpf">Getting started with XDP and eBPF (Red Hat docs)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://medium.com/@majidbasharat21/full-guide-to-bpf-firewalls-xdp-tc-and-ebpf-integration-81951f19354b">Full Guide to BPF Firewalls: XDP, tc, and eBPF Integration (Medium, 2025)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.cloudflare.com/xdp-on-bpf-and-bonding/">Cloudflare blog: XDP for DDoS mitigation&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/facebookincubator/katran">Facebook Katran (GitHub)&lt;/a> — L4 LB with XDP, code and paper.&lt;/li>
&lt;/ul>
&lt;p>Cilium:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://docs.cilium.io/">Cilium documentation&lt;/a> — always the first port of call.&lt;/li>
&lt;li>&lt;a href="https://docs.cilium.io/en/stable/network/kubernetes/kubeproxy-free/">Kubernetes Without kube-proxy&lt;/a> — the official guide to the replacement.&lt;/li>
&lt;li>&lt;a href="https://docs.cilium.io/en/stable/network/bgp-control-plane/bgp-control-plane-configuration/">Cilium BGP Control Plane Resources (docs)&lt;/a> — reference for the v2 CRDs.&lt;/li>
&lt;li>&lt;a href="https://oneuptime.com/blog/post/2026-03-13-cilium-bgp-control-plane-configuration/view">Configuring Cilium BGP Control Plane (OneUptime blog, mar 2026)&lt;/a> — walkthrough.&lt;/li>
&lt;li>&lt;a href="https://sigridjin.medium.com/a-guide-to-bgp-control-plane-and-cluster-mesh-in-cilium-networking-f20dbf64c5ed">A Guide to BGP Control Plane and Cluster Mesh in Cilium Networking (Sigrid Jin, Medium)&lt;/a> — a deeper post with use cases.&lt;/li>
&lt;/ul>
&lt;p>Cross-references:&lt;/p>
&lt;ul>
&lt;li>Previous post on this blog: &lt;a href="https://blog.lo0.es/en/posts/kubernetes-cilium-bgp-services-without-ingress/">Kubernetes with Cilium BGP: services reachable without Ingress&lt;/a> — the first step, with version v1 (which now needs migrating).&lt;/li>
&lt;li>Series on LLM inference: &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention deep dive&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM K8s Operators&lt;/a> — where the fast network (which we will see in the following posts of this series) determines real performance.&lt;/li>
&lt;/ul></description></item><item><title>LLM inference operators on Kubernetes: OME, vLLM Production Stack, NVIDIA Dynamo and llm-d</title><link>https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/</link><pubDate>Mon, 18 May 2026 17:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Serving an LLM in production is not running a binary: it is coordinating a model (tens of gigabytes that take minutes to load), a runtime (vLLM, SGLang, TensorRT-LLM with a hundred flags), heterogeneous GPUs (NVLink, MIG, PCIe), prefill and decode that live better apart, a KV cache that wants offloading to colder tiers, intelligent routing that exploits prefix caching, and autoscaling that reacts to metrics other than CPU%. A flat Kubernetes &lt;code>Deployment&lt;/code> only covers the first 20% of that. The other 80% is covered by LLM inference operators, of which four matter in 2026: &lt;strong>OME&lt;/strong> (LMSYS, July 2025, multi-engine with a focus on SGLang), &lt;strong>vLLM Production Stack&lt;/strong> (a curated Helm chart from vLLM itself with LMCache for tiered KV), &lt;strong>NVIDIA Dynamo&lt;/strong> (the official successor to Triton, multi-engine, with its own Grove scheduler) and &lt;strong>llm-d&lt;/strong> (a CNCF donation of March 2026 by Red Hat + Google + IBM + CoreWeave + NVIDIA, built on vLLM, focused on distributed scale). Behind all four sits &lt;strong>KServe&lt;/strong>, the CNCF parent operator that normalised the &lt;code>InferenceService&lt;/code> concept and that several of them lean on. This article walks through the full hierarchy, gives a decision map and teaches you not to get lost when someone drops seven acronyms in the first meeting.&lt;/p>
&lt;blockquote>
&lt;p>This article closes the LLM inference series. The previous ones were &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory that holds up LLM inference&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes: the LLM inference piece that actually scales&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention from the inside and the state of the art of the KV cache in 2026&lt;/a>. There we explained what happens inside one inference process. Here we explain how many inference processes are coordinated through Kubernetes.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-from-initd-to-systemd-to-operators">The analogy: from &lt;code>init.d&lt;/code> to systemd to operators&lt;/h2>
&lt;p>Anyone with 20 years of sysadmin behind them will recognise the pattern. Decades ago, starting a service on Linux was a shell script in &lt;code>/etc/init.d/&lt;/code>: start, stop, status, reloaded by hand. When services got more complex, with dependencies between them, monitoring, restart on failure, per-user slots, it became obvious that a script was not enough. Along came systemd, which turned &amp;ldquo;a service&amp;rdquo; into a declarative unit with dependencies, resources, restart policy, sockets and timers. The script did not disappear; it moved up one level of abstraction.&lt;/p>
&lt;p>Kubernetes made the same move for distributed services. A &lt;code>Deployment&lt;/code> declares &amp;ldquo;I want N replicas of this container&amp;rdquo;; a &lt;code>Service&lt;/code> declares &amp;ldquo;these replicas are exposed like this&amp;rdquo;; an &lt;code>Ingress&lt;/code> declares &amp;ldquo;this HTTP traffic comes in here&amp;rdquo;. The controller translates the declaration into real state and keeps the system converged.&lt;/p>
&lt;p>Serving LLMs in 2024 was the equivalent of &lt;code>/etc/init.d/&lt;/code>: every team wrote its own &lt;code>Deployment&lt;/code>/&lt;code>Service&lt;/code>/&lt;code>HPA&lt;/code> with custom scripts for model loading, session draining and GPU handling. We covered it in the &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes article&lt;/a>: it can be done, and it does work, but it is repetitive, fragile and nobody is extracting the right abstractions. Serving LLMs in 2026 has gone through the same transition as services: the equivalent of systemd has appeared, the inference operators, which normalise the abstractions and let the engineer declare what matters: &amp;ldquo;this model, with this runtime, this scalable, with this routing policy&amp;rdquo;.&lt;/p>
&lt;p>There are four relevant operators in 2026 and a fifth common ancestor. Let us take them in order.&lt;/p>
&lt;h2 id="why-an-operator-and-not-just-a-deployment">Why an operator, and not just a Deployment&lt;/h2>
&lt;p>Listing what an inference operator adds over a flat Deployment is the best way to understand what problem it solves:&lt;/p>
&lt;p>&lt;strong>The model as a first-class citizen.&lt;/strong> In a Deployment, the model is &amp;ldquo;the thing you download in an initContainer and mount as a volume&amp;rdquo;. In an operator, the model is a &lt;code>CustomResource&lt;/code> with metadata (origin, fingerprint, licence, GPU requirements). It can be shared between InferenceServices, versioned, replicated to multiple nodes. It is the difference between &amp;ldquo;a file&amp;rdquo; and &amp;ldquo;a managed artifact&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>The runtime as a first-class citizen.&lt;/strong> The same goes for the runtime (vLLM/SGLang/TRT-LLM): it is not &amp;ldquo;a Docker image with flags&amp;rdquo;; it is a &lt;code>ServingRuntime&lt;/code> that declares which args it accepts, which metrics it exports, which deployment types it supports (single-node, multi-node TP, PD-disag). Changing runtime is changing a reference, not rewriting every manifest.&lt;/p>
&lt;p>&lt;strong>Declarative composition.&lt;/strong> An &lt;code>InferenceService&lt;/code> (the core CRD of KServe and its descendants) references a model and a runtime, declares the scaling policy, wires up observability and configures routing. The controller composes all the pieces: Deployment(s), Service, HPA, possibly a LeaderWorkerSet, a KEDA ScaledObject, a Gateway API HTTPRoute. You declare intent; the operator emits the 8 derived resources.&lt;/p>
&lt;p>&lt;strong>Operational prefill–decode disaggregation.&lt;/strong> As we saw in the PagedAttention article, separating prefill and decode into distinct pools can give 7× goodput. Modelling that with flat Deployments is feasible, but it requires coordinating two sets of pods, a transport to move the KV cache and conditional routing. An operator models it as a single &lt;code>InferenceService&lt;/code> with two sub-pools.&lt;/p>
&lt;p>&lt;strong>Autoscaling on LLM metrics.&lt;/strong> The standard HPA does not understand &lt;code>vllm:num_requests_waiting&lt;/code>. An operator integrates KEDA or Prometheus Adapter automatically and exposes the right metrics as knobs on the CRD.&lt;/p>
&lt;p>&lt;strong>Multi-tenancy.&lt;/strong> Multiple models on the same cluster, with quotas, priorities and fairness. One Deployment per model scaling independently is fine up to the fifth model; beyond that, coordinating GPUs between tenants becomes operationally hostile.&lt;/p>
&lt;p>&lt;strong>Model lifecycle.&lt;/strong> Weights on a shared PVC, warm-up of the first pod, rolling updates with &lt;code>maxUnavailable: 0&lt;/code>, draining of active sessions, integrated observability. Things that with a flat Deployment have to be reinvented by every team.&lt;/p>
&lt;p>If your workload is one model, one node, up to three replicas, a flat Deployment is enough and an operator is overkill. If your workload is two or more models, serious scaling, disaggregation or multi-tenancy, an operator stops being optional.&lt;/p>
&lt;h2 id="kserve-the-common-ancestor">KServe: the common ancestor&lt;/h2>
&lt;p>Before the four new ones, we have to mention &lt;a href="https://kserve.github.io/website/">KServe&lt;/a>, the parent operator from which all the rest conceptually descend. It was born as KFServing inside the Kubeflow project in 2019, was renamed KServe when it became independent in 2021, and in 2025 was &lt;a href="https://thenewstack.io/kserve-joins-cncf-to-standardize-ai-model-serving-on-kubernetes/">accepted into the CNCF&lt;/a> as a project incubating towards graduation.&lt;/p>
&lt;p>KServe&amp;rsquo;s conceptual contribution is the &lt;code>InferenceService&lt;/code> CRD, which has become the field&amp;rsquo;s common vocabulary: a declarative K8s object that joins a &lt;code>model&lt;/code> (origin + metadata) with a &lt;code>predictor&lt;/code> (runtime + resources) and produces a ready HTTP service. Under the hood, the controller emits Deployments, Services, HorizontalPodAutoscalers, Knative Services if you go serverless, Istio VirtualServices if you do traffic splitting.&lt;/p>
&lt;p>KServe was designed in a pre-LLM era: its first use cases were traditional scikit-learn, TensorFlow and PyTorch models served as simple REST APIs. That gives it strengths (it is mature, with 6 years in production at Bloomberg, JPMorgan and others) and weaknesses (it was not designed to manage multi-node tensor parallel, prefill–decode disaggregation, or the patterns specific to LLMs).&lt;/p>
&lt;p>The way the ecosystem has reacted is elegant: the new LLM operators inherit from or take inspiration from &lt;code>InferenceService&lt;/code> but extend the API with LLM-specific primitives. OME is the clearest example: it uses the name &lt;code>InferenceService&lt;/code> and the idea of &amp;ldquo;model + runtime → service&amp;rdquo;, but adds &lt;code>BaseModel&lt;/code>, a &lt;code>ServingRuntime&lt;/code> with LLM-aware flags, and deployment modes (PD-disag, multi-node) that KServe does not cover natively.&lt;/p>
&lt;h2 id="ome-open-model-engine">OME (Open Model Engine)&lt;/h2>
&lt;p>&lt;a href="https://github.com/ome-projects/ome">OME&lt;/a> was published by the LMSYS team in July 2025 (announced on &lt;a href="https://www.lmsys.org/blog/2025-07-08-ome/">their blog&lt;/a>). It is an operator that understands SGLang deeply (that is its first-class runtime) but also supports vLLM, TensorRT-LLM and Triton.&lt;/p>
&lt;h3 id="the-crd-hierarchy">The CRD hierarchy&lt;/h3>
&lt;p>OME models the domain with four main CRDs:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>BaseModel&lt;/code>&lt;/strong> and &lt;strong>&lt;code>ClusterBaseModel&lt;/code>&lt;/strong>: the model itself. It defines origin (Hugging Face, S3, URL), fingerprint and metadata. The &lt;code>Cluster*&lt;/code> version is global; &lt;code>BaseModel&lt;/code> is namespaced. It lets multiple &lt;code>InferenceService&lt;/code> objects reference the same model without duplicating the download.&lt;/li>
&lt;li>&lt;strong>&lt;code>FineTunedWeight&lt;/code>&lt;/strong>: LoRA adapters or finetuned weights served on top of a &lt;code>BaseModel&lt;/code>. Critical for multi-tenant setups where each customer has their own finetune.&lt;/li>
&lt;li>&lt;strong>&lt;code>ServingRuntime&lt;/code>&lt;/strong> and &lt;strong>&lt;code>ClusterServingRuntime&lt;/code>&lt;/strong>: the runtime (vLLM, SGLang, and so on) with its configuration. It declares which args it accepts, which metrics it exports, which deployment modes it supports.&lt;/li>
&lt;li>&lt;strong>&lt;code>InferenceService&lt;/code>&lt;/strong>: the central declarative piece, joining &lt;code>BaseModel&lt;/code> + &lt;code>ServingRuntime&lt;/code> + infrastructure.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ome.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">InferenceService&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama3-70b-prod&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">meta-llama-3-70b-instruct &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># reference to a BaseModel&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">runtime&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sglang-h100 &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># reference to a ServingRuntime&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">deploymentMode&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PrefillDecodeDisaggregated &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># standard | PD | MultiNode | Serverless&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prefill&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">decode&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">16&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">router&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cache-aware &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># SGLang router with cache awareness&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">autoscaling&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricSource&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">keda&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metrics&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm_requests_waiting&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;10&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That is what the operator takes as input. The output is roughly 8 derived resources that would be horrible to declare by hand: two LeaderWorkerSets (one per prefill/decode pool), two Services, a Deployment for the router, KEDA ScaledObjects for each pool, a Gateway API HTTPRoute, and a PriorityClass that hooks into Kueue for gang scheduling.&lt;/p>
&lt;h3 id="the-four-deployment-modes">The four deployment modes&lt;/h3>
&lt;p>OME materialises the &lt;code>InferenceService&lt;/code> differently depending on &lt;code>deploymentMode&lt;/code>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Standard&lt;/strong>: one Deployment with N replicas; the classic. For small or single-GPU models.&lt;/li>
&lt;li>&lt;strong>PrefillDecodeDisaggregated&lt;/strong>: two coordinated pools; the SGLang router routes between them.&lt;/li>
&lt;li>&lt;strong>MultiNode&lt;/strong>: tensor parallel across multiple nodes via LeaderWorkerSet, with NCCL/InfiniBand. For models above 70B where a single node is not enough.&lt;/li>
&lt;li>&lt;strong>Serverless&lt;/strong>: Knative-style scale-to-zero. For sporadic workloads where the cost of keeping GPUs powered up does not pay off. The trade-off: the first request pays the model&amp;rsquo;s cold start cost (minutes).&lt;/li>
&lt;/ul>
&lt;h3 id="integration-with-the-k8s-ecosystem">Integration with the K8s ecosystem&lt;/h3>
&lt;p>OME does not invent primitives where they already exist. It leans on:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://kueue.sigs.k8s.io/">Kueue&lt;/a> for gang scheduling: all the pods of a tensor parallel group have to start at once or not at all; Kueue guarantees it.&lt;/li>
&lt;li>&lt;a href="https://lws.sigs.k8s.io/">LeaderWorkerSet (LWS)&lt;/a> for multi-node: workers join the leader&amp;rsquo;s Ray cluster, with an atomic lifecycle (one going down restarts the group).&lt;/li>
&lt;li>KEDA for autoscaling on LLM-specific Prometheus metrics (queue depth, GPU cache usage, TTFT p95).&lt;/li>
&lt;li>&lt;a href="https://gateway-api.sigs.k8s.io/">Gateway API&lt;/a> and its Inference Extension for advanced routing (model-aware, prefix-aware, weighted canary).&lt;/li>
&lt;/ul>
&lt;p>The consequence: OME feels &amp;ldquo;idiomatically Kubernetes&amp;rdquo;. It does not introduce new concepts where they are not needed; it uses standard primitives and concentrates on what is specific to the LLM domain.&lt;/p>
&lt;h3 id="when-to-choose-it">When to choose it&lt;/h3>
&lt;p>OME is the natural choice if SGLang is your main runtime and/or if you come from the KServe ecosystem and want an idiomatic evolution of it. It is mature but relatively young (a year old at the time of writing); expect rough edges on advanced features.&lt;/p>
&lt;h2 id="vllm-production-stack">vLLM Production Stack&lt;/h2>
&lt;p>&lt;a href="https://github.com/vllm-project/production-stack">vLLM Production Stack&lt;/a> is the official project from vLLM itself for production on Kubernetes. Its philosophy is the opposite of OME&amp;rsquo;s: instead of an operator with new CRDs, it is a curated Helm chart that deploys a coherent set of pieces.&lt;/p>
&lt;h3 id="the-three-pieces">The three pieces&lt;/h3>
&lt;p>The stack has three components:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Serving engines&lt;/strong>: vLLM pods, configured with the flags we have been seeing throughout the series (&lt;code>--enable-prefix-caching&lt;/code>, &lt;code>--kv-cache-dtype fp8&lt;/code>, and so on). The Helm chart lets you declare them as a list; it deploys the underlying Deployments and Services.&lt;/li>
&lt;li>&lt;strong>Request router&lt;/strong>: a proxy in front of the engines that decides which one each request goes to. It supports several policies:
&lt;ul>
&lt;li>&lt;strong>Round-robin&lt;/strong>: trivial, for a baseline.&lt;/li>
&lt;li>&lt;strong>Session-based&lt;/strong>: pins each session to one replica to keep its KV cache.&lt;/li>
&lt;li>&lt;strong>Prefix-aware&lt;/strong>: detects shared prefixes between requests and routes them to the replica that already has them cached.&lt;/li>
&lt;li>&lt;strong>KV-aware&lt;/strong>: sees each replica&amp;rsquo;s &lt;code>gpu_cache_usage_perc&lt;/code> and avoids the saturated ones.&lt;/li>
&lt;li>&lt;strong>Disaggregated-prefill&lt;/strong> with native LMCache: separates prefill and decode, with LMCache as the transport for the KV cache between the two.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Observability stack&lt;/strong>: Prometheus + Grafana with ready-made dashboards. It measures TTFT, TBT (Time-Between-Tokens), throughput, queue depth and GPU memory.&lt;/li>
&lt;/ol>
&lt;h3 id="lmcache-and-tiered-kv">LMCache and tiered KV&lt;/h3>
&lt;p>One of the most interesting pieces the stack brings is &lt;a href="https://github.com/LMCache/LMCache">LMCache&lt;/a>, which adds a KV cache with multiple tiers: GPU HBM as L1, CPU RAM as L2, local disk as L3, and optionally remote storage as L4. When a KV cache block does not fit in HBM, instead of evicting and recomputing it, LMCache drops it to a lower tier. For workloads with shared prefixes and multi-turn conversations, the saving is huge.&lt;/p>
&lt;p>LMCache integrates as a sidecar to the engines and as part of the transport in disaggregated-prefill. Production Stack ships it enabled by default in its Helm chart.&lt;/p>
&lt;h3 id="a-typical-manifest-valuesyaml">A typical manifest (values.yaml)&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">servingEngineSpec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">modelSpec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">repository&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tag&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v0.6.3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">modelURL&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">meta-llama/Meta-Llama-3-8B-Instruct&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requestCPU&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requestMemory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">16Gi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requestGPU&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">vllmConfig&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enablePrefixCaching&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kvCacheDtype&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">fp8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxModelLen&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">32768&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enableChunkedPrefill&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">routerSpec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routingLogic&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prefix-aware &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># round-robin | session | prefix-aware | kv-aware&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sessionKey&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">x-user-id &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># when routingLogic=session&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">cacheserverSpec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># LMCache for tiered KV&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">storageBackends&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">cpu&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">disk &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># offload to local disk&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">observabilitySpec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">grafana&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">dashboards&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">vllm-engine-metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">lmcache-metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This is declarative but these are not CRDs: they are Helm chart values. The difference with OME is not semantic (both start from declaration) but operational: with Helm, changes go through &lt;code>helm upgrade&lt;/code>; with CRDs, they go through &lt;code>kubectl apply&lt;/code>. For teams already living in GitOps with Argo CD or Flux, both approaches integrate cleanly, but the flows are different.&lt;/p>
&lt;h3 id="when-to-choose-it-1">When to choose it&lt;/h3>
&lt;p>If vLLM is your only runtime and you want the closest thing to &amp;ldquo;the happy path the project recommends&amp;rdquo;, this one. It is the productionised version maintained by the same people who write the engine. The downsides: it ties you to vLLM (it is not generic) and it does not solve some advanced cases such as multi-tenancy with strict quotas or gang scheduling, where OME or full-fledged operators are better.&lt;/p>
&lt;h2 id="nvidia-dynamo">NVIDIA Dynamo&lt;/h2>
&lt;p>&lt;a href="https://developer.nvidia.com/dynamo">NVIDIA Dynamo&lt;/a> is the official successor to Triton Inference Server, announced at GTC 2025 and merged into the brand as Dynamo-Triton in March of that year. Triton had spent years as the most-used inference engine in &amp;ldquo;serious&amp;rdquo; NVIDIA infrastructures; Dynamo is what NVIDIA believes the new generation needs.&lt;/p>
&lt;h3 id="what-it-is-exactly">What it is exactly&lt;/h3>
&lt;p>Dynamo is a distributed inference framework, not exactly a Kubernetes operator. It has its own runtime (it can run engines), a scheduler (Grove), intelligent routing, multi-tier KV cache management and disaggregation. It supports SGLang, TensorRT-LLM and vLLM as engines, but the engines are executed by Dynamo, not the other way round: the model is &amp;ldquo;Dynamo manages, the engine executes&amp;rdquo;.&lt;/p>
&lt;p>On Kubernetes, Dynamo is deployed via its own operator plus CRDs, normalised with the K8s integration NVIDIA formalised at the end of 2025 (covered by &lt;a href="https://www.infoq.com/news/2025/12/nvidia-dynamo-kubernetes/">this InfoQ piece&lt;/a>). The CRDs are product-specific: they define a &lt;code>DynamoCluster&lt;/code>, a topology of prefill/decode workers and a routing policy.&lt;/p>
&lt;h3 id="the-four-contributions">The four contributions&lt;/h3>
&lt;p>Dynamo is sold on four pillars, with figures reported by NVIDIA:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Disaggregated serving&lt;/strong> built in, with its own scheduler.&lt;/li>
&lt;li>&lt;strong>Smart routing&lt;/strong> based on cache state: if a worker already has most of a prompt cached, the request goes there.&lt;/li>
&lt;li>&lt;strong>Multi-tier KV cache&lt;/strong>: analogous to LMCache, with HBM/RAM/SSD/NVMe.&lt;/li>
&lt;li>&lt;strong>Autoscaling&lt;/strong> integrated with Dynamo&amp;rsquo;s scheduler.&lt;/li>
&lt;/ol>
&lt;p>The marketing figure: up to 30× more throughput than legacy Triton on the same hardware. With all the caution a vendor benchmark deserves.&lt;/p>
&lt;h3 id="grove-its-own-scheduler">Grove: its own scheduler&lt;/h3>
&lt;p>A controversial decision in Dynamo is not to rely 100% on the Kubernetes scheduler and instead bring its own scheduler, called Grove, that understands GPU topologies. Grove decides which worker runs on which physical GPU, which interconnects (NVLink/InfiniBand) are relevant, and how to distribute tensor parallel across nodes. That gives it more control than the standard kube-scheduler.&lt;/p>
&lt;p>Operationally: if your cluster is &amp;ldquo;pure Kubernetes&amp;rdquo; with kube-scheduler and heterogeneous workloads (not only LLMs), Grove adds one more component to operate. If your cluster is dedicated to LLM inference and there is already a team dedicated to running it, Grove gives you more levers.&lt;/p>
&lt;h3 id="when-to-choose-it-2">When to choose it&lt;/h3>
&lt;p>Dynamo makes sense if:&lt;/p>
&lt;ul>
&lt;li>Your infrastructure is NVIDIA-heavy (Hopper, Blackwell, GB200) and you want to exploit the latest in TensorRT-LLM with the familiar Triton integration, modernised.&lt;/li>
&lt;li>You were already a Triton user for legacy inference (vision, recommendation) and want to keep the ecosystem.&lt;/li>
&lt;li>You have a dedicated SRE team for inference and Grove&amp;rsquo;s extra operational complexity is not a problem.&lt;/li>
&lt;/ul>
&lt;p>It is the vendor-specific option of the quartet. In exchange it gives you NVIDIA&amp;rsquo;s support and first-rate integration with their hardware. If your organisation is already fighting NVIDIA for GPUs, they may even call you offering help with Dynamo.&lt;/p>
&lt;h2 id="llm-d">llm-d&lt;/h2>
&lt;p>&lt;a href="https://github.com/llm-d/llm-d">llm-d&lt;/a> is the youngest and the most &amp;ldquo;political&amp;rdquo; of the four. In March 2026, at &lt;a href="https://siliconangle.com/2026/03/24/red-hat-bets-big-kubernetes-inference-llm-d-kubeconeu/">KubeCon Europe Amsterdam&lt;/a>, Red Hat, Google Cloud, IBM Research, CoreWeave and NVIDIA announced the joint donation of the project to the CNCF as a Sandbox project, with backing from AMD, Cisco, Hugging Face, Intel, Lambda, Mistral AI, UC Berkeley and the University of Chicago. An explicit coalition for vendor neutrality.&lt;/p>
&lt;h3 id="philosophy">Philosophy&lt;/h3>
&lt;p>llm-d positions itself as the vendor-neutral &amp;ldquo;Kubernetes blueprint&amp;rdquo; for distributed inference. It is not a runtime; it is a system that sits on top of vLLM (the default engine) and orchestrates the control plane.&lt;/p>
&lt;p>The primitives the project puts on the table:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Intelligent routing&lt;/strong> with prefix-cache awareness and load-aware balancing.&lt;/li>
&lt;li>&lt;strong>Tiered KV cache&lt;/strong> with offload to CPU and disk for multi-turn.&lt;/li>
&lt;li>&lt;strong>Prefill/decode disaggregation&lt;/strong> over fast interconnects.&lt;/li>
&lt;li>&lt;strong>Wide expert parallelism&lt;/strong> for serving very large Mixture-of-Experts (MoE) models, a critical pattern popularised by DeepSeek-V3 and Mixtral, where the experts live on different GPUs and tokens have to be routed to the right expert.&lt;/li>
&lt;/ul>
&lt;h3 id="numbers">Numbers&lt;/h3>
&lt;p>The &lt;a href="https://github.com/llm-d/llm-d/releases">v0.5 release&lt;/a> validates ~3.1k tok/s per decode GPU on B200, and up to 50k output tok/s on a 16×16 B200 prefill/decode topology. The most interesting benchmark: an order-of-magnitude reduction in TTFT against a round-robin baseline. In other words, intelligent routing is worth what they say it is.&lt;/p>
&lt;h3 id="cncf-and-the-future">CNCF and the future&lt;/h3>
&lt;p>Donating to the CNCF as a Sandbox project means neutral governance: no vendor is in charge. For an organisation wary of being tied to a single supplier, llm-d is probably the safest medium-term bet. The price: like any Sandbox project, it is not yet &amp;ldquo;boring&amp;rdquo; in the way vLLM is. There is API churn, features that move around, documentation that lags the code.&lt;/p>
&lt;h3 id="when-to-choose-it-3">When to choose it&lt;/h3>
&lt;p>llm-d makes sense if:&lt;/p>
&lt;ul>
&lt;li>You want multi-vendor portability without ties to NVIDIA, Red Hat or Google.&lt;/li>
&lt;li>Your workload includes large MoE models (DeepSeek-V3, Mixtral 8x22B, Llama 4 Behemoth if the size is confirmed), where wide expert parallelism is decisive.&lt;/li>
&lt;li>Your organisation is already comfortable with CNCF Sandbox (projects under active evolution, not yet a stable 1.0).&lt;/li>
&lt;li>You want to bet on the project that will probably be the de facto standard in 2-3 years.&lt;/li>
&lt;/ul>
&lt;h2 id="the-common-ancestor-is-still-there-kserve">The common ancestor is still there: KServe&lt;/h2>
&lt;p>It is worth reconnecting before the comparison: KServe is still alive and heavily used in organisations that serve both LLMs and traditional models (scikit-learn, XGBoost, PyTorch CV). Its &lt;code>InferenceService&lt;/code> is generic enough to serve any model, including vLLM or SGLang as a &lt;code>ServingRuntime&lt;/code>. What it does not do well is the LLM-specific part: disaggregation, multi-node tensor parallel, routing with KV cache awareness. If your organisation already has KServe in production for other models, adding an LLM-specific operator alongside it (OME, vLLM Stack or llm-d) is reasonable. Fighting it all out from pure KServe is not.&lt;/p>
&lt;h2 id="decision-map">Decision map&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dimension&lt;/th>
&lt;th>OME&lt;/th>
&lt;th>vLLM Prod Stack&lt;/th>
&lt;th>NVIDIA Dynamo&lt;/th>
&lt;th>llm-d&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Philosophy&lt;/strong>&lt;/td>
&lt;td>Classic K8s-idiomatic operator&lt;/td>
&lt;td>Curated Helm chart&lt;/td>
&lt;td>Framework with its own scheduler&lt;/td>
&lt;td>Vendor-neutral CNCF blueprint&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Own CRDs&lt;/strong>&lt;/td>
&lt;td>Yes (BaseModel, ServingRuntime, InferenceService&amp;hellip;)&lt;/td>
&lt;td>No (Helm values)&lt;/td>
&lt;td>Yes (DynamoCluster)&lt;/td>
&lt;td>Yes (KServe-derived + extensions)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Primary runtime&lt;/strong>&lt;/td>
&lt;td>SGLang (first class), also vLLM/TRT-LLM/Triton&lt;/td>
&lt;td>vLLM only&lt;/td>
&lt;td>TensorRT-LLM (first class), also SGLang/vLLM&lt;/td>
&lt;td>vLLM (first class)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>PD-disaggregation&lt;/strong>&lt;/td>
&lt;td>Yes, declarative&lt;/td>
&lt;td>Yes, with LMCache&lt;/td>
&lt;td>Yes, own scheduler&lt;/td>
&lt;td>Yes, native&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Multi-node TP&lt;/strong>&lt;/td>
&lt;td>Yes, via LWS&lt;/td>
&lt;td>Limited&lt;/td>
&lt;td>Yes, via Grove&lt;/td>
&lt;td>Yes, via LWS and MoE EP&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Multi-model in a cluster&lt;/strong>&lt;/td>
&lt;td>Yes, mature multi-tenant&lt;/td>
&lt;td>Yes (list of models in values)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Multi-LoRA&lt;/strong>&lt;/td>
&lt;td>Yes, first class (FineTunedWeight CRD)&lt;/td>
&lt;td>Limited&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>On the roadmap&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Tiered KV cache&lt;/strong>&lt;/td>
&lt;td>Via LMCache (external integration)&lt;/td>
&lt;td>Native LMCache&lt;/td>
&lt;td>Own multi-tier&lt;/td>
&lt;td>Yes, native&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Intelligent routing&lt;/strong>&lt;/td>
&lt;td>Cache-aware via the SGLang router&lt;/td>
&lt;td>Prefix-aware / KV-aware / session-based&lt;/td>
&lt;td>Own smart routing&lt;/td>
&lt;td>Prefix-cache + load-aware&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>GPU scheduler&lt;/strong>&lt;/td>
&lt;td>kube-scheduler + Kueue&lt;/td>
&lt;td>kube-scheduler&lt;/td>
&lt;td>Grove (its own)&lt;/td>
&lt;td>kube-scheduler + Kueue&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Hardware&lt;/strong>&lt;/td>
&lt;td>NVIDIA, AMD ROCm, Intel&lt;/td>
&lt;td>NVIDIA, AMD ROCm&lt;/td>
&lt;td>NVIDIA only (with emphasis)&lt;/td>
&lt;td>NVIDIA, AMD, Intel — neutral&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Maturity (mid-2026)&lt;/strong>&lt;/td>
&lt;td>Young, evolving&lt;/td>
&lt;td>Stable&lt;/td>
&lt;td>Stable, vendor-driven&lt;/td>
&lt;td>CNCF Sandbox, evolving fast&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Governance&lt;/strong>&lt;/td>
&lt;td>LMSYS (academic-industrial)&lt;/td>
&lt;td>vLLM project (academic)&lt;/td>
&lt;td>NVIDIA (vendor)&lt;/td>
&lt;td>CNCF (neutral)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Learning curve&lt;/strong>&lt;/td>
&lt;td>Medium (4 new CRDs)&lt;/td>
&lt;td>Low (familiar Helm values)&lt;/td>
&lt;td>Medium-high (Grove + own CRDs)&lt;/td>
&lt;td>Medium (similar to extended KServe)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="when-to-choose-each-one">When to choose each one&lt;/h3>
&lt;p>&lt;strong>Choose OME&lt;/strong> if:&lt;/p>
&lt;ul>
&lt;li>SGLang is your main engine.&lt;/li>
&lt;li>You need multi-LoRA serving in production.&lt;/li>
&lt;li>The hierarchical abstraction (BaseModel → ServingRuntime → InferenceService) fits you and you come from, or live alongside, KServe.&lt;/li>
&lt;li>You have an appetite for a young and very active project.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Choose vLLM Production Stack&lt;/strong> if:&lt;/p>
&lt;ul>
&lt;li>vLLM is your only engine and you want to align with what the project recommends.&lt;/li>
&lt;li>Your team already lives in Helm and does not want to learn new CRDs.&lt;/li>
&lt;li>LMCache + advanced routing inside a single Helm chart is exactly what you need.&lt;/li>
&lt;li>Your scale is medium (tens of replicas), not extreme.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Choose NVIDIA Dynamo&lt;/strong> if:&lt;/p>
&lt;ul>
&lt;li>Your infrastructure is NVIDIA-heavy and you want the most optimised path for Hopper/Blackwell.&lt;/li>
&lt;li>You already ran Triton for legacy inference and the transition is natural.&lt;/li>
&lt;li>You accept vendor lock-in in exchange for direct NVIDIA support.&lt;/li>
&lt;li>Your organisation has a dedicated SRE team for inference.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Choose llm-d&lt;/strong> if:&lt;/p>
&lt;ul>
&lt;li>You want to bet on the future CNCF standard, neutral between vendors.&lt;/li>
&lt;li>Your workload includes large MoE models with wide expert parallelism.&lt;/li>
&lt;li>You operate multi-cloud or multi-hardware and portability is valuable.&lt;/li>
&lt;li>You accept the immaturity of a Sandbox project in exchange for the bet on the future.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Choose pure KServe&lt;/strong> if:&lt;/p>
&lt;ul>
&lt;li>You already serve non-LLM models and want to unify; LLMs are a minority of your workload.&lt;/li>
&lt;li>You need the most conservative and mature use case.&lt;/li>
&lt;li>You accept that advanced LLM features (disaggregation, MoE EP, smart routing) are yours to add with external pieces.&lt;/li>
&lt;/ul>
&lt;h3 id="concrete-scenarios">Concrete scenarios&lt;/h3>
&lt;p>&lt;strong>Scenario A — Small startup, 1-2 models, 1-3 GPU nodes.&lt;/strong> You probably do not need an operator. Deployment + Service + HPA with KEDA metrics, as in the vLLM on Kubernetes article. When you grow to 5+ models, reassess.&lt;/p>
&lt;p>&lt;strong>Scenario B — Mid-sized company, 5-15 models, internal multi-tenant.&lt;/strong> vLLM Production Stack or OME are the reasonable options. Production Stack if vLLM is all you are going to use; OME if you want runtime flexibility and idiomatic CRDs.&lt;/p>
&lt;p>&lt;strong>Scenario C — Internal corporate platform or an external service for end customers.&lt;/strong> llm-d or Dynamo. llm-d if you value vendor neutrality; Dynamo if you live on NVIDIA infrastructure and want the path they recommend.&lt;/p>
&lt;p>&lt;strong>Scenario D — Mixed cluster of LLMs plus traditional models.&lt;/strong> KServe as the base, with an LLM operator alongside (OME is the most natural given its conceptual kinship).&lt;/p>
&lt;h2 id="common-traps">Common traps&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;I&amp;rsquo;ll start with pure KServe because it&amp;rsquo;s mature&amp;rdquo;.&lt;/strong> For mid-sized LLMs and up, pure KServe leaves a lot of optimisation on the table. The sensible route is KServe as the base if you live alongside other models, but with an LLM-specific operator next to it.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;I&amp;rsquo;ll wire everything by hand to understand it&amp;rdquo;.&lt;/strong> Reasonable in a PoC, suicidal in production. There are 8 derived resources per model. Multiply by 10 models. You are writing 80 YAMLs and maintaining them. Use an operator.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;I&amp;rsquo;ll pick the one I like best and pivot if I&amp;rsquo;m wrong&amp;rdquo;.&lt;/strong> Pivoting between operators is not free: although the &lt;code>InferenceService&lt;/code> abstraction is converging, the details (how LoRA is modelled, how routing is configured, how metrics are exposed) vary. Migrating from OME to Dynamo is a project of weeks, not days.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;I&amp;rsquo;ll go with Dynamo because it&amp;rsquo;s NVIDIA&amp;rsquo;s and therefore better&amp;rdquo;.&lt;/strong> Only if your organisation is already aligned with its operational philosophy (its own scheduler, acceptable vendor lock-in). For many cases, vLLM Production Stack or llm-d give 95% of the value with less friction.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Helm chart vs operator is a technical decision&amp;rdquo;.&lt;/strong> It is a cultural and operational decision. If your team ships via Argo CD with Helm values in Git, Production Stack fits without friction. If your team lives in direct &lt;code>kubectl apply -f&lt;/code> and the idea of operators feels natural, OME or llm-d.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://github.com/kvcache-ai/Mooncake">Mooncake&lt;/a>&lt;/strong>: the KV cache system shared between instances that Kimi/Moonshot runs in production across hundreds of millions of queries. It is a primitive (not a complete operator), but it integrates as a cache tier with several of the above.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://docs.ray.io/en/latest/serve/llm/serving-llms.html">Ray Serve LLM&lt;/a>&lt;/strong>: Anyscale&amp;rsquo;s offering, on Kubernetes through KubeRay. More tied to the Ray ecosystem than to native K8s CRDs. Useful if Ray is already part of your infrastructure.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://fireworks.ai/">Fireworks AI&lt;/a>, &lt;a href="https://www.modular.com/">Modular MAX&lt;/a>&lt;/strong>: commercial platforms with similar primitives, but hosted. They are not K8s operators; they are competitors at another layer.&lt;/li>
&lt;li>&lt;strong>Gateway API Inference Extension&lt;/strong>: the sigwg proposal to extend Gateway API with LLM primitives (model-aware routing, sticky sessions, fairness). In 2026 it is in alpha; the operators above are already starting to support it. When it matures, routing will stop being each operator&amp;rsquo;s problem and become part of the Kubernetes standard.&lt;/li>
&lt;li>&lt;strong>A generic inference observability stack&lt;/strong>: Prometheus + Grafana is standardising around the &lt;code>vllm:*&lt;/code> metrics we covered in the vLLM article. There is an OpenTelemetry effort for LLMs (&lt;code>gen-ai&lt;/code> semantic conventions) that is probably the next link in the chain.&lt;/li>
&lt;/ul>
&lt;h2 id="closing-the-series">Closing the series&lt;/h2>
&lt;p>This series of four articles has walked through LLM inference in production from the bottom up:&lt;/p>
&lt;ol>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory that holds up LLM inference&lt;/a> — why each token consumes VRAM, and how much.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes: the LLM inference piece that actually scales&lt;/a> — how a model is served in production with a serious Deployment.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention from the inside and the state of the art of the KV cache in 2026&lt;/a> — what happens inside the engine at block level, and what has arrived since.&lt;/li>
&lt;li>This one — how many models are orchestrated in a cluster.&lt;/li>
&lt;/ol>
&lt;p>If you have got this far, you have the vocabulary and the map to sit in a meeting where five people throw acronyms around and place each one correctly. And, more importantly, to start making reasoned decisions about where to begin.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/">Chain of trust for the model (1/4): KServe and the Open Inference Protocol&lt;/a> — the KServe deep dive, the common ancestor this post cites: InferenceService, ServingRuntime, deployment modes and the V2 API contract against the OpenAI API.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/">Chain of trust for the model (2/4): registry, OCI artifacts and distribution&lt;/a> — where the &lt;code>storageUri&lt;/code> of any of these operators gets its weights from.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>Operators and projects covered:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/ome-projects/ome">OME — Open Model Engine (GitHub)&lt;/a> — LMSYS operator for LLM serving with SGLang/vLLM/TRT-LLM/Triton.&lt;/li>
&lt;li>&lt;a href="https://www.lmsys.org/blog/2025-07-08-ome/">Introducing OME (LMSYS Blog, jul 2025)&lt;/a> — announcement and architecture.&lt;/li>
&lt;li>&lt;a href="https://github.com/vllm-project/production-stack">vLLM Production Stack (GitHub)&lt;/a> — official vLLM Helm chart for K8s.&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/projects/production-stack/en/latest/deployment/">vLLM Production Stack docs&lt;/a> — installation and configuration.&lt;/li>
&lt;li>&lt;a href="https://github.com/LMCache/LMCache">LMCache (GitHub)&lt;/a> — KV cache with tiers.&lt;/li>
&lt;li>&lt;a href="https://developer.nvidia.com/dynamo">NVIDIA Dynamo&lt;/a> — successor to Triton.&lt;/li>
&lt;li>&lt;a href="https://www.infoq.com/news/2025/12/nvidia-dynamo-kubernetes/">NVIDIA Dynamo Addresses Multi-Node LLM Inference Challenges (InfoQ, dic 2025)&lt;/a> — K8s integration.&lt;/li>
&lt;li>&lt;a href="https://github.com/llm-d/llm-d">llm-d (GitHub)&lt;/a> — CNCF Sandbox project.&lt;/li>
&lt;li>&lt;a href="https://thenewstack.io/llm-d-cncf-kubernetes-inference/">IBM, Red Hat, and Google donated llm-d to CNCF (The New Stack)&lt;/a> — KubeCon EU 2026 announcement.&lt;/li>
&lt;li>&lt;a href="https://siliconangle.com/2026/03/24/red-hat-bets-big-kubernetes-inference-llm-d-kubeconeu/">Red Hat bets big on Kubernetes inference with llm-d (SiliconANGLE, mar 2026)&lt;/a> — coverage of the announcement.&lt;/li>
&lt;/ul>
&lt;p>Ancestors and primitives:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://kserve.github.io/website/">KServe (site)&lt;/a> and &lt;a href="https://thenewstack.io/kserve-joins-cncf-to-standardize-ai-model-serving-on-kubernetes/">KServe joins CNCF (The New Stack)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://kueue.sigs.k8s.io/">Kueue&lt;/a> — gang scheduling.&lt;/li>
&lt;li>&lt;a href="https://lws.sigs.k8s.io/">LeaderWorkerSet&lt;/a> — coordinated workloads such as multi-pod tensor parallel.&lt;/li>
&lt;li>&lt;a href="https://keda.sh/">KEDA&lt;/a> — autoscaling on external metrics.&lt;/li>
&lt;li>&lt;a href="https://gateway-api.sigs.k8s.io/">Gateway API&lt;/a> — the successor to Ingress.&lt;/li>
&lt;/ul>
&lt;p>Analysis and perspectives:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://jimmysong.io/blog/cloud-native-llm-inference-stack/">Building Efficient LLM Inference with the Cloud Native Quartet: KServe, vLLM, llm-d, and WG Serving (Jimmy Song)&lt;/a> — an integrating view.&lt;/li>
&lt;li>&lt;a href="https://dev.to/x4nent/complete-guide-to-llm-d-cncf-sandbox-kubernetes-native-distributed-llm-inference-1imj">Complete Guide to llm-d CNCF Sandbox (DEV Community)&lt;/a> — an operational walkthrough.&lt;/li>
&lt;li>Previous articles on this blog: &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention deep dive&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>vLLM on Kubernetes: the LLM inference piece that actually scales</title><link>https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/</link><pubDate>Mon, 18 May 2026 13:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>vLLM is the inference engine that turns a general-purpose GPU into a productive LLM server. Its value is not in running a model, since any &lt;code>transformers.pipeline&lt;/code> does that in three lines of Python, but in squeezing the GPU down to the last gigabyte and the last cycle: PagedAttention for the KV cache, &lt;em>continuous batching&lt;/em> to mix requests, its own scheduler to share GPU time between sessions. Kubernetes is its natural habitat because vLLM behaves like a modern UNIX process, with a health endpoint, Prometheus metrics, orderly draining and declarable resources, and K8s already knows how to manage those. But there are traps: the standard HPA does not scale vLLM well, the model takes minutes to load, and naive rolling updates cut sessions halfway through decoding. This article takes the engine apart and then fits it, with real manifests, into a cluster that can actually serve it.&lt;/p>
&lt;blockquote>
&lt;p>This article is the natural continuation of &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory that holds up LLM inference&lt;/a>. There we explained why every token consumes VRAM. Here we look at what you do with that VRAM when you want to offer it as a service.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-a-multiprocess-kernel-for-your-gpu">The analogy: a multiprocess kernel for your GPU&lt;/h2>
&lt;p>Imagine you have a single processor and need to serve a hundred concurrent processes without any of them blocking the rest. Nobody in their right mind would write a &lt;code>while-true&lt;/code> loop dispatching processes one by one: they would install an operating system. The kernel takes care of scheduling, memory paging, isolation, priorities and cleanup on exit. The &amp;ldquo;process&amp;rdquo; becomes a convenient abstraction and the kernel does the dirty work.&lt;/p>
&lt;p>vLLM is, for your GPU, what the kernel is for your CPU. As far as the GPU is concerned, a conversation with an LLM is a process that lives across many decoding steps, occupies a slice of VRAM (its KV cache) and demands compute time every time a token has to be generated. You have a hundred of those processes at once. You need to:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Share GPU time between them&lt;/strong> without pausing any of them entirely (it would be disastrous if one long conversation monopolised the GPU).&lt;/li>
&lt;li>&lt;strong>Manage memory with paging&lt;/strong> because, just as in RAM, contiguous allocation is inefficient.&lt;/li>
&lt;li>&lt;strong>Queue new requests&lt;/strong> when the GPU is saturated and serve them in a reasonable order.&lt;/li>
&lt;li>&lt;strong>Reclaim resources&lt;/strong> when a session ends.&lt;/li>
&lt;/ul>
&lt;p>PagedAttention is the virtual memory of the KV cache. &lt;em>Continuous batching&lt;/em> is the time-slicing scheduler that shares the GPU token by token. The OpenAI-compatible server is the uniform syscall interface. Calling it a &amp;ldquo;kernel&amp;rdquo; for the GPU is marketing, but it is marketing that captures the idea well.&lt;/p>
&lt;h2 id="what-vllm-does-inside">What vLLM does inside&lt;/h2>
&lt;h3 id="continuous-batching-stop-waiting-for-the-slowest">Continuous batching: stop waiting for the slowest&lt;/h3>
&lt;p>The naive inference engine does &lt;em>static batching&lt;/em>: it groups N requests, processes them until all of them are finished, returns and starts another round. The problem is obvious: if one request asks for 8 tokens and another asks for 800, the other seven wait for the slow one. GPU utilisation collapses.&lt;/p>
&lt;p>&lt;em>Continuous batching&lt;/em> (Yu et al., 2022, popularised by vLLM) changes the model. At every decode step, which produces one token for each active session, the engine composes the batch out of the active tokens of ALL sessions alive at that instant. When a session finishes its generation it releases its slot immediately and another request from the queue takes it. The batch never waits for the slowest session because nobody is blocked: everyone advances at one token per step.&lt;/p>
&lt;p>The original paper measured 5–23× more throughput than the equivalent static batching. The exact number depends on how variable the response lengths are, but the order of magnitude holds up in practice.&lt;/p>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 240" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Static vs continuous batching">
&lt;style>.title{font:600 13px sans-serif;fill:#222}.lbl{font:11px sans-serif;fill:#444}.s1{fill:#2a9d8f}.s2{fill:#e76f51}.s3{fill:#264653}.s4{fill:#e9c46a}.empty{fill:#eee;stroke:#999;stroke-dasharray:3 2}&lt;/style>
&lt;text x="180" y="20" text-anchor="middle" class="title">Static batching&lt;/text>
&lt;text x="540" y="20" text-anchor="middle" class="title">Continuous batching&lt;/text>
&lt;text x="20" y="55" class="lbl">session 1&lt;/text>
&lt;text x="20" y="80" class="lbl">session 2&lt;/text>
&lt;text x="20" y="105" class="lbl">session 3&lt;/text>
&lt;text x="20" y="130" class="lbl">session 4&lt;/text>
&lt;rect x="70" y="40" width="40" height="20" class="s1"/>
&lt;rect x="70" y="65" width="120" height="20" class="s2"/>
&lt;rect x="70" y="90" width="60" height="20" class="s3"/>
&lt;rect x="70" y="115" width="30" height="20" class="s4"/>
&lt;rect x="110" y="40" width="80" height="20" class="empty"/>
&lt;rect x="130" y="90" width="60" height="20" class="empty"/>
&lt;rect x="100" y="115" width="90" height="20" class="empty"/>
&lt;text x="180" y="160" text-anchor="middle" class="lbl">empty slots wait for session 2&lt;/text>
&lt;rect x="380" y="40" width="40" height="20" class="s1"/>
&lt;rect x="420" y="40" width="80" height="20" class="s3"/>
&lt;rect x="500" y="40" width="40" height="20" class="s4"/>
&lt;rect x="540" y="40" width="40" height="20" class="s1"/>
&lt;rect x="380" y="65" width="120" height="20" class="s2"/>
&lt;rect x="500" y="65" width="40" height="20" class="s3"/>
&lt;rect x="540" y="65" width="80" height="20" class="s4"/>
&lt;rect x="380" y="90" width="60" height="20" class="s3"/>
&lt;rect x="440" y="90" width="50" height="20" class="s2"/>
&lt;rect x="490" y="90" width="40" height="20" class="s4"/>
&lt;rect x="530" y="90" width="100" height="20" class="s1"/>
&lt;rect x="380" y="115" width="30" height="20" class="s4"/>
&lt;rect x="410" y="115" width="80" height="20" class="s2"/>
&lt;rect x="490" y="115" width="60" height="20" class="s3"/>
&lt;rect x="550" y="115" width="80" height="20" class="s1"/>
&lt;text x="540" y="160" text-anchor="middle" class="lbl">slots reassigned token by token&lt;/text>
&lt;line x1="70" y1="190" x2="630" y2="190" stroke="#666"/>
&lt;text x="350" y="210" text-anchor="middle" class="lbl">time →&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The consequence for the operator is counterintuitive: &lt;strong>a single vLLM replica performs like three naive replicas&lt;/strong>. There is no point adding pods without justifying it with real metrics.&lt;/p>
&lt;h3 id="pagedattention-virtual-memory-for-the-kv-cache">PagedAttention: virtual memory for the KV cache&lt;/h3>
&lt;p>We already flagged it in the KV cache article: the naive engine reserves one contiguous block per session, sized for the &lt;em>worst case&lt;/em> (&lt;code>max_context_len&lt;/code>), and wastes 60–80% of the VRAM because real sessions come nowhere near their ceiling.&lt;/p>
&lt;p>PagedAttention borrows the solution operating systems have been using for half a century: split the VRAM into small blocks (16 tokens in the default implementation) and keep a logical → physical page table per session. A session with 273 tokens of context occupies 18 blocks (not necessarily contiguous), and grows block by block as it generates. The paper measured &amp;lt;4% waste, an order of magnitude better than contiguous allocation, and that translates into 2–4× more aggregate throughput on the same hardware, because more sessions fit at once.&lt;/p>
&lt;p>There is a cost: every attention operation has to be indirected through the page table. But vLLM&amp;rsquo;s CUDA kernels are written so that this indirection is cheap, and the net result is massively positive.&lt;/p>
&lt;h3 id="prefill-vs-decode-two-phases-with-opposite-profiles">Prefill vs decode: two phases with opposite profiles&lt;/h3>
&lt;p>An LLM request has two phases with radically different GPU profiles:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Prefill&lt;/strong>: processes the entire prompt in one go. It is &lt;em>compute-bound&lt;/em>: it uses the tensor cores intensively, the GPU sits above 90%, and it lasts between hundreds of ms and a few seconds depending on prompt size.&lt;/li>
&lt;li>&lt;strong>Decode&lt;/strong>: generates token by token. It is &lt;em>memory-bound&lt;/em>: the compute is modest but the whole KV cache has to be read for every token, and it lasts from tens of ms per token up to minutes for long responses.&lt;/li>
&lt;/ul>
&lt;p>A naive server treats each request as a unit and serves the two phases in series. vLLM decouples them: it mixes requests in prefill with requests in decode in the same step (a technique called &lt;em>chunked prefill&lt;/em> when it also splits long prefills into chunks). The result: the GPU is always busy doing &lt;em>something&lt;/em>, the tensor cores with prefills, the HBM bandwidth with decodes, instead of oscillating between phases.&lt;/p>
&lt;p>Operational implication: the &amp;ldquo;% GPU utilisation&amp;rdquo; metric from &lt;code>nvidia-smi&lt;/code> is misleading. A GPU at 100% doing prefills may have its HBM bandwidth idle. A GPU at 40% doing decodes may have its HBM saturated. For LLM serving, &lt;strong>the useful metric is effective HBM bandwidth&lt;/strong>, not the compute percentage.&lt;/p>
&lt;h3 id="tensor-parallel-when-the-model-does-not-fit-on-one-gpu">Tensor parallel: when the model does not fit on one GPU&lt;/h3>
&lt;p>Llama 3 70B in BF16 is ~140 GB. There is no single GPU on the market that can hold it. The solution is tensor parallel: split each model layer by columns and run the partitions on N GPUs in parallel, synchronising with an &lt;em>all-reduce&lt;/em> after each layer.&lt;/p>
&lt;p>For N=5 GPUs and a 70B model, each GPU sees roughly 28 GB of weights. That sounds fine until you remember that the all-reduce on every layer means reading and writing large tensors between GPUs. If the GPUs share NVLink/NVSwitch (300–900 GB/s), the all-reduce is cheap. If they only share PCIe (~32 GB/s gen4 x16), the all-reduce eats half the time and throughput collapses.&lt;/p>
&lt;p>The implication for K8s, which comes next: the scheduler has to guarantee that the N GPUs are physically close. That translates into NodeAffinity to the right product (&lt;code>NVIDIA-H100-80GB-HBM3&lt;/code>), a single pod with &lt;code>nvidia.com/gpu: N&lt;/code> (not N pods sharing) and, if multi-node is unavoidable, InfiniBand with NCCL as transport.&lt;/p>
&lt;h3 id="the-openai-compatible-server">The OpenAI-compatible server&lt;/h3>
&lt;p>On top of all of the above, vLLM exposes an HTTP server with endpoints identical to OpenAI&amp;rsquo;s: &lt;code>/v1/chat/completions&lt;/code>, &lt;code>/v1/completions&lt;/code>, &lt;code>/v1/embeddings&lt;/code>, &lt;code>/v1/models&lt;/code>. It supports Server-Sent Events streaming. It supports tool calling. It supports logprobs.&lt;/p>
&lt;p>The value of this is enormous and underrated: &lt;strong>any client using the OpenAI SDK works without changes&lt;/strong>. Your application points at &lt;code>https://vllm.your-cluster.local/v1&lt;/code> instead of &lt;code>https://api.openai.com/v1&lt;/code>, and everything else, the LangChain, LlamaIndex, OpenAI Python and OpenAI JS SDKs, just works. It is the main reason vLLM has gained traction over technically comparable alternatives: &lt;strong>it is the boring option that works&lt;/strong>.&lt;/p>
&lt;h2 id="why-kubernetes-is-the-natural-habitat">Why Kubernetes is the natural habitat&lt;/h2>
&lt;p>vLLM is a well-behaved process: it starts, exposes metrics, serves a health endpoint, takes SIGTERM with dignity and declares the resources it needs. Kubernetes has spent ten years perfecting the management of processes like that. The only thing K8s took a while to absorb properly was the GPU, and that is now solved.&lt;/p>
&lt;h3 id="the-gpu-as-a-primitive-resource">The GPU as a primitive resource&lt;/h3>
&lt;p>The plumbing goes like this:&lt;/p>
&lt;ol>
&lt;li>The node has the NVIDIA driver installed (or the GPU Operator installs it).&lt;/li>
&lt;li>A DaemonSet, nvidia-device-plugin, registers the physical GPUs as &lt;code>nvidia.com/gpu&lt;/code> resources with kubelet.&lt;/li>
&lt;li>The Kubernetes scheduler sees those resources the way it sees CPU and memory, puts them in its accounting and assigns them to Pods that ask for them.&lt;/li>
&lt;li>The nvidia-container-toolkit makes sure containerd injects the right devices into the container at startup.&lt;/li>
&lt;/ol>
&lt;p>For the pod, asking for a GPU looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Without MIG, MPS or time-slicing configured, &lt;strong>a GPU is not shared between pods&lt;/strong>: you ask for it whole or you do not ask for it. For vLLM, which wants the entire GPU to itself, that is exactly what you want.&lt;/p>
&lt;h3 id="the-vllm-pod-lifecycle">The vLLM Pod lifecycle&lt;/h3>
&lt;p>Differences from a typical webapp Pod:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Long startup&lt;/strong>. Loading 16 GB of weights into VRAM over the network takes 30 seconds in the best case and 5 minutes in the worst. A &lt;code>readinessProbe&lt;/code> with &lt;code>initialDelaySeconds: 30&lt;/code> and &lt;code>failureThreshold: 3&lt;/code> kills the pod before it starts. Solution: a &lt;code>startupProbe&lt;/code> with a high threshold before the &lt;code>livenessProbe&lt;/code> starts evaluating.&lt;/li>
&lt;li>&lt;strong>Warm-up matters&lt;/strong>. The first prefill compiles CUDA kernels specific to the input shape. The first 2–3 requests are noticeably slower. If latency matters from second one, it is worth firing a warm-up POST after ready.&lt;/li>
&lt;li>&lt;strong>Draining is not instantaneous&lt;/strong>. SIGTERM must not kill in-flight sessions. vLLM, configured with &lt;code>--disable-graceful-shutdown false&lt;/code> (the default), finishes active requests before closing. That can take 30–180 seconds. &lt;code>terminationGracePeriodSeconds&lt;/code> has to accommodate it.&lt;/li>
&lt;li>&lt;strong>Hostile rollouts&lt;/strong>. A naive rolling update (&lt;code>maxUnavailable: 1&lt;/code>) can leave you with no replicas serving if the new one is slow to load. Set &lt;code>maxSurge: 1, maxUnavailable: 0&lt;/code> so the new pod is Ready before the old one is drained.&lt;/li>
&lt;/ul>
&lt;h2 id="anatomy-of-a-serious-deployment">Anatomy of a serious deployment&lt;/h2>
&lt;h3 id="first-of-all-gpu-operator">First of all: GPU Operator&lt;/h3>
&lt;p>Without the GPU Operator (or an equivalent manual installation), a Pod with &lt;code>nvidia.com/gpu: 1&lt;/code> stays Pending forever. What the operator installs as DaemonSets on every GPU node:&lt;/p>
&lt;ul>
&lt;li>&lt;code>nvidia-driver-daemonset&lt;/code> — the kernel-mode driver (if you do not have it installed at host level).&lt;/li>
&lt;li>&lt;code>nvidia-device-plugin-daemonset&lt;/code> — registers the GPUs as a kubelet resource.&lt;/li>
&lt;li>&lt;code>nvidia-container-toolkit-daemonset&lt;/code> — the containerd integration.&lt;/li>
&lt;li>&lt;code>nvidia-dcgm-exporter&lt;/code> — Prometheus metrics for the GPU (utilisation, temperature, ECC errors, memory).&lt;/li>
&lt;li>&lt;code>gpu-feature-discovery&lt;/code> — node labels: &lt;code>nvidia.com/gpu.product&lt;/code>, &lt;code>nvidia.com/gpu.memory&lt;/code>, and so on, essential for NodeAffinity.&lt;/li>
&lt;/ul>
&lt;p>The recommended installation is the official Helm chart. The delicate part is aligning the driver with the host kernel version: if the nodes run kernel 6.x, the operator needs a compatible driver branch.&lt;/p>
&lt;h3 id="a-complete-annotated-vllm-deployment">A complete, annotated vLLM Deployment&lt;/h3>
&lt;p>The following deploys Llama 3 8B with an FP8-quantised KV cache, up to 32K of context, on an RTX 4090. It is the reference manifest; the comments explain the non-obvious decisions.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apps/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Deployment&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">RollingUpdate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rollingUpdate&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxSurge&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxUnavailable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># never run out of replicas during the rollout&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus.io/scrape&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;true&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus.io/port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;8000&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus.io/path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;/metrics&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Only nodes with the GPU we expect&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu.product&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NVIDIA-GeForce-RTX-4090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tolerations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nvidia.com/gpu&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Exists&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Pre-download the weights if they are not on the shared PVC&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">initContainers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">model-download&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ghcr.io/huggingface/huggingface-cli:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sh&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;-c&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> if [ ! -f /models/llama-3-8b/config.json ]; then
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> huggingface-cli download meta-llama/Meta-Llama-3-8B-Instruct \
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --local-dir /models/llama-3-8b --local-dir-use-symlinks False
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> fi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">HF_TOKEN&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">valueFrom&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">secretKeyRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">huggingface&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">token&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:v0.6.3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">model=/models/llama-3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">served-model-name=llama-3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">tensor-parallel-size=1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">max-model-len=32768&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">kv-cache-dtype=fp8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">enable-chunked-prefill&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">enable-prefix-caching&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">gpu-memory-utilization=0.92&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">port=8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containerPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containerPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># same port as http; /metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">8Gi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;8&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">16Gi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">startupProbe&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/health&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">failureThreshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 10 min of grace to load the model&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">readinessProbe&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/health&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">livenessProbe&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/health&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">20&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">failureThreshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">readOnly&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># no process should write here at runtime&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">shm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/dev/shm &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># vLLM uses shared memory for IPC between workers&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">persistentVolumeClaim&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">claimName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">model-cache&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">shm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">emptyDir&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">medium&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Memory&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sizeLimit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">4Gi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">terminationGracePeriodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># accommodates draining of active sessions&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Service&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">80&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">targetPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Five things that are not visible on a first read:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>&lt;code>/dev/shm&lt;/code> in memory, 4 GB&lt;/strong>. vLLM launches worker processes (one per GPU in tensor parallel, plus the driver) that communicate over shared memory. Docker&amp;rsquo;s default (64 MB) blows up as soon as the model is mid-sized. Without this, the pod starts but fails the moment it serves the first complex request.&lt;/li>
&lt;li>&lt;strong>&lt;code>--enable-prefix-caching&lt;/code>&lt;/strong>. If the prompts in your workload share structure (a common system prompt, few-shot examples), vLLM reuses the KV cache of the shared part. A free 30–60% gain in TTFT.&lt;/li>
&lt;li>&lt;strong>&lt;code>--gpu-memory-utilization=0.92&lt;/code>&lt;/strong>. vLLM reserves the given percentage of the VRAM for itself. The remaining 8% leaves room for activations, CUDA kernels and the overhead that does not get counted. Lowering it buys safety; raising it above 0.95 invites OOM.&lt;/li>
&lt;li>&lt;strong>A &lt;code>ReadOnlyMany&lt;/code> PVC&lt;/strong> is ideal. The model does not change at runtime. Several pods can mount the same PVC without contention.&lt;/li>
&lt;li>&lt;strong>No &lt;code>livenessProbe&lt;/code> shorter than the &lt;code>terminationGracePeriodSeconds&lt;/code>&lt;/strong>. If a drain takes 90s and liveness kills at 60s, rollouts lose sessions.&lt;/li>
&lt;/ol>
&lt;h3 id="multi-pod-tensor-parallel-leaderworkerset">Multi-pod tensor parallel: LeaderWorkerSet&lt;/h3>
&lt;p>When the model needs more GPUs than a single node has, the pattern is a group of coordinated pods, one per GPU, that behave as a single replica. For years this was modelled with a StatefulSet plus init scripts; since Kubernetes 1.32, the idiomatic primitive is LeaderWorkerSet (LWS):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">leaderworkerset.x-k8s.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LeaderWorkerSet&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">leaderWorkerTemplate&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">size&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 1 leader + 4 workers = 5 pods, 5 GPUs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">restartPolicy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">RecreateGroupOnPodRestart&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">leaderTemplate&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu.product&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NVIDIA-H100-80GB-HBM3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-leader&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:v0.6.3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">model=/models/llama-3-70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">tensor-parallel-size=5&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">distributed-executor-backend=ray&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># ...&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">workerTemplate&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu.product&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NVIDIA-H100-80GB-HBM3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-worker&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:v0.6.3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># the workers join the leader&amp;#39;s Ray cluster&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>LWS guarantees startup order (workers first, leader afterwards) and an atomic lifecycle (if a worker goes down, the whole group restarts, not a single pod). Without it, the coordination is manually fragile.&lt;/p>
&lt;p>A simpler alternative, if all the tensor parallel GPUs fit on a single node (the case with HGX H100 boxes with 8 GPUs and an internal NVSwitch): one single Pod with &lt;code>nvidia.com/gpu: 5&lt;/code>, &lt;code>--tensor-parallel-size=5&lt;/code>, and vLLM handles everything internally. No Ray, no LWS, far simpler. It is the recommended path whenever it is available.&lt;/p>
&lt;h3 id="autoscaling-the-standard-hpa-is-no-use">Autoscaling: the standard HPA is no use&lt;/h3>
&lt;p>An HPA on CPU% is useless for vLLM. The GPU does the work; the pod&amp;rsquo;s CPU sits at 5–10% even under maximum load. Nor is the GPU utilisation percentage from &lt;code>dcgm-exporter&lt;/code> any use: a pod at 100% GPU% with &lt;code>gpu_cache_usage_perc=15%&lt;/code> is serving one long session without being saturated, while a pod at 60% GPU% with &lt;code>gpu_cache_usage_perc=95%&lt;/code> is on the edge of evicting sessions.&lt;/p>
&lt;p>The right metrics are exported by vLLM itself at &lt;code>/metrics&lt;/code> (Prometheus format):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>What it says&lt;/th>
&lt;th>When to scale&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>vllm:num_requests_waiting&lt;/code>&lt;/td>
&lt;td>Requests queued and not yet in the batch.&lt;/td>
&lt;td>If it stays above 5–10.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:num_requests_running&lt;/code>&lt;/td>
&lt;td>Active requests in the batch.&lt;/td>
&lt;td>For capacity planning, not for scaling.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:gpu_cache_usage_perc&lt;/code>&lt;/td>
&lt;td>% of the KV cache occupied.&lt;/td>
&lt;td>If sustained above 80%, there is a risk of preemption.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:time_to_first_token_seconds&lt;/code>&lt;/td>
&lt;td>Prefill latency (histogram).&lt;/td>
&lt;td>If p95 exceeds your SLA.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:e2e_request_latency_seconds&lt;/code>&lt;/td>
&lt;td>Total latency per request.&lt;/td>
&lt;td>An output metric.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For the HPA to consume them there are two routes: Prometheus Adapter (exposes custom metrics to the K8s API) or KEDA (scales on Prometheus queries directly, far more convenient). With KEDA:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">keda.sh/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ScaledObject&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-scaler&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleTargetRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pollingInterval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cooldownPeriod&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 2 min before scale-down (long sessions)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">triggers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serverAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://prometheus.monitoring:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;5&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> sum(vllm:num_requests_waiting{app=&amp;#34;vllm-llama3-8b&amp;#34;})&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The long &lt;code>cooldownPeriod&lt;/code> matters: if you drop replicas while there are sessions decoding, you kill them. Better to leave 2 minutes of slack.&lt;/p>
&lt;h3 id="observability-the-four-metrics-that-matter">Observability: the four metrics that matter&lt;/h3>
&lt;p>Of everything &lt;code>/metrics&lt;/code> exports, a minimal dashboard needs these four:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>TTFT p50/p95&lt;/strong> (time to first token) — what the user perceives when they hit send.&lt;/li>
&lt;li>&lt;strong>TPOT p50/p95&lt;/strong> (time per output token) — the &amp;ldquo;speed&amp;rdquo; of the streaming.&lt;/li>
&lt;li>&lt;strong>Aggregate throughput&lt;/strong> (tokens generated per second across the cluster) — for capacity planning.&lt;/li>
&lt;li>&lt;strong>Queue depth&lt;/strong> (&lt;code>vllm:num_requests_waiting&lt;/code>) — the leading indicator: if it grows, everything is about to degrade.&lt;/li>
&lt;/ol>
&lt;p>To that, add HBM utilisation and free memory per GPU (from &lt;code>dcgm-exporter&lt;/code>) to spot bandwidth saturation and fragmentation problems. A decent Grafana dashboard with those 6 charts anticipates 90% of incidents.&lt;/p>
&lt;h2 id="two-concrete-scenarios">Two concrete scenarios&lt;/h2>
&lt;p>We reuse the same hardware as the previous article for continuity. The same cache arithmetic, now with the engine on top.&lt;/p>
&lt;h3 id="scenario-a--1rtx-4090-workstation-or-small-k8s-node">Scenario A — 1×RTX 4090 (workstation or small K8s node)&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Topology&lt;/strong>: 1 Pod, &lt;code>--tensor-parallel-size=1&lt;/code>, 1 GPU, 1 node.&lt;/li>
&lt;li>&lt;strong>Model&lt;/strong>: up to 8B BF16 (Llama 3 8B, Qwen3 8B, Mistral 7B) or up to 14B in FP8/AWQ.&lt;/li>
&lt;li>&lt;strong>PVC&lt;/strong>: the node&amp;rsquo;s local SSD. The 4090 reads 1 TB/s from HBM; an NVMe SSD at 5 GB/s takes 5 seconds to feed 25 GB of weights into VRAM, negligible next to initialisation.&lt;/li>
&lt;li>&lt;strong>HPA&lt;/strong>: irrelevant inside the 4090 (always 1 vLLM replica per GPU), but useful across nodes: 3 replicas on 3 nodes with a 4090 each, and the K8s Service spreads round-robin.&lt;/li>
&lt;li>&lt;strong>Useful concurrency&lt;/strong>: 4–8 simultaneous sessions with 8K of context, 1–2 with 32K.&lt;/li>
&lt;li>&lt;strong>Natural use case&lt;/strong>: PoC, small teams, departmental environments, edge.&lt;/li>
&lt;/ul>
&lt;p>The manifest above is sized for this scenario. Changing only the model and the args, the same Deployment serves Qwen, Mistral or whatever is next.&lt;/p>
&lt;h3 id="scenario-b--5h100-sxm-cluster-with-nvlinknvswitch">Scenario B — 5×H100 SXM (cluster with NVLink/NVSwitch)&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Topology&lt;/strong>: 1 Pod with &lt;code>nvidia.com/gpu: 5&lt;/code> on an HGX node, &lt;code>--tensor-parallel-size=5&lt;/code>. If the platform does not allow grouping 5 GPUs into a single Pod, a LeaderWorkerSet with 5 pods coordinated by Ray.&lt;/li>
&lt;li>&lt;strong>Model&lt;/strong>: up to 70B BF16 (Llama 3 70B) or up to 200B+ in FP8 with cache quantisation.&lt;/li>
&lt;li>&lt;strong>PVC&lt;/strong>: NVMe attached directly to the node, or fast network storage (Ceph with a 25/100 GbE network, Lustre, GPFS). Loading 140 GB of weights over a slow network takes 5 minutes per start.&lt;/li>
&lt;li>&lt;strong>HPA&lt;/strong>: irrelevant inside the 5-GPU cluster (the 5 are an indivisible unit), but useful for adding whole HGX nodes when the load passes a given threshold. This combines with Cluster Autoscaler if the underlying infrastructure allows it.&lt;/li>
&lt;li>&lt;strong>Useful concurrency&lt;/strong>: 32–128 simultaneous sessions with mid-sized contexts, 4–16 with huge contexts.&lt;/li>
&lt;li>&lt;strong>Natural use case&lt;/strong>: internal corporate service, public exposure with an SLA, multi-tenant.&lt;/li>
&lt;/ul>
&lt;h3 id="a-and-b-side-by-side">A and B, side by side&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Aspect&lt;/th>
&lt;th>A (1×4090)&lt;/th>
&lt;th>B (5×H100 SXM)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Pod topology&lt;/td>
&lt;td>1 pod, 1 GPU&lt;/td>
&lt;td>1 pod with 5 GPUs (or an LWS of 5)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Maximum BF16 model&lt;/td>
&lt;td>8 B&lt;/td>
&lt;td>70 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TTFT @ 8K context, idle&lt;/td>
&lt;td>~250 ms&lt;/td>
&lt;td>~80 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TPOT, idle&lt;/td>
&lt;td>~30 ms/tok&lt;/td>
&lt;td>~15 ms/tok&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Throughput @ concurrency 16&lt;/td>
&lt;td>~50 tok/s/session&lt;/td>
&lt;td>~200 tok/s/session&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Session drain&lt;/td>
&lt;td>30–60 s&lt;/td>
&lt;td>60–180 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Useful autoscaling&lt;/td>
&lt;td>Replicas on peer nodes&lt;/td>
&lt;td>Whole nodes via Cluster Autoscaler&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reasonable multi-tenancy&lt;/td>
&lt;td>Limited: 4–8 sessions&lt;/td>
&lt;td>Comfortable: 32–128 sessions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Indicative cost (hardware)&lt;/td>
&lt;td>~2 K €&lt;/td>
&lt;td>~250 K € (≈ 125×)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The asymmetry is the same one as in the previous article: 125× more expensive, only ~4× more throughput per session and ~10× more concurrency. What the cluster buys is not proportional; it buys &lt;strong>access to models an order of magnitude larger&lt;/strong> and &lt;strong>latencies low enough for interactive use at scale&lt;/strong>. If your workload is batch or asynchronous agents where latency is not critical, several 4090s come surprisingly close.&lt;/p>
&lt;h2 id="vllm-against-tensorrt-llm-and-sglang">vLLM against TensorRT-LLM and SGLang&lt;/h2>
&lt;p>Honestly, all three are good engines. The choice depends on practical criteria, not technical ones. A decision map, not a benchmark:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Criterion&lt;/th>
&lt;th>vLLM&lt;/th>
&lt;th>TensorRT-LLM&lt;/th>
&lt;th>SGLang&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Supported hardware&lt;/td>
&lt;td>NVIDIA, AMD ROCm, Intel Gaudi&lt;/td>
&lt;td>NVIDIA only&lt;/td>
&lt;td>NVIDIA, AMD ROCm&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pure latency (TTFT)&lt;/td>
&lt;td>Good&lt;/td>
&lt;td>&lt;strong>Best&lt;/strong>: kernels compiled for the exact hardware&lt;/td>
&lt;td>Good&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Aggregate throughput&lt;/td>
&lt;td>Excellent&lt;/td>
&lt;td>Excellent&lt;/td>
&lt;td>Excellent (RadixAttention)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Deployment&lt;/td>
&lt;td>&lt;strong>Trivial&lt;/strong>: Docker image + args&lt;/td>
&lt;td>Complex: build an engine per model and per GPU&lt;/td>
&lt;td>Moderate&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OpenAI-compatible API&lt;/td>
&lt;td>&lt;strong>Native, complete&lt;/strong>&lt;/td>
&lt;td>Yes, through Triton Inference Server&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Support for new models&lt;/td>
&lt;td>&lt;strong>Days after release&lt;/strong>&lt;/td>
&lt;td>Weeks (recompile the engine)&lt;/td>
&lt;td>Days&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Quantization&lt;/td>
&lt;td>AWQ, GPTQ, FP8 cache&lt;/td>
&lt;td>INT4/INT8/FP8, very mature&lt;/td>
&lt;td>AWQ, FP8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-modal&lt;/td>
&lt;td>Yes (Llava, Pixtral, Qwen-VL)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Excellent, a priority&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Function calling / tool use&lt;/td>
&lt;td>Good&lt;/td>
&lt;td>Limited&lt;/td>
&lt;td>&lt;strong>First class&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Community / release cadence&lt;/td>
&lt;td>Very active, weekly&lt;/td>
&lt;td>Active, NVIDIA-driven&lt;/td>
&lt;td>Very active, academic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Licence&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>When to choose each one&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>vLLM&lt;/strong>: the &amp;ldquo;boring choice&amp;rdquo; that works. The path of least friction to production. If your team has no dedicated inference serving specialist, this one. It supports varied hardware, up-to-date models, a stable API and a huge community.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>TensorRT-LLM&lt;/strong>: when per-request latency is the only metric that matters and your model is stable (trained in-house, not swapped every fortnight). The price of the performance is that every model plus every GPU plus every TRT version requires an engine rebuild, and that blocks fast iteration.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>SGLang&lt;/strong>: for agent-dominated workloads (heavy tool calling) or complex multi-modal. Its RadixAttention, structural caching of prompts with shared prefixes, shines in ReAct-style patterns where the same system prompt is repeated thousands of times.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>For most teams starting out with on-prem LLM serving, &lt;strong>vLLM is the right answer until you have production data pushing you elsewhere&lt;/strong>.&lt;/p>
&lt;h2 id="common-operational-traps">Common operational traps&lt;/h2>
&lt;p>A list of gotchas that come up again and again:&lt;/p>
&lt;h3 id="the-model-is-downloaded-on-every-rolling-update">The model is downloaded on every rolling update&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: every deploy takes 5+ minutes to become available.
&lt;strong>Cause&lt;/strong>: there is no shared PVC. Every new pod downloads the model from Hugging Face from scratch.
&lt;strong>Fix&lt;/strong>: a ReadOnlyMany PVC on fast storage, or a local mirror of the registry (a Pod with &lt;code>huggingface-cli&lt;/code> serving a directory over HTTP). In CI/CD, hydrating the PVC before the rollout is 1 line of bash.&lt;/p>
&lt;h3 id="short-readiness-timeouts-that-kill-pods-while-they-load">Short readiness timeouts that kill pods while they load&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: new pods go into &lt;code>CrashLoopBackOff&lt;/code> during the first model load.
&lt;strong>Cause&lt;/strong>: a &lt;code>readinessProbe&lt;/code> with too low a timeout fires before vLLM finishes loading; the &lt;code>livenessProbe&lt;/code> finishes the job.
&lt;strong>Fix&lt;/strong>: a &lt;code>startupProbe&lt;/code> with &lt;code>failureThreshold: 60&lt;/code> or more (10 minutes of grace) before liveness starts evaluating.&lt;/p>
&lt;h3 id="an-unquantised-kv-cache-then-oom">An unquantised KV cache, then OOM&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: the pod starts fine, serves for five minutes, then gets OOMKilled when session number five arrives with a long context.
&lt;strong>Cause&lt;/strong>: a BF16 KV cache (the default) consumes twice what FP8 does.
&lt;strong>Fix&lt;/strong>: &lt;code>--kv-cache-dtype=fp8&lt;/code>. Negligible quality loss in the vast majority of cases, double the capacity.&lt;/p>
&lt;h3 id="confusing-replicas-with-concurrency">Confusing replicas with concurrency&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: the HPA scales to 8 replicas under light real load and the cloud bill goes up. Latency does not improve.
&lt;strong>Cause&lt;/strong>: someone configured &lt;code>targetAverageUtilization: 50%&lt;/code> on CPU, thinking that is &amp;ldquo;load&amp;rdquo;. The reality: a single vLLM replica serves dozens of simultaneous sessions.
&lt;strong>Fix&lt;/strong>: an HPA on &lt;code>vllm:num_requests_waiting&lt;/code>. If the queue is empty, one replica is enough even if the GPU is at 90%.&lt;/p>
&lt;h3 id="tensor-parallel-on-gpus-without-nvlink">Tensor parallel on GPUs without NVLink&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: throughput 3× worse than expected, GPUs at 30%, a lot of PCIe traffic.
&lt;strong>Cause&lt;/strong>: &lt;code>tensor_parallel=4&lt;/code> on 4 GPUs connected only by PCIe; the all-reduce saturates the bus on every layer.
&lt;strong>Fix&lt;/strong>: either the GPUs share NVLink/NVSwitch (SXM/HGX models), or you use pipeline parallel (worse latency but less all-reduce), or you reduce TP and accept that the whole model does not fit.&lt;/p>
&lt;h3 id="sessions-cut-off-during-a-rolling-update">Sessions cut off during a rolling update&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: users see truncated responses during the deploy.
&lt;strong>Cause&lt;/strong>: &lt;code>terminationGracePeriodSeconds: 30&lt;/code> (the default) is not enough to drain long generations.
&lt;strong>Fix&lt;/strong>: &lt;code>terminationGracePeriodSeconds: 120–180&lt;/code>. Combined with &lt;code>maxUnavailable: 0&lt;/code>, rollouts are invisible to active users.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>vLLM with hot LoRA adapters&lt;/strong>: serving a base model plus N tenant-specific adapters without reloading weights.&lt;/li>
&lt;li>&lt;strong>Disaggregated serving&lt;/strong>: separating prefill and decode into specialised pods, each optimised for its GPU profile.&lt;/li>
&lt;li>&lt;strong>Quantization deep-dive&lt;/strong>: AWQ vs GPTQ vs dynamic FP8 vs FP4, the real trade-offs, and when to use each.&lt;/li>
&lt;li>&lt;strong>Gateway API + AI Inference Extensions&lt;/strong>: the sigwg proposal to make LLMs first-class citizens in K8s (routing by model, sticky sessions per conversation, multi-tenant fairness).&lt;/li>
&lt;li>&lt;strong>Multi-modal serving&lt;/strong>: the same runtime, a different kind of request — images, audio, embeddings.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-serving-engines-pareto-frontier-comparison/">Comparing serving engines (vLLM/SGLang/TRT-LLM/Dynamo)&lt;/a> — how vLLM behaves against SGLang and TRT-LLM on the goodput-latency Pareto frontier: the data that justifies (or questions) the choice of vLLM on Kubernetes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/cloud-gpu-on-demand-reserved-spot-neoclouds-2026-prices/">Cloud GPU: comparing prices, commitment and sovereign neoclouds&lt;/a> — if the Kubernetes cluster is not on-premise but cloud, here are the spot and reserved prices of the European neoclouds where you would run these manifests.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Kwon et al., &lt;a href="https://arxiv.org/abs/2309.06180">&lt;em>Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/em>&lt;/a> (SOSP 2023) — the original vLLM paper.&lt;/li>
&lt;li>Yu et al., &lt;a href="https://www.usenix.org/conference/osdi22/presentation/yu">&lt;em>Orca: A Distributed Serving System for Transformer-Based Generative Models&lt;/em>&lt;/a> (OSDI 2022) — the paper that popularised &lt;em>continuous batching&lt;/em>.&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/">Official vLLM documentation&lt;/a> — operational and well maintained.&lt;/li>
&lt;li>&lt;a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/">NVIDIA GPU Operator&lt;/a> — installation and troubleshooting of the GPU layer on Kubernetes.&lt;/li>
&lt;li>&lt;a href="https://kubernetes.io/blog/2024/04/16/introducing-leaderworkerset/">LeaderWorkerSet&lt;/a> — the primitive for coordinated workloads such as multi-pod tensor parallel.&lt;/li>
&lt;li>&lt;a href="https://keda.sh/">KEDA&lt;/a> — event-driven autoscaling, ideal for scaling on queue metrics.&lt;/li>
&lt;li>&lt;a href="https://github.com/NVIDIA/TensorRT-LLM">TensorRT-LLM&lt;/a> and &lt;a href="https://github.com/sgl-project/sglang">SGLang&lt;/a> — the two most serious comparables.&lt;/li>
&lt;li>&lt;a href="https://lmsys.org/">LMSYS Chatbot Arena&lt;/a> — periodic benchmarks comparing the three engines.&lt;/li>
&lt;li>Previous article on this blog: &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory that holds up LLM inference&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Kubernetes with Cilium BGP: services reachable without Ingress</title><link>https://blog.lo0.es/en/posts/kubernetes-cilium-bgp-services-without-ingress/</link><pubDate>Sun, 08 Mar 2026 00:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/kubernetes-cilium-bgp-services-without-ingress/</guid><description>&lt;p>One of the advantages of using Cilium as the CNI in Kubernetes is its native BGP support. It lets you advertise ClusterIPs and LoadBalancer IPs directly to the LAN router, making services reachable without needing Ingress or NodePort.&lt;/p>
&lt;h2 id="the-problem">The problem&lt;/h2>
&lt;p>In a standard Kubernetes cluster, pods and services live on internal networks that are not reachable from outside the cluster. Getting to them requires NodePort, Ingress or an external LoadBalancer.&lt;/p>
&lt;p>With Cilium BGP, the pod CIDRs and service CIDRs are advertised over BGP to the upstream router, making the whole cluster network routable from the LAN.&lt;/p>
&lt;p>&lt;em>Work in progress — full configurations for RKE2 + CRS327 coming soon.&lt;/em>&lt;/p></description></item></channel></rss>