<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pavan Madduri</title>
    <description>The latest articles on DEV Community by Pavan Madduri (@pavan_madduri).</description>
    <link>https://dev.to/pavan_madduri</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2932890%2F017c6a22-c36f-4317-8711-3a206162455a.jpg</url>
      <title>DEV Community: Pavan Madduri</title>
      <link>https://dev.to/pavan_madduri</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pavan_madduri"/>
    <language>en</language>
    <item>
      <title>Serving 3 LLMs on 1 GPU - Multi-Model Inference with Docker on OKE</title>
      <dc:creator>Pavan Madduri</dc:creator>
      <pubDate>Thu, 02 Jul 2026 15:38:44 +0000</pubDate>
      <link>https://dev.to/pavan_madduri/serving-3-llms-on-1-gpu-multi-model-inference-with-docker-on-oke-5c05</link>
      <guid>https://dev.to/pavan_madduri/serving-3-llms-on-1-gpu-multi-model-inference-with-docker-on-oke-5c05</guid>
      <description>&lt;p&gt;I had three small models I wanted to serve: Phi-3-mini for general chat, CodeLlama-7B for code suggestions, and a fine-tuned Mistral for document summarization. Each one fits in about 5-6GB of VRAM. An A10 GPU has 24GB. Three models, one GPU, plenty of headroom.&lt;/p&gt;

&lt;p&gt;Running three separate vLLM deployments, each requesting a full GPU, would cost 3x and waste 18GB of VRAM. So I figured out how to serve all three from one container.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Naive Approach (and Why It Didn't Work)
&lt;/h2&gt;

&lt;p&gt;My first idea was simple: run three vLLM processes in one pod, each binding to a different port.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Don't do this&lt;/span&gt;
vllm serve microsoft/Phi-3-mini-4k-instruct &lt;span class="nt"&gt;--port&lt;/span&gt; 8001 &amp;amp;
vllm serve codellama/CodeLlama-7b-Instruct-hf &lt;span class="nt"&gt;--port&lt;/span&gt; 8002 &amp;amp;
vllm serve my-org/mistral-summarizer &lt;span class="nt"&gt;--port&lt;/span&gt; 8003 &amp;amp;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This doesn't work because each vLLM process tries to claim the entire GPU. The second process crashes with a CUDA out-of-memory error because the first one already allocated all the VRAM.&lt;/p&gt;

&lt;p&gt;You can set &lt;code&gt;--gpu-memory-utilization 0.30&lt;/code&gt; on each to split the memory, but vLLM's performance drops significantly when memory is constrained continuous batching can't work efficiently, and you lose the KV cache space that makes vLLM fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Works: vLLM with LoRA Adapters
&lt;/h2&gt;

&lt;p&gt;If your models are fine-tuned versions of the same base model (or you can restructure them that way), vLLM supports serving multiple LoRA adapters on a single base model. One base model in GPU memory, multiple lightweight adapters loaded on demand.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--gpus&lt;/span&gt; all &lt;span class="nt"&gt;-p&lt;/span&gt; 8000:8000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /models:/models &lt;span class="se"&gt;\&lt;/span&gt;
  vllm/vllm-openai:latest &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--model&lt;/span&gt; /models/mistral-7b-base &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-lora&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--lora-modules&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"chat=/models/lora-chat"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"code=/models/lora-code"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"summary=/models/lora-summary"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-loras&lt;/span&gt; 3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-model-len&lt;/span&gt; 4096
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clients specify which adapter to use in the &lt;code&gt;model&lt;/code&gt; field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Chat model&lt;/span&gt;
curl http://localhost:8000/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model": "chat", "messages": [...]}'&lt;/span&gt;

&lt;span class="c"&gt;# Code model&lt;/span&gt;
curl http://localhost:8000/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model": "code", "messages": [...]}'&lt;/span&gt;

&lt;span class="c"&gt;# Summary model&lt;/span&gt;
curl http://localhost:8000/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model": "summary", "messages": [...]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The base model (Mistral 7B) uses ~14GB VRAM. Each LoRA adapter adds only 50-200MB. All three adapters fit easily on a 24GB A10.&lt;/p&gt;

&lt;h2&gt;
  
  
  When You Have Different Base Models
&lt;/h2&gt;

&lt;p&gt;If your models aren't LoRA variants of the same base (mine weren't originally), you have two options:&lt;/p&gt;

&lt;h3&gt;
  
  
  Option A: Ollama with Multiple Models
&lt;/h3&gt;

&lt;p&gt;Ollama handles model loading/unloading automatically. When you request a model, it loads it into GPU memory. When memory fills up, it evicts the least recently used model.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ollama-deployment.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ollama-multi&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ollama&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ollama/ollama:latest&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;11434&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OLLAMA_HOST&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OLLAMA_NUM_PARALLEL&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4"&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;nvidia.com/gpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;32Gi&lt;/span&gt;
          &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;models&lt;/span&gt;
              &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/root/.ollama&lt;/span&gt;
      &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;models&lt;/span&gt;
          &lt;span class="na"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;claimName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ollama-models&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Load models after deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;OLLAMA_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kubectl get svc ollama-multi &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.spec.clusterIP}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

curl http://&lt;span class="nv"&gt;$OLLAMA_IP&lt;/span&gt;:11434/api/pull &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"name": "phi3:mini"}'&lt;/span&gt;
curl http://&lt;span class="nv"&gt;$OLLAMA_IP&lt;/span&gt;:11434/api/pull &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"name": "codellama:7b"}'&lt;/span&gt;
curl http://&lt;span class="nv"&gt;$OLLAMA_IP&lt;/span&gt;:11434/api/pull &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"name": "mistral:7b"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The downside: model swapping takes 5-15 seconds when a cold model needs to load. For a team that mostly uses one model at a time, this is fine. For concurrent usage of all three, there's latency on the first request to each model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option B: Triton Inference Server
&lt;/h3&gt;

&lt;p&gt;NVIDIA Triton can serve multiple models on one GPU with explicit memory allocation. It's more complex to set up but gives you fine-grained control:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# model_repository/
# ├── phi3/
# │   ├── config.pbtxt
# │   └── 1/
# │       └── model.onnx
# ├── codellama/
# │   ├── config.pbtxt
# │   └── 1/
# │       └── model.onnx
# └── summarizer/
#     ├── config.pbtxt
#     └── 1/
#         └── model.onnx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; nvcr.io/nvidia/tritonserver:24.01-py3&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; model_repository /models&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["tritonserver", "--model-repository=/models", "--model-control-mode=explicit"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tried Triton and it works well for ONNX/TensorRT models. For plain HuggingFace transformer models, the conversion step adds friction. I ended up going with the LoRA approach for my use case.&lt;/p&gt;

&lt;h2&gt;
  
  
  My OKE Deployment
&lt;/h2&gt;

&lt;p&gt;I went with vLLM + LoRA because two of my three models were fine-tuned Mistral variants anyway. I retrained the third (the code model) as a LoRA on the same Mistral base.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;multi-model-inference&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inference&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;initContainers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;model-loader&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/oracle/oci-cli:latest&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/bin/bash"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
              &lt;span class="s"&gt;for model in mistral-base lora-chat lora-code lora-summary; do&lt;/span&gt;
                &lt;span class="s"&gt;if [ ! -f /models/$model/.complete ]; then&lt;/span&gt;
                  &lt;span class="s"&gt;oci os object bulk-download --bucket-name ai-models \&lt;/span&gt;
                    &lt;span class="s"&gt;--prefix "models/$model/" \&lt;/span&gt;
                    &lt;span class="s"&gt;--download-dir /models/$model \&lt;/span&gt;
                    &lt;span class="s"&gt;--auth instance_principal&lt;/span&gt;
                  &lt;span class="s"&gt;touch /models/$model/.complete&lt;/span&gt;
                &lt;span class="s"&gt;fi&lt;/span&gt;
              &lt;span class="s"&gt;done&lt;/span&gt;
          &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;models&lt;/span&gt;
              &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/models&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm/vllm-openai:latest&lt;/span&gt;
          &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--model"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/models/mistral-base"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--enable-lora"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--lora-modules"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat=/models/lora-chat"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code=/models/lora-code"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summary=/models/lora-summary"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--max-loras"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--max-model-len"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4096"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--gpu-memory-utilization"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.9"&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8000&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;nvidia.com/gpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;32Gi&lt;/span&gt;
          &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;models&lt;/span&gt;
              &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/models&lt;/span&gt;
      &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;models&lt;/span&gt;
          &lt;span class="na"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;claimName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;model-cache&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Cost Impact
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;GPUs Needed&lt;/th&gt;
&lt;th&gt;Monthly Cost (OCI A10)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3 separate vLLM deployments&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;$3,282&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 vLLM with 3 LoRA adapters&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;$1,094&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Savings&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$2,188/month&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same three models, same inference quality (LoRA adds negligible overhead), one-third the cost. The trade-off is slightly more complex deployment config and the requirement that all models share a base.&lt;/p&gt;

&lt;p&gt;For teams exploring multi-model setups, start with Ollama (simplest), graduate to vLLM + LoRA if your models share a base, and use Triton if you need maximum control over GPU memory allocation.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Pavan Madduri - Oracle ACE Associate, CNCF Golden Kubestronaut. &lt;a href="https://github.com/pmady" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://linkedin.com/in/pavanmadduri" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://pmady.github.io/" rel="noopener noreferrer"&gt;Website&lt;/a&gt; | &lt;a href="https://scholar.google.com/citations?view_op=list_works&amp;amp;hl=en&amp;amp;user=au0O-8oAAAAJ" rel="noopener noreferrer"&gt;Google Scholar&lt;/a&gt; | &lt;a href="https://www.researchgate.net/profile/Pavan-Madduri-2?ev=hdr_xprf" rel="noopener noreferrer"&gt;ResearchGate&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>docker</category>
      <category>kubernetes</category>
      <category>oci</category>
    </item>
    <item>
      <title>Monitoring GPU Inference Containers on OKE with OpenTelemetry - What Prometheus Misses</title>
      <dc:creator>Pavan Madduri</dc:creator>
      <pubDate>Thu, 02 Jul 2026 15:30:29 +0000</pubDate>
      <link>https://dev.to/pavan_madduri/monitoring-gpu-inference-containers-on-oke-with-opentelemetry-what-prometheus-misses-40c6</link>
      <guid>https://dev.to/pavan_madduri/monitoring-gpu-inference-containers-on-oke-with-opentelemetry-what-prometheus-misses-40c6</guid>
      <description>&lt;p&gt;I had Prometheus + DCGM Exporter running on my OKE cluster. It gave me GPU utilization, memory usage, temperature. Basic stuff. What it didn't give me was the correlation between GPU metrics and inference performance request latency, tokens per second, queue depth. Two different dashboards, two different time ranges, no easy way to connect "GPU hit 95% utilization" with "p99 latency spiked to 8 seconds."&lt;/p&gt;

&lt;p&gt;That's what led me to build &lt;a href="https://github.com/pmady/otel-gpu-receiver" rel="noopener noreferrer"&gt;otel-gpu-receiver&lt;/a&gt; and adopt OpenTelemetry for GPU monitoring instead of the Prometheus-only approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Wrong With DCGM Exporter Alone
&lt;/h2&gt;

&lt;p&gt;DCGM Exporter is solid for hardware metrics. It gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;DCGM_FI_DEV_GPU_UTIL&lt;/code&gt;: utilization percentage&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DCGM_FI_DEV_FB_USED&lt;/code&gt; : framebuffer memory used&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DCGM_FI_DEV_GPU_TEMP&lt;/code&gt;: temperature&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DCGM_FI_DEV_POWER_USAGE&lt;/code&gt;: power draw&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These tell you the GPU is busy. They don't tell you why, or whether "busy" means "serving requests efficiently" or "stuck loading a model." I need application-level metrics alongside GPU metrics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tokens/second&lt;/strong&gt; - actual inference throughput&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request queue depth&lt;/strong&gt; - are requests piling up?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to first token&lt;/strong&gt; - user-perceived latency&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch size&lt;/strong&gt; - how well is continuous batching working?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With separate Prometheus endpoints for GPU and application metrics, correlating these requires manual PromQL joins. With OpenTelemetry, everything goes through one pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The OpenTelemetry Stack on OKE
&lt;/h2&gt;

&lt;p&gt;Here's what I run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────┐
│  GPU Node               │
│  ┌───────────────────┐  │
│  │ vLLM Pod          │  │
│  │  └─ OTel SDK      │──┤──► OTel Collector ──► Backend
│  │     (traces +     │  │        (on each      (Grafana Cloud,
│  │      metrics)     │  │         node)         OCI APM, etc.)
│  └───────────────────┘  │
│  ┌───────────────────┐  │
│  │ otel-gpu-receiver │──┤──►
│  │  (NVML metrics)   │  │
│  └───────────────────┘  │
└─────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  1. Deploy the OTel Collector as a DaemonSet
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DaemonSet&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otel-collector&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otel-collector&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otel-collector&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;collector&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otel/opentelemetry-collector-contrib:0.96.0&lt;/span&gt;
          &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;config&lt;/span&gt;
              &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/otel&lt;/span&gt;
          &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--config=/etc/otel/config.yaml"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;config&lt;/span&gt;
          &lt;span class="na"&gt;configMap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otel-collector-config&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Collector Config - GPU + Application Metrics Together
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# otel-collector-config.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ConfigMap&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otel-collector-config&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring&lt;/span&gt;
&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;config.yaml&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;receivers:&lt;/span&gt;
      &lt;span class="s"&gt;# GPU metrics from otel-gpu-receiver&lt;/span&gt;
      &lt;span class="s"&gt;otlp:&lt;/span&gt;
        &lt;span class="s"&gt;protocols:&lt;/span&gt;
          &lt;span class="s"&gt;grpc:&lt;/span&gt;
            &lt;span class="s"&gt;endpoint: 0.0.0.0:4317&lt;/span&gt;

      &lt;span class="s"&gt;# Scrape vLLM's Prometheus metrics and convert to OTel&lt;/span&gt;
      &lt;span class="s"&gt;prometheus:&lt;/span&gt;
        &lt;span class="s"&gt;config:&lt;/span&gt;
          &lt;span class="s"&gt;scrape_configs:&lt;/span&gt;
            &lt;span class="s"&gt;- job_name: 'vllm'&lt;/span&gt;
              &lt;span class="s"&gt;kubernetes_sd_configs:&lt;/span&gt;
                &lt;span class="s"&gt;- role: pod&lt;/span&gt;
              &lt;span class="s"&gt;relabel_configs:&lt;/span&gt;
                &lt;span class="s"&gt;- source_labels: [__meta_kubernetes_pod_label_app]&lt;/span&gt;
                  &lt;span class="s"&gt;regex: vllm&lt;/span&gt;
                  &lt;span class="s"&gt;action: keep&lt;/span&gt;

    &lt;span class="s"&gt;processors:&lt;/span&gt;
      &lt;span class="s"&gt;batch:&lt;/span&gt;
        &lt;span class="s"&gt;timeout: 10s&lt;/span&gt;

      &lt;span class="s"&gt;# Add OKE cluster metadata to all metrics&lt;/span&gt;
      &lt;span class="s"&gt;k8sattributes:&lt;/span&gt;
        &lt;span class="s"&gt;auth_type: serviceAccount&lt;/span&gt;
        &lt;span class="s"&gt;extract:&lt;/span&gt;
          &lt;span class="s"&gt;metadata:&lt;/span&gt;
            &lt;span class="s"&gt;- k8s.node.name&lt;/span&gt;
            &lt;span class="s"&gt;- k8s.pod.name&lt;/span&gt;
            &lt;span class="s"&gt;- k8s.namespace.name&lt;/span&gt;

    &lt;span class="s"&gt;exporters:&lt;/span&gt;
      &lt;span class="s"&gt;# Send to Grafana Cloud (or any OTel backend)&lt;/span&gt;
      &lt;span class="s"&gt;otlphttp:&lt;/span&gt;
        &lt;span class="s"&gt;endpoint: https://otlp-gateway-prod-us-central-0.grafana.net/otlp&lt;/span&gt;
        &lt;span class="s"&gt;headers:&lt;/span&gt;
          &lt;span class="s"&gt;Authorization: "Basic ${GRAFANA_TOKEN}"&lt;/span&gt;

      &lt;span class="s"&gt;# Also export to OCI APM&lt;/span&gt;
      &lt;span class="s"&gt;otlphttp/oci:&lt;/span&gt;
        &lt;span class="s"&gt;endpoint: https://apm-collector.us-ashburn-1.oci.oraclecloud.com/20200101/opentelemetry&lt;/span&gt;
        &lt;span class="s"&gt;headers:&lt;/span&gt;
          &lt;span class="s"&gt;Authorization: "dataKey ${OCI_APM_KEY}"&lt;/span&gt;

    &lt;span class="s"&gt;service:&lt;/span&gt;
      &lt;span class="s"&gt;pipelines:&lt;/span&gt;
        &lt;span class="s"&gt;metrics:&lt;/span&gt;
          &lt;span class="s"&gt;receivers: [otlp, prometheus]&lt;/span&gt;
          &lt;span class="s"&gt;processors: [batch, k8sattributes]&lt;/span&gt;
          &lt;span class="s"&gt;exporters: [otlphttp, otlphttp/oci]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Deploy otel-gpu-receiver
&lt;/h3&gt;

&lt;p&gt;This is the component I built. It reads NVIDIA GPU metrics via NVML (the same library nvidia-smi uses) and exports them as OpenTelemetry metrics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm &lt;span class="nb"&gt;install &lt;/span&gt;otel-gpu-receiver pmady/otel-gpu-receiver &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; monitoring &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; collector.endpoint&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"otel-collector.monitoring:4317"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;scrapeInterval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;15s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs as a DaemonSet on GPU nodes and pushes metrics to the OTel Collector on each node.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metrics I Actually Look At
&lt;/h2&gt;

&lt;p&gt;After a few weeks of running this, these are the metrics I check daily:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GPU utilization vs. tokens/second&lt;/strong&gt; .If utilization is high but throughput is flat, something is wrong (usually a batch size issue or memory pressure).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# In Grafana overlay these two on the same panel
gpu_utilization{node="gpu-1"}
rate(vllm_generation_tokens_total{pod="vllm-0"}[5m])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;GPU memory vs. request queue&lt;/strong&gt; : When GPU memory hits the limit, vLLM starts queuing. This is the first sign you need to either reduce &lt;code&gt;--gpu-memory-utilization&lt;/code&gt; or add another replica.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time to first token&lt;/strong&gt; : The metric users actually feel. If this goes above 2 seconds, something needs attention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Power draw&lt;/strong&gt; : Not for alerting, but useful for cost estimation. I can correlate power draw with request volume to estimate per-request energy cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I See That I Couldn't See Before
&lt;/h2&gt;

&lt;p&gt;Last week GPU utilization dropped to 20% while request latency spiked to 5 seconds. With DCGM Exporter alone, I would've been confused the GPU looks fine, why is it slow?&lt;/p&gt;

&lt;p&gt;With the combined OTel pipeline, I could see that the batch scheduler in vLLM was waiting for the next batch window. The model had just been loaded (I'd updated the deployment), and the KV cache was cold. Throughput recovered in about 30 seconds as the cache warmed up. Without the application metrics alongside the GPU metrics, I would have been debugging this for an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  OCI APM Integration
&lt;/h2&gt;

&lt;p&gt;OCI has its own APM service that accepts OpenTelemetry data. The nice thing about sending metrics there is that you get OCI-native alerting and integration with OCI notifications:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create an alarm in OCI Monitoring&lt;/span&gt;
oci monitoring alarm create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--compartment-id&lt;/span&gt; &lt;span class="nv"&gt;$COMPARTMENT_ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--display-name&lt;/span&gt; &lt;span class="s2"&gt;"GPU inference latency high"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--metric-compartment-id&lt;/span&gt; &lt;span class="nv"&gt;$COMPARTMENT_ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; &lt;span class="s2"&gt;"custom_metrics"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'vllm_e2e_request_latency_seconds[5m]{p99}.max() &amp;gt; 5'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--severity&lt;/span&gt; CRITICAL &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--destinations&lt;/span&gt; &lt;span class="s1"&gt;'["'&lt;/span&gt;&lt;span class="nv"&gt;$NOTIFICATION_TOPIC_ID&lt;/span&gt;&lt;span class="s1"&gt;'"]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sends a PagerDuty/Slack/email alert when p99 inference latency exceeds 5 seconds. The GPU metrics and application metrics are in the same namespace, so you can write alarms that reference both.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Dashboard Panels
&lt;/h2&gt;

&lt;p&gt;If you're setting this up, here's what I'd put on the Grafana dashboard:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;GPU Utilization + Tokens/sec&lt;/strong&gt; - dual-axis, should correlate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPU Memory Used / Total&lt;/strong&gt; - with a threshold line at 90%&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request Queue Depth&lt;/strong&gt; - should be near zero during normal ops&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to First Token (p50, p95, p99)&lt;/strong&gt; - the metric that matters to users&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pod Restart Count&lt;/strong&gt; - OOM kills show up here&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPU Temperature&lt;/strong&gt; - more for curiosity, but useful for thermal throttling detection&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The shift from "GPU dashboard + separate app dashboard" to "one unified dashboard" made debugging 10x faster. OpenTelemetry is the glue that makes it work.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Pavan Madduri - Oracle ACE Associate, CNCF Golden Kubestronaut. Author of &lt;a href="https://github.com/pmady/otel-gpu-receiver" rel="noopener noreferrer"&gt;otel-gpu-receiver&lt;/a&gt;. &lt;a href="https://github.com/pmady" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://linkedin.com/in/pavanmadduri" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://pmady.github.io/" rel="noopener noreferrer"&gt;Website&lt;/a&gt; | &lt;a href="https://scholar.google.com/citations?view_op=list_works&amp;amp;hl=en&amp;amp;user=au0O-8oAAAAJ" rel="noopener noreferrer"&gt;Google Scholar&lt;/a&gt; | &lt;a href="https://www.researchgate.net/profile/Pavan-Madduri-2?ev=hdr_xprf" rel="noopener noreferrer"&gt;ResearchGate&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>oci</category>
      <category>kubernetes</category>
      <category>gpu</category>
      <category>observability</category>
    </item>
    <item>
      <title>Docker Build Cloud Cut My CI Build Times by 75%, Here's How I Wired It to OCIR</title>
      <dc:creator>Pavan Madduri</dc:creator>
      <pubDate>Wed, 01 Jul 2026 14:41:10 +0000</pubDate>
      <link>https://dev.to/pavan_madduri/docker-build-cloud-cut-my-ci-build-times-by-75-heres-how-i-wired-it-to-ocir-2n03</link>
      <guid>https://dev.to/pavan_madduri/docker-build-cloud-cut-my-ci-build-times-by-75-heres-how-i-wired-it-to-ocir-2n03</guid>
      <description>&lt;p&gt;My GPU inference image was 8GB. Building it in GitHub Actions took 14 minutes on the free runner. Pushing to OCIR took another 6 minutes. Twenty minutes of CI time for every commit to main. I was burning through GitHub Actions minutes and my team was complaining about slow deployments.&lt;/p&gt;

&lt;p&gt;Docker Build Cloud offloads the build to Docker's remote builders. They have fast machines with big caches. My 14-minute build dropped to 3 minutes. The push stayed at 6 minutes (that's network, can't speed it up much), but total CI went from 20 minutes to about 9.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Docker Build Cloud Is
&lt;/h2&gt;

&lt;p&gt;It's a remote build service from Docker. Instead of building on your CI runner (which is usually a small VM with cold layer caches), you build on Docker's infrastructure. They cache your layers aggressively across builds.&lt;/p&gt;

&lt;p&gt;You don't change your Dockerfile. You add one setup step to your CI pipeline and change the &lt;code&gt;docker build&lt;/code&gt; command to use the cloud builder. That's it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Before Pipeline
&lt;/h2&gt;

&lt;p&gt;This is what I had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/build.yml (before)&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build and Push&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Login to OCIR&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "${{ secrets.OCIR_TOKEN }}" | docker login iad.ocir.io -u "${{ secrets.OCIR_USER }}" --password-stdin&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build image&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker build -t iad.ocir.io/${{ secrets.TENANCY }}/inference/vllm:${{ github.sha }} .&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Push to OCIR&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker push iad.ocir.io/${{ secrets.TENANCY }}/inference/vllm:${{ github.sha }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build: 14 min. Push: 6 min. Total: ~20 min.&lt;/p&gt;

&lt;p&gt;The GitHub Actions runner starts fresh every time — no layer cache. Every &lt;code&gt;apt-get install&lt;/code&gt;, every &lt;code&gt;pip install&lt;/code&gt;, every &lt;code&gt;go mod download&lt;/code&gt; runs from scratch on every build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The After Pipeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/build.yml (after — with Docker Build Cloud)&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build and Push&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Set up Docker Buildx&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/setup-buildx-action@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cloud&lt;/span&gt;
          &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pmady/oci-builds"&lt;/span&gt;     &lt;span class="c1"&gt;# your Build Cloud org/builder&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Login to Docker (for Build Cloud auth)&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DOCKERHUB_USER }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DOCKERHUB_TOKEN }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Login to OCIR&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iad.ocir.io&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.OCIR_USER }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.OCIR_TOKEN }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build and push&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;iad.ocir.io/${{ secrets.TENANCY }}/inference/vllm:${{ github.sha }}&lt;/span&gt;
            &lt;span class="s"&gt;iad.ocir.io/${{ secrets.TENANCY }}/inference/vllm:latest&lt;/span&gt;
          &lt;span class="na"&gt;cache-from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=registry,ref=iad.ocir.io/${{ secrets.TENANCY }}/inference/vllm:cache&lt;/span&gt;
          &lt;span class="na"&gt;cache-to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=registry,ref=iad.ocir.io/${{ secrets.TENANCY }}/inference/vllm:cache,mode=max&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build: 3 min (layers cached on Build Cloud). Push: 6 min. Total: ~9 min.&lt;/p&gt;

&lt;p&gt;The big difference is the &lt;code&gt;driver: cloud&lt;/code&gt; in the Buildx setup. That sends the build to Docker's builders instead of running it on the GitHub runner. Their builders are faster machines and they persist your layer cache between builds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Build Is So Much Faster
&lt;/h2&gt;

&lt;p&gt;Three reasons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Persistent layer cache.&lt;/strong&gt; On GitHub Actions, every build starts from nothing. On Build Cloud, my base image layers, dependency downloads, and compilation results are all cached from the previous build. Only the layers that actually changed get rebuilt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Better hardware.&lt;/strong&gt; Build Cloud builders have more CPU, more RAM, and faster disks than the free GitHub Actions runners. The compilation step alone runs 2-3x faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Parallel multi-platform builds.&lt;/strong&gt; When I build multi-arch images (amd64 + arm64), Build Cloud runs both architectures in parallel on native hardware. No QEMU emulation. On GitHub Actions, the ARM build was emulated and took 4x longer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registry Cache on OCIR
&lt;/h2&gt;

&lt;p&gt;I store the build cache in OCIR itself using the &lt;code&gt;cache-to: type=registry&lt;/code&gt; option. This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build Cloud uses its own fast cache for immediate rebuilds&lt;/li&gt;
&lt;li&gt;The registry cache in OCIR acts as a secondary cache that any CI runner can pull from&lt;/li&gt;
&lt;li&gt;No external cache service needed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cache image (&lt;code&gt;vllm:cache&lt;/code&gt;) is separate from the production image. It contains all the intermediate layers and gets updated on every build. It's large (~3GB) but OCIR storage is cheap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-Arch With Build Cloud
&lt;/h2&gt;

&lt;p&gt;This is where Build Cloud really shines. Building ARM images on GitHub Actions with QEMU was painfully slow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# With Build Cloud — native ARM + AMD64 builds in parallel&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build and push multi-arch&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v5&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;platforms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;linux/amd64,linux/arm64&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iad.ocir.io/${{ secrets.TENANCY }}/myapp:${{ github.sha }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Build Type&lt;/th&gt;
&lt;th&gt;GitHub Actions (QEMU)&lt;/th&gt;
&lt;th&gt;Docker Build Cloud&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AMD64 only&lt;/td&gt;
&lt;td&gt;14 min&lt;/td&gt;
&lt;td&gt;3 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ARM64 only (emulated)&lt;/td&gt;
&lt;td&gt;45 min&lt;/td&gt;
&lt;td&gt;4 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-arch (both)&lt;/td&gt;
&lt;td&gt;50 min&lt;/td&gt;
&lt;td&gt;5 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The multi-arch build went from 50 minutes to 5 minutes. That's not an optimization, that's a category change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost
&lt;/h2&gt;

&lt;p&gt;Docker Build Cloud has a free tier (200 build minutes/month) and paid plans. For my usage (~50 builds/month averaging 3 minutes each = 150 minutes), the free tier covers it.&lt;/p&gt;

&lt;p&gt;Compare that to what I was spending on GitHub Actions: 20 min × 50 builds = 1,000 minutes/month. The free tier has 2,000 minutes, so I wasn't paying, but I was burning through them fast. Now I use 150 minutes of Build Cloud (free) and ~300 minutes of GitHub Actions (for the push + deploy steps).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup Cost
&lt;/h2&gt;

&lt;p&gt;Getting Build Cloud connected to OCIR took about 15 minutes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a Docker Build Cloud builder in Docker Hub settings&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;DOCKERHUB_USER&lt;/code&gt; and &lt;code&gt;DOCKERHUB_TOKEN&lt;/code&gt; to GitHub secrets&lt;/li&gt;
&lt;li&gt;Change the &lt;code&gt;docker/setup-buildx-action&lt;/code&gt; driver to &lt;code&gt;cloud&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Add the OCIR registry cache config&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No infrastructure to manage, no self-hosted runners to maintain, no cache servers to run.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Pavan Madduri — Oracle ACE Associate, CNCF Golden Kubestronaut. &lt;a href="https://github.com/pmady" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://linkedin.com/in/pavanmadduri" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://pmady.github.io/" rel="noopener noreferrer"&gt;Website&lt;/a&gt; | &lt;a href="https://scholar.google.com/citations?view_op=list_works&amp;amp;hl=en&amp;amp;user=au0O-8oAAAAJ" rel="noopener noreferrer"&gt;Google Scholar&lt;/a&gt; | &lt;a href="https://www.researchgate.net/profile/Pavan-Madduri-2?ev=hdr_xprf" rel="noopener noreferrer"&gt;ResearchGate&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>ci</category>
      <category>oci</category>
      <category>devops</category>
    </item>
    <item>
      <title>How I Run GPU Workloads for 70% Less on OKE Using Preemptible Instances</title>
      <dc:creator>Pavan Madduri</dc:creator>
      <pubDate>Tue, 30 Jun 2026 15:35:49 +0000</pubDate>
      <link>https://dev.to/pavan_madduri/how-i-run-gpu-workloads-for-70-less-on-oke-using-preemptible-instances-4m2e</link>
      <guid>https://dev.to/pavan_madduri/how-i-run-gpu-workloads-for-70-less-on-oke-using-preemptible-instances-4m2e</guid>
      <description>&lt;p&gt;I was spending ~$3,300/month on three A10 GPU instances for a mix of staging inference, batch processing, and experimentation. All on-demand. Then I switched two of the three to preemptible instances and my GPU bill dropped to about $1,450/month.&lt;/p&gt;

&lt;p&gt;The trade-off is that OCI can reclaim preemptible instances with 30 seconds notice. For customer-facing production inference, that's a non-starter. For everything else I was running, it was fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Preemptible Instances Actually Mean
&lt;/h2&gt;

&lt;p&gt;OCI preemptible instances are spare capacity sold at a steep discount. A10 GPU goes from $1.52/hr (on-demand) to ~$0.46/hr (preemptible). That's a 70% discount.&lt;/p&gt;

&lt;p&gt;The catch: OCI can terminate the instance when it needs the capacity back. You get a 30-second warning via instance metadata. Your workload needs to handle this gracefully.&lt;/p&gt;

&lt;p&gt;In practice, I've been running preemptible GPU nodes on OKE for two months and I've had maybe 4-5 evictions total. Some weeks zero. It depends on demand in your region and availability domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  OKE Node Pool Setup
&lt;/h2&gt;

&lt;p&gt;I run two GPU node pools one on-demand for production, one preemptible for everything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Production GPU pool always available&lt;/span&gt;
oci ce node-pool create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; gpu-production &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-shape&lt;/span&gt; VM.GPU.A10.1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-config-details&lt;/span&gt; &lt;span class="s1"&gt;'{
    "size": 1,
    "placementConfigs": [{
      "availabilityDomain": "Uocm:US-ASHBURN-AD-1",
      "subnetId": "'&lt;/span&gt;&lt;span class="nv"&gt;$SUBNET_ID&lt;/span&gt;&lt;span class="s1"&gt;'",
      "preemptibleNodeConfig": null
    }]
  }'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ...

&lt;span class="c"&gt;# Preemptible GPU pool cheap, may get evicted&lt;/span&gt;
oci ce node-pool create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; gpu-preemptible &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-shape&lt;/span&gt; VM.GPU.A10.1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-config-details&lt;/span&gt; &lt;span class="s1"&gt;'{
    "size": 2,
    "placementConfigs": [{
      "availabilityDomain": "Uocm:US-ASHBURN-AD-1",
      "subnetId": "'&lt;/span&gt;&lt;span class="nv"&gt;$SUBNET_ID&lt;/span&gt;&lt;span class="s1"&gt;'",
      "preemptibleNodeConfig": {
        "preemptionAction": {
          "type": "TERMINATE",
          "isPreserveBootVolume": false
        }
      }
    }]
  }'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-metadata&lt;/span&gt; &lt;span class="s1"&gt;'{"user_data": "..."}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--initial-node-labels&lt;/span&gt; &lt;span class="s1"&gt;'[
    {"key": "node-type", "value": "preemptible"},
    {"key": "nvidia.com/gpu", "value": "present"}
  ]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The label &lt;code&gt;node-type=preemptible&lt;/code&gt; is how I control which workloads land on preemptible nodes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Directing Workloads to the Right Pool
&lt;/h2&gt;

&lt;p&gt;Production inference uses a node affinity to avoid preemptible nodes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Production on-demand only&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm-production&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;affinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;nodeAffinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;requiredDuringSchedulingIgnoredDuringExecution&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;nodeSelectorTerms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matchExpressions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node-type&lt;/span&gt;
                    &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NotIn&lt;/span&gt;
                    &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preemptible"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;nvidia.com/gpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Staging and batch workloads prefer preemptible (but tolerate on-demand if preemptible nodes are unavailable):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Staging prefer preemptible, accept on-demand as fallback&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm-staging&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;affinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;nodeAffinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;preferredDuringSchedulingIgnoredDuringExecution&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;90&lt;/span&gt;
              &lt;span class="na"&gt;preference&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;matchExpressions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node-type&lt;/span&gt;
                    &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&lt;/span&gt;
                    &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preemptible"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;tolerations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preemptible"&lt;/span&gt;
          &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Exists"&lt;/span&gt;
          &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NoSchedule"&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;nvidia.com/gpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Handling Eviction Gracefully
&lt;/h2&gt;

&lt;p&gt;When OCI reclaims a preemptible instance, the node drains and pods get terminated. For inference services, this means requests in flight get dropped. Here's how I handle it:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Pod Disruption Budget
&lt;/h3&gt;

&lt;p&gt;Prevents all replicas from being evicted simultaneously (only matters if you have &amp;gt;1 replica):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;policy/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PodDisruptionBudget&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm-staging-pdb&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;minAvailable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm-staging&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Graceful Shutdown
&lt;/h3&gt;

&lt;p&gt;vLLM handles SIGTERM and finishes in-flight requests before shutting down. I set &lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt; to 25 (less than the 30-second eviction notice):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;terminationGracePeriodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;25&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm&lt;/span&gt;
      &lt;span class="na"&gt;lifecycle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;preStop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;exec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/bin/sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sleep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;5"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 5-second preStop sleep gives the load balancer time to stop sending new requests before the container starts shutting down.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Model Cache on PVC
&lt;/h3&gt;

&lt;p&gt;When a pod gets evicted and rescheduled to a new preemptible node, I don't want to re-download the model from scratch. I use the OCI Object Storage init container approach (from my earlier post) so the model loads in ~90 seconds instead of 12 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actual Numbers
&lt;/h2&gt;

&lt;p&gt;Two months of data:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Eviction events&lt;/td&gt;
&lt;td&gt;9 total (avg ~1/week)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Avg time to recover&lt;/td&gt;
&lt;td&gt;~2 minutes (pod reschedule + model load)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Longest outage&lt;/td&gt;
&lt;td&gt;4 minutes (node provisioning + model load)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly cost (3x on-demand)&lt;/td&gt;
&lt;td&gt;$3,282&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly cost (1x on-demand + 2x preemptible)&lt;/td&gt;
&lt;td&gt;~$1,450&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Savings&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$1,832/month (56%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The evictions cluster — I had three in one day during what I assume was a capacity crunch in us-ashburn-1, then nothing for two weeks. Unpredictable, but the recovery is fast enough that nobody on the team complained.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batch Jobs on Preemptible Even Better
&lt;/h2&gt;

&lt;p&gt;For batch inference (processing a dataset, not serving live traffic), preemptible is almost a no-brainer. I use Kubernetes Jobs with checkpointing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Job&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch-inference&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backoffLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;    &lt;span class="c1"&gt;# retry up to 5 times if evicted&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;nodeSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;node-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;preemptible&lt;/span&gt;
      &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OnFailure&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inference&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iad.ocir.io/mytenancy/batch-inference:v1&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CHECKPOINT_BUCKET&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inference-checkpoints"&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;nvidia.com/gpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The batch job saves progress to OCI Object Storage every N records. If it gets evicted, Kubernetes restarts it and it picks up from the last checkpoint. I've had batch jobs complete across 3 evictions without losing any work.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Use Preemptible GPUs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Staging/dev environments&lt;/strong&gt;: latency spikes from eviction are fine&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch inference&lt;/strong&gt;: checkpoint and retry&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Training runs&lt;/strong&gt;: if your framework supports checkpointing (most do)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Experimentation&lt;/strong&gt;: exploring models, testing prompts&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When Not To
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Customer-facing inference&lt;/strong&gt;: use on-demand, the cost is worth the reliability&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short-deadline batch&lt;/strong&gt;: if the job must finish by a specific time, eviction adds unpredictability&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-replica production&lt;/strong&gt;: no fallback when the one instance gets evicted&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Pavan Madduri: Oracle ACE Associate, CNCF Golden Kubestronaut. &lt;a href="https://github.com/pmady" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://linkedin.com/in/pavanmadduri" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://pmady.github.io/" rel="noopener noreferrer"&gt;Website&lt;/a&gt; | &lt;a href="https://scholar.google.com/citations?view_op=list_works&amp;amp;hl=en&amp;amp;user=au0O-8oAAAAJ" rel="noopener noreferrer"&gt;Google Scholar&lt;/a&gt; | &lt;a href="https://www.researchgate.net/profile/Pavan-Madduri-2?ev=hdr_xprf" rel="noopener noreferrer"&gt;ResearchGate&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>oci</category>
      <category>kubernetes</category>
      <category>gpu</category>
      <category>infrastructure</category>
    </item>
    <item>
      <title>Running Ollama on OCI Container Instances - Private LLM API in 5 Minutes, No Kubernetes</title>
      <dc:creator>Pavan Madduri</dc:creator>
      <pubDate>Mon, 29 Jun 2026 14:53:58 +0000</pubDate>
      <link>https://dev.to/pavan_madduri/running-ollama-on-oci-container-instances-private-llm-api-in-5-minutes-no-kubernetes-5dp0</link>
      <guid>https://dev.to/pavan_madduri/running-ollama-on-oci-container-instances-private-llm-api-in-5-minutes-no-kubernetes-5dp0</guid>
      <description>&lt;p&gt;A colleague asked me to set up a private LLM endpoint their team could use for code review suggestions. Requirements: OpenAI-compatible API, runs inside our cloud (no data leaving the tenancy), and "I don't want to learn Kubernetes."&lt;/p&gt;

&lt;p&gt;That last requirement ruled out OKE. And honestly, for a single-model inference endpoint serving 10 people, Kubernetes is overkill anyway.&lt;/p&gt;

&lt;p&gt;I had Ollama running on an OCI Container Instance with a GPU in about 5 minutes. Here's the whole thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Ollama Instead of vLLM
&lt;/h2&gt;

&lt;p&gt;For a small team endpoint, Ollama wins on simplicity:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Single binary, no Python dependencies&lt;/li&gt;
&lt;li&gt;Downloads models automatically on first run&lt;/li&gt;
&lt;li&gt;Manages multiple models with simple &lt;code&gt;ollama pull&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Built-in OpenAI-compatible API at &lt;code&gt;/v1/chat/completions&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Handles model loading/unloading from GPU memory automatically&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;vLLM is better for high-throughput production (continuous batching, PagedAttention), but this isn't that. This is "10 developers hitting it a few times an hour."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Deployment
&lt;/h2&gt;

&lt;p&gt;One CLI command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oci container-instances container-instance create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--compartment-id&lt;/span&gt; &lt;span class="nv"&gt;$COMPARTMENT_ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--availability-domain&lt;/span&gt; &lt;span class="s2"&gt;"Uocm:US-ASHBURN-AD-1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--display-name&lt;/span&gt; &lt;span class="s2"&gt;"team-ollama"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--shape&lt;/span&gt; &lt;span class="s2"&gt;"CI.Standard.GPU.A10.1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--shape-config&lt;/span&gt; &lt;span class="s1"&gt;'{"ocpus": 15, "memoryInGBs": 240}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--containers&lt;/span&gt; &lt;span class="s1"&gt;'[{
    "imageUrl": "docker.io/ollama/ollama:latest",
    "displayName": "ollama",
    "resourceConfig": {
      "vcpusLimit": 15,
      "memoryLimitInGBs": 240
    },
    "environmentVariables": {
      "OLLAMA_HOST": "0.0.0.0"
    },
    "healthChecks": [{
      "healthCheckType": "HTTP",
      "port": 11434,
      "path": "/",
      "intervalInSeconds": 30
    }]
  }]'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vnics&lt;/span&gt; &lt;span class="s1"&gt;'[{
    "subnetId": "'&lt;/span&gt;&lt;span class="nv"&gt;$PRIVATE_SUBNET_ID&lt;/span&gt;&lt;span class="s1"&gt;'",
    "isPublicIpAssigned": false
  }]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Few things to note:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GPU shape&lt;/strong&gt; — &lt;code&gt;CI.Standard.GPU.A10.1&lt;/code&gt; gives you an A10 GPU with 24GB VRAM. Enough for most 7-13B parameter models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private subnet&lt;/strong&gt; — No public IP. The endpoint is only accessible from within the VCN. I added a bastion or VPN for the team to reach it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;OLLAMA_HOST=0.0.0.0&lt;/code&gt;&lt;/strong&gt; — By default Ollama only listens on localhost. Inside a container, you need it to listen on all interfaces.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The container starts in about 10 seconds. But no model is loaded yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loading the Model
&lt;/h2&gt;

&lt;p&gt;Ollama downloads models on first use. I SSH'd through the bastion and triggered the first pull:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# From a VM in the same VCN&lt;/span&gt;
&lt;span class="nv"&gt;OLLAMA_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.0.1.42  &lt;span class="c"&gt;# private IP of the Container Instance&lt;/span&gt;

&lt;span class="c"&gt;# Pull a model (downloads to container's filesystem)&lt;/span&gt;
curl http://&lt;span class="nv"&gt;$OLLAMA_IP&lt;/span&gt;:11434/api/pull &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"name": "llama3.1:8b"}'&lt;/span&gt;

&lt;span class="c"&gt;# Test it&lt;/span&gt;
curl http://&lt;span class="nv"&gt;$OLLAMA_IP&lt;/span&gt;:11434/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "model": "llama3.1:8b",
    "messages": [{"role": "user", "content": "Review this Go function for bugs: func add(a, b int) int { return a - b }"}]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The initial model download takes 3-4 minutes (7B model, ~4GB). After that, responses start within a second or two.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Model Persistence
&lt;/h2&gt;

&lt;p&gt;Here's the catch I should have thought about earlier. Container Instances don't have persistent storage by default. If the container restarts, the downloaded model is gone. You have to pull it again.&lt;/p&gt;

&lt;p&gt;My fix was mounting an OCI Block Volume:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oci container-instances container-instance create &lt;span class="se"&gt;\&lt;/span&gt;
  ... &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--containers&lt;/span&gt; &lt;span class="s1"&gt;'[{
    "imageUrl": "docker.io/ollama/ollama:latest",
    "volumeMounts": [{
      "mountPath": "/root/.ollama",
      "volumeName": "model-storage"
    }]
  }]'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--volumes&lt;/span&gt; &lt;span class="s1"&gt;'[{
    "name": "model-storage",
    "volumeType": "EMPTYDIR",
    "backingStore": "EPHEMERAL_STORAGE"
  }]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For true persistence across container recreations, you'd use an OCI File Storage (NFS) mount. But for this use case, the ephemeral storage survives restarts (not recreations), and I have a simple curl script that re-pulls the model if it's missing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# warmup.sh — run after container instance creation&lt;/span&gt;
&lt;span class="nv"&gt;OLLAMA_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;

&lt;span class="c"&gt;# Wait for Ollama to be ready&lt;/span&gt;
&lt;span class="k"&gt;until &lt;/span&gt;curl &lt;span class="nt"&gt;-sf&lt;/span&gt; http://&lt;span class="nv"&gt;$OLLAMA_IP&lt;/span&gt;:11434/ &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;2
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# Pull model if not present&lt;/span&gt;
curl &lt;span class="nt"&gt;-sf&lt;/span&gt; http://&lt;span class="nv"&gt;$OLLAMA_IP&lt;/span&gt;:11434/api/show &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"name":"llama3.1:8b"}'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null 2&amp;gt;&amp;amp;1
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt; &lt;span class="nt"&gt;-ne&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Pulling model..."&lt;/span&gt;
  curl http://&lt;span class="nv"&gt;$OLLAMA_IP&lt;/span&gt;:11434/api/pull &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"name": "llama3.1:8b"}'&lt;/span&gt;
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Ready at http://&lt;/span&gt;&lt;span class="nv"&gt;$OLLAMA_IP&lt;/span&gt;&lt;span class="s2"&gt;:11434"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What the Team Uses It For
&lt;/h2&gt;

&lt;p&gt;The endpoint has been running for three weeks. The team uses it for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Code review suggestions&lt;/strong&gt; — paste a function, ask for review&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commit message generation&lt;/strong&gt; — describe changes, get a conventional commit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documentation drafts&lt;/strong&gt; — generate docstrings and README sections&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQL query help&lt;/strong&gt; — describe what they want, get a query back&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Traffic is light — maybe 50-100 requests/day total. The A10 GPU sits at 5-15% utilization most of the time. It's overkill, but even overkill on OCI is only ~$1,094/month, and the team finds it useful enough to justify the cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost vs. Alternatives
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Monthly Cost&lt;/th&gt;
&lt;th&gt;Setup Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OCI Container Instance + A10 GPU&lt;/td&gt;
&lt;td&gt;~$1,094&lt;/td&gt;
&lt;td&gt;5 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI API (estimated 100 req/day)&lt;/td&gt;
&lt;td&gt;~$30-150&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted on OKE&lt;/td&gt;
&lt;td&gt;~$1,094 + complexity&lt;/td&gt;
&lt;td&gt;30-60 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Yeah, OpenAI is cheaper for this volume. But the team's requirement was "no data leaving our cloud." Compliance rules. The Container Instance approach gave them a private endpoint with zero Kubernetes complexity. Sometimes you pay for simplicity and privacy.&lt;/p&gt;

&lt;h2&gt;
  
  
  If I Were Doing This Again
&lt;/h2&gt;

&lt;p&gt;I'd use OCI File Storage instead of ephemeral storage so models survive container recreation. And I'd put an OCI API Gateway in front of it for rate limiting and auth, instead of relying on network-level access control. The gateway adds about $50/month but gives you proper API keys and request logging.&lt;/p&gt;

&lt;p&gt;For teams larger than ~20 people or with higher throughput needs, I'd switch to vLLM on OKE with the setup I described in my earlier posts. But for a small team that just wants a private LLM without touching Kubernetes? Ollama on Container Instances is hard to beat for simplicity.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Pavan Madduri — Oracle ACE Associate, CNCF Golden Kubestronaut. &lt;a href="https://github.com/pmady" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://linkedin.com/in/pavanmadduri" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://pmady.github.io/" rel="noopener noreferrer"&gt;Website&lt;/a&gt; | &lt;a href="https://scholar.google.com/citations?view_op=list_works&amp;amp;hl=en&amp;amp;user=au0O-8oAAAAJ" rel="noopener noreferrer"&gt;Google Scholar&lt;/a&gt; | &lt;a href="https://www.researchgate.net/profile/Pavan-Madduri-2?ev=hdr_xprf" rel="noopener noreferrer"&gt;ResearchGate&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>docker</category>
      <category>oci</category>
      <category>llm</category>
    </item>
    <item>
      <title>Zero-Downtime Crossplane v1 v2 Migration: Adopt-in-Place at Production Scale</title>
      <dc:creator>Pavan Madduri</dc:creator>
      <pubDate>Fri, 26 Jun 2026 01:33:13 +0000</pubDate>
      <link>https://dev.to/pavan_madduri/zero-downtime-crossplane-v1-v2-migration-adopt-in-place-at-production-scale-2l6m</link>
      <guid>https://dev.to/pavan_madduri/zero-downtime-crossplane-v1-v2-migration-adopt-in-place-at-production-scale-2l6m</guid>
      <description>&lt;p&gt;Crossplane v2 (released in late 2025) introduced a cleaner, namespaced resource model and removed a lot of the v1 ceremony around Claims and cluster-scoped composites. Upgrading the &lt;em&gt;control plane&lt;/em&gt; to v2 is usually painless — if you're not using the v1 features that changed, your existing claims keep working thanks to backward compatibility.&lt;/p&gt;

&lt;p&gt;The hard part is the next step: &lt;strong&gt;migrating your existing v1-style workloads onto v2-style namespaced resources.&lt;/strong&gt; That's where there's still no cohesive, end-to-end story — and it's where I spent most of my effort taking a production EKS fleet all the way through.&lt;/p&gt;

&lt;p&gt;This post is the field guide I wish I'd had: the adopt-in-place method, how to validate it before touching anything, and the three failure modes that will bite you in production.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Nothing here destroys or recreates cloud infrastructure. The whole point is to keep every existing AWS resource exactly where it is and just change &lt;em&gt;which Crossplane resource owns it&lt;/em&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The setup (in generic terms)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A control plane running Crossplane, managing &lt;strong&gt;Amazon EKS clusters&lt;/strong&gt; end to end.&lt;/li&gt;
&lt;li&gt;Each cluster is represented by a Crossplane composite, which in turn owns &lt;strong&gt;~90–100 managed resources (MRs)&lt;/strong&gt;: IAM roles/policies, the EKS cluster, EKS add-ons, a managed NodeGroup, a launch template, security groups and rules, an OIDC provider, and a pile of &lt;code&gt;Object&lt;/code&gt;s managed through &lt;code&gt;provider-kubernetes&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Several distinct cluster archetypes, each backed by its own Composition (think: general workload clusters, ingress/gateway clusters, and stateful clusters). Same migration mechanics, slightly different resource sets.&lt;/li&gt;
&lt;li&gt;GitOps-driven: a Git repository is the source of truth, reconciled by a GitOps controller.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Constraints that shaped everything: &lt;strong&gt;no resource recreation, no node rotation, zero downtime.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why "delete and recreate" is a non-starter
&lt;/h2&gt;

&lt;p&gt;The naive migration is: delete the v1 composite, create the v2 XR, let the provider rebuild everything. In production that's a non-starter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You cannot destroy a VPC, an EKS control plane, or a live NodeGroup and rebuild it under traffic.&lt;/li&gt;
&lt;li&gt;Even Crossplane's &lt;code&gt;Observe&lt;/code&gt; /import flows leave a window where the resource is briefly unmanaged or re-created.&lt;/li&gt;
&lt;li&gt;Anything that recreates a NodeGroup triggers a &lt;strong&gt;node rotation&lt;/strong&gt; — every pod on the cluster gets evicted and rescheduled. That's a customer-visible event you do not want as a side effect of an internal refactor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the goal isn't "create v2 resources." It's "make a v2 XR &lt;em&gt;adopt&lt;/em&gt; the exact resources the v1 composite already owns, with zero observable change."&lt;/p&gt;




&lt;h2&gt;
  
  
  How Crossplane decides what to create vs. adopt
&lt;/h2&gt;

&lt;p&gt;Two facts make adopt-in-place possible:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;External-name is the source of truth for the real cloud resource.&lt;/strong&gt; Crossplane reconciles a managed resource against the actual AWS object identified by its &lt;code&gt;crossplane.io/external-name&lt;/code&gt; annotation. If a v2-owned MR has the same external-name as the live AWS resource, Crossplane &lt;em&gt;observes&lt;/em&gt; it instead of creating a new one.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Ownership is expressed by a label + an ownerReference.&lt;/strong&gt; A composed MR points back to its owning composite via:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;code&gt;crossplane.io/composite&lt;/code&gt; label, and&lt;/li&gt;
&lt;li&gt;a Kubernetes &lt;code&gt;ownerReference&lt;/code&gt; to the owning XR (name + UID).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Within a composition, each MR is keyed by a "composition-resource-name" (crn)&lt;/strong&gt; — the &lt;code&gt;crossplane.io/composition-resource-name&lt;/code&gt; annotation. The engine matches the &lt;em&gt;desired&lt;/em&gt; resource the composition wants to produce against the &lt;em&gt;observed&lt;/em&gt; MR with the same crn. Same crn + same external-name → adopt in place. Different crn → the engine thinks the desired resource is missing and creates a new one (and treats the old one as an orphan).&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Adopt-in-place is just: rewrite ownership (#2) and make crn + external-name line up (#1, #3) so the v2 composition's desired output matches what's already there.&lt;/p&gt;




&lt;h2&gt;
  
  
  The adopt-in-place method, step by step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 0 — Snapshot and pre-validate (do this before touching prod)
&lt;/h3&gt;

&lt;p&gt;Before any mutation, capture the live state and prove the v2 composition will adopt rather than recreate. Crossplane's render command can do this offline against observed resources:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;crossplane beta render &lt;span class="se"&gt;\&lt;/span&gt;
  xr.yaml &lt;span class="se"&gt;\&lt;/span&gt;
  composition.yaml &lt;span class="se"&gt;\&lt;/span&gt;
  functions.yaml &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--observed-resources&lt;/span&gt; ./observed/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;./observed/&lt;/code&gt; holds the live MRs (exported from the cluster). The command prints the &lt;em&gt;desired&lt;/em&gt; resources the v2 composition would produce. Diff desired vs. observed and classify every resource:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;adopt-in-place&lt;/strong&gt; — desired crn (after remap, see below) and external-name match an observed MR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;net-new&lt;/strong&gt; — desired resource the v2 composition adds that v1 didn't have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;orphan&lt;/strong&gt; — observed MR that the v2 composition doesn't produce.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Gate the migration on: &lt;strong&gt;zero orphans, zero unexpected net-new.&lt;/strong&gt; This single offline check caught every surprise before it reached production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — Pause both the claim and the composite
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl annotate &amp;lt;claim-kind&amp;gt; &amp;lt;name&amp;gt; crossplane.io/paused&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"true"&lt;/span&gt;
kubectl annotate &amp;lt;composite-kind&amp;gt; &amp;lt;name&amp;gt; crossplane.io/paused&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pausing only the claim is a classic mistake — the &lt;strong&gt;composite keeps reconciling&lt;/strong&gt; and will fight you. Pause both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Reparent every managed resource
&lt;/h3&gt;

&lt;p&gt;For each MR owned by the v1 composite, repoint ownership at the new v2 XR:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;set the &lt;code&gt;crossplane.io/composite&lt;/code&gt; label to the v2 XR name,&lt;/li&gt;
&lt;li&gt;replace the &lt;code&gt;ownerReference&lt;/code&gt; with one pointing at the v2 XR (kind, name, &lt;strong&gt;UID&lt;/strong&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;crossplane.io/composite&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;v2-xr-name&amp;gt;&lt;/span&gt;     &lt;span class="c1"&gt;# was: &amp;lt;v1-composite-name&amp;gt;&lt;/span&gt;
  &lt;span class="na"&gt;ownerReferences&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;v2-xr-apiVersion&amp;gt;&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;v2-xr-kind&amp;gt;&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;v2-xr-name&amp;gt;&lt;/span&gt;
      &lt;span class="na"&gt;uid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;v2-xr-uid&amp;gt;&lt;/span&gt;                          &lt;span class="c1"&gt;# the new XR's uid&lt;/span&gt;
      &lt;span class="na"&gt;controller&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;blockOwnerDeletion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Script this across all ~90–100 MRs; doing it by hand is how you get an inconsistent state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — Point the v2 XR at the adopted resources
&lt;/h3&gt;

&lt;p&gt;Patch the v2 XR so it references the composition and the exact resources it's adopting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;crossplane&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;compositionRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;v2-composition&amp;gt;&lt;/span&gt;
    &lt;span class="na"&gt;resourceRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;...&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;...&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;&amp;lt;mr-1&amp;gt;&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;...&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;...&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;&amp;lt;mr-2&amp;gt;&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="c1"&gt;# ... all adopted MRs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the XR &lt;strong&gt;paused&lt;/strong&gt; while you do this (create it paused from the start).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — Unpause and let it converge
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl annotate &amp;lt;v2-xr-kind&amp;gt; &amp;lt;name&amp;gt; crossplane.io/paused-
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine reconciles, matches desired↔observed by crn + external-name, and adopts. Watch the XR and its MRs go &lt;code&gt;Synced=True&lt;/code&gt;/&lt;code&gt;Ready=True&lt;/code&gt; without any &lt;code&gt;Create&lt;/code&gt; calls hitting AWS.&lt;/p&gt;




&lt;h2&gt;
  
  
  The three failure modes that will bite you
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. NodeGroup composition-resource-name drift (blue/green)
&lt;/h3&gt;

&lt;p&gt;This is the one most likely to cause a real incident.&lt;/p&gt;

&lt;p&gt;Our v1 composition emitted the managed NodeGroup with one crn (e.g. a blue/green-style &lt;code&gt;nodegroup-active&lt;/code&gt;), while the v2 composition emits a different crn (e.g. &lt;code&gt;nodegroup&lt;/code&gt;). Because the engine matches desired↔observed by crn, the mismatch means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the v2 composition's desired &lt;code&gt;nodegroup&lt;/code&gt; has &lt;strong&gt;no&lt;/strong&gt; matching observed MR → it wants to &lt;strong&gt;create&lt;/strong&gt; one, and&lt;/li&gt;
&lt;li&gt;the live NodeGroup (crn &lt;code&gt;nodegroup-active&lt;/code&gt;) has no matching desired resource → it's treated as an &lt;strong&gt;orphan&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The net effect would be a brand-new NodeGroup and a rotation of every node.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; remap the crn annotation on the &lt;em&gt;live&lt;/em&gt; NodeGroup to match what the v2 composition expects, and &lt;strong&gt;preserve the existing NodeGroup name/external-name&lt;/strong&gt;. Don't touch external-name — that's what keeps it bound to the real AWS NodeGroup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl annotate nodegroup.&amp;lt;group&amp;gt; &amp;lt;existing-ng-name&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  crossplane.io/composition-resource-name&lt;span class="o"&gt;=&lt;/span&gt;nodegroup &lt;span class="nt"&gt;--overwrite&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verify no rotation&lt;/strong&gt; after cutover by confirming the launch-template name+version and the NodeGroup version are unchanged from before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get nodegroup.&amp;lt;group&amp;gt; &amp;lt;name&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'LT={.status.atProvider.launchTemplate.name}:v{.status.atProvider.launchTemplate.version} ver={.status.atProvider.version}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same values before and after = the existing nodes were adopted, not replaced.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The cluster-auth connection-secret republish race
&lt;/h3&gt;

&lt;p&gt;The subtlest one — a silent failure if you're not watching for it.&lt;/p&gt;

&lt;p&gt;For EKS, a managed "cluster auth" resource generates the kubeconfig (a short-lived token) and writes it to a connection &lt;code&gt;Secret&lt;/code&gt;. The &lt;code&gt;provider-kubernetes&lt;/code&gt; &lt;code&gt;ProviderConfig&lt;/code&gt; reads that Secret to talk to the workload cluster, and every &lt;code&gt;Object&lt;/code&gt; on that cluster depends on it.&lt;/p&gt;

&lt;p&gt;When the v2 XR took ownership, the connection Secret got &lt;strong&gt;recreated empty&lt;/strong&gt;. If the cluster-auth resource's last token refresh happened &lt;em&gt;before&lt;/em&gt; that recreation, it didn't immediately republish — so the Secret stayed empty. Every downstream &lt;code&gt;Object&lt;/code&gt; then stranded with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cannot build kube client for provider config: currentContext not set in kubeconfig
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On most clusters this self-healed on the cluster-auth resource's next refresh cycle. On one, the timing left it stuck for several minutes with no sign of recovering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; force the cluster-auth resource to reconcile so it republishes the kubeconfig. A benign annotation bump does it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl annotate &amp;lt;clusterauth-kind&amp;gt; &amp;lt;name&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  example.com/republish&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--overwrite&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The connection Secret repopulates, and the stranded &lt;code&gt;Object&lt;/code&gt;s build their client and sync. The lesson: &lt;strong&gt;adopting an MR can re-create its connection Secret out from under downstream consumers.&lt;/strong&gt; Put health checks on the &lt;em&gt;downstream&lt;/em&gt; objects, not just the cluster resource, or you'll never see it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. GitOps source-of-truth drift
&lt;/h3&gt;

&lt;p&gt;The live cutover above is imperative. Your GitOps repo still describes the v1 world. Until you reconcile it, your GitOps controller will try to "fix" the cluster back toward the manifests — unpausing the v1 claim, or having no record of the v2 XR at all.&lt;/p&gt;

&lt;p&gt;Treat the &lt;em&gt;cluster&lt;/em&gt; migration and the &lt;em&gt;source-of-truth&lt;/em&gt; migration as two separate workstreams. After the live cutover, land a Git change that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;adds &lt;code&gt;crossplane.io/paused: "true"&lt;/code&gt; to the v1 claim manifests, and&lt;/li&gt;
&lt;li&gt;adds the v2 XR manifests &lt;strong&gt;without&lt;/strong&gt; the paused annotation (so the controller manages them as the active resources).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Make sure auto-sync/self-heal won't revert your live state in the gap between cutover and merge.&lt;/p&gt;




&lt;h2&gt;
  
  
  A repeatable runbook
&lt;/h2&gt;

&lt;p&gt;Boiled down, every cluster followed the same pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;snapshot&lt;/strong&gt; — export live MRs, claim, composite, and composition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;render-gate&lt;/strong&gt; — &lt;code&gt;beta render --observed-resources&lt;/code&gt; + diff; require zero orphans / zero unexpected net-new; confirm the NodeGroup crn remap and that launch-template/version match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;reparent&lt;/strong&gt; — script the label + ownerReference rewrite for all MRs (with a rollback script that puts them back).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;patch&lt;/strong&gt; — set the v2 XR &lt;code&gt;compositionRef&lt;/code&gt; + &lt;code&gt;resourceRefs&lt;/code&gt; (still paused).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;pause v1 → unpause v2.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;health-gate&lt;/strong&gt; — every MR &lt;code&gt;Synced&lt;/code&gt;/&lt;code&gt;Ready&lt;/code&gt; excluding a known-baseline set; NodeGroup unchanged; downstream &lt;code&gt;Object&lt;/code&gt;s connected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;reconcile Git&lt;/strong&gt; — pause v1 manifests, add unpaused v2 manifests.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do non-prod first, build the runbook, then prod. Keep the v1 composite paused (not deleted) for a cooldown period so rollback is a single unpause away.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the ecosystem still needs
&lt;/h2&gt;

&lt;p&gt;Most of the above was hand-rolled. A few things would turn this from "expert-only surgery" into a supported workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A migrate command&lt;/strong&gt; that, given a v1 claim/composite and a target v2 composition, generates the reparent patches, the v2 XR with populated &lt;code&gt;resourceRefs&lt;/code&gt;, and — critically — a &lt;strong&gt;crn remap table&lt;/strong&gt; between the two compositions. Matching must be by external resource identity, not crn string equality.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An adopt-preview/dry-run&lt;/strong&gt; that classifies every MR as adopt-in-place / net-new / orphan and gates on zero orphans before proceeding (productizing the &lt;code&gt;render --observed-resources&lt;/code&gt; diff).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connection-secret-aware adoption&lt;/strong&gt; — on adoption, force a reconcile or wait on connection-secret readiness so downstream providers don't lose connectivity.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There's an active community effort around exactly this (a maintainer-run feedback discussion and a migration-tooling tracking issue, plus a community CLI for migrating composition manifests). If you've done a migration like this, your war stories are genuinely useful input — the design is still being shaped.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Adopt, don't recreate.&lt;/strong&gt; Make a v2 XR own the exact MRs the v1 composite owned; never let external-names change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate offline first.&lt;/strong&gt; &lt;code&gt;beta render --observed-resources&lt;/code&gt; + a desired/observed diff is the single highest-leverage safety check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;crn alignment is everything&lt;/strong&gt; for NodeGroups — a mismatch is the difference between a silent adoption and a full node rotation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch your connection secrets.&lt;/strong&gt; Adoption can recreate them empty; downstream consumers fail silently until the owner republishes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two migrations, not one.&lt;/strong&gt; The live cluster and the GitOps source of truth move separately — plan for both.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's very doable to take a production fleet from v1 to v2 with zero downtime today — it just isn't yet a one-command experience. Hopefully this shortens the path for the next person.&lt;/p&gt;

</description>
      <category>crossplane</category>
      <category>kubernetes</category>
      <category>aws</category>
      <category>platformengineering</category>
    </item>
    <item>
      <title>Give Your AI Agent Eyes on GPUs: Introducing gpu-mcp-server</title>
      <dc:creator>Pavan Madduri</dc:creator>
      <pubDate>Wed, 24 Jun 2026 19:34:39 +0000</pubDate>
      <link>https://dev.to/pavan_madduri/give-your-ai-agent-eyes-on-gpus-introducing-gpu-mcp-server-54d4</link>
      <guid>https://dev.to/pavan_madduri/give-your-ai-agent-eyes-on-gpus-introducing-gpu-mcp-server-54d4</guid>
      <description>&lt;p&gt;Your AI agent can write code, search the web, and query databases. But ask it what's happening on your GPUs right now utilization, memory pressure, thermals and it draws a blank. That's because there's been no standard way for agents to access hardware telemetry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;gpu-mcp-server&lt;/strong&gt; changes that. It's an open source MCP server that gives any AI agent real-time access to NVIDIA GPU metrics through the Model Context Protocol.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;If you're running GPU workloads inference servers, training jobs, fine-tuning pipelines you probably check GPU status the same way everyone does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nvidia-smi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works for humans. It doesn't work for agents.&lt;/p&gt;

&lt;p&gt;When an agent needs to decide whether to scale up replicas, diagnose a slow inference endpoint, or figure out why a training job OOM'd, it has no way to see the hardware layer. It's flying blind.&lt;/p&gt;

&lt;p&gt;Monitoring stacks like Prometheus + dcgm-exporter solve this for dashboards, but they're heavy infrastructure that agents can't easily query through MCP.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gpu-mcp-server Does
&lt;/h2&gt;

&lt;p&gt;It's a single Go binary that runs alongside your agent and exposes three tools over the Model Context Protocol:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What It Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list_gpus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Lists every GPU on the machine with utilization and memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_gpu_metrics&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Deep dive on one GPU temp, power, PCIe/NVLink throughput&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gpu_summary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Aggregate stats across all devices&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The server talks to GPUs through NVIDIA's NVML C library directly - no Prometheus, no metric pipelines, no extra infrastructure.&lt;/p&gt;

&lt;h3&gt;
  
  
  MIG Support Built In
&lt;/h3&gt;

&lt;p&gt;If you're running Multi-Instance GPU (MIG) on A100s or H100s, each MIG instance shows up as a separate device. Shared metrics (temperature, power, PCIe) are pulled from the physical GPU. Per-instance metrics (utilization, memory) come from the MIG slice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why MCP?
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; is an open standard now hosted by the &lt;a href="https://aaif.io/" rel="noopener noreferrer"&gt;Agentic AI Foundation&lt;/a&gt; at the Linux Foundation for connecting AI agents to tools and data sources. Every major AI platform supports it: Claude, Goose, Cursor, Windsurf, and more.&lt;/p&gt;

&lt;p&gt;By implementing GPU metrics as an MCP server, any of these agents can access your GPU state without custom integrations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Build
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/pmady/gpu-mcp-server.git
&lt;span class="nb"&gt;cd &lt;/span&gt;gpu-mcp-server
make build   &lt;span class="c"&gt;# requires CGO + NVIDIA drivers&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Claude Desktop
&lt;/h3&gt;

&lt;p&gt;Add to &lt;code&gt;claude_desktop_config.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"gpu"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/path/to/gpu-mcp-server"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Goose
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;extensions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gpu-metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;stdio&lt;/span&gt;
    &lt;span class="na"&gt;cmd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/path/to/gpu-mcp-server&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once connected, you can ask your agent things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;"Which GPU has the most memory available?"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Are any GPUs thermal throttling right now?"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"What's the total power draw across all devices?"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Show me the MIG instances on GPU 0"&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent calls the right tool, gets structured JSON back, and reasons over it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agent (Claude / Goose / Cursor)
          │
          │ MCP (stdio)
          ▼
    gpu-mcp-server
          │
          │ NVML (cgo)
          ▼
    NVIDIA GPU Hardware
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No sidecar. No network hops. No metric pipeline. The server runs as a local process and calls NVML directly through cgo. If you can run &lt;code&gt;nvidia-smi&lt;/code&gt;, you can run this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who Is This For?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ML engineers&lt;/strong&gt; who want agents to diagnose GPU workload issues&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platform teams&lt;/strong&gt; building agentic infrastructure on GPU clusters&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anyone running inference servers&lt;/strong&gt; (vLLM, Triton, SGLang) who wants agents to understand hardware utilization&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Relationship to keda-gpu-scaler
&lt;/h2&gt;

&lt;p&gt;If you've seen &lt;a href="https://github.com/pmady/keda-gpu-scaler" rel="noopener noreferrer"&gt;keda-gpu-scaler&lt;/a&gt; - my KEDA external scaler for GPU-based autoscaling — this shares the same NVML foundation. Where keda-gpu-scaler uses GPU metrics to drive Kubernetes autoscaling decisions, gpu-mcp-server exposes those same metrics to AI agents through MCP.&lt;/p&gt;

&lt;p&gt;Same GPU telemetry engine, two different consumers: orchestrators and agents.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Streamable HTTP transport&lt;/strong&gt; - run as a network service, not just stdio&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource endpoints&lt;/strong&gt; - expose GPU info as MCP resources for agent context&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Process-level GPU usage&lt;/strong&gt; - per-PID memory and compute attribution&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AAIF project proposal&lt;/strong&gt; - working toward official AAIF hosted project status&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Get Involved
&lt;/h2&gt;

&lt;p&gt;The project is Apache 2.0 and contributions are welcome:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/pmady/gpu-mcp-server" rel="noopener noreferrer"&gt;github.com/pmady/gpu-mcp-server&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Issues:&lt;/strong&gt; Bug reports and feature requests&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PRs:&lt;/strong&gt; See &lt;a href="https://github.com/pmady/gpu-mcp-server/blob/main/CONTRIBUTING.md" rel="noopener noreferrer"&gt;CONTRIBUTING.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're running GPU workloads and using MCP-compatible agents, give it a try.&lt;br&gt;
Star the repo if it's useful. Open an issue if it's not.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Pavan Madduri is a platform engineer focused on GPU-aware AI infrastructure.&lt;br&gt;
He maintains &lt;a href="https://github.com/pmady/keda-gpu-scaler" rel="noopener noreferrer"&gt;keda-gpu-scaler&lt;/a&gt; and&lt;br&gt;
contributes to CNCF projects including Dragonfly, Volcano, and KEDA.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>mcp</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>docker init OCIR OKE: From Empty Folder to Production in 15 Minutes</title>
      <dc:creator>Pavan Madduri</dc:creator>
      <pubDate>Wed, 24 Jun 2026 01:36:06 +0000</pubDate>
      <link>https://dev.to/pavan_madduri/docker-init-ocir-oke-from-empty-folder-to-production-in-15-minutes-2o4e</link>
      <guid>https://dev.to/pavan_madduri/docker-init-ocir-oke-from-empty-folder-to-production-in-15-minutes-2o4e</guid>
      <description>&lt;p&gt;I timed myself. Starting from an empty directory with a Go application idea, how fast could I get to a running deployment on OKE? The answer was 14 minutes. &lt;code&gt;docker init&lt;/code&gt; did more of the work than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  What docker init Does
&lt;/h2&gt;

&lt;p&gt;If you haven't used it, &lt;code&gt;docker init&lt;/code&gt; is an interactive scaffolding tool built into Docker CLI. You run it in your project directory and it generates a Dockerfile, .dockerignore, and docker-compose.yml tuned for your language.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;oci-api &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;oci-api
go mod init github.com/pmady/oci-api

&lt;span class="c"&gt;# Write a quick API&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; main.go &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
package main

import (
    "fmt"
    "log"
    "net/http"
    "os"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "running on %s", os.Getenv("OCI_REGION"))
    })
    http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(200)
    })
    log.Fatal(http.ListenAndServe(":8080", nil))
}
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the magic part:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker init

Welcome to the Docker Init CLI!

? What application platform does your project use? Go
? What version of Go &lt;span class="k"&gt;do &lt;/span&gt;you want to use? 1.22
? What&lt;span class="s1"&gt;'s the relative directory for your main package? .
? What port does your server listen on? 8080
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It generates three files:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dockerfile&lt;/strong&gt; — Multi-stage build, distroless base, non-root user. Actually good defaults. I've seen teams write worse Dockerfiles by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;compose.yaml&lt;/strong&gt; — Basic setup with port mapping and env vars.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;.dockerignore&lt;/strong&gt; — Excludes .git, binaries, vendor directory. Reasonable.&lt;/p&gt;

&lt;p&gt;The generated Dockerfile looked like this (slightly simplified):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;golang:1.22&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /src&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; go.mod go.sum ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;go mod download
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nv"&gt;CGO_ENABLED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 go build &lt;span class="nt"&gt;-o&lt;/span&gt; /bin/server .

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; gcr.io/distroless/static-debian12:nonroot&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /bin/server /bin/&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8080&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; nonroot:nonroot&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["/bin/server"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multi-stage, static binary, distroless, non-root. I'd write almost the same thing myself. The only change I made was adding &lt;code&gt;-ldflags="-s -w"&lt;/code&gt; to strip debug symbols and shrink the binary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minute 0-5: Build and Test Locally
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt;

&lt;span class="c"&gt;# In another terminal&lt;/span&gt;
curl localhost:8080
&lt;span class="c"&gt;# running on&lt;/span&gt;

curl localhost:8080/health
&lt;span class="c"&gt;# 200 OK&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Works. Five minutes in and I have a containerized API running locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minute 5-8: Push to OCIR
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Login&lt;/span&gt;
docker login iad.ocir.io &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;tenancy-namespace&amp;gt;/pmady'&lt;/span&gt;

&lt;span class="c"&gt;# Tag&lt;/span&gt;
docker tag oci-api-server:latest iad.ocir.io/&amp;lt;tenancy&amp;gt;/demos/oci-api:v1

&lt;span class="c"&gt;# Quick scan&lt;/span&gt;
docker scout cves iad.ocir.io/&amp;lt;tenancy&amp;gt;/demos/oci-api:v1

&lt;span class="c"&gt;# Push&lt;/span&gt;
docker push iad.ocir.io/&amp;lt;tenancy&amp;gt;/demos/oci-api:v1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scout showed zero CVEs because distroless has almost nothing in it. Push took about 10 seconds because the image is 12MB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minute 8-14: Deploy to OKE
&lt;/h2&gt;

&lt;p&gt;I already had an OKE cluster running (if you don't, add 20 minutes for &lt;code&gt;oci ce cluster create&lt;/code&gt;). The deployment manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# deploy.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oci-api&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oci-api&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oci-api&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iad.ocir.io/&amp;lt;tenancy&amp;gt;/demos/oci-api:v1&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OCI_REGION&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-ashburn-1&lt;/span&gt;
          &lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/health&lt;/span&gt;
              &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
            &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;100m&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;64Mi&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;500m&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;128Mi&lt;/span&gt;
      &lt;span class="na"&gt;imagePullSecrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ocir-secret&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oci-api&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;oci.oraclecloud.com/load-balancer-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lb"&lt;/span&gt;
    &lt;span class="na"&gt;service.beta.kubernetes.io/oci-load-balancer-shape&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;flexible"&lt;/span&gt;
    &lt;span class="na"&gt;service.beta.kubernetes.io/oci-load-balancer-shape-flex-min&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10"&lt;/span&gt;
    &lt;span class="na"&gt;service.beta.kubernetes.io/oci-load-balancer-shape-flex-max&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;100"&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;LoadBalancer&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oci-api&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; deploy.yaml

&lt;span class="c"&gt;# Wait for LB IP&lt;/span&gt;
kubectl get svc oci-api &lt;span class="nt"&gt;-w&lt;/span&gt;
&lt;span class="c"&gt;# NAME      TYPE           CLUSTER-IP    EXTERNAL-IP      PORT(S)&lt;/span&gt;
&lt;span class="c"&gt;# oci-api   LoadBalancer   10.96.1.120   129.153.xx.xx    80:31234/TCP&lt;/span&gt;

curl http://129.153.xx.xx/
&lt;span class="c"&gt;# running on us-ashburn-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;14 minutes. Empty folder to public API on OKE.&lt;/p&gt;

&lt;h2&gt;
  
  
  What docker init Got Right
&lt;/h2&gt;

&lt;p&gt;I was skeptical about &lt;code&gt;docker init&lt;/code&gt; being useful for anything beyond demos. But the generated Dockerfile was genuinely good:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multi-stage build&lt;/strong&gt; — keeps the final image small&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distroless base&lt;/strong&gt; — minimal attack surface, near-zero CVEs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-root user&lt;/strong&gt; — security best practice out of the box&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate dependency download&lt;/strong&gt; — &lt;code&gt;go mod download&lt;/code&gt; before &lt;code&gt;COPY .&lt;/code&gt; means dependencies are cached and rebuilds are fast&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only things I changed for OKE deployment were the &lt;code&gt;-ldflags&lt;/code&gt; optimization and adding a health check endpoint (which &lt;code&gt;docker init&lt;/code&gt; can't know about since it's application-specific).&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Doesn't Do
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;docker init&lt;/code&gt; handles the Docker side. It doesn't generate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kubernetes manifests&lt;/li&gt;
&lt;li&gt;CI/CD pipeline config&lt;/li&gt;
&lt;li&gt;OCIR login/push scripts&lt;/li&gt;
&lt;li&gt;Terraform for infrastructure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's fair. It's a Docker tool, not a platform tool. But the Dockerfile it generates is solid enough that I don't need to edit it for most Go and Python projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Languages I've Tested
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Language&lt;/th&gt;
&lt;th&gt;Quality of Generated Dockerfile&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Multi-stage, static binary, distroless&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Uses slim base, proper requirements.txt handling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node.js&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Multi-stage, npm ci for production&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;cargo-chef for caching, musl for static binary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Java&lt;/td&gt;
&lt;td&gt;Decent&lt;/td&gt;
&lt;td&gt;Uses Eclipse Temurin, could use jlink for smaller images&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Go and Rust output is good enough to use as-is. Python and Node need minor tweaks depending on your framework. Java needs the most work.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Workflow Now
&lt;/h2&gt;

&lt;p&gt;For quick services and prototypes, this is my default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;project &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;project
&lt;span class="c"&gt;# write code&lt;/span&gt;
docker init
&lt;span class="c"&gt;# tweak Dockerfile if needed&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt;      &lt;span class="c"&gt;# test locally&lt;/span&gt;
docker push ...                &lt;span class="c"&gt;# push to OCIR&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; deploy.yaml   &lt;span class="c"&gt;# deploy to OKE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gap between "it works on my laptop" and "it's running on OKE" is smaller than it's ever been.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Pavan Madduri — Oracle ACE Associate, CNCF Golden Kubestronaut. &lt;a href="https://github.com/pmady" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://linkedin.com/in/pavanmadduri" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://pmady.github.io/" rel="noopener noreferrer"&gt;Website&lt;/a&gt; | &lt;a href="https://scholar.google.com/citations?view_op=list_works&amp;amp;hl=en&amp;amp;user=au0O-8oAAAAJ" rel="noopener noreferrer"&gt;Google Scholar&lt;/a&gt; | &lt;a href="https://www.researchgate.net/profile/Pavan-Madduri-2?ev=hdr_xprf" rel="noopener noreferrer"&gt;ResearchGate&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>oci</category>
      <category>devops</category>
      <category>platformengineering</category>
    </item>
    <item>
      <title>I Stopped Paying for Idle GPUs - Scale-to-Zero AI Inference on OKE with KEDA</title>
      <dc:creator>Pavan Madduri</dc:creator>
      <pubDate>Wed, 17 Jun 2026 14:37:11 +0000</pubDate>
      <link>https://dev.to/pavan_madduri/i-stopped-paying-for-idle-gpus-scale-to-zero-ai-inference-on-oke-with-keda-3oen</link>
      <guid>https://dev.to/pavan_madduri/i-stopped-paying-for-idle-gpus-scale-to-zero-ai-inference-on-oke-with-keda-3oen</guid>
      <description>&lt;p&gt;A single A10 GPU on OCI costs $1.52/hr. Running 24/7, that's $1,094/month. For a production inference service with steady traffic, that's fine. But I had a staging environment and a couple of internal tools that got maybe 20 requests per day. I was paying over $2,000/month for GPUs that sat idle 95% of the time.&lt;/p&gt;

&lt;p&gt;The obvious solution: scale to zero when there's no traffic, spin up when a request comes in. KEDA does this on Kubernetes, but getting it to work properly with GPU pods took some figuring out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Scaling GPUs Is Harder Than Scaling CPU Pods
&lt;/h2&gt;

&lt;p&gt;With normal HTTP services, KEDA watches a metric (HTTP requests, queue depth, whatever), and Kubernetes can spin up a new pod in seconds. The user barely notices.&lt;/p&gt;

&lt;p&gt;GPU pods are different:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cold start is slow&lt;/strong&gt; - Model loading takes 60-120 seconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node scaling is slow&lt;/strong&gt; - If there's no GPU node in the pool, OKE needs to provision a new VM (3-5 minutes)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Images are huge&lt;/strong&gt; - 5-15GB pull times if not cached&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPU resources are binary&lt;/strong&gt; - You can't give a pod "half a GPU" (without MIG)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So you can't just scale-to-zero and expect sub-second response times when traffic returns. The trade-off is cost savings vs. cold start latency. For my use case (internal tools, staging), a 2-3 minute cold start was acceptable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Install KEDA on OKE
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add kedacore https://kedacore.github.io/charts
helm &lt;span class="nb"&gt;install &lt;/span&gt;keda kedacore/keda &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; keda-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-namespace&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Prometheus for Request Metrics
&lt;/h3&gt;

&lt;p&gt;I'm using the nginx ingress controller's Prometheus metrics to track request rate. If you're using OCI's native load balancer, you'd use OCI Monitoring metrics instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# prometheus-scaledobject.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keda.sh/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ScaledObject&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm-scaler&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inference&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;scaleTargetRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm-inference&lt;/span&gt;
  &lt;span class="na"&gt;minReplicaCount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;          &lt;span class="c1"&gt;# scale to zero&lt;/span&gt;
  &lt;span class="na"&gt;maxReplicaCount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;cooldownPeriod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt;          &lt;span class="c1"&gt;# wait 5 min of no traffic before scaling down&lt;/span&gt;
  &lt;span class="na"&gt;pollingInterval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;

  &lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
      &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;serverAddress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://prometheus.monitoring:9090&lt;/span&gt;
        &lt;span class="na"&gt;metricName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http_requests_total&lt;/span&gt;
        &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;sum(rate(nginx_ingress_controller_requests{&lt;/span&gt;
            &lt;span class="s"&gt;namespace="inference",&lt;/span&gt;
            &lt;span class="s"&gt;service="vllm-inference"&lt;/span&gt;
          &lt;span class="s"&gt;}[2m]))&lt;/span&gt;
        &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;         &lt;span class="c1"&gt;# scale up if &amp;gt;1 req/sec averaged over 2 min&lt;/span&gt;
        &lt;span class="na"&gt;activationThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.1"&lt;/span&gt;  &lt;span class="c1"&gt;# activate from zero if any traffic&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key settings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;minReplicaCount: 0&lt;/code&gt; — this is what enables scale-to-zero&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cooldownPeriod: 300&lt;/code&gt; — 5 minutes of no traffic before scaling down (prevents flapping)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;activationThreshold: "0.1"&lt;/code&gt; — even a trickle of traffic triggers scale-up from zero&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Handling the Cold Start
&lt;/h3&gt;

&lt;p&gt;When the pod scales from zero, there's a gap. The request that triggered the scale-up needs to wait for the pod to be ready. I handle this with a simple queue pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# queue-proxy.yaml — lightweight proxy that holds requests during cold start&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inference-proxy&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inference&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;    &lt;span class="c1"&gt;# always running, tiny resource footprint&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iad.ocir.io/mytenancy/inference-proxy:v1&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BACKEND_URL&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://vllm-inference:8000"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TIMEOUT_SECONDS&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;180"&lt;/span&gt;    &lt;span class="c1"&gt;# wait up to 3 min for backend&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;50m&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;64Mi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proxy is a tiny Go service (always running, costs almost nothing) that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Accepts incoming requests immediately&lt;/li&gt;
&lt;li&gt;Forwards them to the vLLM backend&lt;/li&gt;
&lt;li&gt;If the backend isn't ready, retries with exponential backoff up to 3 minutes&lt;/li&gt;
&lt;li&gt;Returns a 503 with "model loading, please retry" if it times out
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;proxyHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;backendURL&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"BACKEND_URL"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Atoi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"TIMEOUT_SECONDS"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;backoff&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Before&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Do&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cloneRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;backendURL&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;copyResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;backoff&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"inference backend unavailable, try again shortly"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;503&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Keeping a Warm GPU Node
&lt;/h3&gt;

&lt;p&gt;The slowest part of cold start isn't model loading — it's waiting for OKE to provision a GPU node when none exist. This takes 3-5 minutes.&lt;/p&gt;

&lt;p&gt;My workaround: keep one GPU node always available, but let the inference pods on it scale to zero. The node costs money even when idle, but it's a single node vs. multiple. And when traffic comes in, the pod starts in ~90 seconds (model loading) instead of 5+ minutes (node provisioning + model loading).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# GPU node pool with min 1 node (always warm)&lt;/span&gt;
oci ce node-pool update &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-pool-id&lt;/span&gt; &lt;span class="nv"&gt;$GPU_NODE_POOL_ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-config-details&lt;/span&gt; &lt;span class="s1"&gt;'{
    "size": 1,
    "placementConfigs": [...]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For staging environments where the 5-minute cold start is acceptable, I set the node pool to autoscale from 0 to 2 nodes and let OKE handle it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Savings
&lt;/h2&gt;

&lt;p&gt;My three GPU workloads (staging vLLM, internal summarizer, internal code review tool) were running 24/7 on three A10 instances:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3x A10 always-on&lt;/td&gt;
&lt;td&gt;1x A10 warm node + scale-to-zero pods&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$3,282/month&lt;/td&gt;
&lt;td&gt;~$1,094/month (warm node) + ~$50 (burst usage)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;$3,282/month&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$1,144/month&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;65% savings. The internal tools scale up when someone uses them (a few times a day) and scale back down after 5 minutes of idle. The warm node means cold starts are 90 seconds, which is fine for internal users.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Not to Do This
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Customer-facing APIs&lt;/strong&gt; - 90-second cold starts are unacceptable. Keep replicas warm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Steady traffic&lt;/strong&gt; - If your GPU is busy &amp;gt;60% of the time, scale-to-zero doesn't save enough to justify the complexity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time applications&lt;/strong&gt; - Anything latency-sensitive should stay always-on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This works for internal tools, batch endpoints, staging environments, and anything where "please wait a moment" is an okay response.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Pavan Madduri — Oracle ACE Associate, CNCF Golden Kubestronaut. I'm also building &lt;a href="https://github.com/pmady/keda-gpu-scaler" rel="noopener noreferrer"&gt;keda-gpu-scaler&lt;/a&gt; for GPU-aware autoscaling. &lt;a href="https://github.com/pmady" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://linkedin.com/in/pavanmadduri" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://pmady.github.io/" rel="noopener noreferrer"&gt;Website&lt;/a&gt; | &lt;a href="https://scholar.google.com/citations?view_op=list_works&amp;amp;hl=en&amp;amp;user=au0O-8oAAAAJ" rel="noopener noreferrer"&gt;Google Scholar&lt;/a&gt; | &lt;a href="https://www.researchgate.net/profile/Pavan-Madduri-2?ev=hdr_xprf" rel="noopener noreferrer"&gt;ResearchGate&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>keda</category>
      <category>kubernetes</category>
      <category>oci</category>
    </item>
    <item>
      <title>Deploying vLLM on OKE with NVIDIA A10 GPUs: The 20-Minute Setup Nobody Talks About</title>
      <dc:creator>Pavan Madduri</dc:creator>
      <pubDate>Tue, 16 Jun 2026 19:42:21 +0000</pubDate>
      <link>https://dev.to/pavan_madduri/deploying-vllm-on-oke-with-nvidia-a10-gpus-the-20-minute-setup-nobody-talks-about-3je7</link>
      <guid>https://dev.to/pavan_madduri/deploying-vllm-on-oke-with-nvidia-a10-gpus-the-20-minute-setup-nobody-talks-about-3je7</guid>
      <description>&lt;p&gt;Last month I needed to stand up a Llama 3 inference endpoint for an internal tool. The requirements were simple: OpenAI-compatible API, auto-scaling, and it couldn't cost more than the team's coffee budget. AWS wanted $3.06/hr for a &lt;code&gt;g5.xlarge&lt;/code&gt;. Azure quoted something similar.&lt;/p&gt;

&lt;p&gt;Then I looked at OCI's GPU shapes. &lt;code&gt;VM.GPU.A10.1&lt;/code&gt; — a single NVIDIA A10 with 24GB VRAM — at $1.52/hr on-demand. Half the price. And on preemptible? $0.46/hr. That's a latte.&lt;/p&gt;

&lt;p&gt;Here's how I got vLLM running on OKE in about 20 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The OKE Cluster Setup
&lt;/h2&gt;

&lt;p&gt;If you already have an OKE cluster, skip ahead. If not, this is the fastest path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create a VCN (or use an existing one)&lt;/span&gt;
oci network vcn create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--compartment-id&lt;/span&gt; &lt;span class="nv"&gt;$COMPARTMENT_ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cidr-blocks&lt;/span&gt; &lt;span class="s1"&gt;'["10.0.0.0/16"]'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--display-name&lt;/span&gt; &lt;span class="s2"&gt;"ai-inference-vcn"&lt;/span&gt;

&lt;span class="c"&gt;# Create the OKE cluster&lt;/span&gt;
oci ce cluster create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--compartment-id&lt;/span&gt; &lt;span class="nv"&gt;$COMPARTMENT_ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"inference-cluster"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vcn-id&lt;/span&gt; &lt;span class="nv"&gt;$VCN_ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--kubernetes-version&lt;/span&gt; &lt;span class="s2"&gt;"v1.30.1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--service-lb-subnet-ids&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="nv"&gt;$PUBLIC_SUBNET_ID&lt;/span&gt;&lt;span class="s2"&gt;]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key part is the GPU node pool. OCI has several GPU shapes, but for inference the A10 is the sweet spot:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;th&gt;GPU&lt;/th&gt;
&lt;th&gt;VRAM&lt;/th&gt;
&lt;th&gt;$/hr (on-demand)&lt;/th&gt;
&lt;th&gt;$/hr (preemptible)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VM.GPU.A10.1&lt;/td&gt;
&lt;td&gt;1x A10&lt;/td&gt;
&lt;td&gt;24 GB&lt;/td&gt;
&lt;td&gt;~$1.52&lt;/td&gt;
&lt;td&gt;~$0.46&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VM.GPU.A10.2&lt;/td&gt;
&lt;td&gt;2x A10&lt;/td&gt;
&lt;td&gt;48 GB&lt;/td&gt;
&lt;td&gt;~$3.04&lt;/td&gt;
&lt;td&gt;~$0.91&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BM.GPU.A100-v2.8&lt;/td&gt;
&lt;td&gt;8x A100&lt;/td&gt;
&lt;td&gt;640 GB&lt;/td&gt;
&lt;td&gt;~$26.52&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a 7B parameter model, a single A10 is plenty. For 70B, you'd want 2xA10 or the A100 bare metal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create the GPU node pool&lt;/span&gt;
oci ce node-pool create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cluster-id&lt;/span&gt; &lt;span class="nv"&gt;$CLUSTER_ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--compartment-id&lt;/span&gt; &lt;span class="nv"&gt;$COMPARTMENT_ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"gpu-a10-pool"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-shape&lt;/span&gt; &lt;span class="s2"&gt;"VM.GPU.A10.1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--size&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-config-details&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s1"&gt;'{"size": 1, "placementConfigs": [{"availabilityDomain": "'&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s1"&gt;'", "subnetId": "'&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WORKER_SUBNET_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s1"&gt;'"}]}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-source-details&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s1"&gt;'{"sourceType": "IMAGE", "imageId": "'&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GPU_IMAGE_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s1"&gt;'"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make sure you use the &lt;strong&gt;OKE GPU image&lt;/strong&gt; — it comes with NVIDIA drivers and &lt;code&gt;nvidia-container-toolkit&lt;/code&gt; pre-installed. You don't want to deal with driver installation yourself. Trust me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The NVIDIA Device Plugin
&lt;/h2&gt;

&lt;p&gt;OKE's GPU images already include the drivers, but Kubernetes needs the device plugin to expose GPUs as a schedulable resource:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# nvidia-device-plugin.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DaemonSet&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia-device-plugin-daemonset&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia-device-plugin-ds&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia-device-plugin-ds&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;tolerations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia.com/gpu&lt;/span&gt;
        &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Exists&lt;/span&gt;
        &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NoSchedule&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvcr.io/nvidia/k8s-device-plugin:v0.16.1&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia-device-plugin-ctr&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;FAIL_ON_INIT_ERROR&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
        &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;allowPrivilegeEscalation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
          &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALL"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;device-plugin&lt;/span&gt;
          &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/lib/kubelet/device-plugins&lt;/span&gt;
      &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;device-plugin&lt;/span&gt;
        &lt;span class="na"&gt;hostPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/lib/kubelet/device-plugins&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; nvidia-device-plugin.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify GPUs show up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get nodes &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="s1"&gt;'.items[].status.capacity["nvidia.com/gpu"]'&lt;/span&gt;
&lt;span class="c"&gt;# "1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that says &lt;code&gt;"1"&lt;/code&gt;, you're golden.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying vLLM
&lt;/h2&gt;

&lt;p&gt;vLLM's Docker image is the easiest way to run it. No pip installs, no dependency conflicts, no wondering why PyTorch can't find CUDA.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# vllm-deployment.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm-llama3&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm-inference&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm-inference&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm-inference&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm&lt;/span&gt;
        &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm/vllm-openai:v0.6.4&lt;/span&gt;
        &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--model"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;meta-llama/Llama-3.1-8B-Instruct"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--max-model-len"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4096"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--gpu-memory-utilization"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.90"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--dtype"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto"&lt;/span&gt;
        &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8000&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;nvidia.com/gpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
          &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;nvidia.com/gpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
            &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;24Gi"&lt;/span&gt;
            &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4"&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HUGGING_FACE_HUB_TOKEN&lt;/span&gt;
          &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;secretKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hf-token&lt;/span&gt;
              &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;token&lt;/span&gt;
        &lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/health&lt;/span&gt;
            &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8000&lt;/span&gt;
          &lt;span class="na"&gt;initialDelaySeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;120&lt;/span&gt;
          &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
        &lt;span class="na"&gt;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/health&lt;/span&gt;
            &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8000&lt;/span&gt;
          &lt;span class="na"&gt;initialDelaySeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;180&lt;/span&gt;
          &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
      &lt;span class="na"&gt;tolerations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia.com/gpu&lt;/span&gt;
        &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Exists&lt;/span&gt;
        &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NoSchedule&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm-service&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm-inference&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8000&lt;/span&gt;
    &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8000&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterIP&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the HuggingFace token secret first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create secret generic hf-token &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$HF_TOKEN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; vllm-deployment.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model download takes a few minutes depending on the model size. Watch the logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs &lt;span class="nt"&gt;-f&lt;/span&gt; deployment/vllm-llama3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see it load the model weights, compile the CUDA kernels, and eventually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;INFO:     Uvicorn running on http://0.0.0.0:8000
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Testing It
&lt;/h2&gt;

&lt;p&gt;Port-forward and hit it with curl:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl port-forward svc/vllm-service 8000:8000

curl http://localhost:8000/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "model": "meta-llama/Llama-3.1-8B-Instruct",
    "messages": [{"role": "user", "content": "Explain Kubernetes in one sentence"}],
    "max_tokens": 100
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API is OpenAI-compatible. Your existing code that talks to &lt;code&gt;gpt-4&lt;/code&gt; just needs a base URL change.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;A few things that bit me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model download speed&lt;/strong&gt; — OKE nodes have good bandwidth to the internet, but the first pull of a 16GB model takes time. I ended up baking the model into a custom Docker image so pod restarts don't re-download. That's a separate blog post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory headroom&lt;/strong&gt; — &lt;code&gt;gpu-memory-utilization: 0.90&lt;/code&gt; leaves 10% for KV cache overhead. Don't set this to 0.99 thinking you're being efficient. vLLM will OOM during burst traffic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Readiness probe timing&lt;/strong&gt; — &lt;code&gt;initialDelaySeconds: 120&lt;/code&gt; seems high, but model loading legitimately takes 60-90 seconds on an A10. If your probe fires too early, Kubernetes will restart the pod in a loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Preemptible instances&lt;/strong&gt; — At $0.46/hr they're incredible for dev/staging. For production, use on-demand and set up a second preemptible pool as overflow. I'll cover that in a future post about cost optimization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost Comparison
&lt;/h2&gt;

&lt;p&gt;Running Llama 3.1 8B on different clouds (single GPU, on-demand):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cloud&lt;/th&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;th&gt;$/hr&lt;/th&gt;
&lt;th&gt;$/month (24/7)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OCI&lt;/td&gt;
&lt;td&gt;VM.GPU.A10.1&lt;/td&gt;
&lt;td&gt;$1.52&lt;/td&gt;
&lt;td&gt;~$1,094&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS&lt;/td&gt;
&lt;td&gt;g5.xlarge&lt;/td&gt;
&lt;td&gt;$3.06&lt;/td&gt;
&lt;td&gt;~$2,203&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure&lt;/td&gt;
&lt;td&gt;NC24ads_A100_v4&lt;/td&gt;
&lt;td&gt;$3.67&lt;/td&gt;
&lt;td&gt;~$2,642&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GCP&lt;/td&gt;
&lt;td&gt;g2-standard-8&lt;/td&gt;
&lt;td&gt;$2.86&lt;/td&gt;
&lt;td&gt;~$2,059&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;OCI is roughly half the price for equivalent hardware. And the preemptible pricing makes it even more dramatic for non-production workloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;This is the simplest possible setup — one model, one GPU, one replica. In the next posts I'll cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cost optimization with preemptible GPU pools and scale-to-zero&lt;/li&gt;
&lt;li&gt;Multi-model serving with vLLM's LoRA adapter support&lt;/li&gt;
&lt;li&gt;Monitoring GPU utilization with OpenTelemetry on OKE&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full YAML files are on my GitHub. If you're running inference on OCI, I'd love to hear what shapes you're using.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Pavan Madduri — CNCF Golden Kubestronaut, building GPU/AI infrastructure tools. &lt;a href="https://github.com/pmady" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://linkedin.com/in/pavanmadduri" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://pmady.github.io/" rel="noopener noreferrer"&gt;Website&lt;/a&gt; | &lt;a href="https://scholar.google.com/citations?view_op=list_works&amp;amp;hl=en&amp;amp;user=au0O-8oAAAAJ" rel="noopener noreferrer"&gt;Google Scholar&lt;/a&gt; | &lt;a href="https://www.researchgate.net/profile/Pavan-Madduri-2?ev=hdr_xprf" rel="noopener noreferrer"&gt;ResearchGate&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>oci</category>
      <category>docker</category>
      <category>kubernetes</category>
      <category>ai</category>
    </item>
    <item>
      <title>Stop Downloading 8GB Models on Every Pod Restart - Use OCI Object Storage as a Model Cache</title>
      <dc:creator>Pavan Madduri</dc:creator>
      <pubDate>Fri, 12 Jun 2026 14:11:16 +0000</pubDate>
      <link>https://dev.to/pavan_madduri/stop-downloading-8gb-models-on-every-pod-restart-use-oci-object-storage-as-a-model-cache-3dnj</link>
      <guid>https://dev.to/pavan_madduri/stop-downloading-8gb-models-on-every-pod-restart-use-oci-object-storage-as-a-model-cache-3dnj</guid>
      <description>&lt;p&gt;The first time I deployed vLLM on OKE, the pod took 12 minutes to become ready. The model download from HuggingFace was 7.5GB. Then the pod crashed (liveness probe, classic mistake), restarted, and downloaded the model again. Another 12 minutes. I burned nearly half an hour watching progress bars.&lt;/p&gt;

&lt;p&gt;I added a PVC, which helped — the model persisted across restarts on the same node. But if the pod got rescheduled to a different GPU node? Fresh download. Back to square one.&lt;/p&gt;

&lt;p&gt;The fix was obvious in hindsight: pre-stage models in OCI Object Storage and download from there. Same region, private network, 10x faster than HuggingFace.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With HuggingFace Downloads in Production
&lt;/h2&gt;

&lt;p&gt;HuggingFace is great for browsing and testing. It's not great as a production model source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Slow&lt;/strong&gt; — downloads go over the internet, through CDN, subject to bandwidth limits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unreliable&lt;/strong&gt; — I've had downloads fail partway through during peak hours&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No access control&lt;/strong&gt; — your production pods need a HF token with internet access&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limits&lt;/strong&gt; — download the same model across 5 pods and you might get throttled&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For development? Fine. For production pods that might restart at 3am? I want the model sitting in my own storage, same region as my cluster, accessible over the internal network.&lt;/p&gt;

&lt;h2&gt;
  
  
  Uploading Models to OCI Object Storage
&lt;/h2&gt;

&lt;p&gt;First, download the model once and upload it to a bucket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Download model locally&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;huggingface_hub
python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
from huggingface_hub import snapshot_download
snapshot_download('microsoft/Phi-3-mini-4k-instruct', local_dir='./phi3-mini')
"&lt;/span&gt;

&lt;span class="c"&gt;# Create OCI bucket&lt;/span&gt;
oci os bucket create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--compartment-id&lt;/span&gt; &lt;span class="nv"&gt;$COMPARTMENT_ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; ai-model-cache &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--storage-tier&lt;/span&gt; Standard

&lt;span class="c"&gt;# Upload model files&lt;/span&gt;
oci os object bulk-upload &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket-name&lt;/span&gt; ai-model-cache &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--src-dir&lt;/span&gt; ./phi3-mini &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--prefix&lt;/span&gt; &lt;span class="s2"&gt;"models/phi3-mini/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--content-type&lt;/span&gt; application/octet-stream
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upload takes a few minutes over the internet. After that, every download from OKE nodes is over OCI's internal network — much faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Init Container Approach
&lt;/h2&gt;

&lt;p&gt;I use a Kubernetes init container to download the model from Object Storage before the inference container starts. The init container uses instance principal auth (no credentials needed — the OKE node's identity is enough).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm-inference&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;initContainers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;model-loader&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/oracle/oci-cli:latest&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/bin/bash&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-c&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
              &lt;span class="s"&gt;# Skip if model already cached on PVC&lt;/span&gt;
              &lt;span class="s"&gt;if [ -f /models/phi3-mini/.complete ]; then&lt;/span&gt;
                &lt;span class="s"&gt;echo "Model already cached, skipping download"&lt;/span&gt;
                &lt;span class="s"&gt;exit 0&lt;/span&gt;
              &lt;span class="s"&gt;fi&lt;/span&gt;

              &lt;span class="s"&gt;echo "Downloading model from OCI Object Storage..."&lt;/span&gt;
              &lt;span class="s"&gt;oci os object bulk-download \&lt;/span&gt;
                &lt;span class="s"&gt;--bucket-name ai-model-cache \&lt;/span&gt;
                &lt;span class="s"&gt;--prefix "models/phi3-mini/" \&lt;/span&gt;
                &lt;span class="s"&gt;--download-dir /models/phi3-mini \&lt;/span&gt;
                &lt;span class="s"&gt;--auth instance_principal&lt;/span&gt;

              &lt;span class="s"&gt;# Mark as complete so we don't re-download&lt;/span&gt;
              &lt;span class="s"&gt;touch /models/phi3-mini/.complete&lt;/span&gt;
              &lt;span class="s"&gt;echo "Model download complete"&lt;/span&gt;
          &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;model-cache&lt;/span&gt;
              &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/models&lt;/span&gt;

      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iad.ocir.io/mytenancy/vllm:v1&lt;/span&gt;
          &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--model"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/models/phi3-mini"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--max-model-len"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4096"&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8000&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;nvidia.com/gpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;32Gi&lt;/span&gt;
          &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;model-cache&lt;/span&gt;
              &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/models&lt;/span&gt;

      &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;model-cache&lt;/span&gt;
          &lt;span class="na"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;claimName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;model-cache-pvc&lt;/span&gt;

&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PersistentVolumeClaim&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;model-cache-pvc&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ReadWriteOnce"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oci-bv&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;50Gi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.complete&lt;/code&gt; marker file is a simple trick. If the PVC already has the model (because the pod restarted on the same node), the init container exits immediately. No re-download. If it's a fresh node, it pulls from Object Storage — which takes about 90 seconds over the internal network compared to 12 minutes from HuggingFace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instance Principal Auth — No Credentials Needed
&lt;/h2&gt;

&lt;p&gt;The nice thing about OCI Object Storage with OKE is that you can use instance principal authentication. The OKE worker node already has an identity in OCI. You just need a policy that allows it to read from the bucket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Terraform — allow GPU nodes to read model bucket&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"oci_identity_policy"&lt;/span&gt; &lt;span class="s2"&gt;"model_access"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;compartment_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenancy_id&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"gpu-nodes-model-access"&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow GPU node pool to read model cache bucket"&lt;/span&gt;

  &lt;span class="nx"&gt;statements&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;"Allow dynamic-group gpu-node-pool to read objects in compartment ${var.compartment_name} where target.bucket.name='ai-model-cache'"&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No API keys, no secrets, no tokens to rotate. The node proves its identity to OCI automatically. This is cleaner than storing HuggingFace tokens in Kubernetes secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Startup Time Comparison
&lt;/h2&gt;

&lt;p&gt;I measured this across 10 pod restarts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model Source&lt;/th&gt;
&lt;th&gt;Avg Download Time&lt;/th&gt;
&lt;th&gt;Reliability&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HuggingFace (internet)&lt;/td&gt;
&lt;td&gt;11-14 min&lt;/td&gt;
&lt;td&gt;8/10 succeeded first try&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OCI Object Storage (same region)&lt;/td&gt;
&lt;td&gt;1-2 min&lt;/td&gt;
&lt;td&gt;10/10 succeeded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PVC cache hit (no download)&lt;/td&gt;
&lt;td&gt;0 sec&lt;/td&gt;
&lt;td&gt;10/10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The PVC cache is the fast path. Object Storage is the fallback for new nodes. HuggingFace is what I never want to depend on in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-Model Setup
&lt;/h2&gt;

&lt;p&gt;For serving multiple models, I just add more prefixes in the bucket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oci os object bulk-upload &lt;span class="nt"&gt;--bucket-name&lt;/span&gt; ai-model-cache &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--src-dir&lt;/span&gt; ./llama3-8b &lt;span class="nt"&gt;--prefix&lt;/span&gt; &lt;span class="s2"&gt;"models/llama3-8b/"&lt;/span&gt;

oci os object bulk-upload &lt;span class="nt"&gt;--bucket-name&lt;/span&gt; ai-model-cache &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--src-dir&lt;/span&gt; ./mistral-7b &lt;span class="nt"&gt;--prefix&lt;/span&gt; &lt;span class="s2"&gt;"models/mistral-7b/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the init container takes a model name as an env var:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MODEL_NAME&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llama3-8b"&lt;/span&gt;
&lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/bin/bash&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-c&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;if [ -f /models/$MODEL_NAME/.complete ]; then exit 0; fi&lt;/span&gt;
    &lt;span class="s"&gt;oci os object bulk-download \&lt;/span&gt;
      &lt;span class="s"&gt;--bucket-name ai-model-cache \&lt;/span&gt;
      &lt;span class="s"&gt;--prefix "models/$MODEL_NAME/" \&lt;/span&gt;
      &lt;span class="s"&gt;--download-dir /models/$MODEL_NAME \&lt;/span&gt;
      &lt;span class="s"&gt;--auth instance_principal&lt;/span&gt;
    &lt;span class="s"&gt;touch /models/$MODEL_NAME/.complete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Cost
&lt;/h2&gt;

&lt;p&gt;OCI Object Storage Standard tier is $0.0255/GB/month. A 10GB model costs $0.26/month to store. That's basically free. And you're saving 10+ minutes on every cold start, which matters when you're paying $1.50/hr for a GPU that's sitting idle while a model downloads.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Pavan Madduri — Oracle ACE Associate, CNCF Golden Kubestronaut. &lt;a href="https://github.com/pmady" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://linkedin.com/in/pavanmadduri" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://pmady.github.io/" rel="noopener noreferrer"&gt;Website&lt;/a&gt; | &lt;a href="https://scholar.google.com/citations?view_op=list_works&amp;amp;hl=en&amp;amp;user=au0O-8oAAAAJ" rel="noopener noreferrer"&gt;Google Scholar&lt;/a&gt; | &lt;a href="https://www.researchgate.net/profile/Pavan-Madduri-2?ev=hdr_xprf" rel="noopener noreferrer"&gt;ResearchGate&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>ai</category>
      <category>kubernetes</category>
      <category>oci</category>
    </item>
    <item>
      <title>Docker vs Podman for AI/ML Workloads in 2026: A Technical Comparison</title>
      <dc:creator>Pavan Madduri</dc:creator>
      <pubDate>Tue, 02 Jun 2026 02:50:39 +0000</pubDate>
      <link>https://dev.to/pavan_madduri/docker-vs-podman-for-aiml-workloads-in-2026-a-technical-comparison-316e</link>
      <guid>https://dev.to/pavan_madduri/docker-vs-podman-for-aiml-workloads-in-2026-a-technical-comparison-316e</guid>
      <description>&lt;p&gt;This is an honest comparison from someone who runs GPU containers in production daily. Both Docker and Podman are excellent container runtimes. But for AI/ML infrastructure in 2026, Docker has pulled ahead in ways that matter if you're building inference services, training pipelines, or agentic AI workflows.&lt;/p&gt;

&lt;p&gt;I maintain &lt;a href="https://github.com/pmady/keda-gpu-scaler" rel="noopener noreferrer"&gt;keda-gpu-scaler&lt;/a&gt; (GPU autoscaling for KEDA), &lt;a href="https://github.com/pmady/otel-gpu-receiver" rel="noopener noreferrer"&gt;otel-gpu-receiver&lt;/a&gt; (GPU observability for OpenTelemetry), and contributed GPU NUMA topology scheduling to &lt;a href="https://github.com/volcano-sh/volcano" rel="noopener noreferrer"&gt;Volcano&lt;/a&gt;. All of this runs in Docker containers. Here's why.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Docker Model Runner — No Podman Equivalent
&lt;/h2&gt;

&lt;p&gt;Docker Model Runner lets you pull, run, and manage LLMs alongside your containers using the same CLI and registry infrastructure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Pull a model like you pull an image&lt;/span&gt;
docker model pull ai/llama3.2:1B-Q8_0

&lt;span class="c"&gt;# Run inference&lt;/span&gt;
docker model run ai/llama3.2:1B-Q8_0 &lt;span class="s2"&gt;"Explain GPU memory fragmentation"&lt;/span&gt;

&lt;span class="c"&gt;# List local models&lt;/span&gt;
docker model &lt;span class="nb"&gt;ls&lt;/span&gt;

&lt;span class="c"&gt;# OpenAI-compatible API on localhost&lt;/span&gt;
curl http://localhost:12434/engines/llama3.2/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model": "ai/llama3.2:1B-Q8_0", "messages": [{"role": "user", "content": "Hello"}]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a unified workflow: containers for your application, models for your AI, same CLI, same lifecycle management. The OpenAI-compatible API means your code doesn't change between local development (Model Runner) and production (self-hosted vLLM or cloud APIs).&lt;/p&gt;

&lt;p&gt;Podman has no equivalent feature. If you're building AI applications, you'd need to separately install and manage Ollama, llama.cpp, or another inference runtime alongside Podman. Two tools, two lifecycles, two sets of configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Winner: Docker&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. GPU Passthrough UX
&lt;/h2&gt;

&lt;p&gt;Both runtimes support the NVIDIA Container Toolkit. The capability is equivalent. The experience is not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--gpus&lt;/span&gt; all nvidia/cuda:12.4-base nvidia-smi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One flag. Works after installing &lt;code&gt;nvidia-container-toolkit&lt;/code&gt;. Docker Desktop on Linux and WSL2 handles GPU passthrough configuration automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Podman
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;podman run &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--device&lt;/span&gt; nvidia.com/gpu&lt;span class="o"&gt;=&lt;/span&gt;all &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--security-opt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;disable &lt;span class="se"&gt;\&lt;/span&gt;
  nvidia/cuda:12.4-base nvidia-smi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Requires CDI (Container Device Interface) configuration. The &lt;code&gt;--security-opt=label=disable&lt;/code&gt; is needed because SELinux blocks GPU device access by default in rootless mode. Rootless GPU support has additional edge cases — CDI device nodes need to be readable by the unprivileged user, which requires extra system configuration.&lt;/p&gt;

&lt;p&gt;For multi-GPU setups where you want to expose specific GPUs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--gpus&lt;/span&gt; &lt;span class="s1"&gt;'"device=0,2"'&lt;/span&gt; my-training-image
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Podman:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;podman run &lt;span class="nt"&gt;--device&lt;/span&gt; nvidia.com/gpu&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nt"&gt;--device&lt;/span&gt; nvidia.com/gpu&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--security-opt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;disable my-training-image
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both work. Docker's syntax is more compact and better documented for GPU-specific use cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Winner: Docker (UX), Tie (raw capability)&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Docker Scout — Integrated Supply Chain Security
&lt;/h2&gt;

&lt;p&gt;Docker Scout is built into the Docker CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Scan for vulnerabilities&lt;/span&gt;
docker scout cves my-gpu-image:latest

&lt;span class="c"&gt;# Policy evaluation against your organization's rules&lt;/span&gt;
docker scout policy my-gpu-image:latest

&lt;span class="c"&gt;# Base image recommendations&lt;/span&gt;
docker scout recommendations my-gpu-image:latest

&lt;span class="c"&gt;# Compare two image versions&lt;/span&gt;
docker scout compare my-gpu-image:v2 &lt;span class="nt"&gt;--to&lt;/span&gt; my-gpu-image:v1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scout has first-party provenance data for Docker Official Images and Docker Hardened Images. It knows the full dependency chain from source → build → image. This matters for GPU images because the dependency trees are deep (OS → CUDA → cuDNN → Python → PyTorch → vLLM) and CVEs can hide anywhere in that chain.&lt;/p&gt;

&lt;p&gt;Podman has no built-in scanning. You'd use Trivy, Grype, or Snyk as separate tools. These are excellent scanners, but they're not integrated into the container CLI, and they don't have first-party knowledge of Docker's image provenance.&lt;/p&gt;

&lt;p&gt;For CI pipelines, Docker Scout provides a GitHub Action that can fail builds on critical CVEs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Docker Scout scan&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/scout-action@v1&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cves&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-gpu-image:${{ github.sha }}&lt;/span&gt;
    &lt;span class="na"&gt;only-severities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;critical,high&lt;/span&gt;
    &lt;span class="na"&gt;exit-code&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  &lt;span class="c1"&gt;# Fail the build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Winner: Docker&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Docker Extensions Ecosystem
&lt;/h2&gt;

&lt;p&gt;Docker Desktop has an extensions marketplace with tools built by the community. I built a &lt;a href="https://github.com/pmady/docker-gpu-dashboard-extension" rel="noopener noreferrer"&gt;GPU Dashboard extension&lt;/a&gt; that shows real-time NVIDIA GPU metrics directly in Docker Desktop — utilization, memory, temperature, power draw per device. No terminal, no nvidia-smi.&lt;/p&gt;

&lt;p&gt;Podman Desktop has a plugin system, and it's growing. But Docker's marketplace is larger and more actively promoted. If you're building developer tools for GPU/AI workflows, Docker Extensions reaches more users.&lt;/p&gt;

&lt;p&gt;The extension development experience is also more mature on Docker — the SDK, documentation, and example extensions are further along.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Winner: Docker&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Docker Sandboxes for AI Agents
&lt;/h2&gt;

&lt;p&gt;Docker Sandboxes provide purpose-built isolation for agentic AI workloads — LLMs that execute code, call APIs, or modify files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ai-agent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-agent:latest&lt;/span&gt;
    &lt;span class="na"&gt;sandbox&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;egress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api.openai.com:443"&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;huggingface.co:443"&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;4g&lt;/span&gt;
        &lt;span class="na"&gt;gpus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is designed for the specific use case of running untrusted LLM-generated code safely. Filesystem isolation, network egress rules, resource limits, ephemeral execution — all in one configuration.&lt;/p&gt;

&lt;p&gt;Podman's approach is rootless-by-default with user namespaces, which provides strong general-purpose isolation. But there's no agent-specific sandbox abstraction. You'd build the equivalent with a combination of rootless mode, &lt;code&gt;--network=none&lt;/code&gt; plus manual iptables rules, cgroups limits, and tmpfs mounts. Doable, but not turnkey.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Winner: Docker (for AI agent use case)&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Docker Compose for GPU Development
&lt;/h2&gt;

&lt;p&gt;Both Docker Compose and Podman Compose handle GPU workloads, but Docker Compose has more mature GPU support:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Docker Compose — native GPU support&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;inference&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm/vllm-openai:latest&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;reservations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;devices&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia&lt;/span&gt;
              &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
              &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;gpu&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="na"&gt;gpu-monitor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pmady/otel-gpu-receiver:latest&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;reservations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;devices&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia&lt;/span&gt;
              &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;
              &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;gpu&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Podman Compose supports GPU devices through CDI, but the syntax is different and less documented for multi-GPU configurations. Docker Compose also integrates with Docker Desktop's resource management, giving you a GUI to see which services are using which GPUs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Winner: Docker&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Where Podman Wins
&lt;/h2&gt;

&lt;p&gt;This wouldn't be honest without acknowledging where Podman is genuinely better:&lt;/p&gt;

&lt;h3&gt;
  
  
  Rootless by Default
&lt;/h3&gt;

&lt;p&gt;Podman runs containers as your unprivileged user by default. No daemon running as root. This is a meaningful security advantage for multi-tenant systems and environments where the Docker daemon's root access is a compliance concern.&lt;/p&gt;

&lt;h3&gt;
  
  
  Daemonless Architecture
&lt;/h3&gt;

&lt;p&gt;No background daemon means smaller attack surface and no single point of failure. If a Podman container crashes, it doesn't affect other containers. If the Docker daemon crashes, everything stops.&lt;/p&gt;

&lt;h3&gt;
  
  
  Systemd Integration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;podman generate systemd &lt;span class="nt"&gt;--new&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; my-gpu-service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generates a proper systemd service file. Elegant for non-Kubernetes deployments where you want GPU containers managed by the init system.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pod Semantics
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;podman pod create &lt;span class="nt"&gt;--name&lt;/span&gt; ml-pod
podman run &lt;span class="nt"&gt;--pod&lt;/span&gt; ml-pod &lt;span class="nt"&gt;--gpus&lt;/span&gt; all inference-server
podman run &lt;span class="nt"&gt;--pod&lt;/span&gt; ml-pod metrics-sidecar
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;podman pod&lt;/code&gt; mirrors Kubernetes pod concepts directly. Containers in a pod share network and IPC namespaces. If you're prototyping Kubernetes pod configurations locally, Podman is more natural.&lt;/p&gt;

&lt;h3&gt;
  
  
  RHEL/CentOS Ecosystem
&lt;/h3&gt;

&lt;p&gt;If you're in a Red Hat shop, Podman is the native container runtime. It's supported, patched, and integrated with the RHEL security stack (SELinux, FIPS). Docker on RHEL is possible but not the paved path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kubernetes YAML Support
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;podman play kube deployment.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run Kubernetes YAML directly with Podman. Useful for testing manifests without a cluster. Docker has &lt;code&gt;docker compose&lt;/code&gt; but not native Kubernetes YAML support in the CLI.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. The Production Runtime Question
&lt;/h2&gt;

&lt;p&gt;Here's the thing most of this comparison misses: &lt;strong&gt;in production, neither Docker nor Podman is your container runtime.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Kubernetes uses containerd or CRI-O. Both Docker and Podman are development tools that produce OCI-compliant images. The image you build with &lt;code&gt;docker build&lt;/code&gt; runs identically on containerd in your Kubernetes cluster.&lt;/p&gt;

&lt;p&gt;So the real question is: &lt;strong&gt;which tool gives you the best development experience for GPU/AI containers?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For that question, Docker's answer in 2026 is comprehensive:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model Runner&lt;/strong&gt; for local LLM inference&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sandboxes&lt;/strong&gt; for AI agent isolation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scout&lt;/strong&gt; for supply chain security on deep GPU dependency trees&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extensions&lt;/strong&gt; for custom developer tools (GPU monitoring)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compose&lt;/strong&gt; with mature GPU support&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Desktop&lt;/strong&gt; with automatic GPU passthrough&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Podman's answer is: strong fundamentals (rootless, daemonless, pod semantics) but no AI-specific features.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criteria&lt;/th&gt;
&lt;th&gt;Docker&lt;/th&gt;
&lt;th&gt;Podman&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LLM inference (Model Runner)&lt;/td&gt;
&lt;td&gt;✅ Built-in&lt;/td&gt;
&lt;td&gt;❌ Need separate tool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI agent sandboxes&lt;/td&gt;
&lt;td&gt;✅ Native&lt;/td&gt;
&lt;td&gt;⚠️ Manual configuration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU passthrough UX&lt;/td&gt;
&lt;td&gt;✅ One flag&lt;/td&gt;
&lt;td&gt;⚠️ CDI + SELinux workarounds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supply chain scanning&lt;/td&gt;
&lt;td&gt;✅ Scout built-in&lt;/td&gt;
&lt;td&gt;⚠️ External tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extensions ecosystem&lt;/td&gt;
&lt;td&gt;✅ Marketplace&lt;/td&gt;
&lt;td&gt;⚠️ Smaller plugin system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rootless security&lt;/td&gt;
&lt;td&gt;⚠️ Opt-in&lt;/td&gt;
&lt;td&gt;✅ Default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daemonless&lt;/td&gt;
&lt;td&gt;❌ Requires daemon&lt;/td&gt;
&lt;td&gt;✅ No daemon&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Systemd integration&lt;/td&gt;
&lt;td&gt;⚠️ Basic&lt;/td&gt;
&lt;td&gt;✅ Native&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes YAML&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;podman play kube&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RHEL support&lt;/td&gt;
&lt;td&gt;⚠️ Community&lt;/td&gt;
&lt;td&gt;✅ Native&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;For AI/ML development in 2026:&lt;/strong&gt; Docker. The Model Runner, Sandboxes, Scout, and GPU UX advantages are not marginal — they're the entire AI developer workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For security-first Linux servers:&lt;/strong&gt; Podman. Rootless-by-default and daemonless architecture are real advantages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For production Kubernetes:&lt;/strong&gt; Doesn't matter. Both produce OCI images. containerd runs them.&lt;/p&gt;

&lt;p&gt;Pick the tool that matches your workload. For GPU/AI containers, that's Docker in 2026.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Pavan Madduri is a Senior Cloud Platform Engineer at W.W. Grainger, Inc., CNCF Golden Kubestronaut, and Oracle ACE Associate. He maintains &lt;a href="https://github.com/pmady/keda-gpu-scaler" rel="noopener noreferrer"&gt;keda-gpu-scaler&lt;/a&gt; and &lt;a href="https://github.com/pmady/otel-gpu-receiver" rel="noopener noreferrer"&gt;otel-gpu-receiver&lt;/a&gt;, and builds GPU infrastructure tools on Docker and Kubernetes.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>gpu</category>
      <category>agents</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
