{"componentChunkName":"component---src-templates-blog-post-js","path":"/blog/2026-09-17-kubernetes-native-sidecar-containers/","result":{"data":{"site":{"siteMetadata":{"title":"M.Hassan Ahmed","author":"Hassan11196"}},"markdownRemark":{"id":"6f4f493c-56f7-58b0-87ae-575a3407763f","excerpt":"For years, the sidecar pattern in Kubernetes rested on a lie of omission. You put two containers in a pod, called one of them a “sidecar”, and pretended the…","html":"<p>For years, the sidecar pattern in Kubernetes rested on a lie of omission. You put two containers in a pod, called one of them a “sidecar”, and pretended the kubelet (the agent that runs pods on each node) knew which was which. It did not. Both were just entries in the <code class=\"language-text\">containers</code> list. They started at roughly the same time and were torn down at roughly the same time. Everything built on that fiction was a workaround.</p>\n<p>I ran into this on the deployment side of <a href=\"/project/cms-workflow-operations/\">CMS workflow operations</a> at CERN, where services sit behind proxy and log-shipping containers on Kubernetes. Two failures in particular cost real time: the app crashing on boot, and logs lost on shutdown. Native sidecars fix both at the spec level instead of with shell tricks.</p>\n<p>This post is for anyone who ships a pod with more than one container and has watched it crash on boot or lose data on shutdown. It covers the two races, what a native sidecar is, the full pod lifecycle, probes, and the edges that still catch people.</p>\n<h2>The two races: one at startup, one at shutdown</h2>\n<h3>Startup: the app dials a proxy that is not listening yet</h3>\n<p>Say your application container talks to a database through a proxy sidecar: a connection pooler, a CERN-SSO auth proxy, whatever sits in front. In the old model both containers start together. So the app often dials the proxy a few hundred milliseconds before the proxy is listening.</p>\n<p>The connection is refused, and the app exits non-zero. Kubernetes restarts it, and you get a crash loop that clears itself once the proxy happens to win the race. It looks flaky because it <em>is</em> flaky.</p>\n<p><img src=\"/16d6d320212baa3ab8c19c4dd31248ed/init-vs-sidecar.svg\" alt=\"Two panels comparing an ordinary-container sidecar with a native sidecar. On the left, the app container and proxy sidecar start together; the app connects on boot while the proxy is still starting, so the connection is refused and the pod crash-loops. On the right, the proxy is declared as an init container with restartPolicy Always, so it is up and probed before the app container starts, and the app never sees a dead proxy. A caption notes the only change is moving the sidecar into initContainers and setting restartPolicy Always, after which the kubelet keeps it running for the whole pod life instead of waiting for it to exit.\"></p>\n<h3>Shutdown: the log shipper leaves before the app</h3>\n<p>Shutdown is the same race running backwards. When a pod is deleted, the kubelet sends <code class=\"language-text\">SIGTERM</code> to every container at once. Your app wants a few seconds to finish in-flight requests and flush its last log lines. But the log-shipping sidecar next to it got the same signal and is already gone. The final logs, the ones you actually want when something died, never leave the node.</p>\n<h3>The workarounds</h3>\n<p>People papered over both races with the same kinds of hack:</p>\n<ul>\n<li>an init container that blocks until the proxy answers;</li>\n<li>a <code class=\"language-text\">preStop</code> hook that sleeps;</li>\n<li>an app that retries its first connection for thirty seconds.</li>\n</ul>\n<p>They mostly work, until the day a timeout is a touch too short. The <a href=\"https://kubernetes.io/docs/concepts/workloads/pods/init-containers/\">regular init container</a> already had the ordering guarantee you wanted, but it had the wrong shape. It must run to completion before the app starts, so it cannot host a process that needs to stay up.</p>\n<h2>What a native sidecar is: an init container that keeps running</h2>\n<p>The fix is almost anticlimactic. It reached beta and became on by default in <a href=\"https://kubernetes.io/blog/2023/12/13/kubernetes-1-29-release/\">Kubernetes 1.29</a>, and went stable in <a href=\"https://kubernetes.io/blog/2025/04/23/kubernetes-v1-33-release/\">1.33</a>. A native sidecar is an <a href=\"https://kubernetes.io/docs/concepts/workloads/pods/sidecar-containers/\">init container with <code class=\"language-text\">restartPolicy: Always</code></a>. That one field changes how the kubelet treats it.</p>\n<p>A normal init container runs and exits, and only then does the next one start. An init container marked <code class=\"language-text\">restartPolicy: Always</code> behaves differently. It starts, and the kubelet moves on to the next init container (or to the app containers) as soon as this one has <em>started</em>, not <em>finished</em>. So it stays running. It gets the init sequence’s ordering for free, and it keeps living into the pod’s main phase like an ordinary sidecar. <code class=\"language-text\">Always</code> is the only value the field accepts here; any other value is rejected.</p>\n<p>In this Deployment, the database proxy is a native sidecar with a startup probe, and the app container is unchanged:</p>\n<div class=\"gatsby-highlight\" data-language=\"yaml\"><pre class=\"language-yaml\"><code class=\"language-yaml\"><span class=\"token key atrule\">apiVersion</span><span class=\"token punctuation\">:</span> apps/v1\n<span class=\"token key atrule\">kind</span><span class=\"token punctuation\">:</span> Deployment\n<span class=\"token key atrule\">metadata</span><span class=\"token punctuation\">:</span>\n  <span class=\"token key atrule\">name</span><span class=\"token punctuation\">:</span> wmcore<span class=\"token punctuation\">-</span>console\n<span class=\"token key atrule\">spec</span><span class=\"token punctuation\">:</span>\n  <span class=\"token key atrule\">template</span><span class=\"token punctuation\">:</span>\n    <span class=\"token key atrule\">spec</span><span class=\"token punctuation\">:</span>\n      <span class=\"token key atrule\">initContainers</span><span class=\"token punctuation\">:</span>\n        <span class=\"token punctuation\">-</span> <span class=\"token key atrule\">name</span><span class=\"token punctuation\">:</span> db<span class=\"token punctuation\">-</span>proxy\n          <span class=\"token key atrule\">image</span><span class=\"token punctuation\">:</span> registry.cern.ch/db<span class=\"token punctuation\">-</span>proxy<span class=\"token punctuation\">:</span><span class=\"token number\">1.8</span>\n          <span class=\"token key atrule\">restartPolicy</span><span class=\"token punctuation\">:</span> Always <span class=\"token comment\"># this line makes it a sidecar</span>\n          <span class=\"token key atrule\">startupProbe</span><span class=\"token punctuation\">:</span>\n            <span class=\"token key atrule\">tcpSocket</span><span class=\"token punctuation\">:</span>\n              <span class=\"token key atrule\">port</span><span class=\"token punctuation\">:</span> <span class=\"token number\">5432</span>\n            <span class=\"token key atrule\">periodSeconds</span><span class=\"token punctuation\">:</span> <span class=\"token number\">2</span>\n            <span class=\"token key atrule\">failureThreshold</span><span class=\"token punctuation\">:</span> <span class=\"token number\">30</span>\n      <span class=\"token key atrule\">containers</span><span class=\"token punctuation\">:</span>\n        <span class=\"token punctuation\">-</span> <span class=\"token key atrule\">name</span><span class=\"token punctuation\">:</span> app\n          <span class=\"token key atrule\">image</span><span class=\"token punctuation\">:</span> registry.cern.ch/wmcore<span class=\"token punctuation\">-</span>console<span class=\"token punctuation\">:</span><span class=\"token number\">2026.9</span>\n          <span class=\"token comment\"># by the time this starts, db-proxy has passed its startup probe</span></code></pre></div>\n<p>The sidecar moved out of <code class=\"language-text\">containers</code> and into <code class=\"language-text\">initContainers</code>, and it gained one line. What changed is the contract. The kubelet now guarantees that the proxy is up before the app starts, and it keeps the proxy alive until after the app is gone.</p>\n<h2>The full pod lifecycle, in order</h2>\n<p>The ordering guarantees are the whole point, so it helps to see the pod’s life as one timeline.</p>\n<p><img src=\"/c06a236a75c7ebec354ffc44d09215cf/pod-lifecycle.svg\" alt=\"A timeline of a pod that has one native sidecar, from scheduled to gone. A regular init container runs a database migration and exits. The sidecar, a db-proxy and log shipper marked restartPolicy Always, starts next and its bar spans nearly the entire pod life; the app is unblocked once the sidecar&#x27;s startup probe passes. The FastAPI app container starts after the sidecar is up and runs until shutdown. On termination the app receives SIGTERM first, drains and exits, and only then does the sidecar stop, last of all. A caption notes sidecars are torn down in reverse start order.\"></p>\n<p>Reading the timeline left to right, startup goes like this:</p>\n<ol>\n<li>Ordinary init containers still run first, each to completion. A schema migration or a config fetch happens before anything else.</li>\n<li>Sidecars start next, in the order they appear. Each one waits until the previous sidecar is up.</li>\n<li>Only after every sidecar has started do the app containers start, all together as before.</li>\n</ol>\n<p>On the way down, the order reverses:</p>\n<ol>\n<li>App containers get <code class=\"language-text\">SIGTERM</code> first and get their full <code class=\"language-text\">terminationGracePeriodSeconds</code> to drain.</li>\n<li>Sidecars stop <em>after</em> that, in the reverse of the order they started.</li>\n</ol>\n<p>Your log shipper is the last thing standing. That is exactly what you want, because the interesting logs are the ones from the app’s final seconds.</p>\n<h2>Probes: something init containers never had</h2>\n<p>A regular init container cannot have a liveness or readiness probe. The concept makes no sense for something that runs once and exits. A native sidecar can have all three probe types, and each does something specific:</p>\n<ul>\n<li><strong><code class=\"language-text\">startupProbe</code> closes the boot race.</strong> The kubelet does not consider the sidecar “started”, and so does not let the app container start, until the startup probe passes. That is what the TCP check in the snippet above does: the app cannot start until port 5432 answers.</li>\n<li><strong><code class=\"language-text\">readinessProbe</code> feeds the pod’s overall readiness.</strong> A proxy that has lost its upstream connection can pull the whole pod out of a Service’s endpoints.</li>\n<li><strong><code class=\"language-text\">livenessProbe</code> lets a wedged sidecar restart on its own</strong>, without taking the app down with it.</li>\n</ul>\n<p>That independent restart deserves a closer look. During the pod’s running phase, the kubelet restarts a crashed native sidecar on its own, following the pod’s <code class=\"language-text\">restartPolicy</code>, without disturbing the app container. Under the old two-container model, a sidecar crash and an app crash were tangled together through the pod’s restart behavior. Now the proxy can die and come back while the app keeps serving through the blip.</p>\n<h2>Where it still bites</h2>\n<p>Native sidecars do not remove the need to think, and a few edges catch people.</p>\n<p><strong>The startup probe is load-bearing, not decorative.</strong> If you declare a sidecar without a startup probe, “started” means only that the container process launched, not that it is ready to serve. The app can still start before your proxy is actually listening. The ordering guarantee is about container start; the probe is what upgrades “the process exists” into “the port answers”. Leave it off, and you have quietly rebuilt the original race inside the new mechanism.</p>\n<p><strong>The grace period is shared, and it starts at the app’s <code class=\"language-text\">SIGTERM</code>.</strong> The pod has one <code class=\"language-text\">terminationGracePeriodSeconds</code>, and the clock starts when the app containers are signaled. The sidecar’s own shutdown happens in whatever is left of that window after the app drains. If your app uses the whole grace period and the log shipper needs three seconds to flush, budget for both when you set the number. I have written separately about <a href=\"/blog/2026-07-31-graceful-shutdown-fastapi-kubernetes/\">getting FastAPI to shut down cleanly under Kubernetes</a>; the sidecar’s flush time is now part of that same budget.</p>\n<p><strong>A sidecar that fails to start can wedge the pod.</strong> This works the same way as a failing regular init container. With the pod’s <code class=\"language-text\">restartPolicy</code> set to <code class=\"language-text\">Never</code>, a sidecar that cannot start means the pod does not start, full stop. That is usually what you want (no proxy, no app). But it does mean a broken sidecar image causes a pod-level outage, not a degraded-but-running pod. Watch the init phase in <code class=\"language-text\">kubectl describe pod</code>: a sidecar stuck starting shows up there, not in the main container status.</p>\n<p><strong>You need a new enough Kubernetes version.</strong> The feature is stable from 1.33 and on by default from 1.29. But if any cluster in your fleet is older than 1.28, the <code class=\"language-text\">restartPolicy</code> field on an init container is either ignored or rejected, depending on how old the cluster is. If it is ignored, your “sidecar” silently reverts to a blocking init container that never exits, and that hangs the pod. When you manage deployments across clusters with something like <a href=\"/blog/2026-08-07-argocd-applicationsets-many-clusters/\">ArgoCD ApplicationSets</a>, confirm every target is new enough before you rely on this. On an old node, the failure is a pod that never becomes ready, not a clear error.</p>\n<h2>What I would change first</h2>\n<p>If you have pods using the old two-container sidecar pattern, the migration is mechanical and low-risk:</p>\n<ol>\n<li>Move each sidecar from <code class=\"language-text\">containers</code> into <code class=\"language-text\">initContainers</code>.</li>\n<li>Add <code class=\"language-text\">restartPolicy: Always</code>.</li>\n<li>Give every sidecar the app depends on a <code class=\"language-text\">startupProbe</code> that tests real readiness, not just that the process is alive.</li>\n</ol>\n<p>The payoff is that a whole category of boot-order flakiness and lost shutdown logs stops being your problem and becomes the kubelet’s.</p>\n<p>This matters to me for the same reason I care about <a href=\"/blog/2026-07-07-kubernetes-liveness-readiness-startup-probes/\">readiness and liveness probes</a> and about <a href=\"/blog/2026-07-15-kubernetes-oomkilled-requests-vs-limits/\">sizing memory limits so a pod fails predictably</a>. The operational failures that wake someone up are almost never the exciting ones. They are ordering, timing, and cleanup. Native sidecars take one of those three and turn a pile of hooks and retry loops into a guarantee the platform makes for you. For the workflow tooling I ran for CMS, fewer moving parts in the boot path was worth more than any feature I could add on top.</p>\n<hr>\n<p><em>Diagrams by M. Hassan Ahmed, released under CC0. No external image was used for this post; the figures are original work by the author.</em></p>","frontmatter":{"title":"Kubernetes Native Sidecars: Fix Startup Order","date":"2026-09-17T00:00:00.000Z","description":"Kubernetes native sidecars are init containers that keep running. They start before your app, restart on their own, and get shut down last. Here is how.","thumbnail":{"childImageSharp":{"fluid":{"base64":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAALCAIAAADwazoUAAAACXBIWXMAAAsSAAALEgHS3X78AAACA0lEQVQozz2Qa2/aMBiFo3YCX2Lj3O2EJBQSCLcB41YILQyiUrZCR/dl09Am1v3/3zAD3aRHR5bfc+TXR9GTVKLVpkZ9Fqe7arprLF5uxptq+hxPt+XJ5/p8X5k8JfN9NN2enel/VTD1EHWlalY5Sob19rTTn9dat9XmpN6ZNbp37d590rptdmZJc6LblYv5ogrWQ6wHUolRkoq0AGAHqg5ENkDWGRuqHGoB+uc5+08RBTP/DS2QAUwEqwyM/idneeDZT2f5w+hvCuU+UjnCjvScnW8RBRWKJ5gPoaXVJuLhKFaHOPvudz+K9l3Yzyqrb3x5EOsji8cQmSenXPuckmEPsSKAhjl6Eo+vhNcgMCjzKtVenAzi+kClHEGTuInY/DFHW+pEVtAgRgipewoDoOutBc9+wTzLAd3yavNsN55mveFilGaL7NngUQ4YIM/E+lVvzTG2MSvKzhRIOGIefzjKAoDqEM1frb+IIMlh2ys130HTCxurxz0quHKKjRJf/5YHSIVEkQvTmw9W+gJy7AqY1eaoO7hXrohml00eMaukXNN2b1Z/P7nOm/LPZvqVBG2k2qcwxBbzm2bcR0SWWSxFXTdsYOZByk8vyL2o8MJWGPfkGVNhVoea31CZC4mjANUmepFZsgBOjUDVihKq+/L+jCV9RPclmLmoIOQuBTO4TP8CyY1NprMB3wIAAAAASUVORK5CYII=","aspectRatio":1.899441340782123,"src":"/static/3ee5aa23c0d9f96fa0a3f6f3949a00ed/40a76/hero.png","srcSet":"/static/3ee5aa23c0d9f96fa0a3f6f3949a00ed/c972b/hero.png 340w,\n/static/3ee5aa23c0d9f96fa0a3f6f3949a00ed/27625/hero.png 680w,\n/static/3ee5aa23c0d9f96fa0a3f6f3949a00ed/40a76/hero.png 1360w,\n/static/3ee5aa23c0d9f96fa0a3f6f3949a00ed/ed396/hero.png 2000w","sizes":"(max-width: 1360px) 100vw, 1360px"}}}}}},"pageContext":{"slug":"/2026-09-17-kubernetes-native-sidecar-containers/","previous":"blog/2026-09-18-fix-react-rerenders-streaming-chat/","next":"blog/2026-09-21-binary-quantization-vector-search/"}},"staticQueryHashes":["32046230"]}