<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="http://tylerjarjoura.com/feed.xml" rel="self" type="application/atom+xml" /><link href="http://tylerjarjoura.com/" rel="alternate" type="text/html" /><updated>2026-04-24T12:28:04+00:00</updated><id>http://tylerjarjoura.com/feed.xml</id><title type="html">Tyler Jarjoura</title><entry><title type="html">Breaking A Kubernetes Service in Three Ways And Examining The Raw Packets</title><link href="http://tylerjarjoura.com/blog/breaking-kubernetes-service-in-three-ways/" rel="alternate" type="text/html" title="Breaking A Kubernetes Service in Three Ways And Examining The Raw Packets" /><published>2026-04-24T00:00:00+00:00</published><updated>2026-04-24T00:00:00+00:00</updated><id>http://tylerjarjoura.com/blog/breaking-kubernetes-service-in-three-ways</id><content type="html" xml:base="http://tylerjarjoura.com/blog/breaking-kubernetes-service-in-three-ways/"><![CDATA[<p>A Kubernetes service is simple conceptually. You get a stable endpoint which load balances traffic across a pool of backend pods. Under the hood it’s implemented by standard Linux kernel features – DNAT iptables rules, virtual bridges, and veth pairs. There’s no separate proxy process implementing the logic for a Kubernetes service, so when it breaks, the errors are just going to be standard Linux networking errors.</p>

<p>To demonstrate this, I’m going to intentionally break a Kubernetes service in three different ways, and examine the raw packet captures from each.</p>

<h1 id="set-up">Set Up</h1>
<p>For the set up, I created a simple deployment which runs a single instance of <code class="language-plaintext highlighter-rouge">socat</code>, and I put a <code class="language-plaintext highlighter-rouge">NodePort</code> Kubernetes service in front of it. This is all running on a single node instance of  <code class="language-plaintext highlighter-rouge">k3d</code>, which means the service is ultimately exposed at <code class="language-plaintext highlighter-rouge">&lt;k3d node&gt;:&lt;node port&gt;</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">apps/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Deployment</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">annotations</span><span class="pi">:</span>
  <span class="na">labels</span><span class="pi">:</span>
    <span class="na">app</span><span class="pi">:</span> <span class="s">socat</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">socat</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">default</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">replicas</span><span class="pi">:</span> <span class="m">1</span>
  <span class="na">selector</span><span class="pi">:</span>
    <span class="na">matchLabels</span><span class="pi">:</span>
      <span class="na">app</span><span class="pi">:</span> <span class="s">socat</span>
  <span class="na">template</span><span class="pi">:</span>
    <span class="na">metadata</span><span class="pi">:</span>
      <span class="na">labels</span><span class="pi">:</span>
        <span class="na">app</span><span class="pi">:</span> <span class="s">socat</span>
    <span class="na">spec</span><span class="pi">:</span>
      <span class="na">containers</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">args</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s">TCP-LISTEN:12345,fork,reuseaddr</span>
        <span class="pi">-</span> <span class="s">EXEC:/bin/cat</span>
        <span class="na">command</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s">socat</span>
        <span class="na">image</span><span class="pi">:</span> <span class="s">alpine/socat:latest</span>
        <span class="na">imagePullPolicy</span><span class="pi">:</span> <span class="s">Always</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s">socat</span>
        <span class="na">ports</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">containerPort</span><span class="pi">:</span> <span class="m">12345</span>
          <span class="na">protocol</span><span class="pi">:</span> <span class="s">TCP</span>
<span class="nn">---</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Service</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">labels</span><span class="pi">:</span>
    <span class="na">app</span><span class="pi">:</span> <span class="s">socat</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">socat</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">default</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">ports</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">nodePort</span><span class="pi">:</span> <span class="m">31234</span>
    <span class="na">port</span><span class="pi">:</span> <span class="m">12345</span>
    <span class="na">protocol</span><span class="pi">:</span> <span class="s">TCP</span>
  <span class="na">type</span><span class="pi">:</span> <span class="s">NodePort</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">socat</code> process in the pod listens on port <code class="language-plaintext highlighter-rouge">12345</code>, and we want packets to the <code class="language-plaintext highlighter-rouge">k3d</code> node at port <code class="language-plaintext highlighter-rouge">31234</code> to be routed there.</p>

<p>As a quick demonstration of what this <code class="language-plaintext highlighter-rouge">socat</code> command does, it just echoes back whatever is sent to it on a TCP connection. I can <code class="language-plaintext highlighter-rouge">telnet</code> into the node port and get whatever I send echo’d back. Note the <code class="language-plaintext highlighter-rouge">127.0.0.1</code> is because I have the k3d docker container port forwarded to localhost on my MacBook host network:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tyler@MacBookPro sysobs % telnet 127.0.0.1 31234
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
hello
hello
world 
world
</code></pre></div></div>

<p>Under the hood, Kubernetes services are implemented by <code class="language-plaintext highlighter-rouge">EndpointSlice</code> resources, which are translated by <code class="language-plaintext highlighter-rouge">kube-proxy</code> into <code class="language-plaintext highlighter-rouge">iptables</code>/<code class="language-plaintext highlighter-rouge">netfilter</code> rules that the Linux Kernel can use directly. Essentially, these rules match packets for specific IP address / port tuples and then use network address translation to rewrite the destination address on the packet.</p>

<p>The <code class="language-plaintext highlighter-rouge">EndpointSlice</code> typically gets updated dynamically by a controller based on the <code class="language-plaintext highlighter-rouge">selector</code> field on the service, so you usually don’t have to touch them. For the purposes of this post, I removed the <code class="language-plaintext highlighter-rouge">selector</code> field from the service and created the <code class="language-plaintext highlighter-rouge">EndpointSlice</code> manually.</p>

<h1 id="happy-path">Happy Path</h1>
<p>I’ll set the correct pod IP and correct port in the <code class="language-plaintext highlighter-rouge">EndpointSlice</code> spec. From <code class="language-plaintext highlighter-rouge">kubectl get pod</code> we can see the pod’s IP address:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>socat-5c97445bc5-vw5dz       1/1     Running   0          23h     10.42.0.142   k3d-k3s-default-server-0   &lt;none&gt;           &lt;none&gt;
</code></pre></div></div>

<p>Which just needs to be set on the <code class="language-plaintext highlighter-rouge">EndpointSlice</code>, along with the correct port:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>addressType: IPv4
apiVersion: discovery.k8s.io/v1
endpoints:
- addresses:
  - 10.42.0.142
  conditions:
    ready: true
kind: EndpointSlice
metadata:
  labels:
    app: socat
    kubernetes.io/service-name: socat
  name: socat
  namespace: default

ports:
- name: ""
  port: 12345
  protocol: TCP
</code></pre></div></div>

<p>This creates the following chain of IPTables rules:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>-A KUBE-NODEPORTS -p tcp -m comment --comment "default/socat" -m tcp --dport 31234 -j KUBE-EXT-6GHM6NOKDRAB634Q

...

-A KUBE-SEP-STAYFALK6DPGANUO -p tcp -m comment --comment "default/socat" -m tcp -j DNAT --to-destination 10.42.0.142:12345
</code></pre></div></div>

<p>I removed some of the intermediate rules to simplify, but basically – this matches packets coming in to port <code class="language-plaintext highlighter-rouge">31234</code> (the <code class="language-plaintext highlighter-rouge">nodePort</code> on our service) and changes the destination address on them to <code class="language-plaintext highlighter-rouge">10.42.0.142:12345</code>.</p>

<p>And if I attempt a TCP connection against the <code class="language-plaintext highlighter-rouge">NodePort</code> service, this is packet capture:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>+0.401s  [pktcap]           capture-started  default/socat bpf="(ip and ((host 10.43.161.224 and port 12345) or ((net 10.42.0.0/24) and port 12345) or ((host 192.168.97.3) and port 31234))) or (arp and (host 10.42.0.142))" link=Linux SLL2
+3.519s  [pktcap]           tcp-packet  [eth0] 192.168.97.2:54398 -&gt; 192.168.97.3:31234 SYN
+3.519s  [pktcap]           tcp-packet  [cni0] 10.42.0.1:10639 -&gt; 10.42.0.142:12345 SYN
+3.519s  [pktcap]           tcp-packet  [veth0829e839] 10.42.0.1:10639 -&gt; 10.42.0.142:12345 SYN
+3.519s  [pktcap]           tcp-packet  [veth0829e839] 10.42.0.142:12345 -&gt; 10.42.0.1:10639 SYN,ACK
+3.519s  [pktcap]           tcp-packet  [cni0] 10.42.0.142:12345 -&gt; 192.168.97.2:54398 SYN,ACK
+3.519s  [pktcap]           tcp-packet  [eth0] 192.168.97.3:31234 -&gt; 192.168.97.2:54398 SYN,ACK
+3.519s  [pktcap]           tcp-packet  [eth0] 192.168.97.2:54398 -&gt; 192.168.97.3:31234 ACK
+3.519s  [pktcap]           tcp-packet  [cni0] 10.42.0.1:10639 -&gt; 10.42.0.142:12345 ACK
+3.519s  [pktcap]           tcp-packet  [veth0829e839] 10.42.0.1:10639 -&gt; 10.42.0.142:12345 ACK
</code></pre></div></div>

<p>This is just a standard TCP handshake, but we see three different network interfaces being traversed for each packet. The first is <code class="language-plaintext highlighter-rouge">eth0</code>, which is the “external” interface of the <code class="language-plaintext highlighter-rouge">k3d</code> node. The packet is addressed to <code class="language-plaintext highlighter-rouge">192.168.97.3:31234</code>, which is the <code class="language-plaintext highlighter-rouge">k3d</code> node IP and node port.</p>

<p>Next we see the same <code class="language-plaintext highlighter-rouge">SYN</code> packet, this time on the <code class="language-plaintext highlighter-rouge">cni0</code> interface, the virtual bridge connecting every pod on the node, and the destination address has been changed to <code class="language-plaintext highlighter-rouge">10.42.0.142:12345</code>, which is the pod’s IP and listening port. This is the direct effect of the DNAT iptables rule we looked at above, and the packet was routed to <code class="language-plaintext highlighter-rouge">cni0</code> because it now belonged to the <code class="language-plaintext highlighter-rouge">10.42.0.0/24</code> network.</p>

<p>Notice also that the source address changed, which is another effect of the iptables rules that <code class="language-plaintext highlighter-rouge">kube-proxy</code> installs, which is then reversed on the return path.</p>

<p>Finally, each pod has its own <code class="language-plaintext highlighter-rouge">veth</code> pair which bridges the pod’s network namespace to the host’s network namespace. The host end of the pair is connected to the <code class="language-plaintext highlighter-rouge">cni0</code> virtual bridge, so the packet is re-transmitted to this interface.</p>

<p>When the server sends packets back to the client, the three interfaces are traversed in reverse order (<code class="language-plaintext highlighter-rouge">veth</code> -&gt; <code class="language-plaintext highlighter-rouge">cni0</code> -&gt; <code class="language-plaintext highlighter-rouge">eth0</code>), as expected.</p>

<h1 id="break-1-wrong-port">Break #1: Wrong Port</h1>
<p>A simple way to break the service is to point at a port nothing in the pod is listening on. Since we can modify the <code class="language-plaintext highlighter-rouge">EndpointSlice</code> however we want, we can just change the port on it:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ports:
- name: ""
  port: 12346 # changed from 12345
  protocol: TCP
</code></pre></div></div>

<p>This changes the iptables rule to look like:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>-A KUBE-SEP-XVRNL33HWWUQHJC7 -p tcp -m comment --comment "default/socat" -m tcp -j DNAT --to-destination 10.42.0.142:12346
</code></pre></div></div>

<p>Nothing is listening on this port, so let’s see the packet capture:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>+0.626s  [pktcap]           capture-started  default/socat bpf="(ip and ((host 10.43.161.224 and port 12345) or ((net 10.42.0.0/24) and port 12346) or ((host 192.168.97.3) and port 31234))) or (arp and (host 10.42.0.142))" link=Linux SLL2
+50.032s  [pktcap]           tcp-packet  [eth0] 192.168.97.2:58364 -&gt; 192.168.97.3:31234 SYN
+50.032s  [pktcap]           tcp-packet  [cni0] 10.42.0.1:6999 -&gt; 10.42.0.142:12346 SYN
+50.032s  [pktcap]           tcp-packet  [veth0829e839] 10.42.0.1:6999 -&gt; 10.42.0.142:12346 SYN
+50.032s  [pktcap]           tcp-packet  [veth0829e839] 10.42.0.142:12346 -&gt; 10.42.0.1:6999 ACK,RST
+50.032s  [pktcap]           tcp-packet  [cni0] 10.42.0.142:12346 -&gt; 192.168.97.2:58364 ACK,RST
+50.032s  [pktcap]           tcp-packet  [eth0] 192.168.97.3:31234 -&gt; 192.168.97.2:58364 ACK,RST
</code></pre></div></div>

<p>Since the IP address on the EndpointSlice is still correct, the packet still gets routed to the correct pod veth pair, but since nothing inside the pod’s network namespace is listening on port <code class="language-plaintext highlighter-rouge">12346</code>, a TCP RST packet is sent back through the interfaces to the client.</p>

<p>This typically manifests on the client as a “connection refused” error.</p>

<h1 id="break-2-wrong-ip-address">Break #2: Wrong IP Address</h1>
<p>Again, we can just update the IP address on the EndpointSlice directly:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>endpoints:
- addresses:
  - 10.42.0.143
  conditions:
    ready: true
</code></pre></div></div>

<p>Which changes the iptables rule to look like:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>-A KUBE-SEP-L7Q23S57UQHHMQS4 -p tcp -m comment --comment "default/socat" -m tcp -j DNAT --to-destination 10.42.0.143:12345
</code></pre></div></div>

<p>Which results in the following packet capture:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>+0.564s  [pktcap]           capture-started  default/socat bpf="(ip and ((host 10.43.161.224 and port 12345) or ((net 10.42.0.0/24) and port 12345) or ((host 192.168.97.3) and port 31234))) or (arp and (host 10.42.0.143))" link=Linux SLL2
+7.389s  [pktcap]           tcp-packet  [eth0] 192.168.97.2:47514 -&gt; 192.168.97.3:31234 SYN
+7.389s  [pktcap]           arp-request  [cni0] arp who-has 10.42.0.143 tell 10.42.0.1
+7.389s  [pktcap]           arp-request  [veth0829e839] arp who-has 10.42.0.143 tell 10.42.0.1
+7.389s  [pktcap]           arp-request  [veth261c8b4a] arp who-has 10.42.0.143 tell 10.42.0.1
+7.389s  [pktcap]           arp-request  [vethb5026255] arp who-has 10.42.0.143 tell 10.42.0.1
+7.389s  [pktcap]           arp-request  [vethf88de69a] arp who-has 10.42.0.143 tell 10.42.0.1
+7.389s  [pktcap]           arp-request  [vethfd2cfe3e] arp who-has 10.42.0.143 tell 10.42.0.1
+7.389s  [pktcap]           arp-request  [veth03bb59f4] arp who-has 10.42.0.143 tell 10.42.0.1
+7.389s  [pktcap]           arp-request  [veth71d61d24] arp who-has 10.42.0.143 tell 10.42.0.1
+7.389s  [pktcap]           arp-request  [veth683aa91d] arp who-has 10.42.0.143 tell 10.42.0.1
</code></pre></div></div>

<p>After the packet’s destination address changes, the Kernel attempts to discover the corresponding MAC address for this IP, and broadcasts an ARP <code class="language-plaintext highlighter-rouge">who-has</code> request on the pod bridge network. The <code class="language-plaintext highlighter-rouge">veth</code> pair for each pod on the node receives a copy of the ARP request, and since the IP address isn’t attached to any of the pods, nobody is able to reply, and the TCP connection is unable to complete.</p>

<p>From the client you’re not going to see an “ARP failed” error message, you’ll probably just see it hanging while it retries the TCP handshake until it gives up with a timeout.</p>

<h1 id="break-3-a-mix-of-good-and-bad-ip-addresses">Break #3: A Mix of Good and Bad IP Addresses</h1>
<p>An <code class="language-plaintext highlighter-rouge">EndpointSlice</code> can contain multiple pod IP addresses, so let’s put the correct address alongside the wrong one:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>endpoints:
- addresses:
  - 10.42.0.143
  conditions:
    ready: true
- addresses:
  - 10.42.0.142
  conditions:
    ready: true
</code></pre></div></div>

<p>This creates iptables rules with a probabilistic load balancing effect:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>-A KUBE-SVC-6GHM6NOKDRAB634Q -m comment --comment "default/socat -&gt; 10.42.0.142:12345" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-STAYFALK6DPGANUO
-A KUBE-SVC-6GHM6NOKDRAB634Q -m comment --comment "default/socat -&gt; 10.42.0.143:12345" -j KUBE-SEP-L7Q23S57UQHHMQS4
</code></pre></div></div>

<p>And now the packet capture shows only intermittent failures:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>+20.003s  [tcp-probe]        io-error  127.0.0.1:57014 -&gt; 127.0.0.1:31234 read: read tcp 127.0.0.1:57014-&gt;127.0.0.1:31234: i/o timeout
+20.064s  [pktcap]           arp-request  [cni0] arp who-has 10.42.0.143 tell 10.42.0.1
+20.064s  [pktcap]           arp-request  [veth0829e839] arp who-has 10.42.0.143 tell 10.42.0.1
+20.064s  [pktcap]           arp-request  [veth261c8b4a] arp who-has 10.42.0.143 tell 10.42.0.1
+20.064s  [pktcap]           arp-request  [vethb5026255] arp who-has 10.42.0.143 tell 10.42.0.1
+20.064s  [pktcap]           arp-request  [vethf88de69a] arp who-has 10.42.0.143 tell 10.42.0.1
+20.064s  [pktcap]           arp-request  [vethfd2cfe3e] arp who-has 10.42.0.143 tell 10.42.0.1
+20.064s  [pktcap]           arp-request  [veth03bb59f4] arp who-has 10.42.0.143 tell 10.42.0.1
+20.064s  [pktcap]           arp-request  [veth71d61d24] arp who-has 10.42.0.143 tell 10.42.0.1
+20.064s  [pktcap]           arp-request  [veth683aa91d] arp who-has 10.42.0.143 tell 10.42.0.1
+21.007s  [tcp-probe]        echo-ok  127.0.0.1:57015 -&gt; 127.0.0.1:31234 connect=0ms echo=4ms (5B)
+21.005s  [pktcap]           tcp-packet  [eth0] 192.168.97.2:46896 -&gt; 192.168.97.3:31234 SYN
+21.005s  [pktcap]           tcp-packet  [cni0] 10.42.0.1:29742 -&gt; 10.42.0.142:12345 SYN
+21.005s  [pktcap]           tcp-packet  [veth0829e839] 10.42.0.1:29742 -&gt; 10.42.0.142:12345 SYN
+21.005s  [pktcap]           tcp-packet  [veth0829e839] 10.42.0.142:12345 -&gt; 10.42.0.1:29742 SYN,ACK
+21.005s  [pktcap]           tcp-packet  [cni0] 10.42.0.142:12345 -&gt; 192.168.97.2:46896 SYN,ACK
+21.005s  [pktcap]           tcp-packet  [eth0] 192.168.97.3:31234 -&gt; 192.168.97.2:46896 SYN,ACK
+21.005s  [pktcap]           tcp-packet  [eth0] 192.168.97.2:46896 -&gt; 192.168.97.3:31234 ACK
+21.005s  [pktcap]           tcp-packet  [cni0] 10.42.0.1:29742 -&gt; 10.42.0.142:12345 ACK
+21.005s  [pktcap]           tcp-packet  [veth0829e839] 10.42.0.1:29742 -&gt; 10.42.0.142:12345 ACK
</code></pre></div></div>

<p>We get a mix of behavior from the Happy Path and Break #2.</p>

<p>As I showed in the previous post about race conditions, this can happen when a pod is terminated before <code class="language-plaintext highlighter-rouge">kube-proxy</code> has a chance to remove the iptables rule DNAT-ing traffic to it. Unlike the first two examples – in practice nobody is going to intentionally modify EndpointSlices with the wrong IP addresses / ports – this can very well happen in real world production scenarios. You’d observe general flakiness / intermittent connection failures around rolling deploys.</p>

<h1 id="takeaway">Takeaway</h1>
<p>The experiments in this post clearly reveal that Kubernetes services are just an abstraction on top of the Linux kernel’s <code class="language-plaintext highlighter-rouge">iptables</code>/<code class="language-plaintext highlighter-rouge">netfilter</code> subsystem. All of the errors didn’t really have to do with the Kubernetes components themselves, but were just standard Linux networking errors you’d get any time you tried to access the wrong port or IP address.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[A Kubernetes service is simple conceptually. You get a stable endpoint which load balances traffic across a pool of backend pods. Under the hood it’s implemented by standard Linux kernel features – DNAT iptables rules, virtual bridges, and veth pairs. There’s no separate proxy process implementing the logic for a Kubernetes service, so when it breaks, the errors are just going to be standard Linux networking errors.]]></summary></entry><entry><title type="html">Demonstrating Kubernetes Race Conditions With A Custom Observability Tool</title><link href="http://tylerjarjoura.com/blog/demonstrating-kubernetes-race-conditions/" rel="alternate" type="text/html" title="Demonstrating Kubernetes Race Conditions With A Custom Observability Tool" /><published>2026-04-17T00:00:00+00:00</published><updated>2026-04-17T00:00:00+00:00</updated><id>http://tylerjarjoura.com/blog/demonstrating-kubernetes-race-conditions</id><content type="html" xml:base="http://tylerjarjoura.com/blog/demonstrating-kubernetes-race-conditions/"><![CDATA[<p>I had always assumed that updating Kubernetes deployments was a zero downtime operation, but I recently discovered that isn’t necessarily the case.</p>

<p>We had a <code class="language-plaintext highlighter-rouge">traefik</code> proxy running inside of a Kubernetes cluster, acting as an API gateway in front of various backend services and we noticed that there would be occasional network errors during rolling restarts of the corresponding K8s Deployment. There were small windows of downtime during deploys. Granted this was small enough not to be noticed most of the time, but it flatly contradicted my previous expectation, and I wanted to understand why this was happening.</p>

<p>Some googling led me to this <a href="https://www.gruntwork.io/blog/delaying-shutdown-to-wait-for-pod-deletion-propagation">Gruntwork blog post</a> and then this excerpt from <a href="https://freecontent.manning.com/handling-client-requests-properly-with-kubernetes/">Kubernetes In Action</a> which gave a good explanation of the problem.</p>

<p>To summarize – the basic problem is that there are two processes that happen in parallel when a pod which is fronted by a Kubernetes service shuts down. The first is that SIGTERM is sent to the actual process running inside the pod container, which is handled according to whatever the application is coded to do. The second is that the pod’s IP is removed from the list of endpoints belonging to the service, which is then picked up by <code class="language-plaintext highlighter-rouge">kube-proxy</code>, which <em>then</em> removes the corresponding <code class="language-plaintext highlighter-rouge">iptables</code> rule for the pod’s IP address. There is no built in mechanism for keeping these two in sync, so it can often happen that the process is terminated (and thus is not listening on its IP address) before the <code class="language-plaintext highlighter-rouge">iptables</code> rule for routing traffic to its IP address is removed.</p>

<p>This makes sense in theory – Kubernetes deployments and services are managed by separate controllers that operate independently, so why should I have assumed they would magically sync up? Still, I wanted to really prove to myself that this was actually happening. There are a lot of moving parts here – how could I possibly keep track of them at once?</p>

<p>The answer is that I needed a custom-built tool. And the realization I came to is that building this kind of tool is now possible in a way that just wasn’t previously. The specific unlock was being able to use LLM-assisted coding to dramatically lower the tedium involved.</p>

<p>The first problem was to reliably reproduce the network errors. Because the window of downtime was so small, the error didn’t always happen “organically”. I needed to create some artificial traffic while performing the rolling restart in order to trigger the error.</p>

<p>I’m not ashamed to admit that in the past I would have written a bash loop that cURLs the traefik URL, opened up a separate terminal to trigger the rolling restart, and then tried to move my eyes back and forth fast enough to catch the error. Instead, this time I had Claude Code write a Go program that performs continuous HTTP requests against Traefik’s URL, and in a separate goroutine triggers a rolling restart of the deployment. Then I had it log timestamped events for each operation.</p>

<p>This isn’t a hard problem, but it would have been tedious to code it all out by hand, and I probably would have decided it wasn’t worth the effort for this “small” network blip.</p>

<p>I’ve recreated the environment for the purposes of this blog post. Here’s an example of running the tool against a stock <code class="language-plaintext highlighter-rouge">k3d</code> cluster, hitting the built-in <code class="language-plaintext highlighter-rouge">traefik</code> proxy. I set up docker port-forwarding so it was exposed on <code class="language-plaintext highlighter-rouge">localhost:80</code>. I also modified it so there would be 2 replicas, and created an Ingress for a basic webpage at the path <code class="language-plaintext highlighter-rouge">/</code>.</p>

<p>What’s interesting is that the availability gap on a single node k3d cluster is very small, much smaller than the gap I noticed on the “real” cluster, so I had to be pretty aggressive with the rate of HTTP requests. Here’s an example run where it fires a request every 100ms:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>+0.006s  [deployment-rollout]  restarting  deployment=traefik namespace=kube-system
+0.009s  [http]             response  duration=7.967706ms method=GET status=200 url=http://localhost:80
+0.037s  [deployment-rollout]  restarted  deployment=traefik namespace=kube-system
+0.113s  [http]             response  duration=3.19735ms method=GET status=200 url=http://localhost:80
  ...
+2.312s  [http]             response  duration=2.380564ms method=GET status=200 url=http://localhost:80
+2.377s  [pod-termination]  terminating  deletion_timestamp=2026-04-17T05:50:32-04:00 namespace=kube-system pod=traefik-6fdcf5b898-rwbfd pod_ip=10.42.0.83
+2.412s  [http]             error  error=Get "http://localhost:80": EOF method=GET url=http://localhost:80
+2.512s  [http]             response  duration=2.286649ms method=GET status=200 url=http://localhost:80
+2.614s  [http]             response  duration=4.442221ms method=GET status=200 url=http://localhost:80
</code></pre></div></div>

<p>You can see that it immediately triggers a restart of the k8s deployment. In the background this spins up a new pod, and after <code class="language-plaintext highlighter-rouge">2.377s</code> it terminates one of the old ones, and then the next request <code class="language-plaintext highlighter-rouge">35ms</code> later fails with EOF.  Presumably this was the gap between when the traefik process had closed its listening socket but traffic was still being forwarded to it. Then in another 100ms the requests start succeeding again.</p>

<p>So with this I was able to reliably reproduce the error.</p>

<p>Next I prompted Claude Code to “add endpointslice events” to the output of the tool. Now I could see, next to the logs for the HTTP requests and the rolling restart events, the precise timestamped moment when the pod’s IP address was removed from the endpoint slice for the service.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>+12.010s  [http]             response  duration=3.896189ms method=GET status=200 url=http://localhost:80
+12.309s  [http]             response  duration=3.026349ms method=GET status=200 url=http://localhost:80
+12.609s  [http]             response  duration=2.908761ms method=GET status=200 url=http://localhost:80
+12.856s  [endpointslice]    slice-modified  changes=[~10.42.0.99(ready)] slice=traefik-gmfgw
+12.884s  [pod-termination]  terminating  deletion_timestamp=2026-04-17T06:16:40-04:00 namespace=kube-system pod=traefik-75f59cb466-qjccp pod_ip=10.42.0.97
+12.909s  [http]             error  error=Get "http://localhost:80": EOF method=GET url=http://localhost:80
+12.914s  [endpointslice]    slice-modified  changes=[~10.42.0.97(terminating)] slice=traefik-gmfgw
+13.058s  [endpointslice]    slice-modified  changes=[-10.42.0.97(terminating)] slice=traefik-gmfgw
+13.863s  [pod-termination]  deleted  namespace=kube-system pod=traefik-75f59cb466-qjccp pod_ip=10.42.0.97
</code></pre></div></div>

<p>This was interesting, but the endpoint slices are just abstract API objects, in themselves they don’t have any impact on the actual network packets flying around. What we really care about are the actual iptables rules, so that’s what I decided to add next.</p>

<p>Now this is really starting to get in the realm of things that would have not been worth the time without AI assistance. Without going into too much detail about iptables/nftables (that’s worth its own post), the solution I landed on was to:</p>
<ul>
  <li>run <code class="language-plaintext highlighter-rouge">nft monitor</code> to capture rule changes when <code class="language-plaintext highlighter-rouge">kube-proxy</code> makes them. Modern Linux distributions convert iptables -&gt; nftables under the hood, so everything shows up under <code class="language-plaintext highlighter-rouge">nft monitor</code>.</li>
  <li>figure out which nftables chain belonged to which pod, by comparing the IP address being referenced inside of it to the IP address on the Pod’s spec</li>
  <li>do a diff with previous runs to look for any additions / removals of specific IP addresses. This is necessary because <code class="language-plaintext highlighter-rouge">kube-proxy</code> calls <code class="language-plaintext highlighter-rouge">iptables-restore</code>, which deletes and recreates everything on every change.</li>
  <li>report those changes as events to <code class="language-plaintext highlighter-rouge">stdout</code></li>
</ul>

<p>The algorithm is easy to describe, so I was able to explain to Claude Code how to do it, but coding it all out would have taken quite a bit of time. I was also able to clone the source code for both kube-proxy and iptables and point Claude Code at it for reference material when designing the implementation.</p>

<p>With this I was able to see the actual iptables changes on the same timeline as the pod termination events and the tcp network errors.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>+12.610s  [http]             response  duration=2.260901ms method=GET status=200 url=http://localhost:80
+12.803s  [endpointslice]    slice-modified  changes=[~10.42.0.105(ready)] slice=traefik-gmfgw
+12.825s  [pod-termination]  terminating  deletion_timestamp=2026-04-17T06:30:28-04:00 namespace=kube-system pod=traefik-7b685bbcdd-cktdt pod_ip=10.42.0.102
+12.846s  [endpointslice]    slice-modified  changes=[~10.42.0.102(terminating)] slice=traefik-gmfgw
+12.910s  [http]             error  error=Get "http://localhost:80": EOF method=GET url=http://localhost:80
+13.010s  [endpointslice]    slice-modified  changes=[-10.42.0.102(terminating)] slice=traefik-gmfgw
+13.211s  [http]             response  duration=3.169981ms method=GET status=200 url=http://localhost:80
+13.566s  [nftables]         endpoint-added  pod=10.42.0.105
+13.566s  [nftables]         endpoint-removed  pod=10.42.0.102
</code></pre></div></div>

<p>This shows that the pod began terminating about 700ms before the corresponding iptables rule was removed, leaving a gap of downtime. Seeing the theory confirmed in real time like this was very cool. And I think this is only scratching the surface. We could keep digging by adding:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">strace</code> captures for when the Traefik pod actually closed its listening socket or called <code class="language-plaintext highlighter-rouge">exit()</code></li>
  <li>actual packet captures which could show which backend pod the packets were being routed to</li>
</ul>

<p>Finally, seeing that the gap was consistently a few hundred milliseconds, the solution was to add a <code class="language-plaintext highlighter-rouge">preStop</code> lifecycle hook to the Traefik pod, to give the endpoints / iptables rules time to update before it stopped serving traffic:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>lifecycle:
  preStop:
	exec:
	  command:
	  - /bin/sh
	  - -c
	  - sleep 10
</code></pre></div></div>

<p>Then the error is consistently avoided:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>+20.944s  [pod-termination]  terminating  deletion_timestamp=2026-04-17T07:03:43-04:00 namespace=kube-system pod=traefik-54fcb567cc-4j6hk pod_ip=10.42.0.112
+20.972s  [endpointslice]    slice-modified  changes=[~10.42.0.112(terminating)] slice=traefik-gmfgw
+21.041s  [http]             response  duration=3.703407ms method=GET status=200 url=http://localhost:80
+21.342s  [http]             response  duration=3.984828ms method=GET status=200 url=http://localhost:80
  ...
+21.947s  [nftables]         endpoint-removed  pod=10.42.0.112
  ...
+31.120s  [endpointslice]    slice-modified  changes=[-10.42.0.112(terminating)] slice=traefik-gmfgw
+31.209s  [pod-termination]  deleted  namespace=kube-system pod=traefik-54fcb567cc-4j6hk pod_ip=10.42.0.112
+31.240s  [http]             response  duration=2.995166ms method=GET status=200 url=http://localhost:80
</code></pre></div></div>

<p>Now you can see that there is a big gap between the <code class="language-plaintext highlighter-rouge">terminating</code> and <code class="language-plaintext highlighter-rouge">deleted</code> events, more than big enough for the iptables changes to be applied, and so the HTTP errors are avoided.</p>

<p>There’s a lot of discussion about LLMs atrophying your programming skills, giving you a shallower understanding of the system you’re working on, and just generally reducing your competence as an engineer. In general, there may be some truth to that, but this case was the opposite experience. I was able to gain a much deeper and much more visceral understanding of a complex networking/distributed systems problem than would have been possible previously, at least on a reasonable timeframe.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[I had always assumed that updating Kubernetes deployments was a zero downtime operation, but I recently discovered that isn’t necessarily the case.]]></summary></entry><entry><title type="html">Assumptions Your SaaS Application Makes That Prevent You From Deploying On-Prem</title><link href="http://tylerjarjoura.com/blog/assumptions-your-saas-application-makes/" rel="alternate" type="text/html" title="Assumptions Your SaaS Application Makes That Prevent You From Deploying On-Prem" /><published>2026-04-09T00:00:00+00:00</published><updated>2026-04-09T00:00:00+00:00</updated><id>http://tylerjarjoura.com/blog/assumptions-your-saas-application-makes</id><content type="html" xml:base="http://tylerjarjoura.com/blog/assumptions-your-saas-application-makes/"><![CDATA[<p>SaaS is the default delivery method for many software companies, for good reason, but it can totally shut you out from entire market segments.</p>

<p>Organizations in segments like government, healthcare, and finance all have extensive legal regulations which make on-prem a de-facto requirement. They could potentially benefit from your software but need you to package it in a way that works with this on-prem requirement.</p>

<p>In order to achieve this, it’s important to avoid certain assumptions that wouldn’t hold in an on-premises environment. Any of these assumptions could end up being a future dealbreaker for a lucrative contract.</p>

<p>Here’s a few examples that I used to take for granted when I had unlimited access to whatever cloud services I wanted and had unlimited time to fine-tune the singleton SaaS environment to my liking. The purpose is not to be comprehensive, but just to get you thinking.</p>

<h2 id="network-connectivity">Network Connectivity</h2>

<p>Your SaaS offering might assume it has free rein to access the internet. Maybe it tries to access a third party service hosted on the internet – cloud hosted email providers, payment processors, telemetry collectors, etc. Maybe your install/deploy process tries to download dependencies from the internet.</p>

<p>Any of these can result in network connectivity errors, where your application hangs for 30 seconds on startup and then crashes with a cryptic error message.</p>

<p>You should identify every outbound network dependency that your application has. It’s probably a lot more than you think. Then as much as possible think about an internal solution that could replace these, or make the dependency optional and think about a way for your application to handle it gracefully.</p>

<h2 id="secret-management">Secret Management</h2>

<p>Your SaaS offering might assume it can pull secrets from AWS Secrets Manager, a managed Hashicorp Vault instance, or Google Cloud Secrets. On prem these secrets need to be pulled from some secure storage inside the customer’s environment.</p>

<p>Audit your code for assumptions about where it pulls secrets from. Add support for general purpose injection methods like environment variables, or bundle your own secret management solution. Make it easy for the customer to provide the secret inputs your application needs without exposing them to unnecessary security risks.</p>

<h2 id="certificate-management">Certificate Management</h2>

<p>Six months after you deploy to the customer’s environment, they start getting confusing errors about certificate expiration. Your SaaS application used Let’s Encrypt, and certificate rotation was handled automatically, so no one thought about this problem in the customer’s environment.</p>

<p>Additionally, your SaaS offering might serve TLS with a certificate obtained from a public CA, but your on-prem customer might have their own internal CA they want you to use, or maybe you need to come with a solution to package some kind of mini-PKI with your application.</p>

<h2 id="runtime">Runtime</h2>

<p>Your SaaS offering might run on cloud hosted Kubernetes. On-prem, the customer might not have a Kubernetes cluster, or might have their own in house cluster. Or maybe you need to ship a self-hosted Kubernetes distribution like K3s, which may have different constraints than your cloud-hosted Kubernetes.</p>

<p>Ideally you should have integration tests which spin up your application from scratch in the type of environment you expect your customer to have.</p>

<h2 id="authentication">Authentication</h2>

<p>Your SaaS offering might have integrations with cloud hosted identity providers like Google, but on-prem customers might want to authenticate users with LDAP or Kerberos (Active Directory). Try to keep your authentication methods de-coupled from the rest of your business logic.</p>

<p>This is potentially a more complex undertaking than the others, but could really move the needle with enterprise buyers.</p>

<h2 id="dns-records">DNS Records</h2>

<p>Your SaaS offering is available at <code class="language-plaintext highlighter-rouge">my-cool-saas.com</code>. On-prem it becomes <code class="language-plaintext highlighter-rouge">my-cool-saas.corp.internal</code>. Same for any auxiliary services like databases. Same for internal service to service communication if your app has multiple services.</p>

<p>Audit your code and make sure it doesn’t assume a particular service is available at a hard-coded hostname. All hostnames should be somehow injectable from the environment – configuration files, environment variables, etc.</p>

<h2 id="conclusion">Conclusion</h2>

<p>These are just a few of the assumptions that can creep in when you’re only developing for a SaaS environment that you have full control over. The common thread running through them is that they couple the business logic of your software (what your buyer is paying for) with the environment that it happens to run in today.</p>

<p>And even if you’re not planning to deploy on-prem any time soon, de-coupling these two will lead to more robust code that will help even a purely SaaS deployment. It’s a great exercise to really sit down and think critically about the actual requirements of your application, and how you could make it more portable.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[SaaS is the default delivery method for many software companies, for good reason, but it can totally shut you out from entire market segments.]]></summary></entry></feed>