<?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>Slow 200s in nginx, fast in the application server's logs</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Fri, 30 Oct 2026 13:02:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/nginx-upstreamconnecttime-comes-in-1-second-steps-357a</link>
      <guid>https://dev.to/shinagawa-web/nginx-upstreamconnecttime-comes-in-1-second-steps-357a</guid>
      <description>&lt;p&gt;An nginx latency alert fires, yet the logs and metrics of the application server behind nginx can show the same requests finishing quickly. This happens when the application server stops entirely for a few seconds, for example during a full GC&lt;sup id="fnref1"&gt;1&lt;/sup&gt;, and connections that arrive in the meantime are kept waiting.&lt;/p&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant N as nginx
    participant K as Application server's kernel
    participant U as Application server
    Note over U: Stopped by a full GC or similar
    N-&amp;gt;&amp;gt;K: SYN (open a connection)
    Note over K: The accept queue is full, so the SYN is dropped&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The dropped SYN is resent every second and connects after the application server resumes. From nginx, these look like 200s that took about 1, 2, or 3 seconds to connect. The three lines below are from the nginx access log, which records &lt;code&gt;$upstream_connect_time&lt;/code&gt; (the seconds nginx took to finish connecting to the application server) as uct. The columns are time, request ID, status, uct, &lt;code&gt;$upstream_header_time&lt;/code&gt; (uht), &lt;code&gt;$upstream_response_time&lt;/code&gt; (urt), and &lt;code&gt;$request_time&lt;/code&gt; (rt). uht and urt are the seconds from when nginx starts connecting to the application server until it receives the start of the response, and until it has received the whole response. rt is the seconds from when nginx receives the request until it finishes sending the response.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1792402370.463 487aefe88a1328206a8e93c7c242399a 200 uct=1.002 uht=1.002 urt=1.002 rt=1.002
1792402370.464 cfdd11ceca67677e3601df796850a712 200 uct=2.067 uht=2.067 urt=2.067 rt=2.068
1792402371.040 dd774240d10448d9dc41bfcaaa7d8dfe 200 uct=3.054 uht=3.055 urt=3.055 rt=3.054
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application server receives the request ID that nginx assigned, and its log records time, request ID, and processing time in milliseconds. These are its lines for the same request IDs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1792402370.463 487aefe88a1328206a8e93c7c242399a 0.092
1792402370.463 cfdd11ceca67677e3601df796850a712 0.104
1792402371.040 dd774240d10448d9dc41bfcaaa7d8dfe 0.084
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nearly all of the time went to connecting, and processing on the application server took about 0.1 ms. The application server starts timing only after it takes the connection off the queue, so the wait to connect does not appear in its logs or metrics.&lt;/p&gt;

&lt;p&gt;The logs above were taken from an application server that receives 200 requests per second, stopped for 3 seconds&lt;sup id="fnref2"&gt;2&lt;/sup&gt;.&lt;/p&gt;

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

&lt;p&gt;When the application server stops, the accept queue fills up. SYNs that arrive after that are dropped, and connections are made through retransmissions by the nginx-side kernel.&lt;/p&gt;

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

&lt;p&gt;The kernel carries out the exchange that sets up a TCP connection (the handshake). The connecting side sends a SYN to request a connection, the receiving side replies with a SYN-ACK to accept it, and the handshake completes when the connecting side sends an ACK. A completed connection waits in the kernel's accept queue until the server process takes it with the accept system call.&lt;/p&gt;

&lt;p&gt;Even while the application server is stopped, the kernel on its side keeps completing handshakes and queuing the connections. Nothing takes connections off the queue during the stop, so the queue fills up.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant N as nginx
    participant NK as nginx-side kernel
    participant UK as Application server's kernel
    participant U as Application server
    Note over U: Stopped
    N-&amp;gt;&amp;gt;NK: Connect to the application server
    NK-&amp;gt;&amp;gt;UK: SYN
    UK--&amp;gt;&amp;gt;NK: SYN-ACK
    NK-&amp;gt;&amp;gt;UK: ACK
    NK--&amp;gt;&amp;gt;N: Connected
    Note over UK: Puts the connection in the accept queue
    Note over U: On resuming, takes connections off the queue in order&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The maximum number of connections the queue can hold is the backlog. The application server passes the backlog to the kernel when it starts listening for connections (when it calls listen). The value comes from the runtime's or framework's default, or from configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  SYNs are dropped when the queue is full
&lt;/h3&gt;

&lt;p&gt;When the number of connections in the queue exceeds the backlog, the application server's kernel drops newly arriving SYNs without replying&lt;sup id="fnref3"&gt;3&lt;/sup&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Retransmissions get through
&lt;/h3&gt;

&lt;p&gt;When a SYN gets no reply, the nginx-side kernel waits and sends it again. This is SYN retransmission. If the nginx-side kernel is Linux 6.5 or later, the first few retransmissions go out at 1-second intervals&lt;sup id="fnref4"&gt;4&lt;/sup&gt;.&lt;/p&gt;

&lt;p&gt;Retransmitted SYNs are dropped too while the queue is full. Once the application server resumes and the queue has room, the next retransmission gets a SYN-ACK and the connection is made. Depending on which retransmission gets through, &lt;code&gt;$upstream_connect_time&lt;/code&gt; is about 1, 2, or 3 seconds. The three log lines at the top are these cases.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant N as nginx
    participant NK as nginx-side kernel
    participant UK as Application server's kernel
    participant U as Application server
    Note over U: Stopped
    Note over UK: The accept queue is full
    N-&amp;gt;&amp;gt;NK: Connect to the application server
    NK-&amp;gt;&amp;gt;UK: SYN
    Note over UK: Drops it without replying
    loop Every second while the queue is full
        NK-&amp;gt;&amp;gt;UK: Resend the SYN
        Note over UK: Drops it again
    end
    Note over U: Resumes and takes connections off the queue
    NK-&amp;gt;&amp;gt;UK: Next retransmission
    UK--&amp;gt;&amp;gt;NK: SYN-ACK
    NK-&amp;gt;&amp;gt;UK: ACK
    NK--&amp;gt;&amp;gt;N: Connected ($upstream_connect_time is about 1, 2, or 3 seconds)&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;p&gt;I stopped the application server for 3 seconds to confirm this sequence. Requests that arrived during the stop split into those that connected immediately and those that connected after waiting about 1, 2, or 3 seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup
&lt;/h3&gt;

&lt;p&gt;nginx and the application server ran as containers on the same Docker network, and nginx was sent 200 requests per second for 15 seconds. Five seconds in, the application server process was stopped from outside with SIGSTOP, then resumed with SIGCONT 3 seconds later. This mimics a full GC stopping the whole process.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;nginx is 1.30.6, with a single application server in the &lt;code&gt;upstream&lt;/code&gt; block. &lt;code&gt;proxy_connect_timeout&lt;/code&gt; (how long nginx waits to finish connecting to the application server) is left at its default of 60 seconds&lt;/li&gt;
&lt;li&gt;The application server is an HTTP server that returns 200 immediately for every request, and listens with a backlog of 100&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Time to connect
&lt;/h3&gt;

&lt;p&gt;In the 3 seconds between the stop and the resume, 600 requests reached nginx, and all of them returned 200. The counts by &lt;code&gt;$upstream_connect_time&lt;/code&gt; are:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;$upstream_connect_time&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Requests&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0 seconds (connected immediately)&lt;/td&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;About 1 second&lt;/td&gt;
&lt;td&gt;204&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;About 2 seconds&lt;/td&gt;
&lt;td&gt;205&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;About 3 seconds&lt;/td&gt;
&lt;td&gt;89&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This holds beyond the three lines at the top: for all 600 requests, the processing time in the application server's log was under 1 ms.&lt;/p&gt;

&lt;p&gt;Which row a request falls into depends on when it arrived. The queue was full about 0.5 seconds after the stop. Connections that arrived before that went into the queue and connected immediately. Connections that arrived later had their SYNs dropped, the nginx-side kernel resent them every second, and they connected on the first retransmission after the application server resumed. The earlier a connection arrived, the more times its SYN was resent and the longer it waited.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spotting it in your own logs
&lt;/h2&gt;

&lt;p&gt;To tell whether the accept queue overflowed, match the nginx access log against the application server's log. The access log shows that SYNs were dropped and the connections were made by retransmission. The application server's log shows that the application server was stopped.&lt;/p&gt;

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

&lt;p&gt;In the access log, look at &lt;code&gt;$upstream_connect_time&lt;/code&gt; on the 200 lines from when the alert fired. If the values cluster around 1, 2, and 3 seconds, SYNs were dropped and the connections were made by retransmission.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;$upstream_connect_time&lt;/code&gt; is not part of the default &lt;code&gt;combined&lt;/code&gt; format, so add it with &lt;code&gt;log_format&lt;/code&gt;&lt;sup id="fnref5"&gt;5&lt;/sup&gt;. The verification used this format:&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;log_format&lt;/span&gt; &lt;span class="s"&gt;lab&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="nv"&gt;$msec&lt;/span&gt; &lt;span class="nv"&gt;$request_id&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="s"&gt;uct=&lt;/span&gt;&lt;span class="nv"&gt;$upstream_connect_time&lt;/span&gt; &lt;span class="s"&gt;uht=&lt;/span&gt;&lt;span class="nv"&gt;$upstream_header_time&lt;/span&gt; &lt;span class="s"&gt;urt=&lt;/span&gt;&lt;span class="nv"&gt;$upstream_response_time&lt;/span&gt; &lt;span class="s"&gt;rt=&lt;/span&gt;&lt;span class="nv"&gt;$request_time&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Application server log
&lt;/h3&gt;

&lt;p&gt;In the application server's log, find the lines with the same IDs as the requests whose &lt;code&gt;$upstream_connect_time&lt;/code&gt; came in 1-second steps, and look at how the lines are spaced around that time. If those requests finished quickly and the lines stop for a while around that time, the application server was stopped. The lines after the gap are bunched at the time it resumed.&lt;/p&gt;

&lt;p&gt;To match the two logs, pass the ID that nginx assigns to each request (&lt;code&gt;$request_id&lt;/code&gt;) to the application server. On the nginx side, this takes one line:&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;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Request-Id&lt;/span&gt; &lt;span class="nv"&gt;$request_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the application server side, its access log needs to be configured to include the received &lt;code&gt;X-Request-Id&lt;/code&gt; header.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix
&lt;/h2&gt;

&lt;p&gt;If the application server's backlog is set larger than the number of requests that arrive during a stop (requests per second × seconds stopped), the queue does not overflow&lt;sup id="fnref6"&gt;6&lt;/sup&gt;. With a backlog of 4096, the 200s of about 1, 2, and 3 seconds no longer appear.&lt;/p&gt;

&lt;p&gt;The default backlogs of JVM servers, which can stop for a full GC, are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tomcat and Spring Boot: 100&lt;sup id="fnref7"&gt;7&lt;/sup&gt;
&lt;/li&gt;
&lt;li&gt;Jetty: 50&lt;sup id="fnref8"&gt;8&lt;/sup&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even without an overflow, requests that arrive during a stop wait in the queue until the application server resumes. That wait shows up in &lt;code&gt;$upstream_header_time&lt;/code&gt;, so 200s slowed by the length of the stop remain.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this varies by environment
&lt;/h2&gt;

&lt;h3&gt;
  
  
  With a shortened connect timeout
&lt;/h3&gt;

&lt;p&gt;If &lt;code&gt;proxy_connect_timeout&lt;/code&gt; is shorter than the stop, nginx gives up on connections whose retransmissions have not gotten through in time and returns 504.&lt;/p&gt;

&lt;h3&gt;
  
  
  When the stop is not 3 seconds
&lt;/h3&gt;

&lt;p&gt;A stop of a different length should change the values &lt;code&gt;$upstream_connect_time&lt;/code&gt; clusters around. This article tests a 3-second stop.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kernels older than 6.5
&lt;/h3&gt;

&lt;p&gt;If the nginx-side kernel is older than 6.5, &lt;code&gt;$upstream_connect_time&lt;/code&gt; clusters around 1 and 3 seconds, and about 2 seconds does not appear. This is because the retransmission interval doubles each time: after 1 second, then 2 seconds after that, then 4 seconds after that.&lt;/p&gt;

&lt;p&gt;The stock kernel of Ubuntu 24.04 (6.8) is 6.5 or later. Those of Ubuntu 22.04 (5.15), Debian 12 (6.1), and RHEL 9 (5.14) are older. Whether a distribution has backported this change into its own kernel has not been checked.&lt;/p&gt;

&lt;h3&gt;
  
  
  With a proxy in front of the application server
&lt;/h3&gt;

&lt;p&gt;If a proxy that accepts connections first sits between nginx and the application server (Docker's userland-proxy, which handles published ports, or a sidecar proxy), that proxy answers the SYNs from nginx. Even if the application server's queue overflows, no retransmissions happen on the nginx side, so the method in this article should not apply. This article does not verify this case.&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;Ordinary GC pauses are not this long. The JVM's G1 collector has a default pause-time goal of 200 ms (&lt;a href="https://github.com/openjdk/jdk/blob/jdk-25-ga/src/hotspot/share/gc/g1/g1Arguments.cpp#L213-L216" rel="noopener noreferrer"&gt;&lt;code&gt;G1Arguments::initialize()&lt;/code&gt;&lt;/a&gt; in JDK 25). Pauses of several seconds come from things like a full GC under heap pressure, which this article does not verify.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn2"&gt;
&lt;p&gt;The results are &lt;a href="https://github.com/shinagawa-web/show-your-work/actions/runs/39230808164/job/117988019977" 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/e1f8b7e929b7084062f4b4bd96ff1e4ce8a40713/nginx-accept-queue-overflow" rel="noopener noreferrer"&gt;show-your-work&lt;/a&gt;. It ran on GitHub Actions ubuntu-24.04 (x86_64, kernel 6.17) with Docker 28.0.4.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn3"&gt;
&lt;p&gt;In the Linux 6.17 source, &lt;a href="https://github.com/torvalds/linux/blob/v6.17/net/ipv4/tcp_input.c#L7166-L7169" rel="noopener noreferrer"&gt;&lt;code&gt;tcp_conn_request()&lt;/code&gt;&lt;/a&gt; drops the SYN when the queue is full. &lt;a href="https://github.com/torvalds/linux/blob/v6.17/include/net/sock.h#L1062-L1065" rel="noopener noreferrer"&gt;&lt;code&gt;sk_acceptq_is_full()&lt;/code&gt;&lt;/a&gt; decides whether it is full, and treats the queue as full when the number of connections in it is greater than (&lt;code&gt;&amp;gt;&lt;/code&gt;) the backlog.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn4"&gt;
&lt;p&gt;The number of retransmissions sent at 1-second intervals is set by &lt;code&gt;net.ipv4.tcp_syn_linear_timeouts&lt;/code&gt; (default 4; see the Linux 6.17 &lt;a href="https://github.com/torvalds/linux/blob/v6.17/Documentation/networking/ip-sysctl.rst#L1075-L1083" rel="noopener noreferrer"&gt;documentation&lt;/a&gt;).&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn5"&gt;
&lt;p&gt;Adding variables with &lt;code&gt;log_format&lt;/code&gt; is covered in &lt;a href="https://dev.to/shinagawa-web/requesttime-looks-fast-but-users-say-its-slow-breaking-nginx-latency-into-4-parts-1pn9"&gt;nginx request_time is fast, but users say it's slow&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn6"&gt;
&lt;p&gt;To go above 4096, also raise &lt;code&gt;net.core.somaxconn&lt;/code&gt; in the application server's kernel. The kernel caps the backlog at &lt;code&gt;somaxconn&lt;/code&gt; (&lt;a href="https://github.com/torvalds/linux/blob/v6.17/net/socket.c#L1906-L1908" rel="noopener noreferrer"&gt;&lt;code&gt;__sys_listen_socket()&lt;/code&gt;&lt;/a&gt; in Linux 6.17), and its default is 4096 (&lt;a href="https://github.com/torvalds/linux/blob/v6.17/net/core/net_namespace.c#L388-L390" rel="noopener noreferrer"&gt;&lt;code&gt;preinit_net_sysctl()&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://github.com/torvalds/linux/blob/v6.17/Documentation/networking/ip-sysctl.rst#L333-L335" rel="noopener noreferrer"&gt;documentation&lt;/a&gt;).&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn7"&gt;
&lt;p&gt;The Tomcat 11 documentation on &lt;a href="https://tomcat.apache.org/tomcat-11.0-doc/config/http.html#Attributes_Standard%20Implementation_acceptCount" rel="noopener noreferrer"&gt;HTTP connector configuration&lt;/a&gt;, and &lt;a href="https://github.com/spring-projects/spring-boot/blob/v4.1.1/module/spring-boot-tomcat/src/main/java/org/springframework/boot/tomcat/autoconfigure/TomcatServerProperties.java#L126" rel="noopener noreferrer"&gt;&lt;code&gt;TomcatServerProperties&lt;/code&gt;&lt;/a&gt; in the Spring Boot 4.1.1 source.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn8"&gt;
&lt;p&gt;Jetty passes 0, and the JDK replaces it with 50. The default is 0 in &lt;a href="https://github.com/jetty/jetty.project/blob/jetty-12.1.14/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/ServerConnector.java#L80" rel="noopener noreferrer"&gt;&lt;code&gt;ServerConnector&lt;/code&gt;&lt;/a&gt; in the Jetty 12.1.14 source, and &lt;a href="https://github.com/openjdk/jdk/blob/jdk-25-ga/src/java.base/share/classes/sun/nio/ch/ServerSocketChannelImpl.java#L336" rel="noopener noreferrer"&gt;&lt;code&gt;ServerSocketChannelImpl&lt;/code&gt;&lt;/a&gt; in the JDK 25 source turns any value below 1 into 50.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>nginx</category>
      <category>linux</category>
      <category>devops</category>
      <category>performance</category>
    </item>
    <item>
      <title>HPA does not keep the replicas you set with kubectl scale</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 27 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/hpa-does-not-keep-the-replicas-you-set-with-kubectl-scale-2jpg</link>
      <guid>https://dev.to/shinagawa-web/hpa-does-not-keep-the-replicas-you-set-with-kubectl-scale-2jpg</guid>
      <description>&lt;p&gt;Sometimes you want more Pods even though CPU usage is low: when the workers in each Pod are all tied up waiting on a downstream response, or when you want capacity in place before an event. In those cases, people sometimes raise replicas with &lt;code&gt;kubectl scale&lt;/code&gt;, even on a Deployment that has an HPA (HorizontalPodAutoscaler). HPA is a controller that decides the number of Pods from metrics such as CPU usage and rewrites the Deployment's replicas.&lt;/p&gt;

&lt;p&gt;Right after the scale-up, READY in &lt;code&gt;kubectl get deploy&lt;/code&gt; matches the new count. HPA does not revert it right away either, so the count looks like it will hold.&lt;/p&gt;

&lt;p&gt;A few minutes later, it is back to the original count. No errors or warnings appear. If HPA reverted it, it is not clear why it took a few minutes to do so.&lt;/p&gt;

&lt;p&gt;I reproduced this state and checked whether HPA was really what reverted it, and why the revert happens a few minutes later.&lt;/p&gt;

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

&lt;p&gt;I reproduced the state above on a Kubernetes v1.37.0 cluster created with kind.&lt;sup id="fnref1"&gt;1&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;I attached an HPA to a Deployment that runs 2 Pods using almost no CPU. The HPA targets 50% CPU utilization, with &lt;code&gt;minReplicas&lt;/code&gt; 2 and &lt;code&gt;maxReplicas&lt;/code&gt; 5. HPA decides the count again every 15 seconds. Below, each of these runs is called a sync.&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 hpa c1-1
NAME   REFERENCE         TARGETS       MINPODS   MAXPODS   REPLICAS   AGE
c1-1   Deployment/c1-1   cpu: 0%/50%   2         5         2          81s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I scaled this Deployment from 2 to 4 with &lt;code&gt;kubectl scale&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 scale deploy/c1-1 &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4
deployment.apps/c1-1 scaled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Right after the scale-up, READY reached &lt;code&gt;4/4&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 deploy c1-1
NAME   READY   UP-TO-DATE   AVAILABLE   AGE
c1-1   4/4     4            4           107s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;About 5 minutes later, it went back to 2.&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 deploy c1-1
NAME   READY   UP-TO-DATE   AVAILABLE   AGE
c1-1   2/2     2            2           7m17s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Deployment replicas can be written back by &lt;code&gt;kubectl apply&lt;/code&gt; run by another person or by CI, by GitOps tools, and by controllers such as HPA.&lt;/p&gt;

&lt;p&gt;Kubernetes objects carry managedFields, a record of which manager (writer) wrote each field of the object.&lt;sup id="fnref2"&gt;2&lt;/sup&gt; Viewing the Deployment after it went back to 2 with &lt;code&gt;kubectl get deploy c1-1 -o yaml --show-managed-fields&lt;/code&gt; showed that the manager of &lt;code&gt;spec.replicas&lt;/code&gt; was &lt;code&gt;kube-controller-manager&lt;/code&gt;.&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
    &lt;span class="na"&gt;fieldsType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;FieldsV1&lt;/span&gt;
    &lt;span class="na"&gt;fieldsV1&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;f:spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;f:replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
    &lt;span class="na"&gt;manager&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-controller-manager&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;Update&lt;/span&gt;
    &lt;span class="na"&gt;subresource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scale&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;kube-controller-manager is the in-cluster process that runs HPA and the other controllers. If &lt;code&gt;kubectl apply&lt;/code&gt; or a GitOps tool had written the field, the manager would be some name other than kube-controller-manager.&lt;/p&gt;

&lt;p&gt;Events show which controller wrote it. An Event is a record of something that happened, which Kubernetes keeps per object. &lt;code&gt;kubectl events --for hpa/c1-1&lt;/code&gt; showed a &lt;code&gt;SuccessfulRescale&lt;/code&gt; at the time of the revert.&lt;sup id="fnref3"&gt;3&lt;/sup&gt; &lt;code&gt;New size: 2&lt;/code&gt; means HPA rewrote replicas to 2. HPA was what reverted it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl events --for hpa/c1-1
LAST SEEN               TYPE      REASON                         OBJECT                         MESSAGE
16s                     Normal    SuccessfulRescale              HorizontalPodAutoscaler/c1-1   New size: 2; reason: All metrics below target
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What remains of the opening question is why HPA takes about 5 minutes to revert.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  What a sync does
&lt;/h3&gt;

&lt;p&gt;On each sync, HPA does the following.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reads the Pods' CPU utilization&lt;/li&gt;
&lt;li&gt;Divides the average utilization by the target to get a ratio, and computes a Pod count from the ratio. Below, this count is called the computed count. If the ratio is between 0.9 and 1.1, the computed count is the current count (the Deployment's &lt;code&gt;spec.replicas&lt;/code&gt;). Outside that range, it is the current number of Pods multiplied by the ratio, rounded up.&lt;sup id="fnref4"&gt;4&lt;/sup&gt;
&lt;/li&gt;
&lt;li&gt;When scaling down, does not go below the maximum computed count over the past 5 minutes. It then clamps the result to the range from &lt;code&gt;minReplicas&lt;/code&gt; to &lt;code&gt;maxReplicas&lt;/code&gt;.&lt;sup id="fnref5"&gt;5&lt;/sup&gt;
&lt;/li&gt;
&lt;li&gt;If the count decided in step 3 differs from the Deployment's &lt;code&gt;spec.replicas&lt;/code&gt; on the cluster, rewrites &lt;code&gt;spec.replicas&lt;/code&gt; to that count&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The "5 minutes" in step 3 is the value used when the HPA has no &lt;code&gt;behavior&lt;/code&gt;, and it comes from the default of kube-controller-manager's &lt;code&gt;--horizontal-pod-autoscaler-downscale-stabilization&lt;/code&gt;.&lt;sup id="fnref6"&gt;6&lt;/sup&gt; It can be changed per HPA with &lt;code&gt;behavior.scaleDown.stabilizationWindowSeconds&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  From the scale-up to the revert
&lt;/h3&gt;

&lt;p&gt;The table below shows the syncs from the scale-up to the revert. Seconds are the time elapsed since running &lt;code&gt;kubectl scale&lt;/code&gt;. The computed counts were taken from kube-controller-manager logs in the reproduction environment, with &lt;code&gt;--v=4&lt;/code&gt; (a setting that makes the logs more detailed).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Since the operation&lt;/th&gt;
&lt;th&gt;Metrics for the 2 new Pods&lt;/th&gt;
&lt;th&gt;Computed count&lt;/th&gt;
&lt;th&gt;replicas&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;8 s&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23 s&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;38 s&lt;/td&gt;
&lt;td&gt;present (0%)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;(same every 15 s)&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;323 s&lt;/td&gt;
&lt;td&gt;present (0%)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At the 8 s and 23 s syncs, the 2 new Pods are running but have no metrics yet. metrics-server is the component that collects Pod CPU and memory usage from each node and passes it to HPA. Values for a Pod that has just started do not appear right away.&lt;/p&gt;

&lt;p&gt;When the ratio computed from only the Pods that have metrics is below 1, HPA recomputes it, treating the Pods without metrics as 100% utilized.&lt;sup id="fnref7"&gt;7&lt;/sup&gt; Here the 2 existing Pods are at 0% and the 2 new ones at 100%, so the average is 50%. The ratio to the 50% target is 1.0, so in step 2 the computed count is the current count, 4.&lt;/p&gt;

&lt;p&gt;From the 38 s sync on, metrics for the 2 new Pods are available and the computed count becomes 0. In step 3, though, HPA does not go below 4, the maximum computed count over the past 5 minutes, so replicas stays at 4.&lt;/p&gt;

&lt;p&gt;At the 323 s sync, the computed count of 4 from 23 s is now more than 5 minutes old, and the maximum computed count over the past 5 minutes becomes 0. HPA clamps this to &lt;code&gt;minReplicas&lt;/code&gt;, 2, and rewrites &lt;code&gt;spec.replicas&lt;/code&gt; to 2.&lt;/p&gt;

&lt;p&gt;To HPA this is an ordinary scale-down, so the only Event is a Normal &lt;code&gt;SuccessfulRescale&lt;/code&gt;, with no errors or warnings.&lt;/p&gt;

&lt;h2&gt;
  
  
  How long the kubectl scale count lasts
&lt;/h2&gt;

&lt;p&gt;HPA takes the replicas a person wrote with &lt;code&gt;kubectl scale&lt;/code&gt; into its calculation as the current count. When scaling down, HPA preserves only the computed counts from the past 5 minutes.&lt;/p&gt;

&lt;p&gt;The count a person wrote lasts only until 5 minutes after the computed count last matched it. In the reproduction, the 4 Pods lasted about 5 minutes because the computed count was 4 at the syncs before the 2 new Pods had metrics, and that 4 stayed as the maximum computed count over the past 5 minutes.&lt;/p&gt;

&lt;p&gt;If the computed count never matches that number, the first sync reverts it. In the same setup, scaling from 2 to 3 went back to 2 at the sync about 10 seconds later. Even with the 1 Pod without metrics treated as 100% utilized, the average across 3 Pods is about 33%. The ratio to the 50% target is below 0.9, and the computed count is 2.&lt;/p&gt;

&lt;p&gt;Whether the revert comes a few minutes later or right away, HPA is what does it.&lt;/p&gt;

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

&lt;p&gt;The two places to change are the HPA's lower bound (&lt;code&gt;minReplicas&lt;/code&gt;) and its scale-down rules (&lt;code&gt;behavior.scaleDown&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Raise &lt;code&gt;minReplicas&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Raise the HPA's &lt;code&gt;minReplicas&lt;/code&gt; to the count you want. replicas goes up immediately. Only the lower bound goes up, so HPA can still add more Pods if load increases.&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 hpa &amp;lt;hpa&amp;gt; &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;merge &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"spec":{"minReplicas":4}}'&lt;/span&gt;
horizontalpodautoscaler.autoscaling/&amp;lt;hpa&amp;gt; patched
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the count you want is above &lt;code&gt;maxReplicas&lt;/code&gt;, raise &lt;code&gt;maxReplicas&lt;/code&gt; in the same patch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Disable scale-down, then scale up
&lt;/h3&gt;

&lt;p&gt;Set the HPA's &lt;code&gt;behavior.scaleDown.selectPolicy&lt;/code&gt; to &lt;code&gt;Disabled&lt;/code&gt;, then scale up with &lt;code&gt;kubectl scale&lt;/code&gt;. HPA no longer reduces replicas.&lt;sup id="fnref8"&gt;8&lt;/sup&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 patch hpa &amp;lt;hpa&amp;gt; &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;merge &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"spec":{"behavior":{"scaleDown":{"selectPolicy":"Disabled"}}}}'&lt;/span&gt;
horizontalpodautoscaler.autoscaling/&amp;lt;hpa&amp;gt; patched
&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl scale deploy/&amp;lt;deployment&amp;gt; &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4
deployment.apps/&amp;lt;deployment&amp;gt; scaled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Which to choose
&lt;/h3&gt;

&lt;p&gt;I choose to raise &lt;code&gt;minReplicas&lt;/code&gt;. The only thing it stops is scaling down below the lower bound. HPA keeps adding Pods as load rises and keeps scaling down as far as the lower bound. Disabling scale-down also stops HPA from removing the Pods it added in response to load.&lt;/p&gt;

&lt;p&gt;When you no longer need the extra Pods, set &lt;code&gt;minReplicas&lt;/code&gt; back to its original value.&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;The results are &lt;a href="https://github.com/shinagawa-web/show-your-work/actions/runs/38530770017" 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/c5c2b71/hpa-reverts-manual-scale" 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;&lt;a href="https://kubernetes.io/docs/reference/using-api/server-side-apply/#field-management" rel="noopener noreferrer"&gt;Field management&lt;/a&gt; in the Kubernetes documentation.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn3"&gt;
&lt;p&gt;Events are deleted after 1 hour by default, because the default of kube-apiserver's &lt;a href="https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/" rel="noopener noreferrer"&gt;&lt;code&gt;--event-ttl&lt;/code&gt;&lt;/a&gt; is &lt;code&gt;1h0m0s&lt;/code&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn4"&gt;
&lt;p&gt;&lt;a href="https://kubernetes.io/docs/concepts/workloads/autoscaling/horizontal-pod-autoscale/#algorithm-details" rel="noopener noreferrer"&gt;Algorithm details&lt;/a&gt; in the Kubernetes documentation.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn5"&gt;
&lt;p&gt;&lt;a href="https://github.com/kubernetes/kubernetes/blob/v1.37.0/pkg/controller/podautoscaler/horizontal.go#L1109-L1127" rel="noopener noreferrer"&gt;&lt;code&gt;normalizeDesiredReplicas&lt;/code&gt;&lt;/a&gt; in kube-controller-manager.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn6"&gt;
&lt;p&gt;kube-controller-manager's &lt;a href="https://github.com/kubernetes/kubernetes/blob/v1.37.0/pkg/controller/podautoscaler/config/v1alpha1/defaults.go#L43-L45" rel="noopener noreferrer"&gt;defaults&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn7"&gt;
&lt;p&gt;&lt;a href="https://github.com/kubernetes/kubernetes/blob/v1.37.0/pkg/controller/podautoscaler/replica_calculator.go#L121-L128" rel="noopener noreferrer"&gt;replica_calculator.go&lt;/a&gt; in kube-controller-manager.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn8"&gt;
&lt;p&gt;&lt;a href="https://kubernetes.io/docs/concepts/workloads/autoscaling/horizontal-pod-autoscale/#example-disable-scale-down" rel="noopener noreferrer"&gt;Example: disable scale down&lt;/a&gt; in the Kubernetes documentation.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>sre</category>
      <category>debugging</category>
    </item>
    <item>
      <title>301 redirect change doesn't take effect for some users</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Fri, 23 Oct 2026 13:03:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/301-redirect-change-doesnt-take-effect-for-some-users-3gh4</link>
      <guid>https://dev.to/shinagawa-web/301-redirect-change-doesnt-take-effect-for-some-users-3gh4</guid>
      <description>&lt;p&gt;On the documentation site, &lt;code&gt;/docs&lt;/code&gt; redirects to the pages for the latest version. Links on other sites, such as the product site, point to &lt;code&gt;/docs&lt;/code&gt;. For the v4 release, the nginx config was edited to change the target from &lt;code&gt;/docs/v3/&lt;/code&gt; to &lt;code&gt;/docs/v4/&lt;/code&gt;.&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;/docs&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt; &lt;span class="n"&gt;/docs/v4/&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;301 is the status code meaning the requested URL has moved permanently to another URL. The &lt;code&gt;/docs&lt;/code&gt; URL itself will not change, so it was set up as a 301.&lt;/p&gt;

&lt;p&gt;After the change, reports keep coming in: "When I open it from the link on the product site, I get v3." Running curl against &lt;code&gt;/docs&lt;/code&gt; locally returns a 301 to v4.&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;$ curl -sI https://&amp;lt;host&amp;gt;/docs
&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="m"&gt;301&lt;/span&gt; &lt;span class="ne"&gt;Moved Permanently&lt;/span&gt;
&lt;span class="na"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://&amp;lt;host&amp;gt;/docs/v4/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I reproduced this with nginx and browsers and measured why some users land on v3.&lt;/p&gt;

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

&lt;p&gt;Some of the users who opened &lt;code&gt;/docs&lt;/code&gt; from the product site link while it pointed to v3 keep reading the v3 pages after the v4 release. No error appears, so only the users who notice send a report. The ones who don't notice go on configuring and working by the v3 instructions.&lt;/p&gt;

&lt;p&gt;This does not go away, however many days pass. It happens again every time a release changes the target.&lt;/p&gt;

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

&lt;p&gt;I reproduced the scenario above with nginx 1.31.6 and three browsers driven by Playwright 1.63.0: Chromium, WebKit (the engine behind Safari), and Firefox.&lt;sup id="fnref1"&gt;1&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;A page containing only a link to &lt;code&gt;/docs&lt;/code&gt; is served on a host name separate from the documentation site, and &lt;code&gt;/docs&lt;/code&gt; is opened from that page. &lt;code&gt;/docs&lt;/code&gt; responds with the same &lt;code&gt;return 301&lt;/code&gt; as above.&lt;/p&gt;

&lt;p&gt;While the target is v3, open &lt;code&gt;/docs&lt;/code&gt; from the link page. Then change the nginx config to v4 and reload. After that, open &lt;code&gt;/docs&lt;/code&gt; from the link page three times: right after the switch, 70 seconds later, and after restarting the browser. Each time, record which version's page the browser landed on.&lt;/p&gt;

&lt;p&gt;Chromium and WebKit landed on v3 all three times. Firefox landed on v4 from right after the switch.&lt;sup id="fnref2"&gt;2&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;/docs&lt;/code&gt; was opened while the target was v3, all three browsers left a &lt;code&gt;/docs&lt;/code&gt; line in the nginx access log.&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="p"&gt;&amp;lt;&lt;/span&gt;client&lt;span class="sr"&gt; IP&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;-&lt;/span&gt; &lt;span class="err"&gt;-&lt;/span&gt; &lt;span class="err"&gt;[&lt;/span&gt;15/Oct/2026:23:13:51 +0000] "GET /docs HTTP/1.1" 301 169 ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the switch, there was no &lt;code&gt;/docs&lt;/code&gt; line for any of the three times Chromium and WebKit landed on v3. Firefox, which landed on v4, left a &lt;code&gt;/docs&lt;/code&gt; line all three times. Chromium and WebKit went to v3 without asking the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why v3 keeps showing
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;/docs&lt;/code&gt; 301 does not say how long it may be stored and reused. How to treat a 301 without that lifetime is up to the browser. Chromium treats it as never expiring and WebKit as valid for one year, and both reuse the stored 301 without asking the server.&lt;sup id="fnref3"&gt;3&lt;/sup&gt;&lt;sup id="fnref4"&gt;4&lt;/sup&gt; So users who stored the 301 to v3 still land on v3 after the switch. Firefox does not reuse the stored 301 as is. It asks the server every time the page is opened.&lt;/p&gt;

&lt;p&gt;These are the headers of the &lt;code&gt;/docs&lt;/code&gt; 301:&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;$ curl -sI https://&amp;lt;host&amp;gt;/docs
&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="m"&gt;301&lt;/span&gt; &lt;span class="ne"&gt;Moved Permanently&lt;/span&gt;
&lt;span class="na"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx/1.31.6&lt;/span&gt;
&lt;span class="na"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Thu, 15 Oct 2026 23:14:03 GMT&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;text/html&lt;/span&gt;
&lt;span class="na"&gt;Content-Length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;169&lt;/span&gt;
&lt;span class="na"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://&amp;lt;host&amp;gt;/docs/v4/&lt;/span&gt;
&lt;span class="na"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keep-alive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Cache-Control: max-age=&amp;lt;seconds&amp;gt;&lt;/code&gt; is how a response tells the browser it may store the response and use it without asking for that many seconds. This response has no such header. Nor does it have &lt;code&gt;Last-Modified&lt;/code&gt;, the header that gives the time the file was last modified.&lt;/p&gt;

&lt;p&gt;Firefox computes the lifetime of a 301 without a stated lifetime from &lt;code&gt;Last-Modified&lt;/code&gt;.&lt;sup id="fnref5"&gt;5&lt;/sup&gt; This response has no &lt;code&gt;Last-Modified&lt;/code&gt;, so the lifetime is 0, which means the response is already expired when it is stored. That is why Firefox asks the server every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix
&lt;/h2&gt;

&lt;p&gt;Switch to a 302, or give the 301 a lifetime with &lt;code&gt;max-age&lt;/code&gt;. Either way, users who have already stored the 301 to v3 stay on v3. Those users are covered in the notes at the end.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use a 302
&lt;/h3&gt;

&lt;p&gt;302 is the status code meaning the requested URL has moved temporarily to another URL. In nginx:&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;/docs&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt; &lt;span class="n"&gt;/docs/v4/&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;For a 302 with neither a stated lifetime nor &lt;code&gt;Last-Modified&lt;/code&gt;, Chromium, WebKit, and Firefox all set the lifetime to 0 and ask the server for &lt;code&gt;/docs&lt;/code&gt; every time it is opened.&lt;sup id="fnref3"&gt;3&lt;/sup&gt;&lt;sup id="fnref4"&gt;4&lt;/sup&gt;&lt;sup id="fnref5"&gt;5&lt;/sup&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Add max-age to the 301
&lt;/h3&gt;

&lt;p&gt;You can also keep the 301 and state a lifetime with &lt;code&gt;max-age&lt;/code&gt; in &lt;code&gt;Cache-Control&lt;/code&gt;. The verification used 60 seconds.&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;/docs&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;"max-age=60"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt; &lt;span class="n"&gt;/docs/v4/&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;Headers added with &lt;code&gt;add_header&lt;/code&gt; are also attached to the 301 sent by &lt;code&gt;return&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The tradeoff is that users who opened &lt;code&gt;/docs&lt;/code&gt; just before a release land on the previous version for up to &lt;code&gt;max-age&lt;/code&gt; seconds after the release.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to choose
&lt;/h3&gt;

&lt;p&gt;I choose the 302. At the start, &lt;code&gt;/docs&lt;/code&gt; got a 301 because its URL does not change. A 301, however, says that the move from &lt;code&gt;/docs&lt;/code&gt; to its target is permanent. The target changes with every release, so the move is temporary.&lt;/p&gt;

&lt;p&gt;I don't choose &lt;code&gt;max-age&lt;/code&gt;. It saves one round trip to &lt;code&gt;/docs&lt;/code&gt; during the lifetime, but in exchange, users who opened &lt;code&gt;/docs&lt;/code&gt; just before a release land on the previous version.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  With a 302
&lt;/h3&gt;

&lt;p&gt;With the config already using &lt;code&gt;return 302&lt;/code&gt; while the target was v3, I opened &lt;code&gt;/docs&lt;/code&gt; with the same steps as the reproduction.&lt;sup id="fnref2"&gt;2&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;Time&lt;/th&gt;
&lt;th&gt;Chromium&lt;/th&gt;
&lt;th&gt;WebKit&lt;/th&gt;
&lt;th&gt;Firefox&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Right after the switch&lt;/td&gt;
&lt;td&gt;v4&lt;/td&gt;
&lt;td&gt;v4&lt;/td&gt;
&lt;td&gt;v4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;70 seconds later&lt;/td&gt;
&lt;td&gt;v4&lt;/td&gt;
&lt;td&gt;v4&lt;/td&gt;
&lt;td&gt;v4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three browsers landed on v4 from right after the switch.&lt;/p&gt;

&lt;h3&gt;
  
  
  With max-age on the 301
&lt;/h3&gt;

&lt;p&gt;With the config already returning the 301 with &lt;code&gt;max-age=60&lt;/code&gt; while the target was v3, I opened &lt;code&gt;/docs&lt;/code&gt; with the same steps as the reproduction.&lt;sup id="fnref2"&gt;2&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;Time&lt;/th&gt;
&lt;th&gt;Chromium&lt;/th&gt;
&lt;th&gt;WebKit&lt;/th&gt;
&lt;th&gt;Firefox&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Right after the switch&lt;/td&gt;
&lt;td&gt;v3&lt;/td&gt;
&lt;td&gt;v3&lt;/td&gt;
&lt;td&gt;v3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;70 seconds later&lt;/td&gt;
&lt;td&gt;v4&lt;/td&gt;
&lt;td&gt;v4&lt;/td&gt;
&lt;td&gt;v4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Right after the switch, Firefox also used the stored 301 and landed on v3. At 70 seconds, after the 60-second lifetime had passed, all three landed on v4.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Affected browsers
&lt;/h3&gt;

&lt;p&gt;Chromium-based browsers and Safari keep landing on v3. Firefox 139 and later are not affected. Firefox 138 and earlier, and the ESR releases based on them, are. Firefox 138 and earlier treated a 301 without a stated lifetime as never expiring.&lt;sup id="fnref6"&gt;6&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;Chromium stores the 301 separately depending on whether the page was opened from a link on another site or typed into the address bar.&lt;sup id="fnref7"&gt;7&lt;/sup&gt; In the verification, after the 301 to v3 was stored by opening &lt;code&gt;/docs&lt;/code&gt; from the link page and the target was switched to v4, opening &lt;code&gt;/docs&lt;/code&gt; directly, without going through a link on another site, took Chromium to v4 and WebKit to v3.&lt;sup id="fnref2"&gt;2&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;To check this in your own Chrome, open &lt;code&gt;/docs&lt;/code&gt; from a link on another site, the way users do.&lt;/p&gt;

&lt;h3&gt;
  
  
  Users who still land on v3 after changing to 302
&lt;/h3&gt;

&lt;p&gt;I also ran the case where the 301 to v3 is stored first and the config is changed to &lt;code&gt;return 302&lt;/code&gt; at the switch, opening &lt;code&gt;/docs&lt;/code&gt; with the same steps as the reproduction. Chromium and WebKit landed on v3 all three times: right after the switch, 70 seconds later, and after restarting the browser.&lt;sup id="fnref2"&gt;2&lt;/sup&gt; They keep using the stored 301 to v3, so the change to 302 never reaches these users.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Clear-Site-Data&lt;/code&gt; is a header that tells the browser receiving it to delete that host's cache and other stored data. In the verification, the v4 page, on the same origin as &lt;code&gt;/docs&lt;/code&gt;, returned this header.&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;/docs/v4/&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;Clear-Site-Data&lt;/span&gt; &lt;span class="s"&gt;'"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;For users who opened this page, the stored 301 was deleted, and Chromium and WebKit also landed on v4 from &lt;code&gt;/docs&lt;/code&gt;.&lt;sup id="fnref2"&gt;2&lt;/sup&gt; The header only reaches users who open a v4 page. Users being sent to v3 never get to a v4 page from &lt;code&gt;/docs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Note that the header tells the browser to delete the entire cache for that host, not just the v4 page. The header also works only over HTTPS (or on localhost).&lt;sup id="fnref8"&gt;8&lt;/sup&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Setups that give the redirect a lifetime
&lt;/h3&gt;

&lt;p&gt;If &lt;code&gt;expires&lt;/code&gt; is set at the server level, the 301 sent by &lt;code&gt;return&lt;/code&gt; in a location also gets a &lt;code&gt;Cache-Control&lt;/code&gt; header.&lt;sup id="fnref9"&gt;9&lt;/sup&gt; In the verification, with &lt;code&gt;expires 30d&lt;/code&gt; at the server level and only &lt;code&gt;return 301&lt;/code&gt; in the &lt;code&gt;/docs&lt;/code&gt; location, the 301 for &lt;code&gt;/docs&lt;/code&gt; got &lt;code&gt;Cache-Control: max-age=2592000&lt;/code&gt; (30 days).&lt;sup id="fnref2"&gt;2&lt;/sup&gt; Users land on the previous version for up to 30 days after the release.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setups not tested
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Sites with a registered Service Worker that answers requests to &lt;code&gt;/docs&lt;/code&gt; with a locally held response&lt;/li&gt;
&lt;li&gt;Setups with a CDN in front of nginx that stores the &lt;code&gt;/docs&lt;/code&gt; 301 and serves it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In both, the request does not reach nginx, so the &lt;code&gt;/docs&lt;/code&gt; lines in the access log cannot tell you whether the browser used a stored 301.&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;Run on GitHub Actions (ubuntu-24.04). Browser versions: Chromium 153.0.8010.12, WebKit 26.6, Firefox 155.0. The logs and curl output in the text have the per-condition paths and host names rewritten to &lt;code&gt;/docs&lt;/code&gt; and &lt;code&gt;&amp;lt;host&amp;gt;&lt;/code&gt;. The config, scripts, and raw logs are in &lt;a href="https://github.com/shinagawa-web/show-your-work/tree/4ad843b9d33fc20b1b97344b94d560cb5a809f46/cached-301-stale-redirect" 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;Verification results: &lt;a href="https://github.com/shinagawa-web/show-your-work/actions/runs/38822188959/job/116721140834" rel="noopener noreferrer"&gt;CI job&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn3"&gt;
&lt;p&gt;Chromium's &lt;a href="https://github.com/chromium/chromium/blob/971a7443b0c9b0a9b2860529b33331b76077ec62/net/http/http_response_headers.cc#L1356-L1370" rel="noopener noreferrer"&gt;http_response_headers.cc&lt;/a&gt; (&lt;code&gt;GetFreshnessLifetimes&lt;/code&gt;). A 301 without a stated lifetime never expires. A 302 gets 0.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn4"&gt;
&lt;p&gt;WebKit's &lt;a href="https://github.com/WebKit/WebKit/blob/4d05d732e5a84f32675bef4cc135a2e7a9269a87/Source/WebCore/platform/network/CacheValidation.cpp#L120-L149" rel="noopener noreferrer"&gt;CacheValidation.cpp&lt;/a&gt; (&lt;code&gt;computeFreshnessLifetimeForHTTPFamily&lt;/code&gt;). A 301 without a stated lifetime gets one year. Anything else gets a lifetime computed from &lt;code&gt;Last-Modified&lt;/code&gt;, or 0 if there is none.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn5"&gt;
&lt;p&gt;Firefox's &lt;a href="https://github.com/mozilla-firefox/firefox/blob/d065a04bc5610f496762935dee56604a78b91b51/netwerk/protocol/http/nsHttpResponseHead.cpp#L627-L701" rel="noopener noreferrer"&gt;nsHttpResponseHead.cpp&lt;/a&gt; (&lt;code&gt;ComputeFreshnessLifetime&lt;/code&gt;). A 301 without a stated lifetime gets a lifetime computed from &lt;code&gt;Last-Modified&lt;/code&gt;, or 0 if there is none. A 302 without a stated lifetime gets 0.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn6"&gt;
&lt;p&gt;Mozilla &lt;a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1957619" rel="noopener noreferrer"&gt;Bug 1957619&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn7"&gt;
&lt;p&gt;Chromium's &lt;a href="https://github.com/chromium/chromium/blob/971a7443b0c9b0a9b2860529b33331b76077ec62/net/http/http_cache.cc#L865-L873" rel="noopener noreferrer"&gt;http_cache.cc&lt;/a&gt;. Top-level navigations coming from another site are stored under a separate cache key.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn8"&gt;
&lt;p&gt;W3C's &lt;a href="https://w3c.github.io/webappsec-clear-site-data/#clear-response" rel="noopener noreferrer"&gt;Clear Site Data&lt;/a&gt;. &lt;code&gt;"cache"&lt;/code&gt; deletes the cache for the same host as the response URL (4.2.3). If the response URL is not treated as secure (HTTPS and localhost URLs are), nothing is deleted (4.2).&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn9"&gt;
&lt;p&gt;nginx's &lt;a href="https://nginx.org/en/docs/http/ngx_http_headers_module.html" rel="noopener noreferrer"&gt;ngx_http_headers_module&lt;/a&gt;. &lt;code&gt;expires&lt;/code&gt; adds &lt;code&gt;Expires&lt;/code&gt; and &lt;code&gt;Cache-Control&lt;/code&gt; to responses with status code 301 as well.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>nginx</category>
      <category>webdev</category>
      <category>devops</category>
      <category>caching</category>
    </item>
    <item>
      <title>K8s upgrade removes cgroup paths and changes how you read the values</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 20 Oct 2026 13:01:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/k8s-upgrade-drops-a-path-fixed-scripts-read-other-values-1nje</link>
      <guid>https://dev.to/shinagawa-web/k8s-upgrade-drops-a-path-fixed-scripts-read-other-values-1nje</guid>
      <description>&lt;p&gt;After a Kubernetes version upgrade replaces the node image, procedures and scripts that read container CPU and memory values from under &lt;code&gt;/sys/fs/cgroup&lt;/code&gt; stop working.&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; &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/cpu/cpu.stat
&lt;span class="nb"&gt;cat&lt;/span&gt;: can&lt;span class="s1"&gt;'t open '&lt;/span&gt;/sys/fs/cgroup/cpu/cpu.stat&lt;span class="s1"&gt;': No such file or directory
command terminated with exit code 1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running &lt;code&gt;ls&lt;/code&gt; inside the container shows no &lt;code&gt;cpu/&lt;/code&gt; and no &lt;code&gt;memory/&lt;/code&gt;. &lt;code&gt;cpu.stat&lt;/code&gt; and &lt;code&gt;memory.stat&lt;/code&gt;, which used to live in separate directories, are listed directly. The node's &lt;code&gt;/sys/fs/cgroup&lt;/code&gt; looks the same.&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; &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;ls&lt;/span&gt; /sys/fs/cgroup
cgroup.controllers
cgroup.events
...
cpu.stat
...
memory.current
memory.events
...
memory.stat
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The layout with one directory per controller is cgroup v1, and this single flat layout is cgroup v2. The switch from v1 to v2 happened when the node image changed.&lt;/p&gt;

&lt;p&gt;Removing &lt;code&gt;cpu/&lt;/code&gt; from the script's path opens a file with the same name.&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 &amp;lt;pod&amp;gt; -- cat /sys/fs/cgroup/cpu.stat
usage_usec 768869
user_usec 684571
system_usec 84297
nice_usec 0
core_sched.force_idle_usec 0
nr_periods 38
nr_throttled 37
throttled_usec 1592698
nr_bursts 0
burst_usec 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;throttled_time&lt;/code&gt; line from the old &lt;code&gt;cpu.stat&lt;/code&gt; is missing, and there is a &lt;code&gt;throttled_usec&lt;/code&gt; line in its place. A script that collected &lt;code&gt;throttled_time&lt;/code&gt; finds no matching line and returns an empty result without raising an error.&lt;/p&gt;

&lt;p&gt;Even once the files open, some values read the v1 way come out different. This article covers three of them: CPU throttle time, memory usage, and OOM count.&lt;sup id="fnref1"&gt;1&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;It is written for people who read the files under &lt;code&gt;/sys/fs/cgroup&lt;/code&gt; with their own scripts or runbooks. If you see these values through a monitoring agent such as cAdvisor, the agent handles v2 itself, and that case is outside the scope here. An explanation of v2 itself and the migration steps are on the &lt;a href="https://kubernetes.io/blog/2026/10/06/kubernetes-cgroups-v2-shift/" rel="noopener noreferrer"&gt;Kubernetes blog&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three reads that change
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Throttle time
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;throttled_time&lt;/code&gt;, missing from the &lt;code&gt;cpu.stat&lt;/code&gt; above, became &lt;code&gt;throttled_usec&lt;/code&gt;. It is the total time the cgroup was stopped for hitting its CPU limit, and its unit changed from nanoseconds to microseconds.&lt;sup id="fnref2"&gt;2&lt;/sup&gt; If you change only the key name and keep the &lt;code&gt;/1e9&lt;/code&gt; that converts nanoseconds to seconds, you get a value 1/1000 of the real one.&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; &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$1=="throttled_usec"{printf "%.6f\n", $2/1e9}'&lt;/span&gt; /sys/fs/cgroup/cpu.stat
0.001593
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;/1e6&lt;/code&gt; instead, it comes out to 1.592698 seconds.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;nr_throttled&lt;/code&gt; and &lt;code&gt;nr_periods&lt;/code&gt;, which give the fraction of periods that hit the limit, keep the same names and meanings as in v1, so the division works unchanged.&lt;/p&gt;

&lt;h3&gt;
  
  
  Memory usage
&lt;/h3&gt;

&lt;p&gt;In v1, memory usage was calculated with this formula.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;memory.usage_in_bytes − total_inactive_file (a line in memory.stat)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a process reads or writes a file, the kernel keeps the contents in memory as page cache, and the cgroup's usage includes it. The part of the page cache that hasn't been used recently is &lt;code&gt;inactive_file&lt;/code&gt;, which the kernel reclaims as soon as memory runs short. Usage minus &lt;code&gt;inactive_file&lt;/code&gt; is the part that can't be released right away: the working set. The kubelet exposes this value as &lt;code&gt;container_memory_working_set_bytes&lt;/code&gt;, and the memory column of &lt;code&gt;kubectl top pod&lt;/code&gt; shows the same value.&lt;sup id="fnref3"&gt;3&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;In v2, the formula becomes:&lt;sup id="fnref4"&gt;4&lt;/sup&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;memory.current − inactive_file (a line in memory.stat)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;v2's &lt;code&gt;memory.stat&lt;/code&gt; has no lines starting with &lt;code&gt;total_&lt;/code&gt;. The aggregate lines from v1 appear in v2 under their names with &lt;code&gt;total_&lt;/code&gt; removed.&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; &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/memory.current
108158976
&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; ^inactive_file /sys/fs/cgroup/memory.stat
inactive_file 104906752
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference, about 3 MiB, is the working set.&lt;/p&gt;

&lt;p&gt;The corrected value matches &lt;code&gt;container_memory_working_set_bytes&lt;/code&gt; from the kubelet.&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 &lt;span class="nt"&gt;--raw&lt;/span&gt; &lt;span class="s2"&gt;"/api/v1/nodes/&amp;lt;node&amp;gt;/proxy/metrics/resource"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; ^container_memory_working_set_bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  OOM count
&lt;/h3&gt;

&lt;p&gt;In v1, a container's OOM count came from &lt;code&gt;oom_kill&lt;/code&gt; in &lt;code&gt;memory.oom_control&lt;/code&gt;.&lt;sup id="fnref5"&gt;5&lt;/sup&gt; In v2, the same &lt;code&gt;oom_kill&lt;/code&gt; is in &lt;code&gt;memory.events&lt;/code&gt;, a file that counts the memory events in that cgroup by type. However, &lt;code&gt;oom_kill&lt;/code&gt; in the container's &lt;code&gt;memory.events&lt;/code&gt; reads 0 even after the container has restarted due to OOM. The count is kept in the &lt;code&gt;memory.events&lt;/code&gt; of the Pod's slice, one level above the container.&lt;/p&gt;

&lt;p&gt;The rest of this section covers why it reads 0, how to find the Pod's slice, and how to read the slice's &lt;code&gt;memory.events&lt;/code&gt;, in that order.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why the container's oom_kill reads 0
&lt;/h4&gt;

&lt;p&gt;In a Pod that restarted due to OOM, the container's &lt;code&gt;memory.events&lt;/code&gt; reads 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;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;grep &lt;/span&gt;oom /sys/fs/cgroup/memory.events
oom 0
oom_kill 0
oom_group_kill 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On v2 nodes, the kubelet sets the container's &lt;code&gt;memory.oom.group&lt;/code&gt; to 1.&lt;sup id="fnref6"&gt;6&lt;/sup&gt; When OOM occurs in this cgroup, the kernel kills every process in the container, the container is recreated along with its cgroup, and the counters start again from 0.&lt;/p&gt;

&lt;p&gt;To check whether your nodes behave this way, see whether the container's &lt;code&gt;/sys/fs/cgroup/memory.oom.group&lt;/code&gt; is 1. If the kubelet's &lt;code&gt;singleProcessOOMKill&lt;/code&gt; is true, it stays 0.&lt;/p&gt;

&lt;h4&gt;
  
  
  Finding the Pod's slice
&lt;/h4&gt;

&lt;p&gt;The Pod's slice is the cgroup that groups all of the Pod's containers, and its &lt;code&gt;memory.events&lt;/code&gt; adds up the counts of the containers under it. The slice survives container restarts, so the count survives too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/sys/fs/cgroup/
└── kubepods.slice/
    └── kubepods-burstable.slice/
        └── kubepods-burstable-pod&amp;lt;uid&amp;gt;.slice/         ← Pod's slice
            └── cri-containerd-&amp;lt;container-id&amp;gt;.scope/   ← container's scope
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To locate the slice, search for the scope by container ID. The container ID is the part of the Pod's &lt;code&gt;containerID&lt;/code&gt; after &lt;code&gt;containerd://&lt;/code&gt;. When you enter the node with &lt;code&gt;kubectl debug node&lt;/code&gt;, the node's files appear 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 shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl get pod &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.status.containerStatuses[*].containerID}'&lt;/span&gt;
containerd://&amp;lt;container-id&amp;gt;
&lt;span class="nv"&gt;$ &lt;/span&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;busybox
&lt;span class="c"&gt;# find /host/sys/fs/cgroup -type d -name 'cri-containerd-&amp;lt;container-id&amp;gt;.scope'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parent of the directory it finds is the Pod's slice. &lt;code&gt;cri-containerd-&lt;/code&gt; is the prefix used when the runtime is containerd. If you can log in to the node as root, drop &lt;code&gt;/host&lt;/code&gt; from the path.&lt;/p&gt;

&lt;h4&gt;
  
  
  Reading the slice's memory.events
&lt;/h4&gt;

&lt;p&gt;In the slice's &lt;code&gt;memory.events&lt;/code&gt;, &lt;code&gt;oom_group_kill&lt;/code&gt; is the OOM count, and it matches restartCount. &lt;code&gt;oom_group_kill&lt;/code&gt; is the number of times all processes in the cgroup were killed together because of &lt;code&gt;memory.oom.group&lt;/code&gt;.&lt;sup id="fnref7"&gt;7&lt;/sup&gt; For a Pod with a restartCount of 3, it looks like this.&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# grep oom &amp;lt;slice&amp;gt;/memory.events
oom 3
oom_kill 9
oom_group_kill 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;oom_kill&lt;/code&gt; goes up each time the kernel kills a process, so a single OOM raises it by more than one. In this Pod, three OOMs brought it to 9.&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;I checked these on a Kubernetes 1.37.0 cluster started with kind v0.33.0 on a GitHub Actions runner (ubuntu-24.04). The results are &lt;a href="https://github.com/shinagawa-web/show-your-work/actions/runs/38369999475" 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/0edeb00/cgroup-v2-scripts-read-wrong-values" 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 &lt;code&gt;cpu.stat&lt;/code&gt; entry in the kernel's &lt;a href="https://docs.kernel.org/admin-guide/cgroup-v2.html#cpu-interface-files" rel="noopener noreferrer"&gt;cgroup v2 documentation&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn3"&gt;
&lt;p&gt;The &lt;code&gt;memory&lt;/code&gt; entry in the Kubernetes &lt;a href="https://kubernetes.io/docs/tasks/debug/debug-cluster/resource-metrics-pipeline/" rel="noopener noreferrer"&gt;Resource metrics pipeline&lt;/a&gt; documentation.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn4"&gt;
&lt;p&gt;cAdvisor's &lt;a href="https://github.com/google/cadvisor/blob/v0.60.6/lib/container/libcontainer/handler.go#L876-L899" rel="noopener noreferrer"&gt;handler.go&lt;/a&gt; subtracts &lt;code&gt;total_inactive_file&lt;/code&gt; for v1 and &lt;code&gt;inactive_file&lt;/code&gt; for v2. The &lt;a href="https://github.com/kubernetes/website/blob/main/content/en/examples/admin/resource/memory-available-cgroupv2.sh" rel="noopener noreferrer"&gt;memory-available-cgroupv2.sh&lt;/a&gt; example in the Kubernetes documentation writes the v2 formula the same way.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn5"&gt;
&lt;p&gt;The &lt;code&gt;memory.oom_control&lt;/code&gt; entry in the kernel's &lt;a href="https://docs.kernel.org/admin-guide/cgroup-v1/memory.html#oom-control-deprecated" rel="noopener noreferrer"&gt;cgroup v1 memory documentation&lt;/a&gt;. &lt;code&gt;oom_kill&lt;/code&gt; is the number of processes in that cgroup killed by OOM.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn6"&gt;
&lt;p&gt;In the kubelet's &lt;a href="https://github.com/kubernetes/kubernetes/blob/v1.35.0/pkg/kubelet/kuberuntime/kuberuntime_container_linux.go#L314-L320" rel="noopener noreferrer"&gt;kuberuntime_container_linux.go&lt;/a&gt;, &lt;code&gt;memory.oom.group&lt;/code&gt; is set to 1 on v2 nodes unless &lt;code&gt;singleProcessOOMKill&lt;/code&gt; is true. This was added in &lt;a href="https://github.com/kubernetes/kubernetes/pull/117793" rel="noopener noreferrer"&gt;kubernetes/kubernetes#117793&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn7"&gt;
&lt;p&gt;The &lt;code&gt;memory.events&lt;/code&gt; entry in the kernel's &lt;a href="https://docs.kernel.org/admin-guide/cgroup-v2.html#memory-interface-files" rel="noopener noreferrer"&gt;cgroup v2 documentation&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>kubernetes</category>
      <category>linux</category>
      <category>devops</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Docker exit code 137: telling the causes apart from the logs</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Fri, 16 Oct 2026 13:02:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/docker-exit-code-137-telling-the-causes-apart-from-the-logs-3kdg</link>
      <guid>https://dev.to/shinagawa-web/docker-exit-code-137-telling-the-causes-apart-from-the-logs-3kdg</guid>
      <description>&lt;p&gt;A container has stopped with exit code 137. &lt;code&gt;docker ps -a&lt;/code&gt; shows &lt;code&gt;Exited (137)&lt;/code&gt;, and &lt;code&gt;docker inspect&lt;/code&gt; shows &lt;code&gt;"ExitCode": 137&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This value does not tell you whether the container hit its memory limit, the host as a whole ran out of memory, or Docker or some other process stopped it.&lt;/p&gt;

&lt;p&gt;I reproduced this on a test VM, changed how the container was stopped, and checked what each case left in the kernel log and the Docker daemon log.&lt;sup id="fnref1"&gt;1&lt;/sup&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which log points to which cause
&lt;/h2&gt;

&lt;p&gt;The OOM killer is the mechanism the kernel uses when memory runs short: it picks a process and kills it. For each way the container stopped, the kernel log and the Docker daemon log contained the following.&lt;sup id="fnref2"&gt;2&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;What happened&lt;/th&gt;
&lt;th&gt;Kernel log&lt;/th&gt;
&lt;th&gt;Docker daemon log&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The container hit its memory limit and the OOM killer killed it&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Memory cgroup out of memory: Killed process&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The host as a whole ran out of memory and the OOM killer killed it&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Out of memory: Killed process&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The container did not exit on &lt;code&gt;docker stop&lt;/code&gt;, so Docker killed it&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;&lt;code&gt;using the force&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Someone ran &lt;code&gt;docker kill&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;hasBeenManuallyStopped=true&lt;/code&gt; (if the container has a restart policy; no &lt;code&gt;using the force&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cannot be told from the logs&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row covers a process on the host running &lt;code&gt;kill -9&lt;/code&gt;, the app returning 137 itself, and a container without a restart policy being stopped with &lt;code&gt;docker kill&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For a container with a restart policy, &lt;code&gt;docker stop&lt;/code&gt; also logs &lt;code&gt;hasBeenManuallyStopped=true&lt;/code&gt;. If a &lt;code&gt;using the force&lt;/code&gt; line comes before it, it was &lt;code&gt;docker stop&lt;/code&gt;. If not, it was &lt;code&gt;docker kill&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When a container without a restart policy is stopped with &lt;code&gt;docker kill&lt;/code&gt;, the Docker daemon log has no line that identifies it as &lt;code&gt;docker kill&lt;/code&gt;. The only trace is the &lt;code&gt;kill&lt;/code&gt; event in &lt;code&gt;docker events&lt;/code&gt;, and the Docker daemon keeps only the most recent 256 events in memory, counted across all containers on the host.&lt;sup id="fnref3"&gt;3&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;Read the kernel log with &lt;code&gt;journalctl -k&lt;/code&gt;. A line starting with &lt;code&gt;oom-kill:&lt;/code&gt; appears just before the &lt;code&gt;Killed process&lt;/code&gt; line. The &lt;code&gt;task_memcg=&lt;/code&gt; value on that line contains the container ID, which tells you which container it was.&lt;sup id="fnref4"&gt;4&lt;/sup&gt; When the host as a whole runs out of memory, the container that gets killed is not necessarily the one that used up the memory. In the test, the OOM killer killed the app container, which held more memory than the container whose memory kept growing.&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;-k&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;'Killed process|oom-kill:'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the Docker daemon log with &lt;code&gt;journalctl -u docker&lt;/code&gt;. The string after &lt;code&gt;container=&lt;/code&gt; is the container ID.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; docker | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'using the force|hasBeenManuallyStopped=true'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Using the table in production
&lt;/h2&gt;

&lt;p&gt;In production, there are situations where the values in &lt;code&gt;docker inspect&lt;/code&gt; cannot be relied on. The logs in the table still tell the causes apart in those situations.&lt;/p&gt;

&lt;h3&gt;
  
  
  The container has restarted
&lt;/h3&gt;

&lt;p&gt;If &lt;code&gt;RestartCount&lt;/code&gt; in &lt;code&gt;docker inspect&lt;/code&gt; is 1 or more, the container has restarted. When a container with a restart policy restarts, &lt;code&gt;ExitCode&lt;/code&gt; in &lt;code&gt;docker inspect&lt;/code&gt; goes back to 0 and &lt;code&gt;OOMKilled&lt;/code&gt; back to false, and nothing there shows that it stopped with 137.&lt;/p&gt;

&lt;p&gt;After a restart, &lt;code&gt;FinishedAt&lt;/code&gt; in &lt;code&gt;docker inspect&lt;/code&gt; still holds the time of the previous stop. If the kernel log has a &lt;code&gt;Killed process&lt;/code&gt; line at about the same time, the previous stop was a kill by the OOM killer. &lt;code&gt;FinishedAt&lt;/code&gt; is in UTC, so use &lt;code&gt;journalctl -k --utc&lt;/code&gt; to line up the timestamps.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;OOMKilled&lt;/code&gt; is false
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;State&lt;/code&gt; in &lt;code&gt;docker inspect&lt;/code&gt; has a value called &lt;code&gt;OOMKilled&lt;/code&gt;, which Docker sets to true when a process in the container is killed by the OOM killer. But it sometimes stays false even though the OOM killer did the kill. Decide whether the OOM killer killed it from the &lt;code&gt;Killed process&lt;/code&gt; line in the kernel log (&lt;code&gt;journalctl -k&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  What exit code 137 indicates
&lt;/h2&gt;

&lt;p&gt;PID 1 is the first process started in the container, the one given in &lt;code&gt;ENTRYPOINT&lt;/code&gt; or &lt;code&gt;CMD&lt;/code&gt;. When PID 1 exits, the container exits, and the exit code is determined by how PID 1 ended.&lt;/p&gt;

&lt;p&gt;When PID 1 is ended by a signal, the exit code is 128 plus the signal number. SIGKILL is 9, so the code is 137.&lt;sup id="fnref5"&gt;5&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;The exit code can be 137 even when PID 1 itself did not receive SIGKILL. If an init is started ahead of the app and runs the app as its child, as with &lt;code&gt;docker run --init&lt;/code&gt;, then when the app is ended by SIGKILL, init exits with 137.&lt;sup id="fnref6"&gt;6&lt;/sup&gt; With init in place, the logs for a kill at the container's memory limit and for &lt;code&gt;kill -9&lt;/code&gt; from a host process looked the same as without init.&lt;/p&gt;

&lt;p&gt;SIGKILL is sent to a container's processes by one of the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The kernel's OOM killer&lt;/li&gt;
&lt;li&gt;Docker (when the container does not exit on &lt;code&gt;docker stop&lt;/code&gt;, and on &lt;code&gt;docker kill&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Another process on the host&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every kill in the table is this SIGKILL. The exit code does not tell you who sent it, so use the logs in the table to tell the senders apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was not checked
&lt;/h2&gt;

&lt;p&gt;I did not check mechanisms outside the kernel that watch for memory shortage and kill processes (systemd-oomd and earlyoom).&lt;/p&gt;

&lt;h2&gt;
  
  
  Appendix: each way of stopping and what it left
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;--memory 128m&lt;/code&gt;, app allocates 256MiB
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Kernel log: &lt;code&gt;Memory cgroup out of memory&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Docker daemon log: none&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker events&lt;/code&gt;: &lt;code&gt;oom&lt;/code&gt;, &lt;code&gt;die&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OOMKilled&lt;/code&gt;: true (false in 0 to 1 of 3 tries per full test run)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Another container with no limit uses up the host's memory
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Kernel log: &lt;code&gt;Out of memory&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Docker daemon log: none&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker events&lt;/code&gt;: &lt;code&gt;oom&lt;/code&gt;, &lt;code&gt;die&lt;/code&gt; (order varies between tries)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OOMKilled&lt;/code&gt;: true (false in 0 to 2 of 10 tries per full test run)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;docker stop -t 2&lt;/code&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Kernel log: none&lt;/li&gt;
&lt;li&gt;Docker daemon log: &lt;code&gt;using the force&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker events&lt;/code&gt;: &lt;code&gt;kill&lt;/code&gt; (15), &lt;code&gt;kill&lt;/code&gt; (9), &lt;code&gt;stop&lt;/code&gt;, &lt;code&gt;die&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OOMKilled&lt;/code&gt;: false&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;docker stop -t 2&lt;/code&gt;, with a restart policy
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Kernel log: none&lt;/li&gt;
&lt;li&gt;Docker daemon log: &lt;code&gt;using the force&lt;/code&gt;, &lt;code&gt;hasBeenManuallyStopped=true&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker events&lt;/code&gt;: &lt;code&gt;kill&lt;/code&gt; (15), &lt;code&gt;kill&lt;/code&gt; (9), &lt;code&gt;stop&lt;/code&gt;, &lt;code&gt;die&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OOMKilled&lt;/code&gt;: false (does not restart)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;docker kill&lt;/code&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Kernel log: none&lt;/li&gt;
&lt;li&gt;Docker daemon log: none&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker events&lt;/code&gt;: &lt;code&gt;kill&lt;/code&gt; (9), &lt;code&gt;die&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OOMKilled&lt;/code&gt;: false&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;docker kill&lt;/code&gt;, with a restart policy
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Kernel log: none&lt;/li&gt;
&lt;li&gt;Docker daemon log: &lt;code&gt;hasBeenManuallyStopped=true&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker events&lt;/code&gt;: &lt;code&gt;kill&lt;/code&gt; (9), &lt;code&gt;die&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OOMKilled&lt;/code&gt;: false (does not restart)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;kill -9&lt;/code&gt; from the host to the app
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Kernel log: none&lt;/li&gt;
&lt;li&gt;Docker daemon log: none&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker events&lt;/code&gt;: &lt;code&gt;die&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OOMKilled&lt;/code&gt;: false&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The app returns 137 itself
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Kernel log: none&lt;/li&gt;
&lt;li&gt;Docker daemon log: none&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker events&lt;/code&gt;: &lt;code&gt;die&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OOMKilled&lt;/code&gt;: false&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;--memory 128m&lt;/code&gt;, with a restart policy
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Kernel log: &lt;code&gt;Memory cgroup out of memory&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Docker daemon log: none&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker events&lt;/code&gt;: &lt;code&gt;oom&lt;/code&gt;, &lt;code&gt;die&lt;/code&gt;, &lt;code&gt;start&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OOMKilled&lt;/code&gt;: false after the restart (&lt;code&gt;ExitCode&lt;/code&gt; is 0)&lt;/li&gt;
&lt;/ul&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;I tested on a VM started with KVM inside a GitHub Actions runner (Ubuntu 24.04, kernel 6.8, 512MiB memory, no swap, cgroup v2), using Docker 29.1.3 from Ubuntu's docker.io package (cgroup driver: systemd). The scripts and results are in &lt;a href="https://github.com/shinagawa-web/show-your-work/tree/bda73ad/exit137-sender" rel="noopener noreferrer"&gt;show-your-work&lt;/a&gt; and in &lt;a href="https://github.com/shinagawa-web/show-your-work/actions/runs/37558180900" rel="noopener noreferrer"&gt;the GitHub Actions run&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn2"&gt;
&lt;p&gt;The kernel log strings come from kernel 6.8: &lt;a href="https://github.com/torvalds/linux/blob/v6.8/mm/oom_kill.c#L1170-L1171" rel="noopener noreferrer"&gt;the call to &lt;code&gt;oom_kill_process()&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/torvalds/linux/blob/v6.8/mm/oom_kill.c#L946" rel="noopener noreferrer"&gt;the line that prints &lt;code&gt;Killed process&lt;/code&gt;&lt;/a&gt;. The Docker daemon log lines come from Docker 29.1.3: &lt;a href="https://github.com/moby/moby/blob/docker-v29.1.3/daemon/stop.go#L114" rel="noopener noreferrer"&gt;the line that logs &lt;code&gt;using the force&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/moby/moby/blob/docker-v29.1.3/daemon/monitor.go#L91-L101" rel="noopener noreferrer"&gt;the lines that log the warning containing &lt;code&gt;hasBeenManuallyStopped=true&lt;/code&gt;&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn3"&gt;
&lt;p&gt;&lt;a href="https://github.com/moby/moby/blob/docker-v29.1.3/daemon/events/events.go#L13" rel="noopener noreferrer"&gt;&lt;code&gt;eventsLimit&lt;/code&gt;&lt;/a&gt; in the Docker 29.1.3 source.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn4"&gt;
&lt;p&gt;Kernel 6.8: &lt;a href="https://github.com/torvalds/linux/blob/v6.8/mm/oom_kill.c#L444" rel="noopener noreferrer"&gt;where the &lt;code&gt;oom-kill:&lt;/code&gt; line is printed&lt;/a&gt; and &lt;a href="https://github.com/torvalds/linux/blob/v6.8/mm/memcontrol.c#L1724" rel="noopener noreferrer"&gt;where &lt;code&gt;task_memcg=&lt;/code&gt; is printed&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn5"&gt;
&lt;p&gt;runc's &lt;a href="https://github.com/opencontainers/runc/blob/v1.3.0/libcontainer/utils/utils.go#L13-L23" rel="noopener noreferrer"&gt;&lt;code&gt;ExitStatus()&lt;/code&gt;&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn6"&gt;
&lt;p&gt;The init that &lt;code&gt;--init&lt;/code&gt; starts is tini, which returns 128 plus the signal number when its child is ended by a signal (&lt;a href="https://github.com/krallin/tini/blob/v0.19.0/src/tini.c#L574-L577" rel="noopener noreferrer"&gt;tini v0.19.0&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>debugging</category>
    </item>
    <item>
      <title>nginx 504 Gateway Timeout with zero 429s from limit_req</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:03:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/nginx-504-gateway-timeout-with-zero-429s-from-limitreq-31pd</link>
      <guid>https://dev.to/shinagawa-web/nginx-504-gateway-timeout-with-zero-429s-from-limitreq-31pd</guid>
      <description>&lt;p&gt;nginx uses &lt;code&gt;limit_req&lt;/code&gt; to cap the number of requests per second for each IP address. Requests over the limit get a 429 (Too Many Requests) through &lt;code&gt;limit_req_status 429&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;At some point, 504s (Gateway Timeout) start to increase. A 504 is the status nginx returns when it forwards a request to the application server behind it (an upstream, in nginx terms; called the upstream below), waits for the time set in &lt;code&gt;proxy_read_timeout&lt;/code&gt; without receiving a response, and gives up. In the access log, &lt;code&gt;$request_time&lt;/code&gt; (the time from when nginx receives the request until it finishes sending the response) on every 504 line equals the &lt;code&gt;proxy_read_timeout&lt;/code&gt; value. Each time nginx gives up, error.log records the following string.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;upstream timed out (110: Connection timed out) while reading response header from upstream
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are zero 429s, and the number of requests per IP address is the same as usual.&lt;/p&gt;

&lt;p&gt;The upstream is not down, and its access log keeps flowing.&lt;/p&gt;

&lt;p&gt;I reproduced this state and measured the conditions under which the upstream falls behind while traffic stays within the &lt;code&gt;limit_req&lt;/code&gt; limit, how to tell the causes apart, and a remedy that needs only nginx configuration.&lt;/p&gt;

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

&lt;p&gt;This article covers upstreams built this way: the upstream has a cap on how many requests it can process at the same time, and connections that arrive beyond that cap wait in an OS queue. Even after nginx closes the connection, the upstream finishes processing the request. Handlers that detect the disconnect and abort processing are out of scope.&lt;/p&gt;

&lt;p&gt;With this design, if the upstream cannot keep up with even a fraction of the incoming requests and that continues, no response succeeds. Every user waits until the timeout and then receives a 504.&lt;/p&gt;

&lt;p&gt;The upstream keeps processing at full capacity, and that capacity goes into building responses nobody will receive. Almost every request it finishes is one for which nginx has already returned a 504.&lt;/p&gt;

&lt;p&gt;When 504s increase, nothing is recorded on the rate limiting side. 429s stay at zero, which leaves nothing to base a decision on: whether to lower the &lt;code&gt;limit_req&lt;/code&gt; limit, and if so, by how much.&lt;/p&gt;

&lt;h2&gt;
  
  
  What limit_req looks at
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;limit_req&lt;/code&gt; records one excess value per key value. The key is the value given to &lt;code&gt;limit_req_zone&lt;/code&gt;; here it is the IP address. Two settings determine the limit. &lt;code&gt;rate&lt;/code&gt; is the average speed allowed for requests from one IP address. Requests that arrive faster than &lt;code&gt;rate&lt;/code&gt; are recorded as excess (how many requests' worth above &lt;code&gt;rate&lt;/code&gt; have arrived), and the excess decreases over time at the speed of &lt;code&gt;rate&lt;/code&gt;. &lt;code&gt;burst&lt;/code&gt; is how much excess is allowed. When the excess exceeds &lt;code&gt;burst&lt;/code&gt;, that request gets a 429.&lt;sup id="fnref1"&gt;1&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;Heavy and light requests alike, however many seconds they take on the upstream, each add the same one request's worth to the excess.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;rate&lt;/code&gt; and &lt;code&gt;burst&lt;/code&gt; are fixed when the configuration is loaded and do not change while nginx is running. Whether to return a 429 is decided before the request is forwarded to the upstream. When upstream responses slow down, or the number of requests in progress on the upstream grows, &lt;code&gt;limit_req&lt;/code&gt; does not see the change.&lt;/p&gt;

&lt;p&gt;In a zone keyed by IP address, &lt;code&gt;rate&lt;/code&gt; and &lt;code&gt;burst&lt;/code&gt; apply to requests from one IP address. If the number of IP addresses sending requests triples while requests per IP address stay the same, every IP address stays within the limit and the requests reaching the upstream triple.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conditions under which the upstream falls behind within the limit
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Setup
&lt;/h3&gt;

&lt;p&gt;I ran nginx 1.30.5 and the upstream.&lt;/p&gt;

&lt;p&gt;The upstream is a small HTTP server written in Go, built the way described in the Impact section. It processes up to 20 requests at a time, and connections beyond that wait in the queue where the OS holds connections until it hands them to the server (the accept queue). Processing takes 50ms per request on the light endpoint and 300ms on the heavy endpoint.&lt;/p&gt;

&lt;p&gt;The upstream closes the connection after each response. In nginx 1.30.5, keepalive to the upstream is enabled by default, but because the upstream closes the connection, connections are not reused and one request is one connection.&lt;sup id="fnref2"&gt;2&lt;/sup&gt; The handler processes each request to the end without watching for nginx disconnecting. In Go's net/http, the request context is canceled when the client disconnects, and this upstream does not check it.&lt;sup id="fnref3"&gt;3&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;Load came from the load testing tool k6: 200 requests per second at even intervals, all to the light endpoint. I prepared 10 key values and assigned 20 requests per second to each.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;limit_req&lt;/code&gt; was set to &lt;code&gt;rate=50r/s&lt;/code&gt; and &lt;code&gt;burst=20 nodelay&lt;/code&gt;. &lt;code&gt;nodelay&lt;/code&gt; forwards the excess requests to the upstream immediately instead of holding them back, as long as the excess stays within &lt;code&gt;burst&lt;/code&gt;.&lt;sup id="fnref4"&gt;4&lt;/sup&gt; Each key value receives 20 requests per second, fewer than the 50 per second of &lt;code&gt;rate&lt;/code&gt;, so every key value stays within the limit.&lt;/p&gt;

&lt;p&gt;Because load came from a single machine, I used a request header value as the key in place of the IP address. &lt;code&gt;limit_req&lt;/code&gt; treats the key as a string, so the behavior is the same whether the key is an IP address or a header value.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;proxy_read_timeout&lt;/code&gt; was shortened to 1 second to keep the test short. The mechanism is the same with the default of 60 seconds.&lt;/p&gt;

&lt;p&gt;At this starting point, about 10 requests are in progress on the upstream at a time (200 per second × 0.05 seconds), and the upstream can handle 400 per second (20 ÷ 0.05 seconds).&lt;/p&gt;

&lt;p&gt;Load ran for 25 seconds, and one item was changed at the 8-second mark. I also ran a pass with nothing changed as the baseline.&lt;sup id="fnref5"&gt;5&lt;/sup&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Results
&lt;/h3&gt;

&lt;p&gt;In every condition, 429s stayed at zero, and the success rate was 0% from 6 seconds after the change onward. This is the same state as the symptom described at the start.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Changed at 8 seconds&lt;/th&gt;
&lt;th&gt;429&lt;/th&gt;
&lt;th&gt;Success rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;More heavy requests&lt;/td&gt;
&lt;td&gt;Share of heavy requests 0% → 40%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Longer processing time&lt;/td&gt;
&lt;td&gt;Processing time of light requests 50ms → 120ms&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fewer concurrent slots&lt;/td&gt;
&lt;td&gt;Requests the upstream can process at the same time 20 → 8&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;More key values&lt;/td&gt;
&lt;td&gt;Number of key values 10 → 30 (600 per second in total)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The success rate is the fraction of requests k6 sent between 14 and 25 seconds (from 6 seconds after the change) that got a 200.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reading the conditions
&lt;/h3&gt;

&lt;p&gt;More heavy requests corresponds to heavy requests mixing in while the number of incoming requests stays the same. The number of requests the upstream can handle drops, and &lt;code&gt;limit_req&lt;/code&gt; adds one request's worth to the excess regardless of weight.&lt;/p&gt;

&lt;p&gt;Longer processing time corresponds to the upstream, or the DB or external API behind it, slowing down. The upstream can no longer keep up with the same number of requests, and &lt;code&gt;limit_req&lt;/code&gt; does not see the change.&lt;/p&gt;

&lt;p&gt;Fewer concurrent slots corresponds to a reduction in upstream nodes or workers. The number of requests the upstream can handle drops, while &lt;code&gt;rate&lt;/code&gt; and &lt;code&gt;burst&lt;/code&gt; stay at their configured values.&lt;/p&gt;

&lt;p&gt;More key values corresponds to more IP addresses sending requests. Every key value stays within the limit while the total number of requests reaching the upstream exceeds its capacity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why no 200s come back
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Mechanism
&lt;/h3&gt;

&lt;p&gt;In each of the four conditions in the previous section, after the change, the number of requests the upstream can handle per second falls below the number arriving. The upstream processes at most 20 at a time, so the connections it cannot handle line up in the accept queue. While the amount reaching the upstream exceeds what it can handle, the number of connections in the accept queue keeps growing, and requests that arrive later wait longer.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant C as Client
    participant N as nginx
    participant Q as accept queue
    participant U as upstream

    C-&amp;gt;&amp;gt;N: Request
    N-&amp;gt;&amp;gt;Q: Connects and sends the request
    Note over Q,U: Waits while the upstream processes&amp;lt;br/&amp;gt;the connections ahead of this one, in order
    Note over N: proxy_read_timeout elapses
    N-&amp;gt;&amp;gt;C: 504
    N-&amp;gt;&amp;gt;Q: Closes the connection
    Q-&amp;gt;&amp;gt;U: Hands over the connection once it reaches the head
    Note over U: Processes it without noticing&amp;lt;br/&amp;gt;the connection was closed
    U-&amp;gt;&amp;gt;N: Response nobody receives&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;When the time waited in the accept queue plus the processing time on the upstream exceeds &lt;code&gt;proxy_read_timeout&lt;/code&gt;, nginx gives up on that request, returns a 504, and closes the connection to the upstream. This is the symptom seen at the start, where &lt;code&gt;$request_time&lt;/code&gt; on 504 lines equals the &lt;code&gt;proxy_read_timeout&lt;/code&gt; value.&lt;/p&gt;

&lt;p&gt;The upstream does not notice that nginx closed the connection. When the connection reaches the head of the accept queue, the upstream accepts it, processes it, and writes a response nobody receives. This is why records of finished processing keep appearing in the upstream access log.&lt;/p&gt;

&lt;p&gt;The accept queue is processed from the front and keeps growing in the meantime, so everything that reaches the head is a request nginx has already given up on. All of the upstream's capacity goes to these, and by the time a request that is still waiting gets its turn, it too is past &lt;code&gt;proxy_read_timeout&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  In the logs
&lt;/h3&gt;

&lt;p&gt;For the Longer processing time condition (light request processing time 50ms → 120ms, changed at 8 seconds), I counted the requests in each second from the start of the load, using the upstream access log and the nginx access log.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Second&lt;/th&gt;
&lt;th&gt;Requests the upstream finished&lt;/th&gt;
&lt;th&gt;200s returned by nginx&lt;/th&gt;
&lt;th&gt;504s returned by nginx&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;167&lt;/td&gt;
&lt;td&gt;167&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;167&lt;/td&gt;
&lt;td&gt;167&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;162&lt;/td&gt;
&lt;td&gt;162&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;166&lt;/td&gt;
&lt;td&gt;166&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;168&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;td&gt;139&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;164&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;165&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;167&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Through second 12, the number of requests the upstream finished matches the number of 200s nginx returned. The difference from the 200 per second arriving accumulates in the accept queue.&lt;/p&gt;

&lt;p&gt;Requests that arrive after 12.3 seconds spend more than the 1-second &lt;code&gt;proxy_read_timeout&lt;/code&gt; on waiting plus processing, so 504s start at 13.3 seconds.&lt;/p&gt;

&lt;p&gt;From second 14 on, the upstream keeps processing at the same speed, yet only 504s come back. nginx returns a 504 one second after a request arrives, so the count equals the 200 per second arriving.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking your own logs
&lt;/h2&gt;

&lt;p&gt;When 504s increase and 429s are zero, first check whether the wait in the accept queue is growing. If it is, the upstream has fallen behind within the &lt;code&gt;limit_req&lt;/code&gt; limit. Then identify which cause applies.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check whether the accept queue wait is growing
&lt;/h3&gt;

&lt;p&gt;The accept queue wait shows up as the difference between the upstream response time as seen by nginx and the processing time in the upstream access log. If this difference grows second by second, waits are building up in the accept queue. The difference starts growing before the 504s appear. On 504 lines &lt;code&gt;$upstream_header_time&lt;/code&gt; is &lt;code&gt;-&lt;/code&gt;, so look at the period before the 504s.&lt;/p&gt;

&lt;p&gt;The upstream response time as seen by nginx is &lt;code&gt;$upstream_header_time&lt;/code&gt;, which can be recorded in the access log. It is the time from when nginx starts connecting to the upstream until it receives the response header.&lt;sup id="fnref6"&gt;6&lt;/sup&gt; It includes both the accept queue wait and the processing time on the upstream. The processing time in the upstream access log is the time from when the upstream accepts the connection until it returns the response, and does not include the accept queue wait.&lt;/p&gt;

&lt;p&gt;To get the difference, take the per-second, per-endpoint median of &lt;code&gt;$upstream_header_time&lt;/code&gt; and of the upstream processing time, then subtract one from the other. Record the nginx access log in the following format.&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;log_format&lt;/span&gt; &lt;span class="s"&gt;rl&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="nv"&gt;$msec&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt; &lt;span class="nv"&gt;$upstream_addr&lt;/span&gt; &lt;span class="nv"&gt;$upstream_header_time&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Identifying the cause
&lt;/h3&gt;

&lt;p&gt;Check in this order.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In the upstream access log, look at processing time per endpoint. If it has grown, the upstream or the DB or external API behind it has slowed down (Longer processing time). However, an increase in heavy requests can put pressure on shared resources such as the DB and make even light endpoints slower. Even if processing time has grown, still check the share in the next step. In the test, the heavy processing is a sleep that does not affect other requests, so this interference was not verified.&lt;/li&gt;
&lt;li&gt;If it has not grown, look at each endpoint's (&lt;code&gt;$uri&lt;/code&gt;) share of the total count in the nginx access log. If the share of endpoints with long processing time has increased, heavy requests have increased (More heavy requests).&lt;/li&gt;
&lt;li&gt;If the shares have not changed, look at the total requests per second in the nginx access log. If it has increased, more requests are arriving. Break it down by how many distinct key values there are (the number of distinct &lt;code&gt;$remote_addr&lt;/code&gt; values) and by requests per second per key value. If the number of distinct key values has increased, more IP addresses are sending requests (More key values). The case where requests per key value increase within &lt;code&gt;rate&lt;/code&gt; was not tested, but the total reaching the upstream increases in the same way.&lt;/li&gt;
&lt;li&gt;If none of these apply, look at the number of upstream nodes or workers. If it is lower than usual, the number of requests that can be processed at the same time has dropped (Fewer concurrent slots).&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;First stop the damage with &lt;code&gt;max_conns&lt;/code&gt;, then fix the cause.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cap concurrent requests with max_conns
&lt;/h3&gt;

&lt;p&gt;Put &lt;code&gt;max_conns&lt;/code&gt; on the upstream &lt;code&gt;server&lt;/code&gt; line. Set it to the number of requests the upstream can process at the same time. &lt;code&gt;zone&lt;/code&gt; is needed to share the concurrent connection count across nginx workers.&lt;sup id="fnref7"&gt;7&lt;/sup&gt; If you run several nginx instances side by side, divide this value by the number of instances to get the per-instance value.&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;app&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;zone&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt; &lt;span class="mi"&gt;64k&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;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8081&lt;/span&gt; &lt;span class="s"&gt;max_conns=20&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;Connections nginx sends to the upstream at the same time no longer exceed &lt;code&gt;max_conns&lt;/code&gt;. Requests over that are not sent to the upstream, and nginx returns a 502 (Bad Gateway) on the spot. nginx OSS has no mechanism to make them wait until a slot frees up. The upstream processes only requests nginx is still waiting on, and the accept queue does not grow.&lt;/p&gt;

&lt;p&gt;In this test the upstream closes the connection, so keepalive connections are not reused. The nginx documentation states that with keepalive to the upstream, multiple workers, and &lt;code&gt;zone&lt;/code&gt;, the total of active and idle connections can exceed &lt;code&gt;max_conns&lt;/code&gt;.&lt;sup id="fnref8"&gt;8&lt;/sup&gt; From nginx 1.29.7 on, keepalive is enabled by default, so if the upstream keeps connections open, you get this setup even without a &lt;code&gt;keepalive&lt;/code&gt; directive. If the upstream limits concurrent processing by the number of connections, idle connections also take up slots. The case where keepalive connections are reused was not tested.&lt;/p&gt;

&lt;p&gt;For each of the four conditions, I compared no remedy against &lt;code&gt;max_conns=20&lt;/code&gt;. p99 is the value at the 99% position when the response times of successful responses are sorted from shortest to longest. p99 in the run with nothing changed was 51.6ms.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Success rate (no remedy)&lt;/th&gt;
&lt;th&gt;Success rate (max_conns=20)&lt;/th&gt;
&lt;th&gt;p99 of successful responses (max_conns=20)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;More heavy requests&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;66%&lt;/td&gt;
&lt;td&gt;301ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Longer processing time&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;80%&lt;/td&gt;
&lt;td&gt;122ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fewer concurrent slots&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;79%&lt;/td&gt;
&lt;td&gt;132ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;More key values&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;64%&lt;/td&gt;
&lt;td&gt;52ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There were zero 504s in every condition. The success rate is roughly equal to what the upstream can handle divided by what arrives. Successful responses come back without waiting, and p99 matches the upstream processing time. In Fewer concurrent slots it was 132ms because the amount by which &lt;code&gt;max_conns=20&lt;/code&gt; exceeds the upstream's 8 (up to 12 requests) lines up in the accept queue, and that wait is added.&lt;/p&gt;

&lt;p&gt;Even if the &lt;code&gt;max_conns&lt;/code&gt; value is larger than the number of requests the upstream can actually process at the same time, the accept queue does not keep growing. In the Fewer concurrent slots run, &lt;code&gt;max_conns&lt;/code&gt; stayed at 20 while the upstream's concurrent capacity dropped to 8. There were zero 504s, and the accept queue stopped at a maximum of 12. A connection is established when the OS accepts it, so the connections nginx counts include those waiting in the accept queue. The connections in the accept queue never exceed &lt;code&gt;max_conns&lt;/code&gt; minus the upstream's concurrent capacity.&lt;/p&gt;

&lt;p&gt;Users beyond what can be handled receive a 502 without waiting. You cannot choose which requests get the 502. This 502 is recorded in error.log with the following string.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;no live upstreams while connecting to upstream
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the access log, &lt;code&gt;$upstream_addr&lt;/code&gt;, which normally holds the upstream's IP address and port, becomes the upstream name (&lt;code&gt;app&lt;/code&gt; here).&lt;sup id="fnref9"&gt;9&lt;/sup&gt; 502s returned without sending the request to the upstream can be counted by this value. The error.log message is the same as for a 502 when all upstream servers are down, but with a 502 from &lt;code&gt;max_conns&lt;/code&gt;, 200s keep coming back in the same time window.&lt;/p&gt;

&lt;h3&gt;
  
  
  Options ruled out
&lt;/h3&gt;

&lt;p&gt;Shortening &lt;code&gt;proxy_read_timeout&lt;/code&gt; is ruled out. Changing it from 1 second to 500ms still gave a 0% success rate. nginx gives up sooner, and the accept queue grows at the same speed.&lt;/p&gt;

&lt;p&gt;Stacking a second &lt;code&gt;limit_req&lt;/code&gt; to cap the total of requests from all IP addresses is also ruled out. A zone keyed by a fixed string (&lt;code&gt;limit_req_zone all zone=fixed:1m rate=160r/s;&lt;/code&gt;) is stacked on top of the IP address zone. &lt;code&gt;rate=160r/s&lt;/code&gt; is set to match the 167 per second the upstream can handle under Longer processing time (20 ÷ 0.12 seconds). With this setting, the success rate under Longer processing time was 80%, and the accept queue did not grow. However, about 19% got a 429 even in normal operation, when the upstream had spare capacity. &lt;code&gt;rate&lt;/code&gt; has to be fixed to match the number of requests per second the upstream can handle, and that number changes with processing time. &lt;code&gt;max_conns&lt;/code&gt; caps the number of requests in progress at the same time, so when processing time grows, it automatically lets fewer requests through, and in normal operation it turns nothing into a 502.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fixing the cause
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;max_conns&lt;/code&gt; only stops the damage. What the upstream can handle does not recover. Where to act for each cause:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For Longer processing time, fix the DB or external API that slowed down&lt;/li&gt;
&lt;li&gt;For More heavy requests, move heavy processing to a separate upstream or to asynchronous processing&lt;/li&gt;
&lt;li&gt;For Fewer concurrent slots, restore the lost nodes or workers&lt;/li&gt;
&lt;li&gt;For More key values, add upstream capacity to match the increase&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;What &lt;code&gt;limit_req&lt;/code&gt; caps is the number of requests arriving per IP address. What the upstream can handle is determined by processing time and the number of requests it can process at the same time, and changes in these do not reach &lt;code&gt;limit_req&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What cannot be handled accumulates in the accept queue and turns into 504s once waiting plus processing exceeds &lt;code&gt;proxy_read_timeout&lt;/code&gt;. The upstream keeps processing requests nginx has already given up on, so no response succeeds.&lt;/p&gt;

&lt;p&gt;Protect the upstream with a cap on how many requests nginx sends to it at the same time (&lt;code&gt;max_conns&lt;/code&gt;). Regardless of cause, only what cannot be handled becomes a 502, and the rest comes back at the usual speed.&lt;/p&gt;

&lt;p&gt;This applies to upstreams that make connections arriving beyond their concurrent capacity wait in the accept queue, and whose handlers do not abort processing when nginx closes the connection.&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;In the nginx 1.30.5 source, &lt;a href="https://github.com/nginx/nginx/blob/release-1.30.5/src/http/modules/ngx_http_limit_req_module.c#L454-L464" rel="noopener noreferrer"&gt;&lt;code&gt;ngx_http_limit_req_lookup()&lt;/code&gt;&lt;/a&gt; makes this decision.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn2"&gt;
&lt;p&gt;Keepalive to the upstream has been enabled by default since nginx 1.29.7 (&lt;a href="https://nginx.org/en/CHANGES" rel="noopener noreferrer"&gt;CHANGES&lt;/a&gt;; nginx documentation for &lt;a href="https://nginx.org/en/docs/http/ngx_http_upstream_module.html#keepalive" rel="noopener noreferrer"&gt;&lt;code&gt;keepalive&lt;/code&gt;&lt;/a&gt;).&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn3"&gt;
&lt;p&gt;Go documentation for &lt;a href="https://pkg.go.dev/net/http#Request.Context" rel="noopener noreferrer"&gt;&lt;code&gt;Request.Context&lt;/code&gt;&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn4"&gt;
&lt;p&gt;nginx documentation for &lt;a href="https://nginx.org/en/docs/http/ngx_http_limit_req_module.html#limit_req" rel="noopener noreferrer"&gt;&lt;code&gt;limit_req&lt;/code&gt;&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn5"&gt;
&lt;p&gt;I ran it on a GitHub Actions runner (ubuntu-24.04). The results are &lt;a href="https://github.com/shinagawa-web/show-your-work/actions/runs/37588151050" 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/e9e0c62ae3e9f1c58140211bfec90ae988a88677/rate-limit-cannot-protect" rel="noopener noreferrer"&gt;show-your-work&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn6"&gt;
&lt;p&gt;The nginx documentation for &lt;a href="https://nginx.org/en/docs/http/ngx_http_upstream_module.html#var_upstream_header_time" rel="noopener noreferrer"&gt;&lt;code&gt;$upstream_header_time&lt;/code&gt;&lt;/a&gt; does not say where the measurement starts. In the 1.30.5 source, it is &lt;a href="https://github.com/nginx/nginx/blob/release-1.30.5/src/http/ngx_http_upstream.c#L2596" rel="noopener noreferrer"&gt;computed&lt;/a&gt; from &lt;a href="https://github.com/nginx/nginx/blob/release-1.30.5/src/http/ngx_http_upstream.c#L1578" rel="noopener noreferrer"&gt;&lt;code&gt;u-&amp;gt;start_time&lt;/code&gt;&lt;/a&gt;, which is recorded when nginx starts connecting to the upstream.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn7"&gt;
&lt;p&gt;nginx documentation for &lt;a href="https://nginx.org/en/docs/http/ngx_http_upstream_module.html#max_conns" rel="noopener noreferrer"&gt;&lt;code&gt;max_conns&lt;/code&gt;&lt;/a&gt;. Without &lt;code&gt;zone&lt;/code&gt;, the limit applies per worker.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn8"&gt;
&lt;p&gt;nginx documentation for &lt;a href="https://nginx.org/en/docs/http/ngx_http_upstream_module.html#max_conns" rel="noopener noreferrer"&gt;&lt;code&gt;max_conns&lt;/code&gt;&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn9"&gt;
&lt;p&gt;nginx documentation for &lt;a href="https://nginx.org/en/docs/http/ngx_http_upstream_module.html#var_upstream_addr" rel="noopener noreferrer"&gt;&lt;code&gt;$upstream_addr&lt;/code&gt;&lt;/a&gt;. When no server can be selected, it holds the upstream name.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>nginx</category>
      <category>devops</category>
      <category>performance</category>
      <category>linux</category>
    </item>
    <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)&lt;sup id="fnref1"&gt;1&lt;/sup&gt; 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;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;sup id="fnref2"&gt;2&lt;/sup&gt;&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 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;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;sup id="fnref3"&gt;3&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;resolution: 20&lt;/code&gt;, Node.js sets a timer that fires every 20ms, and each time it fires, it records the actual interval since the previous firing.&lt;sup id="fnref4"&gt;4&lt;/sup&gt; If the event loop is blocked by other work, this timer cannot get in either. With a 300ms block, the timer does not fire during the block, and when it next fires, a single interval of a little over 300ms is recorded. With no blocking, intervals of around 20ms are recorded. These accumulate in a histogram (the distribution of how often each recorded interval occurs), and &lt;code&gt;percentile(99)&lt;/code&gt; extracts the p99.&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 p99 as a metric and you can catch abnormal lag even when the CPU graph is flat.&lt;sup id="fnref5"&gt;5&lt;/sup&gt;&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 Node.js starts a libuv timer from the C++ side and does not call into JS for each measurement.&lt;sup id="fnref6"&gt;6&lt;/sup&gt; 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;/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).&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;sup id="fnref7"&gt;7&lt;/sup&gt;&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.&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;sup id="fnref8"&gt;8&lt;/sup&gt;&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;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;The results are &lt;a href="https://github.com/shinagawa-web/show-your-work/actions/runs/37862107644" 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/27e0847/nodejs-eventloop-starvation-p99" rel="noopener noreferrer"&gt;show-your-work&lt;/a&gt;. The Node.js version is 22.23.3.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn2"&gt;
&lt;p&gt;The &lt;a href="https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop#blocking-the-event-loop-nodejs-core-modules" rel="noopener noreferrer"&gt;core modules&lt;/a&gt; and &lt;a href="https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop#blocking-the-event-loop-json-dos" rel="noopener noreferrer"&gt;JSON&lt;/a&gt; sections of the Node.js guide "Don't Block the Event Loop".&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn3"&gt;
&lt;p&gt;The description of &lt;a href="https://nodejs.org/docs/latest-v22.x/api/perf_hooks.html#perf_hooksmonitoreventloopdelayoptions" rel="noopener noreferrer"&gt;&lt;code&gt;monitorEventLoopDelay()&lt;/code&gt;&lt;/a&gt; in the Node.js 22 documentation.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn4"&gt;
&lt;p&gt;&lt;a href="https://github.com/nodejs/node/blob/v22.23.3/src/histogram-inl.h#L82-L96" rel="noopener noreferrer"&gt;&lt;code&gt;Histogram::RecordDelta()&lt;/code&gt;&lt;/a&gt; in the Node.js 22.23.3 source. It puts the difference from the previous recording time into the histogram as is.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn5"&gt;
&lt;p&gt;Each block produces only one sample, so a block that happens just once in a 5-second window does not show up in p99, only in &lt;code&gt;h.max&lt;/code&gt;. If heavy work runs only rarely, export &lt;code&gt;h.max&lt;/code&gt; as well. A Node.js maintainer &lt;a href="https://github.com/nodejs/node/issues/34661#issuecomment-670515339" rel="noopener noreferrer"&gt;acknowledged this bias in nodejs/node#34661&lt;/a&gt; and suggested &lt;a href="https://github.com/nodejs/node/issues/34661#issuecomment-670955607" rel="noopener noreferrer"&gt;reading the histogram together with&lt;/a&gt; Event Loop Utilization. The issue was closed with the bias left in place.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn6"&gt;
&lt;p&gt;&lt;a href="https://github.com/nodejs/node/blob/v22.23.3/src/node_perf.cc#L281-L300" rel="noopener noreferrer"&gt;&lt;code&gt;CreateELDHistogram()&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/nodejs/node/blob/v22.23.3/src/histogram.cc#L408-L415" rel="noopener noreferrer"&gt;&lt;code&gt;IntervalHistogram::OnStart()&lt;/code&gt;&lt;/a&gt; in the Node.js 22.23.3 source. The timer callback only calls &lt;code&gt;RecordDelta()&lt;/code&gt; and does not enter JS.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn7"&gt;
&lt;p&gt;The &lt;a href="https://nodejs.org/en/learn/getting-started/profiling" rel="noopener noreferrer"&gt;profiling&lt;/a&gt; page of the Node.js guide. It also explains how to read the &lt;code&gt;[Bottom up (heavy) profile]&lt;/code&gt; section of the &lt;code&gt;--prof-process&lt;/code&gt; output.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn8"&gt;
&lt;p&gt;The &lt;a href="https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop#offloading" rel="noopener noreferrer"&gt;Offloading&lt;/a&gt; section of the Node.js guide "Don't Block the Event Loop", and &lt;a href="https://nodejs.org/docs/latest-v22.x/api/worker_threads.html" rel="noopener noreferrer"&gt;&lt;code&gt;worker_threads&lt;/code&gt;&lt;/a&gt; in the Node.js 22 documentation.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&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;Once a browser has fetched an index.html served without &lt;code&gt;Cache-Control&lt;/code&gt; (the header that says how long a response may be cached), it keeps using its own copy without going back to the server. That period is 10% of the time since index.html was last modified.&lt;/p&gt;

&lt;p&gt;If you deploy during that period, reopening the site still runs the previous version's page. The files that page loads later are gone from the server, so when a user uses a feature the page hasn't loaded yet, the load fails and clicking the button does nothing. An error tracker (Sentry or similar) shows this line:&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: &amp;lt;chunk URL&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It means a JS file the page tried to load at runtime with &lt;code&gt;import()&lt;/code&gt; could not be fetched. A chunk is a separate file the build splits some of the page's features into. It is loaded for the first time when the user clicks a button or similar. The error hits only users who use a feature the previous version hadn't loaded yet. Everyone else keeps running the previous version with no error.&lt;/p&gt;

&lt;p&gt;On a site that deploys once a week, the previous version keeps running in users' browsers for up to about 17 hours after the deploy. Browsers that open the site for the first time after the deploy, or that reload, run the new version, so checking internally may not reproduce it. Users running the previous version don't get a fix even after you deploy it.&lt;/p&gt;

&lt;p&gt;I reproduced this with a Vite app served by nginx with its default config, and measured how long the previous version runs and what each fix does.&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&lt;sup id="fnref1"&gt;1&lt;/sup&gt; lets a cache guess how long it may use its copy without going back to the server, and gives 10% of the time since &lt;code&gt;Last-Modified&lt;/code&gt; (when the file was last modified) as a typical value. Chromium uses exactly 10% of the difference between the time the response was sent (&lt;code&gt;Date&lt;/code&gt;) and &lt;code&gt;Last-Modified&lt;/code&gt;.&lt;sup id="fnref2"&gt;2&lt;/sup&gt; Below, the difference between &lt;code&gt;Date&lt;/code&gt; and &lt;code&gt;Last-Modified&lt;/code&gt; at fetch time is called the time since modification.&lt;/p&gt;

&lt;p&gt;Files with a hash (a string the build derives from a file's contents and puts in its name) get a new name whenever their contents change. Which version's files get loaded is decided only by the file names written in index.html, whose own name never changes. Without &lt;code&gt;Cache-Control&lt;/code&gt;, the browser decides when to fetch that index.html again.&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 its own copy of index.html, without going to the server, for about 17 hours, 10% of 7 days.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1           2  3            4         5
|           |  |            |         |

server serves
[=====old=====][============new=============]

browser runs
            [===========old===========][=new]

&amp;lt;- 7 days -&amp;gt;&amp;lt;------- 16.8 hours ------&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The top row is the version the server serves. The bottom row is the version that runs when the browser that opened the site at 2 reopens it at each point in time. 1 is when the previous version was built (&lt;code&gt;Last-Modified&lt;/code&gt;), 2 is when the user opened the site and fetched index.html (&lt;code&gt;Date&lt;/code&gt;), 3 is the deploy, 4 is when the user reopened the site, and 5 is when 10% of the span from 1 to 2 has passed since 2. The span from 1 to 2 is compressed in the figure. If a deploy falls between 2 and 5, reopening the site still runs the previous version. After 5, index.html is fetched again and the new version runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;p&gt;I served a Vite app that loads a chunk when a button is clicked, using nginx with its default config. I opened the previous version, closed the browser, deployed by replacing the container, then relaunched the browser and opened the site again.&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;Time since modification&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&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 since modification. Reopening the site in a new tab with the browser kept open gave the same result.&lt;sup id="fnref4"&gt;4&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;In browsers that hadn't clicked the button on the first open, clicking it while the previous version was running produced the error. In browsers that had clicked it, no error appeared.&lt;/p&gt;

&lt;p&gt;Reloading while the previous version was running fetched index.html again and switched to the new version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking your own environment
&lt;/h2&gt;

&lt;p&gt;The response headers on index.html tell you whether browsers keep using their own copy of it. The access log tells you whether any users ran the previous version and hit the error.&lt;/p&gt;

&lt;h3&gt;
  
  
  Response headers
&lt;/h3&gt;

&lt;p&gt;Check the response headers on index.html with this command. Look at the same headers whether you serve from S3, a CDN, or a hosting service, or render the HTML on the server per request.&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):'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the response has &lt;code&gt;Last-Modified&lt;/code&gt; but neither &lt;code&gt;Cache-Control&lt;/code&gt; nor &lt;code&gt;Expires&lt;/code&gt; (the header that sets an expiry time), the previous version keeps running after a deploy for 10% of the time since modification.&lt;/p&gt;

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

&lt;p&gt;From a browser that ran the previous version and hit the error, the new version's server receives only the following line and returns 404 (the status meaning the file doesn't exist). It is a request for the previous version's chunk, and the same browser sends no request for 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" 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;From a browser that had clicked the button under the previous version, not a single line arrives.&lt;/p&gt;

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

&lt;p&gt;There are three fixes: add no-cache to index.html, add max-age to it, or reload when loading fails.&lt;/p&gt;

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

&lt;p&gt;With &lt;code&gt;Cache-Control: no-cache&lt;/code&gt; on index.html, the browser asks the server whether index.html has changed every time it opens the page.&lt;sup id="fnref5"&gt;5&lt;/sup&gt; If it hasn't, the server returns 304 (the status meaning not modified) and sends no body.&lt;/p&gt;

&lt;p&gt;In nginx, add 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 new version runs every time the site is reopened, and the window where the previous version runs disappears.&lt;sup id="fnref6"&gt;6&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;The cost is one extra round trip to the server each time the page opens. Reopening when nothing has been deployed still gets a 304 every time.&lt;/p&gt;

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

&lt;p&gt;With &lt;code&gt;Cache-Control: max-age=&amp;lt;seconds&amp;gt;&lt;/code&gt;, the browser uses its own copy of index.html without going to the server for the number of seconds you specify, regardless of time since modification.&lt;sup id="fnref7"&gt;7&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;max-age=300&lt;/code&gt; (5 minutes), the previous version runs for about 5 minutes.&lt;/p&gt;

&lt;p&gt;The cost is that the window where the previous version runs doesn't go away. If the seconds you specify are longer than what the browser would have guessed, the window gets longer.&lt;/p&gt;

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

&lt;p&gt;When a chunk fails to load, reload the page. 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; The code below reloads only once, so that it doesn't keep reloading if the new version also fails.&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 window where the previous version runs doesn't change. When a user clicks the button under the previous version, the page reloads once after the chunk fails to load, and the new version runs.&lt;/p&gt;

&lt;p&gt;The cost is that in-progress page state is lost on reload. Only users who hit the error switch to the new version.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to pick
&lt;/h3&gt;

&lt;p&gt;Start with no-cache. It is the only one of the three that removes the window where the previous version runs. If you want fewer round trips, use max-age instead to cap the window. Reloading on failure doesn't change the window, so combine it with one of the other two and use it to switch users who hit the error to the new version.&lt;/p&gt;

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

&lt;p&gt;Everything above assumes that index.html has &lt;code&gt;Last-Modified&lt;/code&gt; but no &lt;code&gt;Cache-Control&lt;/code&gt;, that the deploy removes the previous version's files, and that the browser is Chromium.&lt;/p&gt;

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

&lt;p&gt;Only Chromium was measured. According to their source, Firefox and Safari also use their own copy of index.html without going to the server for 10% of the time since modification, the same as Chromium.&lt;sup id="fnref9"&gt;9&lt;/sup&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  When the previous version's files stay
&lt;/h3&gt;

&lt;p&gt;In setups where the previous version's files stay on the server after a deploy, the window where the previous version runs doesn't change. Clicking the button during that window returns the previous version's chunk, and no error appears.&lt;/p&gt;

&lt;p&gt;With no error, 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.&lt;/p&gt;

&lt;p&gt;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; (the option that removes files that exist only at the destination) is one of these.&lt;/p&gt;

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

&lt;p&gt;With &lt;code&gt;try_files $uri /index.html&lt;/code&gt; (an nginx setting that returns index.html for paths that don't exist), the request for the previous version's chunk gets a 200 whose body is index.html. No 404 shows up in the access log.&lt;/p&gt;

&lt;p&gt;In the access log, look for lines where a hashed file path returned 200 with the same byte count as index.html (321 in the example below).&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 reaches the error tracker is the same &lt;code&gt;Failed to fetch dynamically imported module&lt;/code&gt; as with the 404.&lt;/p&gt;

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

&lt;p&gt;If a Service Worker (a script that can intercept the page's requests and answer them from its own cache) 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.&lt;/p&gt;

&lt;p&gt;To check whether one is registered, run &lt;code&gt;navigator.serviceWorker.getRegistrations()&lt;/code&gt; in the browser 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;ol&gt;

&lt;li id="fn1"&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="fn2"&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="fn3"&gt;
&lt;p&gt;The browser was Chromium headless shell 153.0.8010.12 (the build without a window) driven by Playwright 1.63.0, with nginx 1.30.5 and Vite 8.3.1. The time since modification was produced by shifting the modification time of the served files into the past when starting the container (nginx returns a file's modification time as &lt;code&gt;Last-Modified&lt;/code&gt;). The time until reopening was produced with an &lt;code&gt;Age&lt;/code&gt; header that the previous version's server added to index.html. The browser treats the response as already that many seconds old when it receives it (&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;). At a time since modification of 10 minutes, the results matched actually waiting. Each condition ran 4 times in CI, for 12 trials per data point. The reproduction setup and scripts are in &lt;a href="https://github.com/shinagawa-web/show-your-work/tree/c5b012a/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="fn4"&gt;
&lt;p&gt;Compared at a time since modification of 100 minutes, side by side with relaunching the browser, within the same deploy.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn5"&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="fn6"&gt;
&lt;p&gt;Using the same setup as with no fix, I opened an index.html with a time since modification of 100 minutes and compared different times until reopening. Each condition ran 4 times in CI, for 12 trials per data point.&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.&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;/ol&gt;

</description>
      <category>vite</category>
      <category>webdev</category>
      <category>caching</category>
      <category>devops</category>
    </item>
    <item>
      <title>Only p99 is slow at low CPU usage: the cause is the CPU limit</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;When a container has a CPU limit (the amount of CPU the container may use, set with &lt;code&gt;--cpus&lt;/code&gt; in Docker and &lt;code&gt;resources.limits.cpu&lt;/code&gt; in Kubernetes), it gets a fixed amount of CPU time for each 100ms period. Once it uses that up, the kernel stops every thread in the container until the end of the period. This happens when processing overlaps within one period, and the only requests delayed are the ones being processed when the container is stopped and the ones that arrive while it is stopped.&lt;/p&gt;

&lt;p&gt;As long as the container is stopped in only some periods, p50 (the value at the 50% position when response times are sorted from fastest to slowest) barely changes, while p99 (the value at the 99% position) and the rest of the slow end go up. The container uses no CPU while it is stopped, so CPU usage stays low. The number of periods in which the container was stopped is counted in &lt;code&gt;nr_throttled&lt;/code&gt; in &lt;code&gt;cpu.stat&lt;/code&gt; (a file where the kernel writes cumulative CPU usage counters for each container).&lt;/p&gt;

&lt;p&gt;I reproduced this with Docker and checked the conditions under which the container is stopped and the effect of raising the limit.&lt;/p&gt;

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

&lt;p&gt;A &lt;code&gt;--cpus 0.5&lt;/code&gt; limit is enforced as up to 50ms of CPU time in each 100ms period.&lt;sup id="fnref1"&gt;1&lt;/sup&gt; This 50ms is drawn down by the total CPU time used by all threads in the container.&lt;/p&gt;

&lt;p&gt;Once the 50ms is used up, every thread in the container stops until the next period starts.&lt;sup id="fnref2"&gt;2&lt;/sup&gt; The kernel stopping a container's threads this way is called throttling. A request being processed stops partway through, and a request that arrives during the stop does not start processing until the next period.&lt;/p&gt;

&lt;p&gt;Even when average CPU usage is low, requests arriving at random intervals sometimes overlap within one period. If three requests that each use 20ms of CPU overlap in one period, they add up to 60ms, which exceeds 50ms.&lt;/p&gt;

&lt;p&gt;The diagram below shows A and B arriving at 0ms, C at 10ms, D at 40ms, E at 100ms, and F at 105ms. Each character is 5ms. &lt;code&gt;#&lt;/code&gt; is time spent processing, &lt;code&gt;.&lt;/code&gt; is time spent stopped, and &lt;code&gt;-&lt;/code&gt; is time spent waiting before processing starts. The number of &lt;code&gt;#&lt;/code&gt; in a column is the number of CPUs in use at that point.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ms     0   20  40  60  80  100 120 140 160 180 200
       |                   |                   |
A      ####
B      ####
C        ##................##
D              ------------###.................#
E                          ###.................#
F                           ##.................##
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the 0–100ms period, the &lt;code&gt;#&lt;/code&gt; of A, B, and C add up to 10 at the 20ms mark, and the CPU time used reaches 50ms. A and B finish and return, but C, still being processed, is stopped until 100ms. D arrives at 40ms and does not start processing until 100ms.&lt;/p&gt;

&lt;p&gt;In the next period, E and F join C and D, which were carried over from the previous period, and the 50ms runs out again at 115ms. C returns at 110ms, but D, E, and F are stopped until 200ms. D returns at 205ms, 165ms after it arrived.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measurements
&lt;/h2&gt;

&lt;p&gt;I ran an HTTP server that uses 20ms of CPU per request before responding (4 workers) in a container with &lt;code&gt;--cpus 0.5&lt;/code&gt;, and sent requests at random intervals from outside the container for 60 seconds.&lt;sup id="fnref3"&gt;3&lt;/sup&gt; I used three average arrival rates: 5, 10, and 20 per second. The throttle rate is the fraction of periods in which the container was stopped: &lt;code&gt;nr_throttled&lt;/code&gt; divided by &lt;code&gt;nr_periods&lt;/code&gt; (the number of elapsed periods).&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 (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;5&lt;/td&gt;
&lt;td&gt;20.5%&lt;/td&gt;
&lt;td&gt;2.8%&lt;/td&gt;
&lt;td&gt;20.5ms&lt;/td&gt;
&lt;td&gt;67.0ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&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;td&gt;20.6ms&lt;/td&gt;
&lt;td&gt;138.6ms&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;td&gt;91.7ms&lt;/td&gt;
&lt;td&gt;733.7ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Even at 5 and 10 per second, where CPU usage stays below 50%, p99 reaches 67.0ms and 138.6ms. p50 stays at 20ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking your own environment
&lt;/h2&gt;

&lt;p&gt;If the throttle rate rises at the times p99 goes up, the container is being stopped by its CPU limit. If the throttle rate stays at 0, the slowness has a cause other than the CPU limit.&lt;/p&gt;

&lt;p&gt;Calculate the throttle rate from &lt;code&gt;cpu.stat&lt;/code&gt;. From inside the container, read &lt;code&gt;/sys/fs/cgroup/cpu.stat&lt;/code&gt;. From the host, read &lt;code&gt;/sys/fs/cgroup/system.slice/docker-&amp;lt;id&amp;gt;.scope/cpu.stat&lt;/code&gt;.&lt;sup id="fnref4"&gt;4&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;Both &lt;code&gt;nr_throttled&lt;/code&gt; and &lt;code&gt;nr_periods&lt;/code&gt; are cumulative since the container started, so read them twice while p99 is elevated and divide the increase in &lt;code&gt;nr_throttled&lt;/code&gt; by the increase in &lt;code&gt;nr_periods&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix
&lt;/h2&gt;

&lt;p&gt;Raise the CPU limit until the throttle rate is close to 0. At an average of 10 arrivals per second, raising the limit to 1.0 brings the throttle rate to nearly 0, and p99 drops to the low 30ms range, the same as at 1.5 and 2.0, where the throttle rate is 0. The container uses about 0.2 cores on average, and the limit needs to be about 5 times that.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;--cpus&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Throttle rate&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;12.2%&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;2.1%&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;0.38%&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;0%&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;0%&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;Raising the limit increases the CPU the container can use at once in periods where requests overlap. Note that at times when the host's CPU has no headroom, the container competes for that extra CPU with the other containers on the same host.&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;The kernel 6.17 cgroup v2 documentation on &lt;a href="https://github.com/torvalds/linux/blob/v6.17/Documentation/admin-guide/cgroup-v2.rst?plain=1#L1172-L1182" 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="fn2"&gt;
&lt;p&gt;The kernel 6.17 documentation on &lt;a href="https://github.com/torvalds/linux/blob/v6.17/Documentation/scheduler/sched-bwc.rst?plain=1#L13-L18" rel="noopener noreferrer"&gt;CFS bandwidth control&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn3"&gt;
&lt;p&gt;GitHub Actions ubuntu-24.04 (4 vCPU), kernel 6.17, cgroup v2, Docker cgroup driver systemd. 294 / 609 / 1228 requests were sent, with 0 errors. Each condition ran in a separate job, so the CPU model differs between jobs. 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;, and the results are in &lt;a href="https://github.com/shinagawa-web/show-your-work/actions/runs/36109031236" rel="noopener noreferrer"&gt;the CI run&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn4"&gt;
&lt;p&gt;This is the location when Docker's cgroup driver is systemd. &lt;code&gt;&amp;lt;id&amp;gt;&lt;/code&gt; is the full container ID.&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>
  </channel>
</rss>
