<?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>Stop Running Database Migrations in initContainers</title>
      <dc:creator>Farooq Hussain</dc:creator>
      <pubDate>Tue, 10 Nov 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/farooqhussain/stop-running-database-migrations-in-initcontainers-433m</link>
      <guid>https://dev.to/farooqhussain/stop-running-database-migrations-in-initcontainers-433m</guid>
      <description>&lt;p&gt;&lt;em&gt;How we moved Flyway into a Helm hook that runs once, before the deploy, and works the same in Argo CD&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Our app ran 6 replicas. On every deploy, 6 pods tried to migrate the same MySQL database.&lt;/p&gt;

&lt;p&gt;We had put Flyway in an initContainer. It looked clean: the migration runs before the app starts, so the app always sees the right schema. It's a common pattern, and for a single pod, it's fine.&lt;/p&gt;

&lt;p&gt;With 6 replicas, it wasn't.&lt;/p&gt;

&lt;p&gt;Here's why we moved away from it, the Helm hook we replaced it with, and what happens when a migration fails.&lt;/p&gt;

&lt;p&gt;We didn't want to write this from memory. So we rebuilt the setup on a local kind cluster with MySQL, Helm, and Argo CD, reproduced the problems we'd had in production, and then tested the fix. Every YAML file and every error message you'll see below comes from that test setup.&lt;/p&gt;

&lt;p&gt;Our example app is a Spring service on MySQL. Nothing here is Spring-specific, though. It works for any app that uses Flyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the initContainer had to go
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Every replica ran the migration.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not once per deploy. Once per pod. And it wasn't only deploys: every restart, every rescheduled pod, and every HPA scale-up ran Flyway again.&lt;/p&gt;

&lt;p&gt;Flyway protects itself with a database lock, so the pods didn't corrupt anything. They waited for each other instead. That leads to the second problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. New pods waited on the database lock.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If one pod is running a slow migration, every other new pod waits for the lock before its app can start. That includes pods the HPA adds because traffic just went up.&lt;/p&gt;

&lt;p&gt;In our test setup, we reproduced it: a pod with nothing to migrate started 5 seconds after another pod began a 40-second migration. It waited 36 seconds before it could finish, even though it had no work to do.&lt;/p&gt;

&lt;p&gt;Flyway itself isn't slow. With nothing to migrate, it finished in about a second in our tests. The problem is that scaling now depends on the database being free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Failures were hard to see.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When a migration failed, we didn't get one clear "migration failed" step. We got pods stuck in &lt;code&gt;Init:CrashLoopBackOff&lt;/code&gt;. Someone had to find the right pod and read the init container logs to see why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: one Job, before the deploy
&lt;/h2&gt;

&lt;p&gt;We wanted the migration to run once per release, not once per pod. Kubernetes already has a tool for that. It's called a Job.&lt;/p&gt;

&lt;p&gt;Helm can run a Job as a hook before it touches anything else in the release. If the Job fails, Helm stops the upgrade, and the old pods keep running.&lt;/p&gt;

&lt;p&gt;The setup has three parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A small migration image with the SQL files inside&lt;/li&gt;
&lt;li&gt;A Job with Helm hook annotations&lt;/li&gt;
&lt;li&gt;The app, with its own Flyway turned off&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Part 1: The migration image
&lt;/h2&gt;

&lt;p&gt;Bake the SQL files into an image built on the official Flyway 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;FROM&lt;/span&gt;&lt;span class="s"&gt; flyway/flyway:11&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; sql /flyway/sql&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;migrations/
  Dockerfile
  sql/
    V1__create_users.sql
    V2__create_orders.sql
    V3__add_order_status.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build it and tag it with the same version as the app. The migrations for app version 1.4.0 live in migration image 1.4.0. You don't need volume mounts or ConfigMaps, and you always know exactly which SQL ran in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: The migration Job as a Helm hook
&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch/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;Job&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="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;.Release.Name&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;&lt;span class="s"&gt;-db-migrate&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;helm.sh/hook&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pre-install,pre-upgrade&lt;/span&gt;
    &lt;span class="na"&gt;helm.sh/hook-weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0"&lt;/span&gt;
    &lt;span class="na"&gt;helm.sh/hook-delete-policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;before-hook-creation&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;backoffLimit&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;activeDeadlineSeconds&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;template&lt;/span&gt;&lt;span class="pi"&gt;:&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;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Never&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;flyway&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;.Values.migrations.repository&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}:{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;.Values.migrations.tag&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
          &lt;span class="na"&gt;args&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;migrate"&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;FLYWAY_URL&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;.Values.db.url | quote&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;FLYWAY_USER&lt;/span&gt;
              &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;secretKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;.Values.db.secretName&lt;/span&gt; &lt;span class="pi"&gt;}},&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;username&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;FLYWAY_PASSWORD&lt;/span&gt;
              &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;secretKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;.Values.db.secretName&lt;/span&gt; &lt;span class="pi"&gt;}},&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;password&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;FLYWAY_CONNECT_RETRIES&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;10"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each part does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pre-install,pre-upgrade&lt;/strong&gt;: the Job runs before Helm creates or updates anything else in the release, on the first install and on every upgrade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;before-hook-creation&lt;/strong&gt;: Helm deletes the old Job only when the next release starts. A failed Job stays in the cluster, so you can still read its logs. Don't use hook-succeeded alone; you'll want those logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;backoffLimit: 0&lt;/strong&gt;: a failed migration fails once and stops. Retrying a half-applied migration on MySQL won't fix it (more on that below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;activeDeadlineSeconds&lt;/strong&gt;: a stuck migration doesn't hang the release forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FLYWAY_CONNECT_RETRIES&lt;/strong&gt;: if the database is briefly unreachable, Flyway retries the connection instead of failing straight away.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing to check: the Secret with the DB credentials must exist before the hook runs. In our test setup, on a fresh install where the Secret didn't exist yet, the Job sat in &lt;code&gt;CreateContainerConfigError&lt;/code&gt; until Helm timed out. The app was never deployed. Create the Secret outside the app chart, or make sure it exists before the release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3: The app stops migrating itself
&lt;/h2&gt;

&lt;p&gt;Spring Boot runs Flyway on startup by default when Flyway is on the classpath. If you leave that on, you're back to every pod migrating. Turn it off in the Deployment:&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;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;SPRING_FLYWAY_ENABLED&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;false"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now your app just connects. By the time it starts, the database is already at the right version.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a deploy looks like now
&lt;/h2&gt;

&lt;p&gt;The order with &lt;code&gt;helm upgrade --wait&lt;/code&gt; is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Helm creates the migration Job.&lt;/li&gt;
&lt;li&gt;Flyway applies any new migrations and exits.&lt;/li&gt;
&lt;li&gt;Only after the Job succeeds does Helm update the Deployment.&lt;/li&gt;
&lt;li&gt;The rolling update starts, and the new pods start straight away.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We checked this on the test cluster. On the first install, the Deployment was created only after the Job had completed. On an upgrade with a new V3 migration and a new app version, V3 was applied first, then the pods rolled.&lt;/p&gt;

&lt;p&gt;New pods from the HPA don't touch Flyway at all anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a migration fails
&lt;/h2&gt;

&lt;p&gt;This is the part that matters most. So we broke a migration on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- V4__broken.sql&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orderz&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Helm stopped the upgrade:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: UPGRADE FAILED: pre-upgrade hooks failed:
resource Job/shop/webapp-db-migrate not ready. status: Failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Helm never touched the Deployment. Our 3 test pods kept running the old version and kept serving traffic. That's the whole point.&lt;/p&gt;

&lt;p&gt;Then we fixed the SQL and deployed again. It still failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: Validate failed: Migrations have failed validation
Detected failed migration to version 4 (broken).
Please remove any half-completed changes then run repair to fix the schema history.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's MySQL, not a Flyway bug. MySQL commits DDL statements on its own, so Flyway can't roll back a failed migration. It marks it as failed in its history table and won't go any further until someone looks at it. Flyway's documentation says the same.&lt;/p&gt;

&lt;p&gt;The recovery is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check what the failed migration changed and undo any half-applied part by hand.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;flyway repair&lt;/code&gt;. On MySQL, this removes the failed row from the history table.&lt;/li&gt;
&lt;li&gt;Fix the migration file and deploy again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After the repair, our next deploy applied the fixed V4 and updated the app.&lt;/p&gt;

&lt;p&gt;We'd call that manual step a feature. If a schema change fails on production, you want a person to look at it before anything retries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same chart in Argo CD
&lt;/h2&gt;

&lt;p&gt;We deploy through Argo CD, and the chart didn't need to change.&lt;/p&gt;

&lt;p&gt;Argo CD reads Helm hook annotations and maps pre-install and pre-upgrade hooks to its PreSync phase. We tested this on the same cluster: Argo CD ran the migration Job as a PreSync hook, and the Deployment was synced only after the Job succeeded.&lt;/p&gt;

&lt;p&gt;With the broken migration, the sync failed and Argo CD retried it, while the app stayed on the old version with all pods ready. If you use automated sync, the retries won't fix a failed MySQL migration, so expect the same repair step as above.&lt;/p&gt;

&lt;p&gt;So one chart works with Argo CD and with plain &lt;code&gt;helm upgrade&lt;/code&gt; from a pipeline.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;An initContainer runs your migration once per pod, not once per release.&lt;/li&gt;
&lt;li&gt;Move it to a Job with &lt;code&gt;helm.sh/hook: pre-install,pre-upgrade&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Bake the SQL into a Flyway image versioned with the app.&lt;/li&gt;
&lt;li&gt;Turn off Flyway inside the app.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;before-hook-creation&lt;/code&gt; so failed Jobs stay and you can read their logs.&lt;/li&gt;
&lt;li&gt;On MySQL, a failed migration needs &lt;code&gt;flyway repair&lt;/code&gt; before the next deploy.&lt;/li&gt;
&lt;li&gt;Argo CD runs the same hook as PreSync, with no chart changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our 6 replicas don't race for the database anymore. Each release runs its migrations once, and if that fails, nothing else changes.&lt;/p&gt;

&lt;p&gt;How do you run migrations in your clusters today: in the app, in an initContainer, or as a Job?&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>helm</category>
      <category>mysql</category>
    </item>
    <item>
      <title>Flyway Migration: From Legacy Database to CI/CD</title>
      <dc:creator>Farooq Hussain</dc:creator>
      <pubDate>Wed, 28 Oct 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/farooqhussain/flyway-migration-from-legacy-database-to-cicd-maf</link>
      <guid>https://dev.to/farooqhussain/flyway-migration-from-legacy-database-to-cicd-maf</guid>
      <description>&lt;p&gt;Most teams think of Flyway only when they need to manage ongoing database schema changes. But what if your project already has a complete, stable schema and you’re only now introducing version control? Or what if you want to incorporate your schema into a CI/CD pipeline for reliable, reproducible deployments?&lt;/p&gt;

&lt;p&gt;In this guide, I’ll walk you through a real-world scenario where we used Flyway to baseline an existing MySQL database and then extended it to support version-controlled schema migrations in CI/CD, without disrupting production.&lt;/p&gt;

&lt;h2&gt;
  
  
  🚀 What is Flyway?
&lt;/h2&gt;

&lt;p&gt;Flyway is a simple but powerful tool for managing database migrations. It applies versioned SQL scripts to your database and tracks them in a special table called &lt;code&gt;flyway_schema_history&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Typical script names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;V1__initial_schema.sql
V2__add_orders_table.sql
V3__rename_column.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flyway ensures these scripts run only once and in the correct order. It also integrates well with CI/CD tools, making it ideal for automated environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  📘 Use Case: Existing Schema with No Migrations (Yet)
&lt;/h2&gt;

&lt;p&gt;In our project, we had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A production MySQL database with dozens of tables&lt;/li&gt;
&lt;li&gt;No migration history&lt;/li&gt;
&lt;li&gt;No current need for structural changes&lt;/li&gt;
&lt;li&gt;A desire to version control the database and enable future migrations via CI/CD&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🧱 Step 1: Baselining an Existing Schema
&lt;/h2&gt;

&lt;h3&gt;
  
  
  🔹 Dump the schema
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mysqldump &lt;span class="nt"&gt;-u&lt;/span&gt; root &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;--no-data&lt;/span&gt; your_database &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; V1__baseline_schema.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean the output: remove auto-increments, user references, and environment-specific SQL.&lt;/p&gt;

&lt;h3&gt;
  
  
  🔹 Create directory structure
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; db/sql
&lt;span class="nb"&gt;mv &lt;/span&gt;V1__baseline_schema.sql db/sql/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🔹 Configure flyway.conf
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;flyway.url&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;jdbc:mysql://your-db-host:3306/your_database&lt;/span&gt;
&lt;span class="py"&gt;flyway.user&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;db_user&lt;/span&gt;
&lt;span class="py"&gt;flyway.password&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;secret_password&lt;/span&gt;
&lt;span class="py"&gt;flyway.locations&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;filesystem:db/sql&lt;/span&gt;
&lt;span class="py"&gt;flyway.baselineOnMigrate&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🔹 Run baseline
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flyway baseline
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates the &lt;code&gt;flyway_schema_history&lt;/code&gt; table and marks version 1 as the current state without running any script.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧪 Step 2: Add a Real Migration
&lt;/h2&gt;

&lt;p&gt;After baselining, we added a real change in a new script&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- db/sql/V2__add_status_to_orders.sql&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then we ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flyway migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; Flyway applied version 2, recorded it in history, and our database was now safely versioned.&lt;/p&gt;

&lt;h2&gt;
  
  
  ⚙️ Step 3: Integrate Flyway into CI/CD
&lt;/h2&gt;

&lt;p&gt;You can integrate Flyway with GitHub Actions, GitLab CI, Bitbucket Pipelines, or Jenkins.&lt;/p&gt;

&lt;h2&gt;
  
  
  ✅ Example GitHub Actions Workflow
&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Flyway Migration&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;main&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;migrate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&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;Checkout Code&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v3&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;Set up Java (Flyway needs it)&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-java@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;distribution&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;temurin'&lt;/span&gt;
          &lt;span class="na"&gt;java-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;17'&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;Install Flyway&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;curl -L https://repo1.maven.org/maven2/org/flywaydb/flyway-commandline/10.8.1/flyway-commandline-10.8.1-linux-x64.tar.gz | tar xvz&lt;/span&gt;
          &lt;span class="s"&gt;sudo ln -s $PWD/flyway-10.8.1/flyway /usr/local/bin/flyway&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;Run Flyway Migrations&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;FLYWAY_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jdbc:mysql://your-host:3306/your_database&lt;/span&gt;
          &lt;span class="na"&gt;FLYWAY_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DB_USER }}&lt;/span&gt;
          &lt;span class="na"&gt;FLYWAY_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DB_PASSWORD }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;flyway -url=$FLYWAY_URL \&lt;/span&gt;
                 &lt;span class="s"&gt;-user=$FLYWAY_USER \&lt;/span&gt;
                 &lt;span class="s"&gt;-password=$FLYWAY_PASSWORD \&lt;/span&gt;
                 &lt;span class="s"&gt;-locations=filesystem:./db/sql \&lt;/span&gt;
                 &lt;span class="s"&gt;migrate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  📦 Benefits of This Approach
&lt;/h2&gt;

&lt;p&gt;✅ You gain &lt;strong&gt;auditability&lt;/strong&gt; for all database changes&lt;/p&gt;

&lt;p&gt;✅ Developers can build &lt;strong&gt;local environments&lt;/strong&gt; using the same scripts&lt;/p&gt;

&lt;p&gt;✅ CI/CD ensures &lt;strong&gt;consistency&lt;/strong&gt; across all environments&lt;/p&gt;

&lt;p&gt;✅ Flyway’s &lt;code&gt;validate&lt;/code&gt; command catches missing or changed scripts&lt;/p&gt;

&lt;p&gt;✅ Schema management becomes part of your &lt;strong&gt;software delivery lifecycle&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  🧱 Potential Challenges
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Problem → Fix&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Flyway re-applies the baseline script
→ Use baselineOnMigrate=true and never modify a baseline&lt;/li&gt;
&lt;li&gt;Passwords in config files
→ Use secrets and environment variables in CI/CD&lt;/li&gt;
&lt;li&gt;Manual schema drift
→ Disable direct DB access in prod or run flyway validate periodically&lt;/li&gt;
&lt;li&gt;Need rollback support
→ Write manual undo scripts or use point-in-time backups&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🔁 Alternatives (and Why Flyway Wins)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;🔹 Liquibase&lt;/strong&gt; &lt;br&gt;
✅ Pros: Advanced features, rollback support &lt;br&gt;
❌ Cons: More complex, steeper learning curve&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;🔹 Django ORM / Alembic&lt;/strong&gt; &lt;br&gt;
✅ Pros: Built-in for app devs &lt;br&gt;
❌ Cons: Not cross-language or DB-agnostic&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;🔹 Manual SQL or DB dumps&lt;/strong&gt; &lt;br&gt;
✅ Pros: Simple &lt;br&gt;
❌ Cons: No versioning, no tracking, no automation&lt;/p&gt;

&lt;p&gt;Flyway is a great middle-ground: powerful enough for teams, simple enough for solo devs, and perfect for CI/CD.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧠 Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Flyway isn’t just for teams writing migrations every day. It’s also ideal for bringing an existing database under version control, giving your project a foundation for safe schema evolution.&lt;/p&gt;

&lt;p&gt;We started with a baseline, added our first real migration, and then built it into our CI/CD pipeline — all in a day’s work.&lt;/p&gt;

&lt;p&gt;If you’re managing an existing DB without schema tracking, now’s the time to fix that. Start with a baseline. Your future self will thank you.&lt;/p&gt;

&lt;h2&gt;
  
  
  📣 Let’s Connect!
&lt;/h2&gt;

&lt;p&gt;Have you tried Flyway for baseline-only use cases or integrated it into your CI/CD pipeline? I’d love to hear your experience in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you found this helpful, follow me here on DEV for more DevOps and database engineering content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>database</category>
      <category>mysql</category>
      <category>cicd</category>
    </item>
    <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>
