<?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: Kazu</title>
    <description>The latest articles on DEV Community by Kazu (@shinagawa-web).</description>
    <link>https://dev.to/shinagawa-web</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%2F3422958%2F561728f2-3289-4079-9cba-cc1c855c8b68.png</url>
      <title>DEV Community: Kazu</title>
      <link>https://dev.to/shinagawa-web</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shinagawa-web"/>
    <language>en</language>
    <item>
      <title>High p99 latency in Node.js at low CPU usage: a blocked event loop</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Mon, 12 Oct 2026 13:04:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/high-p99-latency-in-nodejs-at-low-cpu-usage-a-blocked-event-loop-5b0k</link>
      <guid>https://dev.to/shinagawa-web/high-p99-latency-in-nodejs-at-low-cpu-usage-a-blocked-event-loop-5b0k</guid>
      <description>&lt;p&gt;The latency of a Node.js service suddenly gets worse. p99 (the 99th percentile of response time: when 1000 requests are sorted from fastest to slowest, the value of the 990th) jumps. CPU looks low when you check overall usage. Memory is normal too. Nothing points to a cause. Endpoints that respond immediately, like a health check, are slow in the same way.&lt;/p&gt;

&lt;p&gt;Node.js runs its event loop on a single thread. When one piece of work blocks, every other request waits in the queue. This congestion rarely shows up on an overall CPU graph.&lt;/p&gt;

&lt;p&gt;I reproduced event loop starvation on GitHub Actions (ubuntu-24.04, 4 vCPU) and show, with the code I ran, which metric catches the degradation that an overall CPU graph tends to miss. In the same environment I also measured how much an overload guard (load shedding: returning 503 immediately once event loop delay exceeds a threshold) improves p99, and what it does not improve.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/shinagawa-web/show-your-work/tree/27e0847/nodejs-eventloop-starvation-p99" rel="noopener noreferrer"&gt;shinagawa-web/show-your-work: nodejs-eventloop-starvation-p99&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How Node.js handles requests
&lt;/h2&gt;

&lt;p&gt;In an architecture that assigns a thread to each request, more concurrent connections mean more threads, and the cost of context switches (the overhead incurred each time the CPU switches from one thread to another) grows. Node.js avoids this by handling every request on an event loop that runs on one thread.&lt;/p&gt;

&lt;p&gt;The event loop works through a to-do list (a queue) in order on a single thread. Each incoming request is added to the end of the queue, and the event loop takes items from the front and runs them. Work that has to wait for I/O (reading a file, network communication) leaves the queue while it waits and comes back when the I/O completes. In the meantime the loop can pick up the next item, so it can serve many connections without adding threads.&lt;/p&gt;

&lt;p&gt;If one piece of work keeps hold of the thread, though, the rest of the queue has to wait until it finishes. This is event loop blocking. Reading a file with &lt;code&gt;fs.readFileSync&lt;/code&gt; inside a request handler, running a shell command with &lt;code&gt;execSync&lt;/code&gt;, and calling &lt;code&gt;JSON.parse&lt;/code&gt; on a response several MB in size are all examples.&lt;/p&gt;

&lt;p&gt;How far this blocking backs up the queue is called lag. &lt;code&gt;setTimeout(fn, 100)&lt;/code&gt; is a reservation to call fn after 100ms, but while the event loop is blocked, the call to fn is held back too. If fn should have been called after 100ms and is called after 450ms, the lag is 350ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why overall CPU looks flat
&lt;/h2&gt;

&lt;p&gt;A CPU graph shows usage for the whole system. Because Node.js runs on a single thread, a blocking operation that consumes CPU uses only one core's worth. On a server with 4 vCPU (virtual CPUs, the unit in which cloud environments divide physical cores and allocate them), one core at 100% shows up as only 25% overall.&lt;/p&gt;

&lt;p&gt;I checked this by measurement, with two endpoints. &lt;code&gt;/sync-cpu&lt;/code&gt; occupies one core with a busy loop (a loop that keeps hold of the CPU until its completion condition is met) that repeats JSON.parse until a deadline, blocking the event loop for a specified time. &lt;code&gt;/light&lt;/code&gt; returns a response immediately with no computation. Ten concurrent connections sent a mix of requests, 1/3 to &lt;code&gt;/sync-cpu&lt;/code&gt; and 2/3 to &lt;code&gt;/light&lt;/code&gt;. The blocking time (&lt;code&gt;sync_ms&lt;/code&gt;) was set to 50ms, 100ms, 150ms, and 200ms, and each setting was measured for 30 seconds. The environment was GitHub Actions (ubuntu-24.04, 4 vCPU). The columns to watch in the table are &lt;code&gt;cpu_pct&lt;/code&gt; (CPU usage) and &lt;code&gt;lag_p99&lt;/code&gt; (p99 of event loop lag).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;sync_ms&lt;/th&gt;
&lt;th&gt;/sync-cpu p99&lt;/th&gt;
&lt;th&gt;/light p99&lt;/th&gt;
&lt;th&gt;cpu_pct&lt;/th&gt;
&lt;th&gt;lag_p99&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0ms&lt;/td&gt;
&lt;td&gt;4ms&lt;/td&gt;
&lt;td&gt;4ms&lt;/td&gt;
&lt;td&gt;26.7%&lt;/td&gt;
&lt;td&gt;23ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50ms&lt;/td&gt;
&lt;td&gt;407ms&lt;/td&gt;
&lt;td&gt;353ms&lt;/td&gt;
&lt;td&gt;25.5%&lt;/td&gt;
&lt;td&gt;306ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100ms&lt;/td&gt;
&lt;td&gt;712ms&lt;/td&gt;
&lt;td&gt;1524ms&lt;/td&gt;
&lt;td&gt;25.9%&lt;/td&gt;
&lt;td&gt;612ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;150ms&lt;/td&gt;
&lt;td&gt;1214ms&lt;/td&gt;
&lt;td&gt;1610ms&lt;/td&gt;
&lt;td&gt;25.6%&lt;/td&gt;
&lt;td&gt;764ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200ms&lt;/td&gt;
&lt;td&gt;1222ms&lt;/td&gt;
&lt;td&gt;2407ms&lt;/td&gt;
&lt;td&gt;25.7%&lt;/td&gt;
&lt;td&gt;1224ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://github.com/shinagawa-web/show-your-work/actions/runs/37862107644" rel="noopener noreferrer"&gt;Results (CI run #37862107644)&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cpu_pct&lt;/code&gt; stays almost fixed, between 25.5% and 26.7%. Quadrupling the blocking time from 50ms to 200ms barely moves CPU. &lt;code&gt;lag_p99&lt;/code&gt;, on the other hand, is the event loop lag of the whole process, measured as a single value regardless of endpoint. The 23ms at 0ms is a floor that comes from the &lt;code&gt;resolution: 20&lt;/code&gt; setting; it is the baseline with no blocking. Lag rises with the blocking time: 306ms at 50ms, 612ms at 100ms, 1224ms at 200ms. CPU does not change, and only lag reflects the blocking.&lt;/p&gt;

&lt;p&gt;At 200ms, the p99 of &lt;code&gt;/light&lt;/code&gt; (2407ms) is higher than that of &lt;code&gt;/sync-cpu&lt;/code&gt; (1222ms). &lt;code&gt;/light&lt;/code&gt; has no blocking work of its own, but it sometimes waits a long time in the queue, and the larger the blocking time, the wider the spread of its p99.&lt;/p&gt;

&lt;p&gt;Even when the overall CPU graph is flat, requests keep waiting in the queue if the event loop is congested. Looking only at overall CPU misses the congestion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spillover to unrelated endpoints
&lt;/h2&gt;

&lt;p&gt;A Node.js process shares one event loop across all of its endpoints. While &lt;code&gt;/sync-cpu&lt;/code&gt; is blocking the event loop, requests to &lt;code&gt;/light&lt;/code&gt; wait in the queue too.&lt;/p&gt;

&lt;p&gt;At 0ms, the p99 of &lt;code&gt;/light&lt;/code&gt; is 4ms. At 50ms it jumps to 353ms. Nothing about &lt;code&gt;/light&lt;/code&gt; itself changed. &lt;code&gt;/sync-cpu&lt;/code&gt; occupied the event loop, and as a result the unrelated &lt;code&gt;/light&lt;/code&gt; was kept waiting in the queue as well. The longer the blocking time, the worse the latency of &lt;code&gt;/light&lt;/code&gt; gets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observing lag
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Measuring event loop lag
&lt;/h3&gt;

&lt;p&gt;Since Node.js v11.10.0, &lt;code&gt;monitorEventLoopDelay()&lt;/code&gt; in &lt;code&gt;node:perf_hooks&lt;/code&gt; gives you event loop lag.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;resolution: 20&lt;/code&gt;, Node.js repeatedly sets a timer that says "call me back in 20ms." Each time it is called back, it records how many ms it actually took against the planned 20ms. If the event loop is blocked by other work, this timer cannot get in either. With a 300ms block, a timer meant for 20ms is called only after 320ms. The difference, 300ms, is the lag for that sample. The samples accumulate in a histogram (a distribution recording how often each lag value occurs), and &lt;code&gt;percentile(99)&lt;/code&gt; extracts the p99.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;monitorEventLoopDelay&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:perf_hooks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;monitorEventLoopDelay&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;resolution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lag p99:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;percentile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;h.percentile(99)&lt;/code&gt; returns the p99 of the accumulated samples in nanoseconds, and &lt;code&gt;/ 1e6&lt;/code&gt; converts it to milliseconds. &lt;code&gt;h.reset()&lt;/code&gt; clears the samples and starts measurement for the next 5 seconds. Export this as a metric and you can catch abnormal lag even when the CPU graph is flat.&lt;/p&gt;

&lt;h3&gt;
  
  
  Per-core CPU
&lt;/h3&gt;

&lt;p&gt;The blocking work in this experiment is a busy loop that repeats JSON.parse, and it keeps one core fully used. Overall CPU looks like around 25%, but a per-thread view with &lt;code&gt;top -H&lt;/code&gt; or &lt;code&gt;htop&lt;/code&gt; shows the Node.js main thread pinned at 100%, which confirms why the overall figure looked low.&lt;/p&gt;

&lt;p&gt;When you run into the same symptom (low overall CPU, high p99), per-core CPU shows which thread the offending work is saturating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Effect and limits of an overload guard
&lt;/h2&gt;

&lt;p&gt;I implemented middleware that uses the measured lag to detect overload and return 503 (Service Unavailable, the HTTP status code that indicates the server temporarily cannot handle the request).&lt;/p&gt;

&lt;p&gt;At startup it repeatedly sets a &lt;code&gt;setTimeout&lt;/code&gt; with a 100ms interval. Each time the callback runs, it writes the difference between the planned 100ms and the actual elapsed ms into &lt;code&gt;lagMs&lt;/code&gt;. If the event loop is blocked, this timer cannot get in either, so &lt;code&gt;lagMs&lt;/code&gt; rises.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;monitorEventLoopDelay&lt;/code&gt; can run at a 20ms interval without trouble because it is implemented in the C++ layer of Node.js (libuv), which keeps its impact on the JS thread to a minimum. A hand-written &lt;code&gt;setTimeout&lt;/code&gt; loop, by contrast, creates a timer and runs a callback on the JS thread every time. The shorter the interval, the more of that overhead lands on the event loop itself, so the middleware's measurement uses 100ms.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lagMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;measure&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;lagMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;measure&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;unref&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;On each request, the middleware compares &lt;code&gt;lagMs&lt;/code&gt; with the threshold. If lag exceeds the threshold (150ms), it returns 503 immediately with &lt;code&gt;Retry-After: 1&lt;/code&gt;. If lag is at or below the threshold, it passes the request to the next handler.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lagMs&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;thresholdMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Retry-After&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;overloaded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lag_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lagMs&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;lagMs&lt;/code&gt; is a single global variable in the process. &lt;code&gt;/sync-cpu&lt;/code&gt; and &lt;code&gt;/light&lt;/code&gt; both read the same value. Even when &lt;code&gt;/sync-cpu&lt;/code&gt; is the one pushing lag up, the request that gets the 503 once the threshold is exceeded is the next one to arrive: whichever request enters the queue first, regardless of endpoint.&lt;/p&gt;

&lt;p&gt;I applied the same load with this guard in place (threshold 150ms, same run).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;sync_ms&lt;/th&gt;
&lt;th&gt;/sync-cpu p99&lt;/th&gt;
&lt;th&gt;/sync-cpu 503 rate (%)&lt;/th&gt;
&lt;th&gt;/light p99&lt;/th&gt;
&lt;th&gt;/light 503 rate (%)&lt;/th&gt;
&lt;th&gt;lag_p99&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0ms&lt;/td&gt;
&lt;td&gt;3ms&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;3ms&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;21.7ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50ms&lt;/td&gt;
&lt;td&gt;208ms&lt;/td&gt;
&lt;td&gt;95.6%&lt;/td&gt;
&lt;td&gt;203ms&lt;/td&gt;
&lt;td&gt;95.4%&lt;/td&gt;
&lt;td&gt;305.7ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100ms&lt;/td&gt;
&lt;td&gt;212ms&lt;/td&gt;
&lt;td&gt;98.7%&lt;/td&gt;
&lt;td&gt;202ms&lt;/td&gt;
&lt;td&gt;98.7%&lt;/td&gt;
&lt;td&gt;713.0ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;150ms&lt;/td&gt;
&lt;td&gt;307ms&lt;/td&gt;
&lt;td&gt;98.9%&lt;/td&gt;
&lt;td&gt;162ms&lt;/td&gt;
&lt;td&gt;99.0%&lt;/td&gt;
&lt;td&gt;1072.2ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200ms&lt;/td&gt;
&lt;td&gt;401ms&lt;/td&gt;
&lt;td&gt;99.0%&lt;/td&gt;
&lt;td&gt;217ms&lt;/td&gt;
&lt;td&gt;99.0%&lt;/td&gt;
&lt;td&gt;1653.6ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;p99 here is computed over all requests, 503s included. Since most of them are immediate 503s, this p99 is not a measure of how slow the requests that got through were. At sync=100ms, the &lt;code&gt;/sync-cpu&lt;/code&gt; p99 goes from 712ms to 212ms, and the &lt;code&gt;/light&lt;/code&gt; p99 from 1524ms to 202ms.&lt;/p&gt;

&lt;p&gt;Look at the 503 rate. With a 150ms threshold, at sync=100ms, 98.7% of requests to both endpoints get a 503. Of the 18793 requests that reached &lt;code&gt;/sync-cpu&lt;/code&gt;, only 239 got through. As long as the blocking work continues, lag piles up far beyond the threshold, and the service stays stuck rejecting most requests. The overload guard alone does not make a working service.&lt;/p&gt;

&lt;p&gt;With the guard in place, lag_p99 did not go down, and at sync_ms of 100ms or more it went up.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;sync_ms&lt;/th&gt;
&lt;th&gt;lag_p99 (no guard)&lt;/th&gt;
&lt;th&gt;lag_p99 (with guard, threshold 150ms)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;50ms&lt;/td&gt;
&lt;td&gt;305.9ms&lt;/td&gt;
&lt;td&gt;305.7ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100ms&lt;/td&gt;
&lt;td&gt;612.4ms&lt;/td&gt;
&lt;td&gt;713.0ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;150ms&lt;/td&gt;
&lt;td&gt;763.9ms&lt;/td&gt;
&lt;td&gt;1072.2ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200ms&lt;/td&gt;
&lt;td&gt;1223.7ms&lt;/td&gt;
&lt;td&gt;1653.6ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The guard turns new requests away with 503. It cannot stop blocking work that is already running on the event loop. The time &lt;code&gt;/sync-cpu&lt;/code&gt; spends blocking the event loop continues regardless of the guard. The middleware's check and the &lt;code&gt;setTimeout&lt;/code&gt; callback for measuring lag also run on the event loop, so while &lt;code&gt;/sync-cpu&lt;/code&gt; is blocking, they cannot get in either.&lt;/p&gt;

&lt;p&gt;Once lag exceeds the threshold, the guard returns 503 to every request regardless of endpoint. Even when &lt;code&gt;/sync-cpu&lt;/code&gt; is what pushes lag up, &lt;code&gt;/light&lt;/code&gt; gets rejected the same way.&lt;/p&gt;

&lt;p&gt;The guard improves what requests that get through experience, but it does not resolve event loop starvation. Lag staying high shows this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding and fixing the blocking work
&lt;/h2&gt;

&lt;p&gt;Wherever the 503 threshold is set, lag does not go down as long as the blocking work keeps running. The overload guard limits inflow, and it leaves the work that congests the event loop untouched.&lt;/p&gt;

&lt;h3&gt;
  
  
  Finding it
&lt;/h3&gt;

&lt;p&gt;The fundamental fix is to identify the work that is blocking the event loop and fix it. Once you have confirmed the symptom in production, bring it to a staging or development environment where you can reproduce the same load pattern. Start the process with &lt;code&gt;node --prof&lt;/code&gt;, apply similar load, and after stopping it, process the sampling log with &lt;code&gt;node --prof-process&lt;/code&gt;. This identifies the functions that occupy CPU time.&lt;/p&gt;

&lt;p&gt;Here is a profile taken in this test environment. It is the result of loading &lt;code&gt;/sync-cpu?ms=200&lt;/code&gt; with 10 concurrent connections for 20 seconds (same run).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Statistical profiling result (27870 ticks, 2 unaccounted, 0 excluded).

[Bottom up (heavy) profile]:
   ticks parent  name
  25652   92.0%  node
  25375   98.9%    node
  23368   92.1%      JS: *blockEventLoop  routes/sync-cpu.js:10
  23368  100.0%        JS: ^&amp;lt;anonymous&amp;gt;   routes/sync-cpu.js:17
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;84% of all CPU ticks (the number of times the profiler, sampling at a fixed interval, observed where the CPU was executing) are concentrated in &lt;code&gt;blockEventLoop&lt;/code&gt;. Following the callers also shows that it is called from an Express route handler. Confirming that blocking exists with lag, then narrowing down the responsible function with &lt;code&gt;--prof-process&lt;/code&gt;, is the efficient order.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fixing it
&lt;/h3&gt;

&lt;p&gt;How to fix it depends on the kind of work. For synchronous I/O like &lt;code&gt;fs.readFileSync&lt;/code&gt; or &lt;code&gt;execSync&lt;/code&gt;, replacing it with the asynchronous &lt;code&gt;fs.readFile&lt;/code&gt; or &lt;code&gt;exec&lt;/code&gt; frees the event loop.&lt;/p&gt;

&lt;p&gt;For work that uses CPU for a long time, the design of doing it inside a request handler on the application server needs to be reconsidered. Heavy work such as image processing or video conversion does not need to be synchronous with the response. The application server receives the request, puts a job on a queue, and immediately returns "accepted." The work itself lives outside the application server, in a separate worker process, Lambda, Cloud Functions, and so on. Because it is decoupled from the application server's event loop, the application server does not get congested however heavy the work is. For work that has to happen on the request path, such as JSON.parse on MB-sized data, moving it to a separate thread with &lt;code&gt;worker_threads&lt;/code&gt; is an option. Before that, reconsider whether the application server should be handling that work at all.&lt;/p&gt;

&lt;p&gt;Running many Node.js application servers such as Express or Fastify as 1 vCPU containers, with a load balancer in front distributing requests, is a widely used setup. In this setup, when one container gets congested, the other containers keep accepting requests, so the single-thread constraint is absorbed operationally. The problem of one container's event loop getting congested does not go away, though. Requests assigned to that container stay queued inside it, and the same symptom shown in this article appears. Horizontal scaling solves the throughput problem; it does not resolve latency degradation inside one container.&lt;/p&gt;

&lt;p&gt;Check whether the fix worked with lag_p99. Once the blocking work is removed, lag drops sharply.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The Node.js event loop handles every request on a single thread. When one piece of work blocks, every request in the process stops in the queue, and unrelated endpoints slow down uniformly.&lt;/p&gt;

&lt;p&gt;An overall CPU graph does not show this congestion. On a 4 vCPU machine, one congested core shows up as only around 25% overall. Looking at CPU for this symptom will not find the cause.&lt;/p&gt;

&lt;p&gt;What to check is lag. Export lag_p99 as a metric with &lt;code&gt;monitorEventLoopDelay()&lt;/code&gt;, and you can detect blocking even when the CPU graph is flat. Looking at it alongside per-core CPU also shows which thread is congested.&lt;/p&gt;

&lt;p&gt;An overload guard that returns 503 once lag exceeds a threshold improves response time, but lag itself does not go down, because the blocking work keeps running on the event loop. The guard buys time to find the root cause. Treat it as temporary protection until the blocking work is removed.&lt;/p&gt;

</description>
      <category>node</category>
      <category>performance</category>
      <category>observability</category>
      <category>devops</category>
    </item>
    <item>
      <title>Buttons don't work for some users after a deploy</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Fri, 09 Oct 2026 13:03:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/deploy-weekly-and-the-previous-version-keeps-running-for-17-hours-1ji0</link>
      <guid>https://dev.to/shinagawa-web/deploy-weekly-and-the-previous-version-keeps-running-for-17-hours-1ji0</guid>
      <description>&lt;p&gt;After a deploy, the service that collects errors from users' browsers (Sentry or similar) starts showing this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeError: Failed to fetch dynamically imported module: https://app.example.com/assets/feature-DHyZGV0s.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser tried to load a JS file with &lt;code&gt;import()&lt;/code&gt; at runtime and could not fetch it. &lt;code&gt;feature-DHyZGV0s.js&lt;/code&gt; in the URL is a chunk: part of the app's functionality, split out into its own file by the build. &lt;code&gt;DHyZGV0s&lt;/code&gt; is a hash the build derived from the file's contents.&lt;/p&gt;

&lt;p&gt;Around the same time, some users report that "the button does nothing." Someone on the team opens the same page and clicks the same button, and it works. Nobody can reproduce it locally, and the errors keep coming in, not only right after the deploy but for hours afterward.&lt;/p&gt;

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

&lt;p&gt;Users who lose a feature this way have no way to fix it themselves. They don't know what would make it work again, so all they can do is file a report. On the receiving end, the team spends time answering reports that don't reproduce internally and chasing their cause. This repeats with every deploy. In the browsers that throw the error, the deployed change is not running. A deploy meant to fix a bug does not reach those users. The side serving the app does not control which version runs in which user's browser. After every deploy, some users stay off the fixed version for as long as the previous version keeps running.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduction
&lt;/h2&gt;

&lt;p&gt;I built a minimal app with Vite. The build produces three files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;index.html: the HTML the browser fetches first when it opens the site&lt;/li&gt;
&lt;li&gt;Entry JS: loaded by index.html, renders the page&lt;/li&gt;
&lt;li&gt;Chunk: loaded with &lt;code&gt;import()&lt;/code&gt; when the button is clicked&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built it twice with slightly different contents: a previous version, served first, and a new version that replaces it in the deploy. Since the contents differ, the hashes in the JS file names differ too. index.html is the only file without a hash.&lt;/p&gt;

&lt;p&gt;Each version's files were baked into an nginx container image. To check whether this happens without any special configuration, nginx ran with the default config that ships in the image. A deploy means replacing the previous version's container with the new version's container. The new image does not include the previous version's JS, so after the deploy those files are gone from the server. The previous version started being served 10 minutes before the browser opened the site.&lt;/p&gt;

&lt;p&gt;The browser is Chromium driven by Playwright. Each trial uses a fresh profile (the on-disk storage for history and other data that survives closing the browser), and runs these steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the site while it serves the previous version. Two cases: click the button here to fetch the chunk, or don't&lt;/li&gt;
&lt;li&gt;Close the browser&lt;/li&gt;
&lt;li&gt;Deploy the new version. This finishes within a few seconds of opening the site&lt;/li&gt;
&lt;li&gt;Once a set time has passed since the first open, relaunch the browser with the same profile, open the site again, and click the button&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only two things are recorded: which version ran after reopening (the app displays its own version on the page), and whether the chunk loaded.&lt;/p&gt;

&lt;p&gt;I tried reopening 30, 54, and 66 seconds after the first open.&lt;sup id="fnref1"&gt;1&lt;/sup&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Without clicking the button first
&lt;/h3&gt;

&lt;p&gt;Each case ran 18 times in CI, with identical results every time.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;First open to reopen&lt;/th&gt;
&lt;th&gt;Version on reopen&lt;/th&gt;
&lt;th&gt;Result of clicking the button&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;30.1 to 30.3 s&lt;/td&gt;
&lt;td&gt;Previous&lt;/td&gt;
&lt;td&gt;Error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;54.1 to 54.2 s&lt;/td&gt;
&lt;td&gt;Previous&lt;/td&gt;
&lt;td&gt;Error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;66.1 to 66.2 s&lt;/td&gt;
&lt;td&gt;New&lt;/td&gt;
&lt;td&gt;New version's feature shown&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When the previous version ran, clicking the button failed to load the chunk with the following error, and nothing appeared on the page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeError: Failed to fetch dynamically imported module: http://127.0.0.1:18000/assets/feature-DHyZGV0s.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The site was opened after the deploy, yet the previous version ran, tried to load its own chunk, and failed. This is the same error as at the top, and the same "button does nothing" behavior.&lt;/p&gt;

&lt;h3&gt;
  
  
  With the button clicked first
&lt;/h3&gt;

&lt;p&gt;On the first open, I clicked the button so the previous version's chunk was loaded, then closed the browser. Each case ran 9 times in CI, with identical results every time.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;First open to reopen&lt;/th&gt;
&lt;th&gt;Version on reopen&lt;/th&gt;
&lt;th&gt;Result of clicking the button&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;30.1 to 30.2 s&lt;/td&gt;
&lt;td&gt;Previous&lt;/td&gt;
&lt;td&gt;Previous version's feature shown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;54.1 to 54.2 s&lt;/td&gt;
&lt;td&gt;Previous&lt;/td&gt;
&lt;td&gt;Previous version's feature shown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;66.1 to 66.4 s&lt;/td&gt;
&lt;td&gt;New&lt;/td&gt;
&lt;td&gt;New version's feature shown&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The version on reopen was the same as without the click. The only difference was what happened when the button was clicked while the previous version was running: the already-loaded chunk ran as is, and not a single error occurred.&lt;/p&gt;

&lt;p&gt;In both cases, for a browser that opened the site 10 minutes after the previous version started being served, reopening 54 seconds after the first open still ran the previous version, and reopening at 66 seconds ran the new one. The error appears only when the user had not clicked that button under the previous version. Only part of the users running the previous version show up in the error collector. The rest never appear there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Narrowing it down
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Access log
&lt;/h3&gt;

&lt;p&gt;Look at the nginx access log around the time of the error. For a browser that ran the previous version and hit the error, the new version's server logged only this one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;"GET /assets/feature-DHyZGV0s.js HTTP/1.1" 404 153 "http://127.0.0.1:18000/assets/index-DB4b__oJ.js"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The previous version's chunk is being requested by the same version's entry JS (&lt;code&gt;index-DB4b__oJ.js&lt;/code&gt;). The new version's server doesn't have that file, so it returns 404.&lt;/p&gt;

&lt;p&gt;After the deploy, the previous version's page is running. I checked the possible causes in order, starting from the server side.&lt;/p&gt;

&lt;h3&gt;
  
  
  Old and new mixed during the deploy
&lt;/h3&gt;

&lt;p&gt;In a rolling deploy, some requests reach previous-version servers while the replacement is in progress. The reproduction replaces a single container, and that finishes within a few seconds of the first open. The request above reached the new version's server about 50 seconds after that. The previous version's page is running after the replacement has finished, so this is ruled out.&lt;/p&gt;

&lt;h3&gt;
  
  
  Server-side cache
&lt;/h3&gt;

&lt;p&gt;Check whether the server is returning the previous version's index.html, including any proxy or cache in between if the setup has one. Look at the new version's access log per browser. From browsers that ran the new version, requests for index.html and the entry JS arrived, and both returned 200.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="err"&gt;"&lt;/span&gt;GET / HTTP/1.1" 200 321 "-"
&lt;span class="err"&gt;"&lt;/span&gt;GET /assets/index-D879X9_S.js HTTP/1.1" 200 2573 "http://127.0.0.1:18000/"
&lt;span class="err"&gt;"&lt;/span&gt;GET /assets/feature-CpSIXz8z.js HTTP/1.1" 200 89 "http://127.0.0.1:18000/assets/index-D879X9_S.js"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From browsers that ran the previous version, no request arrived for index.html or for the entry JS. The chunk request was the only line. The server isn't returning the previous index.html; it isn't being asked for index.html at all. Ruled out.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tabs left open
&lt;/h3&gt;

&lt;p&gt;A tab left open since before the deploy can also request the previous version's chunk. In the reproduction, the browser was closed after the first open and relaunched after the deploy. The page was loaded after the deploy, so it is not a tab left open. Ruled out. To check this in your own environment, attach the page load time (&lt;code&gt;performance.timeOrigin&lt;/code&gt;) when reporting errors and compare it with the deploy time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Browser HTTP cache
&lt;/h3&gt;

&lt;p&gt;What remains is the browser using an index.html it already had. Whether a page load came from the server or from the browser's own copy shows up in &lt;code&gt;transferSize&lt;/code&gt; in Navigation Timing, which the browser records for each page load. Run this in the browser DevTools console to see the value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntriesByType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;transferSize&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;JS files have the same &lt;code&gt;transferSize&lt;/code&gt; in Resource Timing, which you can list with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntriesByType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resource&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transferSize&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The value is positive when bytes came over the network and 0 when the response came from the browser's HTTP cache.&lt;sup id="fnref2"&gt;2&lt;/sup&gt; When the previous version ran, both index.html and the entry JS were 0. When the new version ran, index.html was 621 (larger than the byte count in the access log because it includes headers) and the entry JS was 2873. The previous version's page was rendered from the index.html and JS in the browser's HTTP cache. In the case where the button had been clicked first, the chunk was 0 too, and not a single request reached the new version's server.&lt;/p&gt;

&lt;p&gt;The headers on the index.html received at first open had no &lt;code&gt;Cache-Control&lt;/code&gt;, which specifies how long a response may be cached. nginx's default config doesn't add one. Instead, they had the time the response was sent (&lt;code&gt;Date&lt;/code&gt;) and the time the file was last modified (&lt;code&gt;Last-Modified&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Date: Tue, 29 Sep 2026 08:26:55 GMT
Last-Modified: Tue, 29 Sep 2026 08:16:53 GMT
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Last-Modified&lt;/code&gt; is about 10 minutes before &lt;code&gt;Date&lt;/code&gt;, matching the reproduction's setup of starting to serve the previous version 10 minutes before opening the site.&lt;/p&gt;

&lt;p&gt;The browser is using an index.html with no specified cache lifetime without asking the server. But when reopening 66 seconds after the first open, it does go back to the server for index.html.&lt;/p&gt;

&lt;h2&gt;
  
  
  Relationship to time since modification
&lt;/h2&gt;

&lt;p&gt;Next, I measured what determines how long the browser uses index.html without asking the server. Three things were varied:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How long index.html had gone unmodified at the moment it was fetched: the difference between &lt;code&gt;Date&lt;/code&gt; and &lt;code&gt;Last-Modified&lt;/code&gt; at fetch time, called time since modification below. "Opened 10 minutes after the previous version started being served" in the reproduction is a time since modification of 10 minutes. The 30, 54, and 66 second reopens correspond to 5%, 9%, and 11% of that&lt;/li&gt;
&lt;li&gt;The time from the first open to the reopen&lt;/li&gt;
&lt;li&gt;How the page was reopened: relaunching the browser and opening the site, or reloading the open page&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else matched the reproduction, and the button was not clicked on the first open. Each condition ran 4 times in CI, for 12 trials per data point.&lt;/p&gt;

&lt;h3&gt;
  
  
  Time since modification
&lt;/h3&gt;

&lt;p&gt;I set the time since modification to 10 minutes, 100 minutes, 1000 minutes, and 6 days, and reopened at 5%, 9%, 11%, and 20% of each.&lt;sup id="fnref3"&gt;3&lt;/sup&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;index.html modified to first open&lt;/th&gt;
&lt;th&gt;How long the previous version ran&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10 min&lt;/td&gt;
&lt;td&gt;About 1 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 min&lt;/td&gt;
&lt;td&gt;About 10 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1000 min (about 17 h)&lt;/td&gt;
&lt;td&gt;About 100 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6 days&lt;/td&gt;
&lt;td&gt;About 14 h&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each was about 10% of the time from modification to first open. With 100 minutes, for example, reopening after 9 minutes ran the previous version, and after 11 minutes the new one. With 6 days, reopening after 13 hours still ran the previous version, and clicking the button produced the error.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reload
&lt;/h3&gt;

&lt;p&gt;With a time since modification of 100 minutes, I relaunched the browser, opened the site, and reloaded the page before clicking the button. In all 24 trials where the previous version was running when the site opened, the reload switched to the new version, and clicking the button showed the new version's feature. The reload fetched index.html from the server again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is happening
&lt;/h2&gt;

&lt;p&gt;For responses without &lt;code&gt;Cache-Control&lt;/code&gt;, the HTTP caching spec lets a cache guess how long it may use the response without checking, and gives 10% of the time since &lt;code&gt;Last-Modified&lt;/code&gt; as a typical value.&lt;sup id="fnref4"&gt;4&lt;/sup&gt; Chromium uses exactly this, with no upper limit.&lt;sup id="fnref5"&gt;5&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;When build artifacts are baked into an image, &lt;code&gt;Last-Modified&lt;/code&gt; is the build time. On a site that deploys once a week, a browser that opened the site just before the next deploy uses index.html and the entry JS without checking for about 17 hours, 10% of 7 days.&lt;/p&gt;

&lt;p&gt;If a deploy happens in that window, reopening the site still runs the previous version's page. Its chunks are gone from the server, so using a feature that hasn't been loaded yet returns 404. After about 17 hours, index.html is fetched again and the new version takes over.&lt;/p&gt;

&lt;p&gt;Users who opened the site earlier in the week had a shorter time since modification, so the previous version lingers for less time after the deploy. With weekly deploys, the errors last at most about 17 hours. Browsers that open the site for the first time after the deploy, or that reload, get the new version, so checking internally may not reproduce it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What decides it
&lt;/h2&gt;

&lt;p&gt;Which version runs in the browser is decided not by when you deployed, but by when the HTML the browser holds gets fetched again.&lt;/p&gt;

&lt;p&gt;Hashed files change name whenever their contents change. Which version's files get loaded is decided entirely by the HTML, whose name never changes. If you don't specify a cache lifetime for that HTML, the browser decides when to fetch it again. When a deployed version reaches users is then out of the serving side's hands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Set a cache lifetime for the HTML
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Add no-cache to index.html
&lt;/h4&gt;

&lt;p&gt;With &lt;code&gt;Cache-Control: no-cache&lt;/code&gt; on index.html, the browser checks with the server every time it opens the page whether index.html has changed.&lt;sup id="fnref6"&gt;6&lt;/sup&gt; If it hasn't, the server returns 304 and sends no body. Opening the site after a deploy gets the new version's index.html.&lt;/p&gt;

&lt;p&gt;In the reproduction's nginx, I added this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/index.html&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt;   &lt;span class="n"&gt;/usr/share/nginx/html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"no-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;The cost is one extra round trip to the server each time the page opens. This fits when you want deployed versions to reach users right away.&lt;/p&gt;

&lt;h4&gt;
  
  
  Add max-age to index.html
&lt;/h4&gt;

&lt;p&gt;With &lt;code&gt;Cache-Control: max-age=&amp;lt;seconds&amp;gt;&lt;/code&gt;, the browser uses index.html without checking for that many seconds, regardless of time since modification.&lt;sup id="fnref7"&gt;7&lt;/sup&gt; The window in which the previous version runs doesn't go away. You decide its length yourself instead of letting the browser guess.&lt;/p&gt;

&lt;p&gt;If the seconds you specify are longer than what the browser would have guessed, the previous version actually runs longer. This fits when you want to avoid a round trip on every open but still want a ceiling on how long the previous version can run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reload when loading fails
&lt;/h3&gt;

&lt;p&gt;Catch the chunk load failure and reload the page. A reload fetches index.html again, so it switches to the new version. In Vite, a load failure is signaled with the &lt;code&gt;vite:preloadError&lt;/code&gt; event.&lt;sup id="fnref8"&gt;8&lt;/sup&gt; In the reproduction, I reloaded only once, like this:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vite:preloadError&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reloaded-once&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reloaded-once&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;();&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;The cost is that in-progress page state is lost on reload, and you need a guard so that it doesn't reload repeatedly if the new version also fails. The code above never clears its flag, so if the same tab fails again after the next deploy, it won't reload. The previous version still runs, and this does nothing for users who are running it without hitting an error. Don't use it alone. Combine it with no-cache or max-age, and use it to move users who still hit the error onto the new version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Effect of the fixes
&lt;/h2&gt;

&lt;p&gt;Using the same setup as the reproduction, I opened an index.html with a time since modification of 100 minutes, varied the time until reopening, and compared against no fix. Each condition ran 4 times in CI, for 12 trials per data point.&lt;/p&gt;

&lt;h3&gt;
  
  
  no-cache
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Reopen after&lt;/th&gt;
&lt;th&gt;No fix&lt;/th&gt;
&lt;th&gt;no-cache&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5 min&lt;/td&gt;
&lt;td&gt;Previous (error)&lt;/td&gt;
&lt;td&gt;New&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9 min&lt;/td&gt;
&lt;td&gt;Previous (error)&lt;/td&gt;
&lt;td&gt;New&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11 min&lt;/td&gt;
&lt;td&gt;New&lt;/td&gt;
&lt;td&gt;New&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20 min&lt;/td&gt;
&lt;td&gt;New&lt;/td&gt;
&lt;td&gt;New&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The new version ran at every point measured, and the window where the previous version runs disappeared. When reopening without a deploy, there was one request for index.html each time, which returned 304 with no body.&lt;/p&gt;

&lt;h3&gt;
  
  
  max-age
&lt;/h3&gt;

&lt;p&gt;I added &lt;code&gt;max-age=300&lt;/code&gt; (5 minutes).&lt;/p&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;How long the previous version ran&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No fix&lt;/td&gt;
&lt;td&gt;About 10 min (previous at 9 min, new at 11 min)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;max-age=300&lt;/td&gt;
&lt;td&gt;About 5 min (previous at 4 min, new at 6 min)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The window where the previous version runs shrank from about 10 minutes to about 5. Reopening at 9 and 11 minutes also ran the new version. With a time since modification of 1000 minutes, the result was the same: previous at 4 minutes, new at 6. The window is the length you specify, regardless of time since modification.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reload on failure
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Reopen after&lt;/th&gt;
&lt;th&gt;No fix&lt;/th&gt;
&lt;th&gt;Reload on failure&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5 min&lt;/td&gt;
&lt;td&gt;Previous (error)&lt;/td&gt;
&lt;td&gt;Previous, new after reload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9 min&lt;/td&gt;
&lt;td&gt;Previous (error)&lt;/td&gt;
&lt;td&gt;Previous, new after reload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11 min&lt;/td&gt;
&lt;td&gt;New&lt;/td&gt;
&lt;td&gt;New&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20 min&lt;/td&gt;
&lt;td&gt;New&lt;/td&gt;
&lt;td&gt;New&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The previous version ran for about 10 minutes, the same as with no fix. Clicking the button under the previous version failed to load the chunk, triggered one reload, and then showed the new version's feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope
&lt;/h2&gt;

&lt;p&gt;These results assume that index.html has &lt;code&gt;Last-Modified&lt;/code&gt; but no &lt;code&gt;Cache-Control&lt;/code&gt;, that hashed files are removed by the deploy, and that the browser is Chromium. Change any of these and the length of the previous-version window or the way errors show up changes. Of the variations below, only two were measured: the setup that keeps the previous version's JS, and the setup that doesn't return 404. The rest are read from source code and how the mechanism works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Browsers
&lt;/h3&gt;

&lt;p&gt;Only Chromium headless shell 153.0.8010.12 was measured.&lt;/p&gt;

&lt;p&gt;According to their source, Firefox and Safari also use index.html without checking for 10% of the time since &lt;code&gt;Last-Modified&lt;/code&gt;, the same as Chromium.&lt;sup id="fnref9"&gt;9&lt;/sup&gt; With weekly deploys, the maximum is about 17 hours in every browser.&lt;/p&gt;

&lt;p&gt;Firefox is the only one that differs from Chromium: it caps this window at one week. The cap kicks in only when the time since modification exceeds 70 days, so if you deploy more often than every 70 days, there is no difference.&lt;/p&gt;

&lt;p&gt;In the reproduction, the browser was closed and relaunched for each reopen. Opening the site in a new tab with the browser still running was not measured.&lt;/p&gt;

&lt;h3&gt;
  
  
  When the previous version's JS is kept
&lt;/h3&gt;

&lt;p&gt;I included the previous version's JS in the new image as well and measured with a time since modification of 100 minutes. The previous version ran for about 10 minutes, the same as with no fix, and clicking the button during that time returned the previous version's chunk with 200 and showed the previous version's feature. Not a single error occurred.&lt;/p&gt;

&lt;p&gt;With no errors, it is harder to notice that the previous version is running. The previous version's page keeps calling the newly deployed server-side code, so that code has to stay compatible with it. A setup that puts build artifacts in S3 and runs &lt;code&gt;aws s3 sync&lt;/code&gt; without &lt;code&gt;--delete&lt;/code&gt; is in this state, because the previous version's files are never removed.&lt;/p&gt;

&lt;h3&gt;
  
  
  When missing paths don't return 404
&lt;/h3&gt;

&lt;p&gt;In the main results, the request for the previous version's chunk got a 404. With nginx configured to return index.html for missing paths (&lt;code&gt;try_files $uri /index.html&lt;/code&gt;), the same request gets a 200 whose body is index.html. Searching the access log for 404 finds nothing. Instead, look for lines where a hashed file path returned 200 with 321 bytes, the same size as index.html.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;"GET /assets/feature-DHyZGV0s.js HTTP/1.1" 200 321 "http://127.0.0.1:18024/assets/index-DB4b__oJ.js"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The message that reached the error collector was the same &lt;code&gt;Failed to fetch dynamically imported module&lt;/code&gt; as with the 404. The browser console also showed this error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failed to load module script: Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of "text/html". Strict MIME type checking is enforced for module scripts per HTML spec.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Other ways of serving
&lt;/h3&gt;

&lt;p&gt;Whether you serve from S3, a CDN, or a hosting service, or render the HTML on the server per request, you can tell whether this applies by looking at the response headers for index.html.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://app.example.com/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'^(cache-control|expires|last-modified|age):'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If there is neither &lt;code&gt;Cache-Control&lt;/code&gt; nor &lt;code&gt;Expires&lt;/code&gt;, and only &lt;code&gt;Last-Modified&lt;/code&gt;, the same thing as in this article will happen. If there is an &lt;code&gt;Age&lt;/code&gt; header, the response came from a CDN in between that was holding it.&lt;/p&gt;

&lt;h3&gt;
  
  
  When file timestamps are pinned
&lt;/h3&gt;

&lt;p&gt;If the build pins file modification times to a fixed past time (&lt;code&gt;SOURCE_DATE_EPOCH&lt;/code&gt; and similar), &lt;code&gt;Last-Modified&lt;/code&gt; is that time, not the build time. The time since modification is then not the deploy interval but however long it has been since that fixed time. Chromium has no upper limit, so the further back the fixed time, the longer index.html is used without checking, potentially for years.&lt;/p&gt;

&lt;h3&gt;
  
  
  With a Service Worker
&lt;/h3&gt;

&lt;p&gt;If a Service Worker serves index.html from its own cache, the same symptom appears. In that case, adding no-cache or max-age to index.html has no effect. To check whether one is registered, run &lt;code&gt;navigator.serviceWorker.getRegistrations()&lt;/code&gt; in the console.&lt;/p&gt;

&lt;h3&gt;
  
  
  Built with webpack
&lt;/h3&gt;

&lt;p&gt;The same failure surfaces as &lt;code&gt;ChunkLoadError&lt;/code&gt;. The message starts with &lt;code&gt;Loading chunk &amp;lt;chunkId&amp;gt; failed.&lt;/code&gt;&lt;sup id="fnref10"&gt;10&lt;/sup&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Appendix: how the times were produced
&lt;/h2&gt;

&lt;p&gt;The time since modification was produced without waiting. nginx returns a file's modification time as &lt;code&gt;Last-Modified&lt;/code&gt;, so when starting the container, I shifted the modification time of the files on the server into the past.&lt;/p&gt;

&lt;p&gt;The time until reopening was actually waited out in the reproduction, and produced without waiting in the conditions that varied the time since modification. When the previous version's server adds an &lt;code&gt;Age: &amp;lt;seconds&amp;gt;&lt;/code&gt; header to index.html, the browser treats the response as already that many seconds old at the moment it receives it.&lt;sup id="fnref11"&gt;11&lt;/sup&gt; The reopen happened right after the fetch, and the &lt;code&gt;Age&lt;/code&gt; value plus the few seconds that actually passed was recorded as the time until reopening.&lt;/p&gt;

&lt;p&gt;I confirmed that this method doesn't change the results by comparing it with actually waiting, at a time since modification of 10 minutes. With both methods, the switch to the new version happened once the time until reopening exceeded 10% of the time since modification.&lt;/p&gt;

&lt;p&gt;Changing when the new version is deployed (right after the first open, halfway to the reopen, or right before the reopen) gave the same results. This was confirmed by actually waiting, at a time since modification of 10 minutes.&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;The setup was Chromium headless shell 153.0.8010.12 (the build without a window), Playwright 1.63.0, nginx 1.30.5, and Vite 8.3.1. The reproduction setup and scripts are in &lt;a href="https://github.com/shinagawa-web/show-your-work/tree/8a52991/chunk-load-heuristic-freshness" rel="noopener noreferrer"&gt;show-your-work&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn2"&gt;
&lt;p&gt;The definition of &lt;a href="https://www.w3.org/TR/resource-timing/#dom-performanceresourcetiming-transfersize" rel="noopener noreferrer"&gt;&lt;code&gt;transferSize&lt;/code&gt;&lt;/a&gt; in W3C Resource Timing.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn3"&gt;
&lt;p&gt;9% of 6 days is over 13 hours. None of the times in this section were actually waited out; the appendix at the end explains how they were produced.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn4"&gt;
&lt;p&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9111#section-4.2.2" rel="noopener noreferrer"&gt;RFC 9111, section 4.2.2&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn5"&gt;
&lt;p&gt;The line &lt;code&gt;lifetimes.freshness = (date_value - last_modified_value.value()) / 10;&lt;/code&gt; in Chromium 153.0.8010.12's &lt;a href="https://github.com/chromium/chromium/blob/153.0.8010.12/net/http/http_response_headers.cc#L1350" rel="noopener noreferrer"&gt;&lt;code&gt;GetFreshnessLifetimes&lt;/code&gt;&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn6"&gt;
&lt;p&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9111#section-5.2.2.4" rel="noopener noreferrer"&gt;RFC 9111, section 5.2.2.4&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn7"&gt;
&lt;p&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9111#section-5.2.2.1" rel="noopener noreferrer"&gt;RFC 9111, section 5.2.2.1&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn8"&gt;
&lt;p&gt;&lt;a href="https://vite.dev/guide/build#load-error-handling" rel="noopener noreferrer"&gt;Load error handling&lt;/a&gt; in the Vite docs. A Vite maintainer has commented that this failure is not specific to Vite, also occurs with webpack, and has no proposed fix on the bundler side (&lt;a href="https://github.com/vitejs/vite/issues/11804#issuecomment-1402258323" rel="noopener noreferrer"&gt;vitejs/vite#11804&lt;/a&gt;).&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn9"&gt;
&lt;p&gt;Firefox: &lt;a href="https://github.com/mozilla-firefox/firefox/blob/b333202cd8d8385cb382c42404a03995dbc126ec/netwerk/protocol/http/nsHttpResponseHead.cpp#L681-L690" rel="noopener noreferrer"&gt;nsHttpResponseHead.cpp&lt;/a&gt;. Safari: WebKit's &lt;a href="https://github.com/WebKit/WebKit/blob/6999b70da5d7d7158d841a3e342a1548f80aa2e1/Source/WebCore/platform/network/CacheValidation.cpp#L155-L158" rel="noopener noreferrer"&gt;CacheValidation.cpp&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn10"&gt;
&lt;p&gt;&lt;a href="https://github.com/webpack/webpack/blob/v5.111.1/lib/web/JsonpChunkLoadingRuntimeModule.js#L224-L230" rel="noopener noreferrer"&gt;webpack v5.111.1 source&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn11"&gt;
&lt;p&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9111#section-4.2.3" rel="noopener noreferrer"&gt;RFC 9111, section 4.2.3&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>vite</category>
      <category>webdev</category>
      <category>caching</category>
      <category>devops</category>
    </item>
    <item>
      <title>High p99 latency at low CPU usage is caused by CPU throttling</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 06 Oct 2026 13:01:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/high-p99-latency-at-low-cpu-usage-is-caused-by-cpu-throttling-gm9</link>
      <guid>https://dev.to/shinagawa-web/high-p99-latency-at-low-cpu-usage-is-caused-by-cpu-throttling-gm9</guid>
      <description>&lt;p&gt;Every metric on the dashboard is within its normal range. CPU usage is 44%, memory has headroom, and the error rate is zero. p50 (the value in the exact middle when response times are sorted from shortest to longest) is 21ms, the same as usual.&lt;/p&gt;

&lt;p&gt;In that state, p99 (the value at the boundary with the slowest 1% when response times are sorted) goes up and stays up. It is normally 31ms, and now it is 139ms.&lt;/p&gt;

&lt;p&gt;The slow requests are not concentrated in any particular time of day. A single request to the same endpoint returns quickly.&lt;/p&gt;

&lt;p&gt;At the time this was found, it was not clear under what conditions requests became slow.&lt;/p&gt;

&lt;p&gt;I reproduced this state with Docker and measured why only p99 gets slow.&lt;/p&gt;

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

&lt;p&gt;The first impact is slower responses for users. At least 1 in 100 requests waits 139ms or more for a response that would normally come back in 31ms. Since every metric on the dashboard is within its normal range, no alert fires, and nobody notices that slow responses keep happening.&lt;/p&gt;

&lt;p&gt;The second impact is cost. Raising the CPU limit to bring p99 back means fewer containers fit on one host, so more hosts are needed. And because usage is 44%, usage cannot tell you how far to raise the limit. Too little, and requests stay slow. Too much, and the extra becomes idle host capacity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduction
&lt;/h2&gt;

&lt;p&gt;I reproduced the state above with Docker.&lt;sup id="fnref1"&gt;1&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;The server under test is an HTTP server that spends 20ms of CPU time per request and returns an empty 200. Four workers each accept and handle connections. There is no queue inside the application; while all workers are busy, connections wait in the kernel's accept queue. This is so the queue length can be read from outside the server with &lt;code&gt;ss&lt;/code&gt;. The container has a CPU limit set with &lt;code&gt;--cpus 0.5&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Load was applied from outside the container for 60 seconds. Each request opened its own connection, at an average of 10 per second, with random intervals between arrivals (Poisson arrivals). This models many users sending requests independently.&lt;/p&gt;

&lt;p&gt;At 20ms per request and 10 requests per second, CPU usage works out to 40% of the limit. Latency was measured on the load generator side, from send to receive.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&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;CPU usage (60-second average)&lt;/td&gt;
&lt;td&gt;43.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p50&lt;/td&gt;
&lt;td&gt;20.6ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99&lt;/td&gt;
&lt;td&gt;138.6ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Errors&lt;/td&gt;
&lt;td&gt;0 of 609&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This matches the dashboard described at the start.&lt;sup id="fnref2"&gt;2&lt;/sup&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Narrowing it down
&lt;/h2&gt;

&lt;p&gt;I narrowed down the source of the delay by ruling out candidates one at a time.&lt;/p&gt;

&lt;h3&gt;
  
  
  The accept queue
&lt;/h3&gt;

&lt;p&gt;A slow request was either delayed while waiting for a worker to become free, or delayed after processing started. If there are not enough workers, connections queue in the listen socket's accept queue. Its length can be read from Recv-Q in &lt;code&gt;ss -lnt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Over the 60 seconds, Recv-Q peaked at 3 and averaged 0.018. At the moments the 7 slow requests at or above p99 were sent, it was 0 to 2. Requests were not queuing for busy workers. This rules out a worker shortage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Downstream calls
&lt;/h3&gt;

&lt;p&gt;A delay after processing starts means either waiting on something downstream (a DB or an external call) or not getting CPU. If it is waiting downstream, long entries show up in APM spans or DB-side records at the same time as the slow requests. The server under test makes no downstream calls, so this check is listed only as a step to perform on a real system, and I moved on to the CPU side.&lt;/p&gt;

&lt;h3&gt;
  
  
  CPU usage
&lt;/h3&gt;

&lt;p&gt;The dashboard shows 44% CPU usage, so CPU looks sufficient. But that is a 60-second average, and consumption concentrated in short bursts gets buried in the average. So I collected usage again as 1-second averages.&lt;/p&gt;

&lt;p&gt;Even at 1-second averages, usage peaked at 73.5% of the limit, and 0 of the 60 seconds were at 90% or above. Shrinking the aggregation interval to 1 second still does not show any moment where CPU runs short. Instead of making the average finer, I looked at the records of hitting the limit directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;cpu.stat&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;When a container hits its CPU limit, it is stopped (throttled). The number of times it was stopped accumulates as &lt;code&gt;nr_throttled&lt;/code&gt; in the container cgroup's &lt;code&gt;cpu.stat&lt;/code&gt;.&lt;sup id="fnref3"&gt;3&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;Over the 60 seconds, &lt;code&gt;nr_throttled&lt;/code&gt; increased by 65. The limit is enforced per 100ms period, and there were 531 periods in the 60 seconds (&lt;code&gt;nr_periods&lt;/code&gt; in &lt;code&gt;cpu.stat&lt;/code&gt;). In 12% of them, roughly once per second, the container hit the limit and was stopped. If &lt;code&gt;nr_throttled&lt;/code&gt; is not increasing, suspect CPU contention with other processes on the host.&lt;/p&gt;

&lt;p&gt;Usage is only 44% averaged over 60 seconds. The records so far do not explain why it still hits the limit.&lt;sup id="fnref4"&gt;4&lt;/sup&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conditions for hitting the limit
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--cpus 0.5&lt;/code&gt; is a limit that allows using up to half a core. The kernel enforces it as up to 50ms of CPU time per 100ms period.&lt;sup id="fnref5"&gt;5&lt;/sup&gt; Even with 44% usage averaged over 60 seconds, arrivals are random, so the CPU time used in each period varies.&lt;/p&gt;

&lt;p&gt;Two possible reasons for hitting the limit: usage is high to begin with, or the 4 workers use CPU at the same time. Starting from the reproduction setup, I changed one condition at a time, ran each for 60 seconds, and compared the fraction of periods that hit the limit (&lt;code&gt;nr_throttled&lt;/code&gt; / &lt;code&gt;nr_periods&lt;/code&gt;, called the throttle rate below).&lt;/p&gt;

&lt;h3&gt;
  
  
  Usage
&lt;/h3&gt;

&lt;p&gt;With 4 workers, I changed arrivals to 5 and 20 per second.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Arrivals per second&lt;/th&gt;
&lt;th&gt;CPU usage (60-second average)&lt;/th&gt;
&lt;th&gt;Throttle rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;20.5%&lt;/td&gt;
&lt;td&gt;2.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 (same as reproduction)&lt;/td&gt;
&lt;td&gt;43.5%&lt;/td&gt;
&lt;td&gt;12.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;87.1%&lt;/td&gt;
&lt;td&gt;70.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The throttle rate rises with usage. But even at 20% usage it does not reach zero; the limit is hit about 3 times per 100 periods. Usage alone does not determine it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Number of workers
&lt;/h3&gt;

&lt;p&gt;With arrivals at 10 per second, I reduced the workers to 1.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workers&lt;/th&gt;
&lt;th&gt;Arrivals per second&lt;/th&gt;
&lt;th&gt;CPU usage (60-second average)&lt;/th&gt;
&lt;th&gt;Throttle rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4 (same as reproduction)&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;43.5%&lt;/td&gt;
&lt;td&gt;12.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;42.9%&lt;/td&gt;
&lt;td&gt;13.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The throttle rate barely changed.&lt;/p&gt;

&lt;p&gt;The kernel checks the total CPU time used within the period, not how many workers used it. Even when one worker handles requests in sequence, 3 arrivals in one period use 60ms, which exceeds 50ms.&lt;/p&gt;

&lt;h3&gt;
  
  
  CPU time per period
&lt;/h3&gt;

&lt;p&gt;From these two results, whether the limit is hit appears to depend on how much CPU time is needed within a period. I split the 60 seconds of the reproduction (4 workers) into 100ms periods aligned to the times the kernel switches periods, and counted the CPU time needed in each. The CPU time needed is the sum of processing for requests arriving in that period and processing that spilled over unfinished from the previous period.&lt;/p&gt;

&lt;p&gt;Periods needing less than 40ms never hit the limit. Periods needing 40 to 50ms hit it in 4 of 78, periods needing 50 to 60ms in 16 of 17, and periods needing 60ms or more in all 45.&lt;/p&gt;

&lt;p&gt;Restricting to periods with no spillover from the previous period, the split follows the number of arrivals. Periods with 0 to 2 arrivals hit the limit in 0 of 410, and periods with 3 or more arrivals in 29 of 34. In the periods with 3 or more arrivals that did not hit the limit, the last arrival came 93ms or later after the period started, and its processing spilled into the next period.&lt;/p&gt;

&lt;p&gt;Of the 65 periods that hit the limit, 36 carried spillover from the previous period. Even with low average usage, random arrivals sometimes overlap, and processing that overlaps and does not finish is carried into the next period.&lt;/p&gt;

&lt;p&gt;Both the overlap and the carryover happen on a 100ms scale, so they do not show up as usage in either the 60-second or the 1-second average. Even at 100ms granularity, what you see depends on where the window falls.&lt;/p&gt;

&lt;p&gt;When usage is cut into 100ms windows aligned to period boundaries, all 65 periods that hit the limit used 45ms or more. In the 1-worker run, the 100ms windows used to collect usage were offset by 74ms from the period boundaries. Each window straddled two periods, and of the 70 periods that hit the limit, only 26 showed 45ms or more.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is happening
&lt;/h2&gt;

&lt;p&gt;To see how much a request in a throttled period is delayed, I followed a single request from arrival to response.&lt;/p&gt;

&lt;h3&gt;
  
  
  Waiting before accept
&lt;/h3&gt;

&lt;p&gt;When a connection arrives, the kernel completes the TCP handshake. Even while the container is stopped by the limit, the connection is established and placed in the accept queue.&lt;/p&gt;

&lt;p&gt;A queued connection is accepted by a free worker, which starts processing it. This request needs 20ms of CPU time. But while the container is stopped, the free workers are stopped too and cannot accept. Even at the head of the queue, the connection waits until the next period starts.&lt;/p&gt;

&lt;h3&gt;
  
  
  The period's 50ms
&lt;/h3&gt;

&lt;p&gt;The container is allotted a budget of 50ms of CPU time per 100ms period. This 50ms is drawn down by the total CPU time used by all workers in the container. If N workers run at the same time on separate cores, it drops by N ms per 1ms of elapsed time.&lt;/p&gt;

&lt;p&gt;The rate of drawdown depends on the number of workers, but the total CPU time demanded in that period does not. The total decides whether the budget runs out. If 3 requests arrive in one period, the demand is 60ms, which exceeds 50ms. With 4 workers, 3 run at the same time and use it up about 17ms into the period. With 1 worker, requests are processed one at a time and it is used up at 50ms. Either way it runs out.&lt;/p&gt;

&lt;p&gt;Whether a given request is stopped depends on whether the period's 50ms runs out before the request finishes its own 20ms. What matters is how much the requests that arrived earlier in the same period have already used. If it does not run out, the request returns in a little over 20ms.&lt;/p&gt;

&lt;h3&gt;
  
  
  The whole container stops
&lt;/h3&gt;

&lt;p&gt;When the 50ms is used up, all threads in the container are stopped.&lt;sup id="fnref6"&gt;6&lt;/sup&gt; Requests being processed stop mid-processing, and connections not yet accepted wait unaccepted.&lt;/p&gt;

&lt;p&gt;At this point &lt;code&gt;nr_throttled&lt;/code&gt; in &lt;code&gt;cpu.stat&lt;/code&gt; goes up by 1. The stopped time accumulates in &lt;code&gt;throttled_usec&lt;/code&gt; in the same file, but what gets added is not the time for one container stop. It is the time per stopped CPU.&lt;sup id="fnref7"&gt;7&lt;/sup&gt; If threads running on 3 CPUs are stopped for 30ms, &lt;code&gt;throttled_usec&lt;/code&gt; goes up by 90ms.&lt;/p&gt;

&lt;p&gt;So dividing &lt;code&gt;throttled_usec&lt;/code&gt; by &lt;code&gt;nr_throttled&lt;/code&gt; does not give the length of one stop. In the 60-second reproduction, this division gave 75.0ms, but the length of each stop measured individually with bpftrace averaged 39.3ms. Summing the stopped time per stopped CPU gives 4960ms, which nearly matches the 4873ms increase in &lt;code&gt;throttled_usec&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;While stopped, no request in the container makes progress, whatever stage it is in. Its response is delayed by the time remaining in the period.&lt;/p&gt;

&lt;p&gt;What the number of workers changes is when the budget runs out and how long the stop lasts from there. In the example of 3 arrivals in one period, 4 workers stop for about 83ms and 1 worker for 50ms. However, as shown below in "Tracing each request", every slow request at or above p99 was stopped twice with either number of workers.&lt;/p&gt;

&lt;p&gt;When the next period starts, the 50ms is restored. Waiting connections are accepted, and requests that were mid-processing continue. If the budget runs out again before they finish, they stop again.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tracing each request
&lt;/h3&gt;

&lt;p&gt;In the reproduction setup, I traced each request to the thread that handled it. I matched the load generator's source port to the peer port of the connection the server accepted, and used bpftrace to record which threads were running at the moment of throttling. The length of each stop was measured as the time from the kernel's &lt;code&gt;throttle_cfs_rq&lt;/code&gt; to &lt;code&gt;unthrottle_cfs_rq&lt;/code&gt;.&lt;sup id="fnref8"&gt;8&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;A single stop lasted at most 84.4ms, with a median of 34.5ms, and all 65 were released when the next period started.&lt;/p&gt;

&lt;p&gt;All 7 slow requests at or above p99 were throttled twice before returning a response. Two stops on top of 20ms of processing give 139 to 192ms. The stages at which the two stops occurred were as follows.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage stopped&lt;/th&gt;
&lt;th&gt;4 workers (same as reproduction)&lt;/th&gt;
&lt;th&gt;1 worker&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Once before accept, once during processing&lt;/td&gt;
&lt;td&gt;6 of 7&lt;/td&gt;
&lt;td&gt;6 of 7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twice during processing&lt;/td&gt;
&lt;td&gt;1 of 7&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twice before accept&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1 of 7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With 4 workers, the 6 requests stopped before accept waited 32 to 69ms to be accepted. Every stop during processing happened while that request's thread was running.&lt;/p&gt;

&lt;p&gt;When these 7 were sent, Recv-Q was 0 to 2. That is the same value as when the worker shortage was ruled out in "The accept queue", so the workers were not full. Even with a short queue, if the accepting side is stopped, the connection waits at the head of the queue.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mapping to the symptoms
&lt;/h3&gt;

&lt;p&gt;Only some requests are slow because identical requests to the same endpoint split by arrival timing: some finish before the period's 50ms runs out, and the rest are stopped when it does.&lt;/p&gt;

&lt;p&gt;The slow requests are not concentrated in any time of day because the only thing deciding the split is arrival timing; the time of day itself plays no part.&lt;/p&gt;

&lt;p&gt;A single request is fast because no other worker is running and the period's 50ms has not been drawn down. 20ms always fits within the period's 50ms.&lt;/p&gt;

&lt;p&gt;CPU usage stays at 44% because how much of the period's 50ms was used shows up in usage, but the time spent unable to proceed after it ran out does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What decides it
&lt;/h2&gt;

&lt;p&gt;For a resource with a fixed amount available per period, whether it runs out is not decided by average usage alone, but by how much is used within one period. Here the period is 100ms, the available amount is 50ms, and the amount used within a period was the sum of arrivals overlapping in that period and processing spilled over from the previous period. To reduce how often it runs out, change how it is used within the period, or change how much is available per period.&lt;/p&gt;

&lt;p&gt;When a resource is handed out in short periods and observed as averages over longer intervals, the averages do not show that the allotment ran out. Here, against the 100ms period, the dashboard used 60-second averages, and even re-collected data used 1-second averages; neither showed the periods that ran out.&lt;/p&gt;

&lt;p&gt;So a low average is not evidence of headroom. Even if the aggregation interval is shrunk to the allotment period, windows not aligned to period boundaries show only some of the periods that ran out. The reliable check is to count the number of times it ran out. For a CPU limit, that count is &lt;code&gt;nr_throttled&lt;/code&gt; in &lt;code&gt;cpu.stat&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Remedies
&lt;/h2&gt;

&lt;p&gt;The cause was the combination of the CPU limit and arrivals overlapping within one period, and it did not show up in usage. There are two places to act: change how much is available per period (raise the CPU limit), and make it possible to count how many times it ran out.&lt;/p&gt;

&lt;h3&gt;
  
  
  Raise the CPU limit
&lt;/h3&gt;

&lt;p&gt;This increases the amount available per period. Unless it is raised enough to cover the arrivals that overlap within one period, throttling remains.&lt;/p&gt;

&lt;p&gt;This fits when the host has spare capacity and p99 takes priority over cost. It is an option to consider when the application cannot be changed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Add &lt;code&gt;nr_throttled&lt;/code&gt; and &lt;code&gt;nr_periods&lt;/code&gt; to monitoring
&lt;/h3&gt;

&lt;p&gt;This does not fix p99. The next time the same symptom appears, the monitoring screen shows that the limit is being hit, without re-collecting usage as 60-second and then 1-second averages.&lt;/p&gt;

&lt;p&gt;Both are cumulative counters, so taking the difference at your current collection interval gives the number of times the limit was hit and the number of periods in that interval.&lt;/p&gt;

&lt;p&gt;There are two costs. The location of &lt;code&gt;cpu.stat&lt;/code&gt; differs by environment, so collection needs per-environment configuration. And as shown by the 2.8% throttle rate at 20% usage, the throttle rate is not necessarily zero in normal operation, so choosing an alert threshold takes work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Effect of the remedy
&lt;/h2&gt;

&lt;p&gt;Keeping the reproduction setup (4 workers, 10 per second), I changed only the CPU limit to 0.5 / 0.75 / 1.0 / 1.5 / 2.0 and ran each for 60 seconds. 0.5 is the reproduction condition. The limit number is how many cores' worth may be used. 2.0 is 2 cores' worth, half of the host's 4 cores.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;CPU limit&lt;/th&gt;
&lt;th&gt;CPU usage (fraction of limit)&lt;/th&gt;
&lt;th&gt;Throttle rate&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;p99&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;td&gt;43.5%&lt;/td&gt;
&lt;td&gt;12.2%&lt;/td&gt;
&lt;td&gt;20.6ms&lt;/td&gt;
&lt;td&gt;138.6ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.75&lt;/td&gt;
&lt;td&gt;28.1%&lt;/td&gt;
&lt;td&gt;2.1%&lt;/td&gt;
&lt;td&gt;20.4ms&lt;/td&gt;
&lt;td&gt;44.5ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;21.1%&lt;/td&gt;
&lt;td&gt;0.38%&lt;/td&gt;
&lt;td&gt;20.4ms&lt;/td&gt;
&lt;td&gt;33.2ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.5&lt;/td&gt;
&lt;td&gt;14.5%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;20.5ms&lt;/td&gt;
&lt;td&gt;31.1ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.0&lt;/td&gt;
&lt;td&gt;10.5%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;20.4ms&lt;/td&gt;
&lt;td&gt;33.0ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The higher the limit, the lower the throttle rate. At 1.0 it was 0.38% (2 of 522 periods), and at 1.5 and above it was 0.&lt;/p&gt;

&lt;p&gt;p99 is 31 to 33ms at 1.0 and above, and raising the limit further barely changes it. The "usual p99" of 31ms from the start falls in this range.&lt;/p&gt;

&lt;p&gt;The CPU the container actually used averaged 0.21 to 0.22 cores in every condition. The limit of 1.0 needed to nearly eliminate throttling is about 5 times that.&lt;/p&gt;

&lt;p&gt;The throttle rate went from 12.2% to 0.38% when the limit was raised from 0.5 to 1.0. With &lt;code&gt;nr_throttled&lt;/code&gt; and &lt;code&gt;nr_periods&lt;/code&gt; in monitoring, the effect of raising the limit can also be confirmed by the change in this value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes
&lt;/h2&gt;

&lt;p&gt;The CPU limit appears in &lt;code&gt;cpu.max&lt;/code&gt; under Docker, systemd, and Kubernetes (k3s) alike, and &lt;code&gt;cpu.stat&lt;/code&gt; can be read in all of them. A half-core limit was &lt;code&gt;50000 100000&lt;/code&gt; in every case. What differs between environments is where the limit is set and the path to &lt;code&gt;cpu.stat&lt;/code&gt;.&lt;sup id="fnref9"&gt;9&lt;/sup&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Environment&lt;/th&gt;
&lt;th&gt;Where the limit is set&lt;/th&gt;
&lt;th&gt;Location of &lt;code&gt;cpu.stat&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;From inside the container&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Docker (systemd cgroup driver)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--cpus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/sys/fs/cgroup/system.slice/docker-&amp;lt;id&amp;gt;.scope/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Readable at &lt;code&gt;/sys/fs/cgroup/cpu.stat&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;systemd service&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CPUQuota=&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/sys/fs/cgroup/system.slice/&amp;lt;name&amp;gt;.service/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes (k3s)&lt;/td&gt;
&lt;td&gt;Pod &lt;code&gt;resources.limits.cpu&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/sys/fs/cgroup/kubepods.slice/kubepods-&amp;lt;qos&amp;gt;.slice/kubepods-&amp;lt;qos&amp;gt;-pod&amp;lt;uid&amp;gt;.slice/cri-containerd-&amp;lt;cid&amp;gt;.scope/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Readable at &lt;code&gt;/sys/fs/cgroup/cpu.stat&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;Docker ran on a GitHub Actions runner (ubuntu-24.04, 4 vCPU, cgroup v2).&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn2"&gt;
&lt;p&gt;The reproduction results are &lt;a href="https://github.com/shinagawa-web/show-your-work/actions/runs/36109031236" rel="noopener noreferrer"&gt;here&lt;/a&gt;, and the code is in &lt;a href="https://github.com/shinagawa-web/show-your-work/tree/84d0863/cpu-quota-throttling-p99" rel="noopener noreferrer"&gt;show-your-work&lt;/a&gt;. Each condition ran in a separate CI job, and the runner CPU model differed between jobs (a mix of AMD EPYC and Intel Xeon). Comparisons between conditions in this article therefore span different CPU models.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn3"&gt;
&lt;p&gt;The kernel 6.8 cgroup v2 docs on &lt;a href="https://github.com/torvalds/linux/blob/v6.8/Documentation/admin-guide/cgroup-v2.rst#L1078-L1090" rel="noopener noreferrer"&gt;&lt;code&gt;cpu.stat&lt;/code&gt;&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn4"&gt;
&lt;p&gt;On kernel 5.3 and earlier, a bug where the unused part of the quota handed out to each CPU expires also produces a high throttle rate at low CPU usage. The fix went into 5.4 (&lt;a href="https://github.com/torvalds/linux/commit/de53fd7aedb100f03e5d2231cfce0e4993282425" rel="noopener noreferrer"&gt;de53fd7&lt;/a&gt;). The measurements in this article were taken on a newer kernel.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn5"&gt;
&lt;p&gt;The description of &lt;code&gt;--cpus&lt;/code&gt; in Docker's &lt;a href="https://docs.docker.com/engine/containers/resource_constraints/" rel="noopener noreferrer"&gt;resource constraints&lt;/a&gt; docs, and the kernel 6.8 cgroup v2 docs on &lt;a href="https://github.com/torvalds/linux/blob/v6.8/Documentation/admin-guide/cgroup-v2.rst#L1114-L1125" rel="noopener noreferrer"&gt;&lt;code&gt;cpu.max&lt;/code&gt;&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn6"&gt;
&lt;p&gt;The kernel 6.8 documentation on &lt;a href="https://github.com/torvalds/linux/blob/v6.8/Documentation/scheduler/sched-bwc.rst" rel="noopener noreferrer"&gt;CFS bandwidth control&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn7"&gt;
&lt;p&gt;In kernel 6.8, &lt;a href="https://github.com/torvalds/linux/blob/v6.8/kernel/sched/fair.c#L5833-L5848" rel="noopener noreferrer"&gt;&lt;code&gt;unthrottle_cfs_rq()&lt;/code&gt;&lt;/a&gt; adds up the time each per-CPU run queue was stopped, each time it releases one.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn8"&gt;
&lt;p&gt;The kernel 6.8 functions &lt;a href="https://github.com/torvalds/linux/blob/v6.8/kernel/sched/fair.c#L5744" rel="noopener noreferrer"&gt;&lt;code&gt;throttle_cfs_rq()&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/torvalds/linux/blob/v6.8/kernel/sched/fair.c#L5833" rel="noopener noreferrer"&gt;&lt;code&gt;unthrottle_cfs_rq()&lt;/code&gt;&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn9"&gt;
&lt;p&gt;The CPU limit settings in systemd (&lt;a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html#CPUQuota=" rel="noopener noreferrer"&gt;&lt;code&gt;CPUQuota=&lt;/code&gt;&lt;/a&gt;) and Kubernetes (&lt;a href="https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/" rel="noopener noreferrer"&gt;Resource Management for Pods and Containers&lt;/a&gt;).&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>docker</category>
      <category>linux</category>
      <category>devops</category>
      <category>performance</category>
    </item>
    <item>
      <title>No shell in a distroless Pod: what kubectl debug can and can't see</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 29 Sep 2026 13:02:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/no-shell-in-a-distroless-pod-what-kubectl-debug-can-and-cant-see-122o</link>
      <guid>https://dev.to/shinagawa-web/no-shell-in-a-distroless-pod-what-kubectl-debug-can-and-cant-see-122o</guid>
      <description>&lt;p&gt;Something is off with a Pod. The app logs show nothing that looks like an error.&lt;/p&gt;

&lt;p&gt;At this point I want to get inside and look around: open a shell with &lt;code&gt;kubectl exec&lt;/code&gt;, check processes with &lt;code&gt;ps&lt;/code&gt;, send a request to the app with &lt;code&gt;curl localhost&lt;/code&gt;. The usual routine.&lt;/p&gt;

&lt;p&gt;But the shell won't start.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl exec app -c app -- sh
error: Internal error occurred: Internal error occurred: error executing command in container: failed to exec in container: failed to start exec "5559c54c81498e2d06cfaf7704ee420b7a48a48ef1531e34307aa515f53083b3": OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last part, &lt;code&gt;exec: "sh": executable file not found in $PATH&lt;/code&gt;, is the answer: this container has no &lt;code&gt;sh&lt;/code&gt;. It has no &lt;code&gt;ls&lt;/code&gt; or &lt;code&gt;cat&lt;/code&gt; either.&lt;/p&gt;

&lt;p&gt;The container runs from a distroless image. A distroless image contains only the app's binary and the minimum files it needs to run. It leaves out the shell and the package manager so that there are no tools for an attacker to use. As a result, even during an incident, you can't run commands inside the container to investigate.&lt;/p&gt;

&lt;p&gt;Kubernetes has a mechanism for this situation. You can add a temporary container to a running Pod. This is called an ephemeral container, and you add one with the &lt;code&gt;kubectl debug&lt;/code&gt; command.&lt;/p&gt;

&lt;p&gt;You leave the app container untouched and add a separate container to the same Pod, one that has &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;ss&lt;/code&gt;, and &lt;code&gt;tcpdump&lt;/code&gt;. Containers in a Pod share the network, so running &lt;code&gt;curl localhost:3000&lt;/code&gt; from the added container reaches the app directly.&lt;/p&gt;

&lt;p&gt;The official docs cover the basic usage of &lt;code&gt;kubectl debug&lt;/code&gt;, so this article covers what you can see once you're inside.&lt;/p&gt;

&lt;p&gt;Once you're inside, you quickly run into cases where you can't get the information you want.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You try to read the app's config file, and nothing exists at that path&lt;/li&gt;
&lt;li&gt;You check the CPU limit in &lt;code&gt;/sys/fs/cgroup/cpu.max&lt;/code&gt;, and instead of the app's &lt;code&gt;50000 100000&lt;/code&gt; you get your own unlimited &lt;code&gt;max 100000&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;There's a way around to see the app's files, but without &lt;code&gt;SYS_PTRACE&lt;/code&gt; you get &lt;code&gt;Permission denied&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even within the same Pod, how far you can reach into the app is determined by Linux namespaces, and whether you can read what you reach is determined by permissions. This article doesn't walk through tracking down the cause of an incident. It checks, one item at a time against real output, what you can and can't see once inside along these two axes, and shows a workaround for each thing you can't see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test setup
&lt;/h2&gt;

&lt;p&gt;I ran everything on a kind v0.33.0 cluster on a GitHub Actions x86_64 runner (ubuntu-24.04, kernel 6.17). Kubernetes is v1.37.0 and the container runtime is containerd v2.3.4. The target app is a small HTTP server written in Go, built on &lt;code&gt;gcr.io/distroless/static-debian12:nonroot&lt;/code&gt;. For the debug image I used &lt;code&gt;nicolaka/netshoot:v0.16&lt;/code&gt;. All output in this article is pasted as-is from this environment.&lt;/p&gt;

&lt;p&gt;kind is a tool that runs a Kubernetes cluster using Docker containers as nodes. I used a cluster with a single node (&lt;code&gt;ded-control-plane&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;I put one Pod, &lt;code&gt;app&lt;/code&gt;, on this cluster and added containers to it with &lt;code&gt;kubectl debug&lt;/code&gt; to investigate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Node ded-control-plane (kind)
└── Pod app
    ├── app            distroless HTTP server (:3000, uid 65532)
    ├── sidecar        HTTP server from the same image (:4000)
    ├── dbg-notarget   added with kubectl debug (no --target)
    ├── dbg-default    added with kubectl debug (--target=app)
    └── dbg-sysadmin   added with kubectl debug (--target=app --profile=sysadmin)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;app&lt;/code&gt; container is the one under investigation. The distroless &lt;code&gt;:nonroot&lt;/code&gt; image sets the runtime user to uid 65532 (&lt;code&gt;nonroot&lt;/code&gt;), and this Pod doesn't override it with &lt;code&gt;runAsUser&lt;/code&gt;, so &lt;code&gt;app&lt;/code&gt; runs as uid 65532. Two of its settings matter in later sections.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The CPU limit is &lt;code&gt;500m&lt;/code&gt; (half a core)&lt;/li&gt;
&lt;li&gt;The ConfigMap's &lt;code&gt;app.json&lt;/code&gt; is mounted at &lt;code&gt;/config&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;sidecar&lt;/code&gt; is a regular container defined in the Pod from the start, running the same image as &lt;code&gt;app&lt;/code&gt; on a different port. It's there to compare what containers in a Pod share.&lt;/p&gt;

&lt;p&gt;The three containers whose names start with &lt;code&gt;dbg-&lt;/code&gt; are ephemeral containers added later with &lt;code&gt;kubectl debug&lt;/code&gt;. They differ only in the options passed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--target=app&lt;/code&gt; makes the added container see the same set of processes as the &lt;code&gt;app&lt;/code&gt; container. With it, &lt;code&gt;app&lt;/code&gt;'s processes show up in &lt;code&gt;ps&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--profile&lt;/code&gt; selects the permissions the added container gets. If you don't specify it, you get &lt;code&gt;general&lt;/code&gt;, which adds one capability for inspecting other processes (&lt;code&gt;SYS_PTRACE&lt;/code&gt;). &lt;code&gt;dbg-notarget&lt;/code&gt; and &lt;code&gt;dbg-default&lt;/code&gt; use this profile. Specifying &lt;code&gt;sysadmin&lt;/code&gt; gives you a privileged container, meaning one with nearly every permission. A later section shows measured differences between the profiles.&lt;/p&gt;

&lt;p&gt;The reproduction code is here.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/shinagawa-web/show-your-work/tree/main/distroless-ephemeral-debug" rel="noopener noreferrer"&gt;https://github.com/shinagawa-web/show-your-work/tree/main/distroless-ephemeral-debug&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Background: container isolation is set per namespace
&lt;/h2&gt;

&lt;p&gt;Linux can partition what a process sees (the network, process IDs, the filesystem, and so on), each one separately. Each of these partitions is called a namespace. A container is built from a combination of these namespaces.&lt;/p&gt;

&lt;p&gt;The key point is that each namespace is independent. You can't describe a container as simply "isolated". It's normal for a container to share the network with its neighbor while having a separate filesystem. This explains why, from a container added with &lt;code&gt;kubectl debug&lt;/code&gt;, &lt;code&gt;curl localhost&lt;/code&gt; reaches the app while the app's config file is not visible. Both happen at the same time.&lt;/p&gt;

&lt;p&gt;You can check how they're actually split from the node. Each process has one link per namespace under &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/ns/&lt;/code&gt;. Reading &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/ns/net&lt;/code&gt; with &lt;code&gt;readlink&lt;/code&gt;, for example, returns a string like &lt;code&gt;net:[4026532670]&lt;/code&gt;. The number in brackets identifies the namespace, and processes with the same number are in the same namespace.&lt;/p&gt;

&lt;p&gt;To do this you need each container's PID. On the node &lt;code&gt;ded-control-plane&lt;/code&gt;, I used &lt;code&gt;crictl&lt;/code&gt; (a command that talks to the container runtime directly) and took each container's process PID on the node from &lt;code&gt;.info.pid&lt;/code&gt; in &lt;code&gt;crictl inspect &amp;lt;container ID&amp;gt;&lt;/code&gt;. For each PID I ran &lt;code&gt;readlink&lt;/code&gt; on every link under &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/ns/&lt;/code&gt; and put the results on one line per container. That gives the table below, trimmed to the rows and columns this article needs. I dropped the ipc, uts, user, and time columns because this article doesn't use them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;container      node_pid  net                    pid                    mnt                    cgroup
node-init      1         net:[4026532365]       pid:[4026532362]       mnt:[4026532359]       cgroup:[4026532364]
app            4082      net:[4026532670]       pid:[4026532736]       mnt:[4026532735]       cgroup:[4026532737]
sidecar        4118      net:[4026532670]       pid:[4026532739]       mnt:[4026532738]       cgroup:[4026532740]
dbg-notarget   4193      net:[4026532670]       pid:[4026532742]       mnt:[4026532741]       cgroup:[4026532743]
dbg-default    4247      net:[4026532670]       pid:[4026532736]       mnt:[4026532744]       cgroup:[4026532745]
dbg-sysadmin   4863      net:[4026532670]       pid:[4026532736]       mnt:[4026532754]       cgroup:[4026532364]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;node_pid&lt;/code&gt; is the PID on the node from &lt;code&gt;crictl&lt;/code&gt;, and the columns to its right are the &lt;code&gt;readlink&lt;/code&gt; results. &lt;code&gt;node-init&lt;/code&gt; is PID 1 on the node, included for comparison.&lt;/p&gt;

&lt;p&gt;net has the same number for every container. For pid, only &lt;code&gt;dbg-default&lt;/code&gt; and &lt;code&gt;dbg-sysadmin&lt;/code&gt; have the same number as &lt;code&gt;app&lt;/code&gt;. mnt is different for every container. For cgroup, only &lt;code&gt;dbg-sysadmin&lt;/code&gt; has the same number as the node.&lt;/p&gt;

&lt;p&gt;The next table summarizes these results per namespace and adds what each namespace separates and what a debug container can see.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;namespace&lt;/th&gt;
&lt;th&gt;What it separates&lt;/th&gt;
&lt;th&gt;Between containers in a Pod&lt;/th&gt;
&lt;th&gt;Ephemeral container&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;network&lt;/td&gt;
&lt;td&gt;IP addresses, ports, sockets&lt;/td&gt;
&lt;td&gt;Shared&lt;/td&gt;
&lt;td&gt;Shared&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;curl localhost&lt;/code&gt; and &lt;code&gt;ss&lt;/code&gt; show the app's traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PID&lt;/td&gt;
&lt;td&gt;Process IDs and which processes are visible&lt;/td&gt;
&lt;td&gt;Separate (can be shared with &lt;code&gt;shareProcessNamespace&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Shared with the target when &lt;code&gt;--target&lt;/code&gt; is set, separate otherwise&lt;/td&gt;
&lt;td&gt;With &lt;code&gt;--target&lt;/code&gt;, the target's processes show up in &lt;code&gt;ps&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mount&lt;/td&gt;
&lt;td&gt;How the filesystem looks&lt;/td&gt;
&lt;td&gt;Separate&lt;/td&gt;
&lt;td&gt;Separate&lt;/td&gt;
&lt;td&gt;The other container's files aren't directly visible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cgroup&lt;/td&gt;
&lt;td&gt;How CPU and memory limits look&lt;/td&gt;
&lt;td&gt;Separate&lt;/td&gt;
&lt;td&gt;Separate (only sysadmin shares it with the node)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/sys/fs/cgroup&lt;/code&gt; shows your own values (except with sysadmin)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This table covers how far you can reach into the app. Whether you can read what you reach depends on permissions, which I cover in "Permissions: SYS_PTRACE and profiles".&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding an ephemeral container
&lt;/h2&gt;

&lt;p&gt;For the debug image I use &lt;code&gt;nicolaka/netshoot&lt;/code&gt;. It bundles network troubleshooting tools such as &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;ss&lt;/code&gt;, &lt;code&gt;tcpdump&lt;/code&gt;, and &lt;code&gt;dig&lt;/code&gt;, along with basic commands like &lt;code&gt;ps&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To get an interactive shell, run this.&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;kubectl debug &lt;span class="nt"&gt;-it&lt;/span&gt; app &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot:v0.16 &lt;span class="nt"&gt;--target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app &lt;span class="nt"&gt;-c&lt;/span&gt; dbg-it &lt;span class="nt"&gt;--&lt;/span&gt; bash
Targeting container &lt;span class="s2"&gt;"app"&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; If you don&lt;span class="s1"&gt;'t see processes from this container it may be because the container runtime doesn'&lt;/span&gt;t support this feature.
All commands and output from this session will be recorded &lt;span class="k"&gt;in &lt;/span&gt;container logs, including credentials and sensitive information passed through the &lt;span class="nb"&gt;command &lt;/span&gt;prompt.
If you don&lt;span class="s1"&gt;'t see a command prompt, try pressing enter.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two warnings appear before the shell opens.&lt;/p&gt;

&lt;p&gt;The first says that &lt;code&gt;--target&lt;/code&gt; only works if the container runtime supports it. In this article I confirmed it works on containerd v2.3.4. I don't cover what things look like on a runtime without support.&lt;/p&gt;

&lt;p&gt;The second says that every command you type in this session, and its output, is recorded in the container logs. For example, if you print &lt;code&gt;/proc/1/environ&lt;/code&gt; as in "Environment and arguments: read from /proc/1" below, the app's environment variables end up in these logs, secrets included.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;--target&lt;/code&gt; changes what &lt;code&gt;ps&lt;/code&gt; shows
&lt;/h3&gt;

&lt;p&gt;In the table in the background section, the PID row was the only one that changed with &lt;code&gt;--target&lt;/code&gt;. Let's check this with &lt;code&gt;ps&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For this test I wanted to run commands in the same container repeatedly and compare the output. So instead of entering with &lt;code&gt;-it&lt;/code&gt;, I kept each container running with &lt;code&gt;sleep infinity&lt;/code&gt; and ran commands with &lt;code&gt;kubectl exec -c &amp;lt;container name&amp;gt;&lt;/code&gt;. I added &lt;code&gt;dbg-notarget&lt;/code&gt; without &lt;code&gt;--target&lt;/code&gt; and &lt;code&gt;dbg-default&lt;/code&gt; with &lt;code&gt;--target=app&lt;/code&gt; as follows. &lt;code&gt;--image-pull-policy=Never&lt;/code&gt; is there because the image was preloaded onto the kind node.&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;kubectl debug app &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot:v0.16 &lt;span class="nt"&gt;--image-pull-policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Never &lt;span class="nt"&gt;-c&lt;/span&gt; dbg-notarget &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;infinity
&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl debug app &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot:v0.16 &lt;span class="nt"&gt;--image-pull-policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Never &lt;span class="nt"&gt;-c&lt;/span&gt; dbg-default &lt;span class="nt"&gt;--target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;infinity
Targeting container &lt;span class="s2"&gt;"app"&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; If you don&lt;span class="s1"&gt;'t see processes from this container it may be because the container runtime doesn'&lt;/span&gt;t support this feature.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran &lt;code&gt;ps auxf&lt;/code&gt; in each.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl exec app -c dbg-notarget -- ps auxf
PID   USER     TIME  COMMAND
    1 root      0:00 sleep infinity
   14 root      0:00 ps auxf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl exec app -c dbg-default -- ps auxf
PID   USER     TIME  COMMAND
    1 65532     0:00 /server -http=:3000
   29 root      0:00 sleep infinity
   42 root      0:00 ps auxf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;--target&lt;/code&gt;, all you see is your own &lt;code&gt;sleep infinity&lt;/code&gt; and the &lt;code&gt;ps&lt;/code&gt; you just ran. The app's process is nowhere to be found.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;--target=app&lt;/code&gt;, the app's &lt;code&gt;/server&lt;/code&gt; shows up as PID 1, and your own &lt;code&gt;sleep infinity&lt;/code&gt; becomes PID 29. The &lt;code&gt;65532&lt;/code&gt; in the USER column is the uid of the distroless nonroot user.&lt;/p&gt;

&lt;p&gt;The app starts as PID 1 inside its container, so it also appears as PID 1 from a container that joins its PID namespace with &lt;code&gt;--target&lt;/code&gt;. That's why later sections write &lt;code&gt;/proc/1/...&lt;/code&gt;. However, if the app is launched through an init process like tini, or if the Pod has &lt;code&gt;shareProcessNamespace&lt;/code&gt; enabled, the app may not be PID 1. Check with &lt;code&gt;ps&lt;/code&gt; first.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dbg-notarget&lt;/code&gt;, which has no &lt;code&gt;--target&lt;/code&gt;, also has the capability for inspecting other processes (&lt;code&gt;SYS_PTRACE&lt;/code&gt;). But it can't see the app's process at all, so there's nothing to use it on. Every step below that uses &lt;code&gt;/proc/1/root&lt;/code&gt; or &lt;code&gt;/proc/1/environ&lt;/code&gt; assumes you entered with &lt;code&gt;--target&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can see directly
&lt;/h2&gt;

&lt;p&gt;From here on, I check the background table against real output. First come the things an ephemeral container reaches directly. The network namespace is shared across the whole Pod, and the PID namespace is shared with &lt;code&gt;app&lt;/code&gt; through &lt;code&gt;--target&lt;/code&gt;. Unless stated otherwise, the results are from inside &lt;code&gt;dbg-default&lt;/code&gt;, which was added with &lt;code&gt;--target=app&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dbg-default&lt;/code&gt; has &lt;code&gt;SYS_PTRACE&lt;/code&gt;, so everything read in this section was readable except &lt;code&gt;/proc/1/stack&lt;/code&gt;. Some of these become unreadable without &lt;code&gt;SYS_PTRACE&lt;/code&gt;. I cover that in "Permissions: SYS_PTRACE and profiles".&lt;/p&gt;

&lt;h3&gt;
  
  
  Network: use it directly
&lt;/h3&gt;

&lt;p&gt;The network row in the table said "Shared". From &lt;code&gt;dbg-default&lt;/code&gt;, you see the same network as the app.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ss -tanp
State  Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0      4096               *:3000            *:*    users:(("server",pid=1,fd=4))
LISTEN 0      4096               *:4000            *:*   
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;:3000&lt;/code&gt; is the port the app (&lt;code&gt;server&lt;/code&gt;, pid 1) listens on. &lt;code&gt;:4000&lt;/code&gt; is the sidecar's port. You can see that something is listening on it, but not which process.&lt;/p&gt;

&lt;p&gt;This also matches the table. The network is shared across the whole Pod, so the sidecar's socket is visible. The PID namespace, on the other hand, is shared only with &lt;code&gt;app&lt;/code&gt;, so the sidecar's process isn't visible. &lt;code&gt;ss&lt;/code&gt; finds a socket's owner by looking through the processes under &lt;code&gt;/proc&lt;/code&gt;, so it can't show the name of a process that isn't visible.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;localhost&lt;/code&gt; reaches the app as well.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -sv -m 3 localhost:3000/health
...
&amp;lt; HTTP/1.1 200 OK
&amp;lt; Date: Sun, 27 Sep 2026 02:28:19 GMT
&amp;lt; Content-Length: 3
&amp;lt; Content-Type: text/plain; charset=utf-8
&amp;lt; 
{ [3 bytes data]
ok
* Connection #0 to host localhost:3000 left intact
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is &lt;code&gt;200 OK&lt;/code&gt; with the body &lt;code&gt;ok&lt;/code&gt;, so the app answers requests from &lt;code&gt;localhost&lt;/code&gt;. The &lt;code&gt;...&lt;/code&gt; part is the connection setup, which I left out.&lt;/p&gt;

&lt;p&gt;You can capture packets too. I ran &lt;code&gt;tcpdump&lt;/code&gt; while sending &lt;code&gt;curl&lt;/code&gt; requests in the background, and stopped after the first 2 packets, which show the SYN and SYN-ACK of one connection.&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;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in &lt;/span&gt;1 2 3&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;1&lt;span class="p"&gt;;&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 2 localhost:3000/health &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;done&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &amp;amp; tcpdump &lt;span class="nt"&gt;-i&lt;/span&gt; any &lt;span class="nt"&gt;-nn&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 2 port 3000&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="nv"&gt;$rc&lt;/span&gt;
tcpdump: WARNING: any: That device doesn&lt;span class="s1"&gt;'t support promiscuous mode
(Promiscuous mode not supported on the "any" device)
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes
02:28:20.415650 lo    In  IP6 ::1.43214 &amp;gt; ::1.3000: Flags [S], seq 838274280, win 65476, options [mss 65476,sackOK,TS val 235319763 ecr 0,nop,wscale 10], length 0
02:28:20.415666 lo    In  IP6 ::1.3000 &amp;gt; ::1.43214: Flags [S.], seq 4234760723, ack 838274281, win 65464, options [mss 65476,sackOK,TS val 235319763 ecr 235319763,nop,wscale 10], length 0
2 packets captured
20 packets received by filter
0 packets dropped by kernel
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both captured packets are IPv6 traffic over &lt;code&gt;lo&lt;/code&gt; (loopback). The first is a connection request (&lt;code&gt;Flags [S]&lt;/code&gt;, SYN) from port &lt;code&gt;43214&lt;/code&gt; on &lt;code&gt;::1&lt;/code&gt; to the app's &lt;code&gt;:3000&lt;/code&gt;. The second is the app's reply (&lt;code&gt;Flags [S.]&lt;/code&gt;, SYN-ACK). Traffic to the app can be captured from inside the debug container.&lt;/p&gt;

&lt;p&gt;Name resolution works too. I looked up the name of the Service &lt;code&gt;app&lt;/code&gt; against the nameserver listed in &lt;code&gt;dbg-default&lt;/code&gt;'s own &lt;code&gt;/etc/resolv.conf&lt;/code&gt;.&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;dig @&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'/^nameserver/{print $2; exit}'&lt;/span&gt; /etc/resolv.conf&lt;span class="si"&gt;)&lt;/span&gt; app.default.svc.cluster.local +short
10.96.248.99
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The returned &lt;code&gt;10.96.248.99&lt;/code&gt; is the ClusterIP of the Service &lt;code&gt;app&lt;/code&gt;. The app's environment variable &lt;code&gt;APP_SERVICE_HOST&lt;/code&gt; holds the same value.&lt;/p&gt;

&lt;h3&gt;
  
  
  Process state: read from /proc/1
&lt;/h3&gt;

&lt;p&gt;As shown in "&lt;code&gt;--target&lt;/code&gt; changes what &lt;code&gt;ps&lt;/code&gt; shows", the app appears as PID 1 when you enter with &lt;code&gt;--target=app&lt;/code&gt;. Its process state is readable under &lt;code&gt;/proc/1/&lt;/code&gt;.&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;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(Name|State|Uid|Threads|VmRSS):'&lt;/span&gt; /proc/1/status
Name:   server
State:  S &lt;span class="o"&gt;(&lt;/span&gt;sleeping&lt;span class="o"&gt;)&lt;/span&gt;
Uid:    65532   65532   65532   65532
VmRSS:      7256 kB
Threads:    4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;State&lt;/code&gt; is &lt;code&gt;S (sleeping)&lt;/code&gt;, meaning the process is blocked waiting for something. &lt;code&gt;Threads&lt;/code&gt; shows 4 threads, and &lt;code&gt;VmRSS&lt;/code&gt;, the physical memory actually in use, is 7256 kB. &lt;code&gt;Uid&lt;/code&gt; is &lt;code&gt;65532&lt;/code&gt; in every field because the process runs as the nonroot user.&lt;/p&gt;

&lt;p&gt;The list of open files is in &lt;code&gt;/proc/1/fd&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ls -l /proc/1/fd
total 0
lrwx------    1 65532    65532           64 Sep 27 02:28 0 -&amp;gt; /dev/null
l-wx------    1 65532    65532           64 Sep 27 02:28 1 -&amp;gt; pipe:[45581]
l-wx------    1 65532    65532           64 Sep 27 02:28 2 -&amp;gt; pipe:[45582]
lr-x------    1 65532    65532           64 Sep 27 02:28 3 -&amp;gt; /sys/fs/cgroup/cpu.max
lrwx------    1 65532    65532           64 Sep 27 02:28 4 -&amp;gt; socket:[45610]
lrwx------    1 65532    65532           64 Sep 27 02:28 5 -&amp;gt; anon_inode:[eventpoll]
lrwx------    1 65532    65532           64 Sep 27 02:28 6 -&amp;gt; anon_inode:[eventfd]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;fd 4 is a socket, and it corresponds to &lt;code&gt;fd=4&lt;/code&gt; (&lt;code&gt;:3000&lt;/code&gt;) in the earlier &lt;code&gt;ss -tanp&lt;/code&gt; output.&lt;/p&gt;

&lt;p&gt;For slow operations such as receiving from the network or reading from disk, a program makes a system call and blocks inside the kernel (the core of the OS) until the call completes. &lt;code&gt;/proc/1/wchan&lt;/code&gt; (short for wait channel) holds the name of the kernel function where the process is currently blocked, so it tells you what the process is waiting for.&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;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/1/wchan&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo
&lt;/span&gt;ep_poll
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this run it was &lt;code&gt;ep_poll&lt;/code&gt;. When I entered the same Pod with &lt;code&gt;--profile=sysadmin&lt;/code&gt;, it returned &lt;code&gt;futex_do_wait&lt;/code&gt;. The value depends on when you read it, so read it a few times instead of judging from a single read.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ep_poll&lt;/code&gt;: waiting for data to arrive on one of its connections. This is the idle state of waiting for requests&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;futex_do_wait&lt;/code&gt;: blocked while synchronizing with another thread of the same program. In Go apps, idle threads are often in this state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For more detail on what the process is waiting for, &lt;code&gt;/proc/1/stack&lt;/code&gt; prints the call stack inside the kernel. However, it's not readable from &lt;code&gt;dbg-default&lt;/code&gt;.&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;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/1/stack
&lt;span class="nb"&gt;cat&lt;/span&gt;: &lt;span class="nb"&gt;read &lt;/span&gt;error: Permission denied
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Among the profiles I tried, only the privileged &lt;code&gt;dbg-sysadmin&lt;/code&gt; could read it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat /proc/1/stack
[&amp;lt;0&amp;gt;] futex_do_wait+0x3a/0x70
[&amp;lt;0&amp;gt;] __futex_wait+0x99/0x100
[&amp;lt;0&amp;gt;] futex_wait+0x72/0x120
[&amp;lt;0&amp;gt;] do_futex+0xc9/0x190
[&amp;lt;0&amp;gt;] __x64_sys_futex+0x125/0x1f0
[&amp;lt;0&amp;gt;] x64_sys_call+0x1d1d/0x20d0
[&amp;lt;0&amp;gt;] do_syscall_64+0x7b/0xb40
[&amp;lt;0&amp;gt;] entry_SYSCALL_64_after_hwframe+0x76/0x7e
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the call path inside the kernel, read from bottom to top. The bottom is the entry point where the app entered the kernel, and the top is where it's blocked right now. In this output, the app is waiting in &lt;code&gt;futex&lt;/code&gt; (a system call for synchronizing threads) and is blocked in &lt;code&gt;futex_do_wait&lt;/code&gt; at the top. Numbers like &lt;code&gt;+0x3a/0x70&lt;/code&gt; are offsets within the function, and you can usually ignore them.&lt;/p&gt;

&lt;p&gt;The top entry, &lt;code&gt;futex_do_wait&lt;/code&gt;, is the same as the &lt;code&gt;wchan&lt;/code&gt; value read from the same &lt;code&gt;dbg-sysadmin&lt;/code&gt;. &lt;code&gt;stack&lt;/code&gt; also shows the path that led there. Still, if all you need is what the process is waiting for, &lt;code&gt;wchan&lt;/code&gt; is usually enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  Environment and arguments: read from /proc/1
&lt;/h3&gt;

&lt;p&gt;The app's environment variables at startup are in &lt;code&gt;/proc/1/environ&lt;/code&gt;. The entries are separated by NUL characters, so replace them with newlines to read them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ tr '\0' '\n' &amp;lt; /proc/1/environ
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=app
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
APP_MODE=production
APP_UPSTREAM={"upstream": "http://backend.default.svc:8080", "timeout_ms": 250}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the &lt;code&gt;...&lt;/code&gt;, the output continues with Service addresses and other variables that Kubernetes injects automatically.&lt;/p&gt;

&lt;p&gt;The startup arguments are in &lt;code&gt;/proc/1/cmdline&lt;/code&gt;.&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;&lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\0'&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &amp;lt; /proc/1/cmdline&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt;
/server &lt;span class="nt"&gt;-http&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;:3000 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only argument is &lt;code&gt;-http=:3000&lt;/code&gt;, the same command line that &lt;code&gt;ps&lt;/code&gt; showed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can't see directly
&lt;/h2&gt;

&lt;p&gt;Next come the things the table marks "Separate", which you can't reach just by entering: mount and cgroup. Both have a workaround through &lt;code&gt;/proc/1/root&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Files: read from /proc/1/root
&lt;/h3&gt;

&lt;p&gt;In the introduction I wrote that when you try to read the app's config file, nothing exists at that path. Here is that case. The app mounts a ConfigMap at &lt;code&gt;/config&lt;/code&gt;, but reading the same path from &lt;code&gt;dbg-default&lt;/code&gt; finds nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat /config/app.json
cat: can't open '/config/app.json': No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mount row in the table said "Separate". The &lt;code&gt;/&lt;/code&gt; in &lt;code&gt;dbg-default&lt;/code&gt; is the netshoot image's filesystem, which is separate from the app's.&lt;/p&gt;

&lt;p&gt;This is where &lt;code&gt;/proc/1/root&lt;/code&gt; comes in. &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/root&lt;/code&gt; is a link that points to "&lt;code&gt;/&lt;/code&gt; as seen by that process", and following it takes you straight into the filesystem the app sees. You can read the contents of an image that has no shell and no &lt;code&gt;ls&lt;/code&gt; with the tools on your side.&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;&lt;span class="nb"&gt;ls&lt;/span&gt; /proc/1/root/
bin
boot
config
dev
etc
home
lib
proc
...
root
run
sbin
scratch
server
sys
tmp
usr
var
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;config&lt;/code&gt; is where the ConfigMap is mounted, &lt;code&gt;scratch&lt;/code&gt; is where the Pod spec mounts an emptyDir, and &lt;code&gt;server&lt;/code&gt; is the app's binary. The ConfigMap's contents are readable too.&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;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/1/root/config/app.json
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"upstream"&lt;/span&gt;: &lt;span class="s2"&gt;"http://backend.default.svc:8080"&lt;/span&gt;, &lt;span class="s2"&gt;"timeout_ms"&lt;/span&gt;: 250&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The config the app reads is now readable from the debug container.&lt;/p&gt;

&lt;p&gt;If you suspect name resolution, you can read the &lt;code&gt;resolv.conf&lt;/code&gt; the app actually uses in the same way.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat /proc/1/root/etc/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local urp3p5u2pksefpdlab2z34ww2g.gx.internal.cloudapp.net
nameserver 10.96.0.10
options ndots:5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;10.96.0.10&lt;/code&gt; in &lt;code&gt;nameserver&lt;/code&gt; is where the app sends its queries. &lt;code&gt;search&lt;/code&gt; lists the domains that are appended to short names in order. &lt;code&gt;ndots:5&lt;/code&gt; means that for a name with fewer than 5 dots, the &lt;code&gt;search&lt;/code&gt; domains are appended and tried first. When a name doesn't resolve, this tells you which names the app actually queries. The last &lt;code&gt;search&lt;/code&gt; entry, &lt;code&gt;…cloudapp.net&lt;/code&gt;, isn't a cluster domain. It comes from the host side's configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  cgroup: read from /proc/1/root
&lt;/h3&gt;

&lt;p&gt;A cgroup is the mechanism that applies CPU and memory limits per container. The limit values are readable from files under &lt;code&gt;/sys/fs/cgroup&lt;/code&gt;. The app's CPU limit is &lt;code&gt;500m&lt;/code&gt; (half a core), so &lt;code&gt;cpu.max&lt;/code&gt; should contain &lt;code&gt;50000 100000&lt;/code&gt; (up to 50,000 microseconds out of every 100,000 microseconds).&lt;/p&gt;

&lt;p&gt;From &lt;code&gt;dbg-default&lt;/code&gt;, I checked where my own cgroup is, that &lt;code&gt;/sys/fs/cgroup&lt;/code&gt; lists cgroup files, and the value of &lt;code&gt;cpu.max&lt;/code&gt;, in one go. The result looks like this.&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;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/self/cgroup&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;ls&lt;/span&gt; /sys/fs/cgroup | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 8&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/cpu.max
0::/
cgroup.controllers
cgroup.events
cgroup.freeze
cgroup.kill
cgroup.max.depth
cgroup.max.descendants
cgroup.pressure
cgroup.procs
max 100000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;max 100000&lt;/code&gt; means "no limit", and it's not the app's value. This is the case from the introduction where you get your own value. What you see is the debug container's own cgroup, which has no limit.&lt;/p&gt;

&lt;p&gt;The cgroup row in the table said "Separate". &lt;code&gt;dbg-default&lt;/code&gt; has a cgroup namespace of its own, and its own cgroup appears at the top of &lt;code&gt;/sys/fs/cgroup&lt;/code&gt;. The first line, &lt;code&gt;0::/&lt;/code&gt;, means "my cgroup is at the top of the hierarchy as I see it".&lt;/p&gt;

&lt;p&gt;To read the app's values, go through &lt;code&gt;/proc/1/root&lt;/code&gt; as in "Files: read from /proc/1/root". Reading &lt;code&gt;/sys/fs/cgroup&lt;/code&gt; as the app sees it gives the app's own values.&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;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/1/root/sys/fs/cgroup/cpu.max
50000 100000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output is &lt;code&gt;50000 100000&lt;/code&gt;, matching the app's &lt;code&gt;500m&lt;/code&gt; limit. This path goes through &lt;code&gt;/proc/1/root&lt;/code&gt;, so whether you can read it depends on having &lt;code&gt;SYS_PTRACE&lt;/code&gt;. It was readable with general (no &lt;code&gt;--profile&lt;/code&gt;) and sysadmin, and returned &lt;code&gt;Permission denied&lt;/code&gt; with legacy, baseline, and netadmin. I explain why in "Permissions: SYS_PTRACE and profiles".&lt;/p&gt;

&lt;p&gt;The exception is sysadmin. &lt;code&gt;dbg-sysadmin&lt;/code&gt; shares the cgroup namespace with the node, so the app's cgroup path shown in &lt;code&gt;/proc/1/cgroup&lt;/code&gt; exists at the same path under its own &lt;code&gt;/sys/fs/cgroup&lt;/code&gt;.&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;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; /sys/fs/cgroup&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;: &lt;span class="nt"&gt;-f3&lt;/span&gt; /proc/1/cgroup&lt;span class="si"&gt;)&lt;/span&gt;
/sys/fs/cgroup/kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-pod719135d1_ee24_4f46_948a_440e1c6f4f77.slice/cri-containerd-698b44cb65ab91fba808118cba48f23ab4761ddd8a3e05977a04c4323bcef648.scope
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This reaches the app's cgroup directory directly, without going through &lt;code&gt;/proc/1/root&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you have permission to get onto the node, you can also read the app's cgroup directly from the node side. I cover that later in "Reading cgroups from the node".&lt;/p&gt;

&lt;h2&gt;
  
  
  Permissions: SYS_PTRACE and profiles
&lt;/h2&gt;

&lt;p&gt;The reads of &lt;code&gt;/proc/1/root&lt;/code&gt; and &lt;code&gt;/proc/1/environ&lt;/code&gt; so far worked because &lt;code&gt;dbg-default&lt;/code&gt; has &lt;code&gt;SYS_PTRACE&lt;/code&gt;. The table in the background section only covers how far you can reach into the app. Whether you can read what you reach depends on the permissions in this section.&lt;/p&gt;

&lt;h3&gt;
  
  
  SYS_PTRACE: without it, you can't read the contents
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;SYS_PTRACE&lt;/code&gt; is what decides between reading the contents of &lt;code&gt;/proc/1&lt;/code&gt; and getting &lt;code&gt;Permission denied&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When you read &lt;code&gt;/proc/1/root&lt;/code&gt;, &lt;code&gt;/proc/1/environ&lt;/code&gt;, &lt;code&gt;/proc/1/maps&lt;/code&gt;, the link targets in &lt;code&gt;/proc/1/fd&lt;/code&gt;, and similar files, the kernel checks whether the reading process is allowed to inspect the contents of the other process. &lt;code&gt;dbg-default&lt;/code&gt; runs as root (uid 0 / gid 0). The app runs as uid 65532, and its gid is also 65532 (you can see this in the owner columns, &lt;code&gt;65532 65532&lt;/code&gt;, of the earlier &lt;code&gt;ls -l /proc/1/fd&lt;/code&gt;). When your uid and gid differ from the target's like this, inspecting it requires &lt;code&gt;SYS_PTRACE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This isn't about file permissions. You're root, and you even have the capability to bypass file permissions (&lt;code&gt;DAC_OVERRIDE&lt;/code&gt;), but that doesn't get you past this check.&lt;/p&gt;

&lt;p&gt;To confirm this, I also entered with &lt;code&gt;--profile=legacy&lt;/code&gt;, a profile without &lt;code&gt;SYS_PTRACE&lt;/code&gt;, ran the same commands as with default, and compared the results. This container is &lt;code&gt;dbg-legacy&lt;/code&gt;, added to the same Pod &lt;code&gt;app&lt;/code&gt; with only &lt;code&gt;--profile&lt;/code&gt; changed. legacy is deprecated, and kubectl prints a warning when you add it saying that it's scheduled for removal in v1.39. baseline, which also lacks &lt;code&gt;SYS_PTRACE&lt;/code&gt;, gave the same results as legacy.&lt;/p&gt;

&lt;p&gt;I compared 9 items that read the contents of the app's process. They are the process column of &lt;code&gt;ss -tanp&lt;/code&gt;, the link targets in &lt;code&gt;/proc/1/fd&lt;/code&gt;, &lt;code&gt;/proc/1/maps&lt;/code&gt;, &lt;code&gt;/proc/1/environ&lt;/code&gt;, the listing of &lt;code&gt;/proc/1/root/&lt;/code&gt;, and reads of &lt;code&gt;app.json&lt;/code&gt;, &lt;code&gt;resolv.conf&lt;/code&gt;, &lt;code&gt;cpu.max&lt;/code&gt;, and &lt;code&gt;cpu.stat&lt;/code&gt; through &lt;code&gt;/proc/1/root/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;All 9 items failed with legacy, and all 9 passed with default.&lt;/p&gt;

&lt;p&gt;Here are two examples of the failures after entering &lt;code&gt;app&lt;/code&gt; with legacy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat /proc/1/root/config/app.json
cat: can't open '/proc/1/root/config/app.json': Permission denied
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ls -l /proc/1/fd
total 0
ls: /proc/1/fd/0: cannot read link: Permission denied
lrwx------    1 65532    65532           64 Sep 27 02:28 0
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are &lt;code&gt;Permission denied&lt;/code&gt;. &lt;code&gt;ls -l /proc/1/fd&lt;/code&gt; prints the list but can't read the link targets.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;users:(...)&lt;/code&gt; column disappears from &lt;code&gt;ss -tanp&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ss -tanp
State     Recv-Q Send-Q Local Address:Port  Peer Address:PortProcess
LISTEN    0      4096               *:3000             *:*   
LISTEN    0      4096               *:4000             *:*   
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;:3000&lt;/code&gt; line has no &lt;code&gt;users:(("server",pid=1,fd=4))&lt;/code&gt; either, so you can't tell which process owns the socket.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;wchan&lt;/code&gt; doesn't produce an error, which makes this case easy to miss. Reading it after entering &lt;code&gt;app&lt;/code&gt; with legacy returned &lt;code&gt;0&lt;/code&gt; instead of &lt;code&gt;futex_do_wait&lt;/code&gt; or &lt;code&gt;ep_poll&lt;/code&gt;.&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;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/1/wchan&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo
&lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;wchan&lt;/code&gt; is &lt;code&gt;0&lt;/code&gt;, first check whether you have &lt;code&gt;SYS_PTRACE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On the other hand, &lt;code&gt;/proc/1/status&lt;/code&gt;, &lt;code&gt;/proc/1/cmdline&lt;/code&gt;, and the listing of &lt;code&gt;/proc/1/fd&lt;/code&gt; itself were readable even with legacy.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SYS_PTRACE&lt;/code&gt; is the capability for inspecting another process, and it's useless if that process isn't visible. &lt;code&gt;--target&lt;/code&gt; makes the target visible, and &lt;code&gt;SYS_PTRACE&lt;/code&gt; grants permission to inspect it. The commands so far work only with both in place.&lt;/p&gt;

&lt;h3&gt;
  
  
  uid and gid: when both match, reads worked without SYS_PTRACE
&lt;/h3&gt;

&lt;p&gt;There's another way past this kernel check. In this test, when both the uid and the gid of my process matched the target's, reads worked without &lt;code&gt;SYS_PTRACE&lt;/code&gt;. However, every target I tried drops all capabilities with &lt;code&gt;capabilities.drop: ["ALL"]&lt;/code&gt;. The kernel check also has a condition on the target's capabilities, and I didn't test targets that keep any capabilities.&lt;/p&gt;

&lt;p&gt;To confirm this, I prepared two Pods in addition to &lt;code&gt;app&lt;/code&gt;, with only &lt;code&gt;runAsUser&lt;/code&gt; and &lt;code&gt;runAsGroup&lt;/code&gt; changed. &lt;code&gt;app-uid0&lt;/code&gt; runs the app as uid 0 / gid 65532, and &lt;code&gt;app-uid0-gid0&lt;/code&gt; runs it as uid 0 / gid 0.&lt;/p&gt;

&lt;p&gt;Reading each app's &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/status&lt;/code&gt; from the node gave the following. The first block is &lt;code&gt;app-uid0&lt;/code&gt;, and the second is &lt;code&gt;app-uid0-gid0&lt;/code&gt;.&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;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(Uid|Gid|Groups|CapInh|CapPrm|CapEff|CapBnd|CapAmb|NoNewPrivs|Seccomp):'&lt;/span&gt; /proc/7049/status
Uid:    0   0   0   0
Gid:    65532   65532   65532   65532
...
CapEff: 0000000000000000
...
&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;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(Uid|Gid|Groups|CapInh|CapPrm|CapEff|CapBnd|CapAmb|NoNewPrivs|Seccomp):'&lt;/span&gt; /proc/7042/status
Uid:    0   0   0   0
Gid:    0   0   0   0
...
CapEff: 0000000000000000
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;app-uid0&lt;/code&gt; has uid 0 and gid 65532, and &lt;code&gt;app-uid0-gid0&lt;/code&gt; has uid 0 and gid 0. Both have &lt;code&gt;CapEff&lt;/code&gt; set to all zeros, so both run with every capability dropped.&lt;/p&gt;

&lt;p&gt;I entered each one with legacy and ran the same commands. The results for &lt;code&gt;app&lt;/code&gt; are included for comparison.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Target app&lt;/th&gt;
&lt;th&gt;uid / gid&lt;/th&gt;
&lt;th&gt;Result with legacy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;65532 / 65532&lt;/td&gt;
&lt;td&gt;All 9 items failed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app-uid0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0 / 65532&lt;/td&gt;
&lt;td&gt;All 9 items failed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app-uid0-gid0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0 / 0&lt;/td&gt;
&lt;td&gt;All passed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Matching only the uid (&lt;code&gt;app-uid0&lt;/code&gt;) isn't enough. The reads pass only when the gid matches as well (&lt;code&gt;app-uid0-gid0&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;wchan&lt;/code&gt; follows the same condition. Entering &lt;code&gt;app-uid0-gid0&lt;/code&gt; with legacy returned &lt;code&gt;futex_do_wait&lt;/code&gt; rather than &lt;code&gt;0&lt;/code&gt;. So the &lt;code&gt;0&lt;/code&gt; seen in "SYS_PTRACE: without it, you can't read the contents" can be read as the value the kernel returns when this check fails.&lt;/p&gt;

&lt;p&gt;The distroless nonroot image uses 65532 for both uid and gid. As long as you enter as root, both the uid and the gid differ from the app's, so the quickest route is to enter with a profile that includes &lt;code&gt;SYS_PTRACE&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Profiles: only general and sysadmin can read /proc/1/root
&lt;/h3&gt;

&lt;p&gt;The capabilities the debug container gets are determined by &lt;code&gt;--profile&lt;/code&gt;, and the profile determines which commands fail.&lt;/p&gt;

&lt;p&gt;What differs between profiles is the securityContext, the part of the spec that sets things like a container's capabilities. I also added &lt;code&gt;legacy&lt;/code&gt;, &lt;code&gt;general&lt;/code&gt;, &lt;code&gt;baseline&lt;/code&gt;, and &lt;code&gt;netadmin&lt;/code&gt; to the same Pod &lt;code&gt;app&lt;/code&gt; as &lt;code&gt;dbg-default&lt;/code&gt; and &lt;code&gt;dbg-sysadmin&lt;/code&gt;, changing only &lt;code&gt;--profile&lt;/code&gt;, and ran the same commands to compare them. restricted requires an image that runs as non-root, and it didn't start with netshoot, which runs as root, so I left it out of the comparison. Here are the results.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;profile&lt;/th&gt;
&lt;th&gt;securityContext&lt;/th&gt;
&lt;th&gt;Capabilities&lt;/th&gt;
&lt;th&gt;&lt;code&gt;/proc/1/root&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;iptables&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;general (same as no &lt;code&gt;--profile&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;capabilities.add: [SYS_PTRACE]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Base + &lt;code&gt;SYS_PTRACE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Readable&lt;/td&gt;
&lt;td&gt;Fails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;legacy / baseline&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Base only&lt;/td&gt;
&lt;td&gt;Not readable&lt;/td&gt;
&lt;td&gt;Fails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;netadmin&lt;/td&gt;
&lt;td&gt;&lt;code&gt;capabilities.add: [NET_ADMIN, NET_RAW]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Base + &lt;code&gt;NET_ADMIN&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Not readable&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sysadmin&lt;/td&gt;
&lt;td&gt;&lt;code&gt;privileged: true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nearly all&lt;/td&gt;
&lt;td&gt;Readable&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Base" in the table is the set of capabilities the container runtime (containerd) grants when the securityContext specifies nothing.&lt;/p&gt;

&lt;p&gt;Without &lt;code&gt;NET_ADMIN&lt;/code&gt;, &lt;code&gt;iptables&lt;/code&gt; fails with the misleading error &lt;code&gt;Permission denied (you must be root)&lt;/code&gt;, even when it runs as root. &lt;code&gt;tcpdump&lt;/code&gt; worked with every profile in the table, because &lt;code&gt;NET_RAW&lt;/code&gt;, which packet capture needs, is in the base set from the start. netadmin adds &lt;code&gt;NET_ADMIN&lt;/code&gt; and &lt;code&gt;NET_RAW&lt;/code&gt;, but &lt;code&gt;NET_RAW&lt;/code&gt; is already there, so the only real addition is &lt;code&gt;NET_ADMIN&lt;/code&gt;. That's why the Capabilities column shows "Base + &lt;code&gt;NET_ADMIN&lt;/code&gt;".&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading cgroups from the node
&lt;/h2&gt;

&lt;p&gt;As shown in "cgroup: read from /proc/1/root", an ephemeral container can read the app's cgroup values through &lt;code&gt;/proc/1/root/sys/fs/cgroup/...&lt;/code&gt;, and with sysadmin you can follow them directly from &lt;code&gt;/sys/fs/cgroup&lt;/code&gt;. Looking from the node is the way out when neither of these is available. Here are two such cases.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pod Security settings or similar policies on the app's Kubernetes namespace allow neither &lt;code&gt;SYS_PTRACE&lt;/code&gt; nor privileged containers&lt;/li&gt;
&lt;li&gt;You want to look across multiple Pods on the node&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even in the first case, starting the node debug Pod in a different Kubernetes namespace that allows privileged Pods should let you get onto the node. I didn't test that far in this article, though.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enter the node with kubectl debug node
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;kubectl debug node/&amp;lt;node name&amp;gt;&lt;/code&gt; starts a debug Pod on the specified node and attaches you to it.&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="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kubectl debug node/ded-control-plane &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--profile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sysadmin &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot:v0.16 &lt;span class="nt"&gt;--&lt;/span&gt; bash
&lt;span class="go"&gt;Creating debugging pod node-debugger-ded-control-plane-sjpv8 with container debugger on node ded-control-plane.
All commands and output from this session will be recorded in container logs, including credentials and sensitive information passed through the command prompt.
If you don't see a command prompt, try pressing enter.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This Pod's spec shows that it shares the PID, network, and IPC namespaces with the node, runs privileged, and mounts the node's &lt;code&gt;/&lt;/code&gt; at &lt;code&gt;/host&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl get pod/node-debugger-ded-control-plane-sjpv8 -o json | jq '{hostPID: .spec.hostPID, hostNetwork: .spec.hostNetwork, hostIPC: .spec.hostIPC, sc: .spec.containers[0].securityContext, mounts: .spec.containers[0].volumeMounts, volumes: .spec.volumes}'
{
  "hostPID": true,
  "hostNetwork": true,
  "hostIPC": true,
  "sc": {
    "privileged": true
  },
  "mounts": [
    {
      "mountPath": "/host",
      "name": "host-root"
    },
...
  "volumes": [
    {
      "hostPath": {
        "path": "/",
        "type": ""
      },
      "name": "host-root"
    },
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's privileged, and on top of that it has the node's PID, network, and IPC namespaces and the node's filesystem. That makes it a Pod with very strong permissions. Every process on the node is visible, and so is the node's entire &lt;code&gt;/&lt;/code&gt;. When you use it on a production node, keep in mind that this is the level of access you're working with.&lt;/p&gt;

&lt;p&gt;Let's look under &lt;code&gt;/host&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ls --color=never /host
LICENSES  bin       boot      dev       etc       home      kind      lib       lib64     media     mnt       opt       proc      root      run       sbin      srv       sys       tmp       usr       var
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the node's &lt;code&gt;/&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trace the cgroup from the app's process
&lt;/h3&gt;

&lt;p&gt;The PID namespace is shared with the node, so you can find the app's process on the node with &lt;code&gt;ps&lt;/code&gt;.&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;ps &lt;span class="nt"&gt;-eo&lt;/span&gt; pid,user,args | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'[/]server -http='&lt;/span&gt;
 4082 65532    /server &lt;span class="nt"&gt;-http&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;:3000
 4118 65532    /server &lt;span class="nt"&gt;-http&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;:4000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;:3000&lt;/code&gt; is the app and &lt;code&gt;:4000&lt;/code&gt; is the sidecar, both running as uid 65532. Take the PID of the &lt;code&gt;:3000&lt;/code&gt; process.&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;$ P&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;pgrep &lt;span class="nt"&gt;-u&lt;/span&gt; 65532 &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s1"&gt;'/server -http=:3000'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"P=&lt;/span&gt;&lt;span class="nv"&gt;$P&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;P&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4082
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;4082&lt;/code&gt; is the same as &lt;code&gt;app&lt;/code&gt;'s PID on the node in the table from the background section. Reading &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/cgroup&lt;/code&gt; for this PID gives the path from the root of the node's cgroup hierarchy.&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;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/&lt;span class="nv"&gt;$P&lt;/span&gt;/cgroup
0::/kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-pod719135d1_ee24_4f46_948a_440e1c6f4f77.slice/cri-containerd-698b44cb65ab91fba808118cba48f23ab4761ddd8a3e05977a04c4323bcef648.scope
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The path is the part after &lt;code&gt;0::&lt;/code&gt;. Extract it.&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;$ CG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;: &lt;span class="nt"&gt;-f3&lt;/span&gt; /proc/&lt;span class="nv"&gt;$P&lt;/span&gt;/cgroup&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"CG=&lt;/span&gt;&lt;span class="nv"&gt;$CG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;CG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-pod719135d1_ee24_4f46_948a_440e1c6f4f77.slice/cri-containerd-698b44cb65ab91fba808118cba48f23ab4761ddd8a3e05977a04c4323bcef648.scope
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Append this path to &lt;code&gt;/host/sys/fs/cgroup&lt;/code&gt;, which corresponds to the node's &lt;code&gt;/sys/fs/cgroup&lt;/code&gt;.&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;&lt;span class="nb"&gt;cat&lt;/span&gt; /host/sys/fs/cgroup&lt;span class="nv"&gt;$CG&lt;/span&gt;/cpu.max
50000 100000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;50000 100000&lt;/code&gt; in &lt;code&gt;cpu.max&lt;/code&gt; is a limit of up to 50,000 microseconds of CPU time out of every 100,000 microseconds (100 ms). It matches the &lt;code&gt;500m&lt;/code&gt; limit set in "Test setup".&lt;/p&gt;

&lt;p&gt;Whether the app actually hits this limit shows up in &lt;code&gt;cpu.stat&lt;/code&gt; in the same directory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat /host/sys/fs/cgroup$CG/cpu.stat
usage_usec 56270
user_usec 34699
system_usec 21570
nice_usec 0
core_sched.force_idle_usec 0
nr_periods 22
nr_throttled 0
throttled_usec 0
nr_bursts 0
burst_usec 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;cpu.stat&lt;/code&gt;, &lt;code&gt;nr_periods&lt;/code&gt; is the number of 100 ms &lt;code&gt;cpu.max&lt;/code&gt; periods in which the app tried to use the CPU. &lt;code&gt;nr_throttled&lt;/code&gt; is the number of those periods in which it hit the limit and was throttled. In this run it was 0 out of 22, so the app isn't hitting the limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations and caveats
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Added containers can't be removed from the Pod
&lt;/h3&gt;

&lt;p&gt;When you add a container with &lt;code&gt;kubectl debug&lt;/code&gt;, its definition is appended to the Pod's spec. The process inside stops using resources once it exits, but the definition stays, even for containers that have already terminated. Here's the list after adding &lt;code&gt;dbg-exit&lt;/code&gt;, which exits immediately because it runs &lt;code&gt;-- true&lt;/code&gt;.&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;kubectl get pod app &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.ephemeralContainers[*].name}{"\n"}'&lt;/span&gt;
dbg-notarget dbg-default dbg-it dbg-legacy dbg-general dbg-baseline dbg-restricted dbg-netadmin dbg-sysadmin dbg-exit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every container I added for this article's tests is on the list. An attempt to remove one is rejected by the API server.&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;kubectl patch pod app &lt;span class="nt"&gt;--subresource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ephemeralcontainers &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'[{"op":"remove","path":"/spec/ephemeralContainers/0"}]'&lt;/span&gt;
The Pod &lt;span class="s2"&gt;"app"&lt;/span&gt; is invalid: spec.ephemeralContainers: Forbidden: existing ephemeral containers &lt;span class="s2"&gt;"dbg-notarget"&lt;/span&gt; may not be removed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The record goes away only when the Pod is recreated. Until then the names stay taken as well. An attempt to add another container named &lt;code&gt;dbg-exit&lt;/code&gt; was rejected with &lt;code&gt;existing ephemeral containers "dbg-exit" may not be changed&lt;/code&gt;. If you experiment casually on a production Pod, these records stay there.&lt;/p&gt;

&lt;h3&gt;
  
  
  You can't use &lt;code&gt;--target&lt;/code&gt; on a stopped container
&lt;/h3&gt;

&lt;p&gt;An app that crashes right after starting and keeps restarting is in a state called CrashLoopBackOff. For testing, I prepared a Pod &lt;code&gt;crash&lt;/code&gt; that exits right after it starts. It mounts the same ConfigMap as &lt;code&gt;app&lt;/code&gt; at &lt;code&gt;/config&lt;/code&gt; and gets the same environment variables (&lt;code&gt;APP_MODE&lt;/code&gt;, &lt;code&gt;APP_UPSTREAM&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl get pod crash
NAME    READY   STATUS             RESTARTS      AGE
crash   0/1     CrashLoopBackOff   4 (81s ago)   2m50s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's in &lt;code&gt;CrashLoopBackOff&lt;/code&gt; and has already restarted 4 times. To find out why it crashed, start with the logs of the previous container.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl logs crash -c app --previous
2026/09/27 02:31:58 exiting on purpose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;exiting on purpose&lt;/code&gt; is the message from the deliberate exit I built in for this test.&lt;/p&gt;

&lt;p&gt;If you add a debug container to this Pod with &lt;code&gt;--target=app&lt;/code&gt;, it doesn't start (its state is &lt;code&gt;CreateContainerError&lt;/code&gt;). The process it's supposed to share a PID namespace with isn't running.&lt;/p&gt;

&lt;p&gt;The workaround that fits this constraint is the &lt;code&gt;--copy-to&lt;/code&gt; option of &lt;code&gt;kubectl debug&lt;/code&gt;. It creates a copy of the Pod and lets you replace a container in the copy and run it.&lt;/p&gt;

&lt;p&gt;First, I replaced only the image with netshoot using &lt;code&gt;--set-image&lt;/code&gt;.&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;kubectl debug crash &lt;span class="nt"&gt;--copy-to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;crash-copy &lt;span class="nt"&gt;--set-image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot:v0.16
&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl get pod crash-copy &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;'{range .spec.containers[*]}{.name} {.image} {.args}{"\n"}{end}'&lt;/span&gt;
app nicolaka/netshoot:v0.16 &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-exit"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The image is now netshoot, but the original Pod's argument &lt;code&gt;-exit&lt;/code&gt; is still there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl get pod crash-copy
NAME         READY   STATUS              RESTARTS     AGE
crash-copy   0/1     RunContainerError   2 (4s ago)   20s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The copy didn't start either and ended up in &lt;code&gt;RunContainerError&lt;/code&gt;. The reason is in the last termination state.&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;kubectl get pod crash-copy &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;'{.status.containerStatuses[0].lastState}{"\n"}'&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"terminated"&lt;/span&gt;:&lt;span class="o"&gt;{&lt;/span&gt;...&lt;span class="s2"&gt;"message"&lt;/span&gt;:&lt;span class="s2"&gt;"failed to create containerd task: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: exec: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;-exit&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: executable file not found in &lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;,&lt;span class="s2"&gt;"reason"&lt;/span&gt;:&lt;span class="s2"&gt;"StartError"&lt;/span&gt;,...&lt;span class="o"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The leftover argument &lt;code&gt;-exit&lt;/code&gt; is treated as the command to run. It doesn't exist in netshoot, so the start fails.&lt;/p&gt;

&lt;p&gt;So I specified the container to replace with &lt;code&gt;--container=app&lt;/code&gt;, and passed both an image with &lt;code&gt;--image&lt;/code&gt; and a command after &lt;code&gt;--&lt;/code&gt;.&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;kubectl debug crash &lt;span class="nt"&gt;--copy-to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;crash-copy2 &lt;span class="nt"&gt;--container&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot:v0.16 &lt;span class="nt"&gt;--image-pull-policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Never &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;infinity
&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl get pod crash-copy2 &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;'{range .spec.containers[*]}{.name} {.image} {.command} {.args}{"\n"}{end}'&lt;/span&gt;
app nicolaka/netshoot:v0.16 &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"sleep"&lt;/span&gt;,&lt;span class="s2"&gt;"infinity"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This time the command is &lt;code&gt;sleep infinity&lt;/code&gt; and the arguments are empty.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl get pod crash-copy2
NAME          READY   STATUS    RESTARTS   AGE
crash-copy2   1/1     Running   0          1s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The copy is &lt;code&gt;Running&lt;/code&gt; and stays up.&lt;/p&gt;

&lt;p&gt;In the copy's &lt;code&gt;app&lt;/code&gt; container, the image and command are replaced, but the environment variables and volume mounts are carried over unchanged from the original Pod. So you can check the config file and environment variables the app would read at startup, under the same conditions.&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;kubectl &lt;span class="nb"&gt;exec &lt;/span&gt;crash-copy2 &lt;span class="nt"&gt;-c&lt;/span&gt; app &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; /config/app.json
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"upstream"&lt;/span&gt;: &lt;span class="s2"&gt;"http://backend.default.svc:8080"&lt;/span&gt;, &lt;span class="s2"&gt;"timeout_ms"&lt;/span&gt;: 250&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl exec crash-copy2 -c app -- printenv APP_MODE APP_UPSTREAM
production
{"upstream": "http://backend.default.svc:8080", "timeout_ms": 250}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both match what the original app receives. However, the copy doesn't run the app itself, so you can't see the process at the moment it crashes. What you can check is the set of conditions the app receives at startup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;A distroless container has no shell, so you can't get in with &lt;code&gt;kubectl exec&lt;/code&gt;. If you add an ephemeral container with &lt;code&gt;kubectl debug&lt;/code&gt;, though, you can bring your own tools and investigate.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The network is shared with the Pod, so &lt;code&gt;curl localhost&lt;/code&gt;, &lt;code&gt;ss&lt;/code&gt;, &lt;code&gt;tcpdump&lt;/code&gt;, and &lt;code&gt;dig&lt;/code&gt; work directly&lt;/li&gt;
&lt;li&gt;If you enter with &lt;code&gt;--target&lt;/code&gt;, the app's process is visible, and you can read its state and environment variables from &lt;code&gt;/proc/1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The app's filesystem is separate and not directly visible. You can read it through &lt;code&gt;/proc/1/root&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/sys/fs/cgroup&lt;/code&gt; shows the debug container's own values, except with sysadmin. The app's values are readable through &lt;code&gt;/proc/1/root&lt;/code&gt;. If an ephemeral container can't reach them, read them from the node&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whether you can read the contents of what you reach depends on two things.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Inspecting another process requires &lt;code&gt;SYS_PTRACE&lt;/code&gt;. You get it by entering with general (no &lt;code&gt;--profile&lt;/code&gt;) or sysadmin&lt;/li&gt;
&lt;li&gt;If the target drops all capabilities (every target in this test did), reads worked without &lt;code&gt;SYS_PTRACE&lt;/code&gt; when your uid and gid both matched the target's&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you use this, watch out for two things.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Added containers can't be removed from the Pod&lt;/li&gt;
&lt;li&gt;You can't use &lt;code&gt;--target&lt;/code&gt; on a stopped container. For a Pod that keeps crashing, make a copy with &lt;code&gt;--copy-to&lt;/code&gt; and investigate the copy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How far you can reach into the app depends on which namespaces you share with it. Whether you can read what you reach depends mainly on &lt;code&gt;SYS_PTRACE&lt;/code&gt;. If the target drops all capabilities, matching uid and gid also let you read it. If you keep these two questions apart, you'll know which one to suspect when you get stuck.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>linux</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Kubernetes: iptables is empty after migrating to Cilium — what to look at next</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 15 Sep 2026 13:03:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/kubernetes-iptables-is-empty-after-migrating-to-cilium-what-to-look-at-next-2knn</link>
      <guid>https://dev.to/shinagawa-web/kubernetes-iptables-is-empty-after-migrating-to-cilium-what-to-look-at-next-2knn</guid>
      <description>&lt;p&gt;A Kubernetes Service stops responding. The first move is always the same: get on the node and run &lt;code&gt;iptables-save | grep KUBE-SERVICES&lt;/code&gt; to see which Pod a given Service address maps to.&lt;/p&gt;

&lt;p&gt;kube-proxy wrote those rules. It's a standard Kubernetes component that runs on every node and keeps Service forwarding rules registered in iptables. Looking up where a Service routes means reading those rules.&lt;/p&gt;

&lt;p&gt;Cilium is a Kubernetes CNI plugin — the piece that wires up Pod networking so Pods can talk to each other. Flannel and Calico fill the same role; you pick one per cluster.&lt;/p&gt;

&lt;p&gt;Cilium has a mode that goes beyond Pod networking and also handles Service forwarding, the job kube-proxy used to own. That mode is called kube-proxy replacement. With &lt;code&gt;kubeProxyReplacement=true&lt;/code&gt;, Cilium runs on the assumption that kube-proxy is absent. Cilium itself does not remove kube-proxy; on an existing cluster you have to remove it separately. The test environment in this article is built with kind's &lt;code&gt;kubeProxyMode: none&lt;/code&gt;, so kube-proxy was never present.&lt;/p&gt;

&lt;p&gt;On a node in that Cilium environment, run the same &lt;code&gt;iptables-save | grep KUBE-SERVICES&lt;/code&gt; from before. Nothing comes back. The same setup on a kube-proxy cluster returns 13 lines; here it returns 0. KUBE-SERVICES is the chain kube-proxy creates — the entry point for packets destined to a ClusterIP. It doesn't exist.&lt;/p&gt;

&lt;p&gt;Broaden the grep to &lt;code&gt;KUBE-&lt;/code&gt; and you get a few lines back, but they're &lt;code&gt;KUBE-FIREWALL&lt;/code&gt; and &lt;code&gt;KUBE-KUBELET-CANARY&lt;/code&gt;, chains created by kubelet that have nothing to do with Service forwarding. The nat table itself has some rules; it isn't empty. Only the Service forwarding rules are missing.&lt;/p&gt;

&lt;p&gt;You try a different table, a different node. KUBE-SERVICES is nowhere, and yet the application is up and Services are reachable.&lt;/p&gt;

&lt;p&gt;Nothing is broken. The information just isn't in iptables.&lt;/p&gt;

&lt;p&gt;This article covers three things: where Cilium stores the forwarding rules that used to live in iptables; why &lt;code&gt;tcpdump&lt;/code&gt; on a ClusterIP produces nothing and &lt;code&gt;conntrack -L&lt;/code&gt; returns no matches; and what to check, in what order, when connectivity fails.&lt;/p&gt;

&lt;p&gt;The test setup runs two kind clusters side by side — one with kube-proxy, one with Cilium — and runs the same commands against the same Service on both. All output shown is from real machines.&lt;/p&gt;

&lt;p&gt;All tests run on kind v0.27.0, node image &lt;code&gt;kindest/node:v1.32.2&lt;/code&gt;, Cilium 1.17.3, cilium-cli v0.20.0, kernel 6.8. Both clusters are single-node; multi-node-only behaviors are out of scope. ClusterIPs change each time a cluster is recreated, so IPs may differ between sections.&lt;/p&gt;

&lt;p&gt;The reproduction repo is at &lt;a href="https://github.com/shinagawa-web/kubernetes-cilium-kube-proxy-replacement-debug-guide" rel="noopener noreferrer"&gt;https://github.com/shinagawa-web/kubernetes-cilium-kube-proxy-replacement-debug-guide&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where forwarding rules live, and when they get written
&lt;/h2&gt;

&lt;h3&gt;
  
  
  kube-proxy
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Rules were registered in netfilter
&lt;/h4&gt;

&lt;p&gt;A Service's ClusterIP doesn't exist on any machine. When a Pod sends a packet to it, the destination gets rewritten to a real Pod IP somewhere in the forwarding path. kube-proxy was what registered those rewrite rules in netfilter. netfilter is the Linux kernel's packet-processing framework; &lt;code&gt;iptables&lt;/code&gt; is the command for reading and writing its rules.&lt;/p&gt;

&lt;p&gt;That's why &lt;code&gt;iptables-save&lt;/code&gt; worked. The forwarding rule — which Pod a ClusterIP packet goes to — was stored as a text rule registered in the kernel, so dumping everything was enough.&lt;/p&gt;

&lt;p&gt;Here's what the packet path looks like when a Pod connects to a ClusterIP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client Pod
   |  sends packet destined for 10.96.111.7:80 (ClusterIP)
   v
network stack
   |
   v
netfilter nat table
   KUBE-SERVICES → KUBE-SVC-xxxx → KUBE-SEP-xxxx
   |  rewrites destination to 10.244.0.6:80 (Pod IP) here
   v
Pod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  tcpdump could capture the pre-DNAT packet
&lt;/h4&gt;

&lt;p&gt;In this path, there's a stretch before the rewrite where the packet still carries the ClusterIP as its destination. Running &lt;code&gt;tcpdump&lt;/code&gt; on the node with the ClusterIP as the filter caught those packets. Capturing before and after the rewrite showed exactly which Pod IP the destination changed to.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;iptables-save&lt;/code&gt; for the rules, &lt;code&gt;tcpdump&lt;/code&gt; for the live packets. Those two tools covered Service forwarding.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cilium
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Cluster setup
&lt;/h4&gt;

&lt;p&gt;To enable kube-proxy replacement, you disable kube-proxy at cluster creation and install Cilium as the CNI. The test repo does this with kind. If you already have Cilium running, skip ahead to "No kube-proxy means no chains."&lt;/p&gt;

&lt;p&gt;First, the kind cluster config disables both kube-proxy and the default CNI:&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;# cluster/kind-cilium.yaml&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;Cluster&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;kind.x-k8s.io/v1alpha4&lt;/span&gt;
&lt;span class="na"&gt;nodes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;control-plane&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;kindest/node:v1.32.2&lt;/span&gt;
&lt;span class="na"&gt;networking&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;disableDefaultCNI&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;kubeProxyMode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;none&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kubeProxyMode: none&lt;/code&gt; keeps kube-proxy off the nodes. &lt;code&gt;disableDefaultCNI: true&lt;/code&gt; removes kindnet so Cilium can take the CNI slot.&lt;/p&gt;

&lt;p&gt;After creating the cluster, install Cilium via Helm. &lt;code&gt;kubeProxyReplacement=true&lt;/code&gt; starts Cilium in the mode where it owns Service forwarding as well.&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;cilium cilium/cilium &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version&lt;/span&gt; 1.17.3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; kube-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;kubeProxyReplacement&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&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;k8sServiceHost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;API_SERVER_IP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&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;k8sServicePort&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;API_SERVER_PORT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;k8sServiceHost&lt;/code&gt; and &lt;code&gt;k8sServicePort&lt;/code&gt; are the address Cilium uses to reach the Kubernetes API server. Without kube-proxy, you have to supply these explicitly. The full setup script is in &lt;code&gt;scripts/setup-cilium.sh&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  No kube-proxy means no chains
&lt;/h4&gt;

&lt;p&gt;With this setup, kube-proxy doesn't exist. No process registers rules, so KUBE-SERVICES is never created. That's why the initial grep returned 0 lines.&lt;/p&gt;

&lt;h4&gt;
  
  
  The forwarding table moves to eBPF maps
&lt;/h4&gt;

&lt;p&gt;The destination mapping moves to eBPF. eBPF lets you load programs into the kernel and run them at predefined hook points — on packet receive, on system call entry — without modifying the kernel itself or rebooting. The attached program can read and modify the data passing through that point.&lt;/p&gt;

&lt;p&gt;eBPF programs are stateless across invocations — local variables don't persist between calls. State lives in maps instead: a key-value store the kernel maintains. Programs read and write there. Cilium writes the ClusterIP-to-Pod-IP mapping into these maps; on each connection, the attached program looks up the map to decide where to forward.&lt;/p&gt;

&lt;h4&gt;
  
  
  The rewrite happens at connect()
&lt;/h4&gt;

&lt;p&gt;If only the storage location changed, you'd just learn a new command. But there's one more difference: the rewrite happens earlier.&lt;/p&gt;

&lt;p&gt;The same connection in a Cilium environment no longer goes through netfilter at all, and the rewrite point shifts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client Pod
   |  calls connect() with 10.96.89.117:80 (ClusterIP) as the destination
   v
eBPF program attached to connect()
   |  rewrites destination to 10.0.0.183:80 (Pod IP)
   v
Pod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cilium attaches an eBPF program to &lt;code&gt;connect()&lt;/code&gt;, the system call where an application tells the kernel what it wants to connect to. At this point, not a single byte of data has been sent. The destination address gets replaced with the Pod IP right here.&lt;/p&gt;

&lt;p&gt;The rewrite is done before any packet is constructed, so the resulting packet carries the Pod IP as its destination — no ClusterIP packet is ever created. That's why tcpdump on a network interface never shows the ClusterIP.&lt;/p&gt;

&lt;p&gt;This applies to connections from within the cluster. Packets arriving from outside the cluster on a NodePort are handled differently, as covered in a later section.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two kinds of "no output"
&lt;/h3&gt;

&lt;p&gt;At this point there are two separate "nothing shows up" situations with different causes. &lt;code&gt;iptables-save&lt;/code&gt; returning 0 lines is because the forwarding rules moved from iptables to eBPF maps. &lt;code&gt;tcpdump&lt;/code&gt; returning 0 packets when filtering on a ClusterIP is because there are no packets with that ClusterIP as a destination in that path. The causes are different, so the replacement commands are different. The next section handles the first one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the forwarding table
&lt;/h2&gt;

&lt;p&gt;The test environment has a single ClusterIP Service named &lt;code&gt;demo&lt;/code&gt;, backed by a Deployment with 2 replicas. Here's how to read the forwarding table for that Service in each environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  kube-proxy
&lt;/h3&gt;

&lt;p&gt;Take another look at those 13 lines from &lt;code&gt;iptables-save | grep KUBE-SERVICES&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;:KUBE-SERVICES - &lt;span class="o"&gt;[&lt;/span&gt;0:0]
&lt;span class="nt"&gt;-A&lt;/span&gt; FORWARD &lt;span class="nt"&gt;-m&lt;/span&gt; conntrack &lt;span class="nt"&gt;--ctstate&lt;/span&gt; NEW &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"kubernetes service portals"&lt;/span&gt; &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SERVICES
&lt;span class="nt"&gt;-A&lt;/span&gt; OUTPUT &lt;span class="nt"&gt;-m&lt;/span&gt; conntrack &lt;span class="nt"&gt;--ctstate&lt;/span&gt; NEW &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"kubernetes service portals"&lt;/span&gt; &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SERVICES
:KUBE-SERVICES - &lt;span class="o"&gt;[&lt;/span&gt;0:0]
&lt;span class="nt"&gt;-A&lt;/span&gt; PREROUTING &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"kubernetes service portals"&lt;/span&gt; &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SERVICES
&lt;span class="nt"&gt;-A&lt;/span&gt; OUTPUT &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"kubernetes service portals"&lt;/span&gt; &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SERVICES
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SERVICES &lt;span class="nt"&gt;-d&lt;/span&gt; 10.96.0.10/32 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"kube-system/kube-dns:metrics cluster IP"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 9153 &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SVC-JD5MR3NA4I4DYORP
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SERVICES &lt;span class="nt"&gt;-d&lt;/span&gt; 10.96.111.7/32 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo cluster IP"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 80 &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SVC-73JNOU5FOFXIWZLE
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SERVICES &lt;span class="nt"&gt;-d&lt;/span&gt; 10.96.182.1/32 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo-nodeport cluster IP"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 80 &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SVC-IECF2FXKD7A6IGZV
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SERVICES &lt;span class="nt"&gt;-d&lt;/span&gt; 10.96.0.1/32 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/kubernetes:https cluster IP"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 443 &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SVC-NPX46M4PTMTKRN6Y
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SERVICES &lt;span class="nt"&gt;-d&lt;/span&gt; 10.96.0.10/32 &lt;span class="nt"&gt;-p&lt;/span&gt; udp &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"kube-system/kube-dns:dns cluster IP"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; udp &lt;span class="nt"&gt;--dport&lt;/span&gt; 53 &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SVC-TCOU7JCQXEZGVUNU
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SERVICES &lt;span class="nt"&gt;-d&lt;/span&gt; 10.96.0.10/32 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"kube-system/kube-dns:dns-tcp cluster IP"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 53 &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SVC-ERIFXISQEP7F7OF4
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SERVICES &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"kubernetes service nodeports; NOTE: this must be the last rule in this chain"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; addrtype &lt;span class="nt"&gt;--dst-type&lt;/span&gt; LOCAL &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-NODEPORTS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first 6 lines are chain definitions and hook points — no forwarding destination information. Lines 7 onward list each Service's entry. &lt;code&gt;-d&lt;/code&gt; is the ClusterIP; &lt;code&gt;-m comment --comment&lt;/code&gt; contains &lt;code&gt;default/&amp;lt;ServiceName&amp;gt; cluster IP&lt;/code&gt;. Find the Service you want by that comment. &lt;code&gt;demo&lt;/code&gt; is line 8 — &lt;code&gt;"default/demo cluster IP"&lt;/code&gt; — and its ClusterIP is &lt;code&gt;10.96.111.7&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;-j KUBE-SVC-73JNOU5FOFXIWZLE&lt;/code&gt; at the end of that line is the jump to the backend-selection chain. Grep for that chain name to get the backend rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;iptables-save &lt;span class="nt"&gt;-t&lt;/span&gt; nat | &lt;span class="nb"&gt;grep &lt;/span&gt;KUBE-SVC-73JNOU5FOFXIWZLE
&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;&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SVC-73JNOU5FOFXIWZLE &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; 10.244.0.0/16 &lt;span class="nt"&gt;-d&lt;/span&gt; 10.96.111.7/32 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo cluster IP"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 80 &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-MARK-MASQ
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SVC-73JNOU5FOFXIWZLE &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo -&amp;gt; 10.244.0.6:80"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; statistic &lt;span class="nt"&gt;--mode&lt;/span&gt; random &lt;span class="nt"&gt;--probability&lt;/span&gt; 0.50000000000 &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SEP-4BM33MODP4BKIE3G
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SVC-73JNOU5FOFXIWZLE &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo -&amp;gt; 10.244.0.7:80"&lt;/span&gt; &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SEP-C7KHQYF2QRUY23U4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The KUBE-SVC chain selects the backend. The first line — &lt;code&gt;! -s 10.244.0.0/16&lt;/code&gt; — handles masquerading for traffic coming from outside the Pod CIDR; it's not part of backend selection. Lines 2 and 3 are the actual selection: the comment shows &lt;code&gt;default/demo -&amp;gt; &amp;lt;Pod IP&amp;gt;:80&lt;/code&gt; and &lt;code&gt;--probability&lt;/code&gt; sets the distribution ratio. The &lt;code&gt;-j KUBE-SEP-xxx&lt;/code&gt; at the end is the jump to the chain that executes the DNAT:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;iptables-save &lt;span class="nt"&gt;-t&lt;/span&gt; nat | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"KUBE-SEP-4BM33MODP4BKIE3G|KUBE-SEP-C7KHQYF2QRUY23U4"&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;&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SEP-4BM33MODP4BKIE3G &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; tcp &lt;span class="nt"&gt;-j&lt;/span&gt; DNAT &lt;span class="nt"&gt;--to-destination&lt;/span&gt; 10.244.0.6:80
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SEP-C7KHQYF2QRUY23U4 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; tcp &lt;span class="nt"&gt;-j&lt;/span&gt; DNAT &lt;span class="nt"&gt;--to-destination&lt;/span&gt; 10.244.0.7:80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--to-destination&lt;/code&gt; is the backend Pod IP and port.&lt;/p&gt;

&lt;p&gt;Summary of how to read the forwarding table in a kube-proxy environment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ClusterIP: the &lt;code&gt;-A KUBE-SERVICES -d&lt;/code&gt; line (confirm the Service name in the comment)&lt;/li&gt;
&lt;li&gt;Backend Pod IP: &lt;code&gt;--to-destination&lt;/code&gt; in the corresponding KUBE-SEP chain&lt;/li&gt;
&lt;li&gt;Weight: &lt;code&gt;--probability&lt;/code&gt; in the KUBE-SVC chain&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Cilium
&lt;/h3&gt;

&lt;p&gt;First, get the ClusterIP from &lt;code&gt;kubectl get svc&lt;/code&gt;:&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 svc demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME   TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
demo   ClusterIP   10.96.24.124    &amp;lt;none&amp;gt;        80/TCP    4s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ClusterIP is &lt;code&gt;10.96.24.124&lt;/code&gt;. &lt;code&gt;cilium-dbg service list -o json&lt;/code&gt; shows the frontend (ClusterIP), Service name, and backend Pod IPs all at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nb"&gt;exec &lt;/span&gt;ds/cilium &lt;span class="nt"&gt;--&lt;/span&gt; cilium-dbg service list &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
| jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] | [
    (.spec.id | tostring),
    (.spec["frontend-address"].ip + ":" + (.spec["frontend-address"].port|tostring)),
    .spec.flags.type,
    (.spec.flags.namespace + "/" + .spec.flags.name),
    (.spec["backend-addresses"] // [] | map(.ip + ":" + (.port|tostring)) | join(" "))
  ] | @tsv'&lt;/span&gt; | column &lt;span class="nt"&gt;-t&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1   10.96.0.1:443      ClusterIP  default/kubernetes       172.18.0.2:6443
5   10.96.0.10:53      ClusterIP  kube-system/kube-dns     10.0.0.88:53     10.0.0.192:53
12  10.96.24.124:80    ClusterIP  default/demo             10.0.0.40:80     10.0.0.8:80
2   0.0.0.0:30080      NodePort   default/demo-nodeport    10.0.0.113:80    10.0.0.195:80
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;10.96.24.124&lt;/code&gt; from &lt;code&gt;kubectl get svc&lt;/code&gt; matches ID 12. The last column shows the backends: &lt;code&gt;10.0.0.40:80&lt;/code&gt; and &lt;code&gt;10.0.0.8:80&lt;/code&gt;. The three-hop walk through KUBE-SERVICES → KUBE-SVC → KUBE-SEP in kube-proxy fits in a single line here.&lt;/p&gt;

&lt;p&gt;In a multi-node cluster, &lt;code&gt;kubectl -n kube-system exec ds/cilium&lt;/code&gt; execs into whichever Pod in the DaemonSet gets scheduled. In a multi-node cluster you only see the local state of that agent. The same applies to &lt;code&gt;hubble observe&lt;/code&gt; flows — traffic on other nodes won't appear. If you know which node your target Pod is on, check &lt;code&gt;kubectl get pod -n kube-system -o wide | grep cilium&lt;/code&gt; for that node's cilium Pod name and exec into it explicitly with &lt;code&gt;kubectl -n kube-system exec &amp;lt;PodName&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packet tracing
&lt;/h2&gt;

&lt;p&gt;Comparing how far &lt;code&gt;tcpdump&lt;/code&gt; gets you when chasing ClusterIP traffic in each environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  kube-proxy
&lt;/h3&gt;

&lt;p&gt;Running &lt;code&gt;tcpdump&lt;/code&gt; on the node and filtering on the ClusterIP captured packets. DNAT happened in netfilter, so there was a stretch before the rewrite where packets actually carried the ClusterIP as their destination. That "bracket the DNAT with two tcpdump captures" technique only worked because of that structure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl debug node/&amp;lt;node&amp;gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot &lt;span class="nt"&gt;--&lt;/span&gt; tcpdump &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; any &lt;span class="s2"&gt;"host 10.96.104.4"&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;tcpdump: data &lt;span class="nb"&gt;link type &lt;/span&gt;LINUX_SLL2
tcpdump: verbose output suppressed, use &lt;span class="nt"&gt;-v&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;v]... &lt;span class="k"&gt;for &lt;/span&gt;full protocol decode
listening on any, link-type LINUX_SLL2 &lt;span class="o"&gt;(&lt;/span&gt;Linux cooked v2&lt;span class="o"&gt;)&lt;/span&gt;, snapshot length 262144 bytes
11:38:19.980617 vethc1bb8f9e In  IP 10.244.0.5.54462 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 10.96.104.4.80: Flags &lt;span class="o"&gt;[&lt;/span&gt;S], &lt;span class="nb"&gt;seq &lt;/span&gt;3530419686, win 64240, options &lt;span class="o"&gt;[&lt;/span&gt;mss 1460,sackOK,TS val 4270529724 ecr 0,nop,wscale 10], length 0
11:38:19.980665 vethc1bb8f9e Out IP 10.96.104.4.80 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 10.244.0.5.54462: Flags &lt;span class="o"&gt;[&lt;/span&gt;S.], &lt;span class="nb"&gt;seq &lt;/span&gt;2901899611, ack 3530419687, win 65160, options &lt;span class="o"&gt;[&lt;/span&gt;mss 1460,sackOK,TS val 4170938121 ecr 4270529724,nop,wscale 10], length 0
11:38:19.980675 vethc1bb8f9e In  IP 10.244.0.5.54462 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 10.96.104.4.80: Flags &lt;span class="o"&gt;[&lt;/span&gt;.], ack 1, win 63, options &lt;span class="o"&gt;[&lt;/span&gt;nop,nop,TS val 4270529724 ecr 4170938121], length 0
11:38:19.980726 vethc1bb8f9e In  IP 10.244.0.5.54462 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 10.96.104.4.80: Flags &lt;span class="o"&gt;[&lt;/span&gt;P.], &lt;span class="nb"&gt;seq &lt;/span&gt;1:76, ack 1, win 63, options &lt;span class="o"&gt;[&lt;/span&gt;nop,nop,TS val 4270529724 ecr 4170938121], length 75: HTTP: GET / HTTP/1.1
11:38:19.980741 vethc1bb8f9e Out IP 10.96.104.4.80 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 10.244.0.5.54462: Flags &lt;span class="o"&gt;[&lt;/span&gt;.], ack 76, win 64, options &lt;span class="o"&gt;[&lt;/span&gt;nop,nop,TS val 4170938121 ecr 4270529724], length 0
11:38:19.983523 vethc1bb8f9e Out IP 10.96.104.4.80 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 10.244.0.5.54462: Flags &lt;span class="o"&gt;[&lt;/span&gt;P.], &lt;span class="nb"&gt;seq &lt;/span&gt;1:1545, ack 76, win 64, options &lt;span class="o"&gt;[&lt;/span&gt;nop,nop,TS val 4170938124 ecr 4270529724], length 1544: HTTP: HTTP/1.1 200 OK
11:38:19.983538 vethc1bb8f9e In  IP 10.244.0.5.54462 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 10.96.104.4.80: Flags &lt;span class="o"&gt;[&lt;/span&gt;.], ack 1545, win 67, options &lt;span class="o"&gt;[&lt;/span&gt;nop,nop,TS val 4170938124 ecr 4270529724], length 0
11:38:19.983641 vethc1bb8f9e In  IP 10.244.0.5.54462 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 10.96.104.4.80: Flags &lt;span class="o"&gt;[&lt;/span&gt;.], &lt;span class="nb"&gt;seq &lt;/span&gt;76, ack 1545, win 67, options &lt;span class="o"&gt;[&lt;/span&gt;nop,nop,TS val 4270529727 ecr 4170938124], length 0
11:38:19.983883 vethc1bb8f9e Out IP 10.96.104.4.80 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 10.244.0.5.54462: Flags &lt;span class="o"&gt;[&lt;/span&gt;F.], &lt;span class="nb"&gt;seq &lt;/span&gt;1545, ack 77, win 64, options &lt;span class="o"&gt;[&lt;/span&gt;nop,nop,TS val 4170938124 ecr 4270529727], length 0
11:38:19.983898 vethc1bb8f9e In  IP 10.244.0.5.54462 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 10.96.104.4.80: Flags &lt;span class="o"&gt;[&lt;/span&gt;.], ack 1546, win 67, options &lt;span class="o"&gt;[&lt;/span&gt;nop,nop,TS val 4270529727 ecr 4170938124], length 0

10 packets captured
10 packets received by filter
0 packets dropped by kernel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Packets to the ClusterIP (&lt;code&gt;10.96.104.4&lt;/code&gt;) are captured. SYN through FIN, all there. DNAT happens in netfilter after this, so the packets with the ClusterIP destination exist in this stretch.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;conntrack -L&lt;/code&gt; also shows the DNAT in a single entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl debug node/&amp;lt;node&amp;gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot &lt;span class="nt"&gt;--&lt;/span&gt; conntrack &lt;span class="nt"&gt;-L&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"10.96.104.4"&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;tcp  6 118 TIME_WAIT &lt;span class="nv"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.244.0.5 &lt;span class="nv"&gt;dst&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.96.104.4 &lt;span class="nv"&gt;sport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;54462 &lt;span class="nv"&gt;dport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;80 &lt;span class="nv"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.244.0.6 &lt;span class="nv"&gt;dst&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.244.0.5 &lt;span class="nv"&gt;sport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;80 &lt;span class="nv"&gt;dport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;54462 &lt;span class="o"&gt;[&lt;/span&gt;ASSURED] &lt;span class="nv"&gt;mark&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nv"&gt;use&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dst=10.96.104.4&lt;/code&gt; is the ClusterIP (outbound direction); &lt;code&gt;src=10.244.0.6&lt;/code&gt; is the backend Pod IP (return direction). Before and after the DNAT, on one line.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cilium
&lt;/h3&gt;

&lt;p&gt;Same experiment on the Cilium cluster. Run &lt;code&gt;tcpdump&lt;/code&gt; while curling ClusterIP &lt;code&gt;10.96.89.117&lt;/code&gt; from the client Pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl debug node/&amp;lt;node&amp;gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot &lt;span class="nt"&gt;--&lt;/span&gt; tcpdump &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; any &lt;span class="s2"&gt;"host 10.96.89.117"&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;tcpdump: data &lt;span class="nb"&gt;link type &lt;/span&gt;LINUX_SLL2
tcpdump: verbose output suppressed, use &lt;span class="nt"&gt;-v&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;v]... &lt;span class="k"&gt;for &lt;/span&gt;full protocol decode
listening on any, link-type LINUX_SLL2 &lt;span class="o"&gt;(&lt;/span&gt;Linux cooked v2&lt;span class="o"&gt;)&lt;/span&gt;, snapshot length 262144 bytes
0 packets captured
0 packets received by filter
0 packets dropped by kernel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The curl succeeds. Connectivity is there. The empty capture is not a filter mistake — there are simply no packets with that ClusterIP as their destination.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;connect()&lt;/code&gt; rewrites the destination to a Pod IP before any packet is created, so whatever hits the network already has the Pod IP. The ClusterIP never reaches the network layer.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;conntrack -L&lt;/code&gt; filtered on the ClusterIP also returns nothing. The conntrack table contains only Pod-IP entries, but since no packets with the ClusterIP pass through netfilter, there's nothing to match.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl debug node/&amp;lt;node&amp;gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot &lt;span class="nt"&gt;--&lt;/span&gt; conntrack &lt;span class="nt"&gt;-L&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"10.96.89.117"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No output.&lt;/p&gt;

&lt;h4&gt;
  
  
  Seeing the ClusterIP translation with Hubble
&lt;/h4&gt;

&lt;p&gt;tcpdump can't show the ClusterIP. So how do you trace ClusterIP traffic in a Cilium environment? Hubble is Cilium's observability component — it surfaces eBPF events as flow records. The test repo's &lt;code&gt;scripts/setup-cilium.sh&lt;/code&gt; installs with Hubble Relay enabled. Curl the ClusterIP from the client Pod and then run &lt;code&gt;hubble observe&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nb"&gt;exec &lt;/span&gt;ds/cilium &lt;span class="nt"&gt;--&lt;/span&gt; hubble observe &lt;span class="nt"&gt;--last&lt;/span&gt; 30 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"client|demo"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--last 30&lt;/code&gt; outputs the 30 most recent flows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sep  7 07:23:31.106: default/client (ID:52940) &amp;lt;&amp;gt; 10.96.89.117:80 (world) pre-xlate-fwd TRACED (TCP)
Sep  7 07:23:31.106: default/client (ID:52940) &amp;lt;&amp;gt; default/demo-864f5f87b9-rvr95:80 (ID:10947) post-xlate-fwd TRANSLATED (TCP)
Sep  7 07:23:31.106: default/client:52852 (ID:52940) -&amp;gt; default/demo-864f5f87b9-rvr95:80 (ID:10947) to-endpoint FORWARDED (TCP Flags: SYN)
Sep  7 07:23:31.106: default/client:52852 (ID:52940) &amp;lt;- default/demo-864f5f87b9-rvr95:80 (ID:10947) to-endpoint FORWARDED (TCP Flags: SYN, ACK)
Sep  7 07:23:31.106: default/client:52852 (ID:52940) -&amp;gt; default/demo-864f5f87b9-rvr95:80 (ID:10947) to-endpoint FORWARDED (TCP Flags: ACK)
Sep  7 07:23:31.106: default/client:52852 (ID:52940) -&amp;gt; default/demo-864f5f87b9-rvr95:80 (ID:10947) to-endpoint FORWARDED (TCP Flags: ACK, PSH)
Sep  7 07:23:31.110: default/client:52852 (ID:52940) &amp;lt;- default/demo-864f5f87b9-rvr95:80 (ID:10947) to-endpoint FORWARDED (TCP Flags: ACK, PSH)
Sep  7 07:23:31.110: default/client:52852 (ID:52940) -&amp;gt; default/demo-864f5f87b9-rvr95:80 (ID:10947) to-endpoint FORWARDED (TCP Flags: ACK, FIN)
Sep  7 07:23:31.110: default/client:52852 (ID:52940) &amp;lt;- default/demo-864f5f87b9-rvr95:80 (ID:10947) to-endpoint FORWARDED (TCP Flags: ACK, FIN)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Focus on the first two lines. &lt;code&gt;pre-xlate-fwd TRACED&lt;/code&gt; is the pre-translation flow, destination still recorded as &lt;code&gt;10.96.89.117:80&lt;/code&gt; — the ClusterIP from &lt;code&gt;kubectl get svc demo&lt;/code&gt;. &lt;code&gt;post-xlate-fwd TRANSLATED&lt;/code&gt; is after, destination now the actual Pod &lt;code&gt;demo-864f5f87b9-rvr95:80&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This confirms why tcpdump couldn't see the ClusterIP: the translation is done before the packet reaches the network. Hubble records both sides of the translation as eBPF events, so you can read "which ClusterIP became which Pod" directly.&lt;/p&gt;

&lt;h4&gt;
  
  
  NodePort is visible to tcpdump
&lt;/h4&gt;

&lt;p&gt;Try NodePort on the same cluster and the result is different. Send a curl from outside the cluster to the NodePort (&lt;code&gt;172.18.0.2:30080&lt;/code&gt;) while running tcpdump for that port on the node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl debug node/&amp;lt;node&amp;gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot &lt;span class="nt"&gt;--&lt;/span&gt; tcpdump &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; any &lt;span class="s2"&gt;"port 30080"&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;tcpdump: data &lt;span class="nb"&gt;link type &lt;/span&gt;LINUX_SLL2
tcpdump: verbose output suppressed, use &lt;span class="nt"&gt;-v&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;v]... &lt;span class="k"&gt;for &lt;/span&gt;full protocol decode
listening on any, link-type LINUX_SLL2 &lt;span class="o"&gt;(&lt;/span&gt;Linux cooked v2&lt;span class="o"&gt;)&lt;/span&gt;, snapshot length 262144 bytes
07:23:24.736193 eth0  In  IP 172.18.0.1.52306 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 172.18.0.2.30080: Flags &lt;span class="o"&gt;[&lt;/span&gt;S], &lt;span class="nb"&gt;seq &lt;/span&gt;3175489113, win 64240, options &lt;span class="o"&gt;[&lt;/span&gt;mss 1460,sackOK,TS val 194961069 ecr 0,nop,wscale 10], length 0
07:23:24.736306 eth0  Out IP 172.18.0.2.30080 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 172.18.0.1.52306: Flags &lt;span class="o"&gt;[&lt;/span&gt;S.], &lt;span class="nb"&gt;seq &lt;/span&gt;2271513508, ack 3175489114, win 64308, options &lt;span class="o"&gt;[&lt;/span&gt;mss 1410,sackOK,TS val 855246694 ecr 194961069,nop,wscale 10], length 0
07:23:24.736333 eth0  In  IP 172.18.0.1.52306 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 172.18.0.2.30080: Flags &lt;span class="o"&gt;[&lt;/span&gt;.], ack 1, win 63, options &lt;span class="o"&gt;[&lt;/span&gt;nop,nop,TS val 194961069 ecr 855246694], length 0
07:23:24.736421 eth0  In  IP 172.18.0.1.52306 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 172.18.0.2.30080: Flags &lt;span class="o"&gt;[&lt;/span&gt;P.], &lt;span class="nb"&gt;seq &lt;/span&gt;1:80, ack 1, win 63, options &lt;span class="o"&gt;[&lt;/span&gt;nop,nop,TS val 194961069 ecr 855246694], length 79
...
10 packets captured
10 packets received by filter
0 packets dropped by kernel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;10 packets captured. The difference is the path. NodePort packets arrive from outside the cluster as real packets, so they're not subject to socket LB. Processing happens at the tc layer (TCX), after the NIC, which is why tcpdump sees them.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cilium-dbg status --verbose&lt;/code&gt; shows which layers are active:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nb"&gt;exec &lt;/span&gt;ds/cilium &lt;span class="nt"&gt;-c&lt;/span&gt; cilium-agent &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  cilium-dbg status &lt;span class="nt"&gt;--verbose&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"Attach Mode|Socket LB|XDP Acceleration"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Attach Mode:            TCX
  Socket LB:              Enabled
  XDP Acceleration:       Disabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Socket LB: Enabled&lt;/code&gt; means Cilium rewrites destinations at the socket layer (the &lt;code&gt;connect()&lt;/code&gt; hook). ClusterIP traffic gets translated to a Pod IP there, before any packet is created — which is why tcpdump never sees it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;XDP Acceleration: Disabled&lt;/code&gt; means NIC-driver-level (XDP) processing is off. XDP requires a compatible NIC, so it's disabled in kind by default. Instead, as &lt;code&gt;Attach Mode: TCX&lt;/code&gt; shows, BPF programs are attached at the tc (Traffic Control) layer. NodePort traffic is real packets from outside the cluster, not subject to socket LB, so it gets processed at that tc layer and passes through the NIC — making it visible to tcpdump.&lt;/p&gt;

&lt;h3&gt;
  
  
  Packet tracing summary
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;kube-proxy&lt;/th&gt;
&lt;th&gt;Cilium&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;tcpdump on ClusterIP&lt;/td&gt;
&lt;td&gt;captures (packet exists before DNAT)&lt;/td&gt;
&lt;td&gt;nothing (translated at connect())&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;conntrack on ClusterIP&lt;/td&gt;
&lt;td&gt;captures (DNAT before/after on one line)&lt;/td&gt;
&lt;td&gt;nothing (doesn't go through netfilter)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tcpdump on NodePort&lt;/td&gt;
&lt;td&gt;captures&lt;/td&gt;
&lt;td&gt;captures (processed at tc layer)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;translation visibility&lt;/td&gt;
&lt;td&gt;tcpdump before/after DNAT&lt;/td&gt;
&lt;td&gt;Hubble pre-xlate-fwd / post-xlate-fwd&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  E2E debugging flow
&lt;/h2&gt;

&lt;p&gt;Starting from "Service is unreachable," walk through the investigation steps for a Cilium environment. By the end of this section, you'll have a clear path from symptom to cause.&lt;/p&gt;

&lt;p&gt;The test repo's &lt;code&gt;manifests/broken/&lt;/code&gt; has two failure patterns: &lt;code&gt;svc-wrong-selector.yaml&lt;/code&gt; (a Service with a selector that matches no Pods) and &lt;code&gt;netpol-deny.yaml&lt;/code&gt; (a NetworkPolicy that blocks the client). The full debug script is in &lt;code&gt;tests/debug-cilium.sh&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case A — selector mismatch
&lt;/h3&gt;

&lt;p&gt;Apply &lt;code&gt;manifests/broken/svc-wrong-selector.yaml&lt;/code&gt;. The &lt;code&gt;demo-broken&lt;/code&gt; Service has selector &lt;code&gt;app: demo-v2&lt;/code&gt;, but every Pod in the cluster has the label &lt;code&gt;app: demo&lt;/code&gt;. Nothing matches.&lt;/p&gt;

&lt;p&gt;Check the ClusterIP 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 get svc demo-broken
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME          TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
demo-broken   ClusterIP   10.96.171.11    &amp;lt;none&amp;gt;        80/TCP    4s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Send a curl from the client Pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; default client &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 5 &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code} (%{time_total}s)'&lt;/span&gt; http://10.96.171.11/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;000 (0.000063s)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;%{http_code}&lt;/code&gt; returning &lt;code&gt;000&lt;/code&gt; means no HTTP response. &lt;code&gt;%{time_total}&lt;/code&gt; shows it returned immediately. When a Service has zero backends, Cilium's eBPF program rejects the connection right away — same speed as kube-proxy's &lt;code&gt;REJECT --reject-with icmp-port-unreachable&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 1 — check Endpoints
&lt;/h4&gt;

&lt;p&gt;Same command as before Cilium:&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 endpoints demo-broken
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME          ENDPOINTS   AGE
demo-broken   &amp;lt;none&amp;gt;      8s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;none&amp;gt;&lt;/code&gt; means no Pods are registered as backends. Compare against a working Service:&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 endpoints demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME   ENDPOINTS                     AGE
demo   10.0.0.117:80,10.0.0.119:80   34s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kubectl get endpoints&lt;/code&gt; works exactly the same as in a kube-proxy environment (from 1.33, &lt;code&gt;kubectl get endpointslices&lt;/code&gt; is preferred).&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 2 — check Cilium's internal state
&lt;/h4&gt;

&lt;p&gt;The JSON output includes the Service name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nb"&gt;exec &lt;/span&gt;ds/cilium &lt;span class="nt"&gt;--&lt;/span&gt; cilium-dbg service list &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
| jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] | [
    (.spec.id | tostring),
    (.spec["frontend-address"].ip + ":" + (.spec["frontend-address"].port|tostring)),
    .spec.flags.type,
    (.spec.flags.namespace + "/" + .spec.flags.name),
    (.spec["backend-addresses"] | length | tostring)
  ] | @tsv'&lt;/span&gt; | column &lt;span class="nt"&gt;-t&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;12  10.96.171.11:80   ClusterIP  default/demo-broken  0
8   10.96.249.121:80  ClusterIP  default/demo         2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last column is backend count. &lt;code&gt;demo&lt;/code&gt; (ID 8) has 2; &lt;code&gt;demo-broken&lt;/code&gt; (ID 12) has 0. A backend count of 0 indicates endpoint shortage. Cilium registers the Service but has nowhere to forward connections.&lt;/p&gt;

&lt;p&gt;In a kube-proxy environment, the same situation shows up in the filter table with a comment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl debug node/&amp;lt;node&amp;gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot &lt;span class="nt"&gt;--&lt;/span&gt; iptables-save &lt;span class="nt"&gt;-t&lt;/span&gt; filter | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"10.96.171.11"&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;&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SERVICES &lt;span class="nt"&gt;-d&lt;/span&gt; 10.96.171.11/32 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo-broken has no endpoints"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 80 &lt;span class="nt"&gt;-j&lt;/span&gt; REJECT &lt;span class="nt"&gt;--reject-with&lt;/span&gt; icmp-port-unreachable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;has no endpoints&lt;/code&gt; is right there in the comment. Run that same command on the Cilium cluster and you get nothing. For endpoint-shortage cases (as opposed to NetworkPolicy), iptables stops explaining itself. Use &lt;code&gt;cilium-dbg service list -o json | jq&lt;/code&gt; backend count instead. 0 means no endpoints.&lt;/p&gt;

&lt;h4&gt;
  
  
  Root cause
&lt;/h4&gt;

&lt;p&gt;The most common reason Endpoints is empty is a selector mismatch between the Service and its Pods. Compare them directly:&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 svc demo-broken &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.selector}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"app"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"demo-v2"&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pod &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;demo &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;'{range .items[*]}{.metadata.name}  {.metadata.labels}{"\n"}{end}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;demo&lt;/span&gt;&lt;span class="mi"&gt;-864&lt;/span&gt;&lt;span class="err"&gt;f&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="err"&gt;f&lt;/span&gt;&lt;span class="mi"&gt;87&lt;/span&gt;&lt;span class="err"&gt;b&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="err"&gt;-dccbz&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"app"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"demo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"pod-template-hash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"864f5f87b9"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;demo&lt;/span&gt;&lt;span class="mi"&gt;-864&lt;/span&gt;&lt;span class="err"&gt;f&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="err"&gt;f&lt;/span&gt;&lt;span class="mi"&gt;87&lt;/span&gt;&lt;span class="err"&gt;b&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="err"&gt;-zzngq&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"app"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"demo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"pod-template-hash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"864f5f87b9"&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;p&gt;The selector points to &lt;code&gt;app: demo-v2&lt;/code&gt;; the Pods carry &lt;code&gt;app: demo&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case B — NetworkPolicy deny
&lt;/h3&gt;

&lt;p&gt;Apply &lt;code&gt;manifests/broken/netpol-deny.yaml&lt;/code&gt;. The &lt;code&gt;deny-client-to-demo&lt;/code&gt; NetworkPolicy allows Ingress to Pods with &lt;code&gt;app: demo&lt;/code&gt; only from Pods labeled &lt;code&gt;app: not-client&lt;/code&gt;. The &lt;code&gt;client&lt;/code&gt; Pod doesn't match, so all connections to &lt;code&gt;demo&lt;/code&gt; are denied.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; default client &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 5 &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code} (%{time_total}s)'&lt;/span&gt; http://10.96.249.121/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;000 (5.001378s)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same symptoms as Case A — but &lt;code&gt;%{time_total}&lt;/code&gt; is 5 seconds. Case A (no backends) returned in 0.000063 seconds. Here, &lt;code&gt;--max-time&lt;/code&gt; was exhausted. Packets are reaching the destination; nothing is sending a response back. Endpoints tell a different story.&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 1 — check Endpoints
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get endpoints demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME   ENDPOINTS                     AGE
demo   10.0.0.117:80,10.0.0.119:80   34s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Endpoints are healthy. That's the branch point from Case A. Empty Endpoints → check selector match and Pod readiness. Endpoints present → keep going.&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 2 — confirm Cilium sees active backends
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nb"&gt;exec &lt;/span&gt;ds/cilium &lt;span class="nt"&gt;--&lt;/span&gt; cilium-dbg service list &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
| jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] | select(.spec["frontend-address"].ip == "10.96.249.121") | [
    (.spec.id | tostring),
    (.spec["frontend-address"].ip + ":" + (.spec["frontend-address"].port|tostring)),
    .spec.flags.type,
    (.spec.flags.namespace + "/" + .spec.flags.name),
    (.spec["backend-addresses"] | length | tostring)
  ] | @tsv'&lt;/span&gt; | column &lt;span class="nt"&gt;-t&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;8  10.96.249.121:80  ClusterIP  default/demo  2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Backend count is 2; Cilium knows about them. Verify the eBPF map itself agrees with what the agent reports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nb"&gt;exec &lt;/span&gt;ds/cilium &lt;span class="nt"&gt;--&lt;/span&gt; cilium-dbg bpf lb list | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"10.96.249.121"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10.96.249.121:80/TCP (0)    0.0.0.0:0 (8) (0) [ClusterIP, non-routable]
10.96.249.121:80/TCP (1)    10.0.0.52:80/TCP (8) (1)
10.96.249.121:80/TCP (2)    10.0.0.167:80/TCP (8) (2)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;service list&lt;/code&gt; is the agent's view of what it configured. &lt;code&gt;bpf lb list&lt;/code&gt; reads the eBPF maps directly. They usually agree; when they diverge, the agent reports healthy backends the datapath never received. Here they match — the forwarding table is correct. Something else is dropping the packets.&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 3 — check Hubble for packet drops
&lt;/h4&gt;

&lt;p&gt;Cilium enforces NetworkPolicy directly in eBPF, so nothing shows up in iptables. Use Hubble:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; default client &lt;span class="nt"&gt;--&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 5 http://10.96.249.121/ &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nb"&gt;exec &lt;/span&gt;ds/cilium &lt;span class="nt"&gt;--&lt;/span&gt; hubble observe &lt;span class="nt"&gt;--verdict&lt;/span&gt; DROPPED &lt;span class="nt"&gt;--last&lt;/span&gt; 20 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"client|demo"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sep  9 08:07:46.753: default/client:51940 (ID:12800) &amp;lt;&amp;gt; default/demo-864f5f87b9-dccbz:80 (ID:41143) policy-verdict:none INGRESS DENIED (TCP Flags: SYN)
Sep  9 08:07:46.753: default/client:51940 (ID:12800) &amp;lt;&amp;gt; default/demo-864f5f87b9-dccbz:80 (ID:41143) Policy denied DROPPED (TCP Flags: SYN)
Sep  9 08:07:47.777: default/client:51940 (ID:12800) &amp;lt;&amp;gt; default/demo-864f5f87b9-dccbz:80 (ID:41143) policy-verdict:none INGRESS DENIED (TCP Flags: SYN)
Sep  9 08:07:47.777: default/client:51940 (ID:12800) &amp;lt;&amp;gt; default/demo-864f5f87b9-dccbz:80 (ID:41143) Policy denied DROPPED (TCP Flags: SYN)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Policy denied DROPPED&lt;/code&gt;. A NetworkPolicy is blocking the connection. &lt;code&gt;policy-verdict:none INGRESS DENIED&lt;/code&gt; is the same verdict from a different angle. Either of these in &lt;code&gt;hubble observe --verdict DROPPED&lt;/code&gt; points to NetworkPolicy as the cause.&lt;/p&gt;

&lt;h4&gt;
  
  
  Root cause
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get networkpolicy deny-client-to-demo &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.ingress}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"from"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"podSelector"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"matchLabels"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"app"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"not-client"&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;p&gt;Ingress is only allowed from Pods labeled &lt;code&gt;app: not-client&lt;/code&gt;. The &lt;code&gt;client&lt;/code&gt; Pod doesn't qualify.&lt;/p&gt;

&lt;h3&gt;
  
  
  Debug flow summary
&lt;/h3&gt;

&lt;p&gt;The two cases point to this sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;kubectl get endpoints&lt;/code&gt; (or &lt;code&gt;kubectl get endpointslices&lt;/code&gt; from 1.33). &lt;code&gt;&amp;lt;none&amp;gt;&lt;/code&gt; → check selector match and Pod readiness.&lt;/li&gt;
&lt;li&gt;If Endpoints exist, run &lt;code&gt;cilium-dbg service list -o json | jq&lt;/code&gt; and check the backend count. 0 → Cilium doesn't see the backends.&lt;/li&gt;
&lt;li&gt;If backends exist but there's no connectivity, run &lt;code&gt;hubble observe --verdict DROPPED&lt;/code&gt;. &lt;code&gt;Policy denied&lt;/code&gt; → NetworkPolicy is the cause.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tool mapping from kube-proxy to Cilium:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Old tool (kube-proxy)&lt;/th&gt;
&lt;th&gt;New tool (Cilium)&lt;/th&gt;
&lt;th&gt;What it shows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;`iptables-save -t nat \&lt;/td&gt;
&lt;td&gt;grep KUBE-SERVICES`&lt;/td&gt;
&lt;td&gt;`cilium-dbg service list -o json \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;{% raw %}`iptables-save -t filter \&lt;/td&gt;
&lt;td&gt;grep `&lt;/td&gt;
&lt;td&gt;backend count from `cilium-dbg service list -o json \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;{% raw %}&lt;code&gt;conntrack -L&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cilium-dbg bpf ct list global&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Connection tracking table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tcpdump&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hubble observe&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Per-flow packet visibility (see "Packet tracing")&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;(no equivalent)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hubble observe --verdict DROPPED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;NetworkPolicy denials&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;iptables-save&lt;/code&gt; returning 0 lines was not a broken cluster. kube-proxy doesn't exist, so nothing writes forwarding rules there — they moved to eBPF maps.&lt;/p&gt;

&lt;p&gt;To read the forwarding table, use &lt;code&gt;cilium-dbg service list -o json | jq&lt;/code&gt;. One command gets you the ClusterIP, Service name, and backend Pod IPs. The three-hop KUBE-SERVICES → KUBE-SVC → KUBE-SEP walk collapses to one line.&lt;/p&gt;

&lt;p&gt;Parts of the troubleshooting workflow carry over unchanged. &lt;code&gt;kubectl get endpoints&lt;/code&gt; works fine in a Cilium environment. After that, check &lt;code&gt;cilium-dbg service list -o json | jq&lt;/code&gt; for backend count, and if backends are present but connectivity still fails, check &lt;code&gt;hubble observe --verdict DROPPED&lt;/code&gt; for NetworkPolicy denials. The &lt;code&gt;has no endpoints&lt;/code&gt; comment that kube-proxy wrote in the filter table is gone, but a backend count of 0 means the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Appendix — iptables evaluation cost grows with endpoint count
&lt;/h2&gt;

&lt;p&gt;Measured while scaling &lt;code&gt;demo&lt;/code&gt; replicas from 1 to 16. The kube-proxy column shows the number of KUBE-SVC chain rules; the Cilium column shows LB map backend slots.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;replicas   kube-proxy SVC rules   Cilium backend slots
1          2                      1
2          3                      2
4          5                      4
8          9                      8
16         17                     16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both grow linearly with endpoint count. The difference is lookup cost. The table shows equal growth in entry count; the asymmetry is in how those entries are evaluated per new connection.&lt;/p&gt;

&lt;p&gt;kube-proxy evaluates KUBE-SVC chain rules top to bottom to pick a backend. Each rule's &lt;code&gt;--probability&lt;/code&gt; check either hits or falls through to the next. When the last endpoint is selected, every rule before it was evaluated first. With 16 replicas, up to 16 evaluations happen. This only applies to the first packet of a new connection — subsequent packets are handled by conntrack and skip the rules entirely. The impact shows up in environments with high new-connection rates and many Services.&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="nt"&gt;-A&lt;/span&gt; KUBE-SVC-73JNOU5FOFXIWZLE &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo -&amp;gt; 10.244.0.10:80"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; statistic &lt;span class="nt"&gt;--mode&lt;/span&gt; random &lt;span class="nt"&gt;--probability&lt;/span&gt; 0.06250000000 &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SEP-...
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SVC-73JNOU5FOFXIWZLE &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo -&amp;gt; 10.244.0.11:80"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; statistic &lt;span class="nt"&gt;--mode&lt;/span&gt; random &lt;span class="nt"&gt;--probability&lt;/span&gt; 0.06666666688 &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SEP-...
...
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SVC-73JNOU5FOFXIWZLE &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo -&amp;gt; 10.244.0.9:80"&lt;/span&gt;  &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SEP-...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cilium computes &lt;code&gt;random() % N&lt;/code&gt; to get a slot number and does one map lookup to find the backend. Slot 0 is the service master entry; slots 1+ are backends. With 16 Pods, it's still one lookup. This slot format is &lt;code&gt;cilium-dbg bpf lb list&lt;/code&gt; output — it reads the eBPF maps directly, not the agent's in-memory state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nb"&gt;exec &lt;/span&gt;ds/cilium &lt;span class="nt"&gt;--&lt;/span&gt; cilium-dbg bpf lb list | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"10.96.210.178"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10.96.210.178:80/TCP (0)    0.0.0.0:0 (8) (0) [ClusterIP, non-routable]
10.96.210.178:80/TCP (1)    10.0.0.207:80/TCP (8) (1)
10.96.210.178:80/TCP (2)    10.0.0.48:80/TCP (8) (2)
...
10.96.210.178:80/TCP (16)   10.0.0.187:80/TCP (8) (16)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>kubernetes</category>
      <category>networking</category>
      <category>ebpf</category>
      <category>cilium</category>
    </item>
    <item>
      <title>Migrating from ingress-nginx to Gateway API: a three-category approach to annotations</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 08 Sep 2026 13:02:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/migrating-from-ingress-nginx-to-gateway-api-a-three-category-approach-to-annotations-5fd</link>
      <guid>https://dev.to/shinagawa-web/migrating-from-ingress-nginx-to-gateway-api-a-three-category-approach-to-annotations-5fd</guid>
      <description>&lt;p&gt;&lt;a href="https://www.kubernetes.dev/blog/2025/11/12/ingress-nginx-retirement/" rel="noopener noreferrer"&gt;The Kubernetes project announced the retirement of ingress-nginx&lt;/a&gt;. ingress-nginx retires in March 2026. No more patches, no more updates. No successor was named — just a recommendation to migrate to Gateway API.&lt;/p&gt;

&lt;p&gt;The reason is straightforward: not enough maintainers. For years, effectively one or two people had kept the project running in their spare time. After the retirement announcement, there were plans to develop a successor controller (InGate) in collaboration with the Gateway API community, but no contributors came forward, and InGate was retired as well. No successor was named because none could be named.&lt;/p&gt;

&lt;p&gt;Existing deployments won't break overnight. Helm charts and container images remain usable. But security patches stop. When new vulnerabilities are found, they'll go unfixed. The system keeps running while the risk accumulates slowly.&lt;/p&gt;

&lt;p&gt;ingress-nginx had a kind of self-evidence: "if you need HTTP in Kubernetes, use this." Gateway API dissolves that.&lt;/p&gt;

&lt;p&gt;This article covers how to choose a Gateway API implementation and how to migrate ingress-nginx annotations by sorting them into three categories — with before/after manifests throughout.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "migrate to Gateway API" actually means
&lt;/h2&gt;

&lt;p&gt;Gateway API is a Kubernetes specification. It is not software.&lt;/p&gt;

&lt;p&gt;In the Ingress era, HTTP routing configuration looked like this:&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;networking.k8s.io/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;Ingress&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;my-ingress&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;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app.example.com&lt;/span&gt;
    &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&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;/&lt;/span&gt;
        &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
        &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;service&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;my-service&lt;/span&gt;
            &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;number&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;p&gt;Apply this YAML and it worked. Assuming ingress-nginx was installed in the cluster — but since "using Kubernetes Ingress" and "using ingress-nginx" were nearly synonymous, that distinction rarely came up. Spec and implementation mapped almost one-to-one.&lt;/p&gt;

&lt;p&gt;Gateway API is different. Writing an &lt;code&gt;HTTPRoute&lt;/code&gt; does nothing unless you have separate software installed that reads those resources and actually routes requests.&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;gateway.networking.k8s.io/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;HTTPRoute&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;my-route&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;parentRefs&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;my-gateway&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matches&lt;/span&gt;&lt;span class="pi"&gt;:&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="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PathPrefix&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;/&lt;/span&gt;
    &lt;span class="na"&gt;backendRefs&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;my-service&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply this YAML without a Gateway controller installed in the cluster, and nothing happens. Gateway API is a spec defining what resources exist — it's not a process that handles traffic.&lt;/p&gt;

&lt;p&gt;"Migrating to Gateway API" meant picking an implementation and installing it. The question of which software was actually running — obvious in the Ingress world — is now something you have to decide yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Too many implementations
&lt;/h2&gt;

&lt;p&gt;"Pick one" sounds simple, but the options are many. In the Envoy-based family alone: Envoy Gateway, kgateway, Istio, Contour, Cilium. Outside Envoy: Traefik, Kong, NGINX Gateway Fabric. Cloud providers each offer their own. And all of them claim "Gateway API conformant."&lt;/p&gt;

&lt;p&gt;It's tempting to think: "if they're all conformant, they'll behave the same within the standard, and I can switch later." That doesn't hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three reasons "conformant" isn't uniform
&lt;/h2&gt;

&lt;p&gt;First, conformance levels vary. Gateway API features are divided into three tiers: Core, Extended, and Implementation-specific. Core is the minimum every implementation must support. Extended is "recommended but optional." Implementation-specific is the open space for vendor extensions. Claiming "Gateway API conformant" only requires Core support. Extended and above vary across implementations.&lt;/p&gt;

&lt;p&gt;Second, the features you actually need in production tend to land in vendor extensions. Auth, rate limiting, circuit breaking — these commonly belong to Extended or Implementation-specific. Envoy Gateway uses a &lt;code&gt;SecurityPolicy&lt;/code&gt; CRD; Istio uses &lt;code&gt;AuthorizationPolicy&lt;/code&gt;. The moment you write one of those resources, you're locked into that implementation.&lt;/p&gt;

&lt;p&gt;Third, there's no default. When ingress-nginx was around, there was a clear first choice. Now there isn't. Every team is being forced to make that choice simultaneously, without a settled answer.&lt;/p&gt;

&lt;p&gt;The first pick carries weight. The comparison below shows why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparing the major implementations
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Envoy Gateway&lt;/th&gt;
&lt;th&gt;Istio&lt;/th&gt;
&lt;th&gt;Traefik&lt;/th&gt;
&lt;th&gt;NGINX Gateway Fabric&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Data plane&lt;/td&gt;
&lt;td&gt;Envoy&lt;/td&gt;
&lt;td&gt;Envoy&lt;/td&gt;
&lt;td&gt;Traefik&lt;/td&gt;
&lt;td&gt;nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service mesh support&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migration ease from ingress-nginx&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extended feature coverage&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Development momentum&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Istio is the only one with full service mesh support — east-west traffic control and sidecar injection included. The other three have no mesh capabilities. NGINX Gateway Fabric scores highest on migration ease because it shares the same nginx data plane. Istio scores lower because installing the entire service mesh stack is a prerequisite. The difference in Extended feature coverage becomes concrete once you read the Category B before/after examples. For development momentum, Envoy Gateway (CNCF incubating) and Istio (CNCF graduated) lead, while NGINX Gateway Fabric has a lower release cadence.&lt;/p&gt;

&lt;p&gt;No recommendation here — the right answer depends on whether you already have a service mesh, how you weigh the annotation rewrite cost, and whether your team has Envoy operational experience. But before picking an implementation, there's work to do: inventory your current ingress-nginx annotations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sorting annotations into three categories
&lt;/h2&gt;

&lt;p&gt;The before/after manifests and validation scripts used in this article are at &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api" rel="noopener noreferrer"&gt;shinagawa-web/ingress-nginx-to-gateway-api&lt;/a&gt;. &lt;code&gt;before/&lt;/code&gt; holds the ingress-nginx manifests, &lt;code&gt;after/category-a/&lt;/code&gt; the standard Gateway API equivalents, and &lt;code&gt;after/category-b-envoy-gateway/&lt;/code&gt; and &lt;code&gt;after/category-b-istio/&lt;/code&gt; the implementation-specific ones.&lt;/p&gt;

&lt;p&gt;Envoy Gateway and Istio were chosen for the Category B manifests because they have broad coverage of features that can't be expressed in the standard spec — external auth, rate limiting, and so on. Traefik and NGINX Gateway Fabric can handle basic routing just as well, but their vendor extensions are less mature. Putting Envoy-based (Envoy Gateway) next to service-mesh-capable (Istio) also makes the architectural difference visible. Links to the relevant files appear at the end of each category section so you can run them locally as you read.&lt;/p&gt;

&lt;p&gt;The goal is to inventory every annotation you have and sort each into one of three categories. The approach differs fundamentally by category. Note that Category C is evaluated line by line within the annotation, not annotation by annotation. A single &lt;code&gt;configuration-snippet&lt;/code&gt; can contain lines that fall into Category A and lines that require Category B.&lt;/p&gt;

&lt;h3&gt;
  
  
  Category A: migrates directly to standard Gateway API
&lt;/h3&gt;

&lt;h4&gt;
  
  
  rewrite
&lt;/h4&gt;

&lt;p&gt;A rewrite is the process of rewriting the URL path before the request reaches the backend. If a client hits &lt;code&gt;/api/hello&lt;/code&gt; and the app should receive &lt;code&gt;/hello&lt;/code&gt;, that's a rewrite. In ingress-nginx, you'd use regex capture groups.&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;# before/02-rewrite.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;networking.k8s.io/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;Ingress&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;rewrite&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;nginx.ingress.kubernetes.io/rewrite-target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/$2&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/use-regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&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;ingressClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app.example.com&lt;/span&gt;
    &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&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;/api(/|$)(.*)&lt;/span&gt;
        &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ImplementationSpecific&lt;/span&gt;
        &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;service&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;echo-v1&lt;/span&gt;
            &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;number&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;p&gt;The pattern &lt;code&gt;/api(/|$)(.*)&lt;/code&gt; captures the second group (&lt;code&gt;$2&lt;/code&gt;) and forwards it as the path. A request to &lt;code&gt;/api/hello&lt;/code&gt; puts &lt;code&gt;hello&lt;/code&gt; into &lt;code&gt;$2&lt;/code&gt;, delivering &lt;code&gt;/hello&lt;/code&gt; to the backend.&lt;/p&gt;

&lt;p&gt;Gateway API expresses the same thing with the &lt;code&gt;URLRewrite&lt;/code&gt; filter in &lt;code&gt;HTTPRoute&lt;/code&gt;. (&lt;code&gt;URLRewrite&lt;/code&gt; is an Extended-tier feature — not Core. Verify your chosen implementation supports it; the four implementations in this article's comparison table all do.)&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;# after/category-a/02-rewrite.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;gateway.networking.k8s.io/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;HTTPRoute&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;rewrite&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;parentRefs&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;eg&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matches&lt;/span&gt;&lt;span class="pi"&gt;:&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="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PathPrefix&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;/api&lt;/span&gt;
    &lt;span class="na"&gt;filters&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;URLRewrite&lt;/span&gt;
      &lt;span class="na"&gt;urlRewrite&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="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ReplacePrefixMatch&lt;/span&gt;
          &lt;span class="na"&gt;replacePrefixMatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
    &lt;span class="na"&gt;backendRefs&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;echo-v1&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it as: replace the &lt;code&gt;/api&lt;/code&gt; prefix with &lt;code&gt;/&lt;/code&gt;. No regex — prefix replacement is explicit.&lt;/p&gt;

&lt;h4&gt;
  
  
  canary
&lt;/h4&gt;

&lt;p&gt;Canary deployment means routing a small percentage of traffic to a new version, then gradually increasing it rather than doing an immediate cutover. In ingress-nginx, this required two Ingress resources: a primary route to v1 and a canary route to v2, with the canary's weight set in an annotation.&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;# before/03-canary.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;networking.k8s.io/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;Ingress&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;canary-primary&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;ingressClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app.example.com&lt;/span&gt;
    &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&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;/&lt;/span&gt;
        &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
        &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;service&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;echo-v1&lt;/span&gt;
            &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&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;networking.k8s.io/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;Ingress&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;canary&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;nginx.ingress.kubernetes.io/canary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/canary-weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;20"&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;ingressClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app.example.com&lt;/span&gt;
    &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&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;/&lt;/span&gt;
        &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
        &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;service&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;echo-v2&lt;/span&gt;
            &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;number&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;p&gt;Two Ingress resources define the same &lt;code&gt;host: app.example.com&lt;/code&gt; and path &lt;code&gt;/&lt;/code&gt;. Normally that's a conflict, but ingress-nginx treats any Ingress with &lt;code&gt;canary: "true"&lt;/code&gt; as a weighted companion to the other. &lt;code&gt;canary-weight: "20"&lt;/code&gt; sends 20% of traffic to echo-v2.&lt;/p&gt;

&lt;p&gt;One routing decision split across two resources is the problem. Reading &lt;code&gt;canary-primary&lt;/code&gt; alone gives you no visibility into the actual traffic split.&lt;/p&gt;

&lt;p&gt;Gateway API fits it into a single &lt;code&gt;HTTPRoute&lt;/code&gt;.&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;# after/category-a/03-canary.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;gateway.networking.k8s.io/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;HTTPRoute&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;canary&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;parentRefs&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;eg&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;backendRefs&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;echo-v1&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;weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&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;echo-v2&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;weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;List v1 and v2 in &lt;code&gt;backendRefs&lt;/code&gt; with &lt;code&gt;weight&lt;/code&gt; ratios. 80:20 sends 80% to v1, 20% to v2. One resource, full picture.&lt;/p&gt;

&lt;p&gt;The conversions in this category can be automated with the &lt;a href="https://github.com/kubernetes-sigs/ingress2gateway" rel="noopener noreferrer"&gt;Ingress2Gateway CLI&lt;/a&gt; (1.0 GA). Worth using to reduce manual work.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/before/02-rewrite.yaml" rel="noopener noreferrer"&gt;before/02-rewrite.yaml&lt;/a&gt; → &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/after/category-a/02-rewrite.yaml" rel="noopener noreferrer"&gt;after/category-a/02-rewrite.yaml&lt;/a&gt;, &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/before/03-canary.yaml" rel="noopener noreferrer"&gt;before/03-canary.yaml&lt;/a&gt; → &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/after/category-a/03-canary.yaml" rel="noopener noreferrer"&gt;after/category-a/03-canary.yaml&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Category B: requires implementation-specific extensions
&lt;/h3&gt;

&lt;h4&gt;
  
  
  external auth
&lt;/h4&gt;

&lt;p&gt;External auth delegates authentication decisions to a separate service rather than the application itself. On each incoming request, the proxy asks the auth service whether to allow it. Only when the auth service responds OK does the request proceed to the app. Because the auth logic lives outside the app, the same auth service can cover multiple apps.&lt;/p&gt;

&lt;p&gt;In ingress-nginx, two annotation lines did it.&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;# before/04-auth.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;networking.k8s.io/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;Ingress&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;auth&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;nginx.ingress.kubernetes.io/auth-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://auth-service.default.svc.cluster.local/auth&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/auth-response-headers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;X-Auth-User&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;ingressClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app.example.com&lt;/span&gt;
    &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&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;/protected&lt;/span&gt;
        &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
        &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;service&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;echo-v1&lt;/span&gt;
            &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;number&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;p&gt;&lt;code&gt;auth-url&lt;/code&gt; is the auth service endpoint. &lt;code&gt;auth-response-headers&lt;/code&gt; lists headers from the auth service response to forward to the backend. When the auth service returns 200, the specified headers are added to the upstream request. Two annotation lines cover delegation and response propagation.&lt;/p&gt;

&lt;p&gt;The sample repository includes a mock auth service in &lt;code&gt;app/auth-service.yaml&lt;/code&gt; — a Python server that returns 200 for &lt;code&gt;Authorization: Bearer valid-token&lt;/code&gt; and 401 for everything else.&lt;/p&gt;

&lt;p&gt;With Envoy Gateway, &lt;code&gt;HTTPRoute&lt;/code&gt; alone has no place to write auth configuration. You create a separate &lt;code&gt;SecurityPolicy&lt;/code&gt; CRD — an Envoy Gateway-specific resource — and bind it to the target &lt;code&gt;HTTPRoute&lt;/code&gt;.&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;# after/category-b-envoy-gateway/04-auth.yaml (excerpt)&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;gateway.envoyproxy.io/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;SecurityPolicy&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;auth&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;targetRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io&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;HTTPRoute&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;protected&lt;/span&gt;
  &lt;span class="na"&gt;extAuth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;backendRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&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;Service&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;auth-service&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;headersToBackend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;x-auth-user&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;targetRefs&lt;/code&gt; specifies which &lt;code&gt;HTTPRoute&lt;/code&gt; the policy applies to — here, the &lt;code&gt;HTTPRoute&lt;/code&gt; named &lt;code&gt;protected&lt;/code&gt;. &lt;code&gt;extAuth.http.backendRefs&lt;/code&gt; points to the auth service.&lt;/p&gt;

&lt;p&gt;The intent is clearer than in ingress-nginx. Annotations were scoped to their Ingress resource, so tracking which route had which auth policy meant reading every Ingress. &lt;code&gt;SecurityPolicy&lt;/code&gt; stands as an independent resource with its target made explicit via &lt;code&gt;targetRefs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To forward &lt;code&gt;X-Auth-User&lt;/code&gt; from the auth service response to the backend, &lt;code&gt;headersToBackend&lt;/code&gt; must be configured explicitly. Unlike ingress-nginx's &lt;code&gt;auth-response-headers&lt;/code&gt;, Envoy Gateway does not forward auth response headers automatically — only the headers listed in &lt;code&gt;headersToBackend&lt;/code&gt; are propagated.&lt;/p&gt;

&lt;p&gt;Migrating to Istio changes the architecture. &lt;code&gt;AuthorizationPolicy&lt;/code&gt; evaluates request attributes locally — headers, JWT claims, source principals — rather than calling out to an external service.&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;# after/category-b-istio/04-auth.yaml (excerpt)&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;security.istio.io/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;AuthorizationPolicy&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;auth&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;echo&lt;/span&gt;
      &lt;span class="na"&gt;version&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;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DENY&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;when&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;request.headers[authorization]&lt;/span&gt;
      &lt;span class="na"&gt;notValues&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;Bearer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;valid-token"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;action: DENY&lt;/code&gt; means "deny requests that match the condition." Requests where &lt;code&gt;request.headers[authorization]&lt;/code&gt; is not &lt;code&gt;Bearer valid-token&lt;/code&gt; are denied — only valid-token requests get through. Note that &lt;code&gt;selector.matchLabels&lt;/code&gt; scopes the policy to the workload, not to the &lt;code&gt;/protected&lt;/code&gt; path — the DENY rule applies to all traffic reaching the matched pods. To restrict it to a specific path, add &lt;code&gt;to.operation.paths&lt;/code&gt; to the rule. Where Envoy Gateway's &lt;code&gt;extAuth&lt;/code&gt; asks an external service for an OK/NG decision, Istio's &lt;code&gt;AuthorizationPolicy&lt;/code&gt; evaluates the policy inside the cluster. The mechanism is fundamentally different from ingress-nginx's &lt;code&gt;auth-url&lt;/code&gt; callback model, so migrating to Istio means rethinking the auth design.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AuthorizationPolicy&lt;/code&gt; evaluates request attributes locally. For logic that requires an external decision — like checking permissions against a database — Istio provides &lt;code&gt;AuthorizationPolicy&lt;/code&gt; with &lt;code&gt;action: CUSTOM&lt;/code&gt; plus an &lt;code&gt;extensionProviders&lt;/code&gt; entry in &lt;code&gt;meshConfig&lt;/code&gt;. This is the proper external auth delegation path in Istio, comparable in ergonomics to Envoy Gateway's &lt;code&gt;extAuth&lt;/code&gt;. The &lt;code&gt;EnvoyFilter&lt;/code&gt; route can also be used but writes Envoy internals directly.&lt;/p&gt;

&lt;p&gt;Category A works the same way regardless of which implementation you choose. Category B changes fundamentally depending on which one you picked. This is the "operational features drift into vendor extensions" reality the comparison table pointed to: you can't form a migration plan for this category until you've chosen an implementation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/before/04-auth.yaml" rel="noopener noreferrer"&gt;before/04-auth.yaml&lt;/a&gt; → &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/after/category-b-envoy-gateway/04-auth.yaml" rel="noopener noreferrer"&gt;after/category-b-envoy-gateway/04-auth.yaml&lt;/a&gt; / &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/after/category-b-istio/04-auth.yaml" rel="noopener noreferrer"&gt;after/category-b-istio/04-auth.yaml&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  rate limit
&lt;/h4&gt;

&lt;p&gt;ingress-nginx's rate limit was self-contained in annotations, but there's a trap: queueing behavior changes after migration.&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;# before/05-rate-limit.yaml (excerpt)&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;nginx.ingress.kubernetes.io/limit-rps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;
  &lt;span class="na"&gt;nginx.ingress.kubernetes.io/limit-burst-multiplier&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;limit-rps: "2"&lt;/code&gt; allows at most 2 requests per second per IP. Requests over the limit don't get rejected immediately — they queue up to the limit set by &lt;code&gt;limit-burst-multiplier&lt;/code&gt;. With &lt;code&gt;limit-burst-multiplier: "1"&lt;/code&gt;, the queue cap is &lt;code&gt;limit-rps × 1 = 2&lt;/code&gt;. Once the queue fills, subsequent requests get a 503 immediately.&lt;/p&gt;

&lt;p&gt;Envoy Gateway uses &lt;code&gt;BackendTrafficPolicy&lt;/code&gt;. Like &lt;code&gt;SecurityPolicy&lt;/code&gt; for external auth, it's a separate resource from &lt;code&gt;HTTPRoute&lt;/code&gt;, with &lt;code&gt;targetRefs&lt;/code&gt; pointing at the target.&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;# after/category-b-envoy-gateway/05-rate-limit.yaml (excerpt)&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;gateway.envoyproxy.io/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;BackendTrafficPolicy&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;rate-limit&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;targetRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io&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;HTTPRoute&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;rate-limited&lt;/span&gt;
  &lt;span class="na"&gt;rateLimit&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;Local&lt;/span&gt;
    &lt;span class="na"&gt;local&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;limit&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="m"&gt;2&lt;/span&gt;
          &lt;span class="na"&gt;unit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Second&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;requests: 2, unit: Second&lt;/code&gt; expresses 2 requests per second, but there's no field corresponding to &lt;code&gt;limit-burst-multiplier&lt;/code&gt;. The queueing behavior can't be reproduced.&lt;/p&gt;

&lt;p&gt;Istio configures the token bucket directly via &lt;code&gt;EnvoyFilter&lt;/code&gt;. There's no abstraction layer like &lt;code&gt;BackendTrafficPolicy&lt;/code&gt; — you're writing Envoy internals directly.&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;# after/category-b-istio/05-rate-limit.yaml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;token_bucket&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;max_tokens&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;tokens_per_fill&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;fill_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tokens_per_fill: 2, fill_interval: 1s&lt;/code&gt; replenishes 2 tokens per second (= 2 req/s). &lt;code&gt;max_tokens: 2&lt;/code&gt; is the burst cap — the maximum number of tokens the bucket can hold. This corresponds to ingress-nginx's &lt;code&gt;limit-rps: "2", limit-burst-multiplier: "1"&lt;/code&gt;. The algorithm differs from nginx's queuing model, but the behavior — 2 requests per second, burst cap of 2 — is equivalent. Whether reproducing &lt;code&gt;limit-burst-multiplier&lt;/code&gt; is worth trading against implementation choice is a judgment call that varies by team.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/before/05-rate-limit.yaml" rel="noopener noreferrer"&gt;before/05-rate-limit.yaml&lt;/a&gt; → &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/after/category-b-envoy-gateway/05-rate-limit.yaml" rel="noopener noreferrer"&gt;after/category-b-envoy-gateway/05-rate-limit.yaml&lt;/a&gt; / &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/after/category-b-istio/05-rate-limit.yaml" rel="noopener noreferrer"&gt;after/category-b-istio/05-rate-limit.yaml&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Category C: no equivalent
&lt;/h3&gt;

&lt;h4&gt;
  
  
  configuration-snippet
&lt;/h4&gt;

&lt;p&gt;Probably the most useful annotation ingress-nginx had — and the most problematic.&lt;/p&gt;

&lt;p&gt;nginx config has &lt;code&gt;server&lt;/code&gt; blocks and &lt;code&gt;location&lt;/code&gt; blocks. &lt;code&gt;configuration-snippet&lt;/code&gt; lets you inject nginx directives directly into the &lt;code&gt;location&lt;/code&gt; block, overriding or extending what ingress-nginx generates. It was effectively an escape hatch for anything the standard annotations couldn't express.&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;# before/06-configuration-snippet.yaml (excerpt)&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;nginx.ingress.kubernetes.io/configuration-snippet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;more_set_headers "X-Custom-Header: my-value";&lt;/span&gt;
    &lt;span class="s"&gt;more_set_headers "X-Request-ID: $request_id";&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;more_set_headers&lt;/code&gt; is a directive from the headers-more module bundled with ingress-nginx, used to add or override response headers. &lt;code&gt;"X-Custom-Header: my-value"&lt;/code&gt; adds a static header. &lt;code&gt;"X-Request-ID: $request_id"&lt;/code&gt; attaches nginx's built-in variable &lt;code&gt;$request_id&lt;/code&gt; — a unique ID auto-generated per request — as a response header.&lt;/p&gt;

&lt;p&gt;Gateway API has no direct equivalent. But saying "no equivalent" oversimplifies it. Break down the annotation line by line, and the picture splits.&lt;/p&gt;

&lt;p&gt;Adding a static header like &lt;code&gt;X-Custom-Header&lt;/code&gt; migrates to &lt;code&gt;HTTPRoute&lt;/code&gt;'s &lt;code&gt;ResponseHeaderModifier&lt;/code&gt; filter. That's standard spec — Category A.&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;# after/category-a/06-response-header.yaml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;filters&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;ResponseHeaderModifier&lt;/span&gt;
  &lt;span class="na"&gt;responseHeaderModifier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;add&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;X-Custom-Header&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;my-value&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dynamic values like &lt;code&gt;$request_id&lt;/code&gt; are a different story. Nothing in the Gateway API standard covers referencing nginx built-in variables. With Envoy Gateway, you can reproduce it by injecting a Lua script via the &lt;code&gt;EnvoyPatchPolicy&lt;/code&gt; CRD — Category B.&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;# after/category-b-envoy-gateway/06-request-id.yaml (excerpt)&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;gateway.envoyproxy.io/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;EnvoyPatchPolicy&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;request-id-lua&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;targetRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io&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;Gateway&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;eg&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;JSONPatch&lt;/span&gt;
  &lt;span class="na"&gt;jsonPatches&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type.googleapis.com/envoy.config.listener.v3.Listener"&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default/eg/http"&lt;/span&gt;
    &lt;span class="na"&gt;operation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;add&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/default_filter_chain/filters/0/typed_config/http_filters/0"&lt;/span&gt;
      &lt;span class="na"&gt;value&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;envoy.filters.http.lua&lt;/span&gt;
        &lt;span class="na"&gt;typed_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@type"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua"&lt;/span&gt;
          &lt;span class="na"&gt;default_source_code&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;inline_string&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
              &lt;span class="s"&gt;function envoy_on_request(request_handle)&lt;/span&gt;
                &lt;span class="s"&gt;local req_id = request_handle:headers():get("x-request-id")&lt;/span&gt;
                &lt;span class="s"&gt;if req_id then&lt;/span&gt;
                  &lt;span class="s"&gt;request_handle:streamInfo():dynamicMetadata():set("lua_ns", "req_id", req_id)&lt;/span&gt;
                &lt;span class="s"&gt;end&lt;/span&gt;
              &lt;span class="s"&gt;end&lt;/span&gt;
              &lt;span class="s"&gt;function envoy_on_response(response_handle)&lt;/span&gt;
                &lt;span class="s"&gt;local meta = response_handle:streamInfo():dynamicMetadata():get("lua_ns")&lt;/span&gt;
                &lt;span class="s"&gt;if meta and meta["req_id"] then&lt;/span&gt;
                  &lt;span class="s"&gt;response_handle:headers():add("x-request-id", meta["req_id"])&lt;/span&gt;
                &lt;span class="s"&gt;end&lt;/span&gt;
              &lt;span class="s"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Envoy generates an &lt;code&gt;x-request-id&lt;/code&gt; header automatically on incoming requests. The Lua script reads it during request processing, stores it in metadata, then attaches it as a response header during response processing. nginx's &lt;code&gt;$request_id&lt;/code&gt; becomes Envoy's request ID, but the intent — a unique per-request ID on the response header — is the same.&lt;/p&gt;

&lt;p&gt;The same Lua logic works in Istio via &lt;code&gt;EnvoyFilter&lt;/code&gt;, but the apply context and metadata namespace differ.&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;# after/category-b-istio/06-request-id.yaml (excerpt)&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;networking.istio.io/v1alpha3&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;EnvoyFilter&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;request-id-response-header&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;configPatches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;applyTo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTP_FILTER&lt;/span&gt;
    &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GATEWAY&lt;/span&gt;
      &lt;span class="na"&gt;listener&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;filterChain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;filter&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;envoy.filters.network.http_connection_manager&lt;/span&gt;
            &lt;span class="na"&gt;subFilter&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;envoy.filters.http.router&lt;/span&gt;
    &lt;span class="na"&gt;patch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;operation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;INSERT_BEFORE&lt;/span&gt;
      &lt;span class="na"&gt;value&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;envoy.filters.http.lua&lt;/span&gt;
        &lt;span class="na"&gt;typed_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@type"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua&lt;/span&gt;
          &lt;span class="s"&gt;default_source_code&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;inline_string&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
              &lt;span class="s"&gt;function envoy_on_request(request_handle)&lt;/span&gt;
                &lt;span class="s"&gt;local req_id = request_handle:headers():get("x-request-id")&lt;/span&gt;
                &lt;span class="s"&gt;if req_id then&lt;/span&gt;
                  &lt;span class="s"&gt;request_handle:streamInfo():dynamicMetadata():set("lua_metadata", "req_id", req_id)&lt;/span&gt;
                &lt;span class="s"&gt;end&lt;/span&gt;
              &lt;span class="s"&gt;end&lt;/span&gt;
              &lt;span class="s"&gt;function envoy_on_response(response_handle)&lt;/span&gt;
                &lt;span class="s"&gt;local meta = response_handle:streamInfo():dynamicMetadata():get("lua_metadata")&lt;/span&gt;
                &lt;span class="s"&gt;if meta and meta["req_id"] then&lt;/span&gt;
                  &lt;span class="s"&gt;response_handle:headers():add("x-request-id", meta["req_id"])&lt;/span&gt;
                &lt;span class="s"&gt;end&lt;/span&gt;
              &lt;span class="s"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Envoy Gateway version uses &lt;code&gt;EnvoyPatchPolicy&lt;/code&gt; with JSONPatch to insert the Lua filter at the Listener level, with state stored under metadata namespace &lt;code&gt;lua_ns&lt;/code&gt;. The Istio version uses &lt;code&gt;EnvoyFilter&lt;/code&gt; with &lt;code&gt;applyTo: HTTP_FILTER&lt;/code&gt; and &lt;code&gt;match.context: GATEWAY&lt;/code&gt;, with state stored under &lt;code&gt;lua_metadata&lt;/code&gt;. The Lua logic is the same, but the configuration layer differs.&lt;/p&gt;

&lt;p&gt;What truly has no equivalent in Category C is nginx-specific functionality that &lt;code&gt;EnvoyPatchPolicy&lt;/code&gt; can't reach — directives that depend deeply on nginx internals, or snippets that assume concepts Envoy doesn't have. In practice, most &lt;code&gt;configuration-snippet&lt;/code&gt; contents, when broken down line by line, land in Category A or Category B. The annotation as a whole isn't Category C — each line has to be read and evaluated individually.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/before/06-configuration-snippet.yaml" rel="noopener noreferrer"&gt;before/06-configuration-snippet.yaml&lt;/a&gt; → &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/after/category-a/06-response-header.yaml" rel="noopener noreferrer"&gt;after/category-a/06-response-header.yaml&lt;/a&gt; (X-Custom-Header) / &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/after/category-b-envoy-gateway/06-request-id.yaml" rel="noopener noreferrer"&gt;after/category-b-envoy-gateway/06-request-id.yaml&lt;/a&gt; (X-Request-ID, Envoy Gateway) / &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/after/category-b-istio/06-request-id.yaml" rel="noopener noreferrer"&gt;after/category-b-istio/06-request-id.yaml&lt;/a&gt; (X-Request-ID, Istio)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Standardization didn't make migration easier
&lt;/h2&gt;

&lt;p&gt;Gateway API gave us a unified spec, so "the interface is now shared." But more implementations means more decisions. The Core tier is consistent across implementations. But operational features drift into vendor extensions, so locking in to an implementation comes after, not before.&lt;/p&gt;

&lt;p&gt;The frustration with ingress-nginx — "settings are scattered across annotations, hard to see the structure" — was a real problem. Gateway API was designed to address it. But from the migration side, new decisions piled on: which software to pick, which annotations map to the standard and which need vendor extensions and which get dropped.&lt;/p&gt;

&lt;p&gt;"Migrate to Gateway API" wasn't a work order. It compressed two separate jobs into one phrase: choose an implementation, then classify each annotation into one of three categories and handle each accordingly.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>networking</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Inspect HTTPS Traffic Without a Proxy — Built with Go and eBPF</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 01 Sep 2026 12:01:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/inspect-https-traffic-without-a-proxy-built-with-go-and-ebpf-4e92</link>
      <guid>https://dev.to/shinagawa-web/inspect-https-traffic-without-a-proxy-built-with-go-and-ebpf-4e92</guid>
      <description>&lt;p&gt;I built a CLI in Go that reads HTTPS traffic as plaintext — no CA certificate installation, no proxy, and no changes to the target app.&lt;/p&gt;

&lt;p&gt;The original motivation was simple: spinning up mitmproxy every time I needed to debug something added one too many steps. I found an approach using eBPF, wrapped it into a CLI, and called it &lt;a href="https://github.com/shinagawa-web/tinytap" rel="noopener noreferrer"&gt;tinytap&lt;/a&gt;. This article is about how I built it.&lt;/p&gt;

&lt;p&gt;Here's what it looks like in action.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvngwl5r0x1wmosduij8a.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvngwl5r0x1wmosduij8a.gif" alt="The left terminal runs an app that sends one HTTPS request. The right terminal shows tinytap printing that request as plaintext — request line, headers, Authorization: Bearer token, JSON body." width="800" height="517"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two terminals side by side. The left sends one HTTPS request; the right shows tinytap printing it in plaintext — request line, headers, Authorization token, JSON body, all of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it captures
&lt;/h2&gt;

&lt;p&gt;tinytap targets apps like this — ordinary code that hits an API, in this case the OpenAI API from Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sk-...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://127.0.0.1:8443/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;what is a uprobe&lt;/span&gt;&lt;span class="sh"&gt;"&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;And here's what tinytap captures and outputs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /v1/chat/completions HTTP/1.1
Host: 127.0.0.1:8443
Authorization: Bearer sk-demo-xxxx
Content-Type: application/json
User-Agent: OpenAI/Python 2.53.0
...
Request body (decoded): {"messages":[{"role":"user","content":"what is a uprobe"}],"model":"gpt-4o-mini"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Request line, all headers, the &lt;code&gt;Authorization: Bearer&lt;/code&gt; token, and the JSON body. The token in the gif is &lt;code&gt;sk-demo-xxxx&lt;/code&gt; — a dummy — but when you point tinytap at a real app, the real token shows up exactly like that. No reformatting.&lt;/p&gt;

&lt;p&gt;Nothing on the app side was touched. One command: &lt;code&gt;tinytap&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How eBPF makes this work
&lt;/h2&gt;

&lt;p&gt;A uprobe is a kernel mechanism for dynamically hooking into user-space functions. tinytap attaches a uprobe to &lt;code&gt;SSL_write&lt;/code&gt; in libssl. The moment that function is called, tinytap reads the argument buffer — which is still plaintext at that point. The data travels through a ring buffer to user space and gets displayed in the TUI. No modifications to the app or to libssl.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not breaking TLS
&lt;/h2&gt;

&lt;p&gt;One thing worth clearing up.&lt;/p&gt;

&lt;p&gt;tinytap only looks at the buffer your own process hands to the TLS library — the moment before it gets encrypted. It is not a vulnerability that lets you intercept traffic on the network. What flows over the wire is properly encrypted. Other processes and other people's traffic are not accessible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;App
  │  plaintext
  ▼
TLS library  ◀── tinytap looks here
  │  ciphertext
  ▼
Network
  │  ciphertext
  ▼
API server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's the same idea as &lt;code&gt;curl&lt;/code&gt; printing its own request to your terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Go
&lt;/h2&gt;

&lt;p&gt;A few reasons I chose Go.&lt;/p&gt;

&lt;p&gt;First, &lt;a href="https://github.com/cilium/ebpf" rel="noopener noreferrer"&gt;cilium/ebpf&lt;/a&gt;. It's a mature library for managing eBPF programs from Go — it absorbs the boilerplate of talking to the kernel, so I could focus on the actual hook logic.&lt;/p&gt;

&lt;p&gt;Second, binary distribution. &lt;code&gt;go build&lt;/code&gt; produces a single binary you can hand to someone and it just runs. No runtime or external dependencies to install. That matters a lot when you're shipping an open-source CLI.&lt;/p&gt;

&lt;p&gt;Third, the TUI. I used &lt;a href="https://github.com/charmbracelet/bubbletea" rel="noopener noreferrer"&gt;Bubble Tea&lt;/a&gt; to build a live event display. Even when a burst of eBPF events comes in from the kernel, Go's goroutines and channels handle concurrent ingestion and rendering without trouble.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;tinytap uses eBPF and is Linux-only. On Mac or Windows, set up a Linux container with Docker.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/shinagawa-web/tinytap/main/scripts/install.sh | sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;setcap cap_dac_read_search,cap_perfmon,cap_bpf,cap_sys_admin&lt;span class="o"&gt;=&lt;/span&gt;eip &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; tinytap&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;sudo chmod&lt;/span&gt; +x &lt;span class="si"&gt;$(&lt;/span&gt;ldconfig &lt;span class="nt"&gt;-p&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;libssl.so | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $NF}'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
tinytap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;setcap&lt;/code&gt; grants the binary the capabilities it needs for eBPF. The &lt;code&gt;chmod +x&lt;/code&gt; step is needed on Debian and Ubuntu because &lt;code&gt;libssl.so.3&lt;/code&gt; ships without the execute bit set there. With both done, tinytap runs without &lt;code&gt;sudo&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If something isn't working, run &lt;code&gt;tinytap doctor&lt;/code&gt;. It checks your kernel version, BTF availability, capabilities, and the libssl execute bit, and tells you exactly what's missing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ tinytap doctor
tinytap doctor — v0.6.1

[OK      ] kernel version               6.1.0 (&amp;gt;= 5.8 required)
[OK      ] kernel BTF                   present
[OK      ] cap_dac_read_search          present
[OK      ] cap_perfmon                  present
[OK      ] cap_bpf                      present
[OK      ] cap_sys_admin                present
[OK      ] cap_syslog                   not needed on arm64
[OK      ] perf_event_paranoid          4
[OK      ] unprivileged_bpf_disabled    2
[OK      ] RLIMIT_MEMLOCK               soft=unlimited hard=unlimited
[OK      ] syscall tracepoints          available
[OK      ] BPF dry-run load             ok
[DEGRADED] libssl execute bit           /usr/lib/x86_64-linux-gnu/libssl.so.3 not set
    Affects: TLS capture only. Plaintext HTTP capture is unaffected.
    Fix:     sudo chmod +x /usr/lib/x86_64-linux-gnu/libssl.so.3

12 ok, 1 degraded, 0 blocking, 0 info
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python, Ruby, PHP, and Node.js all work. Go apps are out of scope — the standard library's &lt;code&gt;crypto/tls&lt;/code&gt; doesn't go through OpenSSL, so there's no &lt;code&gt;SSL_write&lt;/code&gt; to hook. tinytap currently understands HTTP/1.1 only.&lt;/p&gt;

&lt;p&gt;Targeting implementations that bypass OpenSSL — like Go's &lt;code&gt;crypto/tls&lt;/code&gt; or Rust's &lt;code&gt;rustls&lt;/code&gt; — would require attaching the uprobe at a different layer. Where exactly that should be is still an open question.&lt;/p&gt;

&lt;p&gt;Verified compatibility details are in the docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://shinagawa-web.github.io/tinytap/docs/server-compatibility/" rel="noopener noreferrer"&gt;Server compatibility&lt;/a&gt; — how much of the response body tinytap can surface for nginx, Caddy, Uvicorn, and other servers&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://shinagawa-web.github.io/tinytap/docs/tls-compatibility/" rel="noopener noreferrer"&gt;TLS compatibility&lt;/a&gt; — the list of supported TLS implementations; OpenSSL-based stacks (Python, nginx, curl, Node.js, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://shinagawa-web.github.io/tinytap/docs/compat-matrix/" rel="noopener noreferrer"&gt;Platform support&lt;/a&gt; — tested Linux distributions and kernel versions; kernel 5.8 or later is required (Ubuntu 20.04's default kernel 5.4 is too old)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;I built a CLI in Go that reads HTTPS plaintext using eBPF uprobes — no CA certificate, no proxy needed. By hooking into &lt;code&gt;SSL_write&lt;/code&gt; in libssl, it works with any OpenSSL-based stack: Python, Ruby, Node.js, and others, regardless of language.&lt;/p&gt;

&lt;p&gt;Code is on &lt;a href="https://github.com/shinagawa-web/tinytap" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. Environment requirements and compatibility details are in the &lt;a href="https://shinagawa-web.github.io/tinytap/" rel="noopener noreferrer"&gt;docs&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>go</category>
      <category>showdev</category>
      <category>opensource</category>
      <category>devops</category>
    </item>
    <item>
      <title>Nginx, Caddy, Traefik, or HAProxy: How to Pick the Right Reverse Proxy for Your Stack (2026)</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 25 Aug 2026 13:02:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/nginx-caddy-traefik-or-haproxy-how-to-pick-the-right-reverse-proxy-for-your-stack-2026-2doj</link>
      <guid>https://dev.to/shinagawa-web/nginx-caddy-traefik-or-haproxy-how-to-pick-the-right-reverse-proxy-for-your-stack-2026-2doj</guid>
      <description>&lt;p&gt;Have you ever heard "Caddy is great" and wondered what actually makes it different from Nginx? Or maybe you were evaluating Traefik for a Kubernetes setup, went down a rabbit hole of comparison articles, and gave up halfway through?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/shinagawa-web/youve-configured-a-reverse-proxy-but-can-you-explain-why-it-works-part-1-1lpf"&gt;Part 1&lt;/a&gt; ("For People Who've Configured a Reverse Proxy but Can't Explain How It Works") covered how reverse proxies work under the hood. Part 2 — this article — focuses on tool selection: the design philosophies behind Nginx, Caddy, Traefik, and HAProxy, and a mental model for deciding which one fits your use case. If you haven't read Part 1, one baseline is enough: a reverse proxy is software that sits between the browser and the app server, relaying traffic between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four tools and what each one is
&lt;/h2&gt;

&lt;p&gt;A quick one-liner for each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nginx: the most widely used general-purpose web server and reverse proxy&lt;/li&gt;
&lt;li&gt;Caddy: simple config file, automatic TLS certificate management built in&lt;/li&gt;
&lt;li&gt;Traefik: designed for dynamic routing config in container environments&lt;/li&gt;
&lt;li&gt;HAProxy: a full-featured load balancer that operates at both the TCP (L4) and HTTP (L7) levels&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All four can do load balancing, reverse proxying, and TLS termination. Nginx can handle load balancing. HAProxy can work as a reverse proxy. Capability overlap is the point — what differs is design emphasis, not capability ceiling. That emphasis is what drives the choice.&lt;/p&gt;

&lt;p&gt;Consider what "emphasis" means in practice. Nginx was built as a general-purpose machine: serve static files, reverse proxy, act as a load balancer — all from one piece of software. Traefik was born from a specific frustration: having to rewrite config files every time a container starts or stops. The starting point differs, so even when both do the same job, one is working in its natural mode and the other is being stretched.&lt;/p&gt;

&lt;p&gt;Read this article through that lens: where does each tool's center of gravity sit?&lt;/p&gt;

&lt;p&gt;One tool you won't see in this comparison is Envoy. Envoy has native support for circuit breaking, distributed rate limiting, and gRPC load balancing, and it's usually encountered in the context of service meshes like Istio. The configuration complexity and operational overhead are in a different league. For someone who's only used Nginx, it's too big a jump. Envoy does appear in the bonus section at the end — not as "here's the next step to consider," but as "here's what these four can't reach."&lt;/p&gt;

&lt;h2&gt;
  
  
  Nginx vs Caddy: what's actually different
&lt;/h2&gt;

&lt;p&gt;Put the minimal HTTPS configuration for each side by side.&lt;/p&gt;

&lt;p&gt;The Nginx version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt; &lt;span class="n"&gt;/etc/ssl/certs/example.com.crt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt; &lt;span class="n"&gt;/etc/ssl/private/example.com.key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_protocols&lt;/span&gt; &lt;span class="s"&gt;TLSv1.2&lt;/span&gt; &lt;span class="s"&gt;TLSv1.3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_ciphers&lt;/span&gt; &lt;span class="s"&gt;HIGH:!aNULL:!MD5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&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;The equivalent Caddyfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;example.com {
    reverse_proxy localhost:3000
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The line count difference might look like Caddy is skipping things — it isn't. With Caddy, TLS certificate retrieval and renewal happen automatically. It fetches a certificate from Let's Encrypt and renews it automatically well before the 90-day expiry, with no manual steps needed. There's no &lt;code&gt;ssl_certificate&lt;/code&gt; line because you don't supply the cert file yourself. There are no &lt;code&gt;proxy_set_header&lt;/code&gt; lines because Caddy forwards those headers by default.&lt;/p&gt;

&lt;p&gt;If you've ever wrestled with Nginx TLS config, Caddy's automatic TLS will be a real relief. Especially if you've had a service go down because a certificate renewal slipped through the cracks.&lt;/p&gt;

&lt;p&gt;That said, Caddy's simplicity cuts both ways. The convenient defaults make it harder to reason about what's happening when you need to override something. Nginx's verbosity is a feature: every directive is explicit, which makes troubleshooting more straightforward.&lt;/p&gt;

&lt;p&gt;The question to ask when choosing: do you want to control TLS yourself, or do you want to hand it off?&lt;/p&gt;

&lt;p&gt;Caddy does have cases where it doesn't fit. Internal services — those not exposed to the internet — won't work with ACME-based automatic TLS. Let's Encrypt challenges require the domain to be publicly reachable, so for a service on an internal DNS, you'd need extra DNS-01 challenge configuration to get Caddy's automatic TLS working. Going in with the assumption that "Caddy means TLS is automatic" will cause problems for internal services.&lt;/p&gt;

&lt;p&gt;There's also a debugging gap. &lt;code&gt;caddy reload&lt;/code&gt; and &lt;code&gt;caddy validate&lt;/code&gt; error messages can be less specific than Nginx's. Nginx's &lt;code&gt;nginx -t&lt;/code&gt; gives you the exact line number of the problem; Caddy sometimes only tells you "configuration is invalid." For a team with deep Nginx experience, troubleshooting an unfamiliar tool takes longer than it should.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Traefik pairs well with Kubernetes
&lt;/h2&gt;

&lt;p&gt;Traefik's starting point was: "I don't want to rewrite config files every time a container is added."&lt;/p&gt;

&lt;p&gt;With Nginx, you write this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;app1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;app2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&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;Adding &lt;code&gt;app3&lt;/code&gt; means opening the config file, adding &lt;code&gt;server app3:3000;&lt;/code&gt;, and reloading Nginx. Manual every time.&lt;/p&gt;

&lt;p&gt;Traefik works differently. With Docker, you attach labels to the container at startup and it joins the routing automatically.&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;app&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-app&lt;/span&gt;
    &lt;span class="na"&gt;labels&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;traefik.enable=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.app.rule=Host(`example.com`)"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.services.app.loadbalancer.server.port=3000"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The moment that container starts, Traefik reads the Docker labels and adds the route. When the container stops, the route disappears. No config reload needed.&lt;/p&gt;

&lt;p&gt;In Kubernetes, the same thing happens through Ingress annotations. Deploy a new service with &lt;code&gt;kubectl apply&lt;/code&gt;, and Traefik detects it and updates routing. The "update config files on every deploy across 20 containers" workflow goes away.&lt;/p&gt;

&lt;p&gt;If static config files are enough, Nginx works fine. When containers come and go frequently, that dynamic config automation is the reason to choose Traefik.&lt;/p&gt;

&lt;p&gt;Going deeper into Kubernetes: Traefik has its own CRD called IngressRoute. It's more expressive than the standard Ingress resource, but it means writing Traefik-specific config into your K8s manifests. Nginx Ingress Controller is built on the standard Ingress resource and uses annotations to control fine-grained behavior.&lt;/p&gt;

&lt;p&gt;Which you choose depends on whether you'd rather write in the proxy's config language or stay close to K8s manifests. If your team already knows Nginx, the Nginx Ingress Controller has a lower learning curve. If your service churn is high and you want routing definitions managed as code inside K8s, Traefik's IngressRoute is where it earns its keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why HAProxy gets chosen as a dedicated load balancer
&lt;/h2&gt;

&lt;p&gt;Ask the question "Nginx can load balance too, so why choose HAProxy?" and the answer surfaces.&lt;/p&gt;

&lt;p&gt;A health check in HAProxy config looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;backend&lt;/span&gt; &lt;span class="s"&gt;api_servers&lt;/span&gt;
    &lt;span class="s"&gt;balance&lt;/span&gt; &lt;span class="s"&gt;roundrobin&lt;/span&gt;
    &lt;span class="s"&gt;option&lt;/span&gt; &lt;span class="s"&gt;httpchk&lt;/span&gt; &lt;span class="s"&gt;GET&lt;/span&gt; &lt;span class="n"&gt;/health&lt;/span&gt; &lt;span class="s"&gt;HTTP/1.1&lt;/span&gt;
    &lt;span class="s"&gt;http-check&lt;/span&gt; &lt;span class="s"&gt;expect&lt;/span&gt; &lt;span class="s"&gt;status&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
    &lt;span class="s"&gt;default-server&lt;/span&gt; &lt;span class="s"&gt;inter&lt;/span&gt; &lt;span class="s"&gt;2s&lt;/span&gt; &lt;span class="s"&gt;fall&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="s"&gt;rise&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="s"&gt;maxconn&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
    &lt;span class="s"&gt;server&lt;/span&gt; &lt;span class="s"&gt;app1&lt;/span&gt; &lt;span class="nf"&gt;10.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt; &lt;span class="s"&gt;check&lt;/span&gt;
    &lt;span class="s"&gt;server&lt;/span&gt; &lt;span class="s"&gt;app2&lt;/span&gt; &lt;span class="nf"&gt;10.0.0.2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt; &lt;span class="s"&gt;check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;inter 2s fall 3 rise 2&lt;/code&gt; means: check every 2 seconds, mark down after 3 consecutive failures, mark up after 2 consecutive successes. Nginx can do something similar, but HAProxy lets you express this level of control directly in the config.&lt;/p&gt;

&lt;p&gt;Where HAProxy really stands out is observability — seeing what's happening in real time. The built-in stats dashboard shows connection counts, error rates, and latency per backend, live. If one backend starts spiking in latency because it's running out of memory, you can dig through Nginx access logs to find the cause eventually. Opening HAProxy's dashboard makes it immediately obvious which backend has the elevated error rate. When you're handling high traffic and need to know instantly which backend is the bottleneck, that data matters.&lt;/p&gt;

&lt;p&gt;HAProxy also handles both TCP (L4) and HTTP (L7) load balancing. Beyond HTTP apps, you can balance database connections and manage WebSocket traffic in config.&lt;/p&gt;

&lt;p&gt;HAProxy makes sense when traffic is heavy and you need fine-grained control over backend behavior. Reaching for it on a simple reverse proxy setup means absorbing configuration complexity without much benefit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking one for your situation
&lt;/h2&gt;

&lt;p&gt;Mapping the discussion to four scenarios:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Main reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Simple VPS + TLS termination&lt;/td&gt;
&lt;td&gt;Caddy&lt;/td&gt;
&lt;td&gt;Automatic TLS removes certificate management overhead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small Docker microservices (stable topology)&lt;/td&gt;
&lt;td&gt;Nginx&lt;/td&gt;
&lt;td&gt;Proven track record, large community, explicit config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small Docker microservices (high churn)&lt;/td&gt;
&lt;td&gt;Traefik&lt;/td&gt;
&lt;td&gt;Routing updates automatically as containers start and stop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes cluster&lt;/td&gt;
&lt;td&gt;Traefik / Nginx Ingress&lt;/td&gt;
&lt;td&gt;Depends on what the team already knows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High-traffic load balancer&lt;/td&gt;
&lt;td&gt;HAProxy&lt;/td&gt;
&lt;td&gt;Health check granularity and built-in observability&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a simple VPS with TLS termination, Caddy is the pick. Offloading certificate management is a meaningful gain, and the short config file is easy to maintain. It suits personal projects and small services.&lt;/p&gt;

&lt;p&gt;For small Docker-based microservices, Nginx or Traefik are both candidates. If the container count is stable and doesn't change much, Nginx is plenty. If you're adding and removing services regularly, Traefik's dynamic config earns its place.&lt;/p&gt;

&lt;p&gt;For Kubernetes, Traefik or Nginx Ingress Controller. Traefik has a head start on K8s integration, but Nginx Ingress Controller is mature and fully viable. If your team has deep Nginx knowledge, there's no good reason to switch to Traefik just because it's the trendy choice.&lt;/p&gt;

&lt;p&gt;For a high-traffic load balancer, HAProxy. That's where its design philosophy — fine-grained control and first-class observability — actually comes into play.&lt;/p&gt;

&lt;p&gt;My last personal choice was Caddy on a VPS. The reason was simple: I'd taken a service down once because I forgot to renew a certificate. Since switching to Caddy, I haven't thought about certificates once. That alone was worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Config files and benchmark
&lt;/h2&gt;

&lt;p&gt;Working examples for all four proxies are in &lt;a href="https://github.com/shinagawa-web/reverse-proxy-bench" rel="noopener noreferrer"&gt;shinagawa-web/reverse-proxy-bench&lt;/a&gt; — each proxy has its own directory with a config file and a &lt;code&gt;docker-compose.yml&lt;/code&gt;. Run any of them locally with &lt;code&gt;make bench-nginx&lt;/code&gt; (requires Docker and k6).&lt;/p&gt;

&lt;p&gt;The benchmark runs 60 seconds at 20 concurrent virtual users against a minimal Go backend (&lt;code&gt;/ping&lt;/code&gt; → 200 OK), with default configurations:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Proxy&lt;/th&gt;
&lt;th&gt;req/s&lt;/th&gt;
&lt;th&gt;p50 (ms)&lt;/th&gt;
&lt;th&gt;p95 (ms)&lt;/th&gt;
&lt;th&gt;p99 (ms)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HAProxy&lt;/td&gt;
&lt;td&gt;10,762&lt;/td&gt;
&lt;td&gt;1.53&lt;/td&gt;
&lt;td&gt;3.45&lt;/td&gt;
&lt;td&gt;4.89&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Traefik&lt;/td&gt;
&lt;td&gt;7,946&lt;/td&gt;
&lt;td&gt;2.15&lt;/td&gt;
&lt;td&gt;4.62&lt;/td&gt;
&lt;td&gt;6.48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caddy&lt;/td&gt;
&lt;td&gt;7,535&lt;/td&gt;
&lt;td&gt;2.28&lt;/td&gt;
&lt;td&gt;4.93&lt;/td&gt;
&lt;td&gt;6.81&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nginx&lt;/td&gt;
&lt;td&gt;4,386&lt;/td&gt;
&lt;td&gt;4.37&lt;/td&gt;
&lt;td&gt;5.43&lt;/td&gt;
&lt;td&gt;6.83&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These results reflect default configurations on a 2-vCPU GitHub Actions runner. Nginx in particular has significant headroom — &lt;code&gt;worker_processes auto&lt;/code&gt; alone would narrow the gap considerably. The intent isn't to declare a winner, but to show how the design emphasis of each tool translates into behavior under the same load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: what these four can't do
&lt;/h2&gt;

&lt;p&gt;For intermediate and above: the areas where all four have the same blind spot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Distributed rate limiting
&lt;/h3&gt;

&lt;p&gt;Nginx, Caddy, Traefik, and HAProxy all have rate limiting, but all of them apply limits per instance. Scale out to four instances and your limit effectively quadruples. A "100 requests per second" rule becomes "400 requests per second" in practice. If you hit a situation right after a scale-out where rate limiting suddenly seems broken, the connection to instance count isn't always obvious.&lt;/p&gt;

&lt;p&gt;You can share state via an external store like Redis — each proxy instance increments a Redis counter on every request and returns 429 when the threshold is hit. That turns Redis into a single point of failure, so you also need an availability design for Redis. Every request that touches Redis also adds the round-trip latency — typically 0.5–2ms on a local network, but under traffic spikes Redis itself can become the bottleneck, slowing the proxy down across the board.&lt;/p&gt;

&lt;p&gt;The tools that provide this natively are Kong and Envoy. Kong's Rate Limiting Advanced plugin uses a hybrid approach: local counters per instance, synchronized to a shared store on a configurable interval. Requests don't need a Redis write on every hit, which reduces the latency overhead significantly. Envoy is designed to integrate with a dedicated external rate limit service from the start.&lt;/p&gt;

&lt;h3&gt;
  
  
  Circuit breaking
&lt;/h3&gt;

&lt;p&gt;Suppose an upstream service starts responding slowly. The proxy holds connections open until timeout, and new requests keep trying to connect. The connection pool fills with waiting connections, other backends start backing up too, and failures cascade through the whole system. That's a cascading failure.&lt;/p&gt;

&lt;p&gt;A circuit breaker cuts that chain. When it detects consecutive failures to an upstream, it stops sending new requests there and returns errors immediately. Envoy has this natively via &lt;code&gt;outlier_detection&lt;/code&gt; — setting &lt;code&gt;consecutive_5xx: 5&lt;/code&gt; and &lt;code&gt;base_ejection_time: 30s&lt;/code&gt; is enough to get it working. None of these four have an equivalent. Traefik's circuit breaker plugin uses similar configuration, but it operates at the route level rather than the stream level, which makes it less effective for long-lived streams like gRPC where a single stream can hold a backend connection open for minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Native JWT verification / OIDC
&lt;/h3&gt;

&lt;p&gt;Traefik and Caddy have plugin support for this, but many of the available plugins are community-maintained. How they handle JWKS endpoint caching and key rotation varies, and some don't document their behavior clearly. If a signing key rotates and the plugin doesn't refresh its cache in time, valid tokens start getting rejected. Nginx requires Lua or the NJS module. HAProxy is in a similar position.&lt;/p&gt;

&lt;p&gt;If you want to handle authentication at the proxy layer reliably, Kong or Envoy are the more practical choices. Kong's JWT plugin caches JWKS with configurable TTL and handles key rotation predictably — which matters when authentication is in the critical path.&lt;/p&gt;

&lt;h3&gt;
  
  
  gRPC load balancing
&lt;/h3&gt;

&lt;p&gt;With HTTP/1.1, a new connection is opened per request (or closed quickly), so the proxy distributes at the request level. gRPC runs over HTTP/2, which multiplexes requests over a single long-lived connection.&lt;/p&gt;

&lt;p&gt;Put Nginx in front of a gRPC backend as a load balancer, and Nginx maintains one persistent connection to each backend. Those connections stay alive for a long time. Stream distribution becomes uneven, and specific backends end up with concentrated load. You can have 10 backends but effectively route to only 1 or 2 of them.&lt;/p&gt;

&lt;p&gt;Nginx can partially work around this with &lt;code&gt;keepalive&lt;/code&gt; on the upstream block combined with HTTP/2 proxying, but stream-level routing — deciding per-request which backend to send to — remains limited. Traefik opens multiple HTTP/2 streams per backend connection and rebalances more aggressively than Nginx's static upstream model, so load spreads more evenly across backends. Envoy is built gRPC-first, with stream-level load balancing as a baseline assumption.&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>devops</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>You've Configured a Reverse Proxy, But Can You Explain Why It Works? (Part 1)</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Thu, 20 Aug 2026 12:01:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/youve-configured-a-reverse-proxy-but-can-you-explain-why-it-works-part-1-1lpf</link>
      <guid>https://dev.to/shinagawa-web/youve-configured-a-reverse-proxy-but-can-you-explain-why-it-works-part-1-1lpf</guid>
      <description>&lt;p&gt;You wrote &lt;code&gt;proxy_pass http://localhost:3000;&lt;/code&gt;, checked it in the browser, saw it working, and called it done. But you couldn't actually explain why it works. What's happening between nginx and the app? Where is the request going? You probably couldn't answer that with any confidence.&lt;/p&gt;

&lt;p&gt;Getting something working and understanding how it works are two different things. This article (Part 1) is about the how. Once you have that, the next question becomes which tool to use. In &lt;a href="https://dev.to/shinagawa-web/nginx-caddy-traefik-or-haproxy-how-to-pick-the-right-reverse-proxy-for-your-stack-2026-2doj"&gt;Part 2&lt;/a&gt;, I'll break down the design philosophies behind nginx, Caddy, Traefik, and HAProxy, and give you a framework for choosing the one that fits your stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the browser is actually connecting
&lt;/h2&gt;

&lt;p&gt;When a browser sends a GET to &lt;code&gt;https://api.example.com/users&lt;/code&gt;, it resolves &lt;code&gt;api.example.com&lt;/code&gt; via DNS and opens a TCP connection to port 443 at that IP. Who's on the other end of that connection?&lt;/p&gt;

&lt;p&gt;In a setup with a reverse proxy, it's not the app server — it's the proxy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser  ──[TCP connection]──→  nginx (443)  ──[separate TCP connection]──→  App (3000)
         ←───────────────────                ←──────────────────────────────
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the key point. The browser and the app server are not talking directly. The proxy holds two separate connections and shuttles requests between them. That's what "split communication" means: the browser has no idea what the app server's IP address is, what port it's on, or that it even exists.&lt;/p&gt;

&lt;p&gt;This split is also the root cause of several problems I'll get to later. The reason the browser's IP disappears from the app's perspective, and the reason the Host header gets rewritten — both trace back to this structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it differs from a forward proxy
&lt;/h2&gt;

&lt;p&gt;"Proxy" is an overloaded term. Forward proxy and reverse proxy are easy to mix up. Both relay traffic, but they stand in for opposite sides, and they hide opposite things.&lt;/p&gt;

&lt;p&gt;A forward proxy acts on behalf of the client. You see it in corporate networks, where internal machines can't go directly to the internet — they route through a proxy server. The destination server sees the proxy, not the individual machines behind it. What's hidden is the client.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internal PC  ──→  Forward proxy  ──→  External server
                                       ↑
                             Internal PC is not visible
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A reverse proxy acts on behalf of the server. The client (browser) connects to the proxy without knowing how many app servers are behind it or how they're arranged. What's hidden is the server infrastructure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser  ──→  Reverse proxy  ──→  App server
                                   ↑
                         Server is not visible
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forward proxies hide the client; reverse proxies hide the server. That distinction comes back when we look at what disappears when you add a proxy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens before a request reaches the app
&lt;/h2&gt;

&lt;p&gt;Between the browser sending a request and the app server receiving it, the proxy does several things. Let's trace through a concrete nginx config.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;api.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/users&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&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;You've probably written something like this. Let's go through each line and why it's there.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;proxy_set_header Host $host;&lt;/code&gt; — The browser's HTTP request includes a header like &lt;code&gt;Host: api.example.com&lt;/code&gt;. When the proxy forwards that request to the app server, the Host header gets rewritten to &lt;code&gt;localhost:3000&lt;/code&gt; by default, because that's the host of the connection the proxy opened. If your app needs to know the original domain — for virtual host routing, or to construct a redirect URL — it needs &lt;code&gt;api.example.com&lt;/code&gt;, not &lt;code&gt;localhost:3000&lt;/code&gt;. This line passes the original value through explicitly.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;&lt;/code&gt; — X-Forwarded-For (XFF) is a header that records the list of IP addresses a request has passed through. When the proxy forwards a request to the app, the connection comes from the proxy, not the browser. From the app's perspective, the remote IP is the proxy's address. To tell the app what the original client IP was, the proxy adds this header. &lt;code&gt;$proxy_add_x_forwarded_for&lt;/code&gt; is an nginx variable that appends to an existing XFF header if one is present, or creates a new one with the connecting IP if not.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;proxy_set_header X-Forwarded-Proto $scheme;&lt;/code&gt; — When the proxy receives HTTPS on port 443 and forwards it to the app as HTTP, the app has no way of knowing the original request came over HTTPS. X-Forwarded-Proto carries that information.&lt;/p&gt;

&lt;p&gt;Each of those settings exists because of the connection split from the first section.&lt;/p&gt;

&lt;h2&gt;
  
  
  What problems a reverse proxy solves
&lt;/h2&gt;

&lt;p&gt;Consider what happens if there's no proxy and the app server has to handle everything itself.&lt;/p&gt;

&lt;p&gt;To serve over HTTPS, you need to manage a server certificate and handle TLS handshakes. A Node.js app doing that on its own looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;https&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/etc/ssl/private/server.key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/etc/ssl/certs/server.crt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;https&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;443&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app needs to know the certificate file paths. Every time you renew the certificate, you restart the app. If you're using Let's Encrypt for automatic 90-day renewals, that means the app restarts on every renewal cycle. Cipher suite selection and protocol version management live in your app code too — every time TLS 1.0/1.1 gets deprecated somewhere, you touch the app.&lt;/p&gt;

&lt;p&gt;When nginx handles TLS termination (ending the encrypted connection with the client at the proxy), your app code becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Only needs to speak HTTP&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Certificate management and TLS configuration stay in the nginx config file. The app server speaks plain HTTP and never needs to restart because a cert was renewed.&lt;/p&gt;

&lt;p&gt;When traffic grows and a single app server can't keep up, you add more. But if browsers connect to app servers directly, there's no way to spread requests across the new instances. With a proxy, you get load balancing across multiple upstreams:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;api_servers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;least_conn&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# send to the server with fewest active connections&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;10.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;10.0.0.2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;10.0.0.3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&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;&lt;code&gt;least_conn&lt;/code&gt; sends each request to whichever server currently has the fewest active connections. It distributes load more evenly than the default round-robin behavior when response times vary — slow requests don't pile up on one server.&lt;/p&gt;

&lt;p&gt;Routing &lt;code&gt;/api/&lt;/code&gt; requests to the backend cluster and &lt;code&gt;/static/&lt;/code&gt; requests to a file server is also more naturally expressed at the proxy layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;api.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://api_servers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/static/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://file_server&lt;/span&gt;&lt;span class="p"&gt;;&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;The routing logic stays out of your app code. Where each request goes is visible in the nginx config.&lt;/p&gt;

&lt;p&gt;These are usually described as "reverse proxy features," but what they really are is work that doesn't belong in the app server. The proxy takes on what would otherwise be scattered across every instance, and the app gets to stay focused on what it's actually for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What disappears when you add a proxy
&lt;/h2&gt;

&lt;p&gt;After adding a reverse proxy, you look at your app's access logs and every IP is &lt;code&gt;127.0.0.1&lt;/code&gt;. This trips people up. I was one of them.&lt;/p&gt;

&lt;p&gt;Before the proxy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="c"&gt;# Before proxy&lt;/span&gt;
203.0.113.45 - - [12/Aug/2026:10:00:01 +0000] "GET /users HTTP/1.1" 200 512
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After adding the proxy, without XFF headers configured:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# After proxy (no XFF config)
127.0.0.1 - - [12/Aug/2026:10:00:01 +0000] "GET /users HTTP/1.1" 200 512
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason is straightforward: the app's connection comes from the proxy, and if the proxy and app are on the same server, that's the loopback address. What the app sees is "the IP of whatever connected to me," not "the browser's IP."&lt;/p&gt;

&lt;p&gt;The fix is to read the X-Forwarded-For header that nginx is attaching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;# Value available when the app reads XFF
X-Forwarded-For: 203.0.113.45
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But XFF is a header the client can freely manipulate, so trusting the wrong source means your IP can be spoofed.&lt;/p&gt;

&lt;p&gt;If you don't notice that the log format changed after setting up the proxy, debugging gets confusing fast. If your app does any IP-based access control and isn't reading XFF, every request looks like it's coming from the same address.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Everything in this article comes back to one thing: the communication is split into two separate TCP connections. Because of that split, the Host header has to be passed through explicitly, the client IP disappears, and TLS termination at the proxy becomes a viable pattern. Every line in that config exists because of this split. Which tool to use for the proxy is &lt;a href="https://dev.to/shinagawa-web/nginx-caddy-traefik-or-haproxy-how-to-pick-the-right-reverse-proxy-for-your-stack-2026-2doj"&gt;Part 2&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If what's in this article has stuck, you should be able to answer something like: "The proxy holds two TCP connections and splits the communication. That's why the Host header gets rewritten and the client IP disappears. TLS ends at the proxy." You can now explain what's happening between nginx and your app.&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>webdev</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>curl works, your app gets 400: read the exact request your SDK sent</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 18 Aug 2026 13:01:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/curl-works-app-gets-400-peek-at-the-raw-https-your-app-actually-sent-o9e</link>
      <guid>https://dev.to/shinagawa-web/curl-works-app-gets-400-peek-at-the-raw-https-your-app-actually-sent-o9e</guid>
      <description>&lt;p&gt;curl returns 200. The same request from your app returns 400. You followed the docs. The parameters look right. It still doesn't work.&lt;/p&gt;

&lt;p&gt;When that happens, what you really want to know is what your app actually sent. But that's the one thing you can't see. The SDK assembles the final request below your code, and TLS encrypts it before it hits the wire — capture the traffic and you get ciphertext.&lt;/p&gt;

&lt;p&gt;This post is about seeing that plaintext with one command, no CA certificate install, no proxy, no changes to your app. The tool is &lt;a href="https://github.com/shinagawa-web/tinytap" rel="noopener noreferrer"&gt;tinytap&lt;/a&gt;, something I built for exactly this. Here's what it looks like.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvngwl5r0x1wmosduij8a.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvngwl5r0x1wmosduij8a.gif" alt="The left terminal runs the app and sends one HTTPS request. The right terminal shows tinytap printing that request as plaintext — request line, headers, Authorization: Bearer token, JSON body."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  curl works, the app gets 400
&lt;/h2&gt;

&lt;p&gt;Here's a concrete example of how this goes wrong. You're hitting a payment API that expects an integer amount in cents. Your code passes &lt;code&gt;1000&lt;/code&gt;. The app returns &lt;code&gt;400 invalid integer: amount&lt;/code&gt;. You hardcode the same value in &lt;code&gt;curl&lt;/code&gt; and it goes through fine.&lt;/p&gt;

&lt;p&gt;The usual suspects come to mind. Double-encoded body? Type coercion somewhere? Wrong Content-Type? Missing token? All plausible, none confirmable. Your app's logs show the &lt;code&gt;1000&lt;/code&gt; you passed into the SDK — not the final form that went over the wire.&lt;/p&gt;

&lt;p&gt;If you could see the request body with &lt;code&gt;"amount": "10.00"&lt;/code&gt; — the value that silently became a string somewhere — you'd have exactly what you need to compare what you meant to send against what you actually sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SDK's final form never appears in any log
&lt;/h2&gt;

&lt;p&gt;The obvious move is to check the logs. But whatever you &lt;code&gt;print&lt;/code&gt; or pass to your logger is the value before the SDK gets it. The SDK adds headers, serializes the body, and possibly retries or signs the request — all below the layer your logs can see. You get the intent, not the result.&lt;/p&gt;

&lt;p&gt;Wireshark is another option. But the connection is TLS, so you get ciphertext. Decoding that requires setting up &lt;code&gt;SSLKEYLOGFILE&lt;/code&gt; and a decryption step, and not every client supports it.&lt;/p&gt;

&lt;p&gt;mitmproxy gives you plaintext — but it requires installing a CA certificate in your trust store, configuring your app to route through the proxy, and some SDKs ignore proxy environment variables entirely. Installing a CA cert on your machine is a heavy setup just for debugging.&lt;/p&gt;

&lt;p&gt;So there was a gap: seeing the actual plaintext, without touching the app, without installing a certificate, without a proxy in the middle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Peek at the plaintext before it gets encrypted
&lt;/h2&gt;

&lt;p&gt;That's what tinytap does. The gif above shows it: run your app on the left, and the request's plaintext appears on the right in tinytap.&lt;/p&gt;

&lt;p&gt;The left side is running this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sk-...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://127.0.0.1:8443/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;what is a uprobe&lt;/span&gt;&lt;span class="sh"&gt;"&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;And on the right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;POST&lt;/span&gt; &lt;span class="nn"&gt;/v1/chat/completions&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;127.0.0.1:8443&lt;/span&gt;
&lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bearer sk-demo-xxxx&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;
&lt;span class="na"&gt;User-Agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OpenAI/Python 2.53.0&lt;/span&gt;
&lt;span class="s"&gt;...&lt;/span&gt;
&lt;span class="s"&gt;Request body (decoded): {"messages":[{"role":"user","content":"what is a uprobe"}],"model":"gpt-4o-mini"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request line, every header, the &lt;code&gt;Authorization: Bearer&lt;/code&gt; token, the JSON body — exactly what the SDK built and sent. The &lt;code&gt;User-Agent: OpenAI/Python&lt;/code&gt; line confirms this is real bytes from the real SDK, not something I reconstructed. No proxy, no certificate, no changes to the app. The token in the gif is a dummy (&lt;code&gt;sk-demo-xxxx&lt;/code&gt;), but when you point tinytap at your own app, your real token appears in plaintext just like this.&lt;/p&gt;

&lt;p&gt;Your code on the left, what it actually sent on the right. The moment you see both side by side, the "I thought I sent X" question is answered on the spot.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not breaking TLS
&lt;/h2&gt;

&lt;p&gt;Worth stating upfront before anyone raises an eyebrow: this is not a vulnerability that lets you intercept traffic.&lt;/p&gt;

&lt;p&gt;tinytap looks at the buffer your process hands to the TLS library, just before encryption — and the buffer that comes back out, just after decryption.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;App
  │  plaintext
  ▼
TLS library  ◀── tinytap looks here
  │  ciphertext
  ▼
Network
  │  ciphertext
  ▼
API server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It hooks into your process from the inside, at the moment the plaintext is still plaintext. TLS itself is untouched. Other people's traffic is not accessible. This isn't about someone with root on your machine — it's about the inside of your own process. It's the same idea as &lt;code&gt;curl&lt;/code&gt; printing its own request to your terminal. The ciphertext on the wire is untouched. All you're doing is looking at it from your own side, one step before encryption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;"Plaintext without a certificate or proxy" sounds like it might be overselling it. So here's how to reproduce it yourself. tinytap uses eBPF, which means Linux only. On Mac or Windows, spin up a Linux container with Docker.&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;# install&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/shinagawa-web/tinytap/main/scripts/install.sh | sh

&lt;span class="c"&gt;# grant the capabilities needed for eBPF and TLS capture&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;setcap cap_dac_read_search,cap_perfmon,cap_bpf,cap_sys_admin&lt;span class="o"&gt;=&lt;/span&gt;eip &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; tinytap&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# set the execute bit on libssl (Debian/Ubuntu ships it without one)&lt;/span&gt;
&lt;span class="nb"&gt;sudo chmod&lt;/span&gt; +x &lt;span class="si"&gt;$(&lt;/span&gt;ldconfig &lt;span class="nt"&gt;-p&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;libssl.so | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $NF}'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

tinytap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;setcap&lt;/code&gt; grants the binary the capabilities it needs for eBPF and TLS capture. &lt;code&gt;chmod +x&lt;/code&gt; sets the execute bit on libssl — Debian and Ubuntu ship &lt;code&gt;libssl.so.3&lt;/code&gt; without it by default, so this line is required there. With both steps done, tinytap runs without &lt;code&gt;sudo&lt;/code&gt;. Run your app normally and the requests show up.&lt;/p&gt;

&lt;p&gt;If something doesn't work, run &lt;code&gt;tinytap doctor&lt;/code&gt;. It checks your kernel version, BTF availability, capabilities, and the libssl execute bit, and tells you exactly what's missing.&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="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;tinytap doctor
&lt;span class="go"&gt;tinytap doctor — v0.6.1

&lt;/span&gt;&lt;span class="gp"&gt;[OK      ] kernel version               6.1.0 (&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; 5.8 required&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;[OK      ] kernel BTF                   present
[OK      ] cap_dac_read_search          present
[OK      ] cap_perfmon                  present
[OK      ] cap_bpf                      present
[OK      ] cap_sys_admin                present
[OK      ] cap_syslog                   not needed on arm64
[OK      ] perf_event_paranoid          4
[OK      ] unprivileged_bpf_disabled    2
[OK      ] RLIMIT_MEMLOCK               soft=unlimited hard=unlimited
[OK      ] syscall tracepoints          available
[OK      ] BPF dry-run load             ok
[DEGRADED] libssl execute bit           /usr/lib/x86_64-linux-gnu/libssl.so.3 not set
    Affects: TLS capture only. Plaintext HTTP capture is unaffected.
    Fix:     sudo chmod +x /usr/lib/x86_64-linux-gnu/libssl.so.3

12 ok, 1 degraded, 0 blocking, 0 info
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;tinytap only speaks HTTP/1.1 — gRPC and HTTP/2 binary frames aren't readable as-is. Go is also out of scope: its &lt;code&gt;crypto/tls&lt;/code&gt; bypasses OpenSSL entirely. Python, Ruby, PHP, and Node.js all work out of the box. The language is a quick guide: Python / Ruby / PHP / Node.js, you're good; Go, you'll need a different approach.&lt;/p&gt;

&lt;p&gt;Instead of the self-signed local server in the demo, you can point tinytap at whatever API-connected app you normally run. The &lt;code&gt;Authorization&lt;/code&gt; header and body that show up are what you need to debug "curl works, app gets 400."&lt;/p&gt;

&lt;p&gt;Which SDK has bitten you? Silent retries, double-encoded bodies, headers disappearing for no reason — if any of that sounds familiar, it's probably the kind of thing that resolves the moment you can see what was actually sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  So how does it actually see the plaintext?
&lt;/h2&gt;

&lt;p&gt;This post has stayed focused on what tinytap shows, not how it works. The mechanism — eBPF and uprobes, hooking into the TLS library boundary — is a story for another post.&lt;/p&gt;

&lt;p&gt;The server returned 400. It's telling you the request was wrong. You don't know what's wrong because you can't see what your code actually sent. If you could see it, you'd just compare what you meant to send against what went out. &lt;a href="https://github.com/shinagawa-web/tinytap" rel="noopener noreferrer"&gt;tinytap&lt;/a&gt; is built for that.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>opensource</category>
      <category>showdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>nginx doesn't run your config top to bottom: two traps with rewrite, try_files, and if</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 11 Aug 2026 13:01:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/nginx-doesnt-run-your-config-top-to-bottom-two-traps-with-rewrite-tryfiles-and-if-2hlm</link>
      <guid>https://dev.to/shinagawa-web/nginx-doesnt-run-your-config-top-to-bottom-two-traps-with-rewrite-tryfiles-and-if-2hlm</guid>
      <description>&lt;p&gt;You write a &lt;code&gt;rewrite&lt;/code&gt;, then a &lt;code&gt;try_files&lt;/code&gt; right under it, and you read it as "the rewrite on top runs first, the try_files below runs after." You put an &lt;code&gt;if&lt;/code&gt; inside a &lt;code&gt;location&lt;/code&gt; and read it as "when the condition is true, this stuff gets added to the outer config." Both readings trace the config file straight down, line by line, in the order it's written. And both have burned me.&lt;/p&gt;

&lt;p&gt;Let me show you the two symptoms first. In each case the config reads cleanly, yet the behavior doesn't line up. Why that happens — the order in which nginx processes a request — is what this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom 1: rewrite and try_files side by side, and you get a 404
&lt;/h2&gt;

&lt;p&gt;In some location you want to rewrite the request once, then fall back to a static file. The naive version looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/app/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^/app/(.*)&lt;/span&gt;$ &lt;span class="n"&gt;/public/&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="s"&gt;last&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&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;The intent is "rewrite &lt;code&gt;/app/foo&lt;/code&gt; to &lt;code&gt;/public/foo&lt;/code&gt;, serve the file if it exists, 404 otherwise." Since &lt;code&gt;rewrite&lt;/code&gt; is on top and &lt;code&gt;try_files&lt;/code&gt; is below, the rewrite takes effect first and then try_files handles the fallback — that's how it reads.&lt;/p&gt;

&lt;p&gt;But throw &lt;code&gt;/app/logo.png&lt;/code&gt; at it and you get a 404, even when &lt;code&gt;/public/logo.png&lt;/code&gt; really exists.&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;&lt;span class="nb"&gt;ls&lt;/span&gt; /public/logo.png
/public/logo.png            &lt;span class="c"&gt;# the file is definitely there&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; http://localhost/app/logo.png
HTTP/1.1 404 Not Found      &lt;span class="c"&gt;# and yet, 404&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;try_files&lt;/code&gt; in this location is never evaluated once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom 2: config placed inside an &lt;code&gt;if&lt;/code&gt; block doesn't take effect
&lt;/h2&gt;

&lt;p&gt;In another location you want to add one header conditionally. The naive version looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/download/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Served-By&lt;/span&gt; &lt;span class="s"&gt;"nginx"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$arg_debug&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1")&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Debug&lt;/span&gt; &lt;span class="s"&gt;"on"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# only want this when ?debug=1&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&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;The intent is "always add &lt;code&gt;X-Served-By&lt;/code&gt;, and add &lt;code&gt;X-Debug&lt;/code&gt; on top of it only when &lt;code&gt;?debug=1&lt;/code&gt;." But hit &lt;code&gt;/download/foo?debug=1&lt;/code&gt; and &lt;code&gt;X-Debug&lt;/code&gt; shows up while &lt;code&gt;X-Served-By&lt;/code&gt;, which you set on the outside, has vanished. The moment the condition goes true, part of the outer config drops.&lt;/p&gt;

&lt;p&gt;Both configs read cleanly when you scan the lines top to bottom. And both miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works: nginx's HTTP processing phases
&lt;/h2&gt;

&lt;p&gt;nginx runs each request through a fixed sequence of processing phases. Which phase a given directive runs in is determined per directive type, and has nothing to do with where the line sits in the config file. Both symptoms come out of exactly this one fact.&lt;/p&gt;

&lt;p&gt;Start with the big picture. Lay out the phases an HTTP request passes through, in order, and you get this (source: the official &lt;a href="https://nginx.org/en/docs/dev/development_guide.html#http_phases" rel="noopener noreferrer"&gt;Phases section of the Development Guide&lt;/a&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;post-read       right after the request is read
    ↓
server-rewrite  rewrite directly under server (before location selection)
    ↓
find-config     pick the location
    ↓
rewrite         rewrite / if inside the location
    ↓
post-rewrite    if the URI changed, go back to find-config
    ↓
preaccess       connection count, rate limiting, etc.
    ↓
access          allow / deny / auth
    ↓
post-access
    ↓
precontent      try_files
    ↓
content         serving files via proxy_pass / root
    ↓
log             access log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same &lt;code&gt;rewrite&lt;/code&gt; runs before location selection (server-rewrite) when written directly under &lt;code&gt;server&lt;/code&gt;, and after it (rewrite) when written inside a &lt;code&gt;location&lt;/code&gt;. Where you put it changes the phase, which changes the order it runs in. What decides the order is not the line sequence but "which block, and which type of directive" — that's how you read this list. Now let's follow how each of the two symptoms travels along this timeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Symptom 1, followed by phase
&lt;/h3&gt;

&lt;p&gt;The part that matters for &lt;code&gt;rewrite ... last&lt;/code&gt; and &lt;code&gt;try_files&lt;/code&gt; is this stretch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ┌─▶ find-config     pick the location (first pass: /app/. with last, the second pass picks a different one)
  │       ↓
  │   rewrite         rewrite ... last rewrites to /public/foo
  │       ↓
  └── post-rewrite    URI changed → back to find-config
          ↓
      precontent      try_files runs here. we're now in the different location picked on the second pass.
                      the try_files written in the original /app/ is never reached → 404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://nginx.org/en/docs/http/ngx_http_rewrite_module.html#rewrite" rel="noopener noreferrer"&gt;&lt;code&gt;last&lt;/code&gt;&lt;/a&gt; in &lt;code&gt;rewrite ... last&lt;/code&gt; means "once you've rewritten the URI, redo location selection with the new URI." So the instant &lt;code&gt;/app/foo&lt;/code&gt; becomes &lt;code&gt;/public/foo&lt;/code&gt;, the post-rewrite phase calls find-config back and picks a location all over again. The rewritten &lt;code&gt;/public/foo&lt;/code&gt; no longer matches &lt;code&gt;/app/&lt;/code&gt;, so it moves to a different location. Meanwhile &lt;a href="https://nginx.org/en/docs/http/ngx_http_core_module.html#try_files" rel="noopener noreferrer"&gt;&lt;code&gt;try_files&lt;/code&gt;&lt;/a&gt; runs way later, in the precontent phase. It may get evaluated wherever the request landed, but the &lt;code&gt;try_files&lt;/code&gt; written in the original &lt;code&gt;/app/&lt;/code&gt; is never reached again. If the destination has no file and no fallback, that's where the 404 comes from. It's not that your &lt;code&gt;=404&lt;/code&gt; fired — the request dead-ended somewhere else, never reaching the original &lt;code&gt;try_files&lt;/code&gt;. "Written below, so it runs after" is not what happens; the phases differ, so regardless of line position the rewrite fires first and jumps out of the location.&lt;/p&gt;

&lt;h3&gt;
  
  
  Symptom 2, followed by phase
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;if&lt;/code&gt; and &lt;code&gt;add_header&lt;/code&gt; take effect in two phases that are far apart.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;find-config     pick the location (/download/)
    ↓
rewrite         if is true → switch the config context to the inner block
    ↓           (the switch carries over into all later phases)
preaccess       connection count, rate limiting, etc.
    ↓
access          allow / deny / auth
    ↓
post-access
    ↓
precontent      try_files
    ↓
content         apply add_header when the response is being sent
                it looks at the block we switched into (X-Debug only)
                → the outer X-Served-By is gone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;if&lt;/code&gt; inside a location is an "implicit nested location" — it creates a config block of its own. And that &lt;code&gt;if&lt;/code&gt; is a rewrite-phase directive. When the condition goes true, this phase switches the request's config context into the inner block, and that state carries over into the phases that follow. Much later, when &lt;a href="https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header" rel="noopener noreferrer"&gt;&lt;code&gt;add_header&lt;/code&gt;&lt;/a&gt; is applied at the point of sending response headers, what it reads is the block we switched into. All that's inside is &lt;code&gt;X-Debug&lt;/code&gt;, and on top of that, &lt;code&gt;add_header&lt;/code&gt; does not inherit from the outer scope once there's even one in the same block (it replaces rather than accumulates), so &lt;code&gt;X-Served-By&lt;/code&gt; disappears entirely. If the condition is false, no switch happens and &lt;code&gt;X-Served-By&lt;/code&gt; shows up as usual. The outer header vanishing is not about line order — it's because the switch that happened in the rewrite phase is still in effect in the later phases. What people call &lt;a href="https://www.nginx.com/resources/wiki/start/topics/depth/ifisevil/" rel="noopener noreferrer"&gt;if is evil&lt;/a&gt; is, at bottom, this "context switch in the rewrite phase."&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptom 1
&lt;/h3&gt;

&lt;p&gt;If you want to keep processing in the same location after the rewrite, change &lt;code&gt;last&lt;/code&gt; to &lt;code&gt;break&lt;/code&gt;. With &lt;code&gt;break&lt;/code&gt;, the post-rewrite return to find-config doesn't happen, so the request can go down to the precontent &lt;code&gt;try_files&lt;/code&gt; while staying in the same location.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/app/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^/app/(.*)&lt;/span&gt;$ &lt;span class="n"&gt;/public/&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="s"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# last → break&lt;/span&gt;
    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&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;If instead you want to route the request to a different location depending on the URI, leave &lt;code&gt;last&lt;/code&gt; as is. In that case, put the fallback in the destination location.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/app/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^/app/(.*)&lt;/span&gt;$ &lt;span class="n"&gt;/public/&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="s"&gt;last&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/public/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# put the fallback in the destination&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which one is right comes down to a design call: do you want to keep processing in the same location after the rewrite, or hand off to a different one?&lt;/p&gt;

&lt;h3&gt;
  
  
  Symptom 2
&lt;/h3&gt;

&lt;p&gt;Don't put content-handling directives (&lt;code&gt;add_header&lt;/code&gt;, &lt;code&gt;proxy_pass&lt;/code&gt;, &lt;code&gt;try_files&lt;/code&gt;, and so on) inside an &lt;code&gt;if&lt;/code&gt;. Treat &lt;code&gt;return&lt;/code&gt; and &lt;code&gt;rewrite&lt;/code&gt; as about the only things that are safe inside &lt;code&gt;if&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The condition here is a query argument (&lt;code&gt;?debug=1&lt;/code&gt;), and &lt;code&gt;location&lt;/code&gt; can't match on the query string. For branching on a value like this, push it out of &lt;code&gt;if&lt;/code&gt; and into a &lt;code&gt;map&lt;/code&gt; that builds a variable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;map&lt;/span&gt; &lt;span class="nv"&gt;$arg_debug&lt;/span&gt; &lt;span class="nv"&gt;$x_debug&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;default&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;"1"&lt;/span&gt;     &lt;span class="s"&gt;"on"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/download/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Served-By&lt;/span&gt; &lt;span class="s"&gt;"nginx"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Debug&lt;/span&gt;     &lt;span class="nv"&gt;$x_debug&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&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;The two &lt;code&gt;add_header&lt;/code&gt; lines sit at the same level, so no nested block replaces anything and both take effect. When &lt;code&gt;$arg_debug&lt;/code&gt; isn't &lt;code&gt;1&lt;/code&gt;, &lt;code&gt;$x_debug&lt;/code&gt; is empty, and nginx &lt;a href="https://github.com/nginx/nginx.org/issues/50" rel="noopener noreferrer"&gt;won't emit an &lt;code&gt;add_header&lt;/code&gt; with an empty value&lt;/a&gt;, so &lt;code&gt;X-Debug&lt;/code&gt; shows up only during debugging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking which location you're in right now
&lt;/h2&gt;

&lt;p&gt;The feel for phases sticks once you get your hands dirty. The quick way is to plant one marker in a suspect location to tell whether the request settled there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/app/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Loc&lt;/span&gt; &lt;span class="s"&gt;"app"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# marker that shows up if you stay in this location&lt;/span&gt;
    &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^/app/(.*)&lt;/span&gt;$ &lt;span class="n"&gt;/public/&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="s"&gt;last&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&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;Look at the headers with &lt;code&gt;curl -sI http://localhost/app/foo&lt;/code&gt;, and if &lt;code&gt;X-Loc: app&lt;/code&gt; isn't there, the request has already left this location via &lt;code&gt;rewrite ... last&lt;/code&gt; (the &lt;code&gt;add_header&lt;/code&gt; you wrote here has no effect once it's gone). Change &lt;code&gt;last&lt;/code&gt; to &lt;code&gt;break&lt;/code&gt;, hit it again, and if &lt;code&gt;X-Loc: app&lt;/code&gt; appears, the request stayed in the same location and made it down to precontent. One marker header lets you see, across phases, which location the request is in right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;When you read an nginx config, your eyes follow the lines top to bottom. But what nginx actually does is run the request along a timeline of phases. When you're questioning a config, don't trace the line order — trace it in this order instead: "which phase does this directive run in? and at that point, which location is the request in?" That usually makes things line up.&lt;/p&gt;

&lt;p&gt;More places where this phase-based behavior produces silent bugs — &lt;code&gt;add_header&lt;/code&gt; inheritance dropping when &lt;code&gt;if&lt;/code&gt; switches context, &lt;code&gt;alias&lt;/code&gt; path traversal, location selection overrides — are catalogued with reproduction cases in &lt;a href="https://dev.to/shinagawa-web/nginx-t-passed-but-the-behavior-is-wrong-config-patterns-that-break-silently-outside-the-syntax-2nnc"&gt;Silent nginx Config Bugs That Pass nginx -t&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>devops</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>nginx Tools for When Your Config Gets Out of Hand</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 04 Aug 2026 13:02:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/nginx-tools-for-when-your-config-gets-out-of-hand-2075</link>
      <guid>https://dev.to/shinagawa-web/nginx-tools-for-when-your-config-gets-out-of-hand-2075</guid>
      <description>&lt;p&gt;nginx config files grow. What starts simple gets layers added on top: another reverse proxy, TLS settings, routing logic that branches and branches again.&lt;/p&gt;

&lt;p&gt;Before long you're looking at 20+ &lt;code&gt;location&lt;/code&gt; blocks and can't say off the top of your head which one handles which request. You add a single &lt;code&gt;add_header&lt;/code&gt; line and &lt;a href="https://dev.to/shinagawa-web/nginx-t-passed-but-the-behavior-is-wrong-config-patterns-that-break-silently-outside-the-syntax-2nnc"&gt;some other header silently disappears&lt;/a&gt;. You fix an &lt;code&gt;alias&lt;/code&gt; path and prod throws 404s while staging is fine. &lt;code&gt;nginx -t&lt;/code&gt; says "syntax is ok." But you don't feel ok about it.&lt;/p&gt;

&lt;p&gt;Every config change comes with a quiet dread: &lt;em&gt;will this break something?&lt;/em&gt; And that dread usually has a basis. nginx config mistakes are silent. Nothing shows up in the error log. The browser gets a normal-looking response. You don't find out something is broken until much later.&lt;/p&gt;

&lt;p&gt;nginx itself is well-documented, but the ecosystem around it is harder to survey. Validation tools, config generators, test frameworks — searching turns up scattered information. This article organizes the common pain points into six categories and maps the tools that address each one.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Problem 1: Prevent config mistakes upfront&lt;/li&gt;
&lt;li&gt;Problem 2: Generate config files&lt;/li&gt;
&lt;li&gt;Problem 3: Automate testing&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Problem 4: Collect metrics&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Problem 5: Run nginx on Kubernetes&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Problem 6: Visualize logs&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Gaps that still aren't filled&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Problem 1: Prevent config mistakes upfront
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;nginx -t&lt;/code&gt; checks syntax. But syntactically valid config can still have security problems. SSRF, path traversal via &lt;code&gt;alias&lt;/code&gt; (e.g., a request to &lt;code&gt;/files/../../etc/passwd&lt;/code&gt; reaches files it shouldn't), HTTP splitting — none of these trigger a syntax error.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gixy: static analysis focused on security
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/dvershinin/gixy" rel="noopener noreferrer"&gt;https://github.com/dvershinin/gixy&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Originally built at Yandex, now actively maintained as a community fork. It checks for well-known security risks: SSRF, alias traversal, HTTP splitting. Install the &lt;code&gt;gixy-ng&lt;/code&gt; fork from PyPI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;gixy-ng
gixy /etc/nginx/nginx.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output looks like this — it tells you which directive is the problem and why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;[WARN]&lt;/span&gt; &lt;span class="s"&gt;[http_splitting]&lt;/span&gt; &lt;span class="s"&gt;Possible&lt;/span&gt; &lt;span class="s"&gt;HTTP-Splitting&lt;/span&gt; &lt;span class="s"&gt;via&lt;/span&gt; &lt;span class="s"&gt;HTTP&lt;/span&gt; &lt;span class="s"&gt;header.&lt;/span&gt;
&lt;span class="s"&gt;Directive:&lt;/span&gt; &lt;span class="s"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$http_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A good fit when you've just inherited an nginx config and want a quick security audit before touching it. Detection is limited to known patterns and there's no custom rule support, but as a first security gate in CI it offers the best return on effort.&lt;/p&gt;

&lt;h3&gt;
  
  
  crossplane: treat config as data
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/nginxinc/crossplane" rel="noopener noreferrer"&gt;https://github.com/nginxinc/crossplane&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A Python library that parses nginx config files into JSON. Useful not just for validation but for dynamically generating config or loading it in tests.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;crossplane&lt;/span&gt;

&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crossplane&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/etc/nginx/nginx.conf&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Check for parse errors
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;errors&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Error: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Walk the config tree to find specific directives
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_directives&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;block&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;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;directive&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;block&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;find_directives&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;block&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;config&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;parsed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Example: flag any proxy_pass that uses plain HTTP
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;find_directives&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;proxy_pass&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;args&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&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;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;http://&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Warning: proxy_pass uses plain HTTP: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parsed output is a tree of directives and their arguments. With a traversal like &lt;code&gt;find_directives&lt;/code&gt;, you can express project-specific rules in Python: "are all &lt;code&gt;proxy_pass&lt;/code&gt; values HTTPS?", "is &lt;code&gt;server_tokens off&lt;/code&gt; set?" Where Gixy is limited to known patterns, crossplane lets you write your own.&lt;/p&gt;

&lt;p&gt;Scripting against config files and automating structural checks in CI are where this earns its place. If you don't have a concrete need to generate or programmatically inspect config, skip it for now. Adding it "because it looks useful" tends to mean it sits unused.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to choose
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Goal&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Find security issues quickly&lt;/td&gt;
&lt;td&gt;Gixy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manipulate or validate config in code&lt;/td&gt;
&lt;td&gt;crossplane&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Gixy alone covers the security CI gate. If you're writing config generation or structural validation logic in Python, you need crossplane. They don't overlap, so using both is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: Generate config files
&lt;/h2&gt;

&lt;p&gt;Writing TLS config from scratch means making a lot of small decisions: cipher suites, HTTP/2 support, HSTS headers. Starting from a known-good template beats hand-rolling it and getting something subtly wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  nginxconfig.io: generate best-practice config in the browser
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://www.digitalocean.com/community/tools/nginx" rel="noopener noreferrer"&gt;https://www.digitalocean.com/community/tools/nginx&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A web app by DigitalOcean that generates a complete nginx config from a series of UI choices. Select your TLS cipher suites, gzip settings, HTTP/2, security headers, and it outputs ready-to-use config files. Free, no account required.&lt;/p&gt;

&lt;p&gt;Use it when bootstrapping a new server or bringing TLS settings up to current best practices. It's an excellent starting point, but don't deploy the output unchanged. Treat it as a template and adjust for your project's requirements.&lt;/p&gt;

&lt;h3&gt;
  
  
  nginx-proxy: auto-generate config in Docker environments
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/nginx-proxy/nginx-proxy" rel="noopener noreferrer"&gt;https://github.com/nginx-proxy/nginx-proxy&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A container that watches Docker start/stop events and automatically updates nginx config. Set a &lt;code&gt;VIRTUAL_HOST&lt;/code&gt; environment variable on your container and nginx-proxy generates the reverse proxy config for it.&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;app&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-app&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;VIRTUAL_HOST=example.com&lt;/span&gt;
  &lt;span class="na"&gt;nginx-proxy&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;nginxproxy/nginx-proxy&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&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="s"&gt;/var/run/docker.sock:/tmp/docker.sock:ro&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;app&lt;/code&gt; starts, nginx-proxy automatically creates config to route &lt;code&gt;example.com&lt;/code&gt; traffic to it.&lt;/p&gt;

&lt;p&gt;In Docker Compose setups where manually updating nginx on every deploy is friction, this removes that burden. Traefik and Caddy do the same thing. If you're not already committed to nginx (existing config, team familiarity), compare them first. If you're already running nginx, nginx-proxy is the natural fit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to choose
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Environment&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Regular Linux server&lt;/td&gt;
&lt;td&gt;nginxconfig.io&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker / Docker Compose&lt;/td&gt;
&lt;td&gt;nginx-proxy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No Docker? nginxconfig.io is enough. In Docker environments, nginx-proxy's automation pays off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: Automate testing
&lt;/h2&gt;

&lt;p&gt;Manually sending test requests after every config change doesn't scale. Automating it means the same checks run every time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test::Nginx: declarative test framework
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/openresty/test-nginx" rel="noopener noreferrer"&gt;https://github.com/openresty/test-nginx&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A Perl-based test framework from the OpenResty community. It starts a real nginx instance, sends requests, and checks responses. The format is declarative — you describe what config to use, what request to send, and what response to expect. Tests read clearly as a result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight perl"&gt;&lt;code&gt;&lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;TEST&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;basic&lt;/span&gt; &lt;span class="nv"&gt;proxy&lt;/span&gt;
&lt;span class="o"&gt;---&lt;/span&gt; &lt;span class="nv"&gt;config&lt;/span&gt;
&lt;span class="nv"&gt;location&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;api&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;proxy_pass&lt;/span&gt; &lt;span class="nv"&gt;http:&lt;/span&gt;&lt;span class="sr"&gt;//&lt;/span&gt;&lt;span class="mf"&gt;127.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;---&lt;/span&gt; &lt;span class="nv"&gt;request&lt;/span&gt;
&lt;span class="nv"&gt;GET&lt;/span&gt; &lt;span class="sr"&gt;/api/us&lt;/span&gt;&lt;span class="nv"&gt;ers&lt;/span&gt;
&lt;span class="o"&gt;---&lt;/span&gt; &lt;span class="nv"&gt;response_body&lt;/span&gt;
&lt;span class="p"&gt;[{"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="p"&gt;":&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This fits nginx module development and verifying that config changes don't alter behavior. Perl is a real barrier for most teams today. If you're not writing custom nginx modules, hurl covers most of what you actually need.&lt;/p&gt;

&lt;h3&gt;
  
  
  hurl: a DSL for HTTP testing
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://hurl.dev/" rel="noopener noreferrer"&gt;https://hurl.dev/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;hurl lets you write HTTP requests and assertions in a plain-text format, then run them from the shell. Easy to slot into CI. Not nginx-specific — it works for HTTP testing in general.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET http://localhost/api/users
HTTP 200
[Asserts]
header "Content-Type" contains "application/json"
jsonpath "$.length" &amp;gt; 0
&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;hurl &lt;span class="nt"&gt;--test&lt;/span&gt; api.hurl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reach for it when you want to confirm API behavior hasn't broken after a config change, or when the team has no Perl experience. Because it's not nginx-specific, it doesn't go deep on nginx internals. It works well for endpoint reachability checks, but for testing precise nginx behavior (match precedence, path rewriting), Test::Nginx is the right tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to choose
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Testing nginx config or modules directly&lt;/td&gt;
&lt;td&gt;Test::Nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checking that the API behind nginx still works&lt;/td&gt;
&lt;td&gt;hurl&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Team has no Perl experience&lt;/td&gt;
&lt;td&gt;hurl&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Test::Nginx for precise nginx behavior verification. hurl for HTTP interface testing with a lower adoption cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: Collect metrics
&lt;/h2&gt;

&lt;p&gt;Without visibility into how many requests nginx is handling and where the bottlenecks are, there's no basis for making informed decisions when problems surface.&lt;/p&gt;

&lt;h3&gt;
  
  
  nginx-module-vts: embedded stats module
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/vozlt/nginx-module-vts" rel="noopener noreferrer"&gt;https://github.com/vozlt/nginx-module-vts&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A module that adds traffic statistics to nginx — request counts, error rates, latency per virtual host and upstream. Built in at compile time. Exposes a &lt;code&gt;/status&lt;/code&gt; endpoint that returns JSON.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;vhost_traffic_status_zone&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/status&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;vhost_traffic_status_display&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;vhost_traffic_status_display_format&lt;/span&gt; &lt;span class="s"&gt;html&lt;/span&gt;&lt;span class="p"&gt;;&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;Detailed traffic stats are the payoff — when you have a build environment and can compile nginx from source. Requiring a source build is the biggest barrier. If you're using a packaged nginx install, this isn't an option. If you already have Prometheus, the overhead of a custom build may not be worth it.&lt;/p&gt;

&lt;h3&gt;
  
  
  nginx-prometheus-exporter: bridge stub_status to Prometheus
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/nginxinc/nginx-prometheus-exporter" rel="noopener noreferrer"&gt;https://github.com/nginxinc/nginx-prometheus-exporter&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Reads nginx's built-in &lt;code&gt;stub_status&lt;/code&gt; data (connection counts, request totals) and exposes it in Prometheus format. Runs as a separate process alongside nginx — no changes to nginx itself required.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/nginx_status&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;stub_status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="mf"&gt;127.0&lt;/span&gt;&lt;span class="s"&gt;.0.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./nginx-prometheus-exporter &lt;span class="nt"&gt;-nginx&lt;/span&gt;.scrape-uri&lt;span class="o"&gt;=&lt;/span&gt;http://localhost/nginx_status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Prometheus + Grafana stacks, this is the easiest way to add nginx metrics without touching nginx itself. &lt;code&gt;stub_status&lt;/code&gt; only gives you connection counts and request totals — no per-upstream breakdown. Confirm that's enough before committing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to choose
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Already running Prometheus&lt;/td&gt;
&lt;td&gt;nginx-prometheus-exporter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need per-host, per-upstream, per-status-code stats&lt;/td&gt;
&lt;td&gt;nginx-module-vts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Using packaged nginx and don't want to rebuild&lt;/td&gt;
&lt;td&gt;nginx-prometheus-exporter&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you're on Prometheus, nginx-prometheus-exporter is by far the easier path. If you need granular metrics and control your build, look at vts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 5: Run nginx on Kubernetes
&lt;/h2&gt;

&lt;p&gt;When exposing services externally in Kubernetes, you use an Ingress resource — a rule that maps incoming paths to backend services. nginx-based Ingress controllers are widely used for this. Two controllers with similar names exist, and they're frequently confused.&lt;/p&gt;

&lt;h3&gt;
  
  
  ingress-nginx: the Kubernetes community controller
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/kubernetes/ingress-nginx" rel="noopener noreferrer"&gt;https://github.com/kubernetes/ingress-nginx&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Maintained by the Kubernetes community (&lt;code&gt;kubernetes/ingress-nginx&lt;/code&gt;). Used as the default by many Kubernetes distributions. Large amount of documentation and community knowledge available.&lt;/p&gt;

&lt;p&gt;In March 2025, a set of serious vulnerabilities called IngressNightmare was disclosed (CVE-2025-1974 and others, CVSS 9.8). Remote code execution was possible through the admission controller component. Fixed in versions 1.12.1 and 1.11.5. If you're running an older version, upgrade now.&lt;/p&gt;

&lt;p&gt;On a general-purpose Kubernetes cluster running standard Ingress, this is the obvious starting point. Switching Ingress controllers later is painful, so the initial choice matters. When in doubt, ingress-nginx has fewer dead ends from a documentation standpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  kubernetes-ingress: the NGINX Inc. controller
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/nginxinc/kubernetes-ingress" rel="noopener noreferrer"&gt;https://github.com/nginxinc/kubernetes-ingress&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Maintained directly by NGINX Inc. (&lt;code&gt;nginxinc/kubernetes-ingress&lt;/code&gt;). Supports both open-source nginx and NGINX Plus (the commercial version).&lt;/p&gt;

&lt;p&gt;The case for it is NGINX Plus features (advanced load balancing, active health checks) or a support contract with NGINX Inc. If you don't need NGINX Plus, there's little reason to choose this over ingress-nginx. For OSS nginx, the community controller has better coverage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to choose
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Community resources matter&lt;/td&gt;
&lt;td&gt;ingress-nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Planning to use NGINX Plus&lt;/td&gt;
&lt;td&gt;kubernetes-ingress&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Starting on EKS / GKE or similar managed cluster&lt;/td&gt;
&lt;td&gt;ingress-nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you go with ingress-nginx, run version 1.12.1 or 1.11.5 or later. Check your current version before anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 6: Visualize logs
&lt;/h2&gt;

&lt;p&gt;nginx access logs pile up as text. Figuring out which paths are getting hammered, or where errors are spiking, is hard to do from raw log files.&lt;/p&gt;

&lt;h3&gt;
  
  
  GoAccess: real-time stats in the terminal
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://goaccess.io/" rel="noopener noreferrer"&gt;https://goaccess.io/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A TUI tool that aggregates nginx access logs and displays them in real time. Graphs and stats update live in the terminal. No log server or database needed — just install and run. Can also generate HTML reports for browser viewing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;goaccess /var/log/nginx/access.log &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;span class="c"&gt;# Starts in interactive mode to select the log format&lt;/span&gt;

&lt;span class="c"&gt;# Stream live logs in COMBINED format&lt;/span&gt;
&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/log/nginx/access.log | goaccess &lt;span class="nt"&gt;--log-format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;COMBINED -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reach for it when you need answers quickly, especially on a server with no log collection infrastructure in place. It reads local log files directly, so multi-server environments are awkward. Think of it as a per-server quick-look tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Loki + Grafana Alloy: long-term log storage and search
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://grafana.com/oss/loki/" rel="noopener noreferrer"&gt;https://grafana.com/oss/loki/&lt;/a&gt; / &lt;a href="https://grafana.com/docs/alloy/latest/" rel="noopener noreferrer"&gt;https://grafana.com/docs/alloy/latest/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Loki stores and queries logs efficiently, with Grafana for dashboards. Grafana Alloy handles log collection. Alloy replaces Promtail, which reached end-of-life in March 2026 — for new setups, use Alloy.&lt;/p&gt;

&lt;p&gt;Example Alloy config (sending nginx logs to Loki):&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="nx"&gt;loki&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="s2"&gt;"nginx"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;targets&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="nx"&gt;__path__&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/var/log/nginx/access.log"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;job&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"nginx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="nx"&gt;forward_to&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loki&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;write&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;receiver&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;loki&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;write&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;endpoint&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http://loki:3100/loki/api/v1/push"&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;It earns its place when you need to aggregate logs from multiple servers, search historical logs by time range, or view nginx logs alongside other metrics in Grafana. Running the full stack yourself carries real operational overhead. Start with Grafana Cloud's free tier, or bring this in when someone on the team already knows how to operate Grafana.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to choose
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Need to check something right now&lt;/td&gt;
&lt;td&gt;GoAccess&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Already running Grafana&lt;/td&gt;
&lt;td&gt;Loki + Grafana Alloy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need long-term log retention and cross-server search&lt;/td&gt;
&lt;td&gt;Loki + Grafana Alloy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging with direct SSH access to the server&lt;/td&gt;
&lt;td&gt;GoAccess&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;GoAccess gives you immediate answers. Loki + Alloy is for ongoing operations. They're not mutually exclusive — GoAccess for fast situational awareness, Loki for accumulating and analyzing over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary: match tool to problem
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Find security risks in config&lt;/td&gt;
&lt;td&gt;Gixy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manipulate config programmatically&lt;/td&gt;
&lt;td&gt;crossplane&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generate correct config (Linux server)&lt;/td&gt;
&lt;td&gt;nginxconfig.io&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto-manage Docker reverse proxies&lt;/td&gt;
&lt;td&gt;nginx-proxy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test nginx config behavior&lt;/td&gt;
&lt;td&gt;Test::Nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add HTTP connectivity checks to CI&lt;/td&gt;
&lt;td&gt;hurl&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collect metrics via Prometheus&lt;/td&gt;
&lt;td&gt;nginx-prometheus-exporter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need detailed per-upstream stats (module)&lt;/td&gt;
&lt;td&gt;nginx-module-vts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Set up Ingress on Kubernetes&lt;/td&gt;
&lt;td&gt;ingress-nginx (1.12.1+)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need NGINX Plus features&lt;/td&gt;
&lt;td&gt;kubernetes-ingress&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Visualize logs immediately&lt;/td&gt;
&lt;td&gt;GoAccess&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long-term log storage and search&lt;/td&gt;
&lt;td&gt;Loki + Grafana Alloy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;nginx itself is stable and mature, but the ecosystem moves fast — Promtail's EOL is a good example. Periodically check the maintenance status of tools you rely on, or you'll find out about a support cutoff at the worst time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gaps that still aren't filled
&lt;/h2&gt;

&lt;p&gt;The tools covered here leave some problems unsolved or underserved. If you know tools that address any of these, drop them in the comments.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dynamic upstream management
&lt;/h3&gt;

&lt;p&gt;Updating upstreams without an nginx reload is a common requirement. With open-source nginx, the only options are OpenResty (Lua scripting for dynamic upstream control) or NGINX Plus (API-driven management). Vanilla nginx requires rewriting config and reloading.&lt;/p&gt;

&lt;h3&gt;
  
  
  Config drift tracking across environments
&lt;/h3&gt;

&lt;p&gt;There's no standard tool for tracking and visualizing how nginx config differs between dev, staging, and production. Git-based management is the practical answer, but cross-environment diff review and detecting prod-only config still means manual work.&lt;/p&gt;

&lt;h3&gt;
  
  
  IDE / LSP support
&lt;/h3&gt;

&lt;p&gt;VSCode extensions for nginx.conf exist, but completion and inline validation are incomplete. No editor today reliably explains &lt;code&gt;location&lt;/code&gt; block match priority or how &lt;code&gt;proxy_pass&lt;/code&gt; transforms paths.&lt;/p&gt;

&lt;h3&gt;
  
  
  WAF
&lt;/h3&gt;

&lt;p&gt;ModSecurity has a solid track record as an nginx WAF, but rule management and false-positive tuning are heavy ongoing work. There's nothing you can drop in and operate at a low maintenance level.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/owasp-modsecurity/ModSecurity-nginx" rel="noopener noreferrer"&gt;https://github.com/owasp-modsecurity/ModSecurity-nginx&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Distributed tracing
&lt;/h3&gt;

&lt;p&gt;The nginx OpenTelemetry module (&lt;code&gt;ngx_otel_module&lt;/code&gt;) shipped in 2023 but is still maturing. Trace collection with Jaeger or Tempo is possible, but documentation and real-world operational experience are thin.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nginxinc/nginx-otel" rel="noopener noreferrer"&gt;https://github.com/nginxinc/nginx-otel&lt;/a&gt;&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>devops</category>
      <category>opensource</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
