<?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: Farooq Hussain</title>
    <description>The latest articles on DEV Community by Farooq Hussain (@farooqhussain).</description>
    <link>https://dev.to/farooqhussain</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%2F4191068%2F20251f6e-3761-4efc-8023-9acba8596668.png</url>
      <title>DEV Community: Farooq Hussain</title>
      <link>https://dev.to/farooqhussain</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/farooqhussain"/>
    <language>en</language>
    <item>
      <title>Your Kubernetes Deployments Are Killing Sidekiq Jobs</title>
      <dc:creator>Farooq Hussain</dc:creator>
      <pubDate>Wed, 21 Oct 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/farooqhussain/your-deployment-are-killing-sidekiq-jobs-heres-the-fix-doh</link>
      <guid>https://dev.to/farooqhussain/your-deployment-are-killing-sidekiq-jobs-heres-the-fix-doh</guid>
      <description>&lt;p&gt;&lt;em&gt;How a preStop hook, one Unix signal, and the right grace period stopped Kubernetes from killing our long-running Sidekiq jobs in the middle of their work.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every deployment looked clean. Green pipeline, new pods up, old pods gone. And every deploy, a handful of background jobs quietly died halfway through.&lt;/p&gt;

&lt;p&gt;We ran a big part of our product on Sidekiq workers in Kubernetes. Short jobs were fine. The long ones, the jobs that needed a few minutes to finish, were the problem. If a deploy landed while one of them was running, it got cut off. Sometimes Sidekiq retried it, and it finished later. Sometimes it left work half done, and someone had to clean up by hand.&lt;/p&gt;

&lt;p&gt;Nothing was "broken." Kubernetes and Sidekiq were both doing exactly what they were told. They just weren't told the same thing.&lt;/p&gt;

&lt;p&gt;This article walks through why it happens, the fix we put in place, and every piece of config you need to do the same: the preStop script, the Deployment, the Sidekiq config, the Dockerfile, and how to test it before you trust it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;If you're running Sidekiq on Kubernetes with default settings, you've probably seen some of these without connecting them to deploys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Jobs show up in the Retry set right after a release, with errors that don't make sense for the job.&lt;/li&gt;
&lt;li&gt;Long jobs that "ran twice": emails sent twice, records processed twice, partial exports.&lt;/li&gt;
&lt;li&gt;Jobs that were running before the deploy and then simply vanished. No retry, no error, no trace.&lt;/li&gt;
&lt;li&gt;Sidekiq logs on the old pod that end abruptly, without the usual &lt;code&gt;Bye!&lt;/code&gt; shutdown line.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern is the giveaway: it only happens around deploys, scale-downs, and node drains. In other words, it happens whenever Kubernetes stops a pod.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens when Kubernetes stops a pod
&lt;/h2&gt;

&lt;p&gt;When a rolling update replaces a pod, Kubernetes runs this sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Marks the pod as &lt;strong&gt;Terminating&lt;/strong&gt; and removes it from Service endpoints.&lt;/li&gt;
&lt;li&gt;Runs the container's &lt;strong&gt;&lt;code&gt;preStop&lt;/code&gt; hook&lt;/strong&gt;, if one is defined, and waits for it to finish.&lt;/li&gt;
&lt;li&gt;Sends &lt;strong&gt;&lt;code&gt;SIGTERM&lt;/code&gt;&lt;/strong&gt; to PID 1 in the container.&lt;/li&gt;
&lt;li&gt;Waits until &lt;strong&gt;&lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt;&lt;/strong&gt; runs out.&lt;/li&gt;
&lt;li&gt;Sends &lt;strong&gt;&lt;code&gt;SIGKILL&lt;/code&gt;&lt;/strong&gt;. Whatever is still running dies on the spot.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two details matter a lot here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The default &lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt; is &lt;strong&gt;30 seconds&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The grace period clock starts at &lt;strong&gt;step 1&lt;/strong&gt;, not after the preStop hook. The hook's time is spent out of the same budget.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the timeline with defaults:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;t=0s     Pod marked Terminating
t=0s     preStop hook (none by default)
t=0s     SIGTERM -&amp;gt; Sidekiq starts shutting down
t=25s    Sidekiq shutdown timeout expires, unfinished jobs pushed back to Redis
t=30s    SIGKILL if the process is still alive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What Sidekiq does with signals
&lt;/h2&gt;

&lt;p&gt;Sidekiq listens for a few Unix signals. Two of them matter for deploys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;TSTP&lt;/code&gt; (quiet):&lt;/strong&gt; stop fetching new jobs. Keep working on the jobs already running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;TERM&lt;/code&gt; (shutdown):&lt;/strong&gt; stop fetching, wait up to &lt;code&gt;timeout&lt;/code&gt; seconds for running jobs, push unfinished jobs back to Redis, and exit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Older Sidekiq versions used &lt;code&gt;USR1&lt;/code&gt; for quiet. Since Sidekiq 5, it's &lt;code&gt;TSTP&lt;/code&gt;. The default shutdown &lt;code&gt;timeout&lt;/code&gt; is &lt;strong&gt;25 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So, out of the box, Kubernetes sends &lt;code&gt;SIGTERM&lt;/code&gt;, and Sidekiq gives running jobs 25 seconds, pushes the rest back to the queue, and exits. Kubernetes allows 30 seconds total before &lt;code&gt;SIGKILL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We were running both defaults. For our long jobs, neither number was anywhere close.&lt;/p&gt;

&lt;p&gt;Here's what happens to a job that's still running at the deadline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best case:&lt;/strong&gt; Sidekiq pushes it back to Redis. Another worker picks it up and starts again from zero. If the job isn't idempotent, it does half its work twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Worst case:&lt;/strong&gt; the process gets &lt;code&gt;SIGKILL&lt;/code&gt; before it can push the job back. With open-source Sidekiq, which uses basic fetch, that job is simply gone. Sidekiq Pro's reliable fetch can recover it, but most teams run the open-source version.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The fix: quiet first, then wait, then stop
&lt;/h2&gt;

&lt;p&gt;The idea we landed on is simple. Before Kubernetes sends &lt;code&gt;SIGTERM&lt;/code&gt;, tell Sidekiq to go quiet so it stops taking new work. Then give the jobs it already has enough time to finish. Only then let the normal shutdown happen.&lt;/p&gt;

&lt;p&gt;The new timeline looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;t=0s      Pod marked Terminating
t=0s      preStop: kill -TSTP 1      -&amp;gt; Sidekiq stops fetching new jobs
t=0s..N   preStop waits while running jobs finish (new pod already takes new work)
t=N       preStop exits
t=N       SIGTERM -&amp;gt; Sidekiq has nothing left to do, exits in seconds
t&amp;lt;600s    Pod gone. No SIGKILL, no lost jobs.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It takes four pieces to make that work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A preStop script that sends &lt;code&gt;TSTP&lt;/code&gt; and waits for running jobs to finish.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt; longer than your longest job.&lt;/li&gt;
&lt;li&gt;A Sidekiq config with a sensible shutdown timeout.&lt;/li&gt;
&lt;li&gt;A container where Sidekiq is actually PID 1, so the signal reaches it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Let's go through each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Piece 1: the preStop drain script
&lt;/h2&gt;

&lt;p&gt;This script goes into your app image, for example at &lt;code&gt;bin/sidekiq-prestop.sh&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="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="c"&gt;# bin/sidekiq-prestop.sh&lt;/span&gt;
&lt;span class="c"&gt;#&lt;/span&gt;
&lt;span class="c"&gt;# Called by the Kubernetes preStop hook.&lt;/span&gt;
&lt;span class="c"&gt;# 1. Puts Sidekiq into quiet mode (stop fetching new jobs).&lt;/span&gt;
&lt;span class="c"&gt;# 2. Waits until this pod has no busy jobs, or until MAX_WAIT runs out.&lt;/span&gt;
&lt;span class="c"&gt;#&lt;/span&gt;
&lt;span class="c"&gt;# MAX_WAIT must be lower than terminationGracePeriodSeconds minus the&lt;/span&gt;
&lt;span class="c"&gt;# Sidekiq shutdown timeout, or Kubernetes will SIGKILL us mid-drain.&lt;/span&gt;

&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;

&lt;span class="nv"&gt;MAX_WAIT&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;SIDEKIQ_DRAIN_SECONDS&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;540&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;INTERVAL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5

log&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;# Write to PID 1's stdout so the message shows up in `kubectl logs`.&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[prestop] &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%H:%M:%S&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /proc/1/fd/1
&lt;span class="o"&gt;}&lt;/span&gt;

log &lt;span class="s2"&gt;"sending TSTP to Sidekiq (quiet mode)"&lt;/span&gt;
&lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="nt"&gt;-TSTP&lt;/span&gt; 1

&lt;span class="nv"&gt;waited&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$waited&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAX_WAIT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="c"&gt;# Prints "&amp;lt;quiet&amp;gt; &amp;lt;busy&amp;gt;", for example "true 1".&lt;/span&gt;
  &lt;span class="c"&gt;# Prints nothing if the check fails (Redis down, process not found).&lt;/span&gt;
  &lt;span class="nv"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;bundle &lt;span class="nb"&gt;exec &lt;/span&gt;ruby &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'
    require "sidekiq/api"
    me = Sidekiq::ProcessSet.new.find { |p| p["hostname"] == ENV["HOSTNAME"] }
    puts "#{me["quiet"]} #{me["busy"].to_i}" if me
  '&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;

  &lt;span class="nv"&gt;quiet&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$state&lt;/span&gt;&lt;span class="s2"&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="s2"&gt;" "&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;busy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$state&lt;/span&gt;&lt;span class="s2"&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="s2"&gt;" "&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$quiet&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"true"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="c"&gt;# No heartbeat since TSTP yet, or the check failed. Don't trust busy.&lt;/span&gt;
    log &lt;span class="s2"&gt;"waiting for a fresh heartbeat, waited &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;waited&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s of &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MAX_WAIT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s"&lt;/span&gt;
  &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$busy&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"0"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;log &lt;span class="s2"&gt;"no busy jobs after &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;waited&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s, letting shutdown continue"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;0
  &lt;span class="k"&gt;else
    &lt;/span&gt;log &lt;span class="s2"&gt;"still &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;busy&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; busy job(s), waited &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;waited&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s of &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MAX_WAIT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s"&lt;/span&gt;
  &lt;span class="k"&gt;fi

  &lt;/span&gt;&lt;span class="nb"&gt;sleep&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INTERVAL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nv"&gt;waited&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;waited &lt;span class="o"&gt;+&lt;/span&gt; INTERVAL&lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;done

&lt;/span&gt;log &lt;span class="s2"&gt;"drain timeout reached with jobs still running, continuing shutdown"&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few notes on this script:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;kill -TSTP 1&lt;/code&gt;&lt;/strong&gt; only works because Sidekiq is PID 1 in the container. More on that in piece 4.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The busy check&lt;/strong&gt; uses Sidekiq's own API. Each Sidekiq process registers in Redis with its hostname, and in Kubernetes the hostname is the pod name, so &lt;code&gt;ENV["HOSTNAME"]&lt;/code&gt; finds this exact pod.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It waits for a fresh heartbeat.&lt;/strong&gt; The &lt;code&gt;busy&lt;/code&gt; count comes from Sidekiq's heartbeat in Redis, which is a few seconds old. Right after &lt;code&gt;TSTP&lt;/code&gt;, it can still say 0 while a job that was just fetched is running. So the script only trusts &lt;code&gt;busy&lt;/code&gt; once the process reports &lt;code&gt;quiet=true&lt;/code&gt;, which means a heartbeat ran after &lt;code&gt;TSTP&lt;/code&gt;. If the check fails, it keeps waiting instead of assuming the pod is idle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It exits as soon as the pod is idle.&lt;/strong&gt; A quiet pod with nothing running shuts down in about five seconds. A pod in the middle of a long job waits for it. You don't pay the full wait on every pod.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It always exits 0.&lt;/strong&gt; If the hook fails, Kubernetes records a &lt;code&gt;FailedPreStopHook&lt;/code&gt; event and moves straight on to &lt;code&gt;SIGTERM&lt;/code&gt;. Exiting cleanly keeps the shutdown predictable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logs go to &lt;code&gt;/proc/1/fd/1&lt;/code&gt;.&lt;/strong&gt; The preStop output doesn't show up in &lt;code&gt;kubectl logs&lt;/code&gt; by default. Writing to PID 1's stdout puts the drain messages right next to Sidekiq's own logs, which makes debugging much easier.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Make it executable in your image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; bin/sidekiq-prestop.sh /app/bin/sidekiq-prestop.sh&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /app/bin/sidekiq-prestop.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The one-line version
&lt;/h3&gt;

&lt;p&gt;If you don't want the Ruby check, there's a simpler version:&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;lifecycle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;preStop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;exec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/bin/sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kill&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-TSTP&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sleep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;540"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works, but it's blunt. Every pod waits the full 540 seconds, even an idle one, so rollouts and scale-downs get slow. It's fine to start with. The script version is better once you run more than a couple of replicas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Piece 2: the grace period
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;terminationGracePeriodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;600&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;terminationGracePeriodSeconds &amp;gt; preStop max wait + Sidekiq shutdown timeout + buffer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the numbers in this article:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;600 &amp;gt; 540 (SIDEKIQ_DRAIN_SECONDS) + 25 (Sidekiq :timeout:) + 35 (buffer)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick the drain time from your real job durations, not from a guess. If your longest job can run for 9 minutes, a 540-second drain covers it. If you have a job that runs for an hour, a preStop hook is the wrong tool. Split that job into smaller chunks instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Piece 3: the Sidekiq config
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/sidekiq.yml&lt;/span&gt;
&lt;span class="na"&gt;:concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
&lt;span class="na"&gt;:timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;25&lt;/span&gt;
&lt;span class="na"&gt;:queues&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;critical&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;low&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By the time &lt;code&gt;SIGTERM&lt;/code&gt; arrives, the preStop hook has already drained the pod. The 25-second &lt;code&gt;:timeout:&lt;/code&gt; only has to cover whatever is left, which is usually nothing. Keep it well under the grace period minus the drain time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Piece 4: make sure Sidekiq is PID 1
&lt;/h2&gt;

&lt;p&gt;This is the gotcha that quietly breaks the whole thing. &lt;code&gt;kill -TSTP 1&lt;/code&gt; sends the signal to whatever process is PID 1. If that's a shell, Sidekiq never hears it.&lt;/p&gt;

&lt;p&gt;This is wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Shell form: /bin/sh is PID 1, Sidekiq is a child process&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; bundle exec sidekiq -C config/sidekiq.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Exec form: Sidekiq is PID 1 and receives signals directly&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["bundle", "exec", "sidekiq", "-C", "config/sidekiq.yml"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you need an entrypoint script for setup work, end it with &lt;code&gt;exec&lt;/code&gt; so Sidekiq replaces the shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="c"&gt;# docker-entrypoint.sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;

&lt;span class="c"&gt;# ...any setup you need (wait for Redis, load secrets, etc.)&lt;/span&gt;

&lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can check what's running as PID 1 inside a 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;-it&lt;/span&gt; deploy/sidekiq-worker &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; /proc/1/cmdline | &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;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the output starts with &lt;code&gt;sidekiq&lt;/code&gt; or &lt;code&gt;bundle exec sidekiq&lt;/code&gt;, you're good. If it starts with &lt;code&gt;/bin/sh&lt;/code&gt;, fix that first.&lt;/p&gt;

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

&lt;p&gt;Here's everything put together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sidekiq-worker&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sidekiq-worker&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;strategy&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;RollingUpdate&lt;/span&gt;
    &lt;span class="na"&gt;rollingUpdate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;maxSurge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;maxUnavailable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&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;sidekiq-worker&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sidekiq-worker&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;terminationGracePeriodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;600&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sidekiq&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;registry.example.com/app:1.42.0&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bundle"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exec"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sidekiq"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-C"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;config/sidekiq.yml"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SIDEKIQ_DRAIN_SECONDS&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;540"&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;REDIS_URL&lt;/span&gt;
              &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;secretKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-secrets&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;redis-url&lt;/span&gt;
          &lt;span class="na"&gt;lifecycle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;preStop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;exec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/bin/sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/app/bin/sidekiq-prestop.sh"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;250m"&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;512Mi"&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1Gi"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things are worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;maxUnavailable: 0&lt;/code&gt; with &lt;code&gt;maxSurge: 1&lt;/code&gt;&lt;/strong&gt; means Kubernetes starts a new worker before an old one begins draining. The queue keeps moving during the deploy, and you never drop below your normal worker count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch your liveness probe.&lt;/strong&gt; If you have one that checks Sidekiq, make sure a quiet, draining pod still passes it. Otherwise Kubernetes may restart the container in the middle of the drain.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Protect it during node drains too
&lt;/h2&gt;

&lt;p&gt;Deploys aren't the only time pods get stopped. Cluster upgrades and &lt;code&gt;kubectl drain&lt;/code&gt; evict pods as well. A PodDisruptionBudget stops Kubernetes from evicting too many workers at once:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Evicted pods still run the preStop hook and respect the grace period, so the same drain logic applies. The PDB just makes sure they drain one at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to test it before you trust it
&lt;/h2&gt;

&lt;p&gt;Don't wait for the next real deploy to find out if this works. Test it on purpose in a non-production environment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Add a slow test job:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/jobs/slow_test_job.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SlowTestJob&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;Sidekiq&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Job&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;Sidekiq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt; &lt;span class="s2"&gt;"SlowTestJob starting, will run &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s"&lt;/span&gt;
    &lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;times&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;sleep&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="no"&gt;Sidekiq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt; &lt;span class="s2"&gt;"SlowTestJob finished cleanly"&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Enqueue it:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; deploy/sidekiq-worker &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  bundle &lt;span class="nb"&gt;exec &lt;/span&gt;rails runner &lt;span class="s1"&gt;'SlowTestJob.perform_async(120)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Find the pod that picked it up, then delete it:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &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;sidekiq-worker
kubectl delete pod sidekiq-worker-7d9f8c6b5-abcde
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Watch the logs of the terminating pod:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs &lt;span class="nt"&gt;-f&lt;/span&gt; sidekiq-worker-7d9f8c6b5-abcde
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[prestop] 10:14:02 sending TSTP to Sidekiq (quiet mode)
INFO: Received TSTP, no longer accepting new work
[prestop] 10:14:07 still 1 busy job(s), waited 0s of 540s
[prestop] 10:14:12 still 1 busy job(s), waited 5s of 540s
...
INFO: SlowTestJob finished cleanly
[prestop] 10:15:57 no busy jobs after 115s, letting shutdown continue
INFO: Shutting down
INFO: Bye!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see &lt;code&gt;SlowTestJob finished cleanly&lt;/code&gt; and &lt;code&gt;Bye!&lt;/code&gt; before the pod disappears, the drain works. If the logs stop abruptly with no &lt;code&gt;Bye!&lt;/code&gt;, the pod was killed. Check the grace period and the PID 1 setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Check events for hook failures:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get events &lt;span class="nt"&gt;--field-selector&lt;/span&gt; &lt;span class="nv"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;FailedPreStopHook
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty result is what you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Jobs still need to be idempotent
&lt;/h2&gt;

&lt;p&gt;The preStop hook handles planned shutdowns: deploys, scale-downs, node drains. It doesn't handle everything. Nodes crash. Containers get OOM-killed. Someone runs &lt;code&gt;kubectl delete pod --force --grace-period=0&lt;/code&gt;. In all of those cases, no hook runs.&lt;/p&gt;

&lt;p&gt;So the real safety net is still a job that can safely run twice. A simple pattern is to record that the work is done and check before doing it again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SendInvoiceJob&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;Sidekiq&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Job&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sent_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt;   &lt;span class="c1"&gt;# already done, safe to skip&lt;/span&gt;

    &lt;span class="no"&gt;InvoiceMailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;invoice: &lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;deliver_now&lt;/span&gt;
    &lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;sent_at: &lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This still leaves one gap. If the worker dies after the email goes out but before &lt;code&gt;sent_at&lt;/code&gt; is saved, the retry sends the invoice again. No hook can close that window. For a plain notification, a rare duplicate may be fine. For anything tied to money, save a delivery record with a stable key &lt;em&gt;before&lt;/em&gt; sending, pass that key to the provider if it supports idempotency, and check the record before retrying. Test it by killing the worker between the send and the database update.&lt;/p&gt;

&lt;p&gt;For long jobs that loop over many records, save progress as you go, so a retry picks up where it stopped instead of starting over:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BackfillJob&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;Sidekiq&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Job&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;processed: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;find_each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
      &lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;processed: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# checkpoint after each item&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The drain makes deploys clean. Idempotency makes everything else survivable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;p&gt;Before you call this done:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sidekiq runs as PID 1 (exec form &lt;code&gt;CMD&lt;/code&gt;, or &lt;code&gt;exec&lt;/code&gt; in the entrypoint)&lt;/li&gt;
&lt;li&gt;preStop hook sends &lt;code&gt;TSTP&lt;/code&gt; and waits for busy jobs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt; &amp;gt; drain time + Sidekiq timeout + buffer&lt;/li&gt;
&lt;li&gt;Drain time is based on your longest real job&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;maxUnavailable: 0&lt;/code&gt; so new workers start before old ones drain&lt;/li&gt;
&lt;li&gt;PodDisruptionBudget for node drains&lt;/li&gt;
&lt;li&gt;Liveness probe still passes while the pod is quiet&lt;/li&gt;
&lt;li&gt;Tested with a slow job and a manual pod delete&lt;/li&gt;
&lt;li&gt;Jobs are idempotent for the cases no hook can catch&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;After this went in, deploys stopped killing jobs. Long jobs finished on the old pod while new pods picked up new work. Nobody had to check the Retry set after a release, and nobody had to clean up half-processed work by hand.&lt;/p&gt;

&lt;p&gt;The whole fix is a shell script, a couple of numbers, and one signal. The hard part was noticing that Kubernetes and Sidekiq each had a perfectly reasonable idea of "shutting down gracefully," and the two didn't match.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Updated: thanks to Raza Hussain for spotting the stale heartbeat in the busy check, and to Tim Urista for pointing out the crash window in the invoice example. Both are fixed above.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write up real production fixes like this one. If your team runs Sidekiq on Kubernetes, go check your &lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt; right now. If it still says 30, you're probably losing jobs.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>sre</category>
      <category>ruby</category>
    </item>
    <item>
      <title>I Built a Kubernetes Incident Manager That Explains Broken Pods With a Local AI Model</title>
      <dc:creator>Farooq Hussain</dc:creator>
      <pubDate>Mon, 19 Oct 2026 10:19:28 +0000</pubDate>
      <link>https://dev.to/farooqhussain/i-built-a-kubernetes-incident-manager-that-explains-broken-pods-with-a-local-ai-model-4k5m</link>
      <guid>https://dev.to/farooqhussain/i-built-a-kubernetes-incident-manager-that-explains-broken-pods-with-a-local-ai-model-4k5m</guid>
      <description>&lt;p&gt;It's 3 AM. Your phone buzzes. A pod is in &lt;code&gt;CrashLoopBackOff&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You open your laptop, run &lt;code&gt;kubectl describe&lt;/code&gt;, scroll through events, pull the logs from the crashed container, and try to work out what actually went wrong. Ten minutes later you find it: a missing Secret. Or a memory limit. Or a database that isn't reachable.&lt;/p&gt;

&lt;p&gt;AI is good at exactly this kind of reading. But most teams can't paste production logs into a hosted AI chat. Those logs are full of connection strings, tokens, customer emails and internal IPs.&lt;/p&gt;

&lt;p&gt;So I built a small incident manager that runs &lt;strong&gt;inside the cluster&lt;/strong&gt; and explains broken pods with an &lt;strong&gt;open-weight model running on my own machine&lt;/strong&gt;. Nothing leaves the infrastructure, and it costs nothing per incident.&lt;/p&gt;

&lt;p&gt;Code: &lt;a href="https://github.com/farooqhussain82/incident-explainer" rel="noopener noreferrer"&gt;github.com/farooqhussain82/incident-explainer&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc7vzpsdjjk8tmjlgriun.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc7vzpsdjjk8tmjlgriun.png" alt="Incident list" width="800" height="525"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;Every 15 seconds it lists the pods in the cluster.&lt;/li&gt;
&lt;li&gt;When one breaks (crash loop, OOMKilled, image pull error, missing Secret or ConfigMap, failing readiness probe, unschedulable), it &lt;strong&gt;opens an incident&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;It collects the evidence: a &lt;code&gt;kubectl describe&lt;/code&gt;-style summary, events, and logs, including the logs of the previous crashed run.&lt;/li&gt;
&lt;li&gt;It &lt;strong&gt;redacts secrets&lt;/strong&gt; from that text.&lt;/li&gt;
&lt;li&gt;It asks a local model in &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; (Qwen 3 8B by default) what went wrong and what to check next.&lt;/li&gt;
&lt;li&gt;When the pod is healthy for four checks in a row, the incident &lt;strong&gt;resolves itself&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is a small web page for the incidents, and it can post new incidents to Slack, Teams or Mattermost through an incoming webhook.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demo: four pods, four different failures
&lt;/h2&gt;

&lt;p&gt;To test it, I made a throwaway &lt;a href="https://kind.sigs.k8s.io" rel="noopener noreferrer"&gt;kind&lt;/a&gt; cluster and deployed four pods that break in four common ways. All four were detected and explained on my laptop (Apple M4, 16 GB RAM) in &lt;strong&gt;20 to 45 seconds each&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pod&lt;/th&gt;
&lt;th&gt;What I broke&lt;/th&gt;
&lt;th&gt;What the model said&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;payments-api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Refers to a Secret that doesn't exist&lt;/td&gt;
&lt;td&gt;The pod can't start because Secret &lt;code&gt;payments-secrets&lt;/code&gt; is missing. Check it exists in the namespace.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;report-worker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Memory limit of 64Mi, process uses far more&lt;/td&gt;
&lt;td&gt;OOMKilled, exit code 137, because usage goes over the 64Mi limit.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;checkout-web&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;App can't reach its database&lt;/td&gt;
&lt;td&gt;Connection refused to PostgreSQL on port 5432. Check the database is running and reachable.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;orders-db&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Postgres started without a password&lt;/td&gt;
&lt;td&gt;Postgres won't initialise because &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt; is not set.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each answer quotes the exact log or event lines it is based on, so you can check it in seconds instead of trusting it blindly:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F929nek93jj9nnywr4k9b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F929nek93jj9nnywr4k9b.png" alt="Incident detail" width="800" height="1049"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then I created the missing Secret for &lt;code&gt;payments-api&lt;/code&gt;. About a minute later the incident moved to "Recently resolved" on its own.&lt;/p&gt;

&lt;p&gt;One detail I'm happy with: an out-of-memory container also shows &lt;code&gt;CrashLoopBackOff&lt;/code&gt; in Kubernetes, because it keeps restarting. The detector looks at the last termination reason and reports it as &lt;strong&gt;OOMKilled&lt;/strong&gt;, which is the real cause and the thing you need to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an open, local model
&lt;/h2&gt;

&lt;p&gt;This was the main reason for the project, not an extra.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The data stays inside.&lt;/strong&gt; The model runs next to the cluster. Logs are never sent to a third party.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No bill per incident.&lt;/strong&gt; A crash loop that fires 200 times a night doesn't turn into an API invoice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You choose the model.&lt;/strong&gt; Qwen 3 8B runs fine on a laptop. Use a bigger one on a GPU node, or one tuned on your own runbooks. It's one environment variable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also made the model answer in a fixed JSON format (summary, likely cause, evidence, next steps, confidence). That keeps the output predictable enough to show in a UI and send to chat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redaction, even though the model is local
&lt;/h2&gt;

&lt;p&gt;"Local" should not mean "careless". Before anything reaches the model, the text is cleaned:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;private keys, AWS keys, JWTs and bearer tokens&lt;/li&gt;
&lt;li&gt;passwords in URLs like &lt;code&gt;postgres://user:password@host&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;values of &lt;code&gt;password=&lt;/code&gt;, &lt;code&gt;token=&lt;/code&gt;, &lt;code&gt;api_key=&lt;/code&gt; and similar&lt;/li&gt;
&lt;li&gt;email addresses and single IP addresses&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the &lt;code&gt;checkout-web&lt;/code&gt; demo, the log contained a database URL with a password, a JWT and IP addresses. The model only saw this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;connecting to postgres://[REDACTED-URL-CREDS]@[REDACTED-IP]:5432/checkout
Authorization: Bearer [REDACTED-JWT]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each incident page has a "Show exactly what the model saw" section, so anyone can check.&lt;/p&gt;

&lt;p&gt;One thing I had to fix during testing: my first version also masked &lt;code&gt;0.0.0.0/0&lt;/code&gt; in an infrastructure change. That hid the most important fact, which was a database being opened to the whole internet. Now CIDR ranges and &lt;code&gt;0.0.0.0&lt;/code&gt; are kept, and only single host IPs are masked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security of the thing itself
&lt;/h2&gt;

&lt;p&gt;A tool that reads every pod in your cluster has to be boring and safe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read-only RBAC:&lt;/strong&gt; only &lt;code&gt;get&lt;/code&gt;, &lt;code&gt;list&lt;/code&gt; and &lt;code&gt;watch&lt;/code&gt; on pods, pod logs and events. I checked it with &lt;code&gt;kubectl auth can-i&lt;/code&gt;: it &lt;strong&gt;cannot read Secrets&lt;/strong&gt; and &lt;strong&gt;cannot delete pods&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Env var values are never collected&lt;/strong&gt;, only their names and where they come from (for example, "comes from Secret payments-secrets").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A 13 MB &lt;code&gt;FROM scratch&lt;/code&gt; image:&lt;/strong&gt; no shell, no package manager, runs as non-root with a read-only filesystem and all Linux capabilities dropped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's written in Go with only the standard library, so there are no third-party packages to keep patched.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It runs as one replica and stores incidents in a JSON file. Fine for a team cluster, not for a huge fleet.&lt;/li&gt;
&lt;li&gt;It explains, it does not fix. Any action is still a human decision.&lt;/li&gt;
&lt;li&gt;An 8B model gets the common failures right, but small details can be off. In one run it said the OOM test "writes to /dev/zero", when &lt;code&gt;tail /dev/zero&lt;/code&gt; actually reads from it. The quoted evidence makes slips like that easy to spot.&lt;/li&gt;
&lt;li&gt;Redaction is pattern based. It catches the common shapes of secrets, not every possible one. There's a &lt;code&gt;-dry-run&lt;/code&gt; mode that prints exactly what would be sent.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;You need Docker, kind, kubectl, Go and Ollama.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull qwen3:8b
git clone https://github.com/farooqhussain82/incident-explainer
&lt;span class="nb"&gt;cd &lt;/span&gt;incident-explainer
make kind-up    &lt;span class="c"&gt;# local cluster&lt;/span&gt;
make deploy     &lt;span class="c"&gt;# build the image, load it, apply the manifests&lt;/span&gt;
make demo       &lt;span class="c"&gt;# four broken pods&lt;/span&gt;
make ui         &lt;span class="c"&gt;# open http://localhost:8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also works without a cluster, on a single log file or an infrastructure change plan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs my-pod &lt;span class="nt"&gt;--previous&lt;/span&gt; | ./bin/incident-explainer explain -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I'd add next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Helm chart and an option to run Ollama inside the cluster.&lt;/li&gt;
&lt;li&gt;Grouping, so ten replicas of one broken Deployment become one incident, not ten.&lt;/li&gt;
&lt;li&gt;Using node events and recent rollouts as extra evidence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you run Kubernetes and you've ever read crash loop logs at 3 AM, I'd love to hear what failures you'd want this to understand. And if you try it, tell me what it got wrong.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>ai</category>
      <category>go</category>
    </item>
  </channel>
</rss>
