<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Cuong Nguyen on Medium]]></title>
        <description><![CDATA[Stories by Cuong Nguyen on Medium]]></description>
        <link>https://medium.com/@cuong.nguyen_87011?source=rss-e7e45a2f8f19------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*lIrVmZsGxHgi1NnyqNpmqg.png</url>
            <title>Stories by Cuong Nguyen on Medium</title>
            <link>https://medium.com/@cuong.nguyen_87011?source=rss-e7e45a2f8f19------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sun, 15 Nov 2026 10:06:44 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@cuong.nguyen_87011/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[Why one zone of our GPU inference fleet worked 50% harder (and the one-line fix)]]></title>
            <link>https://medium.com/@cuong.nguyen_87011/why-one-zone-of-our-gpu-inference-fleet-worked-50-harder-and-the-one-line-fix-990d0333d75a?source=rss-e7e45a2f8f19------2</link>
            <guid isPermaLink="false">https://medium.com/p/990d0333d75a</guid>
            <category><![CDATA[google-cloud-platform]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[site-reliability-engineer]]></category>
            <category><![CDATA[mlops]]></category>
            <category><![CDATA[kubernetes]]></category>
            <dc:creator><![CDATA[Cuong Nguyen]]></dc:creator>
            <pubDate>Sun, 15 Nov 2026 07:11:12 GMT</pubDate>
            <atom:updated>2026-11-15T07:11:12.244Z</atom:updated>
            <content:encoded><![CDATA[<h4>How Kubernetes topology-aware routing and a “soft” topology spread quietly skewed load across our Triton Inference Server pods, and how we found it.</h4><p>We run NVIDIA Triton Inference Server on GKE across two zones. Each Triton pool has the same number of pods in each zone, one pod per L4 GPU, no GPU sharing. On paper the placement was perfect. Yet when we looked at per-pod request rates, the pods in one zone were serving about 1.5x the traffic of their twins in the other zone.</p><p>Nothing was broken: GPU utilization was low and queue time was under 2 ms. But that kind of skew means one zone hits its ceiling first during peak, and you pay for headroom you cannot use. Here is how we tracked it down.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VqPRkth-PEhPdMZ04gpu2w.png" /></figure><h3>Step 1: Measure per pod, not per service</h3><p>Our APM tool aggregated Triton metrics by service and model. Its tag configuration had dropped the pod tag to save on custom-metric cardinality, so a per-pod breakdown simply was not available there.</p><p>The fix was to go one layer down. Triton exposes Prometheus metrics on port 8002, and our in-cluster Prometheus-compatible store already scraped them per pod. A query like this gives requests per second per pod:</p><pre>sum by (pod) (rate(nv_inference_request_success{model!=&quot;version&quot;}[1h]))</pre><p>(We excluded a tiny “version” model used only for health checks.) Put the result next to each pod’s zone and the pattern is obvious:</p><ul><li>Pool A, zone B pod: 4.6 req/s. Zone A pod: 3.0 req/s, so 1.55x.</li><li>Pool B, zone B pod: 4.5 req/s. Zone A pod: 3.1 req/s, so 1.48x.</li><li>The autoscaled pool: 27 to 30 req/s per pod, within 8%. Even.</li></ul><p>So the problem was specific to some pools, and it was zonal.</p><h3>Step 2: Check how traffic reaches Triton</h3><p>Our Triton Services use topology-aware routing:</p><pre>spec:<br>  trafficDistribution: PreferClose</pre><p>With PreferClose, a client pod is routed to endpoints in its own zone when they exist. That is good for latency and avoids cross-zone network charges. It also means Triton’s load in a zone is proportional to how many client pods live in that zone.</p><p>Long-lived gRPC connections pinning traffic to a few pods is the usual suspect for Triton imbalance, but within a zone the pods were even. The skew was between zones, which points at the callers.</p><h3>Step 3: Count the callers per zone</h3><p>The main caller of the skewed pools was an OCR service with about 20 replicas. Its pods were split 7 in zone A and 13 in zone B.</p><p>Its topology spread looked fine at first glance:</p><pre>topologySpreadConstraints:<br>  - maxSkew: 1<br>    topologyKey: topology.kubernetes.io/zone<br>    whenUnsatisfiable: ScheduleAnyway<br>    labelSelector:<br>      matchLabels:<br>        app: ocr-caller</pre><p>The catch is ScheduleAnyway. It is a soft constraint: the scheduler only adds it to the scoring, together with every other preference. This deployment also had node-affinity preferences (spot vs. on-demand node pools), and those preferences outweighed the spread score. The scheduler kept putting new pods where the preferred nodes had room, which was mostly zone B.</p><p>So the chain was:</p><ol><li>Soft spread loses to node-affinity preference, so the callers end up 7/13.</li><li>PreferClose keeps each caller’s requests in its own zone.</li><li>Zone B’s Triton pods get roughly 13/7 of zone A’s share of that caller’s traffic.</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*By4ltQpG5hODFu_jXWEUEw.png" /></figure><h3>Step 4: The fix</h3><p>Another service in the same cluster already used a hard constraint, and its pods were perfectly balanced. We made the callers match it:</p><pre>whenUnsatisfiable: DoNotSchedule</pre><p>With DoNotSchedule the scheduler will not place a pod that would push the zone skew above maxSkew. After the rollout, the caller went from 7/13 to 8/9 across zones, with no Pending pods, and the Triton pods in both zones started receiving comparable traffic.</p><p>Two details made this a safe change:</p><ul><li><strong>matchLabelKeys with the rollout hash.</strong> We use Argo Rollouts, and the constraint includes matchLabelKeys: [&quot;rollouts-pod-template-hash&quot;]. The spread is computed per revision, so old pods do not distort the placement of new ones during a rollout.</li><li><strong>Existing pods do not move by themselves.</strong> The constraint only applies at scheduling time. The skew fixed itself on the next rollout. If you need it immediately, restart the deployment or use the descheduler’s RemovePodsViolatingTopologySpreadConstraint strategy.</li></ul><h3>The trade-off you must check first</h3><p>A hard spread can leave pods Pending. If the short zone has no capacity and its node pool cannot grow (max nodes reached, or a machine-type stockout in that zone), the scheduler will not “borrow” the other zone. With ScheduleAnyway it would have.</p><p>Before merging, we checked:</p><ul><li>Free CPU and memory per zone in the node pools the callers use. Zone A had room for only one more pod, so we expected a few minutes of Pending while the autoscaler added a node.</li><li>Whether the autoscaler can grow that pool in both zones, and how far it is from its max.</li><li>What happens at max replicas. In our case the pools could not fit every service at max under either setting, so the real fix for that scenario is raising the pool max, not softening the spread.</li></ul><p>We decided against removing PreferClose. Turning it off would balance load instantly, but it gives up zone-local latency and adds cross-zone traffic cost. Fixing the callers&#39; placement keeps both benefits.</p><h3>Takeaways</h3><ul><li>Topology-aware routing (PreferClose) makes your backend&#39;s load mirror your clients&#39; placement. Balance the clients, not only the servers.</li><li>ScheduleAnyway is a hint. If other scheduling preferences exist, expect it to lose.</li><li>Measure per pod. Aggregated service metrics hide this class of problem completely, and the cheapest data source is often the Prometheus endpoint you already scrape.</li><li>Before switching to DoNotSchedule, check per-zone headroom and autoscaler limits, and plan for brief Pending pods.</li></ul><p>A one-line YAML change, but finding it took looking at the system as a chain: placement of callers, routing policy, then placement of servers.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=990d0333d75a" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>