<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[DevOps Enginer]]></title><description><![CDATA[DevOps Enginer]]></description><link>https://devopsenginer.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Mon, 02 Nov 2026 23:15:36 GMT</lastBuildDate><atom:link href="https://devopsenginer.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Restore Drills in 2026: You Can't Automate the Declaration, Only the Comparison]]></title><description><![CDATA[The previous post in this little series — Backup Verification in 2026: Exit Code 0 Is Not a Backup — grew out of a comment thread under a dev.to article by…]]></description><link>https://devopsenginer.hashnode.dev/restore-drills-in-2026-you-can-t-automate-the-declaration-only-the-comparison</link><guid isPermaLink="true">https://devopsenginer.hashnode.dev/restore-drills-in-2026-you-can-t-automate-the-declaration-only-the-comparison</guid><dc:creator><![CDATA[softverseAdmin]]></dc:creator><pubDate>Mon, 02 Nov 2026 06:13:45 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae4b3ab7a8fca3c7281d4ef/78c45514-65db-49b3-a199-a90bb8fc72c3.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Restore Drills in 2026: You Can't Automate the Declaration, Only the Comparison</h1>
<p>The previous post in this little series — <a href="https://www.devopsenginer.com/blog/backup-verification-in-2026-exit-code-0-is-not-a-backup">Backup Verification in 2026: Exit Code 0 Is Not a Backup</a> — grew out of a comment thread under a <a href="http://dev.to">dev.to</a> article by <strong>dafili</strong>, <a href="https://dev.to/dafili/my-backup-exited-0-for-three-weeks-and-nothing-paged-me-5ce6">"My Backup Exited 0 for Three Weeks and Nothing Paged Me"</a>. The conversation did not stop at publication. Dafili corrected one claim in that post (the correction is in, and credited), and then pushed the whole idea one level deeper — to a place the four-level ladder does not reach on its own.</p>
<p>His point, in his words: <strong>"You cannot automate the declaration, only the comparison."</strong></p>
<p>This post is about what that sentence means, because once you hear it, a hole opens up in the middle of most restore drills — including the tidy ones from the ladder post. The ladder tells you <em>how</em> to check a backup. It never tells you what "correct" means. And if the definition of correct comes from the same system you are checking, your drill can pass beautifully while verifying nothing at all.</p>
<blockquote>
<p><strong>TL;DR:</strong> A restore drill is only as honest as the expectations it compares against. If those expectations are derived from the backup or the system being verified — row counts read from the dump itself, file lists generated by the backup tool — the check is circular: it catches corruption, never wrongness. The fix is a <strong>fingerprint</strong>: a short, hand-written declaration of what a correct restore must contain (an expected file list, one file's sha256, a row count, one config value). A human re-declares it quarterly; automation compares against it nightly and shouts on drift. And the drill itself gets monitored with a <code>days_since_last_successful_manual_restore</code> gauge that pages at about 120 days — because an unmonitored drill quietly stops happening.</p>
</blockquote>
<p><em>Originally published on</em> <a href="https://www.devopsenginer.com/blog/restore-drills-in-2026-you-cant-automate-the-declaration"><em>DevOps Enginer</em></a><em>.</em></p>
<h2>The ladder checks the climb, not the destination</h2>
<p>The four levels from the previous post — the artifact exists, it is intact, it can be read, it has actually been restored — still stand. Level 4 is where trust lives: restore into a scratch environment and verify the result like a user would.</p>
<p>But look closely at how a typical level-4 drill "verifies." It restores the dump, then runs queries against the restored database: count the rows in <code>users</code>, check that <code>orders</code> has recent entries, confirm the application boots. Where do the expected values for those checks come from? Usually from the restored data itself, or from the same monitoring that watched the original system. The drill asks the backup: <em>do you agree with yourself?</em> The backup always says yes.</p>
<p>That is how a drill passes on data that is perfectly intact and completely wrong. Dafili made this distinction in the same thread: an integrity check catches media or repository corruption, but only a restore catches data that is wrong — and even then, only if the restore is compared against something that did not come from the restore. An empty mount backed up faithfully, checksummed faithfully, restored faithfully. A dump of the wrong database, in perfect condition. Every level green. Nothing recovered that you actually wanted.</p>
<p>He explored the same trap from another angle in a follow-up article, <a href="https://dev.to/dafili/three-checks-that-look-like-they-passed-but-validated-nothing-ejd">"Three checks that look like they passed but validated nothing"</a>: checks that execute correctly, return success, and prove nothing, because they validate a different property than the one you care about. A restore drill with self-derived expectations is exactly that shape of check — bigger, more expensive, and just as capable of validating nothing.</p>
<h2>The fingerprint: four things a human writes down</h2>
<p>Dafili's answer is a <strong>fingerprint</strong> — a small file a human writes by hand, declaring what a correct restore must contain. Not generated. Not exported from the database. Declared, the way you would declare what matters before a trip: passport, keys, the one document you cannot replace. His fingerprint has four components:</p>
<ul>
<li><strong>An expected file list.</strong> The files (or tables, or object prefixes) that must be present in the restore. Not all of them — the ones whose absence would mean the backup missed its point.</li>
<li><strong>One file's sha256.</strong> Pick one file whose content you can predict — a config file, a seed data file, a known document — and record its hash. The restore must reproduce it bit for bit.</li>
<li><strong>A row count.</strong> One table that matters, with a count (or a sane range) a human states from knowing the business, not from querying the backup. Orders should be in the hundreds of thousands by now; if the restore says 41, something is deeply wrong even if the restore "worked."</li>
<li><strong>One config value.</strong> A single setting whose correctness you can declare: the application's environment name, a feature flag that must be on, the schema version the code expects. Config drift is how "successful" restores fail at boot time.</li>
</ul>
<p>Four declarations. Small enough to write in fifteen minutes, small enough to read at a glance, and — this is the whole design — <strong>none of them can be produced by the system they verify</strong>. The fingerprint stands outside the backup pipeline and judges it.</p>
<p>An example, as a plain file kept with the runbook:</p>
<pre><code class="language-yaml"># restore-fingerprint.yaml — declared by hand, re-declared quarterly
system: billing-db
expected_files:
  - /srv/app/config/app.env
  - /srv/app/data/seed-countries.csv
sha256:
  /srv/app/config/app.env: "9f2c…e41a"   # recorded at declaration time
row_counts:
  orders: "&gt;= 400000"                      # stated from the business, not the dump
config_values:
  schema_version: "2026.09"                # what the current code expects
declared: 2026-11-02
next_review: 2027-02-02
</code></pre>
<p>The exact format does not matter. What matters is the direction of the arrow: expectations flow from a human's knowledge <em>into</em> the check, never from the checked system <em>outward</em>.</p>
<h2>You cannot automate the declaration</h2>
<p>Why insist a human writes it? Because the declaration is not data entry — it is a review disguised as data entry. To write the fingerprint, someone has to sit down and answer questions that no monitoring dashboard asks: <em>What in this system would we actually miss? Which table is the business? Which config value, if wrong, turns a good restore into a bad morning?</em> Those answers live in people's heads and in the incident history, not in the database.</p>
<p>In dafili's practice, the human <strong>re-declares the fingerprint quarterly</strong> — deliberately on the same cadence as the full restore drill. Systems drift legitimately over a quarter: new tables appear, row counts grow, config values change on purpose. The quarterly re-declaration is the moment a human looks at that drift on purpose, in calm conditions, and says: <em>yes, this is what correct looks like now.</em> Skip it, and the fingerprint slowly becomes a declaration about a system that no longer exists — which is its own quiet failure mode, and exactly why the declaration cannot be a one-time setup task.</p>
<p>This is also why automating the declaration defeats the purpose. A generator that rebuilds the fingerprint from production every night produces a fingerprint that is always exactly right and therefore says nothing: it re-declares whatever the system currently is, including its mistakes. The value of the fingerprint is precisely that it can <em>disagree</em> with the system — and that a human chose the terms of the disagreement in advance.</p>
<h2>The comparison is the automation's job</h2>
<p>Everything after the declaration should be boring, mechanical, and nightly. Restore the latest backup into a scratch environment, extract the actuals, and diff them against the fingerprint:</p>
<pre><code class="language-bash">#!/usr/bin/env bash
# compare-restore.sh — runs after the nightly restore-to-scratch
set -euo pipefail

FAIL=0

# 1. Expected files present?
while read -r f; do
  [ -f "/tmp/restore-test$f" ] || { echo "MISSING: $f"; FAIL=1; }
done &lt; &lt;(yq -r '.expected_files[]' restore-fingerprint.yaml)

# 2. The declared hash matches?
sha256sum -c &lt;&lt;&lt;"$(yq -r '.sha256."/srv/app/config/app.env"' restore-fingerprint.yaml)  /tmp/restore-test/srv/app/config/app.env" \
  || { echo "HASH DRIFT: app.env"; FAIL=1; }

# 3. The declared row count is in range?
ORDERS=$(psql restore_test -tAc "SELECT count(*) FROM orders;")
[ "$ORDERS" -ge 400000 ] || { echo "ROW COUNT DRIFT: orders=$ORDERS"; FAIL=1; }

# 4. The declared config value survived?
psql restore_test -tAc "SELECT value FROM settings WHERE key='schema_version';" \
  | grep -qx "2026.09" || { echo "CONFIG DRIFT: schema_version"; FAIL=1; }

exit $FAIL
</code></pre>
<p>The details will differ with your stack — the pattern is the point. Automation compares; it does not get an opinion. When the comparison fails, it <strong>shouts</strong>: a failed fingerprint comparison is a page, not a log line, because it means one of two things, both important — the backup is wrong, or the world changed and nobody re-declared it.</p>
<h2>Drift is a conversation, not just an alarm</h2>
<p>That second case deserves respect rather than dread. Most fingerprint failures, in a healthy team, will be legitimate change: a migration added a table, the row count outgrew the declared range, a config value rolled forward. The correct response is not to silence the check but to <strong>re-declare deliberately</strong> — a human reviews the drift, confirms it is intended, and updates the fingerprint as its own small, visible act.</p>
<p>Contrast that with the alternative everyone actually lives with: expectations embedded in the check script, edited quietly at 2 a.m. to make the red go away, until the check asserts nothing. The fingerprint does not prevent someone from weakening a check. It makes the weakening a declared event, in a file with a date on it, that a second person can review. That is the difference between a system that learns and a system that merely quiets down.</p>
<h2>Monitor the drill itself</h2>
<p>There is one more layer, and it might be the most practical idea in the whole thread. Automated nightly comparisons are excellent — right up until the human parts of the practice quietly die. The quarterly manual restore is the first thing skipped in a busy quarter. Then it is skipped twice. Nothing alarms, because the automation is still green; the drill's <em>judgment</em> — a person restoring by hand, noticing the weird thing no fingerprint anticipated — has been absent for a year and nobody knows.</p>
<p>Dafili's countermeasure is to monitor the drill itself with a gauge: <code>days_since_last_successful_manual_restore</code>, and to page when it crosses about <strong>120 days</strong>. Not a ticket. A page — the same severity as a failed backup, because that is what it is: past roughly one quarter, you no longer have evidence that a human can restore this system, only evidence that a script once could.</p>
<p>In Prometheus terms it is a timestamp gauge the drill updates when — and only when — a manual restore passes its fingerprint comparison:</p>
<pre><code class="language-bash"># Written by the drill checklist's final step, after a human-run
# restore passes the fingerprint comparison:
echo "last_successful_manual_restore_timestamp $(date +%s)" \
  &gt; /var/lib/node_exporter/textfile/restore_drill.prom
</code></pre>
<pre><code class="language-promql"># Page when no successful manual restore in ~120 days
(time() - last_successful_manual_restore_timestamp) / 86400 &gt; 120
</code></pre>
<p>Readers of the ladder post will recognise the shape: it is the dead-man's-switch from that article, pointed at the humans. There, the monitored absence was <em>verification success</em> — page when the checks stop succeeding. Here, the monitored absence is the <em>drill itself</em> — page when the people stop practising. Automation watches the backups; the gauge watches the watchers' habits. A restore you have not performed in four months is a hypothesis, and the gauge exists to say so out loud while it is still cheap to fix.</p>
<h2>Where does the fingerprint live?</h2>
<p>One honest open question, because it came up in the thread and I do not have dafili's answer yet: <strong>where should the fingerprint be stored?</strong> I asked him directly in the conversation; while his reply is pending, the trade-offs are worth stating plainly, because the obvious answer has a thorn in it.</p>
<ul>
<li><strong>With the backup configuration</strong> is convenient — the drill finds it without ceremony. But it risks the fingerprint drifting <em>with</em> the thing it verifies: the same change that breaks the backup can quietly rewrite the expectations, the way a scale that recalibrates itself to whatever it weighs can never report a wrong weight.</li>
<li><strong>In a separate repository, owned like a runbook,</strong> keeps the declaration independent and reviewable — changes arrive as commits a second person can see. The cost is one more place to keep current, and a drill that must fetch its expectations from somewhere it trusts.</li>
<li><strong>On paper, or in the incident binder,</strong> sounds like a joke until you remember what the fingerprint is for: it is the declaration that survives the system. Four values fit on an index card.</li>
</ul>
<p>Wherever it lives, the property to protect is independence: the fingerprint must not be writable by the pipeline it judges. When dafili answers, this section gets an update — his reasoning has improved this series twice already.</p>
<h2>Restore-drill fingerprint checklist</h2>
<ul>
<li>A fingerprint file exists for each system that matters, written by hand — not generated from the system it verifies</li>
<li>It declares all four: an expected file list, one file's sha256, one row count (or range), one config value</li>
<li>A human re-declares it quarterly, on the same cadence as the full restore drill</li>
<li>The nightly drill restores to scratch and compares actuals against the fingerprint automatically</li>
<li>A failed comparison pages — and legitimate drift is resolved by deliberate re-declaration, never by editing the check to be quiet</li>
<li>A <code>days_since_last_successful_manual_restore</code> gauge (or equivalent) exists, with a page at about 120 days</li>
<li>The fingerprint is stored somewhere the backup pipeline itself cannot rewrite</li>
<li>Expectations in older checks that were derived from the checked system have been replaced with declared ones</li>
</ul>
<h2>FAQ</h2>
<p><strong>Isn't this just the level-4 verification from the previous post?</strong> It is level 4 done honestly. The ladder says <em>restore and verify</em>; the fingerprint answers the question that post left open — verify <em>against what?</em> If the answer is "against values taken from the restore itself," the verification is circular. Declared expectations are what break the circle.</p>
<p><strong>Four checks seem too few. Shouldn't the fingerprint cover everything?</strong> Coverage is the automation's job — checksums, listings, and sampled reads already sweep the whole artifact nightly. The fingerprint has a different job: to be an independent witness. A witness who testifies about everything testifies about nothing; four declarations a human can hold in their head, and re-declare with full attention each quarter, beat four hundred generated ones nobody reads.</p>
<p><strong>What if the business can't state a row count in advance?</strong> Declare a range or a floor, stated from business knowledge ("orders grow by roughly ten thousand a month; a restore below last quarter's count is wrong"). Precision is not the point — independence is. An expectation that is roughly right and genuinely external catches the catastrophic wrongness the drill exists to catch.</p>
<p><strong>Does the gauge replace scheduling the quarterly drill?</strong> No — it catches the schedule failing. Calendars get ignored under load; that is precisely the failure the gauge exists to surface. Schedule the drill <em>and</em> monitor its successful completion, the same way you schedule backups <em>and</em> monitor verification success.</p>
<h2>Summary</h2>
<p>The ladder from the previous post — exists, intact, readable, restored — tells you how to check a backup. Dafili's fingerprint tells you what makes the checking mean something: expectations a human declared, standing outside the system, that the system cannot rewrite to flatter itself. <em>You cannot automate the declaration, only the comparison.</em> So declare by hand, re-declare quarterly, compare nightly, shout on drift — and keep a gauge on the drill itself, paging at about 120 days, so the practice cannot die in silence.</p>
<p>Credit where it is due: the fingerprint doctrine, the declaration/comparison line, and the drill gauge all come from <strong>dafili</strong>, in the comment thread under <a href="https://dev.to/dafili/my-backup-exited-0-for-three-weeks-and-nothing-paged-me-5ce6">"My Backup Exited 0 for Three Weeks and Nothing Paged Me"</a> — the same thread that produced the ladder post and corrected it. His follow-up, <a href="https://dev.to/dafili/three-checks-that-look-like-they-passed-but-validated-nothing-ejd">"Three checks that look like they passed but validated nothing"</a>, is the natural next read. This series keeps getting better because a reader keeps arguing with it in public. Long may that continue.</p>
]]></content:encoded></item><item><title><![CDATA[Backup Verification in 2026: Exit Code 0 Is Not a Backup]]></title><description><![CDATA[A story made the rounds in a DevOps community recently, and it should make anyone who owns a cron job uncomfortable. An engineer checked on a backup script…]]></description><link>https://devopsenginer.hashnode.dev/backup-verification-in-2026-exit-code-0-is-not-a-backup</link><guid isPermaLink="true">https://devopsenginer.hashnode.dev/backup-verification-in-2026-exit-code-0-is-not-a-backup</guid><category><![CDATA[Devops]]></category><category><![CDATA[Backup]]></category><category><![CDATA[monitoring]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[softverseAdmin]]></dc:creator><pubDate>Sun, 01 Nov 2026 05:22:47 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae4b3ab7a8fca3c7281d4ef/163368c5-297e-4fbf-809a-077b3d8e9de8.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Backup Verification in 2026: Exit Code 0 Is Not a Backup</h1>
<p>A story made the rounds in a DevOps community recently, and it should make anyone who owns a cron job uncomfortable. An engineer checked on a backup script that had been running perfectly by every visible measure — green in the scheduler, exit code 0, night after night — and found it had been "succeeding" at backing up nothing at all for three weeks, ever since something upstream had quietly changed. No error. No page. Just three weeks of confidence with nothing underneath it.</p>
<blockquote>
<p><strong>TL;DR:</strong> A backup job's exit code tells you the script ran, not that you have a backup. A backup is only trustworthy at four levels: (1) the artifact exists, (2) it is structurally intact, (3) it can be opened and read, and (4) it has actually been restored and the data verified. Most teams verify level 1 and stop. This guide walks a practical verification ladder — checksums, <code>restic check</code>, <code>pg_restore --list</code>, scheduled restore drills — plus the monitoring change that matters most: page on <strong>verification</strong> failure, not job failure.</p>
</blockquote>
<p><em>Originally published on</em> <a href="https://www.devopsenginer.com/blog/backup-verification-in-2026-exit-code-0-is-not-a-backup"><em>DevOps Enginer</em></a><em>.</em></p>
<p>This is deliberately not a Kubernetes backup guide — cluster tooling like Velero and etcd snapshots is covered in the Kubernetes Backup and Disaster Recovery post. This is about the layer underneath every stack: database dumps, file archives, and object-storage backups, and how to prove they work before the day you need them.</p>
<h2>Why exit code 0 lies</h2>
<p>Exit code 0 means one narrow thing: the last command in your script did not report an error. It says nothing about what the script actually captured. Backup scripts fail silently in a handful of classic ways:</p>
<ul>
<li><strong>Wrong or empty source paths.</strong> A directory gets moved, a mount fails to come up, or a path is renamed upstream. The archiver dutifully compresses an empty directory and exits 0. Your backup is a perfectly valid archive of nothing.</li>
<li><strong>Expired or rotated credentials.</strong> The dump tool fails to authenticate and writes a 0-byte file — or the wrapper script swallows the error, logs a warning nobody reads, and still exits successfully because the <em>upload</em> of that empty file worked fine.</li>
<li><strong>Silent partial captures.</strong> A database dump taken without the right flags can complete while missing schemas, extensions, or large objects. The file is large, recent, and wrong.</li>
</ul>
<p>The pattern underneath all of these: the job was monitored, but the <em>backup</em> was never verified. Those are different jobs, and they need different checks.</p>
<h2>The four levels of backup confidence</h2>
<p>A useful way to think about backup verification is as a ladder. Each level proves something the level below cannot:</p>
<p><strong>Level 1 — The artifact exists.</strong> A file or snapshot with the expected name appeared in the expected place, recently, with a plausible size. This is what most monitoring checks. It proves a process ran.</p>
<p><strong>Level 2 — The artifact is structurally intact.</strong> The archive is not truncated or bit-rotted. Checksums match; the backup tool's own integrity check confirms its internal structures — indexes, metadata, pack files — are consistent. This proves the bits survived storage.</p>
<p><strong>Level 3 — The artifact can be opened and read.</strong> The restore tool can actually parse the backup: list the table of contents of a dump, enumerate the files in an archive, read back a sample of the stored data. This proves the format is valid and the content is what you think it is — not an empty tree, not the wrong source.</p>
<p><strong>Level 4 — The artifact has been restored and the data verified.</strong> You restored it into a real (throwaway) environment and checked the result: the database answers queries, row counts and key tables look right, application files open. This is the only level that proves you can recover. Everything below it is inference.</p>
<p>Notice what the ladder implies: levels 1–3 are cheap and automatable on every backup. Level 4 is more expensive, so you schedule it — sampled nightly or weekly, full restores on a rotation. What you cannot do is pretend level 1 is level 4. That gap is exactly where the three-weeks-of-nothing story lives.</p>
<h2>The verification ladder in practice</h2>
<p>Here is how to test yours, level by level. The examples use common tools — <code>restic</code>, PostgreSQL dumps, plain tarballs in object storage — but the pattern transfers to whatever you run.</p>
<h3>Level 1: existence, freshness, and size sanity</h3>
<p>Check that the newest artifact exists, is younger than your backup interval (plus slack), and is within a sane size band of its predecessors. A backup that suddenly shrinks by 90% is a failed backup wearing a success costume.</p>
<pre><code class="language-bash"># Fail loudly if no backup newer than 26 hours exists in the bucket
aws s3 ls s3://my-backups/postgres/ --recursive \
  | sort | tail -n 1

# Size sanity: compare today's artifact against the 7-day median
# and alert if it deviates by more than ~50%
</code></pre>
<p>Tools like restic make this easier because the repository keeps snapshot metadata:</p>
<pre><code class="language-bash">restic snapshots --last 1 --json   # newest snapshot time, paths, tags
restic stats                       # repository size trend
</code></pre>
<p>Encode the rule: <strong>a missing, stale, or suddenly-tiny backup is a verification failure</strong>, even if the job that produced it exited 0.</p>
<h3>Level 2: structural integrity with checksums and tool-native checks</h3>
<p>If you produce plain archives, generate a checksum at backup time and verify it at check time:</p>
<pre><code class="language-bash">sha256sum -c postgres-2026-11-01.dump.sha256
gzip -t nightly-files.tar.gz   # quick structural test for gzip streams
</code></pre>
<p>restic has integrity checking built in:</p>
<pre><code class="language-bash">restic check
</code></pre>
<p><code>restic check</code> verifies the repository structure — all packs, indexes, and metadata present and consistent. It is fast and belongs in the nightly job. What it does <em>not</em> do by default is re-read your backup data — that is level 3.</p>
<h3>Level 3: prove it can be read back</h3>
<p>For archives, listing the contents is the minimum viable read test — it forces the tool to walk the whole structure:</p>
<pre><code class="language-bash">tar -tzf nightly-files.tar.gz &gt; /dev/null   # fails on truncation/corruption
</code></pre>
<p>For PostgreSQL custom-format dumps, <code>pg_restore --list</code> parses the dump's table of contents without restoring anything:</p>
<pre><code class="language-bash">pg_restore --list postgres-2026-11-01.dump | head -n 50
</code></pre>
<p>Scan that listing for what <em>should</em> be there: your critical tables, extensions, and the expected object count. An empty dump has a valid header and almost nothing under it — this is the check that catches the "backed up nothing" failure mode.</p>
<p>For restic, the read-back check is:</p>
<pre><code class="language-bash">restic check --read-data-subset=5%
</code></pre>
<p>Understand the caveat before you rely on it: <code>--read-data-subset=5%</code> reads and verifies <strong>5% of the data packs</strong>, not 5% of each file. It is sampling. A corrupt pack outside the sample will not be found tonight. Two practices make sampling honest:</p>
<ul>
<li><strong>Rotate with the n/t form, not the percentage.</strong> <code>--read-data-subset=5%</code> picks a <em>random</em> subset on every run, so runs can overlap and some packs may never get sampled at all. The <code>n/t</code> form is the deterministic one: restic splits the packs into t groups and checks group n. Pin it to the calendar — <code>--read-data-subset=$(date +%u)/7</code> checks a different seventh each night and guarantees every pack is read within a week, for the same total I/O as one full check spread out.</li>
<li><strong>Run a full read periodically.</strong> <code>restic check --read-data</code> (100%) on a monthly cadence closes any remaining gaps.</li>
</ul>
<p>Also add a content spot-check at this level: restore a handful of known files to a temp directory and diff them, or query the snapshot for a file you know changed yesterday. Existence of <em>expected content</em> is a much stronger signal than existence of <em>an artifact</em>.</p>
<h3>Level 4: restore drills — the only level that counts</h3>
<p>Restore into a throwaway environment and verify the result like a user would. For PostgreSQL:</p>
<pre><code class="language-bash"># Restore last night's dump into a scratch database
createdb -T template0 restore_test
pg_restore --no-owner --dbname=restore_test postgres-2026-11-01.dump

# Then verify like the application would
psql restore_test -c "SELECT count(*) FROM users;"
psql restore_test -c "SELECT max(created_at) FROM orders;"
</code></pre>
<p>Row counts in a plausible range and a recent <code>max(created_at)</code> on your busiest table prove the restore contains live, current data — not a skeleton schema and not last month's state.</p>
<p>For file backups, restore the latest snapshot to a scratch path and verify file counts and a sample of checksums against expectations:</p>
<pre><code class="language-bash">restic restore latest --target /tmp/restore-test
find /tmp/restore-test -type f | wc -l
</code></pre>
<p>The drill is not done when the restore command exits 0. The drill is done when a check <em>of the restored data</em> passes. And time the drill: the duration you measure is your real recovery time, not the one in the runbook.</p>
<h2>Make verification a separate job that can fail</h2>
<p>The single most important structural change: <strong>verification must be its own job, with its own failure state, that runs after the backup job.</strong> Folded into the backup script, a verification failure disappears into the same green checkmark. Give it its own exit code and its own alert path.</p>
<pre><code class="language-bash">#!/usr/bin/env bash
# verify-backups.sh — runs after the backup job, fails independently
set -euo pipefail

LATEST=$(restic snapshots --last 1 --json | jq -r '.[0].id')

restic check                                   # level 2
restic check --read-data-subset=5%             # level 3 (sampled)
restic restore "$LATEST" --target /tmp/restore-test --include /srv/app/config
test -s /tmp/restore-test/srv/app/config/app.env   # expected content present

echo "backup_verification_success $(date +%s)" \
  &gt; /var/lib/node_exporter/textfile/backup_verification.prom
</code></pre>
<p>The script writes a <strong>success timestamp</strong> only if every level passed — and that timestamp is what you monitor.</p>
<h2>Monitor the verification, not the job</h2>
<p>Alerting on "backup job failed" catches crashes. It does not catch the failure mode this article is about, because in that failure mode the job <em>succeeds</em>. Change what you page on:</p>
<ul>
<li><strong>Page on verification failure.</strong> Any level failing — stale artifact, size anomaly, checksum mismatch, unreadable dump, failed drill — is a page-worthy event, because it means you currently do not have a proven backup.</li>
<li><strong>Page on missing success.</strong> Track "time since last successful <em>verification</em>" and alert when it exceeds your threshold (for a nightly cycle, ~30 hours). This dead-man's-switch pattern catches the silent cases: the verification job itself dying, the scheduler stopping, credentials expiring before the check even runs. Absence of failure is not presence of success — so monitor the presence of success.</li>
<li><strong>Track your verified restore point.</strong> The metric that matters in an incident is "how old is the newest backup that has passed level 4?" If that number quietly grows to three weeks, you want to know before the incident, not during it.</li>
</ul>
<p>If you use Prometheus, the textfile pattern above plus one alert — <code>time() - backup_verification_success &gt; 30 * 3600</code> — covers most of this. The tooling is not the hard part; deciding that verification failure pages like a production outage is.</p>
<h2>A restore-drill cadence you can actually keep</h2>
<p>Verification fails as a practice when it depends on someone remembering. Put it on a calendar:</p>
<table>
<thead>
<tr>
<th>Cadence</th>
<th>Scope</th>
<th>Levels</th>
</tr>
</thead>
<tbody><tr>
<td>Nightly (automated)</td>
<td>Every backup, right after it lands</td>
<td>1–3, with n/t-rotated read-data subsets</td>
</tr>
<tr>
<td>Weekly (automated)</td>
<td>Partial level-4: restore one database or one snapshot subset to a scratch environment; run content checks</td>
<td>4 (partial)</td>
</tr>
<tr>
<td>Monthly (automated + reviewed)</td>
<td>Full restore of <strong>one</strong> production system on rotation, timed end to end</td>
<td>4 (full)</td>
</tr>
<tr>
<td>Quarterly</td>
<td>Full restore drill treated like a game day: restore, verify, record duration and surprises, update the runbook</td>
<td>4 (full, measured)</td>
</tr>
</tbody></table>
<p>The rotation matters: every system should get a full restore at least once a quarter, rather than an annual hero-drill on the one system everybody already understands. Record each drill's duration — that number is your measured RTO, and it is almost always worse than the assumed one. Finding that out in a drill is the entire point.</p>
<p>Cloud snapshots deserve a specific warning here: a snapshot of a disk or database is level 1 by default. Snapshots faithfully preserve corruption, they can be silently incomplete if taken mid-write without coordination, and "we have snapshots" has ended more than one incident review badly. Snapshots need the same ladder — restore one into an isolated instance, open it, query it — before they count as backups.</p>
<h2>Backup verification checklist</h2>
<ul>
<li>Every backup has a <strong>separate verification job</strong> with its own pass/fail, running after the backup completes</li>
<li>Level 1: freshness and size-band checks on every artifact, every cycle</li>
<li>Level 2: checksums verified for plain archives; <code>restic check</code> (or your tool's equivalent) nightly</li>
<li>Level 3: read-back tests — <code>pg_restore --list</code>, <code>tar -t</code>, <code>restic check --read-data-subset</code> rotated with the <strong>n/t</strong> form, plus expected-content spot checks</li>
<li>Level 4: automated partial restores weekly; full restores monthly on rotation; quarterly timed drill</li>
<li>Monitoring pages on <strong>verification failure</strong> and on <strong>missing verification success</strong> (~30h for nightly cycles)</li>
<li>"Age of newest level-4-verified backup" is a visible metric, not a surprise</li>
<li>Restore durations are recorded, so your RTO is measured rather than assumed</li>
<li>Snapshots are restore-tested like any other backup before being trusted</li>
</ul>
<h2>FAQ</h2>
<p><strong>Is</strong> <code>restic check</code> <strong>on its own enough?</strong> It covers level 2 well and, with <code>--read-data-subset</code>, samples level 3. It never restores anything, so it cannot prove level 4. Treat it as the nightly baseline, not the whole strategy.</p>
<p><strong>Doesn't all this verification double my costs?</strong> Levels 1–3 are metadata, checksums, listings, and sampled reads — cheap. Level 4 restores cost scratch compute for a few hours a month. Compare that with the cost of discovering, mid-incident, that the backups were empty.</p>
<p><strong>My backups are managed by my cloud provider. Am I covered?</strong> Managed backup services still produce artifacts that can be empty, misconfigured, or pointed at the wrong source. The provider runs the job; verification that the artifact restores <em>your</em> data is still yours.</p>
<p><strong>What is the one change to make first?</strong> Add the dead-man's-switch alert on "time since last successful verification." It is a small change, and it converts the worst failure mode — silent, indefinite, total — into a page within a day.</p>
<h2>Summary</h2>
<p>An exit code tells you a script ran. A trustworthy backup has been checked at four levels: it exists, it is intact, it can be read, and it has been restored with the data verified. Automate the first three nightly; schedule the fourth — weekly partials, monthly full restores on rotation, quarterly timed drills. And page when <em>verification</em> fails or goes missing, not merely when a job crashes. The engineer in the opening story had monitoring. What they did not have was a single check that asked the only question that matters: <em>if I needed this backup tonight, could I restore it?</em> Build the checks that answer that question while the answer is still cheap.</p>
]]></content:encoded></item><item><title><![CDATA[Prometheus in 2026: Metrics, PromQL and Alerting That Pages Only When It Matters]]></title><description><![CDATA[TL;DR: Prometheus fails teams in two predictable ways: cardinality explosions from unbounded labels, and alert noise that trains everyone to ignore pages…]]></description><link>https://devopsenginer.hashnode.dev/prometheus-in-2026-metrics-promql-and-alerting-that-pages-only-when-it-matters</link><guid isPermaLink="true">https://devopsenginer.hashnode.dev/prometheus-in-2026-metrics-promql-and-alerting-that-pages-only-when-it-matters</guid><category><![CDATA[#prometheus]]></category><category><![CDATA[monitoring]]></category><category><![CDATA[observability]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[softverseAdmin]]></dc:creator><pubDate>Sat, 31 Oct 2026 12:02:57 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae4b3ab7a8fca3c7281d4ef/5961d1bf-b9f7-42d6-93f9-43e1f5a7828b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Prometheus in 2026: Metrics, PromQL and Alerting That Pages Only When It Matters</h1>
<blockquote>
<p><strong>TL;DR:</strong> Prometheus fails teams in two predictable ways: cardinality explosions from unbounded labels, and alert noise that trains everyone to ignore pages. The fixes are disciplines, not features. Model metrics as counters, gauges, and histograms with small, bounded label sets; measure services with RED (rate, errors, duration) and resources with USE (utilisation, saturation, errors); compute ratios and quantiles with recording rules so dashboards and alerts stay fast; and alert on user-visible symptoms with an action attached — via Alertmanager grouping and inhibition — never on every cause you can measure. Keep Prometheus itself for what it is (superb short-to-medium-term metrics), and remote-write to long-term storage when history matters.</p>
</blockquote>
<p><em>Originally published on <a href="https://www.devopsenginer.com/blog/prometheus-monitoring-guide-2026">DevOps Enginer</a>.</em></p>
<h2>The model in one picture</h2>
<p>Prometheus <strong>pulls</strong>: on an interval, it scrapes an HTTP endpoint (conventionally <code>/metrics</code>) on each target, parses the exposition format, and stores samples in a time-series database. A <strong>time series</strong> is a metric name plus a set of labels — <code>http_requests_total{job="api", status="200"}</code> — and each unique combination is a separate series with its own storage and query cost. That one sentence explains almost every Prometheus behaviour and failure mode in this guide.</p>
<p>Three metric types do the work:</p>
<ul>
<li><strong>Counters</strong> only increase (requests served, errors seen). You almost never graph a counter raw; you graph its <code>rate()</code> — which also handles counter resets when a process restarts.</li>
<li><strong>Gauges</strong> go up and down (queue depth, memory in use, in-flight requests). Current value, min/max/avg over a window — no rate needed.</li>
<li><strong>Histograms</strong> count observations into buckets (<code>le</code> labels), which is what makes server-side quantiles possible: <code>histogram_quantile()</code> aggregates across instances, which summary-type metrics cannot do correctly. Choose bucket boundaries around the latencies you actually care about — your SLO threshold must live inside your buckets to be measurable.</li>
</ul>
<h2>What to measure: RED and USE</h2>
<p>Instrumentation fails by excess more often than by absence. Two checklists keep it purposeful:</p>
<ul>
<li><strong>RED for every service and endpoint that matters:</strong> request <strong>Rate</strong>, <strong>Errors</strong> (ratio, not just count), <strong>Duration</strong> (histogram). This is the user-visible truth of a service, and the raw material of every good alert below.</li>
<li><strong>USE for every resource:</strong> <strong>Utilisation</strong> (busy fraction), <strong>Saturation</strong> (queued work — the earliest warning), <strong>Errors</strong>. CPU steal aside, saturation metrics are the ones that predict incidents instead of narrating them.</li>
</ul>
<p>Everything else — business metrics, per-feature counters, debug detail — is optional gravy. High-cardinality detail belongs in <strong>logs</strong> (the logging guide's correlation IDs are exactly for this), not in labels.</p>
<h2>Cardinality: the budget everyone blows</h2>
<p>Series count is the product of label-value combinations. A metric with 10 endpoints × 5 status codes × 4 instances is 200 series — fine. Add a <code>user_id</code> label and it becomes millions — the database slows, memory climbs, queries time out, and the postmortem writes itself. The rules:</p>
<ul>
<li><strong>Labels identify dimensions with few, bounded values</strong>: endpoint (normalised — <code>/users/123</code> becomes <code>/users/:id</code> before it is a label), status class or code, method, instance, zone, version.</li>
<li><strong>Never label with unbounded values</strong>: user IDs, request IDs, session tokens, full URLs, error message text. Each is a series factory.</li>
<li><strong>Scrape-time relabeling is the safety net</strong>: Prometheus can drop or rewrite label values during scraping — the place to strip an offending label from a third-party exporter you cannot change.</li>
<li><strong>Watch the budget</strong>: the targets and metrics with the highest series counts should be known numbers in your stack, reviewed like cloud spend (the Kubernetes cost guide makes the same argument for compute).</li>
</ul>
<h2>PromQL that earns its keep</h2>
<p>Four patterns cover most production questions:</p>
<p><strong>Error ratio</strong> (the alert-worthy form of errors):</p>
<pre><code class="language-promql">sum(rate(http_requests_total{job="api", status=~"5.."}[5m]))
/
sum(rate(http_requests_total{job="api"}[5m]))
</code></pre>
<p><strong>Latency quantile from a histogram:</strong></p>
<pre><code class="language-promql">histogram_quantile(0.95,
  sum by (le) (rate(http_request_duration_seconds_bucket{job="api"}[10m])))
</code></pre>
<p><strong>Saturation</strong> — in-flight work versus capacity is usually a gauge ratio; the shape matters more than the exact metric names.</p>
<p><strong>Absence detection</strong> — <code>absent()</code> catches the series that <em>stopped</em>, which is how dead exporters and vanished jobs announce themselves; pair it with a dead man's switch on the alerting pipeline itself, so "no alerts" can be distinguished from "alerting is broken."</p>
<p>Two habits separate useful PromQL from fragile PromQL: always <code>rate()</code> counters (never subtract samples by hand), and aggregate with <code>sum by (...)</code> naming exactly the labels you intend to keep — a query that silently sums across a label you cared about produces confident, wrong dashboards.</p>
<h2>Recording rules: pay for queries once</h2>
<p>Dashboards and alerts re-run the same expressions relentlessly. <strong>Recording rules</strong> precompute them into new series on an interval:</p>
<pre><code class="language-yaml">groups:
  - name: api-slo
    interval: 30s
    rules:
      - record: job:http_error_ratio:5m
        expr: |
          sum(rate(http_requests_total{job="api", status=~"5.."}[5m]))
          /
          sum(rate(http_requests_total{job="api"}[5m]))
</code></pre>
<p>Now the alert is trivial, fast, and testable against the recorded series:</p>
<pre><code class="language-yaml">      - alert: ApiHighErrorRate
        expr: job:http_error_ratio:5m &gt; 0.05
        for: 10m
        labels:
          severity: page
        annotations:
          summary: "API 5xx ratio above 5% for 10 minutes"
          runbook_url: "https://wiki.example.com/runbooks/api-errors"
</code></pre>
<p>Note the anatomy of a good alert: a symptom (error <em>ratio</em>, sustained), a <code>for</code> duration that ignores blips, a severity, and a <strong>runbook link</strong>. An alert without a next action is a notification, not an alert.</p>
<h2>Alerting philosophy: symptoms page, causes ticket</h2>
<p>Alert fatigue is an availability risk in its own right — a team paged twenty times a night stops reading page one. The discipline that fixes it:</p>
<ul>
<li><strong>Page on symptoms users feel</strong>: error ratio, latency burn, saturation of the user path. These have a natural threshold — the SLO — and a natural owner.</li>
<li><strong>Ticket (or dashboard) the causes</strong>: disk filling, a replica down, a certificate ageing. Important, rarely 3 a.m.-shaped; a Deployment with one of three replicas down is healthy by design and should page nobody.</li>
<li><strong>Burn-rate thinking</strong>: rather than a single static threshold, alert on how fast the error budget is being consumed (fast burn pages, slow burn tickets). Recording rules make multi-window burn alerts practical; the concept matters more than any specific window pair.</li>
<li><strong>Alertmanager does the traffic control</strong>: <strong>grouping</strong> collapses a hundred instance alerts from one outage into one notification; <strong>inhibition</strong> suppresses downstream symptom alerts while a known root alert fires; <strong>silences</strong> mute deliberately during maintenance; <strong>routing</strong> sends each alert to the team that owns the action. Configure these on purpose — default Alertmanager behaviour is a starting point, not a design.</li>
</ul>
<h2>Architecture and storage honesty</h2>
<ul>
<li><strong>Local retention is short by design.</strong> A Prometheus server's local TSDB commonly retains around 15 days by default; that is a feature of its role — fast, recent, operational metrics — not a limitation to apologise for.</li>
<li><strong>Long-term history goes elsewhere</strong>: remote-write to Thanos, Grafana Mimir, or VictoriaMetrics (the common open options) when you need months of data for capacity and trend work. Keep the operational Prometheus queryable even if the long-term tier is down.</li>
<li><strong>Federation is rarely the answer</strong> it first appears to be; hierarchical scraping duplicates data and queries painfully. Prefer remote-write and per-team/per-cluster servers with a global query layer.</li>
<li><strong>In Kubernetes</strong>, the Prometheus Operator's <code>ServiceMonitor</code>/<code>PodMonitor</code> resources turn scrape config into the same GitOps-reviewed YAML as everything else in this series (the observability guide covers the wider stack); <code>kube-state-metrics</code> supplies object state (pending pods, failed jobs, replica mismatches) and node exporter supplies host USE metrics. Watch the operator's own cardinality — Kubernetes state is prolific.</li>
<li><strong>Short-lived jobs</strong> can't be scraped on an interval; the Pushgateway exists for them, with real caveats: it doesn't expire series for you, and pushed metrics lose the scrape's health signal. Prefer recording outcomes from the platform side (the Jobs guide's failure conditions) or pushing sparingly and deliberately.</li>
</ul>
<h2>Common mistakes</h2>
<ul>
<li><strong>Unbounded labels</strong>, the cardinal sin above — most often a well-meant <code>user_id</code>, <code>path</code>, or <code>error</code> label that turns a metric into a million series over a weekend.</li>
<li><strong>Alerting on causes.</strong> CPU at 80% pages; the error budget untouched. Pages spent on non-symptoms are how real symptoms get muted.</li>
<li><strong>Counters graphed raw</strong> — ever-climbing lines that say nothing; or worse, alerts on them. <code>rate()</code> exists; use it.</li>
<li><strong>Averaging quantiles.</strong> Averaging per-instance p95s produces a number that is no instance's p95 and nobody's truth. Histograms plus <code>histogram_quantile</code> over the aggregate is the correct shape.</li>
<li><strong>No recording rules on heavy dashboards</strong> — the same 30-day, high-cardinality query from every open browser tab, grinding the server that is supposed to diagnose the incident.</li>
<li><strong>Treating Prometheus as the archive.</strong> Local retention quietly rotates; the quarter's capacity review finds 15 days of data. Decide the retention tiers like log retention (logging guide): hot and short in Prometheus, long in the remote tier.</li>
<li><strong>Silent alerting pipeline.</strong> Alertmanager down, webhook expired, routing typo — discovered during the incident it existed to announce. A dead man's switch (an always-firing alert whose <em>absence</em> pages) is the standard guard.</li>
</ul>
<h2>Where this fits</h2>
<p>Prometheus is the metrics pillar of the observability guide's triad: metrics say <em>what and how much</em>, logs say <em>what exactly</em>, traces say <em>where in the path</em>. The discipline rhymes across all three — bounded cardinality here, volume control in logging, sampling in tracing — because all three are the same budget wearing different clothes: keep the signal, shed the noise, and make sure the alerting end of the pipeline deserves the trust the team puts in it.</p>
<h2>FAQ</h2>
<p><strong>Prometheus or Grafana — do I need both?</strong>
They are different layers: Prometheus collects and stores metrics and evaluates alerts; Grafana visualises (Prometheus has a basic built-in UI for queries, not dashboards). Most stacks run Prometheus (+ Alertmanager) for data and alerting, Grafana for dashboards — Grafana also fronts logs and traces, which is where the triad becomes one investigation.</p>
<p><strong>Pull or push — which is right?</strong>
Pull, by default: it gives Prometheus target health for free (<code>up</code> series), centralises interval control, and survives target restarts gracefully. Push (Pushgateway) is the exception for genuinely short-lived batch work, used with its caveats in mind.</p>
<p><strong>How long should Prometheus retain data?</strong>
Operationally, two to four weeks local is common and usually plenty for incident work. Anything longer belongs in a remote-write long-term tier, where storage is cheaper and the queries are trend-shaped rather than incident-shaped.</p>
<p><strong>Prometheus or OpenTelemetry?</strong>
Complementary, not rivals. OpenTelemetry is instrumentation and pipelines (APIs, SDKs, the Collector); Prometheus is a metrics backend with its own exposition format, which OTel can produce and feed (directly or via remote-write-compatible paths). Many stacks instrument with OTel and still query with PromQL.</p>
<p><strong>When have I outgrown a single Prometheus?</strong>
When scrape volume, cardinality, or query load makes the single server the bottleneck — symptoms are scrape duration creeping toward the interval, slow range queries, and memory pressure dominated by series churn. The step is sharding by team/cluster plus a remote-write tier, not a bigger instance forever.</p>
]]></content:encoded></item><item><title><![CDATA[Kubernetes Jobs and CronJobs in 2026: Batch Work That Finishes (or Fails) Cleanly]]></title><description><![CDATA[TL;DR: Deployments run forever; Jobs run to completion — and that one difference changes how you configure everything. A production Job needs an explicit…]]></description><link>https://devopsenginer.hashnode.dev/kubernetes-jobs-and-cronjobs-in-2026-batch-work-that-finishes-or-fails-cleanly</link><guid isPermaLink="true">https://devopsenginer.hashnode.dev/kubernetes-jobs-and-cronjobs-in-2026-batch-work-that-finishes-or-fails-cleanly</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[batch]]></category><category><![CDATA[Cronjobs]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[softverseAdmin]]></dc:creator><pubDate>Sat, 31 Oct 2026 11:52:44 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae4b3ab7a8fca3c7281d4ef/cda22f52-3cee-4b80-a437-3709e6825d47.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Kubernetes Jobs and CronJobs in 2026: Batch Work That Finishes (or Fails) Cleanly</h1>
<blockquote>
<p><strong>TL;DR:</strong> Deployments run forever; Jobs run to completion — and that one difference changes how you configure everything. A production Job needs an explicit contract: <code>restartPolicy: Never</code> (or OnFailure, knowingly), a <code>backoffLimit</code> you chose, an <code>activeDeadlineSeconds</code> so nothing runs forever, and <code>ttlSecondsAfterFinished</code> so finished work cleans itself up. CronJobs add three decisions on top: a <code>timeZone</code>, a <code>concurrencyPolicy</code> that matches whether runs may overlap, and a <code>startingDeadlineSeconds</code> so a controller outage doesn't fire a pile of missed runs at once. Batch failures are normal — design for retries that are idempotent, and alert on failure rather than discovering it in the data.</p>
</blockquote>
<p><em>Originally published on <a href="https://www.devopsenginer.com/blog/kubernetes-jobs-cronjobs-guide-2026">DevOps Enginer</a>.</em></p>
<h2>The workload Deployments can't express</h2>
<p>Everything earlier in this series assumes services: replicas that should always exist, replaced when they die. Batch work inverts the assumption — a migration, a report, a backup, an import has a <em>done</em>, and success means the pods stop existing. Running batch work in a Deployment produces the classic pathologies: a completed container restarted forever, a failing task CrashLooping with no concept of "give up," no record of whether the run succeeded.</p>
<p>The Job controller exists to own that lifecycle: create pods, watch them finish, count successes against <code>completions</code>, count failures against <code>backoffLimit</code>, and stop. CronJob is a Job factory on a schedule — nothing more mystical than that, which is the right way to reason about both.</p>
<h2>The Job contract, field by field</h2>
<pre><code class="language-yaml">apiVersion: batch/v1
kind: Job
metadata:
  name: nightly-export
spec:
  completions: 1
  parallelism: 1
  backoffLimit: 3
  activeDeadlineSeconds: 3600
  ttlSecondsAfterFinished: 86400
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: export
          image: registry.example.com/export:1.4.2
          args: ["--date", "yesterday"]
</code></pre>
<p>The fields that decide behaviour:</p>
<ul>
<li><strong><code>restartPolicy: Never</code> (or <code>OnFailure</code>)</strong> — Jobs require one of these two; <code>Always</code> is rejected. The choice matters: with <strong>Never</strong>, a failed container means the pod fails and the controller creates a <em>replacement pod</em> (visible, countable, each with its own logs). With <strong>OnFailure</strong>, the kubelet restarts the container <em>inside the same pod</em> — fewer pod objects, murkier failure history. Default to Never unless you have a reason; it makes failures legible.</li>
<li><strong><code>backoffLimit</code> (default 6)</strong> — how many pod failures before the Job itself is marked Failed. Each retry waits with exponential backoff (10s, 20s, 40s… capped at 6 minutes). Six silent retries of a broken image is how a bad deploy burns an hour unnoticed; for most batch work 2–4 is a saner, louder choice. Finer control exists via <strong>pod failure policies</strong> — rules that fail the Job immediately on specific exit codes (a config error will never fix itself) while ignoring disruptions like node drains.</li>
<li><strong><code>activeDeadlineSeconds</code></strong> — the Job's total time budget; exceed it and the Job fails and its pods are terminated. This is the field that prevents the "export job from March is still running" class of surprise.</li>
<li><strong><code>ttlSecondsAfterFinished</code></strong> — after the Job completes or fails, wait this long, then delete the Job and its pods. Without it, finished Jobs and their pod corpses accumulate forever (and with them, cluttered <code>kubectl get pods</code> output and etcd weight). A day is a reasonable default window for debugging; the logging pipeline from the logging guide is what preserves evidence beyond it.</li>
<li><strong><code>completions</code> / <code>parallelism</code></strong> — for work queues: <code>completions</code> is the total successes required, <code>parallelism</code> the pod ceiling. Leave both unset for the simple one-shot case (one success, one pod). <strong>Indexed Jobs</strong> (<code>completionMode: Indexed</code>) give each pod a stable index (<code>JOB_COMPLETION_INDEX</code>) for sharded work — partition the input by index instead of running a separate queue service.</li>
</ul>
<h2>Idempotency: the requirement retries impose</h2>
<p>A retried Job runs your code again against a world the first attempt may have half-changed. Batch code must therefore be <strong>idempotent or transactional</strong>: write to a staging key and publish atomically, upsert rather than insert, name outputs deterministically (by date/partition), and check-before-create where the sink allows. The failure this prevents is specific and common: attempt one writes 90% of the export and dies; attempt two appends a second 100%; downstream reads 190% and nobody knows which records are real. Retries are the controller doing its job — making them safe is yours.</p>
<h2>CronJobs: a schedule plus three decisions</h2>
<pre><code class="language-yaml">apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-export
spec:
  schedule: "15 2 * * *"
  timeZone: "Asia/Tokyo"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 900
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      backoffLimit: 3
      activeDeadlineSeconds: 3600
      ttlSecondsAfterFinished: 86400
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: export
              image: registry.example.com/export:1.4.2
              args: ["--date", "yesterday"]
</code></pre>
<ul>
<li><strong><code>timeZone</code></strong> — set it, always. Without it the schedule runs in the controller-manager's timezone (effectively UTC on most clusters), and "2:15 a.m." silently means something else after a cluster migration. The field takes an IANA name and handles daylight-saving transitions the way the name implies.</li>
<li><strong><code>concurrencyPolicy</code></strong> — the overlap decision, made explicitly:<ul>
<li><code>Allow</code> (the default): runs may stack up. Right for independent, cheap, idempotent work; dangerous when a slow run plus a punctual schedule compounds load.</li>
<li><code>Forbid</code>: skip a scheduled run while the previous is still going. The right default for most sequential batch work — exports, backups, syncs.</li>
<li><code>Replace</code>: kill the running Job and start fresh. For "latest state wins" work where a stale run is worse than none.</li>
</ul>
</li>
<li><strong><code>startingDeadlineSeconds</code></strong> — if the controller misses a start (downtime, upgrades), how late a run may still fire. Without it, a controller recovering after an outage can launch a burst of stale runs; with it, missed runs beyond the window are skipped. Roughly 100 missed starts is the controller's own safety cap, but your deadline should be far tighter and deliberate.</li>
<li><strong>History limits</strong> — <code>successfulJobsHistoryLimit</code> (default 3) and <code>failedJobsHistoryLimit</code> (default 1) bound how many finished Job objects linger for inspection. Failed history is debugging gold and set lowest by default — raise it if you investigate on Mondays what failed on Fridays.</li>
<li><strong><code>suspend: true</code></strong> — the pause button. CronJobs honour it immediately; it is the clean way to stop a schedule during an incident without deleting the object.</li>
</ul>
<h2>Failure is a normal state — observe it like one</h2>
<p>A service failing pages someone; a Job failing at 2 a.m. writes a status field nobody reads. Close that gap deliberately:</p>
<ul>
<li><strong>Alert on Job failure and on CronJob staleness.</strong> The signals exist in the metrics stack from the observability guide: job-failed conditions, and time since a CronJob's last successful run versus its schedule. "The nightly export hasn't succeeded in 26 hours" is an alert; discovering it from a stale dashboard is an incident review.</li>
<li><strong>Logs outlive pods only if you shipped them.</strong> Failed pods are deleted by TTL and history limits by design — the pipeline from the logging guide is the memory. At minimum, make the failure visible where the team already looks (the alert carries the job name; the runbook says where its logs live).</li>
<li><strong>Distinguish "failed" from "never ran."</strong> Scheduler outages, suspended CronJobs, image-pull failures before any pod starts — the Job object tells different stories in each case (conditions, events, pod statuses), and the fixes differ. The troubleshooting guide's habit applies: read the object's status before touching anything.</li>
</ul>
<h2>Resource and disruption behaviour for batch</h2>
<ul>
<li><strong>Requests still matter, limits protect neighbours.</strong> Batch jobs are the classic noisy neighbour — bursty CPU during everyone else's peak. Set requests honestly (requests-and-limits guide) and consider scheduling heavy batch into off-peak windows or lower-priority classes so services win contention; a report can wait 20 minutes, a checkout cannot.</li>
<li><strong>Disruption budgets apply differently.</strong> A Job's pods are not protected by PDBs the way replicas are (the PDB guide covers voluntary disruptions) — node drains during a long Job mean pod termination and a controller retry. Long jobs should checkpoint progress or be restartable from partial state; the idempotency work above is what makes that survivable rather than catastrophic.</li>
<li><strong>One-off manual runs:</strong> <code>kubectl create job --from=cronjob/nightly-export export-manual-1</code> clones the template for an immediate run — the clean pattern for backfills and tests, far better than hand-writing a twin Job manifest that drifts from the scheduled one.</li>
</ul>
<h2>When a Job is the wrong tool</h2>
<ul>
<li><strong>Long-running or never-finishing work</strong> is a service — use a Deployment.</li>
<li><strong>Per-node work</strong> (agents, collectors) is a DaemonSet's shape, not a Job's.</li>
<li><strong>Complex DAG workflows</strong> — fan-out/fan-in pipelines, artefact passing, per-step retries with dependencies — outgrow the Job controller quickly. That is the territory of workflow engines (Argo Workflows and kin); the Job primitives remain the right answer for the large simple majority: one task, a schedule or a trigger, a definition of done.</li>
</ul>
<h2>Common mistakes</h2>
<ul>
<li><strong><code>restartPolicy: Always</code> copied from a Deployment.</strong> The API rejects it for Jobs — usually discovered at apply time in a hurry. The template is not interchangeable.</li>
<li><strong>No deadline.</strong> A hung Job holds resources and blocks <code>Forbid</code>-policy successors indefinitely; the schedule looks fine and nothing runs. <code>activeDeadlineSeconds</code> is the difference between a failure and a mystery.</li>
<li><strong>Retries without idempotency.</strong> The double-write pattern above; the controller retries by design, so the hazard is guaranteed to fire eventually.</li>
<li><strong>Default <code>backoffLimit</code> and no alerting.</strong> Six quiet retries, a Failed status, and silence. Batch observability is an alerting decision, not a dashboard.</li>
<li><strong>CronJobs in the wrong timezone.</strong> The 2 a.m. job running at 11 a.m. after a migration is a rite of passage; the <code>timeZone</code> field is how you skip it.</li>
<li><strong><code>Allow</code> concurrency on heavy work.</strong> One slow run plus an hourly schedule equals a self-inflicted load test by morning.</li>
<li><strong>Secrets via environment for one-shot jobs.</strong> Jobs end; their specs (and any env values in them) linger in history objects and etcd. The ConfigMaps and Secrets guide's file-based patterns apply with extra force to objects designed to be kept around as records.</li>
</ul>
<h2>Where this fits in the series</h2>
<p>Batch is the third workload shape in this series, beside services (Deployments) and per-node agents (DaemonSets): it leans on the requests-and-limits guide for fair scheduling, the storage guide when jobs move real data, the backup guide — whose restores and snapshots are themselves usually Jobs — and the autoscaling guide's cluster-autoscaler behaviour, which treats pending batch pods as the elastic demand they are. Model the done-state honestly and the controller does the rest.</p>
<h2>FAQ</h2>
<p><strong>Job or CronJob for a task I trigger from CI?</strong>
A Job, created by the pipeline when the event happens (a migration after deploy, an export on demand). CronJob is for wall-clock schedules; forcing event-driven work into a polling CronJob adds latency and pointless runs.</p>
<p><strong>Why did my CronJob not run at the expected time?</strong>
In order of likelihood: timezone (no <code>timeZone</code> set, cluster in UTC), the previous run still going under <code>Forbid</code>, <code>suspend: true</code> left on, or a missed start beyond <code>startingDeadlineSeconds</code> after controller downtime. The CronJob's status and events record which.</p>
<p><strong>How many times will a failing Job retry?</strong>
<code>backoffLimit</code> pod failures (default 6), with exponential backoff between attempts — then the Job is marked Failed and stops. With pod failure policies, specific exit codes can fail it sooner, on the first occurrence.</p>
<p><strong>Can a Job run on a schedule <em>and</em> be triggered manually?</strong>
Yes — define the CronJob, and create one-off Jobs from its template with <code>kubectl create job --from=cronjob/&lt;name&gt;</code>. The manual run uses the same spec; it just isn't part of the schedule's history.</p>
<p><strong>Should batch jobs share the cluster with services?</strong>
Usually, yes — with honest requests, sane priorities, and schedules that respect peak hours. Dedicated node pools earn their cost when batch is heavy, spiky, or needs special hardware; reach for isolation when contention is measured, not imagined.</p>
]]></content:encoded></item><item><title><![CDATA[Kubernetes RBAC in 2026: Roles, Bindings and Least Privilege That Actually Holds]]></title><description><![CDATA[TL;DR: Kubernetes RBAC fails in practice not because the model is weak but because grants accrete: a wildcard here, a cluster-admin binding there, a…]]></description><link>https://devopsenginer.hashnode.dev/kubernetes-rbac-in-2026-roles-bindings-and-least-privilege-that-actually-holds</link><guid isPermaLink="true">https://devopsenginer.hashnode.dev/kubernetes-rbac-in-2026-roles-bindings-and-least-privilege-that-actually-holds</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[Security]]></category><category><![CDATA[rbac]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[softverseAdmin]]></dc:creator><pubDate>Sat, 31 Oct 2026 11:44:02 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae4b3ab7a8fca3c7281d4ef/3ea19a86-d54b-4a7f-8482-122ab565bfe7.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Kubernetes RBAC in 2026: Roles, Bindings and Least Privilege That Actually Holds</h1>
<blockquote>
<p><strong>TL;DR:</strong> Kubernetes RBAC fails in practice not because the model is weak but because grants accrete: a wildcard here, a cluster-admin binding there, a "temporary" service account that becomes permanent. The model itself is simple — subjects get verbs on resources in a scope, grants are additive only, and nothing is denied, only not granted. Least privilege that holds means: one service account per workload with token automount off by default, namespace-scoped Roles as the default unit, groups (not individuals) as human subjects, secrets treated as the crown jewels they are, and a regular audit of who can do what — verified with <code>kubectl auth can-i</code>, not assumed.</p>
</blockquote>
<p><em>Originally published on <a href="https://www.devopsenginer.com/blog/kubernetes-rbac-guide-2026">DevOps Enginer</a>.</em></p>
<h2>Why RBAC is where cluster security is won or lost</h2>
<p>The security-basics guide in this series covered the checklist; this guide goes deep on the one control that decides what a compromised credential can actually do. Most cluster incidents follow the same shape: an attacker (or a fat-fingered script) obtains a token — from a pod, a CI job, a leaked kubeconfig — and the blast radius is whatever that token may do. Network policies limit where it can talk; RBAC limits what it can <em>be</em>. A read-only token in one namespace is an embarrassment. A cluster-admin token is a breach report.</p>
<p>And RBAC sprawl is the default outcome, because every grant is a small act of unblocking someone: the deploy pipeline "needs" broader rights, the dashboard "needs" cluster-wide secrets, the new tool "needs" admin "for setup." Each is reasonable on the day. Together they are the incident, pre-positioned.</p>
<h2>The model, precisely</h2>
<p>Four ideas carry the whole system:</p>
<ul>
<li><strong>Subjects</strong> — who: a <strong>User</strong> (external identity, typically from a certificate or OIDC token), a <strong>Group</strong>, or a <strong>ServiceAccount</strong> (in-cluster identity).</li>
<li><strong>Rules</strong> — what: <strong>verbs</strong> (<code>get</code>, <code>list</code>, <code>watch</code>, <code>create</code>, <code>update</code>, <code>patch</code>, <code>delete</code>, and a few specials) on <strong>resources</strong> (<code>pods</code>, <code>deployments</code>, <code>secrets</code>…) within <strong>API groups</strong> (<code>""</code> for core, <code>apps</code>, <code>batch</code>, <code>rbac.authorization.k8s.io</code>…).</li>
<li><strong>Scope</strong> — where: a <strong>Role</strong> grants within one namespace; a <strong>ClusterRole</strong> grants cluster-wide or for cluster-scoped resources (nodes, persistent volumes, namespaces themselves).</li>
<li><strong>Bindings</strong> — the connection: a <strong>RoleBinding</strong> attaches a Role <em>or</em> ClusterRole to subjects <strong>inside one namespace</strong> (a ClusterRole bound by a RoleBinding is effectively trimmed to that namespace); a <strong>ClusterRoleBinding</strong> attaches a ClusterRole everywhere.</li>
</ul>
<p>Two properties shape everything else:</p>
<ol>
<li><strong>Additive only.</strong> There are no deny rules. A subject's rights are the union of all its bindings. "Remove the bad grant" always means deleting or narrowing a binding — and "who granted what" questions are answered by enumerating bindings, never by finding an exception.</li>
<li><strong>Default deny.</strong> With RBAC as the authorisation mode, a subject with no grants can do nothing. Every capability is an explicit decision — which is why the accretion pattern above is a process failure, not a model failure.</li>
</ol>
<h2>A worked example: read-only log access for support</h2>
<p>The support team needs to read pods and logs in one production namespace — nothing else, nowhere else:</p>
<pre><code class="language-yaml">apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: shop-prod
  name: log-reader
rules:
  - apiGroups: [""]
    resources: ["pods", "pods/log"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  namespace: shop-prod
  name: log-reader-support
subjects:
  - kind: Group
    name: support-engineers
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: log-reader
  apiGroup: rbac.authorization.k8s.io
</code></pre>
<p>Details doing quiet work here: the subject is a <strong>Group</strong> (people join and leave groups; bindings should not name individuals), <code>pods/log</code> is a subresource that must be granted explicitly, and there is no <code>secrets</code>, no <code>create</code>, no second namespace. Verify it the way an auditor would:</p>
<pre><code class="language-bash">kubectl auth can-i get pods --namespace shop-prod --as jane --as-group support-engineers   # yes
kubectl auth can-i get secrets --namespace shop-prod --as jane --as-group support-engineers # no
kubectl auth can-i list pods --all-namespaces --as jane --as-group support-engineers         # no
</code></pre>
<p><code>kubectl auth can-i</code> with <code>--as</code>/<code>--as-group</code> impersonation is the ground-truth test for any identity — build the habit of checking grants with it instead of reading YAML and reasoning hopefully.</p>
<h2>Service accounts: the identities that actually get stolen</h2>
<p>Humans are rarely the compromised identity; workloads are. Service-account discipline is therefore the highest-value RBAC work:</p>
<ul>
<li><strong>One service account per workload.</strong> The <code>default</code> service account in each namespace should have <em>no</em> grants. A dedicated account per deployment makes grants reviewable and revocation surgical.</li>
<li><strong>Turn off token automount unless needed.</strong> Most applications never call the Kubernetes API. For them the token is pure attack surface:</li>
</ul>
<pre><code class="language-yaml">apiVersion: v1
kind: ServiceAccount
metadata:
  name: shop-api
  namespace: shop-prod
automountServiceAccountToken: false
</code></pre>
<p>  (The same field exists per-pod in the pod spec, which wins when both are set.) Where a token <em>is</em> needed, modern clusters issue short-lived projected (bound) tokens rather than long-lived secrets — prefer them, and treat any long-lived service-account token secret as a credential to inventory.</p>
<ul>
<li><strong>Deployer accounts are prime targets.</strong> A CI service account that can create deployments in production can, by design, run arbitrary images with that namespace's service accounts — near-admin inside its scope. Narrow its verbs to exactly the resources the pipeline applies, keep it namespace-scoped, and protect its credential like a production key (the OIDC guide's keyless pattern applies to how the pipeline authenticates to the cloud; the in-cluster account deserves the same suspicion).</li>
</ul>
<h2>ClusterRoles: handle with both hands</h2>
<p>Cluster-wide grants are sometimes right — an observability agent reading node and pod metadata everywhere, an operator managing its resource type across namespaces. The disciplines:</p>
<ul>
<li><strong>Prefer a trimmed custom role over the built-ins' extremes.</strong> The default <code>cluster-admin</code> is total; <code>admin</code>, <code>edit</code>, and <code>view</code> are namespace-oriented defaults with specific shapes (notably, <code>view</code> excludes secrets — a deliberate, instructive choice). For cluster-wide read, write the ClusterRole that lists exactly the resources the tool reads; "cluster-reader" patterns from the community are a starting point, not a destination.</li>
<li><strong>Aggregation composes, so audit the aggregate.</strong> A ClusterRole with an <code>aggregationRule</code> collects rules from labelled ClusterRoles (this is how the built-in admin/edit/view roles grow with CRDs). Powerful for platforms, dangerous when a stray label quietly widens a widely bound role — include aggregated roles in audits.</li>
<li><strong>ClusterRoleBindings deserve a list, kept short.</strong> Every entry is a cluster-wide statement. Review the full list on a schedule; in a healthy cluster it fits on one screen and every line has an owner who can explain it.</li>
</ul>
<h2>The dangerous verbs and resources</h2>
<p>Audits should hunt specific shapes, because damage concentrates:</p>
<ul>
<li><strong>Wildcards</strong> — <code>verbs: ["*"]</code> or <code>resources: ["*"]</code> in anything but a deliberately tiny set of admin roles. A wildcard silently includes tomorrow's new subresources.</li>
<li><strong><code>get</code>/<code>list</code> on <code>secrets</code></strong> — especially cluster-wide. Reading secrets is effectively holding every credential in scope (the ConfigMaps and Secrets guide covers why). Treat any new secrets grant as a security review, not a config change.</li>
<li><strong><code>create</code> on <code>pods</code></strong> — pod creation with a chosen service account and mounts is a well-trodden path to privilege escalation; so are <code>create</code> on rolebindings/clusterrolebindings and the special <code>bind</code> and <code>escalate</code> verbs, which exist precisely to gate grant-making itself. <strong><code>impersonate</code></strong> is equally sensitive: it <em>is</em> the other identity, for whoever holds it.</li>
<li><strong><code>deletecollection</code></strong> and broad <code>delete</code> — the availability side of the same coin.</li>
</ul>
<h2>Human access: groups, short-lived credentials, no shared kubeconfigs</h2>
<ul>
<li>Map your identity provider's <strong>groups</strong> to Group subjects once, and manage membership in the IdP — not by editing bindings per person. Joiner/leaver processes then work through the system people already trust for access changes.</li>
<li>Issue <strong>short-lived</strong> credentials: exec-plugin/OIDC flows from your cloud or IdP, so a copied kubeconfig expires instead of opening a permanent door. A long-lived client certificate cannot be revoked (Kubernetes has no certificate revocation for this) — another reason to prefer token-based, expiring auth.</li>
<li><strong>Break-glass, deliberately.</strong> Keep an emergency admin path, alarm on its use, and test it — an incident is the wrong time to discover nobody can grant the rights to fix it. Emergency access is a designed feature; routine access through it is the failure.</li>
</ul>
<h2>Auditing: make "who can do what" a query, not a debate</h2>
<ul>
<li><code>kubectl auth can-i --list --as &lt;identity&gt;</code> prints an identity's effective rights in a namespace — the fastest spot check.</li>
<li>Enumerate bindings as code review: RoleBindings and ClusterRoleBindings are YAML in your GitOps repo (the Argo CD guide's pattern) — changes to them should be pull requests with security-aware reviewers, not <code>kubectl</code> improvisation.</li>
<li>Tooling in the <code>kubectl-who-can</code> / RBAC-lookup family answers the reverse question ("who can get secrets in prod?") across the cluster; run it in CI or on a schedule and diff the output — new entries are events worth a human glance.</li>
<li>API-server <strong>audit logs</strong> record who did what (the logging guide covers collecting them); RBAC denials in those logs are a signal of both attacks and of teams fighting their own permissions — both worth knowing.</li>
</ul>
<h2>Common mistakes</h2>
<ul>
<li><strong>Binding cluster-admin "temporarily."</strong> Temporary grants are permanent until the audit that never happens. Time-box with a ticket, an owner, and a removal date — or better, grant the specific role the task needs.</li>
<li><strong>One service account for everything in the namespace.</strong> Every pod inherits the union of rights; one compromised container spends them all.</li>
<li><strong>Secrets access for convenience.</strong> Dashboards and debug tools asking for cluster-wide secrets are asking for the keys to everything. Scope to the namespace, or redesign the tool's access.</li>
<li><strong>Confusing RoleBinding-to-ClusterRole scope.</strong> Binding a ClusterRole via a RoleBinding grants it <strong>only in that namespace</strong> — teams regularly believe they granted (or limited) something else. Read the binding kind, not just the role kind.</li>
<li><strong>Stale bindings.</strong> People change teams; projects end; vendor tools are uninstalled and their service accounts and bindings remain, valid and forgotten. The audit diff is how they surface.</li>
<li><strong>Testing with your own admin account.</strong> It works for you, so permissions "work." Test as the workload's identity (<code>--as=system:serviceaccount:ns:name</code> or by running the pod) before the 3 a.m. discovery.</li>
</ul>
<h2>Where this fits in the series</h2>
<p>RBAC is the identity layer the rest of the series assumes: the security-basics guide sets the posture, the network policies guide constrains where identities can reach, the ConfigMaps and Secrets guide protects what grants can expose, and the observability and logging guides watch it all — including the audit stream that records every grant being used. Get RBAC boring, and the other controls get to do their jobs.</p>
<h2>FAQ</h2>
<p><strong>Can RBAC deny a specific action?</strong>
No. RBAC is additive — a subject's permissions are the union of its grants, and there is no deny rule to carve exceptions. If a grant is too broad, narrow the role or split it; for true deny semantics (blocking an action even for the granted), admission-time policy tools are the layer that does it.</p>
<p><strong>Role or ClusterRole for a cross-namespace tool?</strong>
If it genuinely acts in many namespaces, a ClusterRole — bound cluster-wide only if it must act everywhere, or via RoleBindings in selected namespaces when its reach should be enumerated namespace by namespace. The second pattern keeps the grant list explicit and reviewable.</p>
<p><strong>Should each pod have its own service account?</strong>
Each <em>workload identity</em> should: one per deployment/workload is the practical unit, shared by its replicas. Per-pod accounts multiply objects without adding a trust boundary; per-namespace sharing erases the boundary that exists.</p>
<p><strong>How do I see everything a service account can do?</strong>
<code>kubectl auth can-i --list --as=system:serviceaccount:&lt;namespace&gt;:&lt;name&gt; -n &lt;namespace&gt;</code> for a namespace view, repeated per namespace or answered cluster-wide with RBAC lookup tooling. Make it a habit after every binding change.</p>
<p><strong>Are the built-in admin/edit/view roles safe to use?</strong>
They are reasonable, maintained defaults — with shapes you should know: <code>view</code> excludes secrets and most write access, <code>edit</code> adds writes but not role management, <code>admin</code> adds role management within the namespace but not namespace deletion or quota changes. The risk is rarely the built-ins; it is the custom wildcard beside them.</p>
]]></content:encoded></item><item><title><![CDATA[Terraform Testing in 2026: Unit Tests With terraform test and Mock Providers]]></title><description><![CDATA[TL;DR: Most Terraform changes are still reviewed by reading a plan and hoping. The native test framework (terraform test, stable since Terraform 1.6)…]]></description><link>https://devopsenginer.hashnode.dev/terraform-testing-in-2026-unit-tests-with-terraform-test-and-mock-providers</link><guid isPermaLink="true">https://devopsenginer.hashnode.dev/terraform-testing-in-2026-unit-tests-with-terraform-test-and-mock-providers</guid><category><![CDATA[Terraform]]></category><category><![CDATA[Testing]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[softverseAdmin]]></dc:creator><pubDate>Sat, 31 Oct 2026 11:27:51 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae4b3ab7a8fca3c7281d4ef/c6a15412-ac52-497c-99c0-c305fea4052f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Terraform Testing in 2026: Unit Tests With terraform test and Mock Providers</h1>
<blockquote>
<p><strong>TL;DR:</strong> Most Terraform changes are still reviewed by reading a plan and hoping. The native test framework (<code>terraform test</code>, stable since Terraform 1.6) changes that: test files declare inputs, run a plan or apply, and assert on the results — and with mock providers (since 1.7) the logic tests run in CI in seconds with no cloud account and no credentials. Test your modules' contracts — outputs, conditional resources, naming and tagging rules — not the cloud provider's behaviour. Keep a small number of real apply tests for critical modules in an ephemeral sandbox, and let linters and scanners do their separate jobs alongside.</p>
</blockquote>
<p><em>Originally published on <a href="https://www.devopsenginer.com/blog/terraform-testing-guide-2026">DevOps Enginer</a>.</em></p>
<h2>The problem: the plan is not a test</h2>
<p>Application code gets unit tests as a matter of course. Infrastructure code — which can delete a database just as dead as any application bug — usually gets a human skimming <code>terraform plan</code> output in a pull request. That review catches typos. It does not catch the conditional that inverted last refactor, the variable default that quietly changed a bucket from private to public, or the count logic that creates zero replicas of the thing production needs three of.</p>
<p>The reason was tooling: for years, testing Terraform meant heavyweight frameworks or applying to a real account and inspecting by hand. The native framework removes the excuse. Tests live next to the module, run with one command, and — the decisive part — can run against <strong>mocked providers</strong>, so the suite executes like unit tests: fast, hermetic, credential-free.</p>
<h2>What is worth testing (and what is not)</h2>
<p>Test <strong>your module's decisions</strong>, not the provider's implementation:</p>
<ul>
<li><strong>Outputs and contracts.</strong> Callers depend on output values and shapes. If <code>bucket_name</code> changes format, downstream code breaks — a test should break first.</li>
<li><strong>Conditional logic.</strong> Every <code>count</code>, <code>for_each</code>, and ternary is a decision your module makes: versioning on/off, optional resources created or skipped, single vs multi-AZ shapes. Each branch deserves a run.</li>
<li><strong>Naming, tagging, and policy invariants.</strong> The rules platform teams actually care about: names compose from project/environment correctly, required tags are always present, encryption is enabled, nothing is public unless an explicit variable says so.</li>
<li><strong>Variable edge cases.</strong> Empty lists, zero counts, nulls — the inputs real callers will eventually pass.</li>
</ul>
<p>Do <strong>not</strong> test that AWS creates an S3 bucket when asked, that an attribute AWS generates looks a certain way beyond your control, or other provider internals. Those tests are slow, brittle, and verify someone else's software.</p>
<h2>The framework in one module</h2>
<p>A small module to test:</p>
<pre><code class="language-hcl"># main.tf
variable "project"     { type = string }
variable "environment" { type = string }

variable "enable_versioning" {
  type    = bool
  default = true
}

resource "aws_s3_bucket" "this" {
  bucket = "${var.project}-${var.environment}-assets"
}

resource "aws_s3_bucket_versioning" "this" {
  bucket = aws_s3_bucket.this.id

  versioning_configuration {
    status = var.enable_versioning ? "Enabled" : "Suspended"
  }
}

output "bucket_name" {
  value = aws_s3_bucket.this.bucket
}
</code></pre>
<p>And its test file (conventionally in a <code>tests/</code> directory inside the module):</p>
<pre><code class="language-hcl"># tests/bucket.tftest.hcl
mock_provider "aws" {}

variables {
  project     = "shop"
  environment = "prod"
}

run "versioning_on_by_default" {
  command = plan

  assert {
    condition     = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Enabled"
    error_message = "Versioning must be enabled by default"
  }

  assert {
    condition     = output.bucket_name == "shop-prod-assets"
    error_message = "Bucket name must compose project and environment"
  }
}

run "versioning_can_be_disabled" {
  command = plan

  variables {
    enable_versioning = false
  }

  assert {
    condition     = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Suspended"
    error_message = "Versioning should suspend when explicitly disabled"
  }
}
</code></pre>
<p>Run it with <code>terraform test</code> from the module root. The anatomy:</p>
<ul>
<li><strong><code>mock_provider "aws" {}</code></strong> replaces the real provider. Terraform generates fake computed values, so a <code>plan</code> (or even an <code>apply</code>) runs without credentials, network, or a cloud account — in milliseconds.</li>
<li><strong><code>variables {}</code></strong> at file level sets shared inputs; a <code>run</code> block can override them per case.</li>
<li><strong><code>run</code> blocks</strong> are the test cases. <code>command = plan</code> is the default workhorse; <code>command = apply</code> executes against the (possibly mocked) provider for shape checks that only appear after apply.</li>
<li><strong><code>assert</code></strong> pairs a condition over any value in the configuration — resources, outputs, variables, locals — with the <code>error_message</code> a human reads when it fails. Write the messages as explanations, not echoes.</li>
</ul>
<p>Where a mocked computed value needs to be specific — an ARN a policy references, an ID another resource consumes — <code>override_resource</code> and <code>override_data</code> blocks pin those values inside a run. Use them sparingly; a test that overrides everything asserts nothing.</p>
<h2>Mock providers: what they change, and their honest limits</h2>
<p>Before mocks, a "unit test" of a module still configured the real provider, which meant credentials in CI, provider API calls in every plan, and suites measured in minutes. Mocks invert all three: the pipeline job needs <strong>no cloud credentials at all</strong> for the test stage (which, per the keyless-CI pattern in the GitHub Actions OIDC guide on this site, is exactly where you want the fewest credentials), tests run at unit-test speed, and nobody's sandbox account accumulates test debris.</p>
<p>The limits are equally clear, and stating them keeps the suite trustworthy:</p>
<ul>
<li>Mocks validate <strong>your configuration logic against the provider's schema</strong>, not against the real API. A value the real AWS API would reject (an invalid name pattern, an unsupported combination) can sail through a mocked plan.</li>
<li>Data sources return generated values unless overridden — tests depending on a data source's real content need overrides or a real read.</li>
<li>Provider-side quirks and eventual-consistency behaviour are, by definition, absent.</li>
</ul>
<p>So the strategy is layered: mocked tests for logic, in bulk; a few real tests for reality, on purpose.</p>
<h2>The top of the pyramid: real apply tests, kept few</h2>
<p>For a critical module — the network foundation, the shared database module — a mocked plan cannot prove the one thing that matters: it actually builds. Keep a small suite of <code>command = apply</code> runs against the real provider in a <strong>sandbox account</strong> with three disciplines:</p>
<ul>
<li><strong>Ephemeral by design.</strong> Tests create, assert, and the framework destroys what it created at the end of the run (that is the framework's contract for test-created resources). Verify the destroy happened; orphaned test resources are a cost and security smell — the state-management guide's hygiene rules apply to test state too.</li>
<li><strong>Separate credentials, narrow scope.</strong> The sandbox role can create and destroy only the resource types under test, in the sandbox account only. The OIDC guide's per-environment role pattern fits exactly here.</li>
<li><strong>Scheduled, not per-commit.</strong> Real applies take minutes and cost real (if small) money. Run them nightly or on module release, while the mocked suite gates every pull request.</li>
</ul>
<h2>Where tests sit among the other tools</h2>
<p>Terraform already has a quality neighbourhood, and the test framework does not evict anyone:</p>
<ul>
<li><strong>Variable validation blocks</strong> — the module rejecting bad input at plan time. First line of defence, and the test suite should cover that they fire.</li>
<li><strong><code>terraform fmt</code> / <code>validate</code></strong> — shape and syntax. Free; run always.</li>
<li><strong>tflint and friends</strong> — lint rules, naming conventions, deprecated syntax. Style and hygiene, not behaviour.</li>
<li><strong>Checkov / tfsec-style scanners</strong> — policy and security posture of the configuration. They check <em>what the config says</em>; tests check <em>what the module decides</em> given inputs. Complementary, not overlapping.</li>
<li><strong><code>terraform test</code></strong> — behaviour: outputs, conditionals, invariants across input variations.</li>
</ul>
<p>A sane pipeline order: fmt check → validate → lint/scan → <code>terraform test</code> (mocked) → plan for human review. Each stage is cheap; together they mean the human reviews intent, not correctness arithmetic.</p>
<h2>Modules are where tests pay rent</h2>
<p>For a root configuration used once, tests are nice. For <strong>modules</strong> — shared, versioned, consumed by teams who did not write them — they are the contract enforcement mechanism. The modules guide in this series covers structuring reusable modules; tests are what let a maintainer refactor a module's internals while proving its callers see the same outputs, the same resource decisions, the same invariants. That confidence is precisely what a module library runs on, and it is why test files should ship inside the module repository, versioned with the code they describe.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Testing the provider.</strong> Asserting that a mocked (or real) AWS resource has AWS-chosen properties teaches the suite to fail for other people's changes. Assert your decisions.</li>
<li><strong>Overriding everything.</strong> Heavy <code>override_resource</code> use pins the fake world so completely that the test passes regardless of the configuration. Overrides are scalpels for specific computed values, not a substitute world.</li>
<li><strong>One giant run block.</strong> A failed assert should name the broken decision. Many small runs with descriptive names beat one mega-run with forty asserts.</li>
<li><strong>Apply tests against shared accounts.</strong> Test resources in an account other teams use will eventually collide, linger, or be "cleaned up" by someone mid-run. Sandbox account, always.</li>
<li><strong>Mocked-only confidence for critical paths.</strong> The suite is green, the module cannot actually apply. Keep the few real apply tests for the modules whose failure would hurt.</li>
<li><strong>Ignoring test state.</strong> <code>terraform test</code> manages its own state for runs, but interrupted real applies can leave residue; a periodic sandbox sweep belongs in the routine.</li>
<li><strong>Writing tests once, at the end.</strong> The suite earns its keep when a refactor lands six months later. Add the run block when you add the conditional — that is when the intent is clearest and cheapest to encode.</li>
</ul>
<h2>FAQ</h2>
<p><strong>terraform test or Terratest?</strong>
They answer different questions. <code>terraform test</code> is native, HCL-based, and ideal for module logic and contracts — no Go toolchain, no extra framework. Terratest (Go) remains strong for end-to-end verification that spans tools and real cloud behaviour beyond one module. Many teams use both: native tests in every module repo, Terratest-style end-to-end checks for platform assemblies.</p>
<p><strong>Does <code>terraform test</code> cost money?</strong>
Mocked and plan-only runs create nothing and cost nothing. <code>command = apply</code> runs against a real provider create real resources for the duration of the test — priced accordingly, then destroyed by the framework. Keep them few, small, and in a sandbox.</p>
<p><strong>Can tests run in CI without cloud credentials?</strong>
Mocked-provider tests: yes, completely — that is their headline feature. Plan runs against a real provider still need whatever credentials that provider requires to configure itself, which is one more reason the mocked suite should carry the bulk of the assertions.</p>
<p><strong>Where do test files live?</strong>
In the module root or a <code>tests/</code> subdirectory (the conventional choice), as <code>*.tftest.hcl</code>. <code>terraform test</code> discovers them in both places; keep one convention per repository.</p>
<p><strong>What about OpenTofu?</strong>
OpenTofu implements the same test framework model — test files, run blocks, and mock providers are compatible concepts, so the approach in this guide carries across. Check your version's documentation for exact feature parity before assuming a specific block behaves identically.</p>
]]></content:encoded></item><item><title><![CDATA[Kubernetes Logging in 2026: From kubectl logs to a Pipeline That Scales]]></title><description><![CDATA[TL;DR: In Kubernetes, logs die with the pod — and the pods you most need logs from are the ones that just died. The fix is an architecture, not a tool…]]></description><link>https://devopsenginer.hashnode.dev/kubernetes-logging-in-2026-from-kubectl-logs-to-a-pipeline-that-scales</link><guid isPermaLink="true">https://devopsenginer.hashnode.dev/kubernetes-logging-in-2026-from-kubectl-logs-to-a-pipeline-that-scales</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[logging]]></category><category><![CDATA[observability]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[softverseAdmin]]></dc:creator><pubDate>Sat, 31 Oct 2026 11:18:01 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae4b3ab7a8fca3c7281d4ef/adce8ecb-18ea-446c-a50c-8dc7bf7720f5.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Kubernetes Logging in 2026: From kubectl logs to a Pipeline That Scales</h1>
<blockquote>
<p><strong>TL;DR:</strong> In Kubernetes, logs die with the pod — and the pods you most need logs from are the ones that just died. The fix is an architecture, not a tool: applications write structured logs to stdout/stderr, a node-level agent (Fluent Bit, usually) collects and enriches them with Kubernetes metadata, and a backend (Loki, OpenSearch, or your cloud's) stores them with retention you chose on purpose. Add correlation IDs so a log line connects to a request and a trace, control the volume before it controls your budget, and test the pipeline by killing a pod and finding its last words.</p>
</blockquote>
<p><em>Originally published on <a href="https://www.devopsenginer.com/blog/kubernetes-logging-guide-2026">DevOps Enginer</a>.</em></p>
<h2>The problem: the evidence deletes itself</h2>
<p>On a VM, a crashed application's logs sat on disk until someone looked. In Kubernetes, the sequence runs the other way: the pod crashes, gets rescheduled or replaced, the node eventually cleans up, and the logs — the primary evidence of what happened — evaporate on a schedule driven by everything except your investigation.</p>
<p><code>kubectl logs</code> papers over this for exactly one generation: it shows the current container, and with <code>-p</code> the previous one. Before that? Gone. On another node after a reschedule? Gone. From the DaemonSet that was OOM-killed at 3 a.m.? Never existed as far as you can prove.</p>
<p>So the troubleshooting guide in this series starts with logs — and production logging is the discipline of making sure they are somewhere else before the pod takes them away.</p>
<h2>The model: stdout is the application's only logging API</h2>
<p>The rule from which everything else follows: <strong>a containerised application logs to stdout and stderr, and nowhere else.</strong> No log files inside the container, no application-managed rotation, no "the file is on the volume somewhere." The container runtime captures those two streams to files on the node (<code>/var/log/containers/…</code>), and the platform takes it from there.</p>
<p>This buys three things:</p>
<ul>
<li><strong>Uniformity.</strong> Every workload, every language, same contract. The pipeline never needs per-application file paths.</li>
<li><strong>Rotation for free</strong> (with a caveat below): the kubelet and runtime rotate the node-side files; the application cannot fill its own container filesystem with logs.</li>
<li><strong>Streams stay separable.</strong> stderr is not automatically "errors" — plenty of frameworks log everything to stdout — but having both captured beats parsing one merged file inside a dying container.</li>
</ul>
<p>The application-side obligations that make this work: one event per line, and <strong>structured</strong> — JSON lines with a timestamp, a level, a message, and the fields a future searcher will filter on (service, version, request ID). Plain-text prose logs are a tax every incident pays at the worst exchange rate.</p>
<h2>The collector: a node-level agent</h2>
<p>The standard shape is one log collector per node, running as a DaemonSet, tailing the runtime's log files, enriching each record with Kubernetes metadata (namespace, pod, container, labels — fetched from the API), parsing structure, and shipping to the backend.</p>
<p>The usual choice in 2026 is <strong>Fluent Bit</strong>: small, fast, CNCF-graduated, written for exactly this job. <strong>Fluentd</strong> (its older sibling) remains capable, especially where a team already runs its plugin ecosystem; a few platforms ship their own agents. What matters more than the brand:</p>
<ul>
<li><strong>Metadata enrichment is non-negotiable.</strong> A log line without namespace/pod/labels is nearly unsearchable at cluster scale. Verify your pipeline adds them — silently missing metadata is the most common logging bug in production clusters.</li>
<li><strong>Buffering and backpressure.</strong> When the backend is unreachable, the agent must buffer (to disk, with a cap) and retry — and its buffer behaviour must not be allowed to fill the node disk it is protecting you from. Size it, alert on it.</li>
<li><strong>The agent is production software.</strong> It runs on every node, touches every log line, and fails silently when misconfigured. It belongs in the observability stack's alerting (the observability guide covers the wider stack): DaemonSet pods healthy, shipping lag near zero, drop counters at zero.</li>
</ul>
<p>A note on <strong>audit logs</strong>: the API server's audit stream is a different animal — who did what to the cluster, the security record from the security-basics guide — collected at the control plane, retained separately, and never mixed into the application pipeline.</p>
<h2>Where logs land: pick the backend by the questions you ask</h2>
<ul>
<li><strong>Loki</strong> — indexes only labels, stores compressed log chunks in object storage. Cheap to run, excellent when your queries are "this service, this namespace, this time window, containing this string" — which is most incident queries. Pairs with Grafana next to your metrics. Its discipline: <strong>labels are precious and few</strong> (service, namespace, level, environment). A user ID or request ID in a label explodes the index (the cardinality trap); those belong in the log line or in structured fields, not labels.</li>
<li><strong>Elasticsearch / OpenSearch</strong> — full-text indexing of everything: expensive in storage and operations, unmatched when you genuinely need arbitrary field search across huge volumes and long retention.</li>
<li><strong>Cloud-native sinks</strong> — CloudWatch Logs, Google Cloud Logging, Azure Monitor: zero extra infrastructure on the matching managed service, pricing that rewards a retention policy, and query languages your team may like less than LogQL. For many EKS/GKE/AKS clusters this is the pragmatic default, with the node agent either replaced by the provider's or forwarding to it.</li>
</ul>
<p>Any of the three works. The expensive mistakes are the two defaults nobody chose: keeping everything forever, and discovering your per-GB ingestion bill during the incident that tripled log volume.</p>
<h2>The sidecar pattern: when node-level isn't enough</h2>
<p>Some applications will not log to stdout — legacy software writing to files, multi-line stack traces an application insists on assembling itself, or a workload needing local pre-processing. The pattern: a <strong>sidecar container</strong> in the same pod that reads the file from a shared volume and writes it to its own stdout (so the node agent picks it up normally), or ships directly to the backend. It works, at the price of a container per pod — resources, complexity, one more thing to upgrade. Reach for it per-workload, knowingly, not as the cluster default.</p>
<h2>Log survival details that bite</h2>
<ul>
<li><strong>Rotation is configured at the node, and it has a size.</strong> Runtime/kubelet log rotation (commonly around 10 MiB per file, a handful of files) means a chatty container's history on the node may cover minutes, not hours. That is fine <em>because the agent ships lines as they are written</em> — but it is why "the agent was down for an hour" translates directly into "that hour's logs exist only where the agent later recovered them, if it could." Agent health is log retention.</li>
<li><strong>Node disk is a shared fate.</strong> Logs, images, and emptyDir volumes all eat the same disk; a runaway logger can trigger disk-pressure evictions of innocent pods (the storage guide covers the disk side). Resource discipline — requests and limits from that guide — applies to your loggiest workloads first.</li>
<li><strong><code>kubectl logs</code> still matters.</strong> For live debugging it is the fastest tool that exists: <code>kubectl logs -l app=api --since=10m --all-containers=true</code> across a label selector, <code>--previous</code> for the crashed instance, <code>--follow</code> during a rollout. The pipeline is for history and search; kubectl is for now. Engineers should be fluent in both.</li>
</ul>
<h2>Correlation: logs that connect to everything else</h2>
<p>A log stream alone answers "what did this service print." Production debugging asks a bigger question: "what happened to <em>this request</em>." The bridge is a <strong>correlation ID</strong>: generated at the edge (or taken from an incoming header), propagated through every service call, and printed in every log line and trace span. With that one discipline, an incident moves from grepping five services by timestamp to one query per system, all keyed by the same ID — and the metrics–traces–logs triad from the observability guide becomes a single investigation instead of three.</p>
<p>Two supporting habits:</p>
<ul>
<li><strong>Levels with meaning.</strong> <code>ERROR</code> should mean "a human may need to act," not "a retry happened." When everything is an error, nothing is; alert on error <em>rate</em> changes, not on the existence of error lines.</li>
<li><strong>Never log secrets or personal data.</strong> Tokens, passwords, full request bodies with credentials, unnecessary personal fields — logs are copied, retained, and widely readable by design; treat them as the least-trusted storage you own. Redaction at the application beats scrubbing in the pipeline, and both beat explaining a credential found in a log bucket (the ConfigMaps and Secrets guide covers the secret-handling side).</li>
</ul>
<h2>Volume, retention, and the bill</h2>
<p>Log volume scales with traffic and verbosity, and both spike during incidents — exactly when you can least afford dropped data or a surprise invoice. The controls, in the order teams usually meet them:</p>
<ol>
<li><strong>Retention tiers.</strong> Hot storage (fast search) for days-to-weeks; cheaper object storage for months; deletion after the period your compliance actually requires. "Forever" is a decision too — an expensive one nobody remembers making.</li>
<li><strong>Drop or sample the noise.</strong> Health-check access logs from internal probes, debug output from one misbehaving workload, a namespace that logs every cache hit — filter at the agent, where dropping is free, rather than at the backend, where you have already paid to ingest.</li>
<li><strong>Per-namespace visibility.</strong> Know which workloads produce the volume. The cost guide in this series makes the same point about compute; logs deserve the same per-team accounting, because the fix is always a conversation with one workload's verbosity.</li>
</ol>
<h2>Prove it: the kill-a-pod test</h2>
<p>Like backups and disruption budgets before it in this series, a logging pipeline is a rumour until exercised. The test is five minutes:</p>
<ol>
<li>Pick a workload. In its logs, find (or generate) a unique marker line — a distinctive request ID works.</li>
<li>Delete the pod. Wait for the node to forget it ever existed.</li>
<li>From the backend alone, retrieve that pod's final log lines, filtered by namespace, pod name, and the marker. Check the metadata is attached and the timestamps are ordered and correct.</li>
</ol>
<p>If step 3 fails, you do not have a logging pipeline — you have a logging intention. Run the test after every agent upgrade and every backend change, and monitor the agent continuously in between; silent gaps are this system's natural failure mode.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Applications logging to files inside containers.</strong> The data is invisible to the platform, dies with the pod, and fills the container filesystem on the way out.</li>
<li><strong>Unstructured prose logs.</strong> Every incident becomes a regex-writing contest. JSON lines with stable fields, from day one.</li>
<li><strong>High-cardinality labels.</strong> Request IDs, user IDs, and pod IPs as index labels: the backend slows, costs climb, and the fix (re-indexing your mental model) is painful. Labels identify the stream; fields describe the event.</li>
<li><strong>No one owns agent health.</strong> The DaemonSet falls behind or dies on two nodes; the gap is discovered mid-incident, which is the only time it cannot be fixed.</li>
<li><strong>Debug level in production</strong>, forever, because one incident needed it once. Verbosity is a per-workload dial with a cost meter attached.</li>
<li><strong>Secrets in logs.</strong> The widest-read storage in your stack is the worst place for the most sensitive strings you own.</li>
<li><strong>Retention set by the tool's default</strong> instead of by your actual compliance and debugging needs — too short loses investigations, too long buys nothing but storage bills.</li>
</ul>
<h2>Where this fits in the series</h2>
<p>Logging is the third pillar the observability guide stands on, and the raw material the troubleshooting guide consumes first. It leans on the storage guide's disk discipline, the requests-and-limits guide's resource accounting, the network policies guide's protection for the shipping path, and the security-basics guide's audit and redaction rules. Get it right and it disappears — every other guide in this series quietly assumes the logs were there when needed.</p>
<h2>FAQ</h2>
<p><strong>Fluent Bit or Fluentd?</strong>
For a new cluster pipeline, Fluent Bit: lighter, faster, and the current centre of gravity for Kubernetes log collection. Fluentd remains reasonable where its plugin ecosystem or an existing deployment carries weight. The architecture matters more than the choice.</p>
<p><strong>Loki or Elasticsearch?</strong>
If your queries are service-and-time-window shaped (most incident work), Loki's label model is dramatically cheaper to run. If you need deep, arbitrary full-text analytics over long retention, Elasticsearch/OpenSearch earns its cost. Many clusters start on their cloud provider's sink and graduate to one of these when query needs or bills demand it.</p>
<p><strong>Should the application ever ship logs directly to the backend?</strong>
Almost never. It couples application deploys to logging infrastructure, duplicates shipping logic in every service, and loses the node agent's uniform metadata and buffering. The stdout contract exists precisely to avoid this.</p>
<p><strong>How long should logs be retained?</strong>
Long enough to cover your slowest investigation and your compliance floor — commonly 2–4 weeks hot and a few months in cheap archive for application logs, longer for audit logs under policy. Decide per class of log, write it down, and let the tiers enforce it automatically.</p>
<p><strong>Do managed services (EKS, GKE, AKS) solve this for me?</strong>
They provide a destination (CloudWatch, Cloud Logging, Azure Monitor) and sometimes an agent option, but the architecture decisions remain yours: stdout discipline, metadata, correlation IDs, retention, and cost control. The provider removes the backend operations, not the design.</p>
]]></content:encoded></item><item><title><![CDATA[GitHub Actions to AWS Without Stored Keys: OIDC Federation in 2026]]></title><description><![CDATA[TL;DR: If your GitHub Actions workflows authenticate to AWS with a stored access key, that key is a permanent credential sitting in a second system…]]></description><link>https://devopsenginer.hashnode.dev/github-actions-to-aws-without-stored-keys-oidc-federation-in-2026</link><guid isPermaLink="true">https://devopsenginer.hashnode.dev/github-actions-to-aws-without-stored-keys-oidc-federation-in-2026</guid><category><![CDATA[AWS]]></category><category><![CDATA[Security]]></category><category><![CDATA[Devops]]></category><category><![CDATA[ci-cd]]></category><dc:creator><![CDATA[softverseAdmin]]></dc:creator><pubDate>Sat, 31 Oct 2026 11:03:23 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae4b3ab7a8fca3c7281d4ef/b559f3d7-c26c-4b40-8d88-da5ffa2c64e8.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>GitHub Actions to AWS Without Stored Keys: OIDC Federation in 2026</h1>
<blockquote>
<p><strong>TL;DR:</strong> If your GitHub Actions workflows authenticate to AWS with a stored access key, that key is a permanent credential sitting in a second system, waiting to leak and rarely rotated. OIDC federation removes it entirely: each workflow run gets a short-lived token from GitHub, presents it to AWS, and assumes an IAM role scoped to exactly that repository, branch, or environment. Credentials live for about an hour and are never stored anywhere. Setup is one IAM identity provider, one role with a careful trust policy, and two lines in the workflow. This guide walks the whole thing, including the scoping mistakes that quietly undo the benefit.</p>
</blockquote>
<p><em>Originally published on <a href="https://www.devopsenginer.com/blog/github-actions-aws-oidc-guide-2026">DevOps Enginer</a>.</em></p>
<h2>The problem: a permanent key in someone else's house</h2>
<p>The classic setup is familiar: create an IAM user for CI, generate an access key pair, paste them into GitHub repository secrets, and reference them in the workflow. It works on day one. Then the years happen:</p>
<ul>
<li>The key <strong>never rotates</strong>, because rotating it means touching every repo that copied it — and nobody remembers all of them.</li>
<li>The key <strong>outlives its purpose</strong>: the project is archived, the repo is forked into a test, the contractor who set it up left. The credential remains valid through all of it.</li>
<li>The key's <strong>permissions drift upward</strong>: it was scoped for one deployment, then someone needed one more action, then it quietly became near-admin because that was faster.</li>
<li>The key <strong>leaks like a secret, not like a session</strong>: pasted into a log, a debug dump, a screenshot. When a session token leaks you have an hour of exposure; when this leaks you have until someone notices and rotates — if anyone notices.</li>
</ul>
<p>The deeper issue is architectural: a long-lived credential is a standing invitation. OIDC federation deletes the standing part. There is nothing to steal from the repository because the repository holds nothing — the workflow <em>proves who it is</em> at runtime instead.</p>
<h2>How it works, in one paragraph</h2>
<p>GitHub runs an OIDC identity provider. Every workflow run can request a signed JSON Web Token whose claims describe that run: the repository (<code>sub</code>ject claims like <code>repo:my-org/my-repo:ref:refs/heads/main</code>), the branch or tag, the environment, the workflow name. In AWS, you register GitHub as an IAM OIDC provider once per account, then create an IAM role whose <strong>trust policy</strong> says: "tokens from GitHub's provider may assume me — but only if the claims match these exact conditions." The workflow exchanges its token via <code>AssumeRoleWithWebIdentity</code> and receives ordinary short-lived AWS credentials (default lifetime one hour). AWS validates the token's signature against GitHub's published keys and checks the audience claim (<code>sts.amazonaws.com</code>) and your conditions. No shared secret exists on either side.</p>
<h2>Step 1: register GitHub as an OIDC provider in IAM</h2>
<p>One time per AWS account (Console: IAM → Identity providers → Add provider → OpenID Connect):</p>
<ul>
<li><strong>Provider URL:</strong> <code>https://token.actions.githubusercontent.com</code></li>
<li><strong>Audience:</strong> <code>sts.amazonaws.com</code></li>
</ul>
<p>That is the entire provider setup. Everything else lives on the role.</p>
<h2>Step 2: the role and its trust policy — where the security actually is</h2>
<p>Create an IAM role for web identity federation. The trust policy is the security boundary of this whole design, so write it deliberately:</p>
<pre><code class="language-json">{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
        }
      }
    }
  ]
}
</code></pre>
<p>What each part earns:</p>
<ul>
<li>The <strong><code>aud</code> condition</strong> confirms the token was minted for AWS STS, not for some other service that also trusts GitHub tokens.</li>
<li>The <strong><code>sub</code> condition</strong> is the scoping decision. <code>repo:my-org/my-repo:ref:refs/heads/main</code> means: only runs of this repo, on the main branch. Alternatives you will actually use:<ul>
<li><code>repo:my-org/my-repo:environment:production</code> — only runs that deploy to the GitHub <em>environment</em> named production (which can itself require manual approval — a strong pattern for prod).</li>
<li><code>repo:my-org/my-repo:ref:refs/tags/v*</code> with <code>StringLike</code> — only tag pushes, for release roles.</li>
</ul>
</li>
<li><strong>Never</strong> a wildcard like <code>repo:my-org/*</code> on a powerful role, and never omit the <code>sub</code> condition. "Any workflow in any repo I own can assume the production deploy role" is the stored-key problem wearing a new coat.</li>
</ul>
<p>Then attach the role's <strong>permissions policy</strong> — the least-privilege part. Scope it to what the pipeline does: update one ECS service, write to one S3 bucket, publish to one ECR repository. The AWS IAM least-privilege guide on this site covers how to build such policies without guessing; the OIDC work here assumes that discipline rather than replacing it.</p>
<p>One role per (repository, environment) pair is the pattern that scales: <code>gha-myrepo-prod</code>, <code>gha-myrepo-staging</code>, each boringly narrow.</p>
<h2>Step 3: the workflow side</h2>
<p>Two requirements, both small:</p>
<pre><code class="language-yaml">name: deploy

on:
  push:
    branches: [main]

permissions:
  id-token: write      # without this, no token is issued
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-myrepo-prod
          aws-region: ap-northeast-1

      - name: Deploy
        run: |
          aws s3 sync ./dist s3://my-prod-site-bucket --delete
</code></pre>
<p>Notes that save debugging time:</p>
<ul>
<li><strong><code>permissions: id-token: write</code> is the step everyone forgets.</strong> Without it the token request fails before AWS is even involved. Setting any <code>permissions</code> block also means everything not listed drops to none — hence <code>contents: read</code> for checkout.</li>
<li>There are <strong>no secrets to configure</strong>: <code>AWS_ACCESS_KEY_ID</code> and friends disappear from the repo settings entirely.</li>
<li>The configure action exports the short-lived credentials to subsequent steps; tools that read the standard AWS credential chain (the AWS CLI, Terraform, SDKs) work unchanged.</li>
</ul>
<h2>Scoping patterns that matter</h2>
<p><strong>Environments are the strongest lever.</strong> GitHub environments can require a human approval before a job runs, and the token's claims carry the environment name. Combine them: the production role trusts only <code>environment:production</code>, so even a maintainer pushing to main cannot reach prod credentials until the environment's approval gate passes. Branch protection plus environment approval plus a scoped role is about as strong as CI identity gets without extra tooling.</p>
<p><strong>Pull requests are a different animal.</strong> A <code>pull_request</code> run's claims do not look like a branch push — and for forks, the workflow runs in a context you should treat as untrusted. Practical rule: give PR workflows a read-only role if they need AWS at all (fetching artifacts, running plan), and never let a PR-triggered run assume a role that can write to production. <code>terraform plan</code> on PRs with a read-only role, <code>apply</code> only from main with the production role, is the canonical split — it pairs naturally with the Terraform state setup from this site's state-management guide.</p>
<p><strong>Multiple accounts, one pattern.</strong> Staging and production in separate AWS accounts is common; OIDC handles it cleanly because the provider registration and role live per account. The same workflow can assume the staging role in the staging account and the production role in the production account, each trust policy scoped to its own environment claim.</p>
<p><strong>Reusable workflows inherit the caller's identity carefully.</strong> If you use reusable workflows (this site's guide covers them), the token claims describe the <em>caller</em> repository and context — which is what you want, but it means a shared deploy workflow used by ten repos needs trust-policy entries or separate roles per caller repo. Plan for that instead of discovering it as a wildcard temptation.</p>
<h2>Migrating off stored keys without breaking Friday's deploy</h2>
<ol>
<li><strong>Inventory.</strong> Search your repos' secrets and workflows for <code>AWS_ACCESS_KEY_ID</code>. Note which IAM user each key belongs to and what it can do — this list is usually longer and scarier than expected, which is the argument for the project.</li>
<li><strong>Create the provider and roles</strong>, one repo/environment at a time, permissions mirroring what the key was <em>supposed</em> to do (not what it accidentally can).</li>
<li><strong>Switch the workflow</strong> to the OIDC pattern and deploy. Verify in CloudTrail that calls now arrive as the assumed role session, not the old user.</li>
<li><strong>Watch, then delete.</strong> Leave the old key deactivated (not deleted) for a short window while you confirm nothing else uses it — scheduled jobs and forgotten repos surface quickly when a key stops working. Then delete the key, and if the CI IAM user has no remaining purpose, delete the user.</li>
<li><strong>Sweep the leftovers:</strong> organisation-level secrets, third-party tools holding the same keys, and local copies in developer tooling.</li>
</ol>
<p>Order matters less than completeness: a migration that moves nine repos and forgets the tenth has kept exactly the risk it meant to remove.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Wildcard <code>sub</code> conditions.</strong> <code>repo:my-org/*</code> or a missing <code>sub</code> check turns one role into every repo's master key. The trust policy is the product; write it narrowly.</li>
<li><strong>One role for everything.</strong> A single powerful role assumed by every workflow recreates the shared-key blast radius with better marketing. Role per repo per environment.</li>
<li><strong>Forgetting <code>id-token: write</code>.</strong> The failure appears before AWS is contacted and confuses everyone who starts debugging in IAM.</li>
<li><strong>Leaving the old keys active "just in case".</strong> The fallback <em>is</em> the vulnerability. Deactivate, watch, delete.</li>
<li><strong>Granting write roles to pull-request contexts.</strong> Plan on PRs, apply from protected branches and environments — never both from the same trigger.</li>
<li><strong>Skipping the audience check.</strong> The <code>aud</code> condition costs one line and prevents tokens minted for other purposes from being replayed at STS.</li>
<li><strong>Assuming OIDC fixes permissions.</strong> Federation decides <em>who</em> may assume the role, not <em>what</em> the role may do. An admin role via OIDC is still an admin role; least privilege is a separate, continuing discipline.</li>
</ul>
<h2>FAQ</h2>
<p><strong>Does this cost anything?</strong>
No. OIDC providers and roles are free IAM features, and STS calls for this pattern are not a line item you will ever notice.</p>
<p><strong>What if a workflow needs to run longer than the token lifetime?</strong>
Role sessions default to one hour and can be configured up to the role's maximum session duration (up to 12 hours). Design for re-assumption or shorter jobs instead of reaching for the maximum by default — long-lived sessions quietly reintroduce the exposure you removed.</p>
<p><strong>Can self-hosted runners use this?</strong>
Yes — runners you host still request the token from GitHub's OIDC endpoint at runtime, so the same trust policy works. (GitHub Enterprise Server deployments use their own provider URL; check your instance's docs for its exact value.)</p>
<p><strong>Does Terraform in GitHub Actions work with OIDC credentials?</strong>
Yes. The configure-aws-credentials step exports standard environment credentials, and the Terraform AWS provider consumes them through its normal credential chain — no provider block changes needed. Remote state locking and the backend setup stay exactly as they were.</p>
<p><strong>What about other clouds?</strong>
The same pattern exists across the major providers — Azure and Google Cloud both accept GitHub-issued OIDC tokens for workload identity federation with no stored secrets. Once a team learns the shape on one cloud, the others are variations, and the destination of all of them is the same: CI with no permanent keys anywhere.</p>
]]></content:encoded></item><item><title><![CDATA[Kubernetes Pod Disruption Budgets in 2026: Graceful Shutdown Without Dropped Requests]]></title><description><![CDATA[TL;DR: Kubernetes constantly disrupts your pods on purpose — node drains, cluster upgrades, autoscaler scale-downs, rollouts. Pod Disruption Budgets (PDBs)…]]></description><link>https://devopsenginer.hashnode.dev/kubernetes-pod-disruption-budgets-in-2026-graceful-shutdown-without-dropped-requests</link><guid isPermaLink="true">https://devopsenginer.hashnode.dev/kubernetes-pod-disruption-budgets-in-2026-graceful-shutdown-without-dropped-requests</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[Reliability]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[softverseAdmin]]></dc:creator><pubDate>Sat, 31 Oct 2026 10:55:39 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae4b3ab7a8fca3c7281d4ef/5d5eb832-0d8f-4981-bb84-a01f1d6c21fb.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Kubernetes Pod Disruption Budgets in 2026: Graceful Shutdown Without Dropped Requests</h1>
<blockquote>
<p><strong>TL;DR:</strong> Kubernetes constantly disrupts your pods on purpose — node drains, cluster upgrades, autoscaler scale-downs, rollouts. Pod Disruption Budgets (PDBs) are how you negotiate those disruptions: "you may take one of my three replicas at a time, never two." But a PDB only covers the eviction handshake. The other half of graceful shutdown is the pod itself: handling SIGTERM, finishing in-flight work, and leaving the load balancer before the SIGKILL lands. Get both halves right and maintenance stops being an outage source; get either wrong and every upgrade is a small incident.</p>
</blockquote>
<p><em>Originally published on <a href="https://www.devopsenginer.com/blog/kubernetes-pod-disruption-budgets-guide-2026">DevOps Enginer</a>.</em></p>
<h2>The problem: most pod deaths are voluntary</h2>
<p>When people imagine pods dying, they imagine failures — a node dying, an OOM kill, a crash. Those are <em>involuntary</em> disruptions, and Kubernetes handles them the only way it can: after the fact, by starting replacements.</p>
<p>But watch a healthy cluster for a week and most pod terminations are <em>voluntary</em>: an admin drains a node for maintenance, the cluster autoscaler removes an underused node, an upgrade rolls the nodes, a deployment replaces pods, the cluster itself gets upgraded version by version (the upgrades guide in this series is essentially a long, careful series of voluntary disruptions). Nothing failed — the platform is doing its job.</p>
<p>The trouble is the intersection: a drain that evicts all three replicas of your API in the same minute because nobody told it not to. A pod killed mid-request because it never got the memo — or got it and ignored it. Voluntary disruptions are scheduled, polite, and negotiable. You just have to actually negotiate.</p>
<h2>Pod Disruption Budgets: the negotiation</h2>
<p>A PDB is a small object that tells the eviction API how many pods of a workload may be voluntarily taken down at once:</p>
<pre><code class="language-yaml">apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-pdb
  namespace: shop
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: api
</code></pre>
<p>This says: of the pods labelled <code>app: api</code> in <code>shop</code>, at least 2 must stay available; evictions that would break that are refused with an error, and the drain waits. Two ways to express the budget:</p>
<ul>
<li><strong><code>minAvailable</code></strong> — an absolute number (<code>2</code>) or a percentage (<code>67%</code>) of desired replicas that must remain up.</li>
<li><strong><code>maxUnavailable</code></strong> — how many may be down at once (<code>1</code>, or <code>34%</code>). Usually the better choice, because it tracks replica count: if you scale from 3 to 6 replicas, "at most 1 down" still means what you meant, while a stale <code>minAvailable: 2</code> silently got weaker.</li>
</ul>
<p>Rounding is asymmetric and worth knowing: percentages for <code>minAvailable</code> round <strong>up</strong> (67% of 3 = 3, not 2), and for <code>maxUnavailable</code> round <strong>down</strong> (34% of 3 = 1). If your PDB seems stricter than the percentage suggests, this is why.</p>
<p>How the handshake works in practice: <code>kubectl drain</code>, cluster upgrades, and well-behaved controllers evict pods through the <strong>eviction API</strong>, which checks the PDB first. If the budget allows it, the pod gets its termination grace period and shuts down; if not, the eviction is rejected and the tool retries later. Tools that delete pods directly (a raw <code>kubectl delete pod</code>) bypass PDBs entirely — a PDB is a contract with polite tooling, not a force field.</p>
<h2>The trap: a PDB that can never be satisfied</h2>
<p>Set <code>minAvailable</code> equal to your replica count — a very common "extra safe" instinct — and you have built a deadlock, not a safeguard. The node can never drain, because draining requires evicting a pod, and the budget forbids it. Cluster upgrades stall, autoscaler scale-downs stall, and someone eventually force-deletes the pod at the worst possible time, defeating the entire exercise under pressure.</p>
<p>The subtler version: the budget is satisfiable on paper, but the workload is unhealthy. If only 1 of 3 replicas is ready, a <code>minAvailable: 2</code> PDB blocks <em>all</em> evictions — including evicting the broken pods — because the budget counts healthy pods, and there is no slack. Recent Kubernetes versions address this with <code>spec.unhealthyPodEvictionPolicy</code> (set it to <code>AlwaysAllow</code> so unhealthy pods can always be evicted; the default <code>IfHealthyBudget</code> is stricter). A blocked drain is information: it usually means the application was already below its own availability floor before maintenance started.</p>
<p>A working rule: <strong>budget for one disruption at a time.</strong> <code>maxUnavailable: 1</code> for replicated stateless services is the sensible default — drains proceed pod by pod, replacements come up before the next eviction is allowed, and upgrades stay boring.</p>
<h2>The other half: what the pod does when told to die</h2>
<p>A PDB buys an orderly <em>sequence</em>. It does not make an individual shutdown graceful. That part happens inside the pod, on a clock:</p>
<ol>
<li>The pod is marked <code>Terminating</code>. It is removed from Service endpoints — mostly. Endpoint removal and the kube-proxy / ingress updates that follow are fast but not instantaneous, so a terminating pod can receive a trickle of new connections for a short window. This race is normal and known.</li>
<li>Any <code>preStop</code> lifecycle hook runs.</li>
<li>The container receives <strong>SIGTERM</strong>. This is the application's cue: stop accepting new work, finish in-flight requests, flush and close.</li>
<li>After <code>terminationGracePeriodSeconds</code> (default 30), if the process is still alive, it receives <strong>SIGKILL</strong> — no negotiation, mid-whatever.</li>
</ol>
<p>Graceful shutdown is an application feature, not a platform one. The checklist:</p>
<ul>
<li><strong>Handle SIGTERM in the process.</strong> Many frameworks do this out of the box now (Spring Boot's graceful shutdown, Go's <code>signal.NotifyContext</code>, Node's server close on signal) — but verify yours does, and that the handler <em>drains</em>: stop taking requests, wait for in-flight ones, close database connections cleanly.</li>
<li><strong>Size the grace period to the work.</strong> A web API that finishes requests in 2 seconds is fine at 30s. A worker with 5-minute jobs at the default 30s grace period gets SIGKILLed mid-job every rollout — set <code>terminationGracePeriodSeconds</code> to match the longest unit of work you intend to finish, and make the work checkpoint or idempotent for the cases where it still gets cut.</li>
<li><strong>The <code>preStop</code> sleep, honestly labelled.</strong> A common pattern is a preStop hook that sleeps a few seconds, giving endpoint removal time to propagate before SIGTERM makes the app stop listening. It looks like a hack because it is one — but it addresses the real endpoint race in step 1, and it prevents the classic "502s during every deploy" bug. Use it knowingly, keep it short.</li>
<li><strong>Workers and queues:</strong> on SIGTERM, stop <em>consuming</em>, finish the current message, then exit — and make sure unprocessed messages return to the queue (visibility timeout / ack discipline) rather than dying with the pod.</li>
</ul>
<h2>Stateful and single-replica workloads: where PDBs can't save you</h2>
<p>PDBs assume redundancy. Apply the arithmetic to a single-replica workload and <code>minAvailable: 1</code> (or <code>maxUnavailable: 0</code>) means "never voluntarily evict this pod, ever" — the drain deadlocks on your one database pod, and the eventual forced eviction takes the downtime anyway, unscheduled. For singletons, the honest choices are:</p>
<ul>
<li><strong>Accept the disruption window</strong>: no PDB (or <code>maxUnavailable: 1</code>), maintenance takes the pod down briefly, planned and announced. Better a scheduled 60-second blip than a blocked upgrade escalating into an unplanned one.</li>
<li><strong>Make it not a singleton</strong> if the availability requirement is real — which for databases means the database's own replication story, not a Kubernetes trick.</li>
</ul>
<p>For StatefulSets, PDBs work normally (they select pods by label like anything else), but pair them with the storage and backup thinking from earlier in this series: an evicted stateful pod comes back to its own volume, so the shutdown risk is about in-flight writes — the application-level SIGTERM handling matters more, not less.</p>
<p>One more placement rule: <strong>do not put PDBs on batch Jobs.</strong> A Job's pods are supposed to terminate; a budget on them protects nothing and can wedge completions and node drains alike. PDBs are for long-running services whose absence a user would notice.</p>
<h2>Proving it: test the drain before the upgrade does</h2>
<p>Like every control in this series, a PDB is a rumour until exercised. The test fits in a staging afternoon:</p>
<ol>
<li>Run a light, continuous request loop against the service (a small script recording status codes is enough).</li>
<li>Cordon and drain a node running the workload: <code>kubectl cordon &lt;node&gt;</code> then <code>kubectl drain &lt;node&gt; --ignore-daemonsets</code>.</li>
<li>Watch: evictions should proceed one at a time, the replacement should be Ready before the next eviction is allowed, and the request loop should show zero — or near-zero — failed requests. Failed requests with graceful PDB behaviour usually point at the application half: SIGTERM ignored, grace period too short, or the endpoint race.</li>
<li>Then test the <em>blocked</em> path deliberately: scale the service down to its floor and try another eviction. Being refused, and seeing the clear PDB error, is the control working. An upgrade night is the wrong time to learn this behaviour for the first time.</li>
</ol>
<p>The cluster autoscaler respects the same handshake when it scales nodes down (covered in the autoscaling guide in this series) — which is why a too-strict PDB shows up twice in a cluster's life: once blocking your upgrade, once blocking your cost savings.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong><code>minAvailable</code> equal to the replica count.</strong> The deadlock special. Budget one disruption at a time.</li>
<li><strong>Percentages that don't mean what you think.</strong> <code>minAvailable: 50%</code> of 3 replicas is 2 pods (it rounds up) — and of 1 replica it is a permanent eviction ban.</li>
<li><strong>Expecting PDBs to cover failures.</strong> They govern voluntary evictions only. Node death, crashes and OOM kills are answered by replicas, topology spread and anti-affinity — the deployment-strategies guide covers that side.</li>
<li><strong>Default 30-second grace for slow work.</strong> Long requests, report generation, queue jobs: if the work takes minutes, the grace period must too, or every rollout is a small data-loss event.</li>
<li><strong>Ignoring SIGTERM "because Kubernetes restarts us anyway".</strong> It does — elsewhere. The in-flight request that died with the old pod does not get restarted; the client just sees an error.</li>
<li><strong>PDBs on everything, including Jobs and singletons.</strong> Indiscriminate budgets create blocked operations across the cluster and train the team to force-delete — the worst possible outcome.</li>
<li><strong>Never alerting on blocked evictions.</strong> A drain stuck for hours is a signal the app is below its availability floor. It belongs in the observability stack (the observability guide), not in tomorrow's surprise.</li>
</ul>
<h2>Where this fits in the series</h2>
<p>PDBs are the politeness protocol that several earlier guides quietly assume: the upgrades guide's node-by-node dance only works when evictions are budgeted, the autoscaling guide's cluster autoscaler asks PDB permission before removing nodes, Argo Rollouts and the deployment-strategies guide manage the same risk from the release side, and the backup guide is the admission that even perfect politeness has limits. Voluntary disruption done well is invisible — which is precisely the point of it.</p>
<h2>FAQ</h2>
<p><strong>Does a Pod Disruption Budget protect against node failure?</strong>
No. PDBs only constrain voluntary evictions through the eviction API. Involuntary loss — node death, kernel panic, a cloud reclaiming the instance — ignores budgets entirely. Redundancy (replicas, topology spread constraints, anti-affinity) is the tool for that side.</p>
<p><strong>Should I use minAvailable or maxUnavailable?</strong>
<code>maxUnavailable</code> in most cases, typically <code>1</code>. It states the disruption you tolerate rather than a floor that silently weakens or deadlocks as replica counts change. Reserve <code>minAvailable</code> for workloads whose floor is a hard contractual number.</p>
<p><strong>Can a PDB block an upgrade forever?</strong>
Yes, if the budget can never be met — either by construction (floor equals replicas) or because the workload is too unhealthy to have slack. That is the PDB doing its stated job; the fix is to repair the workload's health or consciously relax the budget, not to force-delete pods under pressure.</p>
<p><strong>Do Deployments create PDBs automatically?</strong>
No. A Deployment manages replicas; the budget is a separate object you write, select, and own. Some Helm charts ship one (check before assuming — the value may not match your replica count), and the Helm guide in this series shows where such templates live.</p>
<p><strong>What happens to in-flight requests when a pod is evicted?</strong>
The pod leaves the Service endpoints, gets SIGTERM, and has its grace period to finish. Requests still in flight when the grace period expires die with the SIGKILL. Both halves of this guide exist to shrink that window to zero: PDBs sequence the evictions, and the application's shutdown handling empties the pod before the deadline.</p>
]]></content:encoded></item><item><title><![CDATA[Kubernetes Backup and Disaster Recovery in 2026: Backups You Have Actually Restored]]></title><description><![CDATA[TL;DR: High availability is not backup. A three-replica Deployment survives a node failure, but kubectl delete namespace deletes all three replicas just as…]]></description><link>https://devopsenginer.hashnode.dev/kubernetes-backup-and-disaster-recovery-in-2026-backups-you-have-actually-restored</link><guid isPermaLink="true">https://devopsenginer.hashnode.dev/kubernetes-backup-and-disaster-recovery-in-2026-backups-you-have-actually-restored</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[Backup]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[softverseAdmin]]></dc:creator><pubDate>Sat, 31 Oct 2026 10:46:56 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae4b3ab7a8fca3c7281d4ef/041de954-eac4-4fb3-892d-cf5f3beaf667.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Kubernetes Backup and Disaster Recovery in 2026: Backups You Have Actually Restored</h1>
<blockquote>
<p><strong>TL;DR:</strong> High availability is not backup. A three-replica Deployment survives a node failure, but <code>kubectl delete namespace</code> deletes all three replicas just as happily, and no amount of HA brings back a dropped database. Real protection has three layers — cluster resources, persistent volume data, and the things that live outside the cluster — plus a restore you have actually performed and timed. Velero is the standard open-source tool for the first two layers. The work is not installing it; the work is proving, on a schedule, that a restore produces a working application.</p>
</blockquote>
<p><em>Originally published on <a href="https://www.devopsenginer.com/blog/kubernetes-backup-disaster-recovery-guide-2026">DevOps Enginer</a>.</em></p>
<h2>The problem: Kubernetes feels safer than it is</h2>
<p>Kubernetes heals. Kill a pod and it comes back. Lose a node and workloads reschedule. This self-healing is real — and it quietly convinces teams they are protected against data loss, which is a different thing entirely.</p>
<p>Self-healing protects against <em>failures of the machinery</em>. Backups protect against <em>everything else</em>: an engineer deleting the wrong namespace, a bad migration dropping a table, a compromised account wiping resources, a storage failure corrupting a volume, a cloud account problem taking the whole region's control plane with it. In every one of those cases, the cluster faithfully heals itself into the new, broken state. The disaster is not that Kubernetes stops working — it is that it keeps working, on empty data.</p>
<p>The second misconception: "etcd is backed up, so we are covered." On a self-managed cluster, an etcd snapshot captures cluster state — the resource definitions. It does not contain the bytes inside your PersistentVolumes, and restoring it is a cluster surgery procedure, not an application recovery. On managed services (EKS, GKE, AKS) you cannot snapshot etcd at all — the provider runs it — which settles the question: your backup strategy lives at the resource and volume level whether you chose it or not.</p>
<h2>What actually needs protecting: three layers</h2>
<ol>
<li><strong>Cluster resources.</strong> Deployments, Services, ConfigMaps, Secrets, RBAC, NetworkPolicies — the declarative state. Cheap to back up (it is text in etcd), and the layer GitOps already half-covers: if everything is applied from Git, Git is a backup of intent, though not of generated state like Secrets created by operators or resources created by controllers.</li>
<li><strong>Persistent data.</strong> The contents of PersistentVolumes: databases, uploaded files, queues on disk. This is the layer that hurts to lose and the layer most "we ran a backup tool once" setups quietly miss, because resource backup and volume backup are separate switches.</li>
<li><strong>Outside the cluster.</strong> Container images (a registry that garbage-collects the tag you need is a DR failure), external databases and queues, DNS, certificates, and the cloud resources the cluster depends on — IAM roles, load balancer config, the object storage bucket your backups land in. A restore plan that discovers a missing layer at 3 a.m. is not a plan.</li>
</ol>
<p>Backups also need their own threat model: they must survive the loss of the cluster <em>and</em> the loss of the account or credentials that administer it. Backup data stored only in the same account, deletable by the same compromised credential, is a copy, not a backup. Cross-account or cross-region copies with separate retention are the grown-up version.</p>
<h2>Two numbers that decide the design: RPO and RTO</h2>
<p>Before tooling, two definitions do all the real work:</p>
<ul>
<li><strong>RPO (recovery point objective):</strong> how much data you can afford to lose, measured in time. Hourly backups mean up to an hour of data loss. If the business answer is "five minutes", nightly volume snapshots are the wrong tool no matter how reliable they are — you need replication or database-native continuous backup.</li>
<li><strong>RTO (recovery time objective):</strong> how long the business tolerates being down while you restore. This is the number restore tests measure, and it includes everything: provisioning a cluster, restoring resources, restoring data, DNS, verification. Teams routinely discover their restore takes six hours against a one-hour RTO — better discovered in a drill than in an incident.</li>
</ul>
<p>Write both numbers down per application tier. They turn backup arguments ("should we also snapshot the volumes?") into arithmetic.</p>
<h2>Layer 1 + 2 with Velero: the working setup</h2>
<p>Velero is the standard open-source tool for Kubernetes backup: it copies cluster resources to object storage and, when asked, snapshots or copies the data inside PersistentVolumes. Two facts shape every Velero setup: backups land in <strong>object storage outside the cluster</strong> (S3, GCS, Azure Blob, or S3-compatible), and volume data needs an explicit choice — it is not automatic.</p>
<p>Install against your provider's plugin (AWS shown; bucket and credentials are placeholders):</p>
<pre><code class="language-bash">velero install \
  --provider aws \
  --plugins velero/velero-plugin-for-aws \
  --bucket my-cluster-backups \
  --secret-file ./credentials-velero \
  --backup-location-config region=ap-northeast-1 \
  --use-node-agent
</code></pre>
<p><code>--use-node-agent</code> deploys the agent that performs file-system-level volume backups — needed for the second volume option below.</p>
<p>There are two ways Velero captures volume data:</p>
<ul>
<li><strong>CSI snapshots</strong> — the storage driver snapshots the volume (on EKS, an EBS snapshot). Fast, storage-native, and the right default where the CSI driver supports snapshots. Since Velero 1.15 the CSI integration is built in rather than a separate plugin.</li>
<li><strong>File-system backup</strong> — Velero copies the files out of the volume through the node agent (Kopia under the hood in current releases). Slower, but works on storage with no snapshot support and produces data that lives in the same object storage as the resource backup, which simplifies cross-cluster restores.</li>
</ul>
<p>A scheduled backup of one namespace, with volume snapshots, is one object:</p>
<pre><code class="language-yaml">apiVersion: velero.io/v1
kind: Schedule
metadata:
  name: shop-daily
  namespace: velero
spec:
  schedule: "0 2 * * *"
  template:
    includedNamespaces:
      - shop
    snapshotVolumes: true
    ttl: 720h
</code></pre>
<p>And the manual equivalents you will use in drills:</p>
<pre><code class="language-bash">velero backup create shop-drill --include-namespaces shop --snapshot-volumes --wait
velero restore create --from-backup shop-drill --namespace-mappings shop:shop-restore
velero backup describe shop-drill      # check phase: Completed, and read the warnings
</code></pre>
<p>Restoring into a <em>different</em> namespace (the <code>--namespace-mappings</code> above) is the drill pattern: the original workload keeps running while you prove the copy works.</p>
<h2>The consistency problem: volumes are crash copies</h2>
<p>A volume snapshot taken under a running database is a crash-consistent copy — equivalent to pulling the plug. Most databases recover from that on startup, which is why the pattern usually works, but "usually" is doing heavy lifting. For the workloads where consistency matters, you have three honest options:</p>
<ul>
<li><strong>Backup hooks.</strong> Velero can run a command in a pod before or after the snapshot (annotations on the pod template) — the classic pattern is freezing or flushing the database (for example <code>fsfreeze</code> or a database-specific lock) for the seconds the snapshot takes.</li>
<li><strong>Database-native backup.</strong> For a critical PostgreSQL or MySQL, the serious answer is often the database's own tooling — WAL archiving / point-in-time recovery — with Velero protecting the manifests and configuration around it. Point-in-time recovery also beats any snapshot schedule on RPO.</li>
<li><strong>Quiesce the workload.</strong> Scale to zero, snapshot, scale back. Only acceptable where a maintenance window exists — but for small internal systems it is the simplest correct answer.</li>
</ul>
<p>Pick per workload, and write the choice down next to the RPO it achieves. The failure mode is assuming the default (crash-consistent) covers the database it does not.</p>
<h2>The GitOps shortcut, and its limits</h2>
<p>If your manifests live in Git and Argo CD (or equivalent) applies them, layer 1 mostly restores itself: point a new cluster at the repo and the workloads reappear. This is a genuine DR advantage of GitOps — the Argo CD guide in this series covers the setup. But know exactly what Git does <em>not</em> contain: Secrets (usually stored encrypted or externally — your restore needs the key material, e.g. the Sealed Secrets private key, or the external secret store), data created inside the cluster, and resources generated by operators. The standard production answer is both: GitOps for the declarative layer, Velero for the generated state and the volume data.</p>
<h2>Prove it: restore drills are the actual product</h2>
<p>Every guide in this series lands on the same principle — an untested control is a rumour — and backups are where it matters most, because backup failures are silent. Velero will happily report schedules running while the bucket policy expired months ago. The only verification that counts is a restore.</p>
<p>A workable drill rhythm:</p>
<ol>
<li><strong>Monthly:</strong> restore last night's backup of one real namespace into a scratch namespace on the same cluster. Verify the application answers, data row counts match expectations, and record how long it took — that number is your honest RTO for that tier.</li>
<li><strong>Quarterly:</strong> restore into a <em>different</em> cluster (the migration path). This catches the things intra-cluster restores never touch: missing CRDs, storage class names that differ, image pull secrets, ingress differences.</li>
<li><strong>Always:</strong> alert on backup failure, not just on schedule age. Wire backup status into the same observability stack as everything else — the observability guide in this series is where the backup metrics and alerts belong.</li>
</ol>
<p>Notice what the drill produces beyond safety: a measured restore time, a list of missing pieces fixed while nothing was on fire, and a team that has performed the procedure before the night it counts.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Backing up resources and calling volumes done.</strong> <code>snapshotVolumes</code> off (or no file-system backup configured) means your backup is the recipe, not the meal. Check a backup's contents, not just its green status.</li>
<li><strong>Backups in the blast radius.</strong> Same account, same credentials, no versioning or retention lock on the bucket: one compromise deletes production and its backups in the same session.</li>
<li><strong>No TTL / no cost control.</strong> Snapshots accumulate forever; the storage guide in this series covers the orphaned-snapshot problem from the cost side. Every schedule needs a <code>ttl</code>.</li>
<li><strong>Secrets that cannot be restored.</strong> The backup contains the Sealed Secrets resources but the cluster that sealed them is the one that died. Keep the unsealing keys in the DR plan, stored outside the cluster.</li>
<li><strong>Restoring everything at once.</strong> A full-cluster restore recreates namespaces in dependency order whether you plan it or not — and load balancers, ingress and DNS fight you on the way. Restore per-application, most critical first; that ordering <em>is</em> the RTO plan.</li>
<li><strong>One backup tool for the database it cannot help.</strong> Crash-consistent volume copies of a busy database, with no hooks and no native backup, presented as a DR strategy.</li>
<li><strong>Testing backups by reading their status.</strong> Green "Completed" is Velero's opinion. The restore is the test.</li>
</ul>
<h2>Where this fits in the series</h2>
<p>Backup is the layer underneath several earlier guides: the storage guide explains the volumes being protected, the upgrades guide's blue/green cluster pattern is a restore-shaped procedure (and the quarterly cross-cluster drill doubles as upgrade rehearsal), the observability guide is where backup success and failure get monitored, and the network policies and security-basics guides cover the access control that keeps backup credentials and buckets out of an attacker's reach. Recovery is not a feature you add at the end; it is the property the rest of the stack is for.</p>
<h2>FAQ</h2>
<p><strong>Does Velero back up etcd?</strong>
No — deliberately. Velero works at the API level, copying resources and volume data; etcd snapshots are a separate, control-plane procedure for self-managed clusters. Velero's model also restores into a cluster running a different Kubernetes version, which an etcd restore cannot do.</p>
<p><strong>We run GitOps. Do we still need Velero?</strong>
Yes, for the parts Git cannot see: volume data, generated resources, and in-cluster state. GitOps shrinks the restore problem; it does not remove it.</p>
<p><strong>How often should backups run?</strong>
As often as your RPO demands, minus honesty about restore cost: a common shape is daily schedules with a 30-day TTL for general namespaces, more frequent (or database-native continuous backup) for the tier whose RPO is measured in minutes. More schedules than that mostly buy storage cost.</p>
<p><strong>Is a second cluster in another region enough — active-active instead of backup?</strong>
Replication solves some RPO problems and no deletion problems: a replicated <code>kubectl delete</code> is a deletion in two regions. Multi-cluster setups still need backups, and most teams should get backups plus practiced restores working before paying for a second always-on cluster.</p>
<p><strong>Can Velero migrate between clusters?</strong>
Yes — backup on one cluster, restore on another is a first-class workflow, and the standard way to move workloads during a cluster rebuild or the blue/green upgrade pattern. That dual use is another reason the quarterly drill pays off: it rehearses two procedures at once.</p>
]]></content:encoded></item><item><title><![CDATA[Kubernetes Network Policies in 2026: Lock Down Pod Traffic Without Breaking Your App]]></title><description><![CDATA[TL;DR: By default, every pod in Kubernetes can talk to every other pod — a flat network with no internal walls. Network Policies are the built-in way to…]]></description><link>https://devopsenginer.hashnode.dev/kubernetes-network-policies-in-2026-lock-down-pod-traffic-without-breaking-your-app</link><guid isPermaLink="true">https://devopsenginer.hashnode.dev/kubernetes-network-policies-in-2026-lock-down-pod-traffic-without-breaking-your-app</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[Security]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[softverseAdmin]]></dc:creator><pubDate>Sat, 31 Oct 2026 10:37:47 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae4b3ab7a8fca3c7281d4ef/c5bd9a9e-4665-48d7-94e3-ecc6335defb5.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Kubernetes Network Policies in 2026: Lock Down Pod Traffic Without Breaking Your App</h1>
<blockquote>
<p><strong>TL;DR:</strong> By default, every pod in Kubernetes can talk to every other pod — a flat network with no internal walls. Network Policies are the built-in way to change that: declare which pods may talk to which, on which ports, and everything else is denied. Start with a default-deny policy per namespace, add back exactly the flows your app needs (including DNS — everyone forgets DNS), and test with real connection attempts, not hope. One catch: your CNI plugin must actually enforce policies, or you are writing rules nobody obeys.</p>
</blockquote>
<p><em>Originally published on <a href="https://www.devopsenginer.com/blog/kubernetes-network-policies-guide-2026">DevOps Enginer</a>.</em></p>
<h2>The problem: a flat network inside the cluster</h2>
<p>Kubernetes networking guarantees that pods can reach each other. What it does not give you, out of the box, is any restriction on that reachability. A compromised frontend pod can scan the database. A batch job can reach the payment service. A pod in the dev namespace can talk to a pod in prod if they share a cluster.</p>
<p>Network Policies fix this at the platform layer. They are firewall rules expressed as Kubernetes objects: selectors pick the pods a policy applies to, and rules list the allowed sources (ingress), destinations (egress), and ports. Anything not explicitly allowed is denied — but only once at least one policy selects a pod. A pod with no policy selecting it remains wide open, which is the single most misunderstood part of the whole feature.</p>
<h2>First, the prerequisite nobody checks: your CNI</h2>
<p>NetworkPolicy is an API, not an implementation. The enforcement is done by your CNI plugin or network stack. Calico and Cilium enforce policies fully. Flannel, historically, does not enforce them at all — you can apply perfect policies and traffic flows anyway. On managed services it varies: Amazon's VPC CNI supports Network Policy enforcement in current EKS versions, GKE offers Dataplane V2 or Calico-backed policy, and AKS has Azure CNI powered by Cilium or its own policy manager options.</p>
<p>Before writing a single rule, verify enforcement in a scratch namespace: apply a default-deny, try a connection that should fail, and watch it fail. A policy that has never been tested is a rumour, not a control. (The security-basics guide in this series covers where Network Policies sit among the other cluster controls.)</p>
<h2>The anatomy of a policy</h2>
<pre><code class="language-yaml">apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-allow
  namespace: shop
spec:
  podSelector:
    matchLabels:
      app: api
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: web
      ports:
        - protocol: TCP
          port: 8080
  egress:
    - to:
        - podSelector:
            matchLabels:
              app: postgres
      ports:
        - protocol: TCP
          port: 5432
</code></pre>
<p>Read it as a sentence: <em>Pods labelled <code>app: api</code> in namespace <code>shop</code> may accept TCP 8080 from pods labelled <code>app: web</code>, and may initiate TCP 5432 to pods labelled <code>app: postgres</code>. Everything else for these pods is denied.</em></p>
<p>Details that matter:</p>
<ul>
<li><strong>Empty <code>podSelector: {}</code> selects every pod in the namespace</strong> — that is how you write a namespace-wide default-deny.</li>
<li><strong><code>policyTypes</code> is a promise.</strong> If you list <code>Egress</code> but write no egress rules, all egress is denied. If you omit <code>Egress</code> from <code>policyTypes</code> while writing an ingress-only policy, egress stays open.</li>
<li><strong><code>from</code>/<code>to</code> entries in one list item are OR'd; separate list items are also OR'd.</strong> Within one item, a <code>podSelector</code> and <code>namespaceSelector</code> together are AND'd — the classic way to say "pods with this label, in namespaces with that label."</li>
<li><strong>Rules are additive only.</strong> There is no deny rule and no ordering. Policies from multiple objects union together, which is why the default-deny-plus-allows pattern works.</li>
<li><strong>Policies are namespaced.</strong> A policy in <code>shop</code> cannot select pods in another namespace directly; cross-namespace rules go through <code>namespaceSelector</code>.</li>
</ul>
<h2>Step 1: default-deny per namespace</h2>
<p>The baseline move in every namespace that runs real workloads:</p>
<pre><code class="language-yaml">apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: shop
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress
</code></pre>
<p>Apply this and the namespace goes dark: nothing in, nothing out. You have not secured the app yet — you have made the allowed list explicit and empty. Now you earn each flow back deliberately. Do this in a staging namespace first, because the next thing that happens is always the same.</p>
<h2>Step 2: allow DNS, the flow everyone forgets</h2>
<p>The first casualty of default-deny egress is DNS. Pods resolve names through the cluster DNS service (kube-dns/CoreDNS in <code>kube-system</code>), and if egress to it is not allowed, every lookup fails and the app errors look like five unrelated bugs.</p>
<pre><code class="language-yaml">apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns-egress
  namespace: shop
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
</code></pre>
<p>Two subtleties: when a rule item contains both a <code>namespaceSelector</code> and a <code>podSelector</code>, they combine with AND — exactly what you want here. And allow TCP 53 as well as UDP 53; large responses and retries fall back to TCP, and "DNS mostly works" is a miserable state to debug.</p>
<h2>Step 3: allow the real flows, one hop at a time</h2>
<p>Now walk your architecture diagram and convert each arrow into a rule. For a typical three-tier app in one namespace — web → api → postgres — the api-allow policy at the top of this guide is the middle hop. The web tier needs ingress from the ingress controller's namespace (via <code>namespaceSelector</code>) and egress to the api. The database needs ingress only from the api and should usually have no egress at all beyond DNS.</p>
<p>Work namespace by namespace, and keep the policies in Git next to the deployments they describe. If you run GitOps (the Argo CD guide in this series), a policy change then gets the same review trail as a code change — which is precisely the audit story you want for a security control.</p>
<h2>Testing policies: prove the negative</h2>
<p>The failure mode of Network Policies is silent — both kinds. Too open, and nothing complains while the risk stays. Too strict, and the app breaks in ways that look like application bugs. Test both directions deliberately:</p>
<ol>
<li><strong>Positive test:</strong> the allowed flow works (web reaches api, api reaches postgres, names resolve).</li>
<li><strong>Negative test:</strong> a forbidden flow fails. Exec into a pod — or spin up a throwaway debug pod in the namespace — and attempt the connection that should not work: the database port from the web tier, an external IP, another namespace's service. A timeout or refusal is the policy doing its job; a successful connect means the policy is not selecting what you think it is, or the CNI is not enforcing.</li>
</ol>
<p>A debug pod recipe that covers most cases:</p>
<pre><code class="language-bash">kubectl run nettest --rm -it --image=nicolaka/netshoot -n shop -- bash
# inside: dig api.shop.svc.cluster.local, nc -vz postgres 5432, curl to the api
</code></pre>
<p>(<code>netshoot</code> is a well-known troubleshooting image that bundles the usual network tools.) Label the debug pod like the tier you are testing — policies select by labels, so an unlabelled debug pod tests the default, not the app's reality.</p>
<h2>Common mistakes</h2>
<ul>
<li><strong>Applying policies where the CNI ignores them.</strong> Test enforcement first; a policy on Flannel-era setups is decoration.</li>
<li><strong>Forgetting <code>policyTypes</code>.</strong> If you write egress rules but do not list <code>Egress</code> in <code>policyTypes</code>, your egress rules are not applied the way you expect — the API defaults <code>policyTypes</code> based on what you wrote, and the surprises are all in the direction of "more open than intended" or "mysteriously closed." Be explicit.</li>
<li><strong>Denying DNS and chasing ghosts for an afternoon.</strong> Any default-deny egress rollout needs the DNS allow in the same change.</li>
<li><strong>Selecting by namespace name strings.</strong> <code>kubernetes.io/metadata.name</code> is the label to match for a namespace; hand-rolled name labels drift.</li>
<li><strong>Trying to write deny rules.</strong> There are none in the native API. Deny-by-exception designs belong to the CNI-specific policy types (Calico and Cilium both offer higher-level policy objects with ordering and explicit deny) — use them deliberately, not by accident.</li>
<li><strong>One giant policy per namespace.</strong> Small, per-tier policies named after the flow (<code>api-allow</code>, <code>postgres-ingress-from-api</code>) stay readable and reviewable. A 300-line policy is where mistakes hide.</li>
<li><strong>Ignoring egress.</strong> Ingress-only thinking leaves data exfiltration and command-and-control paths wide open. Locking egress to known destinations is the higher-value half of the work, and the half most teams skip.</li>
</ul>
<h2>Where this fits in the series</h2>
<p>Network Policies are one layer, not the whole story. They sit alongside the controls from the security-basics guide (RBAC, Pod Security Standards, secrets handling from the ConfigMaps and Secrets guide), the namespace and quota discipline from the resource requests and limits guide, and the upgrade hygiene that keeps the CNI itself current. No single layer carries the cluster; the layers are the point.</p>
<h2>FAQ</h2>
<p><strong>Do Network Policies replace a service mesh?</strong>
No. Policies operate at L3/L4 — pods, namespaces, ports. A mesh adds identity-based, L7-aware controls (which service may call which path). Many clusters run policies alone happily; regulated or multi-tenant environments often run both.</p>
<p><strong>Can a policy allow traffic from outside the cluster?</strong>
Ingress rules accept <code>ipBlock</code> entries (CIDR ranges), which is how you allow a load balancer range or an office VPN. Prefer selecting the ingress controller's pods by label when the traffic enters through it — CIDRs of cloud load balancers change more often than labels do.</p>
<p><strong>What about traffic between nodes, or to the node itself?</strong>
Native policies govern pod traffic. Host and node-level rules are CNI-specific territory again (Calico's GlobalNetworkPolicy and Cilium's host policies) — reach for those when you need them, knowingly.</p>
<p><strong>How do I roll this out on a live cluster without an outage?</strong>
Namespace by namespace, staging first: apply default-deny where a copy of the workload runs, add the flows, run the positive/negative tests, then repeat in production during a quiet window with the Git revert one command away. The rollout is a series of small, reversible changes — treat it with the same respect as the upgrades in this series' cluster-upgrades guide and it stays boring, which is the goal.</p>
]]></content:encoded></item><item><title><![CDATA[Amazon EKS in 2026: Managed Kubernetes on AWS, Explained for Working Engineers]]></title><description><![CDATA[TL;DR: EKS gives you a managed Kubernetes control plane and leaves everything else — nodes, networking shape, IAM wiring, add-ons — as your design…]]></description><link>https://devopsenginer.hashnode.dev/amazon-eks-in-2026-managed-kubernetes-on-aws-explained-for-working-engineers</link><guid isPermaLink="true">https://devopsenginer.hashnode.dev/amazon-eks-in-2026-managed-kubernetes-on-aws-explained-for-working-engineers</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[AWS]]></category><category><![CDATA[EKS]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[softverseAdmin]]></dc:creator><pubDate>Sat, 31 Oct 2026 09:19:31 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae4b3ab7a8fca3c7281d4ef/26a8c112-04cb-4557-93e2-3762e11168a4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p><strong>TL;DR:</strong> EKS gives you a managed Kubernetes control plane and leaves everything else — nodes, networking shape, IAM wiring, add-ons — as your design decisions. The choices that matter: nodes come as managed node groups (the sensible default), self-managed groups (when you need control), or Fargate (serverless pods, with real constraints); Karpenter increasingly replaces static groups for teams that want nodes to follow demand. Networking runs on your VPC — pods get real VPC IPs from your subnets, which makes the <a href="https://www.devopsenginer.com/blog/aws-vpc-guide-2026">VPC design</a> a Kubernetes decision. IAM meets Kubernetes through Pod Identity (the current, simpler answer to "how does this pod get AWS permissions"), and the cluster is only production-shaped once the add-ons land: EBS CSI for storage, the Load Balancer Controller for ingress, external-dns and cert-manager for the public face. What EKS removes is the control plane burden. Everything in the rest of this series still applies — it just runs on AWS now.</p>
</blockquote>
<p><em>Originally published on <a href="https://www.devopsenginer.com/blog/amazon-eks-guide-2026">DevOps Enginer</a>.</em></p>
<p>Most teams choosing EKS are not really choosing Kubernetes — they already know it. They are choosing to stop operating a control plane. What surprises them is how much remains theirs: EKS is managed Kubernetes, not managed <em>everything</em>, and the gap between "cluster created" and "cluster production-ready" is a series of decisions this guide walks through.</p>
<h2>What AWS manages, and what it pointedly does not</h2>
<p>AWS runs the control plane — API servers and etcd across multiple AZs, patched and highly available — and charges a flat per-cluster hourly fee for it. That is the entire managed surface. <strong>Nodes are your EC2 instances</strong> (or Fargate tasks), the VPC is yours, IAM is yours, and the add-ons that make a cluster useful are yours to install and maintain. Understanding this boundary prevents the most common EKS disappointment: expecting an app platform and receiving excellent infrastructure primitives.</p>
<p>Cluster creation itself (eksctl, Terraform, console) is the easy afternoon. The decisions below are the ones with consequences.</p>
<h2>Nodes: three ways to run pods, and a fourth that changes the question</h2>
<p><strong>Managed node groups</strong> — AWS handles the EC2 lifecycle (provisioning, AMI updates, draining on termination) behind a group definition: instance type, size range, subnets, labels. This is the default recommendation: boring, supported, and sufficient for most clusters.</p>
<p><strong>Self-managed node groups</strong> — you own the Auto Scaling group and the bootstrap. Justified by custom AMIs, unusual instance configuration, or requirements the managed path doesn't expose. You also own every patch and every upgrade chore — the <a href="https://www.devopsenginer.com/blog/kubernetes-upgrades-guide-2026">upgrades guide</a> applies with extra steps.</p>
<p><strong>Fargate</strong> — no nodes at all: each pod runs in an isolated micro-VM with a per-pod price. The constraints are the fine print: no DaemonSets, no persistent volumes beyond EFS, pod sizes from a fixed menu, and startup latency that batch and CI workloads forgive and latency-sensitive services don't. Excellent for spiky, stateless, low-count workloads; a poor default for a whole cluster.</p>
<p><strong>Karpenter</strong> — the open-source autoscaler from AWS that increasingly replaces static groups and the cluster-autoscaler pattern: it watches pending pods and launches <em>right-sized</em> instances (type, family, AZ chosen per workload) in under a minute, then consolidates under-used nodes away. For clusters whose demand moves — most clusters — Karpenter is where the <a href="https://www.devopsenginer.com/blog/kubernetes-autoscaling-guide-2026">autoscaling story</a> lands on AWS, and its cost effect is the rare one that shows up on the bill uninvited, in the good direction.</p>
<h2>Networking: your VPC is the cluster network</h2>
<p>EKS uses the AWS VPC CNI by default, which means <strong>pods receive real IP addresses from your VPC subnets</strong>. The consequences are pleasant (pods are first-class network citizens; security groups can even attach per-pod) and constraining (pod density per node is limited by ENI/IP capacity, and subnet IP space is a real planning input — small subnets plus big clusters is a known way to run out of addresses). Prefix delegation raises pod density substantially where needed. This is why the subnet sizing in the VPC guide leaned generous: Kubernetes spends IP addresses like it means it.</p>
<p>The public face of the cluster is the <strong>AWS Load Balancer Controller</strong>: annotate an Ingress or Service and it provisions an ALB/NLB with target groups wired to your pods — the AWS-native version of the <a href="https://www.devopsenginer.com/blog/kubernetes-ingress-networking-basics-2026">ingress patterns</a> covered earlier. Add <strong>external-dns</strong> (Route 53 records follow your ingresses automatically) and <strong>cert-manager</strong> with ACM or Let's Encrypt, and "deploy an app, get a public HTTPS URL" becomes the paved road rather than a manual ritual.</p>
<h2>IAM inside the cluster: Pod Identity is the current answer</h2>
<p>Pods need AWS permissions — read this bucket, publish to that queue — and the cardinal rule is unchanged from everywhere else in AWS: <strong>per-workload roles, never node-wide credentials.</strong> A node role big enough for every pod on the node is a privilege-escalation welcome mat, per the <a href="https://www.devopsenginer.com/blog/aws-iam-least-privilege-guide-2026">IAM least-privilege guide</a>.</p>
<p>EKS has had two mechanisms. <strong>IRSA</strong> (IAM Roles for Service Accounts) works via OIDC and annotations and remains everywhere in existing clusters. <strong>EKS Pod Identity</strong> is the newer, simpler successor: associate an IAM role with a service account through the EKS API, an agent on the node handles credentials, and the annotation archaeology of IRSA goes away. New clusters should start on Pod Identity; existing ones can run both while migrating. Either way, the <em>design</em> is identical: one role per workload, scoped to the resources that workload touches, with the cluster components (CSI driver, LB controller, Karpenter, external-dns) each holding their own deliberately-sized roles.</p>
<h2>The add-ons that make it production-shaped</h2>
<p>A fresh EKS cluster is deliberately minimal. The standard load-out, whether installed as EKS add-ons or via Helm/Argo CD:</p>
<ul>
<li><strong>EBS CSI driver</strong> — without it, PersistentVolumes on EBS don't exist; the <a href="https://www.devopsenginer.com/blog/kubernetes-storage-guide-2026">storage model</a> (gp3 classes, Retain policies, snapshots) hangs off this driver on AWS.</li>
<li><strong>AWS Load Balancer Controller</strong> — ingress and services, as above.</li>
<li><strong>CoreDNS and kube-proxy</strong> — shipped as EKS add-ons; keep their versions in step with cluster upgrades rather than discovering skew during one.</li>
<li><strong>Metrics and logs</strong> — metrics-server or the Prometheus stack for the <a href="https://www.devopsenginer.com/blog/kubernetes-observability-guide-2026">observability baseline</a>, plus a log route (Fluent Bit to CloudWatch Logs or your aggregator). A cluster nobody can see into is a cluster on borrowed time.</li>
<li><strong>Cluster access</strong> — modern EKS uses <em>access entries</em> (API-based authentication mapping IAM principals to Kubernetes permissions), replacing the hand-edited <code>aws-auth</code> ConfigMap that caused a generation of "I locked everyone out of the cluster" incidents. Whoever provisions the cluster should set access policy deliberately on day one; the creator-admin trap (only the creating identity can administer, ever) is an old rite of passage best skipped.</li>
</ul>
<p>Everything after installation is the standard Kubernetes operating model — <a href="https://www.devopsenginer.com/blog/argo-cd-tutorial-gitops-2026">GitOps delivery</a>, <a href="https://www.devopsenginer.com/blog/kubernetes-deployment-strategies-2025-complete-guide">progressive rollouts</a>, <a href="https://www.devopsenginer.com/blog/kubernetes-security-basics-guide-2026">security basics</a>. EKS changes the substrate, not the discipline.</p>
<h2>Upgrades and cost: the two recurring appointments</h2>
<p><strong>Upgrades</strong> follow the standard Kubernetes rhythm with AWS holding the control-plane end: you trigger the control-plane upgrade one minor version at a time, then roll node groups (or let Karpenter drift-replace nodes onto the new AMI), then bump add-ons. Support windows matter here: standard support for a Kubernetes version on EKS runs about 14 months from its EKS release, with paid extended support beyond — the calendar in the upgrades guide applies verbatim.</p>
<p><strong>Cost</strong> has three components people chronically under-model: the per-cluster control-plane fee (multiplied by every forgotten dev cluster — see the <a href="https://www.devopsenginer.com/blog/kubernetes-cost-optimization-guide-2026">Kubernetes cost guide</a> for why cluster sprawl is the quiet line item), the nodes themselves (where Karpenter's right-sizing and consolidation do real work, plus Spot for fault-tolerant capacity), and the AWS perimeter — NAT gateways, load balancers and data transfer that the VPC design decided before the cluster existed.</p>
<h2>Common mistakes</h2>
<ol>
<li><strong>Expecting a platform.</strong> EKS is infrastructure with a managed control plane. Teams that budget zero platform work discover the add-on, IAM and networking decisions arriving anyway, unscheduled.</li>
<li><strong>The node role that does everything.</strong> One broad role for the node group means every pod inherits it. Per-workload identity via Pod Identity is the entire fix, and it's cheap.</li>
<li><strong>Subnets sized for the demo.</strong> Pods consume VPC IPs directly; small subnets cap cluster growth in a way that surfaces mid-incident. Size for the cluster you expect, with the VPC's spare range held back.</li>
<li><strong>aws-auth archaeology.</strong> Hand-editing the access ConfigMap (on clusters still using it) locks teams out with one typo. Prefer access entries; if you must edit aws-auth, do it from a session that stays open until a second login is verified.</li>
<li><strong>Add-ons drifting behind the cluster version.</strong> CSI and LB controller versions have compatibility windows; treat add-on upgrades as part of the cluster upgrade runbook, not a separate someday.</li>
<li><strong>A cluster per team per environment, forever.</strong> The per-cluster fee plus duplicated add-ons and perimeter makes sprawl a budget line. Consolidate with namespaces and per-team IAM where isolation requirements genuinely allow it — and keep dedicated clusters where they don't (compliance, blast radius), knowingly.</li>
</ol>
<h2>FAQ</h2>
<p><strong>EKS vs self-managed Kubernetes on EC2?</strong>
Self-managed gives total control and total responsibility: control-plane HA, etcd backups, and patching are yours. EKS removes exactly that layer for a flat fee. Teams choose self-managed for specific control or cost-at-scale reasons; everyone else is buying back their weekends with EKS, correctly.</p>
<p><strong>EKS vs ECS Fargate?</strong>
If the team knows Kubernetes or needs its ecosystem (Helm charts, operators, the tooling in this series), EKS. If the need is "run these containers on AWS with minimal concepts," ECS is simpler and entirely respectable. The honest test is which ecosystem your hiring, tooling and existing workloads already speak.</p>
<p><strong>Can I run a cluster with no managed node groups at all?</strong>
Yes — Fargate-only clusters exist and suit small, spiky, stateless estates. The DaemonSet and storage constraints decide for most teams; mixed setups (node groups for the baseline, Fargate for burst jobs) are common.</p>
<p><strong>How many clusters should we have?</strong>
Fewer than instinct suggests, more than one for anything serious: separate production from non-production at minimum. Beyond that, let blast radius, compliance boundaries and team autonomy argue it out — with the per-cluster and perimeter costs on the table while they do.</p>
<h2>Conclusion</h2>
<p>EKS is a trade stated plainly: AWS operates the control plane; you keep the architecture. Choose node strategy deliberately (managed groups by default, Karpenter when demand moves), design the VPC like the cluster depends on it — because it literally runs on it — wire IAM per workload with Pod Identity, and install the add-ons that turn a cluster into a platform. Do those things and EKS delivers what it promises: Kubernetes without the control-plane pager. The rest of running Kubernetes well is, as ever, on you — and it's what the other fifteen parts of this series are for.</p>
<p><em>The free <a href="https://www.devopsenginer.com">Kubernetes Production Checklist</a> (via the newsletter on the homepage) includes the production-readiness items this guide builds toward — access, backups, observability and upgrade readiness.</em></p>
]]></content:encoded></item><item><title><![CDATA[AWS VPC in 2026: The DevOps Guide to Subnets, Routing and Not Locking Yourself Out]]></title><description><![CDATA[TL;DR: A VPC is your own slice of AWS networking, and almost everything about it is decided by five choices: the CIDR block (bigger than you think you…]]></description><link>https://devopsenginer.hashnode.dev/aws-vpc-in-2026-the-devops-guide-to-subnets-routing-and-not-locking-yourself-out</link><guid isPermaLink="true">https://devopsenginer.hashnode.dev/aws-vpc-in-2026-the-devops-guide-to-subnets-routing-and-not-locking-yourself-out</guid><category><![CDATA[AWS]]></category><category><![CDATA[Devops]]></category><category><![CDATA[vpc]]></category><category><![CDATA[networking]]></category><dc:creator><![CDATA[softverseAdmin]]></dc:creator><pubDate>Sat, 31 Oct 2026 09:00:24 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae4b3ab7a8fca3c7281d4ef/bb597489-4af1-498e-94b0-45a1d211cca7.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p><strong>TL;DR:</strong> A VPC is your own slice of AWS networking, and almost everything about it is decided by five choices: the CIDR block (bigger than you think you need, because it can never grow), the subnet layout (public for load balancers, private for workloads, isolated for data — spread across at least two, ideally three AZs), the route tables (a subnet is only "public" because its route table points at the internet gateway), NAT gateways (how private subnets reach the internet — one per AZ, because a shared NAT is both a bottleneck and a bill), and the two security layers (security groups, stateful and attached to instances; network ACLs, stateless and attached to subnets). Add VPC endpoints for AWS services before the NAT bill teaches you why, enable flow logs from day one, and never use the default VPC for anything you care about.</p>
</blockquote>
<p><em>Originally published on <a href="https://www.devopsenginer.com/blog/aws-vpc-guide-2026">DevOps Enginer</a>.</em></p>
<p>Networking is the part of AWS everyone clicks through on the way to launching something — and the part that is nearly impossible to change later. A CIDR block cannot be resized. A subnet layout chosen in an afternoon shapes every architecture decision after it. This guide is the VPC as DevOps engineers actually need to understand it: the design decisions, in the order they become permanent.</p>
<h2>The CIDR block: the one-way door</h2>
<p>Your VPC gets an IPv4 range like <code>10.20.0.0/16</code> — 65,536 addresses. Choose it like it matters, because:</p>
<ul>
<li><strong>It cannot be enlarged later.</strong> You can attach secondary CIDRs, but the clean answer is to start generous: a <code>/16</code> per VPC is the common, sane default. Address space is free; running out is not.</li>
<li><strong>It must not collide with anything it will ever talk to</strong> — your other VPCs (peering and Transit Gateway demand non-overlapping ranges), your office network, your VPN ranges. Collisions discovered at integration time are miserable.</li>
<li><strong>Pick from private ranges everyone recognises</strong> (<code>10.x</code> is conventional) and keep a simple allocation register: which block belongs to which account/environment/region. A spreadsheet beats a memory, and it will be consulted at the worst possible moment if it doesn't exist.</li>
</ul>
<p>Subnets carve the block per AZ and tier: a workable pattern is <code>/20</code>s per subnet (4,096 addresses each) — public, private and data tiers across three AZs consumes nine subnets and still leaves most of a <code>/16</code> unallocated for the future. Remember AWS reserves five addresses in every subnet (network, router, DNS, future use, broadcast), so a subnet's usable count is its size minus five.</p>
<h2>Subnets: three tiers, and what "public" actually means</h2>
<p>The tier names describe <em>routing</em>, not intention:</p>
<ul>
<li><strong>Public subnets</strong> — route table has a route to an <strong>Internet Gateway (IGW)</strong>. For: load balancers, NAT gateways, bastions — the small set of things that face the internet. Nothing else earns a public IP.</li>
<li><strong>Private subnets</strong> — no IGW route; outbound internet goes via a NAT gateway in a public subnet. For: application servers, containers (your EKS/EC2 workloads), everything that should be reachable only through a load balancer.</li>
<li><strong>Isolated (data) subnets</strong> — no internet route at all, in or out. For: databases, caches, queues — resources that should only ever talk to the application tier inside the VPC.</li>
</ul>
<p>The sentence worth internalising: <strong>a subnet is public or private solely because of its route table.</strong> There is no checkbox. Misunderstanding this is the root of most "why can/can't my instance reach the internet" mysteries — the answer is always in the route table, never in the subnet's name.</p>
<pre><code class="language-text">VPC 10.20.0.0/16
├── AZ a: public 10.20.0.0/20 · private 10.20.16.0/20 · data 10.20.32.0/20
├── AZ b: public 10.20.48.0/20 · private 10.20.64.0/20 · data 10.20.80.0/20
└── AZ c: public 10.20.96.0/20 · private 10.20.112.0/20 · data 10.20.128.0/20
    (remainder of the /16 left unallocated, on purpose)
</code></pre>
<h2>Routing and NAT: where the money and the outages live</h2>
<p>Route tables attach per subnet (with one main table as the default). The public table points <code>0.0.0.0/0</code> at the IGW; private tables point <code>0.0.0.0/0</code> at a NAT gateway; data tables have only the local route. Two operational truths follow:</p>
<p><strong>NAT gateways are per-AZ infrastructure, not a shared utility.</strong> One NAT gateway costs a fixed hourly fee plus per-GB processing — and routing three AZs through one NAT in AZ-a means (a) an AZ-a failure takes outbound internet from the whole VPC, and (b) you pay cross-AZ data transfer for the privilege. <strong>One NAT per AZ</strong> is the production pattern; the <a href="https://www.devopsenginer.com/blog/aws-cost-optimization-15-tactics-saved-50k-month">cost guide</a> explains the bill either way. Dev environments can share one NAT and accept the risk knowingly.</p>
<p><strong>VPC endpoints are the other half of the traffic bill.</strong> Without them, your private subnets reach S3, DynamoDB and every other AWS API <em>through the NAT gateway</em>, paying per-GB for traffic that never needed to leave AWS's network. Gateway endpoints for S3 and DynamoDB are <strong>free</strong> — there is no excuse for their absence. Interface endpoints (PrivateLink) for the likes of ECR, CloudWatch Logs and Secrets Manager cost an hourly fee per AZ but keep agent and image-pull traffic fully private — on a busy EKS cluster, pulling images through NAT is a cost and failure-domain decision, not a default.</p>
<h2>The two security layers (they are not interchangeable)</h2>
<p><strong>Security groups</strong> attach to instances/ENIs, are <strong>stateful</strong> (reply traffic is automatically allowed), support allow-rules only, and can reference <em>other security groups</em> — "the database accepts 5432 from the app tier's group" is the single most useful pattern in AWS networking, because it tracks membership automatically as instances come and go.</p>
<p><strong>Network ACLs</strong> attach to subnets, are <strong>stateless</strong> (return traffic needs its own rule — the ephemeral-port gotcha), support allow <em>and</em> deny rules evaluated in numbered order, and apply to everything in the subnet whether you remembered it or not.</p>
<p>The working model: security groups are the primary control, designed per-tier with group-to-group references; NACLs stay near-default (allow all) unless you need a subnet-level guardrail — a deny rule that stands even if someone edits a security group badly. Teams that build elaborate NACL logic usually discover the statelessness tax during their first debugging session at 2 a.m. And least privilege applies to who can edit these as much as to the rules themselves — network changes belong behind the same <a href="https://www.devopsenginer.com/blog/aws-iam-least-privilege-guide-2026">IAM discipline</a> as everything else.</p>
<h2>Flow logs: turn them on before you need them</h2>
<p>VPC Flow Logs record accepted/rejected traffic metadata to CloudWatch Logs or S3. They answer the questions every network incident asks — "is traffic even reaching this subnet?" "what is talking to the database?" — and they cannot be back-filled. Enabling them at VPC creation costs almost nothing and is the difference between evidence and vibes during debugging; the <a href="https://www.devopsenginer.com/blog/kubernetes-observability-guide-2026">observability habits</a> from the Kubernetes side apply here unchanged. While you're in the console: DNS resolution and DNS hostnames <strong>on</strong> (private DNS names make internal life bearable), and no workloads in the <strong>default VPC</strong> — it exists for experiments, its layout is nobody's design, and its flat structure has surprised many auditors.</p>
<h2>Connecting beyond one VPC</h2>
<p>Sooner or later the VPC needs neighbours, and each option has its shape:</p>
<ul>
<li><strong>VPC peering</strong> — a direct link between two VPCs: cheap, fast, and <em>non-transitive</em> (A↔B and B↔C does not give A↔C), with CIDRs that must not overlap. Fine for a pair; a mesh of many VPCs becomes unmanageable surprisingly fast.</li>
<li><strong>Transit Gateway</strong> — the hub: VPCs and VPNs attach to one router, routes propagate sensibly, and the architecture stays legible at ten VPCs and beyond. It costs more than peering and earns it the moment "many" enters the sentence.</li>
<li><strong>VPN / Direct Connect</strong> — to your office or data centre. Start with site-to-site VPN (fast to stand up, internet-bound); Direct Connect is the committed, consistent-bandwidth option with lead times measured in weeks, ordered when the VPN's variability becomes a real complaint rather than a theoretical one.</li>
<li><strong>For Kubernetes specifically:</strong> EKS nodes live in your private subnets and inherit all of this — the cluster's outbound internet, endpoint choices and NAT layout are VPC decisions made before the first node joins, per the networking thinking in the <a href="https://www.devopsenginer.com/blog/kubernetes-ingress-networking-basics-2026">Kubernetes networking guide</a>.</li>
</ul>
<p>And define it all as code. A VPC clicked together in the console is a snowflake nobody can rebuild; the same network as a <a href="https://www.devopsenginer.com/blog/terraform-modules-guide-2026">Terraform module</a> — CIDR, tiers and AZ count as variables — is an asset every new environment inherits.</p>
<h2>Common mistakes</h2>
<ol>
<li><strong>The too-small CIDR.</strong> A <code>/24</code> VPC feels tidy until the second AZ or the EKS cluster arrives (Kubernetes networking is address-hungry). You cannot grow it; you can only rebuild. Start at <code>/16</code>.</li>
<li><strong>Overlapping ranges across VPCs or with the office.</strong> Peering, Transit Gateway and VPN all refuse to cooperate with collisions. Allocate from a register, not from memory.</li>
<li><strong>Workloads in public subnets "temporarily".</strong> Temporary is forever. If it doesn't face the internet for a living, it's private.</li>
<li><strong>One NAT gateway for three AZs</strong> — a single point of failure plus cross-AZ transfer charges. One per AZ in production; shared only where the risk is understood and accepted.</li>
<li><strong>No S3/DynamoDB gateway endpoints.</strong> Free to add, and their absence sends AWS's own traffic through the metered NAT. The most expensive checkbox in AWS is the one that costs nothing.</li>
<li><strong>Elaborate NACL rule sets.</strong> Stateless rules with ephemeral ports turn routine debugging into archaeology. Security groups do the real work; NACLs are guardrails.</li>
<li><strong>Flow logs "later".</strong> Later is during the incident, when the missing history would have answered the question. On at creation, always.</li>
</ol>
<h2>FAQ</h2>
<p><strong>How many subnets do I actually need?</strong>
Per AZ: one public, one private, one data — so 6 for a solid 2-AZ setup, 9 for 3 AZs. More tiers (a separate management tier, say) are legitimate; more <em>AZs than your region offers in your account</em> is the usual surprise, so check AZ availability before finalising the layout.</p>
<p><strong>Can I change the CIDR after creating the VPC?</strong>
You can add secondary CIDR blocks, but you cannot remove or shrink the primary one. Treat the primary block as permanent — that's why the sizing advice leans generous.</p>
<p><strong>Public IP on an instance in a private subnet — possible?</strong>
No, and that's the point: no IGW route means no public reachability, whatever the instance settings say. Reach the internet via NAT (outbound) or not at all (data tier); reach the instance via load balancer, bastion or SSM Session Manager — which, not incidentally, is the modern answer to "how do I SSH in": usually, you don't; Session Manager gives audited shell access without any inbound ports.</p>
<p><strong>VPC peering or Transit Gateway?</strong>
Two VPCs that need each other: peering. Anything beyond a pair, or anything involving a shared services VPC or on-prem: Transit Gateway. The cost difference is real but small next to the operational difference at scale.</p>
<h2>Conclusion</h2>
<p>A VPC rewards the teams who treat its first afternoon as design, not setup: a generous non-overlapping CIDR, three tiers across multiple AZs, route tables understood rather than assumed, NAT per AZ, free gateway endpoints in place, security groups referencing each other, and flow logs recording from the first packet. None of it is glamorous, and almost none of it can be fixed cheaply later — which is precisely why it's worth a guide, and worth doing once, properly.</p>
<p><em>More AWS and Kubernetes guides are published weekly on <a href="https://www.devopsenginer.com">DevOps Enginer</a> — the free Kubernetes Production Checklist is available via the newsletter on the homepage.</em></p>
]]></content:encoded></item><item><title><![CDATA[Terraform Modules in 2026: Reusable Infrastructure Without the Copy-Paste]]></title><description><![CDATA[TL;DR: A Terraform module is infrastructure with an interface: variables in, outputs out, opinions inside. Good modules wrap one coherent thing (a network…]]></description><link>https://devopsenginer.hashnode.dev/terraform-modules-in-2026-reusable-infrastructure-without-the-copy-paste</link><guid isPermaLink="true">https://devopsenginer.hashnode.dev/terraform-modules-in-2026-reusable-infrastructure-without-the-copy-paste</guid><category><![CDATA[Terraform]]></category><category><![CDATA[Devops]]></category><category><![CDATA[#IaC]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[softverseAdmin]]></dc:creator><pubDate>Sat, 31 Oct 2026 08:46:57 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae4b3ab7a8fca3c7281d4ef/3cbdecf7-e8b2-4ed8-972b-a53a2f75d189.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p><strong>TL;DR:</strong> A Terraform module is infrastructure with an interface: variables in, outputs out, opinions inside. Good modules wrap one coherent thing (a network, a cluster, an application stack), expose only the decisions callers genuinely need to make, give sensible defaults for the rest, and publish outputs that let other modules compose on top. Version them like software — semantic versions, pinned references, a changelog — because a module edit silently changes every environment that consumes it on the next plan. The failure modes are consistent: the god-module that tries to cover every case and satisfies none, the abstraction leak that forces callers to know the internals anyway, and the unpinned <code>main</code> branch reference that turns a routine apply into a surprise upgrade. Modules done well are how one engineer maintains ten environments; done badly, they're how a copy-paste problem becomes a shared copy-paste problem.</p>
</blockquote>
<p><em>Originally published on <a href="https://www.devopsenginer.com/blog/terraform-modules-guide-2026">DevOps Enginer</a>.</em></p>
<p>Every growing Terraform codebase reaches the same moment: the staging VPC definition looks exactly like the production one except for six values, someone copies the folder, and from that day the two networks drift apart one forgotten edit at a time. Modules are Terraform's answer — and like most answers, they have their own failure modes. This guide covers the design decisions that separate a module library from a junk drawer.</p>
<h2>What a module actually is</h2>
<p>Strip the ceremony away: any directory of <code>.tf</code> files is a module. The root module is the one you run <code>terraform apply</code> in; child modules are directories (or registry entries, or git repos) that the root calls with a <code>module</code> block:</p>
<pre><code class="language-hcl">module "network" {
  source  = "git::https://example.com/infra-modules.git//network?ref=v1.4.0"
  # or: source = "./modules/network"   (local)
  # or: source = "terraform-aws-modules/vpc/aws"  (registry)

  cidr_block        = "10.20.0.0/16"
  az_count          = 3
  enable_flow_logs  = true
}

output "private_subnet_ids" {
  value = module.network.private_subnet_ids
}
</code></pre>
<p>The contract is the whole idea: <strong>variables are the questions the module asks, outputs are the promises it makes, and everything in between is its private business.</strong> A caller should never need to read a module's internals to use it — the moment they do, the abstraction has leaked and the module has failed at its one job.</p>
<h2>Designing the interface (where modules are won or lost)</h2>
<p><strong>Expose decisions, not resources.</strong> Callers should pass intent — "3 AZs, flow logs on, this CIDR" — not raw resource arguments copied from the AWS provider docs. If your variable list mirrors the underlying resource's argument list one-to-one, you have not written a module; you have written a forwarding service. The module's value <em>is</em> its opinions: the subnet layout, the tag set, the naming scheme, the secure defaults — decided once, encoded once.</p>
<p><strong>Defaults carry the opinions.</strong> A good module can be called with three or four inputs by a team that trusts its defaults:</p>
<pre><code class="language-hcl">variable "az_count" {
  type        = number
  default     = 3
  description = "How many availability zones to spread subnets across."
  validation {
    condition     = var.az_count &gt;= 2 &amp;&amp; var.az_count &lt;= 4
    error_message = "az_count must be between 2 and 4 for this network design."
  }
}

variable "enable_flow_logs" {
  type    = bool
  default = true   # secure by default; turning it off is a decision, not an accident
}
</code></pre>
<p>Note the shape: typed variables, a description a stranger can act on, validation that fails at <em>plan</em> time with a human sentence, and defaults that encode the safe choice. Every default you set is a caller who never has to think about that knob — and a team convention that can never be forgotten.</p>
<p><strong>Outputs are the composition layer.</strong> Publish the identifiers other modules will need (subnet IDs, the cluster endpoint, the bucket ARN) and nothing else. Outputs are a public API: teams will build on whatever you publish, so publish deliberately, and treat removing an output as a breaking change.</p>
<h2>Size: one coherent thing</h2>
<p>The temptation is the mega-module — <code>module "platform"</code> that builds the network, the cluster, the databases and the applications in one call. It feels convenient and ages terribly: every consumer inherits every decision, a change to any layer re-plans all of them, and the state file becomes a blast radius the size of your whole estate.</p>
<p>The working rule: <strong>a module wraps one layer of the stack that changes together and gets consumed together.</strong> Network. Cluster. Data store. Application service. Those compose:</p>
<pre><code class="language-hcl">module "network" { source = "./modules/network"  /* ... */ }
module "cluster" {
  source     = "./modules/cluster"
  subnet_ids = module.network.private_subnet_ids
}
module "app" {
  source        = "./modules/app-service"
  cluster_id    = module.cluster.id
  image         = "registry.example.com/api:${var.api_version}"
}
</code></pre>
<p>Composition through outputs keeps each module independently testable, independently versionable, and independently <em>replaceable</em> — when the cluster design changes, the network module neither knows nor cares. The same thinking, incidentally, is why <a href="https://www.devopsenginer.com/blog/terraform-state-management-guide-2026">state management</a> per layer keeps blast radius small: module boundaries and state boundaries want to be the same lines.</p>
<h2>Versioning: modules are software, treat them that way</h2>
<p>A module consumed from a git repo at <code>?ref=main</code> is an unpinned dependency on a moving target: every consumer silently upgrades whenever anyone merges anything. The first time that merge changes a default, every environment in the company plans a diff nobody asked for — during somebody's unrelated change window.</p>
<p>The disciplined version:</p>
<ul>
<li><strong>Tag releases semantically.</strong> <code>v1.4.0</code> tells consumers what kind of change they're taking: patch for fixes, minor for additive inputs/outputs, major for breaking changes (renamed variables, changed defaults, removed outputs).</li>
<li><strong>Pin every reference.</strong> <code>?ref=v1.4.0</code>, never a branch. Upgrading a module becomes a deliberate, one-line, reviewable act per environment — staging takes it this week, production next week, and the diff in between is the changelog.</li>
<li><strong>Keep a changelog.</strong> Three honest lines per release beats a generated novel: what changed, who is affected, what to check after upgrading.</li>
<li><strong>Test before tagging.</strong> A module's test surface is <code>terraform validate</code>, a plan against a sandbox account, and — for the modules that matter — an ephemeral apply-and-destroy in CI. The registry ecosystem (<code>terraform test</code>, now built in) supports real test runs with mocked providers for the logic-heavy modules. Whatever the depth, a release nobody has planned against is a beta, labelled or not.</li>
</ul>
<p>Local path modules (<code>./modules/...</code>) skip versioning entirely — fine for one repo's internal layers, where the "consumers" ship in the same commit. The moment a module is consumed from <em>another</em> repo or by <em>another team</em>, it has users, and users deserve versions.</p>
<h2>Registry modules: stand on the community carefully</h2>
<p>The public registry holds mature modules for the common shapes — the AWS VPC module alone encodes years of edge cases. Using them is good engineering, with two cautions. First, read the interface before committing: a famous module with 200 input variables demands you learn <em>its</em> opinions, which may not be yours. Second, a registry module is still a dependency: pin its version, and re-check its defaults on upgrade (a default flip in a popular module has surprised more than one team). When a registry module fits, it saves months; when it almost fits, a thin in-house wrapper module — your interface on the outside, the registry module inside — keeps your callers insulated from its surface.</p>
<h2>Common mistakes</h2>
<ol>
<li><strong>The god-module.</strong> Network + cluster + data + apps in one call: unreviewable plans, maximal blast radius, and consumers forced to accept every layer's choices. Split by layer; compose through outputs.</li>
<li><strong>Variables that mirror resource arguments.</strong> A module whose inputs are the provider's docs restated provides no leverage — the opinions <em>are</em> the product.</li>
<li><strong>Unpinned sources.</strong> <code>ref=main</code> turns every merge into a fleet-wide event. Pin versions; upgrade deliberately.</li>
<li><strong>Everything is an output.</strong> Outputs are API. Publishing internals invites consumers to build on them, and then "internal" refactors become breaking changes.</li>
<li><strong>One module for every environment difference.</strong> A variable count that grows with every new consumer is a god-module assembling itself. When a consumer's shape is genuinely different, a second module (or composition of smaller ones) beats a flag for every fork in the road.</li>
<li><strong>No validation, failure at apply time.</strong> An invalid CIDR or an az_count of 7 should fail in seconds at plan, with a sentence explaining why — not fifteen minutes into an apply, midway through creating a network.</li>
</ol>
<h2>FAQ</h2>
<p><strong>When should code become a module?</strong>
At the second honest reuse — or the first, if the thing is a recognised layer (network, cluster) whose shape you already know. Extracting on first use for everything produces speculative abstractions; waiting until the fourth copy produces archaeology.</p>
<p><strong>Local modules or a shared module repo?</strong>
One repo's layers: local paths, shipped together. Modules with consumers in other repos or teams: their own repo (or a registry), versioned and released. The boundary is about who consumes it, not about elegance.</p>
<p><strong>How do I refactor a module without breaking consumers?</strong>
The same way you change any API: additively first (new optional variables with defaults, new outputs), deprecate in the changelog, and reserve removals/renames for a major version. Consumers pinned to the old major are untouched until they choose to move.</p>
<p><strong>Modules vs just using Terragrunt/workspaces?</strong>
Different layers of the problem. Workspaces and wrapper tools manage <em>environments and repetition at the root level</em>; modules manage <em>reusable components</em>. Most estates of any size end up with both, and the state guidance in the <a href="https://www.devopsenginer.com/blog/terraform-state-management-guide-2026">state management guide</a> applies either way. If you're still choosing the IaC tool itself, the <a href="https://www.devopsenginer.com/blog/terraform-vs-pulumi-cloudformation-2025-iac-comparison">Terraform vs Pulumi vs CloudFormation comparison</a> is the wider view.</p>
<h2>Conclusion</h2>
<p>A Terraform module is a promise: give me these few decisions, and I will give you infrastructure built the way this team agreed — every time, in every environment, from one definition. Keep the interface small and opinionated, compose layers through outputs, version like software, and pin like you mean it. Do that, and the tenth environment costs you an afternoon of variables instead of a week of copy-paste — which was the entire point.</p>
<p><em>Setting up your first environments? The Kubernetes Production Checklist and the other free resources are on <a href="https://www.devopsenginer.com">DevOps Enginer</a> — new practical guides are published weekly.</em></p>
]]></content:encoded></item><item><title><![CDATA[Kubernetes ConfigMaps and Secrets in 2026: Configuration Without Rebuilding Images]]></title><description><![CDATA[TL;DR: ConfigMaps hold non-sensitive configuration (feature flags, endpoints, tuning values); Secrets hold sensitive values (passwords, tokens, keys)…]]></description><link>https://devopsenginer.hashnode.dev/kubernetes-configmaps-and-secrets-in-2026-configuration-without-rebuilding-images</link><guid isPermaLink="true">https://devopsenginer.hashnode.dev/kubernetes-configmaps-and-secrets-in-2026-configuration-without-rebuilding-images</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[Devops]]></category><category><![CDATA[secrets]]></category><dc:creator><![CDATA[softverseAdmin]]></dc:creator><pubDate>Sat, 31 Oct 2026 08:13:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae4b3ab7a8fca3c7281d4ef/041e36a3-9f3b-41c5-92d8-bb5d1064397a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p><strong>TL;DR:</strong> ConfigMaps hold non-sensitive configuration (feature flags, endpoints, tuning values); Secrets hold sensitive values (passwords, tokens, keys) — with one warning that must be said out loud: a Kubernetes Secret is only base64-encoded, not encrypted, so anyone with API read access to it has the value. Configuration reaches pods two ways — environment variables (simple, but frozen at pod start) and volume mounts (files the kubelet keeps updated, but only for apps that re-read them). The production pattern most teams converge on: keep config in ConfigMaps, keep real secrets in an external vault (AWS Secrets Manager, Vault, and friends) and sync them in with the External Secrets Operator, encrypt etcd at rest, scope access with RBAC, and treat rotation as a designed event, not an emergency. Configuration is where "works in staging" goes to die — this guide makes it boring instead.</p>
</blockquote>
<p><em>Originally published on <a href="https://www.devopsenginer.com/blog/kubernetes-configmaps-secrets-guide-2026">DevOps Enginer</a>.</em></p>
<p>Rebuild an image to change a log level once, and you understand why configuration exists as its own discipline. Rebuild one to change a database password during an incident, and you never design config badly again. Kubernetes gives you two objects for this job — ConfigMaps and Secrets — and a decade of teams have learned the same few lessons about them. Here they are, in one place.</p>
<h2>ConfigMaps: the plain-text workhorse</h2>
<p>A ConfigMap is key-value data, nothing more exotic:</p>
<pre><code class="language-yaml">apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  LOG_LEVEL: "info"
  FEATURE_CHECKOUT_V2: "true"
  app.properties: |
    pool.size=20
    pool.timeout=5s
</code></pre>
<p>It can carry simple values and whole files (the <code>app.properties</code> block above lands as a file when mounted). Creation from files or literals via <code>kubectl create configmap</code> or your GitOps pipeline — as with everything else on this site, the manifest in Git is the version worth having; ad-hoc <code>kubectl</code> creates are config drift with extra steps. ConfigMaps are namespace-scoped, capped around 1 MiB, and — importantly — they are <em>not</em> a database: hundreds of tiny ConfigMaps are a smell pointing at a config service or a file baked sensibly into the repo, not more objects.</p>
<h2>Secrets: base64 is not encryption (the paragraph to actually read)</h2>
<p>A Secret looks like a ConfigMap with <code>type: Opaque</code> and values that appear encoded:</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Secret
metadata:
  name: db-credentials
type: Opaque
stringData:          # write plaintext here; the API server encodes it
  username: app
  password: "s3cr3t-value"
</code></pre>
<p>That encoding is <strong>base64 — a transport format, not protection</strong>. The defences that actually protect a Secret, in the order they matter:</p>
<ol>
<li><strong>RBAC:</strong> who can <code>get</code>/<code>list</code> Secrets in the namespace. Read access to a Secret <em>is</em> the secret. This is the control that does the most work, and it is covered properly in the <a href="https://www.devopsenginer.com/blog/kubernetes-security-basics-guide-2026">security basics guide</a>.</li>
<li><strong>Encryption at rest:</strong> etcd stores Secrets; enable envelope encryption (KMS-backed on managed clusters, often a toggle) so a stolen etcd backup doesn't hand over every credential in the cluster.</li>
<li><strong>Not committing plaintext Secrets to Git.</strong> The most violated rule in this entire article. If manifests live in Git — they should — Secrets in plaintext manifests means the Git repo <em>is</em> your vault, with Git's access list, clone history and all. The fixes are the next section.</li>
</ol>
<p>Secret types beyond <code>Opaque</code> exist for specific consumers (TLS secrets for ingress, docker-registry secrets for image pulls), and those integrate with their consumers directly — an ingress TLS secret, for instance, is the standard way to terminate HTTPS per the <a href="https://www.devopsenginer.com/blog/kubernetes-ingress-networking-basics-2026">networking guide</a>.</p>
<h2>Getting configuration into the pod: env vars vs files</h2>
<p>Two delivery mechanisms, with opposite trade-offs:</p>
<pre><code class="language-yaml">    env:
      - name: LOG_LEVEL
        valueFrom:
          configMapKeyRef: { name: app-config, key: LOG_LEVEL }
      - name: DB_PASSWORD
        valueFrom:
          secretKeyRef: { name: db-credentials, key: password }
    volumeMounts:
      - name: config
        mountPath: /etc/app
  volumes:
    - name: config
      configMap: { name: app-config }
</code></pre>
<ul>
<li><strong>Environment variables</strong> are set at container start and <em>never change for the life of the pod</em>. Update the ConfigMap and nothing happens until pods restart. Simple, predictable, and the right default for values that shouldn't mutate under a running process anyway.</li>
<li><strong>Volume mounts</strong> present the data as files, and the kubelet updates the files when the source changes (within a minute or so, via an atomic symlink swap). But an updated file only matters if the application re-reads it — most don't. So volume-mounted config gives you <em>eventual files</em>, not <em>live reload</em>, unless the app watches the files (some frameworks do, via the <code>..data</code> symlink change).</li>
</ul>
<p>The operational consequence people trip on: <strong>changing a ConfigMap does not roll your Deployment.</strong> If the change must take effect, trigger the rollout (<code>kubectl rollout restart</code>) — or use the Helm trick of checksumming the config into a pod-template annotation so a config change <em>is</em> a template change and rolls automatically, per the <a href="https://www.devopsenginer.com/blog/helm-charts-tutorial-2026">Helm guide</a>. Decide deliberately which behaviour each config deserves; "sometimes it reloads, sometimes not" is the worst of both.</p>
<h2>Secrets in Git, solved properly: external stores and sync</h2>
<p>Since plaintext Secrets must not live in Git, but workloads need Secrets in the cluster, the industry converged on one architecture: <strong>the vault is the source of truth; a controller syncs values into the cluster as Secrets.</strong></p>
<p>The common implementation is the <strong>External Secrets Operator (ESO)</strong>: you commit an <code>ExternalSecret</code> manifest that contains <em>references</em> — no values — and ESO fetches the real values from AWS Secrets Manager, GCP Secret Manager, Azure Key Vault or HashiCorp Vault using the cluster's own cloud identity:</p>
<pre><code class="language-yaml">apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: db-credentials
spec:
  refreshInterval: 1h
  secretStoreRef: { name: aws-secrets, kind: ClusterSecretStore }
  target: { name: db-credentials }
  data:
    - secretKey: password
      remoteRef: { key: prod/app/db, property: password }
</code></pre>
<p>What Git shows anyone: that a password exists and where it lives. What it never shows: the password. Rotation happens in the vault; ESO refreshes the Secret; pods pick it up on their next restart (or sooner, for the file-watchers). Alternatives in the same family: the cloud providers' own CSI drivers (mounting secrets as in-memory files, never persisted as API objects at all — arguably the cleanest model) and <strong>Sealed Secrets</strong>, which encrypts values with a cluster key so the ciphertext is safe to commit — simpler than a vault, with key management becoming your job instead. For a small team with no vault yet, Sealed Secrets is a legitimate step; for anything multi-cluster or compliance-flavoured, the external-store pattern wins.</p>
<p>Two hygiene rules whichever route you choose:</p>
<ul>
<li><strong>Different secrets per environment.</strong> The staging credential and the production credential being identical is how a staging compromise becomes a production incident.</li>
<li><strong>Audit access.</strong> Secret reads should be logged (CloudTrail on the vault side, API audit logs in-cluster) — per the <a href="https://www.devopsenginer.com/blog/kubernetes-observability-guide-2026">observability guide</a>, the time to learn who read a secret is not during the postmortem.</li>
</ul>
<h2>Rotation: the event you design before it designs you</h2>
<p>Credentials rotate — on a schedule if you're organised, during an incident if you're not. A rotation that requires editing manifests in three repos and restarting everything by hand is not a rotation procedure; it's a hope. The designed version:</p>
<ol>
<li>Vault holds the new value alongside the old (most stores support versions; databases can hold two valid users during a transition window).</li>
<li>ESO syncs; a controlled rollout restarts pods against the new value (<code>kubectl rollout restart</code>, staged by <a href="https://www.devopsenginer.com/blog/kubernetes-deployment-strategies-2025-complete-guide">deployment strategy</a> so a bad credential fails a canary, not the fleet).</li>
<li>Old value revoked <em>after</em> the new one is verified serving traffic.</li>
</ol>
<p>Notice what this procedure never involves: a human seeing the value, a value in a chat message, or a rebuild. If any of those appear in your rotation story, the design has a hole in it.</p>
<h2>Common mistakes</h2>
<ol>
<li><strong>Plaintext Secrets in Git.</strong> The classic. History keeps them forever, every clone keeps them everywhere. Vault + sync, or sealed ciphertext — those are the options.</li>
<li><strong>Believing base64 is encryption.</strong> It is encoding, readable by anyone in milliseconds. RBAC and etcd encryption are the actual protections.</li>
<li><strong>One giant shared Secret for everything.</strong> Every pod that mounts it can read all of it. Split by consumer: a pod should hold the credentials for its own job and nothing else.</li>
<li><strong>Expecting ConfigMap edits to reload apps.</strong> Env vars never update; mounted files update but apps rarely re-read. Roll the pods, or wire the checksum annotation, deliberately.</li>
<li><strong>The same credentials in every environment</strong> — and in the load-test cluster, and in that old namespace nobody deleted. Scope per environment, per workload.</li>
<li><strong>Config in the image, secrets in the wiki.</strong> Config baked into images forces rebuilds for tuning; secrets documented in shared notes are secrets in name only. Both are the same mistake: the value lives where it isn't managed.</li>
</ol>
<h2>FAQ</h2>
<p><strong>ConfigMap or Secret for a database URL?</strong>
The URL itself (host, port, database name) is configuration — ConfigMap. The username and password are Secrets. Splitting them is normal and makes rotation cleaner: credentials change without touching the endpoint config.</p>
<p><strong>How big can a ConfigMap be?</strong>
Around 1 MiB per object (etcd object limits). If your config approaches that, it isn't config anymore — it's data, and it belongs in object storage or a database, mounted or fetched by the app.</p>
<p><strong>Should pods read config from the API directly instead?</strong>
Almost never. It couples the app to Kubernetes APIs and permissions it shouldn't hold. Env vars and files are the interface; keep the app portable and the cluster's API unexposed to workloads.</p>
<p><strong>What's the difference between Sealed Secrets and External Secrets?</strong>
Sealed Secrets stores the encrypted value in Git and decrypts it in-cluster — the repo remains the distribution mechanism. External Secrets stores only a reference in Git and fetches the value from a vault at sync time. The vault model scales to rotation, audit and multi-cluster; the sealed model is simpler to adopt. Both beat plaintext.</p>
<h2>Conclusion</h2>
<p>Configuration is a design surface, not a chore: ConfigMaps for the values you'd print on a whiteboard, Secrets for the ones you wouldn't, RBAC and etcd encryption as the real protections, a vault with sync as the source of truth, and rotation as a rehearsed procedure. Get those five decisions right and config changes become the least interesting part of your week — which, for anything involving production credentials, is exactly the ambition.</p>
<p><em>The free <a href="https://www.devopsenginer.com">Kubernetes Production Checklist</a> (via the newsletter on the homepage) includes the configuration and secrets pre-flight items — RBAC scoping, backup tests and rotation readiness — alongside the rest of the production readiness checks.</em></p>
]]></content:encoded></item><item><title><![CDATA[Kubernetes Upgrades in 2026: Minor Versions Without the Downtime or the Drama]]></title><description><![CDATA[TL;DR: Kubernetes releases three minor versions a year, and each is supported for roughly a year — fall two versions behind and upgrading stops being…]]></description><link>https://devopsenginer.hashnode.dev/kubernetes-upgrades-in-2026-minor-versions-without-the-downtime-or-the-drama</link><guid isPermaLink="true">https://devopsenginer.hashnode.dev/kubernetes-upgrades-in-2026-minor-versions-without-the-downtime-or-the-drama</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Tutorial]]></category><category><![CDATA[upgrades]]></category><dc:creator><![CDATA[softverseAdmin]]></dc:creator><pubDate>Sat, 31 Oct 2026 08:01:27 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae4b3ab7a8fca3c7281d4ef/8d82f7e9-f9dc-4233-909f-33630fc36f00.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p><strong>TL;DR:</strong> Kubernetes releases three minor versions a year, and each is supported for roughly a year — fall two versions behind and upgrading stops being routine maintenance and becomes a project. Safe upgrades follow a fixed order: check for removed APIs before you touch anything, upgrade the control plane first (one minor version at a time, never skip), then rotate nodes with surge capacity so workloads always have somewhere to land, respecting PodDisruptionBudgets the whole way. The two facts that shape everything: you cannot skip minor versions on the control plane, and there is no downgrade button — your rollback plan is an etcd backup and node images you can still boot. Do the preparation and an upgrade is a boring afternoon. Skip it, and the boring afternoon finds you anyway, at a worse time.</p>
</blockquote>
<p><em>Originally published on <a href="https://www.devopsenginer.com/blog/kubernetes-upgrades-guide-2026">DevOps Enginer</a>.</em></p>
<p>Every cluster is quietly ageing. The Kubernetes release train does not stop because a team is busy, and the gap between "we should upgrade soon" and "our version just went end-of-life" is where upgrade projects turn into incidents. This guide is the upgrade as a routine: the checks, the order, and the traps — written for the person who wants upgrades to be boring.</p>
<h2>The rules of the road</h2>
<p>Four constraints govern every upgrade, and planning means accepting them rather than negotiating:</p>
<ol>
<li><strong>One minor version at a time for the control plane.</strong> Going 1.30 → 1.32 directly is not supported for kube-apiserver. Each step gets applied, verified, and only then does the next begin. (Managed control planes enforce this for you; the steps still happen.)</li>
<li><strong>Version skew is a policy, not a vibe.</strong> The supported combinations are specific: kubelets may be up to three minor versions behind the API server (since 1.28), but never newer than it; <code>kubectl</code> works within one minor version either way. "It connected" is not the same as "it's supported."</li>
<li><strong>APIs get removed.</strong> Every minor release can retire beta APIs. Workloads still referencing a removed API fail at apply time — or worse, at the moment you need to redeploy during an unrelated incident. This is what the preparation phase exists to catch.</li>
<li><strong>There is no downgrade.</strong> The API server and etcd do not roll back. If an upgrade goes wrong mid-flight, recovery means restoring etcd from backup or rebuilding — which is why backups are step zero, not an afterthought.</li>
</ol>
<h2>Phase 1: Preparation (where upgrades are actually won)</h2>
<p><strong>Inventory the target.</strong> Read the target version's changelog and the release's "urgent upgrade notes," and check your add-ons against it: ingress controller, CNI, CSI drivers, cert-manager, cluster-autoscaler/Karpenter, metrics-server. Add-on incompatibility breaks more upgrades than Kubernetes itself does — an ingress controller that doesn't support the new version will not announce this until traffic stops.</p>
<p><strong>Hunt for removed APIs.</strong> Two tools make this mechanical:</p>
<pre><code class="language-bash"># Scans manifests / Helm releases for deprecated and removed APIs
pluto detect-helm -o wide
pluto detect-files -d ./manifests -o wide

# Scans the live cluster for APIs in use that are scheduled for removal
kubent
</code></pre>
<p>Fix every finding by moving to the replacement API (the tools name it) <em>before</em> the upgrade window. While you are in there, check the same hygiene for admission webhooks: a dead webhook with <code>failurePolicy: Fail</code> can block the whole cluster's writes after a control-plane change, so inventory webhooks and know which ones are load-bearing.</p>
<p><strong>Back up etcd — and know how you'd restore it.</strong> On self-managed clusters, snapshot etcd and copy the snapshot off the node. On managed clusters (EKS, GKE, AKS), the provider handles the control plane, but your <em>workload state</em> is your problem: if everything is in Git (see my <a href="https://www.devopsenginer.com/blog/argo-cd-tutorial-gitops-2026">Argo CD tutorial</a>), the cluster is rebuildable; if it isn't, a volume-level backup tool is your safety net, per the <a href="https://www.devopsenginer.com/blog/kubernetes-storage-guide-2026">storage guide</a>.</p>
<p><strong>Check cluster health honestly.</strong> Upgrading a cluster that already has failing nodes, Pending pods, or a flapping control plane converts one problem into two. <code>kubectl get nodes</code>, component statuses, and a look at recent events: fix first, upgrade second.</p>
<h2>Phase 2: Control plane first</h2>
<p>With preparation done, the control plane goes first — always. On managed services this is one action per minor version (console, CLI or Terraform), followed by a pause: watch API server availability, controller-manager and scheduler health, and confirm nodes still report Ready before proceeding.</p>
<p>On self-managed clusters with kubeadm, the shape is:</p>
<pre><code class="language-bash"># On the first control-plane node, per minor version:
apt-mark unhold kubeadm &amp;&amp; apt-get install -y kubeadm=1.31.x-*   # target version
kubeadm upgrade plan
kubeadm upgrade apply v1.31.x
# Then upgrade kubelet + kubectl on the node and restart kubelet
</code></pre>
<p><code>kubeadm upgrade plan</code> is the unsung hero here: it reports component versions, warns about what it can and cannot do, and refuses some genuinely bad ideas. Read its output like a pre-flight checklist, because that is what it is. Repeat for the remaining control-plane nodes with <code>kubeadm upgrade node</code>.</p>
<h2>Phase 3: Nodes — rotate, don't patch in place</h2>
<p>Worker nodes upgrade by replacement wherever possible: bring up a node at the new version, drain an old one, let workloads reschedule, remove the old node. On managed node groups this is the built-in rolling update, with one setting that decides whether it is graceful:</p>
<ul>
<li><strong>Max surge:</strong> allow the group to temporarily run <em>extra</em> nodes (e.g. +1 or +33%) during the update. Surge means a drained node's workloads already have a home; without surge, the update cordons a node and hopes capacity suffices — on a tightly packed cluster, it doesn't, and pods sit Pending mid-upgrade. This is the same capacity logic as <a href="https://www.devopsenginer.com/blog/kubernetes-autoscaling-guide-2026">autoscaling</a>: replacement needs somewhere to land.</li>
<li><strong>PodDisruptionBudgets are the contract</strong> that makes draining safe: drain respects voluntary-disruption budgets, so a workload with <code>minAvailable</code> set cannot be fully evicted by an upgrade. Workloads <em>without</em> PDBs can be taken to zero by a drain — if that outage would hurt, the PDB was the missing pre-work (Phase 1 again).</li>
</ul>
<p>The manual shape, when you are rotating nodes yourself:</p>
<pre><code class="language-bash">kubectl cordon &lt;old-node&gt;
kubectl drain &lt;old-node&gt; --ignore-daemonsets --delete-emptydir-data
# wait: confirm every workload is Running elsewhere
kubectl delete node &lt;old-node&gt;   # after the node itself is terminated/replaced
</code></pre>
<p>Watch for the two classic drain blockers: pods with local storage (<code>--delete-emptydir-data</code> acknowledges losing it — be sure you mean that) and pods whose PDB forbids the eviction, in which case the drain waits, correctly, and you investigate the workload rather than forcing it. <code>--force</code> exists; reaching for it during an upgrade is how upgrades become incidents.</p>
<p>On self-managed nodes where replacement isn't practical, kubelet upgrades in place: install the new kubelet (and container runtime, checking its own compatibility), restart, verify the node rejoins at the new version within skew policy.</p>
<h2>Phase 4: Verify like you don't trust it (because you shouldn't)</h2>
<p>"Upgrade completed" from a console is the beginning of verification, not the end:</p>
<ol>
<li><code>kubectl version</code> and <code>kubectl get nodes</code> — every node at the intended version, all Ready.</li>
<li>Core add-ons healthy: DNS resolution from a test pod, ingress serving, metrics flowing (<code>kubectl top nodes</code>) — the <a href="https://www.devopsenginer.com/blog/kubernetes-observability-guide-2026">observability guide</a> lists the signals worth watching in the hours after.</li>
<li>One real deployment: roll a canary workload, confirm scheduling, networking and storage attach all behave.</li>
<li>Add-on upgrades you deferred: now is when ingress, cert-manager and friends get their matching bumps, one at a time, while the window is still staffed.</li>
</ol>
<p>Then leave the cluster alone for a soak period before starting the <em>next</em> minor version. One version, one verification, one soak — the rhythm that keeps a three-version catch-up from becoming a single three-version mystery.</p>
<h2>Managed vs self-managed: what actually differs</h2>
<p>The order and the rules are identical; only the hands change. Managed services move the control plane for you and offer node-group rotation with surge built in — your job concentrates on Phase 1 (API removals, add-on compatibility) and Phase 4 (verification), which is honestly where the risk always lived. Self-managed adds the kubeadm mechanics and etcd backup duty. In both worlds, the teams that get hurt are hurt by preparation gaps, not by the upgrade command itself.</p>
<h2>Common mistakes</h2>
<ol>
<li><strong>Skipping the removed-API scan.</strong> Everything works until the first redeploy against a removed API — during a different incident, naturally.</li>
<li><strong>Trying to jump two minor versions.</strong> The control plane refuses or, worse, half-applies. One step at a time is not a suggestion.</li>
<li><strong>Zero surge on node rotation.</strong> Drained workloads with nowhere to go; Pending pods; an upgrade window that ends in a rollback of the schedule rather than the version.</li>
<li><strong>No PDBs on important workloads.</strong> Drain takes them to zero, voluntarily, as designed — the design just never got told they mattered.</li>
<li><strong>Upgrading an unhealthy cluster.</strong> Pre-existing node or control-plane faults become upgrade casualties and misdirect the whole investigation.</li>
<li><strong>Forgetting the add-ons.</strong> Kubernetes upgrades fine; the two-year-old ingress controller doesn't. Inventory includes everything with a version number, not just Kubernetes.</li>
</ol>
<h2>FAQ</h2>
<p><strong>How often should we upgrade?</strong>
Often enough to never be more than one minor version behind the oldest supported release — in practice, most teams settle into upgrading every 2–4 months, which keeps each event small. The cost of upgrading grows with the distance jumped; frequency is the discount.</p>
<p><strong>Can we skip a version on managed Kubernetes?</strong>
No — the provider performs the same one-minor-at-a-time steps; the console just makes them look like one action. Node groups likewise step through versions.</p>
<p><strong>What if something breaks mid-upgrade?</strong>
For nodes: stop the rotation, keep workloads on healthy nodes, fix forward or replace the bad node. For the control plane: there is no downgrade, which is why the pre-upgrade etcd snapshot (self-managed) or your Git-backed workload state (everywhere) is the real rollback plan.</p>
<p><strong>Do workloads need downtime?</strong>
Properly built ones don't: multiple replicas, PDBs, surge capacity and rolling drains mean pods move, not stop. Single-replica workloads without PDBs will have a gap during their node's drain — the upgrade is merely revealing a property the workload always had. The <a href="https://www.devopsenginer.com/blog/kubernetes-deployment-strategies-2025-complete-guide">deployment strategies guide</a> covers building workloads that survive exactly this.</p>
<h2>Conclusion</h2>
<p>A Kubernetes upgrade is a checklist wearing a scary costume: scan for removed APIs, back up, control plane one minor at a time, nodes rotated with surge and PDBs respected, then verify like a sceptic. Run it a few times a year and it shrinks to what it should be — routine maintenance with a beginning, a middle, and an end. Let it slide for a year, and the same work returns wearing its real costume: a project, with a deadline set by an end-of-life date.</p>
<p><em>The free <a href="https://www.devopsenginer.com">Kubernetes Production Checklist</a> (via the newsletter on the homepage) includes the upgrade pre-flight items — health checks, backup verification and disruption budgets — alongside the rest of the production readiness checks.</em></p>
]]></content:encoded></item><item><title><![CDATA[Kubernetes Storage in 2026: PersistentVolumes, StorageClasses and StatefulSets Without the Fear]]></title><description><![CDATA[TL;DR: Kubernetes storage confuses people because it splits one idea — "my pod needs a disk" — into three objects. A PersistentVolumeClaim (PVC) is the…]]></description><link>https://devopsenginer.hashnode.dev/kubernetes-storage-in-2026-persistentvolumes-storageclasses-and-statefulsets-without-the-fear</link><guid isPermaLink="true">https://devopsenginer.hashnode.dev/kubernetes-storage-in-2026-persistentvolumes-storageclasses-and-statefulsets-without-the-fear</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[Devops]]></category><category><![CDATA[storage]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[softverseAdmin]]></dc:creator><pubDate>Sat, 31 Oct 2026 07:44:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae4b3ab7a8fca3c7281d4ef/9ee6aa6f-b358-4fe5-876e-7f642e6701ac.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p><strong>TL;DR:</strong> Kubernetes storage confuses people because it splits one idea — "my pod needs a disk" — into three objects. A PersistentVolumeClaim (PVC) is the pod's request: "I need 10Gi, writable by one node." A PersistentVolume (PV) is the actual disk that satisfies it. A StorageClass is the recipe that creates those disks on demand, with a chosen type, reclaim behaviour and expansion policy. Stateless apps should stay on Deployments with no volumes at all. Apps that genuinely own data — databases, queues, anything whose identity is its disk — belong on StatefulSets, which give each replica a stable name, a stable network identity and its own claim that survives rescheduling. Most storage incidents come from three places: the wrong reclaim policy deleting data, a volume bound to one availability zone while the pod reschedules into another, and nobody testing a restore until the day it matters.</p>
</blockquote>
<p><em>Originally published on <a href="https://www.devopsenginer.com/blog/kubernetes-storage-guide-2026">DevOps Enginer</a>.</em></p>
<p>Pods are cattle; disks are pets. Everything awkward about Kubernetes storage lives in that sentence. This guide walks the moving parts in the order a request flows through them, then covers StatefulSets, expansion, snapshots, and the failure modes that actually bite.</p>
<h2>The three objects, in the order they meet</h2>
<p>When a pod declares it needs storage, this is the chain:</p>
<ol>
<li><strong>PersistentVolumeClaim</strong> — the request, written by you (or generated by a StatefulSet). It asks for a size, an access mode, and optionally a StorageClass.</li>
<li><strong>StorageClass</strong> — the template. It names the provisioner (your cloud's disk service, a CSI driver, a local provisioner), the parameters (disk type, IOPS tier), the reclaim policy, and whether volumes can be expanded later.</li>
<li><strong>PersistentVolume</strong> — the reality. With dynamic provisioning (the normal case on any cloud cluster), the StorageClass's provisioner creates a real disk and a PV object representing it, bound one-to-one to your claim.</li>
</ol>
<p>You almost never create PVs by hand anymore. You write PVCs, you choose (or set a default) StorageClass, and provisioning does the rest. If a claim sits in <code>Pending</code>, the conversation between those three objects broke somewhere — usually the class doesn't exist, the provisioner can't create disks in that zone, or quota ran out. <code>kubectl describe pvc &lt;name&gt;</code> prints the events that tell you which.</p>
<h2>Access modes: the line that decides your architecture</h2>
<p>Every claim declares how the volume may be mounted, and this choice constrains everything downstream:</p>
<ul>
<li><strong>ReadWriteOnce (RWO)</strong> — one node at a time. The default for cloud block disks (EBS, Azure Disk, GCE PD). Fine for a database with one writer; it also means a rolling update can deadlock if the new pod lands on a different node while the old pod still holds the volume — the classic multi-attach error.</li>
<li><strong>ReadWriteMany (RWX)</strong> — many nodes at once. Requires a shared filesystem (NFS, EFS, Azure Files, Filestore). Needed when several pods across nodes must see the same files; you pay for it in performance characteristics and complexity.</li>
<li><strong>ReadWriteOncePod (RWOP)</strong> — one <em>pod</em>, not just one node (Kubernetes 1.29+ for CSI volumes). The stricter guarantee single-writer workloads actually wanted.</li>
</ul>
<p>Read the mode before you design around a volume. Half of all "Kubernetes storage is broken" stories are a workload assuming RWX semantics on an RWO disk.</p>
<h2>StorageClasses: the decisions hiding in the defaults</h2>
<p>One cluster usually offers several classes — a fast SSD class, a balanced class, maybe an HDD class. The fields that matter most are the quiet ones:</p>
<pre><code class="language-yaml">apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: fast-retain
provisioner: ebs.csi.aws.com        # example: AWS EBS CSI driver
parameters:
  type: gp3
reclaimPolicy: Retain               # not Delete — see below
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
</code></pre>
<ul>
<li><strong><code>reclaimPolicy</code></strong> decides what happens to the disk when the PVC is deleted. <code>Delete</code> destroys the underlying volume — convenient for throwaway environments, catastrophic for a production database whose claim gets deleted by a namespace cleanup or a careless Helm uninstall. For anything whose data matters: <code>Retain</code>. Retain leaves the PV in a Released state; the data survives until a human deliberately deals with it.</li>
<li><strong><code>volumeBindingMode: WaitForFirstConsumer</code></strong> delays provisioning until a pod actually schedules. Without it, a disk can be created in zone A before the scheduler places the pod in zone B — and a zonal disk can never attach across zones, so the pod is stuck forever. This single field prevents one of the most common storage dead-ends on multi-zone clusters.</li>
<li><strong><code>allowVolumeExpansion: true</code></strong> is only settable at class creation in effect — you cannot retroactively make a non-expandable class expandable for existing volumes in most setups. Set it now, thank yourself later.</li>
</ul>
<p>Check what your cluster defaults to before writing a single claim:</p>
<pre><code class="language-bash">kubectl get storageclass
kubectl describe pvc &lt;claim&gt; -n &lt;namespace&gt;
kubectl get pv
</code></pre>
<h2>StatefulSets: when the pod's identity IS the disk</h2>
<p>Deployments treat replicas as interchangeable — perfect for stateless services, wrong for a database cluster where <code>db-0</code>, <code>db-1</code> and <code>db-2</code> are different members with different data. StatefulSets exist for those workloads and give three guarantees Deployments don't:</p>
<ul>
<li><strong>Stable identity:</strong> pods are named <code>name-0</code>, <code>name-1</code>, … and keep that name across restarts, with a matching stable DNS entry per pod via a headless Service.</li>
<li><strong>Per-replica storage:</strong> a <code>volumeClaimTemplates</code> block generates one PVC per replica (<code>data-db-0</code>, <code>data-db-1</code>, …). When a pod is deleted and recreated, it reattaches to <em>its</em> claim — the data follows the identity, not the node.</li>
<li><strong>Ordered behaviour:</strong> replicas start and stop in order, which clustered systems that elect leaders and replicate in sequence genuinely need.</li>
</ul>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: db
spec:
  serviceName: db-headless
  replicas: 3
  selector:
    matchLabels: { app: db }
  template:
    metadata:
      labels: { app: db }
    spec:
      containers:
        - name: db
          image: postgres:16
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: fast-retain
        resources:
          requests:
            storage: 20Gi
</code></pre>
<p>Two operational truths about StatefulSet claims. First, <strong>scaling down does not delete the claims</strong> — scale from 3 back to 5 later and the old replicas reattach to their existing data. That is deliberate and usually what you want; it also means forgotten claims quietly keep disks (and bills) alive, which is why storage belongs in the <a href="https://www.devopsenginer.com/blog/kubernetes-cost-optimization-guide-2026">cost optimization</a> review. Second, deleting a StatefulSet leaves its PVCs behind too — cleanup is a separate, explicit act. Annoying on a tidy-up day; a lifesaver on a bad day.</p>
<p>And the honest question to ask first: does this workload need to be stateful <em>in Kubernetes at all</em>? A managed database service outside the cluster is often the right answer for small teams. Run state in-cluster when you have a real reason — latency, cost at scale, data locality — not by default. If you do run it, the <a href="https://www.devopsenginer.com/blog/kubernetes-deployment-strategies-2025-complete-guide">deployment strategies guide</a> covers how updates roll for these workloads, and it is not "like a Deployment."</p>
<h2>Growing and protecting volumes</h2>
<p><strong>Expansion</strong> is the happy path, when it was prepared: with <code>allowVolumeExpansion: true</code> on the class, you edit the PVC's requested size upward and the provisioner grows the disk; most filesystems expand online once the pod restarts or remounts. Shrinking is not supported — a smaller disk means a new volume and a data migration, full stop.</p>
<p><strong>Snapshots</strong> are the backup primitive: a <code>VolumeSnapshot</code> asks the CSI driver for a point-in-time copy, and a new PVC can be created <em>from</em> a snapshot to restore or clone. Combined with a schedule (via your backup tooling or a CronJob-driven process), this is how in-cluster state gets real backups. Two warnings from experience-shaped convention:</p>
<ul>
<li>A snapshot is not a backup strategy until a <strong>restore has been tested</strong>. Untested restores fail in creative ways: wrong class, wrong zone, permissions, application-level inconsistency.</li>
<li>For databases, storage-level snapshots are crash-consistent at best unless the application quiesces writes. Where correctness matters, pair snapshots with the database's own backup tooling.</li>
</ul>
<p><strong>Security</strong> applies to disks too: volume contents inherit your <a href="https://www.devopsenginer.com/blog/kubernetes-security-basics-guide-2026">RBAC and Pod Security</a> posture — whoever can create a PVC from a snapshot can read the data in it, so snapshot permissions deserve the same least-privilege care as the volumes themselves.</p>
<h2>Common mistakes</h2>
<ol>
<li><strong><code>reclaimPolicy: Delete</code> on data that matters.</strong> The claim gets deleted — by a person, a script, a namespace teardown — and the disk goes with it. Retain for production state, always.</li>
<li><strong>No <code>WaitForFirstConsumer</code> on multi-zone clusters.</strong> Disks provisioned in the wrong zone; pods Pending forever with attach errors that look like scheduler problems.</li>
<li><strong>Assuming RWX from an RWO disk.</strong> Multi-attach errors during rollouts, or worse, silent assumptions in the app design that only surface under failover.</li>
<li><strong>Forgetting StatefulSet claims on cleanup.</strong> Orphaned PVs holding real disks for months — visible in the bill, invisible in <code>kubectl get pods</code>.</li>
<li><strong>Snapshots nobody has restored from.</strong> A backup you have never tested is a hypothesis. Schedule a restore drill like you schedule the backup.</li>
<li><strong>Running stateful workloads on Deployments with a shared claim.</strong> Losing stable identity and per-replica storage is how databases end up with two writers and one corrupted dataset.</li>
</ol>
<h2>FAQ</h2>
<p><strong>Should my web app have a PersistentVolume?</strong>
Almost never. If the app is stateless, uploads and user files belong in object storage (S3/GCS/Azure Blob) or a managed service, not a pod-local disk. PVCs are for workloads whose data is the workload — databases, brokers, search indexes.</p>
<p><strong>What happens to my data if the pod is deleted?</strong>
With a PVC, nothing — the volume outlives the pod and reattaches to its replacement. With no volume (emptyDir or the container filesystem), everything is gone with the pod. That distinction is the entire subject of this article.</p>
<p><strong>Can I move a volume to another cluster?</strong>
Not by copying YAML. The path is snapshot/restore (CSI snapshots, or backup tools built on them) or application-level replication/export. Plan migrations around the data tooling, not the manifests.</p>
<p><strong>Why is my PVC stuck in Pending?</strong>
Read its events: <code>kubectl describe pvc</code>. The usual causes are a StorageClass that doesn't exist, a provisioner that can't create volumes in the needed zone, exhausted quota, or <code>WaitForFirstConsumer</code> waiting (correctly) for a pod that itself can't schedule — in which case the pod's events, covered in the <a href="https://www.devopsenginer.com/blog/kubernetes-troubleshooting-guide-2026">troubleshooting guide</a>, hold the real reason.</p>
<h2>Conclusion</h2>
<p>Kubernetes storage stops being scary when you keep the chain straight: claims ask, classes define, volumes provide — and StatefulSets bind identity to data for the workloads that need it. Choose the access mode honestly, set Retain and WaitForFirstConsumer before they save you, grow volumes instead of replacing them, and test restores before you need them. Disks are pets; treat them with the deliberate care pets require, and they will stop biting.</p>
<p><em>The free <a href="https://www.devopsenginer.com">Kubernetes Production Checklist</a> (via the newsletter on the homepage) includes the storage pre-flight items — reclaim policies, backup tests and disruption budgets — alongside the rest of the production readiness checks.</em></p>
]]></content:encoded></item><item><title><![CDATA[Kubernetes Security Basics in 2026: RBAC, Network Policies and Pod Security That Actually Get Used]]></title><description><![CDATA[TL;DR: A default Kubernetes cluster is far more open than most teams realise: any pod can talk to any other pod, many workloads run with more API…]]></description><link>https://devopsenginer.hashnode.dev/kubernetes-security-basics-in-2026-rbac-network-policies-and-pod-security-that-actually-get-used</link><guid isPermaLink="true">https://devopsenginer.hashnode.dev/kubernetes-security-basics-in-2026-rbac-network-policies-and-pod-security-that-actually-get-used</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Security]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[softverseAdmin]]></dc:creator><pubDate>Sat, 31 Oct 2026 07:18:03 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae4b3ab7a8fca3c7281d4ef/56d4bb95-13ab-497b-8ca2-f2ff9b9c8d0a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p><strong>TL;DR:</strong> A default Kubernetes cluster is far more open than most teams realise: any pod can talk to any other pod, many workloads run with more API permission than they need, and "the app works" often quietly means "the app runs as root with a token mounted it never uses." You do not need a security platform to fix the worst of it. Three controls close most of the real-world holes: RBAC scoped to what each identity actually does, NetworkPolicies that end the flat any-to-any network, and Pod Security Standards that stop privileged, root-running workloads at admission time. Add disciplined secrets handling and image hygiene, and a default cluster becomes a defensible one — with changes your team can roll out namespace by namespace, without a big-bang migration.</p>
</blockquote>
<p><em>Originally published on <a href="https://www.devopsenginer.com/blog/kubernetes-security-basics-guide-2026">DevOps Enginer</a>.</em></p>
<p>Kubernetes security advice tends to arrive as a 200-item checklist, which is why so much of it never gets implemented. This guide takes the opposite approach: the small set of controls that remove the most risk per hour of effort, in the order I would apply them to a real cluster, with the manifests that matter.</p>
<h2>Start from the uncomfortable defaults</h2>
<p>Before the controls, be honest about what a fresh cluster permits:</p>
<ul>
<li><strong>The network is flat.</strong> With no NetworkPolicies, every pod can open a connection to every other pod and service in the cluster. One compromised frontend can reach your database directly.</li>
<li><strong>Every pod gets a service account token</strong> mounted by default (unless you turn it off), whether or not the application ever calls the Kubernetes API.</li>
<li><strong>Containers frequently run as root</strong>, with the default capabilities, on images pulled by a mutable tag like <code>latest</code>.</li>
<li><strong>RBAC is often bypassed in practice</strong> — not by attackers first, but by teams handing out <code>cluster-admin</code> because a narrower role "took too long to debug."</li>
</ul>
<p>None of this is exotic. Attackers do not need zero-days when the defaults hand them lateral movement and credentials. The good news: each default above has a specific, boring fix.</p>
<h2>Control 1: RBAC — least privilege for humans and workloads</h2>
<p>RBAC answers one question: <em>who is allowed to do what to which resources?</em> Two identities matter — the people (and CI systems) who operate the cluster, and the service accounts workloads run as.</p>
<p><strong>For workloads:</strong> give each application its own ServiceAccount, and set <code>automountServiceAccountToken: false</code> unless the app genuinely calls the API. Most applications never do — the mounted token is pure attack surface: steal the pod, steal a credential.</p>
<pre><code class="language-yaml">apiVersion: v1
kind: ServiceAccount
metadata:
  name: api
automountServiceAccountToken: false
</code></pre>
<p>When an app <em>does</em> need API access, bind a Role that names the exact verbs and resources — <code>get</code> and <code>list</code> on <code>configmaps</code> in one namespace is a role; <code>*</code> on <code>*</code> is an incident waiting for a date:</p>
<pre><code class="language-yaml">apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: config-reader
  namespace: production
rules:
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "list", "watch"]
</code></pre>
<p><strong>For humans and CI:</strong> scope roles per namespace, treat <code>cluster-admin</code> like root on a production database — a named, audited exception, not a team default — and audit what you have. Two commands tell you most of the story:</p>
<pre><code class="language-bash">kubectl get clusterrolebindings -o wide
kubectl auth can-i --list --as=system:serviceaccount:production:api -n production
</code></pre>
<p>The second is the one to run for every workload identity: it prints exactly what that token can do, in plain text. If the list surprises you, that is the finding. This is the same least-privilege discipline as IAM on the cloud side — my <a href="https://www.devopsenginer.com/blog/aws-iam-least-privilege-guide-2026">AWS IAM least privilege guide</a> works through the identical thinking for AWS policies.</p>
<h2>Control 2: NetworkPolicies — end the flat network</h2>
<p>A NetworkPolicy is a firewall rule for pods: it selects pods and declares which traffic may reach them (ingress) and where they may connect (egress). Policies are enforced by your CNI plugin — an important caveat: if your CNI does not enforce NetworkPolicy (the basic kubenet setup is the classic example), the objects exist and silently do nothing. Cilium, Calico and the major cloud CNIs enforce them; verify yours before trusting the YAML.</p>
<p>Roll out in this order, namespace by namespace:</p>
<p><strong>Step 1 — default deny.</strong> One policy that selects all pods in the namespace and allows nothing changes the posture from "open unless closed" to "closed unless opened":</p>
<pre><code class="language-yaml">apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny
  namespace: production
spec:
  podSelector: {}
  policyTypes: ["Ingress", "Egress"]
</code></pre>
<p><strong>Step 2 — allow what the app actually needs.</strong> Then add narrow allowances: the frontend may reach the API on its port; the API may reach the database on 5432 and DNS on 53 (forgetting DNS egress is the classic self-inflicted outage — allow it explicitly):</p>
<pre><code class="language-yaml">apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-ingress
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: api
  policyTypes: ["Ingress"]
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: frontend
      ports:
        - port: 8080
</code></pre>
<p>Do this in a staging namespace first, watch what breaks, and promote the same policies with the application — if you run GitOps, they belong in the same repository as the workloads, reviewed like any other change (my <a href="https://www.devopsenginer.com/blog/argo-cd-tutorial-gitops-2026">Argo CD tutorial</a> covers that workflow). A compromised pod inside a default-deny namespace is a contained problem; in a flat network it is a cluster tour.</p>
<h2>Control 3: Pod Security Standards — stop bad workloads at the door</h2>
<p>RBAC limits identities and NetworkPolicies limit traffic, but neither stops a deployment that asks to run privileged, mount the host filesystem, or run as root. That is admission-time work, and Kubernetes has it built in: <strong>Pod Security Standards</strong>, enforced through Pod Security Admission with three profiles:</p>
<ul>
<li><strong>Privileged</strong> — effectively no restrictions. For system components that genuinely need host access, and nothing else.</li>
<li><strong>Baseline</strong> — blocks the known privilege escalations (privileged containers, host namespaces, dangerous capabilities) while staying compatible with most typical workloads.</li>
<li><strong>Restricted</strong> — the hardened target: no root, no privilege escalation, drop all capabilities, seccomp on. What user-facing workloads should aim for.</li>
</ul>
<p>Enforcement is a namespace label — no webhook to install:</p>
<pre><code class="language-bash">kubectl label namespace production \
  pod-security.kubernetes.io/enforce=baseline \
  pod-security.kubernetes.io/audit=restricted \
  pod-security.kubernetes.io/warn=restricted
</code></pre>
<p>That combination is the pragmatic rollout: <strong>enforce Baseline today</strong> (violating pods are rejected), while <strong>audit and warn on Restricted</strong> shows you — in API warnings and audit logs — exactly which workloads would fail the stricter profile, so you can fix them deliberately instead of breaking production on day one. At the workload level, the settings Restricted asks for are small and belong in every Deployment template:</p>
<pre><code class="language-yaml">securityContext:
  runAsNonRoot: true
  runAsUser: 10001
  allowPrivilegeEscalation: false
  capabilities:
    drop: ["ALL"]
  seccompProfile:
    type: RuntimeDefault
</code></pre>
<p>The honest blocker is usually the image: an app whose image only runs as root needs a Dockerfile fix, not an exception. Treat exceptions as named, expiring decisions — a namespace labelled back to Privileged "temporarily" has a way of becoming permanent.</p>
<h2>Secrets: the rules that prevent the embarrassing incidents</h2>
<p>Kubernetes Secrets are base64-encoded, not encrypted, and anyone with the right RBAC verb can read them — which loops straight back to Control 1. The working rules:</p>
<ul>
<li><strong>Encryption at rest on</strong> for etcd (a cluster-level setting; managed clusters differ in defaults — verify yours, don't assume).</li>
<li><strong>Never bake secrets into images or commit them to Git.</strong> Use environment injection or mounted volumes from a secrets source, and scan repositories in CI so a committed credential fails the pipeline — that scanning habit is part of the wider pipeline discipline in my <a href="https://www.devopsenginer.com/blog/devsecops-checklist-securing-pipeline-2025">DevSecOps checklist</a>.</li>
<li><strong>Prefer an external secrets store</strong> (cloud secret managers, or an external-secrets operator syncing from one) as the source of truth, so rotation and audit live in one place instead of being scattered across namespaces.</li>
<li><strong>Rotate like you mean it.</strong> A secret that has never been rotated should be assumed permanent — and anything permanent eventually leaks.</li>
</ul>
<h2>Image hygiene: know what you are actually running</h2>
<p>The last basic: the artefact you deploy. Four habits cover most of it:</p>
<ol>
<li><strong>Pin by digest, or at least by specific version tag.</strong> <code>latest</code> means "whatever exists at pull time," which is neither reproducible nor auditable.</li>
<li><strong>Scan in CI and watch the results.</strong> A scanner that reports into a void changes nothing; gate on fixable critical vulnerabilities for internet-facing workloads.</li>
<li><strong>Use minimal bases.</strong> A small base image is fewer packages to patch and fewer surprises — the same discipline as my <a href="https://www.devopsenginer.com/blog/how-to-optimize-docker-images-production-2025">Docker image optimization guide</a>, where smaller and more secure turned out to be the same work.</li>
<li><strong>Pull from registries you control or trust</strong>, with image pull policies that do not silently fall back to stale local copies.</li>
</ol>
<h2>Common mistakes</h2>
<ol>
<li><strong>NetworkPolicies on a CNI that doesn't enforce them.</strong> The YAML applies cleanly, everyone feels safer, nothing is filtered. Verify enforcement with a test connection that <em>should</em> fail.</li>
<li><strong><code>cluster-admin</code> as a convenience.</strong> Every standing cluster-admin binding is a standing invitation. Scope it, name it, review it.</li>
<li><strong>Default-deny without DNS egress.</strong> The policies "work" and every lookup fails. Always allow UDP/TCP 53 to your DNS service explicitly.</li>
<li><strong>Enforcing Restricted on day one.</strong> Rejected pods across production, followed by a panicked rollback to no standards at all. Baseline-enforce + Restricted-audit gets you to the same place without the outage.</li>
<li><strong>Service account tokens mounted everywhere.</strong> One flag — <code>automountServiceAccountToken: false</code> — removes a credential from every pod that never needed it.</li>
<li><strong>Secrets in Git "just for staging."</strong> Staging secrets have a habit of being production secrets. Scan, block, rotate if it already happened.</li>
</ol>
<h2>FAQ</h2>
<p><strong>Where do I start if the cluster has none of this?</strong>
In this order: inventory RBAC (<code>clusterrolebindings</code> and <code>can-i --list</code> for your workloads), turn off automatic token mounting where the API isn't used, then default-deny NetworkPolicies in one staging namespace, and Baseline Pod Security in audit mode cluster-wide. Each step is an afternoon, not a quarter.</p>
<p><strong>Do NetworkPolicies replace a service mesh or an external firewall?</strong>
No — they cover pod-to-pod traffic inside the cluster at L3/L4. Egress to the internet, L7 rules and identity-based policy are other layers' jobs. NetworkPolicies are the floor, not the whole building.</p>
<p><strong>Managed Kubernetes (EKS/GKE/AKS) — is this handled for me?</strong>
Partially. The provider secures the control plane and offers defaults, but RBAC bindings, NetworkPolicies, Pod Security labels and your images remain yours. The shared-responsibility line runs right through the middle of this article.</p>
<p><strong>How do I prove it's working?</strong>
Test negatively: from a frontend pod, try to reach the database and watch it fail; deploy a privileged test pod in a Baseline namespace and watch it get rejected. A control you have never seen block anything is a hope, not a control. For the wider detection picture, see the <a href="https://www.devopsenginer.com/blog/kubernetes-observability-guide-2026">Kubernetes observability guide</a>.</p>
<h2>Conclusion</h2>
<p>Kubernetes security basics are not a product you buy; they are five defaults you deliberately change: scoped RBAC, no free tokens, a network that denies by default, admission that refuses privileged workloads, and secrets and images handled like they matter. Roll them out namespace by namespace, enforce gradually, and test that each control actually blocks something. A cluster secured that way will not make headlines — which is precisely the point.</p>
<p><em>The free <a href="https://www.devopsenginer.com">Kubernetes Production Checklist</a> (via the newsletter on the homepage) includes the security pre-flight items — RBAC review, token mounting, disruption budgets and rollback paths — alongside the rest of the production readiness checks.</em></p>
]]></content:encoded></item><item><title><![CDATA[Kubernetes Autoscaling in 2026: HPA, VPA, Cluster Autoscaling and KEDA — What Each One Actually Does]]></title><description><![CDATA[TL;DR: Kubernetes has four different autoscaling layers and most problems come from expecting one of them to do another one's job. The Horizontal Pod…]]></description><link>https://devopsenginer.hashnode.dev/kubernetes-autoscaling-in-2026-hpa-vpa-cluster-autoscaling-and-keda-what-each-one-actually-does</link><guid isPermaLink="true">https://devopsenginer.hashnode.dev/kubernetes-autoscaling-in-2026-hpa-vpa-cluster-autoscaling-and-keda-what-each-one-actually-does</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[Devops]]></category><category><![CDATA[autoscaling]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[softverseAdmin]]></dc:creator><pubDate>Sat, 31 Oct 2026 06:37:58 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae4b3ab7a8fca3c7281d4ef/200537ef-af52-41cf-8425-4e2a3a7f2918.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p><strong>TL;DR:</strong> Kubernetes has four different autoscaling layers and most problems come from expecting one of them to do another one's job. The Horizontal Pod Autoscaler (HPA) changes how many pods you run. The Vertical Pod Autoscaler (VPA) changes how big each pod is. Cluster autoscaling (Cluster Autoscaler or Karpenter) changes how many nodes exist for pods to land on. KEDA scales on events — queue depth, Kafka lag, cron schedules — instead of CPU. In production you usually need at least two of these together: HPA for replicas plus node autoscaling underneath, or your new pods sit Pending forever. And none of them work without honest resource requests, because every percentage-based decision is calculated from the requests you set.</p>
</blockquote>
<p><em>Originally published on <a href="https://www.devopsenginer.com/blog/kubernetes-autoscaling-guide-2026">DevOps Enginer</a>.</em></p>
<p>Getting autoscaling right is the difference between a cluster that absorbs a traffic spike quietly and one that pages you at 3 a.m. while pods pile up unscheduled. This guide walks each layer in the order traffic actually flows through your cluster, with the configuration that matters and the mistakes that quietly break each one.</p>
<h2>Why autoscaling fails quietly</h2>
<p>Autoscaling rarely fails with an error. It fails silently, in three classic ways:</p>
<ol>
<li><strong>Pods scale, nodes don't.</strong> HPA adds replicas, but there is no room for them. They sit in <code>Pending</code> while the load that triggered them keeps growing.</li>
<li><strong>Nodes scale, pods can't use them.</strong> The cluster adds capacity, but pod requests are set so high that each node fits only one or two pods — you pay for nodes that are mostly reserved air.</li>
<li><strong>The metric lies.</strong> HPA targets 70% CPU, but the app is memory-bound, or requests were set years ago and no longer resemble reality. The scaler makes confident decisions from bad inputs.</li>
</ol>
<p>Every layer below exists to close one of these gaps. If you haven't set requests and limits deliberately yet, start with my <a href="https://www.devopsenginer.com/blog/kubernetes-resource-requests-limits-guide-2026">guide to Kubernetes resource requests and limits</a> — autoscaling built on guessed requests just automates the guessing.</p>
<h2>Layer 1: Horizontal Pod Autoscaler — more copies of the pod</h2>
<p>HPA is the layer most teams already have. It watches a metric — by default CPU or memory utilisation relative to pod requests — and adjusts the replica count of a Deployment, StatefulSet or similar workload between a minimum and a maximum.</p>
<p>A production-shaped HPA looks like this:</p>
<pre><code class="language-yaml">apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 65
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - type: Percent
          value: 100
          periodSeconds: 30
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - type: Pods
          value: 1
          periodSeconds: 60
</code></pre>
<p>Three details in that manifest do most of the work:</p>
<ul>
<li><strong><code>minReplicas: 3</code>, not 1.</strong> A single replica is a single point of failure and a slow cold start on the first spike. Three gives the scaler something to work with and survives a node loss.</li>
<li><strong>The <code>behavior</code> block.</strong> Without it, scale-down is eager: load dips, replicas vanish, load returns, pods churn. A 300-second stabilisation window on scale-down and one pod per minute keeps the replica count calm instead of flapping. Scale-up stays aggressive — when you need capacity, you need it now.</li>
<li><strong>The target is utilisation of requests.</strong> 65% of a 500m CPU request is a real number; 65% of a request nobody has reviewed since 2023 is not. This is why the requests guide matters more than the HPA manifest itself.</li>
</ul>
<p>Prerequisites people forget: HPA needs metrics. Resource metrics come from the <strong>metrics-server</strong> add-on, which many clusters install by default but minimal or self-managed clusters may not have. If <code>kubectl top pods</code> returns nothing, HPA is flying blind and will do nothing — check that first before debugging the HPA object itself. Custom and external metrics (requests per second, queue length) need an adapter such as the Prometheus Adapter, which is where KEDA, below, often becomes the simpler path.</p>
<h2>Layer 2: Vertical Pod Autoscaler — the right size for each pod</h2>
<p>VPA attacks a different problem: the pod itself is the wrong size. It observes actual usage over time and recommends — or automatically applies — better requests.</p>
<p>VPA runs in three modes, and choosing the mode is the whole decision:</p>
<ul>
<li><strong>Off</strong> — it only shows recommendations (<code>kubectl describe vpa</code> becomes a right-sizing report). Safe everywhere, and honestly the mode most teams should start with for a few weeks.</li>
<li><strong>Initial</strong> — requests are set when a pod is created and never changed afterwards. Low disruption, slow to adapt.</li>
<li><strong>Auto</strong> — VPA evicts and recreates pods to apply new requests. Correct sizes, but your pods restart when VPA decides — never point Auto mode at something that cannot tolerate restarts.</li>
</ul>
<p><strong>The classic conflict:</strong> never let VPA in Auto mode and HPA fight over the same resource. If HPA scales on CPU utilisation while VPA keeps resizing the requests that utilisation is calculated from, the two controllers chase each other. The standard peace treaty: HPA on CPU (or on custom metrics), VPA in Off mode as an advisor — or VPA adjusting memory only while HPA watches CPU. My <a href="https://www.devopsenginer.com/blog/kubernetes-cost-optimization-guide-2026">Kubernetes cost optimization guide</a> covers why this right-sizing loop is where most of the money is: oversized requests are a silent tax on every node you run.</p>
<h2>Layer 3: Node autoscaling — room for the pods to land on</h2>
<p>HPA can decide it wants 20 replicas, but if the cluster has capacity for 8, the other 12 do not exist yet — they are <code>Pending</code>. Node autoscaling is the layer that notices unschedulable pods and adds machines, and later removes empty ones.</p>
<p>You have two mainstream choices:</p>
<p><strong>Cluster Autoscaler</strong> is the older, conservative option. It watches for pending pods, asks the cloud provider for another node from an existing node group or instance pool, and scales groups down when nodes sit under-utilised. It is predictable and battle-tested, and its weakness is rigidity: it scales within node groups <em>you</em> pre-defined, so the instance types and sizes are your homework.</p>
<p><strong>Karpenter</strong> (open source, originally built at AWS and now a CNCF project) takes the opposite approach: instead of scaling fixed groups, it watches pending pods, reads their actual requirements — CPU, memory, zones, labels, taints — and provisions a node shaped to fit them, often in under a minute. When nodes empty out, it consolidates: shuffles pods onto fewer, cheaper nodes and terminates the rest. The practical difference shows up exactly where Cluster Autoscaler is rigid — mixed workloads with very different shapes — and in consolidation, which steadily squeezes wasted capacity out of the cluster instead of only reacting to spikes.</p>
<p>Whichever you run, three settings decide whether it behaves:</p>
<ul>
<li><strong>PodDisruptionBudgets on important workloads.</strong> Node scale-down and consolidation drain nodes. A PDB is how a workload says "you may move me, but never take me below this many healthy replicas." Without PDBs, autoscaling will eventually evict something at a bad moment. (My <a href="https://www.devopsenginer.com/blog/kubernetes-deployment-strategies-2025-complete-guide">deployment strategies guide</a> covers PDBs alongside rolling updates.)</li>
<li><strong>Do-not-evict annotations for the truly pinned pods</strong> — the singleton, the job mid-run. Both controllers respect signals that a pod must not be moved; use them sparingly and deliberately.</li>
<li><strong>A floor under the cluster.</strong> Keep enough baseline capacity for your steady state. Autoscaling is for variance, not for running the cluster at zero slack and hoping provisioning is always fast.</li>
</ul>
<h2>Layer 4: KEDA — scaling on events, not CPU</h2>
<p>Some workloads have no meaningful CPU signal. A worker that drains a queue is idle at 2% CPU with 10,000 messages waiting; a report generator should exist twice a day and never at night. Scaling these on CPU is scaling on a lie.</p>
<p><strong>KEDA</strong> (Kubernetes Event-Driven Autoscaling, a CNCF graduated project) extends HPA rather than replacing it: it feeds external signals — SQS or Kafka backlog, RabbitMQ queue depth, database query results, cron schedules, Prometheus queries — into the scaling decision as metrics. Its two signature abilities:</p>
<ul>
<li><strong>Scale to zero.</strong> HPA's floor is 1 replica (well, <code>minReplicas</code> of 1 in practice). KEDA can take a workload to 0 when the queue is empty and bring it back when work arrives. For intermittent workers, this is the difference between paying for idle pods all month and paying for none.</li>
<li><strong>Scale on the signal that predicts the work.</strong> Queue depth rises <em>before</em> CPU does. Scaling on the cause instead of the symptom removes most of the lag that makes CPU-based scaling feel sluggish.</li>
</ul>
<p>A typical pattern: a <code>ScaledObject</code> declares the target deployment, the trigger (say, messages visible in a queue), and a threshold (one pod per 50 messages, between 0 and 15 replicas). KEDA maintains the underlying HPA for you. If you already run <a href="https://www.devopsenginer.com/blog/argo-cd-tutorial-gitops-2026">Argo CD</a>, ScaledObjects are just more YAML in Git — the same review-and-rollback story as everything else.</p>
<h2>Putting the layers together</h2>
<p>A sane default stack for a typical web service:</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Tool</th>
<th>Decision it makes</th>
</tr>
</thead>
<tbody><tr>
<td>Pods</td>
<td>HPA</td>
<td>How many replicas, from CPU/custom metrics</td>
</tr>
<tr>
<td>Pod size</td>
<td>VPA (Off mode)</td>
<td>Whether requests match reality — advisory</td>
</tr>
<tr>
<td>Nodes</td>
<td>Karpenter or Cluster Autoscaler</td>
<td>How many machines, from pending pods</td>
</tr>
<tr>
<td>Events</td>
<td>KEDA (where relevant)</td>
<td>Scaling workers/jobs on queues and schedules</td>
</tr>
</tbody></table>
<p>And the observability to prove it works: watch <code>Pending</code> pod count, HPA's current-vs-desired replicas, and node utilisation after consolidation. If you cannot see autoscaling deciding, you are trusting it blindly — the metrics to wire up are covered in my <a href="https://www.devopsenginer.com/blog/kubernetes-observability-guide-2026">Kubernetes observability guide</a>.</p>
<h2>Common mistakes</h2>
<ol>
<li><strong>Autoscaling on CPU for a memory-bound app.</strong> The metric must be the resource the workload actually runs out of — or a custom metric closer to the real demand, like requests per second.</li>
<li><strong>No metrics-server, no scaling.</strong> <code>kubectl top</code> returning nothing means HPA sees nothing. Verify the plumbing before tuning the policy.</li>
<li><strong>HPA + VPA Auto on the same resource.</strong> Two controllers, one variable, endless churn. Pick who owns CPU.</li>
<li><strong>maxReplicas as an afterthought.</strong> Too low and the spike wins; absurdly high and a runaway loop or a bad deploy can scale you into a very large cloud bill. The max is a budget decision, not a formality.</li>
<li><strong>Scaling pods without scaling nodes.</strong> The <code>Pending</code> pile-up. Every HPA rollout should be paired with the question: where do the new pods land?</li>
<li><strong>No PodDisruptionBudgets.</strong> Node consolidation and scale-down will eventually evict the wrong pod at the wrong time.</li>
<li><strong>Scale-down as eager as scale-up.</strong> Flapping replicas churn connections, caches and readiness. Stabilisation windows are free stability.</li>
</ol>
<h2>FAQ</h2>
<p><strong>Do I need all four layers?</strong>
No. Most services need HPA plus node autoscaling. Add VPA in advisory mode when you suspect requests are wrong (they usually are), and KEDA when a workload's real demand signal is a queue, a schedule or another external event rather than CPU.</p>
<p><strong>Karpenter or Cluster Autoscaler?</strong>
If you are starting fresh on AWS, Karpenter's just-in-time, right-shaped provisioning and consolidation usually win. Cluster Autoscaler remains a solid, predictable choice — especially outside AWS or where node groups are deliberately fixed for compliance reasons.</p>
<p><strong>Can KEDA replace HPA?</strong>
KEDA <em>uses</em> HPA under the hood for most triggers. What it replaces is your hand-rolled metrics plumbing, and it adds scale-to-zero, which plain HPA does not give you.</p>
<p><strong>Why are my pods Pending even though autoscaling is on?</strong>
Work through the layers: HPA may have scaled (check desired replicas), but if nodes are full and the node autoscaler cannot add capacity — quota limits, no matching node group, Karpenter constraints — pods wait. <code>kubectl describe pod</code> on a pending pod names the failing condition; my <a href="https://www.devopsenginer.com/blog/kubernetes-troubleshooting-guide-2026">Kubernetes troubleshooting guide</a> walks that diagnosis step by step.</p>
<h2>Conclusion</h2>
<p>Autoscaling is not one feature you turn on; it is four decisions stacked on top of each other — how many pods, how big each pod is, how many nodes exist, and which signal you trust. Get the requests honest first, pair every pod scaler with node capacity underneath it, protect workloads with disruption budgets, and scale event-driven work on the events themselves. Do that, and traffic spikes become a graph you glance at — not an incident you get paged for.</p>
<p><em>Running autoscaling in production? The <a href="https://www.devopsenginer.com">Kubernetes Production Checklist</a> (free PDF via the newsletter on the homepage) covers the pre-flight checks — requests, probes, disruption budgets and rollback paths — that make scaling safe to automate.</em></p>
]]></content:encoded></item><item><title><![CDATA[Argo Rollouts 2026: Progressive Delivery for Kubernetes — Canary, Blue-Green and Rollbacks That Happen by Themselves]]></title><description><![CDATA[TL;DR: A plain Kubernetes Deployment can roll an update out, but it cannot send 5% of traffic to the new version, watch the error rate, and roll itself…]]></description><link>https://devopsenginer.hashnode.dev/argo-rollouts-2026-progressive-delivery-for-kubernetes-canary-blue-green-and-rollbacks-that-happen-by-themselves</link><guid isPermaLink="true">https://devopsenginer.hashnode.dev/argo-rollouts-2026-progressive-delivery-for-kubernetes-canary-blue-green-and-rollbacks-that-happen-by-themselves</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[argo cd]]></category><category><![CDATA[gitops]]></category><category><![CDATA[Devops]]></category><category><![CDATA[ci-cd]]></category><dc:creator><![CDATA[softverseAdmin]]></dc:creator><pubDate>Sat, 31 Oct 2026 04:52:57 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae4b3ab7a8fca3c7281d4ef/be713cc4-6fb5-423f-ab17-08883e456ec6.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p><strong>TL;DR:</strong> A plain Kubernetes Deployment can roll an update out, but it cannot send 5% of traffic to the new version, watch the error rate, and roll itself back when the numbers turn bad. Argo Rollouts can. It replaces your Deployment with a Rollout resource that adds weighted canary steps, blue-green switching, metric-driven analysis, and automatic rollback — and it slots neatly next to Argo CD if you already run GitOps. This guide installs it, ships a first canary, wires metrics into the promotion decision, and covers the mistakes that bite.</p>
</blockquote>
<p><em>Originally published on <a href="https://www.devopsenginer.com/blog/argo-rollouts-progressive-delivery-2026">DevOps Enginer</a>.</em></p>
<p>My <a href="https://www.devopsenginer.com/blog/kubernetes-deployment-strategies-2025-complete-guide">Kubernetes deployment strategies guide</a> explains the patterns: rolling, blue-green, canary, recreate. Patterns are the easy half. The hard half is execution — who watches the new version while it serves its first real traffic, and who has the nerve (and the speed) to pull it back at 2 a.m. when the error graph twitches?</p>
<p>A stock Deployment answers "nobody, really." It replaces pods on a schedule of percentages of <em>replicas</em>, not traffic. It has no idea whether the new version is healthy in any sense that matters to users. Rollback is a human noticing, deciding, and typing.</p>
<p>Argo Rollouts is the controller that closes that gap. And if you already run Argo CD — I do, and my <a href="https://www.devopsenginer.com/blog/argo-cd-tutorial-gitops-2026">Argo CD tutorial</a> walks through it — Rollouts is from the same Argo family and feels like the missing half: Argo CD gets the right version <em>to</em> the cluster; Rollouts decides how that version <em>earns</em> the rest of the traffic.</p>
<h2>Why a plain Deployment is not enough</h2>
<p>Three limits show up the first time a release goes wrong:</p>
<ol>
<li><strong>Replica math is not traffic math.</strong> "25% of pods are new" does not mean 25% of users see the new version — behind a Service, it is roughly proportional, but you cannot set 1%, or 5%, or hold a step deliberately.</li>
<li><strong>There is no verify step.</strong> A Deployment promotes on pod readiness. A pod can be Ready and still return 500s, leak memory, or take 4 seconds per request. Readiness is not health.</li>
<li><strong>Rollback is manual and slow.</strong> <code>kubectl rollout undo</code> works — after a human has noticed, paged, decided, and typed. The blast radius grows the whole time.</li>
</ol>
<p>Progressive delivery is the discipline of fixing exactly these three: small traffic steps, automated verification between steps, and rollback that does not wait for a human. That is what Rollouts implements.</p>
<h2>What Argo Rollouts actually is</h2>
<p>Three pieces:</p>
<ul>
<li><strong>The Rollouts controller</strong> — a deployment in your cluster that watches Rollout resources and drives ReplicaSets, Services, and (optionally) your ingress or mesh.</li>
<li><strong>The Rollout resource</strong> — a near drop-in replacement for a Deployment. Same pod template, same selectors; the difference is the <code>strategy</code> block, which describes <em>how</em> versions rotate.</li>
<li><strong>The kubectl plugin</strong> — <code>kubectl argo rollouts …</code> for watching, promoting, pausing, and aborting rollouts from the terminal (and a web dashboard if you want one).</li>
</ul>
<p>One honest nuance up front: <strong>precise traffic percentages need a traffic router.</strong> With a plain Service, canary steps scale ReplicaSets proportionally, so weights are approximate (with 10 replicas, your steps are effectively 10% chunks). Integrate Rollouts with NGINX Ingress, ALB, Traefik, Istio, or another supported router and the weights become real traffic weights, independent of replica count. Replica-based canary is fine to learn on; traffic-based is what you want for user-facing services.</p>
<h2>Install Rollouts and the kubectl plugin</h2>
<p>The controller:</p>
<pre><code class="language-bash">kubectl create namespace argo-rollouts
kubectl apply -n argo-rollouts -f https://github.com/argoproj/argo-rollouts/releases/latest/download/install.yaml

# watch it come up
kubectl -n argo-rollouts get pods
</code></pre>
<p>The plugin (Linux example — grab the matching binary for your OS from the same releases page):</p>
<pre><code class="language-bash">curl -LO https://github.com/argoproj/argo-rollouts/releases/latest/download/kubectl-argo-rollouts-linux-amd64
chmod +x ./kubectl-argo-rollouts-linux-amd64
sudo mv ./kubectl-argo-rollouts-linux-amd64 /usr/local/bin/kubectl-argo-rollouts

kubectl argo rollouts version
</code></pre>
<p>Using <code>latest</code> in a learning cluster is fine. In production, pin the release version in the URL and manage the install through your GitOps repo like everything else — a controller that controls your releases should itself be released carefully.</p>
<h2>Your first canary: a Rollout with steps</h2>
<p>Take an ordinary Deployment, change <code>kind: Deployment</code> to <code>kind: Rollout</code> (and the apiVersion to <code>argoproj.io/v1alpha1</code>), and add a strategy. This example rolls a new version through 20% → pause for a human look → 50% → indefinite pause → 80% → full:</p>
<pre><code class="language-yaml">apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: shop-api
  namespace: production
spec:
  replicas: 10
  revisionHistoryLimit: 3
  selector:
    matchLabels:
      app: shop-api
  template:
    metadata:
      labels:
        app: shop-api
    spec:
      containers:
        - name: shop-api
          image: ghcr.io/example/shop-api:2.4.0
          ports:
            - containerPort: 8080
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080
  strategy:
    canary:
      steps:
        - setWeight: 20
        - pause: { duration: 5m }
        - setWeight: 50
        - pause: {}          # waits for a human promote
        - setWeight: 80
        - pause: { duration: 10m }
</code></pre>
<p>Apply it, trigger an update by changing the image tag, and watch:</p>
<pre><code class="language-bash">kubectl argo rollouts get rollout shop-api -n production --watch
</code></pre>
<p>You will see the step list with the current step highlighted — this little tree view is, honestly, half the reason people adopt Rollouts. The state of a release stops being something you reconstruct from three commands and becomes something you can <em>see</em>.</p>
<p>Promote past a manual pause, or bail out:</p>
<pre><code class="language-bash">kubectl argo rollouts promote rollout shop-api -n production
kubectl argo rollouts abort rollout shop-api -n production   # back to stable, now
kubectl argo rollouts undo rollout shop-api -n production     # roll back a finished one
</code></pre>
<h2>Let metrics decide: analysis runs</h2>
<p>Manual pauses are a fine start, but they still depend on a human watching a dashboard. The feature that turns Rollouts from "nicer rolling update" into progressive delivery is the <strong>AnalysisRun</strong>: during a canary step, Rollouts queries your metrics provider on an interval; if the numbers breach your conditions, it aborts and rolls back automatically.</p>
<p>A minimal success-rate check against Prometheus:</p>
<pre><code class="language-yaml">apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
  name: success-rate
  namespace: production
spec:
  args:
    - name: service-name
  metrics:
    - name: success-rate
      interval: 30s
      count: 10
      successCondition: result[0] &gt;= 0.95
      failureLimit: 2
      provider:
        prometheus:
          address: http://prometheus.monitoring.svc:9090
          query: |
            sum(rate(http_requests_total{service="{{ args.service-name }}",status!~"5.."}[5m]))
            /
            sum(rate(http_requests_total{service="{{ args.service-name }}"}[5m]))
</code></pre>
<p>Reference it from the Rollout's canary strategy, and the analysis starts automatically at the step you choose:</p>
<pre><code class="language-yaml">  strategy:
    canary:
      analysis:
        templates:
          - templateName: success-rate
        args:
          - name: service-name
            value: shop-api
        startingStep: 2
      steps:
        - setWeight: 20
        - pause: { duration: 5m }
        - setWeight: 50
        - pause: { duration: 10m }
</code></pre>
<p>Now the 2 a.m. scenario changes shape: the new version starts failing, the success rate dips under 95% twice, and Rollouts aborts and restores the stable ReplicaSet <strong>before anyone's phone buzzes</strong>. The human reviews a finished, contained event in the morning instead of performing an emergency.</p>
<p>Two disciplines make or break this:</p>
<ul>
<li><strong>Analyse what users feel, not what machines feel.</strong> Success rate and latency percentiles of real requests beat CPU graphs. (This is the same symptom-over-cause rule from my <a href="https://www.devopsenginer.com/blog/kubernetes-observability-guide-2026">Kubernetes observability guide</a> — analysis is only as good as the signals you feed it.)</li>
<li><strong>Your metrics must already exist and be trustworthy.</strong> If the service emits no usable request metrics, step one is observability, not Rollouts. An automated rollback wired to a meaningless query is worse than a manual one — it fails with confidence.</li>
</ul>
<h2>Blue-green with Rollouts</h2>
<p>Prefer an all-or-nothing switch with an instant way back? Blue-green is a strategy block away:</p>
<pre><code class="language-yaml">  strategy:
    blueGreen:
      activeService: shop-api-active
      previewService: shop-api-preview
      autoPromotionEnabled: false
      scaleDownDelaySeconds: 300
</code></pre>
<p>The new version comes up behind the <em>preview</em> Service, where you can smoke-test it against real infrastructure with zero user traffic. Promote it, and the <em>active</em> Service flips to the new ReplicaSet; the old one stays warm for <code>scaleDownDelaySeconds</code> — your undo window. With <code>autoPromotionEnabled: false</code>, the flip waits for <code>kubectl argo rollouts promote</code>. Set an analysis as a <code>prePromotionAnalysis</code> and even blue-green gets a metric gate before the switch.</p>
<p>My rule of thumb, same as in the strategies guide: <strong>canary for stateless user-facing services where gradual is possible; blue-green where a version must switch atomically</strong> (or where testing the exact production-bound version beforehand matters more than gradual exposure).</p>
<h2>Rollouts + Argo CD: who owns what</h2>
<p>If you run GitOps, the division of labour is clean:</p>
<ul>
<li><strong>Argo CD</strong> syncs the desired state from Git — including the Rollout resource and its new image tag. A release is a commit, with the audit trail Git gives you for free.</li>
<li><strong>Rollouts</strong> owns the <em>progression</em> of that version inside the cluster: steps, pauses, analysis, promotion, abort.</li>
</ul>
<p>Argo CD understands Rollouts natively — it reports the Rollout's health (Progressing / Healthy / Degraded) instead of the binary in-sync view you'd get with a Deployment mid-rollout, so a canary sitting at 20% doesn't show up as a permanently "progressing" mystery.</p>
<p>One trap: do not let two systems fight. Your CI pipeline should update the image tag in Git (or the Rollout manifest) and then <em>stop</em> — no <code>kubectl promote</code> step in CI for releases you want gated, and no second controller trying to manage the same ReplicaSets. One owner per decision: Git for <em>what version</em>, Rollouts for <em>how fast it earns traffic</em>, humans for <em>the pauses you deliberately kept</em>.</p>
<h2>Mistakes that bite</h2>
<ul>
<li><strong>Canary by replicas, expectations by traffic.</strong> With few replicas and no traffic router, a "20%" step can be one pod behind a Service — noisy and not really 20% of anything. If precise percentages matter to the release decision, wire up a traffic router first.</li>
<li><strong>Analysis wired to vanity metrics.</strong> An AnalysisRun checking cluster CPU will happily promote a version that is erroring on every tenth request. Gate on per-service success rate and latency, measured on the canary itself.</li>
<li><strong>No baseline.</strong> Analysis compares the canary against an absolute condition (like the 95% above). If normal for this service is 96% on a good day, a 95% gate aborts healthy releases and trains everyone to ignore aborts. Tune the gate to the service's real baseline — my <a href="https://www.devopsenginer.com/blog/kubernetes-resource-requests-limits-guide-2026">requests and limits guide</a> makes the same point about numbers copied from other people's clusters.</li>
<li><strong>Promoting during an incident.</strong> If the platform is already on fire, a promote command is not a fix. Rollouts will even keep an in-flight canary paused politely while everything degrades around it. Abort first; release later.</li>
<li><strong>Forgetting the HPA.</strong> Rollouts scales the stable and canary ReplicaSets to honour weights; an HPA targeting the Rollout works (point it at the Rollout, not the ReplicaSets), but an HPA someone left aimed at the old Deployment setup will confuse everyone. One more reason the migration should be a deliberate manifest change, not a weekend experiment.</li>
<li><strong>revisionHistoryLimit left huge.</strong> Old ReplicaSets are your rollback targets — keep a few (2–3). Keep thirty and every <code>get rollout</code> becomes an archaeology dig.</li>
</ul>
<h2>When NOT to use Rollouts</h2>
<p>Honestly: plenty of services should stay on plain Deployments.</p>
<ul>
<li>Internal tools and low-traffic services where a bad release inconveniences three colleagues, not three thousand customers.</li>
<li>Batch and cron workloads — there is no traffic to graduate.</li>
<li>Anything whose metrics are too thin to analyse; a canary you cannot verify is just a slower rolling update.</li>
<li>Teams still fighting basic stability. Progressive delivery amplifies a release process; it does not repair one. Get deployments boring first (the <a href="https://www.devopsenginer.com/blog/kubernetes-troubleshooting-guide-2026">troubleshooting guide</a> is the place to start), then add graduation.</li>
</ul>
<p>The measure I use, from the project-manager side of my desk: does a bad release of this service cost more than the machinery to catch it early? For the public, revenue-adjacent, or reputation-carrying services, Rollouts pays for itself the first time it aborts at step one. For everything else, it's ceremony.</p>
<h2>FAQ</h2>
<p><strong>Is Argo Rollouts a replacement for Argo CD?</strong>
No — they are siblings, not competitors. Argo CD delivers desired state from Git to the cluster. Rollouts controls how a new version progresses once it's there. Many teams run both; Rollouts also works without Argo CD if your delivery is plain kubectl or another CD tool.</p>
<p><strong>Can I use Rollouts without an ingress controller or service mesh?</strong>
Yes. You get replica-proportional canary steps through Services, which is approximate but functional — fine for learning and for low-stakes services. Precise, replica-independent traffic weights need a supported traffic router (NGINX, ALB, Traefik, Istio, and others).</p>
<p><strong>What happens to my Deployment manifests?</strong>
You convert them: <code>kind: Rollout</code> replaces <code>kind: Deployment</code>, the pod template carries over unchanged, and you add the <code>strategy</code> block. Rollouts manages its own ReplicaSets from there. The conversion is small; the thinking about steps and gates is the real work.</p>
<p><strong>Does an aborted canary count as downtime?</strong>
No — aborting shifts traffic back to the stable ReplicaSet, which never went away. Users saw the canary's behaviour only for the share of traffic it held, which is precisely the blast-radius reduction you adopted this for.</p>
<p><strong>Can analysis roll back a blue-green release too?</strong>
Yes — pre-promotion analysis gates the switch, and post-promotion analysis can trigger a rollback to the previous ReplicaSet while it's still within its scale-down window.</p>
<h2>Conclusion</h2>
<p>A Deployment rolls software out. Argo Rollouts rolls software out <em>on probation</em>: a small share of traffic, real metrics watching, automatic retreat when the numbers say so. Install the controller, convert one service, start with replica-based canary steps and a manual pause — then wire in analysis and graduate to "the release promotes itself, and pages nobody."</p>
<p>Ship the versions. Let the evidence promote them.</p>
<hr />
<p><strong>Keep going:</strong> <a href="https://www.devopsenginer.com/blog/kubernetes-deployment-strategies-2025-complete-guide">Deployment Strategies</a> · <a href="https://www.devopsenginer.com/blog/argo-cd-tutorial-gitops-2026">Argo CD Tutorial</a> · <a href="https://www.devopsenginer.com/blog/kubernetes-observability-guide-2026">Kubernetes Observability</a> · <a href="https://www.devopsenginer.com/#newsletter">Kubernetes Production Checklist (free PDF)</a></p>
]]></content:encoded></item></channel></rss>