<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Florient DOGBE</title>
    <description>The latest articles on DEV Community by Florient DOGBE (@florient_4262fab5463a691).</description>
    <link>https://dev.to/florient_4262fab5463a691</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3900568%2F9e27503b-d47a-4e8c-ad2a-3d508b0dfb52.png</url>
      <title>DEV Community: Florient DOGBE</title>
      <link>https://dev.to/florient_4262fab5463a691</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/florient_4262fab5463a691"/>
    <language>en</language>
    <item>
      <title>Supply Chain Security: Generate an SBOM and Block Critical CVEs Before the Merge, Not in Prod</title>
      <dc:creator>Florient DOGBE</dc:creator>
      <pubDate>Tue, 03 Nov 2026 09:22:06 +0000</pubDate>
      <link>https://dev.to/florient_4262fab5463a691/supply-chain-security-generate-an-sbom-and-block-critical-cves-before-the-merge-not-in-prod-n3</link>
      <guid>https://dev.to/florient_4262fab5463a691/supply-chain-security-generate-an-sbom-and-block-critical-cves-before-the-merge-not-in-prod-n3</guid>
      <description>&lt;p&gt;Most teams discover a critical CVE in a container image the same way: a scanner running against the production cluster fires an alert at 3pm on a Friday, and someone has to figure out whether the vulnerable library is actually reachable, who owns the base image, and how fast a patched version can be shipped. That workflow is expensive and stressful. The fix is unglamorous: move the detection left, into the pull request, where the cost of a change is a one-line bump in a lockfile instead of an emergency change request.&lt;/p&gt;

&lt;p&gt;This article shows how to build that guardrail with two small, focused tools — &lt;strong&gt;Syft&lt;/strong&gt; to produce a &lt;strong&gt;CycloneDX SBOM&lt;/strong&gt;, and &lt;strong&gt;Grype&lt;/strong&gt; to scan it — wired into &lt;strong&gt;GitHub Actions&lt;/strong&gt;, with a severity policy that fails the build and an exception mechanism that is documented, time-boxed, and reviewable. We will also cover where &lt;strong&gt;Trivy&lt;/strong&gt; fits, and how to keep the same policy enforced at admission time on OpenShift/Kubernetes so the CI gate is not the only line of defence.&lt;/p&gt;

&lt;p&gt;The target reader is someone who has never generated an SBOM before. Every command below is runnable as-is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context: why a scanner in prod is already too late
&lt;/h2&gt;

&lt;p&gt;A vulnerability scan tells you three things: what software you ship, which known CVEs affect it, and whether a fix exists. Running that scan in production answers the question &lt;em&gt;after&lt;/em&gt; you have already taken the risk. Running it in CI answers it &lt;em&gt;before&lt;/em&gt; the artifact exists.&lt;/p&gt;

&lt;p&gt;There are three concrete reasons this matters:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. You cannot patch what you cannot enumerate.&lt;/strong&gt; When log4shell landed, the teams that answered "are we affected?" in minutes were the ones with a stored SBOM per build. Everyone else spent two days running &lt;code&gt;find / -name "log4j*"&lt;/code&gt; across clusters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The cost of a fix grows with distance from the commit.&lt;/strong&gt; Bumping &lt;code&gt;express&lt;/code&gt; from 4.17.1 to 4.19.2 in a PR is a 10-minute job. Doing it on a running production service means a change window, a rollback plan, and a regression test campaign.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Regulations now require the artefact.&lt;/strong&gt; The EU Cyber Resilience Act, the US Executive Order 14028, and most enterprise vendor questionnaires ask for a machine-readable bill of materials. CycloneDX and SPDX are the two accepted formats. Generating one as a build side effect costs you about 15 seconds of CI time.&lt;/p&gt;

&lt;p&gt;The guardrail we are building has a simple contract: &lt;strong&gt;no pull request merges if it introduces a Critical (or High, depending on your policy) vulnerability with a known fix, unless an exception is explicitly recorded in the repository.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The tools, in 90 seconds
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Syft&lt;/strong&gt; (Anchore, Apache-2.0) walks a filesystem, a directory, or a container image and lists every package it can identify: OS packages (apk/deb/rpm), language packages (npm, pip, go modules, Maven, Cargo...), and binaries. Output: an SBOM in CycloneDX, SPDX, or its own JSON format. Syft does &lt;strong&gt;not&lt;/strong&gt; know about vulnerabilities.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grype&lt;/strong&gt; (same vendor) takes an SBOM — or an image directly — and matches each package against a vulnerability database (NVD, GitHub Security Advisories, distro trackers). It knows about &lt;em&gt;fix state&lt;/em&gt;, which is the single most useful field for a sane policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trivy&lt;/strong&gt; (Aqua, Apache-2.0) does both jobs in one binary, plus IaC and secret scanning. It is an excellent alternative, and many teams pick it precisely because it is one tool instead of two.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why split Syft and Grype? Because the SBOM becomes an artifact in its own right. You generate it once, attach it to the release, and can re-scan it six months later against a newer vulnerability DB without rebuilding anything. That decoupling is the whole point of an SBOM.&lt;/p&gt;

&lt;h3&gt;
  
  
  Install locally first
&lt;/h3&gt;

&lt;p&gt;Always validate the pipeline on your laptop before writing YAML.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Syft and Grype (binaries land in ./bin or /usr/local/bin)&lt;/span&gt;
curl &lt;span class="nt"&gt;-sSfL&lt;/span&gt; https://raw.githubusercontent.com/anchore/syft/main/install.sh &lt;span class="se"&gt;\&lt;/span&gt;
  | sh &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;-b&lt;/span&gt; /usr/local/bin v1.18.1

curl &lt;span class="nt"&gt;-sSfL&lt;/span&gt; https://raw.githubusercontent.com/anchore/grype/main/install.sh &lt;span class="se"&gt;\&lt;/span&gt;
  | sh &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;-b&lt;/span&gt; /usr/local/bin v0.87.0

syft version
grype version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin the versions. An unpinned &lt;code&gt;install.sh&lt;/code&gt; is itself a supply chain risk, and a tool upgrade that changes default severity mappings will break builds at the worst moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Generate a CycloneDX SBOM
&lt;/h2&gt;

&lt;p&gt;Syft accepts several source types. The two you will use most:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# From a container image in a registry (no Docker daemon required)&lt;/span&gt;
syft registry:quay.io/myorg/payment-api:1.4.2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; cyclonedx-json&lt;span class="o"&gt;=&lt;/span&gt;sbom.cdx.json

&lt;span class="c"&gt;# From the local source tree (catalogs lockfiles: package-lock.json, go.sum, poetry.lock...)&lt;/span&gt;
syft &lt;span class="nb"&gt;dir&lt;/span&gt;:. &lt;span class="nt"&gt;-o&lt;/span&gt; cyclonedx-json&lt;span class="o"&gt;=&lt;/span&gt;sbom-src.cdx.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scan both. The source scan catches your direct and transitive application dependencies early, even before the image is built. The image scan catches everything: OS packages from the base image, binaries copied in, and anything a build stage pulled in.&lt;/p&gt;

&lt;p&gt;Inspect what you got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Human-readable table&lt;/span&gt;
syft registry:quay.io/myorg/payment-api:1.4.2 &lt;span class="nt"&gt;-o&lt;/span&gt; table | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;

&lt;span class="c"&gt;# Count components in the SBOM&lt;/span&gt;
jq &lt;span class="s1"&gt;'.components | length'&lt;/span&gt; sbom.cdx.json

&lt;span class="c"&gt;# List components by package ecosystem&lt;/span&gt;
jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.components[].purl'&lt;/span&gt; sbom.cdx.json | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;: &lt;span class="nt"&gt;-f2&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;/ &lt;span class="nt"&gt;-f1&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A CycloneDX document is just JSON with a &lt;code&gt;bomFormat&lt;/code&gt;, a &lt;code&gt;specVersion&lt;/code&gt;, a &lt;code&gt;metadata&lt;/code&gt; block describing the subject, and a &lt;code&gt;components&lt;/code&gt; array of packages with their &lt;code&gt;purl&lt;/code&gt; (package URL) identifiers. That &lt;code&gt;purl&lt;/code&gt; is what makes the SBOM matchable against vulnerability databases.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cut the noise at the source
&lt;/h3&gt;

&lt;p&gt;Syft catalogs everything it finds, including build-time tooling that never reaches the runtime image. If you use multi-stage builds correctly, this mostly solves itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;registry.access.redhat.com/ubi9/nodejs-20&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package*.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm prune &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; registry.access.redhat.com/ubi9/nodejs-20-minimal&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /app/dist ./dist&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /app/node_modules ./node_modules&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; 1001&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "dist/server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final stage contains production dependencies only, so the SBOM reflects what actually runs. A &lt;code&gt;-minimal&lt;/code&gt; or distroless base image typically removes 100+ OS packages and with them a long tail of irrelevant CVEs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Scan the SBOM with Grype
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Scan the SBOM you just produced&lt;/span&gt;
grype sbom:sbom.cdx.json &lt;span class="nt"&gt;-o&lt;/span&gt; table

&lt;span class="c"&gt;# Fail the process if anything Critical is found&lt;/span&gt;
grype sbom:sbom.cdx.json &lt;span class="nt"&gt;--fail-on&lt;/span&gt; critical

&lt;span class="c"&gt;# Only report vulnerabilities that have an available fix&lt;/span&gt;
grype sbom:sbom.cdx.json &lt;span class="nt"&gt;--only-fixed&lt;/span&gt; &lt;span class="nt"&gt;--fail-on&lt;/span&gt; high
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--only-fixed&lt;/code&gt; is the setting that turns a noisy scanner into a usable gate. A Critical CVE in a distro package where the vendor has not shipped a patch is not something your PR author can act on. Blocking the merge on it just teaches people to bypass the gate. Report it, track it, but do not fail the build on it.&lt;/p&gt;

&lt;p&gt;Exit code is &lt;code&gt;1&lt;/code&gt; when the threshold is hit, &lt;code&gt;0&lt;/code&gt; otherwise — that is all CI needs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Policy as a file, not as CLI flags
&lt;/h3&gt;

&lt;p&gt;Grype reads &lt;code&gt;.grype.yaml&lt;/code&gt; from the working directory. Commit it; it is your policy document.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .grype.yaml&lt;/span&gt;
&lt;span class="c1"&gt;# Severity threshold that breaks the build.&lt;/span&gt;
&lt;span class="na"&gt;fail-on-severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;high&lt;/span&gt;

&lt;span class="c1"&gt;# Only consider vulnerabilities with a known fix available.&lt;/span&gt;
&lt;span class="na"&gt;only-fixed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;table&lt;/span&gt;

&lt;span class="c1"&gt;# Documented, time-boxed exceptions.&lt;/span&gt;
&lt;span class="na"&gt;ignore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# CVE-2024-21538 (cross-spawn ReDoS) - build-time only, not reachable at runtime.&lt;/span&gt;
  &lt;span class="c1"&gt;# Owner: platform-team  Ticket: SEC-1427  Review by: 2025-09-30&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;vulnerability&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CVE-2024-21538&lt;/span&gt;
    &lt;span class="na"&gt;package&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cross-spawn&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&lt;/span&gt;

  &lt;span class="c1"&gt;# GHSA-xxxx: triggers only when the parser is fed untrusted XML; our input is&lt;/span&gt;
  &lt;span class="c1"&gt;# internal and schema-validated. Owner: payments  Ticket: SEC-1455  Review by: 2025-08-15&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;vulnerability&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GHSA-2c8p-7m4v-h3cp&lt;/span&gt;
    &lt;span class="na"&gt;package&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three rules make exceptions safe rather than a loophole:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every entry carries an owner, a ticket, and a review date.&lt;/strong&gt; No anonymous &lt;code&gt;ignore&lt;/code&gt; lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The file is protected by CODEOWNERS&lt;/strong&gt;, so a security reviewer must approve additions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A scheduled job fails when a review date has passed.&lt;/strong&gt; An exception that nobody re-validates silently becomes permanent.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# scripts/check-exceptions.sh — fail if any "Review by" date is in the past&lt;/span&gt;
&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;today&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;expired&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; line&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'[0-9]{4}-[0-9]{2}-[0-9]{2}'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;continue
  if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$today&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"EXPIRED exception (review date &lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;): &lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nv"&gt;expired&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
  &lt;span class="k"&gt;fi
done&lt;/span&gt; &amp;lt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'Review by'&lt;/span&gt; .grype.yaml&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$expired&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3 — Wire it into GitHub Actions
&lt;/h2&gt;

&lt;p&gt;Here is the complete workflow. It runs on pull requests, builds the image, generates both SBOMs, scans, and uploads results.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/supply-chain.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;supply-chain&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Re-scan main nightly: new CVEs appear without any code change.&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;security-events&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;   &lt;span class="c1"&gt;# required to upload SARIF&lt;/span&gt;
  &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;sbom-and-scan&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-24.04&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Verify exception review dates&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash scripts/check-exceptions.sh&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build image (local, not pushed)&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;docker build -t app:${{ github.sha }} .&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Generate CycloneDX SBOM (image)&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;anchore/sbom-action@v0&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app:${{ github.sha }}&lt;/span&gt;
          &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cyclonedx-json&lt;/span&gt;
          &lt;span class="na"&gt;output-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sbom.cdx.json&lt;/span&gt;
          &lt;span class="na"&gt;artifact-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sbom-${{ github.sha }}.cdx.json&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Generate CycloneDX SBOM (source tree)&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;anchore/sbom-action@v0&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
          &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cyclonedx-json&lt;/span&gt;
          &lt;span class="na"&gt;output-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sbom-src.cdx.json&lt;/span&gt;
          &lt;span class="na"&gt;artifact-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sbom-src-${{ github.sha }}.cdx.json&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Scan SBOM with Grype&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grype&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;anchore/scan-action@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;sbom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sbom.cdx.json&lt;/span&gt;
          &lt;span class="na"&gt;fail-build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;severity-cutoff&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;high&lt;/span&gt;
          &lt;span class="na"&gt;only-fixed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;output-format&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sarif&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload SARIF to code scanning&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github/codeql-action/upload-sarif@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;sarif_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.grype.outputs.sarif }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Keep SBOM for 90 days&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sbom-${{ github.sha }}&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;sbom.cdx.json&lt;/span&gt;
            &lt;span class="s"&gt;sbom-src.cdx.json&lt;/span&gt;
          &lt;span class="na"&gt;retention-days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;90&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that matter more than they look:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The nightly schedule.&lt;/strong&gt; A repository whose code has not changed in three months can still become vulnerable overnight, because the vulnerability database changed, not your code. Scanning only on PR gives you a false sense of safety. The scheduled run catches drift.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SARIF upload.&lt;/strong&gt; Results land in the GitHub Security tab with per-finding deduplication and history, rather than being buried in a job log that nobody opens.&lt;/p&gt;

&lt;p&gt;Finally, make the job a &lt;strong&gt;required status check&lt;/strong&gt; in branch protection — otherwise it is advisory and will be ignored under deadline pressure.&lt;/p&gt;

&lt;h3&gt;
  
  
  The same thing with Trivy
&lt;/h3&gt;

&lt;p&gt;If you prefer a single binary, Trivy covers both steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# CycloneDX SBOM&lt;/span&gt;
trivy image &lt;span class="nt"&gt;--format&lt;/span&gt; cyclonedx &lt;span class="nt"&gt;--output&lt;/span&gt; sbom.cdx.json quay.io/myorg/payment-api:1.4.2

&lt;span class="c"&gt;# Scan the SBOM, fail on HIGH/CRITICAL with a fix available&lt;/span&gt;
trivy sbom &lt;span class="nt"&gt;--severity&lt;/span&gt; HIGH,CRITICAL &lt;span class="nt"&gt;--ignore-unfixed&lt;/span&gt; &lt;span class="nt"&gt;--exit-code&lt;/span&gt; 1 sbom.cdx.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exceptions live in a &lt;code&gt;.trivyignore&lt;/code&gt; file (one CVE ID per line, comments with &lt;code&gt;#&lt;/code&gt;) or, for richer rules, in &lt;code&gt;trivyignore.yaml&lt;/code&gt; with per-entry &lt;code&gt;statement&lt;/code&gt; and &lt;code&gt;expired_at&lt;/code&gt; fields — the expiry date being built in is a genuinely nice touch.&lt;/p&gt;

&lt;p&gt;Pick one and standardise. Running Syft/Grype &lt;em&gt;and&lt;/em&gt; Trivy on the same artifact produces slightly different result sets and endless "which one is right?" discussions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Enforce at admission, not just in CI
&lt;/h2&gt;

&lt;p&gt;A CI gate stops &lt;em&gt;your&lt;/em&gt; pipeline from shipping a vulnerable image. It does nothing about an image someone deploys by hand, or a third-party chart pulling &lt;code&gt;some/image:latest&lt;/code&gt;. On OpenShift/Kubernetes, close that gap with a cluster-side policy.&lt;/p&gt;

&lt;p&gt;Minimal example with Kyverno — only allow images from trusted registries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kyverno.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;restrict-image-registries&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;validationFailureAction&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Enforce&lt;/span&gt;
  &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;allowed-registries&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;any&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;kinds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Pod&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
              &lt;span class="na"&gt;namespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prod-*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;validate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
          &lt;span class="s"&gt;Images must come from quay.io/myorg/ or&lt;/span&gt;
          &lt;span class="s"&gt;registry.access.redhat.com/ (scanned in CI).&lt;/span&gt;
        &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;=(initContainers)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quay.io/myorg/*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;registry.access.redhat.com/*"&lt;/span&gt;
            &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quay.io/myorg/*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;registry.access.redhat.com/*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a CronJob that re-scans what is actually running, independently of CI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CronJob&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grype-runtime-rescan&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;security-scanning&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
  &lt;span class="na"&gt;concurrencyPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Forbid&lt;/span&gt;
  &lt;span class="na"&gt;jobTemplate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;backoffLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
      &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OnFailure&lt;/span&gt;
          &lt;span class="na"&gt;serviceAccountName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;image-scanner&lt;/span&gt;
          &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grype&lt;/span&gt;
              &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;anchore/grype:v0.87.0&lt;/span&gt;
              &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;registry:quay.io/myorg/payment-api:1.4.2"&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--only-fixed"&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-o"&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json"&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--file"&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/reports/payment-api.json"&lt;/span&gt;
              &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GRYPE_DB_CACHE_DIR&lt;/span&gt;
                  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/tmp/grype-db&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GRYPE_CHECK_FOR_APP_UPDATE&lt;/span&gt;
                  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
              &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;allowPrivilegeEscalation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
                &lt;span class="na"&gt;runAsNonRoot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
                &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALL"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
                &lt;span class="na"&gt;seccompProfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RuntimeDefault&lt;/span&gt;
              &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;200m&lt;/span&gt;
                  &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512Mi&lt;/span&gt;
                &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Gi&lt;/span&gt;
              &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reports&lt;/span&gt;
                  &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/reports&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tmp&lt;/span&gt;
                  &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/tmp&lt;/span&gt;
          &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reports&lt;/span&gt;
              &lt;span class="na"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;claimName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scan-reports&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tmp&lt;/span&gt;
              &lt;span class="na"&gt;emptyDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the registry is private, mount a pull secret and point Grype at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc &lt;span class="nt"&gt;-n&lt;/span&gt; security-scanning create secret docker-registry quay-pull &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--docker-server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;quay.io &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--docker-username&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'myorg+scanner'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--docker-password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; ~/.quay-robot-token&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

oc &lt;span class="nt"&gt;-n&lt;/span&gt; security-scanning secrets &lt;span class="nb"&gt;link &lt;/span&gt;image-scanner quay-pull &lt;span class="nt"&gt;--for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pull
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never inline credentials in a manifest. Use a &lt;code&gt;Secret&lt;/code&gt;, an External Secrets Operator reference, or OpenShift's built-in service account pull secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security best practices
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pin every tool version.&lt;/strong&gt; &lt;code&gt;install.sh ... v0.87.0&lt;/code&gt;, &lt;code&gt;anchore/grype:v0.87.0&lt;/code&gt;, &lt;code&gt;actions/checkout@v4&lt;/code&gt; — ideally pinned to a commit SHA for third-party actions. A supply chain gate built on floating tags is self-defeating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Restrict GitHub Actions permissions.&lt;/strong&gt; Declare &lt;code&gt;permissions:&lt;/code&gt; explicitly at workflow or job level. The default token is far broader than a scan job needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never scan with &lt;code&gt;pull_request_target&lt;/code&gt; on untrusted forks.&lt;/strong&gt; That trigger runs with repository secrets available to code the PR author controls. Use &lt;code&gt;pull_request&lt;/code&gt; and accept that forks cannot access secrets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Store the SBOM as a durable artifact.&lt;/strong&gt; 90-day retention in CI is a minimum; for released versions, push the SBOM to your registry as an OCI artifact or attach it to the GitHub release. The value of an SBOM is being able to answer "which of our 200 services ship library X version Y?" in under a minute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sign images and SBOMs.&lt;/strong&gt; An unsigned SBOM proves nothing about provenance. &lt;code&gt;cosign attest --predicate sbom.cdx.json --type cyclonedx&lt;/code&gt; binds the SBOM to the image digest, and Kyverno or Sigstore Policy Controller can verify that attestation at admission.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always reference images by digest in production manifests.&lt;/strong&gt; &lt;code&gt;quay.io/myorg/payment-api@sha256:...&lt;/code&gt; is the only way to guarantee that what you scanned is what runs. Tags are mutable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set the threshold where the team can actually operate.&lt;/strong&gt; Start with &lt;code&gt;fail-on-severity: critical&lt;/code&gt; plus &lt;code&gt;only-fixed: true&lt;/code&gt;, clear the backlog, then tighten to &lt;code&gt;high&lt;/code&gt;. A gate that fails 80% of builds on day one gets disabled on day two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separate "block" from "report".&lt;/strong&gt; Vulnerabilities without a fix, and Medium/Low findings, go to the Security tab and the nightly report — not to a red X on a PR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep the vulnerability database fresh.&lt;/strong&gt; Grype downloads it on each run by default; in air-gapped environments, mirror it and set &lt;code&gt;GRYPE_DB_UPDATE_URL&lt;/code&gt;. A stale DB turns a green build into a lie.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat &lt;code&gt;.grype.yaml&lt;/code&gt; as a security-reviewed file.&lt;/strong&gt; CODEOWNERS entry, mandatory ticket reference, mandatory review date, and a job that fails when a date expires.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The gap between "we have a scanner" and "we have a guardrail" is mostly process, not tooling. The technical part takes an afternoon:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Syft produces a CycloneDX SBOM&lt;/strong&gt; from your source tree and from your image. That file is the inventory you will need the next time a log4shell-class event happens — generate it on every build and keep it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grype scans the SBOM&lt;/strong&gt; and returns a non-zero exit code against a policy you commit to the repository. &lt;code&gt;only-fixed: true&lt;/code&gt; plus a severity threshold the team can realistically meet is the difference between a useful gate and an ignored one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Actions runs it on every pull request&lt;/strong&gt;, uploads SARIF to the Security tab, stores the SBOM as an artifact, and re-runs nightly to catch CVEs published after the merge. The job must be a required status check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exceptions are explicit, owned, ticketed, and expiring.&lt;/strong&gt; An exception mechanism that nobody can audit is just a disabled scanner with extra steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI is not the last line of defence.&lt;/strong&gt; Admission policies on OpenShift/Kubernetes and a scheduled re-scan of running images cover the paths that bypass your pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start small: generate an SBOM on your most critical service this week, in report-only mode. Look at the numbers, fix the trivially fixable, document the rest with review dates, then flip &lt;code&gt;fail-build&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt;. Six weeks later, nobody on the team will remember what it was like to find out about a critical CVE from a production alert.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>kubernetes</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Writing Your First Kubernetes Controller in Go: Reconcile, Finalizers, and Mistakes to Avoid</title>
      <dc:creator>Florient DOGBE</dc:creator>
      <pubDate>Mon, 02 Nov 2026 13:33:38 +0000</pubDate>
      <link>https://dev.to/florient_4262fab5463a691/writing-your-first-kubernetes-controller-in-go-reconcile-finalizers-and-mistakes-to-avoid-3lla</link>
      <guid>https://dev.to/florient_4262fab5463a691/writing-your-first-kubernetes-controller-in-go-reconcile-finalizers-and-mistakes-to-avoid-3lla</guid>
      <description>&lt;p&gt;A Kubernetes controller is deceptively simple: watch objects, call &lt;code&gt;Reconcile&lt;/code&gt;, make the world match the spec. Then you ship it, and three weeks later someone reports that deleting a namespace hangs forever, your API server audit log shows 40 000 PATCH requests per minute from a single controller pod, and an orphaned bucket is still billing you in production.&lt;/p&gt;

&lt;p&gt;All three failures come from the same three concepts: &lt;strong&gt;idempotency&lt;/strong&gt;, &lt;strong&gt;finalizers&lt;/strong&gt;, and &lt;strong&gt;status write discipline&lt;/strong&gt;. This article builds a minimal but correct controller with &lt;code&gt;controller-runtime&lt;/code&gt;, scaffolded by &lt;code&gt;kubebuilder&lt;/code&gt;, that manages a CRD representing an external resource. We focus on the parts that actually break in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context: why the reconcile loop is harder than it looks
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Reconcile&lt;/code&gt; is &lt;strong&gt;level-triggered, not edge-triggered&lt;/strong&gt;. You never receive "the user changed &lt;code&gt;spec.size&lt;/code&gt; from 10 to 20". You receive "something about &lt;code&gt;default/my-bucket&lt;/code&gt; may have changed; go look". The event may be a resync, a status update your own controller just wrote, a cache re-list after an API server restart, or a genuine user edit. It may arrive twice for the same change, or once for three changes.&lt;/p&gt;

&lt;p&gt;Three consequences that drive the entire design:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reconcile must be idempotent.&lt;/strong&gt; Running it 100 times must produce the same cluster state as running it once, and must not create 100 external resources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconcile must be restartable.&lt;/strong&gt; The process can die between "create the external bucket" and "record the bucket ID in status". The next pass must recover without leaking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Garbage collection only covers in-cluster objects.&lt;/strong&gt; &lt;code&gt;ownerReferences&lt;/code&gt; will clean up a &lt;code&gt;Secret&lt;/code&gt; you created. Nothing in Kubernetes knows about an S3 bucket, a DNS record, or a row in an external IPAM. That is exactly what finalizers are for.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Scaffolding the project
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;bucket-operator &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;bucket-operator
kubebuilder init &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--domain&lt;/span&gt; example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--repo&lt;/span&gt; github.com/acme/bucket-operator &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--plugins&lt;/span&gt; go/v4

kubebuilder create api &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--group&lt;/span&gt; storage &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version&lt;/span&gt; v1alpha1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--kind&lt;/span&gt; BucketClaim &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource&lt;/span&gt; &lt;span class="nt"&gt;--controller&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API type. Note &lt;code&gt;observedGeneration&lt;/code&gt; and the conditions slice — both are load-bearing, not decoration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// api/v1alpha1/bucketclaim_types.go&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;BucketClaimSpec&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// +kubebuilder:validation:MinLength=3&lt;/span&gt;
    &lt;span class="c"&gt;// +kubebuilder:validation:MaxLength=63&lt;/span&gt;
    &lt;span class="n"&gt;Region&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"region"`&lt;/span&gt;

    &lt;span class="c"&gt;// +kubebuilder:validation:Enum=Retain;Delete&lt;/span&gt;
    &lt;span class="c"&gt;// +kubebuilder:default=Delete&lt;/span&gt;
    &lt;span class="n"&gt;ReclaimPolicy&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"reclaimPolicy,omitempty"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;BucketClaimStatus&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// BucketID is the opaque identifier returned by the storage backend.&lt;/span&gt;
    &lt;span class="n"&gt;BucketID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"bucketID,omitempty"`&lt;/span&gt;

    &lt;span class="c"&gt;// ObservedGeneration is the .metadata.generation this status reflects.&lt;/span&gt;
    &lt;span class="n"&gt;ObservedGeneration&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt; &lt;span class="s"&gt;`json:"observedGeneration,omitempty"`&lt;/span&gt;

    &lt;span class="c"&gt;// +listType=map&lt;/span&gt;
    &lt;span class="c"&gt;// +listMapKey=type&lt;/span&gt;
    &lt;span class="n"&gt;Conditions&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;metav1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Condition&lt;/span&gt; &lt;span class="s"&gt;`json:"conditions,omitempty"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// +kubebuilder:object:root=true&lt;/span&gt;
&lt;span class="c"&gt;// +kubebuilder:subresource:status&lt;/span&gt;
&lt;span class="c"&gt;// +kubebuilder:printcolumn:name="Region",type=string,JSONPath=`.spec.region`&lt;/span&gt;
&lt;span class="c"&gt;// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;BucketClaim&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;status&lt;/code&gt; subresource is non-negotiable: without it, every &lt;code&gt;Update&lt;/code&gt; on status bumps &lt;code&gt;metadata.generation&lt;/code&gt; and re-triggers your own controller. That is infinite loop number one.&lt;/p&gt;

&lt;p&gt;The generated CRD can carry CEL validation so you reject bad specs at admission time instead of in Go:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apiextensions.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CustomResourceDefinition&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bucketclaims.storage.example.com&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;storage.example.com&lt;/span&gt;
  &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Namespaced&lt;/span&gt;
  &lt;span class="na"&gt;names&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BucketClaim&lt;/span&gt;
    &lt;span class="na"&gt;listKind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BucketClaimList&lt;/span&gt;
    &lt;span class="na"&gt;plural&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bucketclaims&lt;/span&gt;
    &lt;span class="na"&gt;singular&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bucketclaim&lt;/span&gt;
  &lt;span class="na"&gt;versions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1alpha1&lt;/span&gt;
      &lt;span class="na"&gt;served&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;subresources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
      &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;openAPIV3Schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;object&lt;/span&gt;
          &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;object&lt;/span&gt;
              &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;region&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
              &lt;span class="na"&gt;x-kubernetes-validations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;self.region&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;==&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;oldSelf.region"&lt;/span&gt;
                  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;immutable"&lt;/span&gt;
              &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
                &lt;span class="na"&gt;reclaimPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
                  &lt;span class="na"&gt;enum&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Retain&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Delete&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
                  &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Delete&lt;/span&gt;
            &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;object&lt;/span&gt;
              &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;bucketID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;string&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
                &lt;span class="na"&gt;observedGeneration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;integer&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;int64&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
                &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;array&lt;/span&gt;
                  &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;object&lt;/span&gt;
                    &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;lastTransitionTime&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;reason&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
                    &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;string&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
                      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;string&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
                      &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;string&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
                      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;string&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
                      &lt;span class="na"&gt;observedGeneration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;integer&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;int64&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
                      &lt;span class="na"&gt;lastTransitionTime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;string&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;date-time&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Immutability enforced by CEL removes an entire class of reconcile branches ("what do I do when the user moves the bucket to another region?").&lt;/p&gt;

&lt;h2&gt;
  
  
  The reconcile loop, step by step
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;finalizerName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"storage.example.com/bucket-cleanup"&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;BucketClaimReconciler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Reconcile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;logf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FromContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;bc&lt;/span&gt; &lt;span class="n"&gt;storagev1alpha1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BucketClaim&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NamespacedName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// NotFound means the object is gone and fully finalized. Nothing to do.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IgnoreNotFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeletionTimestamp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsZero&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reconcileDelete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// 1. Finalizer BEFORE any external side effect.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;controllerutil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddFinalizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;finalizerName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c"&gt;// This Update generates a new event; stop here, the next pass continues.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// 2. Idempotent provisioning, keyed on a deterministic name.&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;bucketName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// e.g. fmt.Sprintf("%s-%s-%s", bc.Namespace, bc.Name, bc.UID[:8])&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EnsureBucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Spec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Region&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetStatusCondition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Conditions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metav1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Condition&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Ready"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;metav1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConditionFalse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Reason&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"BackendError"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;ObservedGeneration&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Generation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;patchStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="c"&gt;// exponential backoff from the workqueue&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// 3. Owned in-cluster object: GC handles deletion, no finalizer needed.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ensureCredentialsSecret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// 4. Status last, and only status.&lt;/span&gt;
    &lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BucketID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
    &lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ObservedGeneration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Generation&lt;/span&gt;
    &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetStatusCondition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Conditions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metav1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Condition&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Ready"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;metav1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConditionTrue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Reason&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Provisioned"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ObservedGeneration&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Generation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;patchStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// Re-check the backend periodically: no watch exists on external systems.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;RequeueAfter&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Minute&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why the finalizer goes first
&lt;/h3&gt;

&lt;p&gt;If you create the bucket and &lt;em&gt;then&lt;/em&gt; add the finalizer, there is a window where the controller can crash, the user deletes the CR, the API server removes it immediately, and the bucket leaks. Adding the finalizer is a cheap, idempotent metadata write. Do it before you touch anything outside the cluster, always.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why provisioning must be keyed on a deterministic name
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;EnsureBucket&lt;/code&gt; must be "create if absent, return existing otherwise" and derive its key from immutable CR data (namespace, name, UID). Never key on &lt;code&gt;status.BucketID&lt;/code&gt; alone: if the process dies between the backend call and the status write, the next pass must still find the existing bucket instead of creating a second one. Reading the status you just wrote back from the cache is also unreliable — the informer cache is eventually consistent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Patch, don't update, for status
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;BucketClaimReconciler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;patchStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bc&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;storagev1alpha1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BucketClaim&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// base is the object as it was read; MergeFrom computes a minimal patch.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MergeFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeepCopy&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice, capture the base object right after &lt;code&gt;Get&lt;/code&gt; (&lt;code&gt;base := bc.DeepCopy()&lt;/code&gt;), mutate &lt;code&gt;bc&lt;/code&gt;, then &lt;code&gt;r.Status().Patch(ctx, bc, client.MergeFrom(base))&lt;/code&gt;. A merge patch that computes to an empty diff is a no-op on the API server: &lt;strong&gt;no write, no event, no loop&lt;/strong&gt;. A blind &lt;code&gt;Status().Update()&lt;/code&gt; on every reconcile rewrites &lt;code&gt;resourceVersion&lt;/code&gt; every time and is the second classic infinite loop — made worse by &lt;code&gt;lastTransitionTime&lt;/code&gt; if you rebuild conditions by hand instead of using &lt;code&gt;meta.SetStatusCondition&lt;/code&gt;, which only touches the timestamp when the status actually flips.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deletion: the finalizer path
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;BucketClaimReconciler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;reconcileDelete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bc&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;storagev1alpha1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BucketClaim&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;controllerutil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ContainsFinalizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;finalizerName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="c"&gt;// someone else's finalizer, not our turn&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Spec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReclaimPolicy&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"Delete"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeleteBucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bucketName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;backend&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrNotFound&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="c"&gt;// Idempotent: already gone is success.&lt;/span&gt;
        &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Recorder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Eventf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;corev1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EventTypeWarning&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"CleanupFailed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"bucket cleanup failed: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;controllerutil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RemoveFinalizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;finalizerName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IgnoreNotFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four rules for finalizers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent cleanup.&lt;/strong&gt; "Resource not found in the backend" is a success, not an error. Otherwise an already-deleted bucket blocks the CR forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove the finalizer only after cleanup succeeded.&lt;/strong&gt; The order in the code above is the whole point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never block forever.&lt;/strong&gt; A finalizer that always errors makes the CR undeletable and, worse, makes its &lt;strong&gt;namespace stuck in &lt;code&gt;Terminating&lt;/code&gt;&lt;/strong&gt;. Expose the failure as an Event and a condition so an operator can see why, and support an escape hatch (for example, honour &lt;code&gt;storage.example.com/skip-cleanup: "true"&lt;/code&gt; as an annotation).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check it is your finalizer.&lt;/strong&gt; Other controllers (and the &lt;code&gt;kubernetes&lt;/code&gt; namespace finalizer) add their own. Only ever touch your own string.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Emergency unblocking, for the runbook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Inspect what is holding the object&lt;/span&gt;
oc get bucketclaim my-bucket &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.metadata.finalizers}{"\n"}'&lt;/span&gt;

&lt;span class="c"&gt;# Last resort: strip the finalizer manually (this LEAKS the external resource)&lt;/span&gt;
oc patch bucketclaim my-bucket &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;merge &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"metadata":{"finalizers":null}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Wiring the controller and filtering events
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;BucketClaimReconciler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;SetupWithManager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mgr&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Manager&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewControllerManagedBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mgr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
        &lt;span class="n"&gt;For&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;storagev1alpha1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BucketClaim&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithPredicates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;predicate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;predicate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GenerationChangedPredicate&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;
                &lt;span class="n"&gt;predicate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AnnotationChangedPredicate&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;
            &lt;span class="p"&gt;)))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
        &lt;span class="n"&gt;Owns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;corev1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Secret&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
        &lt;span class="n"&gt;WithOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;MaxConcurrentReconciles&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
        &lt;span class="n"&gt;Named&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"bucketclaim"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
        &lt;span class="n"&gt;Complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GenerationChangedPredicate&lt;/code&gt; drops update events where &lt;code&gt;metadata.generation&lt;/code&gt; is unchanged — that includes your own status writes. It does &lt;strong&gt;not&lt;/strong&gt; drop deletions: when the API server marks an object for deletion while finalizers are pending, it bumps &lt;code&gt;generation&lt;/code&gt;, so the delete-intent event still reaches you. The trade-off: it also drops periodic resync events, so never rely on resync for drift detection — use the explicit &lt;code&gt;RequeueAfter&lt;/code&gt; in the success path instead.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;MaxConcurrentReconciles &amp;gt; 1&lt;/code&gt; means the same controller processes different objects in parallel, but the workqueue guarantees a given key is never reconciled concurrently with itself. Your &lt;code&gt;Reconcile&lt;/code&gt; must still be safe against &lt;em&gt;two different&lt;/em&gt; CRs touching the same backend.&lt;/p&gt;

&lt;h3&gt;
  
  
  Returning the right thing
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Return&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Done, nothing more to do&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ctrl.Result{}, nil&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transient failure&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ctrl.Result{}, err&lt;/code&gt; — exponential backoff, logged, metered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Waiting on something with no watch&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ctrl.Result{RequeueAfter: d}, nil&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Permanent, user-caused error&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ctrl.Result{}, nil&lt;/code&gt; + a &lt;code&gt;Ready=False&lt;/code&gt; condition&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Never return an error for a user mistake like an invalid region string: the workqueue will retry it forever, spam your logs, and inflate &lt;code&gt;controller_runtime_reconcile_errors_total&lt;/code&gt;. Surface it in &lt;code&gt;status.conditions&lt;/code&gt; and stop. Also, do not hand-roll &lt;code&gt;time.Sleep&lt;/code&gt; inside &lt;code&gt;Reconcile&lt;/code&gt; — you are blocking a worker goroutine; use &lt;code&gt;RequeueAfter&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Conflicts (&lt;code&gt;apierrors.IsConflict&lt;/code&gt;) are normal under optimistic concurrency. Just return the error and let the backoff re-read a fresh object; do not wrap your whole reconcile in &lt;code&gt;retry.RetryOnConflict&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing against a real API server
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;envtest&lt;/code&gt; runs a real &lt;code&gt;kube-apiserver&lt;/code&gt; and &lt;code&gt;etcd&lt;/code&gt; binary, which is the only way to validate finalizer semantics and the status subresource.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make &lt;span class="nb"&gt;test&lt;/span&gt;            &lt;span class="c"&gt;# kubebuilder wires setup-envtest + ginkgo&lt;/span&gt;
make manifests generate
make &lt;span class="nb"&gt;install&lt;/span&gt;         &lt;span class="c"&gt;# applies config/crd into the cluster&lt;/span&gt;
make run             &lt;span class="c"&gt;# runs the manager out-of-cluster against your kubeconfig&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two tests worth writing on day one: delete the CR while the fake backend returns an error, and assert the object still exists with its finalizer; then make the backend succeed and assert the object disappears.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security best practices
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Least-privilege RBAC.&lt;/strong&gt; Kubebuilder markers generate the role, so keep them narrow — no &lt;code&gt;groups=*&lt;/code&gt; or &lt;code&gt;resources=*&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// +kubebuilder:rbac:groups=storage.example.com,resources=bucketclaims,verbs=get;list;watch;update;patch&lt;/span&gt;
&lt;span class="c"&gt;// +kubebuilder:rbac:groups=storage.example.com,resources=bucketclaims/status,verbs=get;update;patch&lt;/span&gt;
&lt;span class="c"&gt;// +kubebuilder:rbac:groups=storage.example.com,resources=bucketclaims/finalizers,verbs=update&lt;/span&gt;
&lt;span class="c"&gt;// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch;delete&lt;/span&gt;
&lt;span class="c"&gt;// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the dedicated &lt;code&gt;/finalizers&lt;/code&gt; subresource permission: it is required as soon as &lt;code&gt;OwnerReferencesPermissionEnforcement&lt;/code&gt; is enabled, which is the default on OpenShift.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope the cache.&lt;/strong&gt; A cluster-wide watch on &lt;code&gt;Secrets&lt;/code&gt; pulls every secret in the cluster into controller memory. Restrict it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;mgr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewManager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ByObject&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ByObject&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;corev1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Secret&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;Label&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SelectorFromSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"app.kubernetes.io/managed-by"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"bucket-operator"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Harden the pod.&lt;/strong&gt; On OpenShift, target the &lt;code&gt;restricted-v2&lt;/code&gt; SCC: do not pin &lt;code&gt;runAsUser&lt;/code&gt;, let the namespace range assign it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bucket-operator-controller-manager&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bucket-operator-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;control-plane&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;controller-manager&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;control-plane&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;controller-manager&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;serviceAccountName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bucket-operator-controller-manager&lt;/span&gt;
      &lt;span class="na"&gt;automountServiceAccountToken&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;runAsNonRoot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;seccompProfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;RuntimeDefault&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;manager&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;quay.io/acme/bucket-operator:v0.1.0&lt;/span&gt;
          &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--leader-elect"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--health-probe-bind-address=:8081"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;allowPrivilegeEscalation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
            &lt;span class="na"&gt;readOnlyRootFilesystem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
            &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALL"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;10m&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;64Mi&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;256Mi&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/healthz&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8081&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/readyz&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8081&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Leader election&lt;/strong&gt; (&lt;code&gt;--leader-elect&lt;/code&gt; with &lt;code&gt;replicas: 2&lt;/code&gt;) is a correctness control, not just HA: two active managers racing on the same external backend will double-provision. It needs &lt;code&gt;leases&lt;/code&gt; in &lt;code&gt;coordination.k8s.io&lt;/code&gt;, namespace-scoped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never log secret material.&lt;/strong&gt; &lt;code&gt;logf.FromContext(ctx)&lt;/code&gt; is structured — log the object key and the backend resource ID, never credentials or full object dumps. Credentials belong in a &lt;code&gt;Secret&lt;/code&gt; with an &lt;code&gt;ownerReference&lt;/code&gt; pointing at the CR, so they are garbage-collected automatically and never written to &lt;code&gt;status&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validate at admission, not in Go.&lt;/strong&gt; CEL rules in the CRD (and a validating webhook with a &lt;code&gt;service-ca&lt;/code&gt; managed certificate on OpenShift) block bad input before it ever reaches a worker. A &lt;code&gt;NetworkPolicy&lt;/code&gt; restricting egress to your backend endpoint closes the loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Reconcile&lt;/code&gt; is level-triggered: make it &lt;strong&gt;idempotent&lt;/strong&gt;, &lt;strong&gt;restartable&lt;/strong&gt;, and keyed on deterministic names derived from immutable fields.&lt;/li&gt;
&lt;li&gt;Add the finalizer &lt;strong&gt;before&lt;/strong&gt; the first external side effect; remove it &lt;strong&gt;only after&lt;/strong&gt; cleanup succeeds, and treat "already gone" as success.&lt;/li&gt;
&lt;li&gt;Use the &lt;strong&gt;status subresource&lt;/strong&gt; with &lt;code&gt;Status().Patch(client.MergeFrom(base))&lt;/code&gt; and &lt;code&gt;meta.SetStatusCondition&lt;/code&gt; — blind &lt;code&gt;Update&lt;/code&gt; calls are the most common infinite loop.&lt;/li&gt;
&lt;li&gt;Filter events with &lt;code&gt;GenerationChangedPredicate&lt;/code&gt;, but keep an explicit &lt;code&gt;RequeueAfter&lt;/code&gt; to detect drift in systems you cannot watch.&lt;/li&gt;
&lt;li&gt;Return an error only for transient failures; report user errors as conditions so the workqueue does not retry them forever.&lt;/li&gt;
&lt;li&gt;A finalizer that can never complete is a namespace that can never terminate: always expose the failure and provide an escape hatch.&lt;/li&gt;
&lt;li&gt;Lock down RBAC, scope the informer cache, and run the manager as a non-root, read-only, capability-dropped pod.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>go</category>
      <category>operators</category>
      <category>openshift</category>
    </item>
    <item>
      <title>Gateway API in Production: Replacing Ingress NGINX Without Breaking Your TLS Certificates</title>
      <dc:creator>Florient DOGBE</dc:creator>
      <pubDate>Sun, 01 Nov 2026 17:07:21 +0000</pubDate>
      <link>https://dev.to/florient_4262fab5463a691/gateway-api-in-production-replacing-ingress-nginx-without-breaking-your-tls-certificates-4g1o</link>
      <guid>https://dev.to/florient_4262fab5463a691/gateway-api-in-production-replacing-ingress-nginx-without-breaking-your-tls-certificates-4g1o</guid>
      <description>&lt;p&gt;Ingress NGINX is in maintenance mode, and the Kubernetes project has been nudging everyone toward Gateway API for a while now. The networking spec graduated to GA (v1) for &lt;code&gt;GatewayClass&lt;/code&gt;, &lt;code&gt;Gateway&lt;/code&gt; and &lt;code&gt;HTTPRoute&lt;/code&gt;, the ecosystem has converged, and your next cluster upgrade is a good excuse to stop postponing the migration.&lt;/p&gt;

&lt;p&gt;The problem is that "replace your ingress controller" is a sentence that hides a lot of risk: a single typo in a TLS listener and every browser in production gets a certificate error. This article walks through a migration strategy that never requires a big-bang cutover: run Gateway API &lt;strong&gt;next to&lt;/strong&gt; your existing Ingress NGINX, move one hostname at a time, shift traffic by weight, and validate the TLS chain at every step.&lt;/p&gt;

&lt;p&gt;We'll use Envoy Gateway as the controller and cert-manager for certificates, but the pattern applies to any conformant implementation (Istio, Cilium, Traefik, NGINX Gateway Fabric).&lt;/p&gt;

&lt;h2&gt;
  
  
  Context: why this migration is different
&lt;/h2&gt;

&lt;p&gt;Swapping an ingress controller is not like swapping a Deployment image. Three things make it risky:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;TLS termination moves.&lt;/strong&gt; Your certificates live in Secrets referenced by &lt;code&gt;Ingress&lt;/code&gt; objects. The new data plane has to reference the same Secrets (or new ones), possibly from a different namespace, under a stricter cross-namespace model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The public IP changes.&lt;/strong&gt; A new controller means a new &lt;code&gt;LoadBalancer&lt;/code&gt; Service, a new external IP, and a DNS change. DNS changes are not atomic — you cannot roll them back in 30 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Annotations don't translate.&lt;/strong&gt; Rewrites, CORS, rate limits, auth, timeouts — everything encoded in &lt;code&gt;nginx.ingress.kubernetes.io/*&lt;/code&gt; annotations becomes either native Gateway API fields, filters, or implementation-specific policies.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The good news: Gateway API was explicitly designed for coexistence. An &lt;code&gt;Ingress&lt;/code&gt; and a &lt;code&gt;Gateway&lt;/code&gt; can serve the same hostname from the same cluster, and nothing in the spec forbids two controllers from watching different resources. That's the lever we're going to pull.&lt;/p&gt;

&lt;h2&gt;
  
  
  Target architecture
&lt;/h2&gt;

&lt;p&gt;The model splits responsibilities across three object types, which maps nicely onto real org boundaries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;GatewayClass&lt;/code&gt;&lt;/strong&gt; — cluster-scoped, owned by the platform team. Points at a controller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Gateway&lt;/code&gt;&lt;/strong&gt; — the listener (ports, protocols, TLS certs, allowed routes). Lives in an infrastructure namespace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;HTTPRoute&lt;/code&gt;&lt;/strong&gt; — owned by application teams, in application namespaces, attached to a Gateway via &lt;code&gt;parentRefs&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ReferenceGrant&lt;/code&gt;&lt;/strong&gt; — the explicit opt-in that allows a cross-namespace reference (Gateway → Secret, HTTPRoute → Service).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Concretely: one shared &lt;code&gt;Gateway&lt;/code&gt; in &lt;code&gt;gateway-infra&lt;/code&gt;, TLS Secrets managed by cert-manager, and each team shipping its own &lt;code&gt;HTTPRoute&lt;/code&gt; in its own namespace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Install the CRDs and the controller
&lt;/h2&gt;

&lt;p&gt;On a vanilla Kubernetes cluster, install the standard channel CRDs first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml
kubectl get crd | &lt;span class="nb"&gt;grep &lt;/span&gt;gateway.networking.k8s.io
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;OpenShift note:&lt;/strong&gt; since 4.19, the Ingress Operator can manage the Gateway API CRDs itself. Check for ownership before applying anything, otherwise the operator will fight you:&lt;/p&gt;


&lt;pre class="highlight shell"&gt;&lt;code&gt;oc get crd gateways.gateway.networking.k8s.io &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.metadata.labels}{"\n"}{.metadata.annotations}{"\n"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;If the CRDs are operator-managed, skip the &lt;code&gt;kubectl apply&lt;/code&gt; and only install your controller. Envoy Gateway's data plane runs unprivileged on ports 10080/10443, so the &lt;code&gt;restricted-v2&lt;/code&gt; SCC is sufficient — no SCC patching required.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Install Envoy Gateway:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm &lt;span class="nb"&gt;install &lt;/span&gt;eg oci://docker.io/envoyproxy/gateway-helm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version&lt;/span&gt; v1.2.6 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; envoy-gateway-system &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; config.envoyGateway.provider.kubernetes.rateLimitDeployment.container.resources.requests.cpu&lt;span class="o"&gt;=&lt;/span&gt;100m

kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; envoy-gateway-system rollout status deploy/envoy-gateway
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then declare the &lt;code&gt;GatewayClass&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GatewayClass&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eg-external&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;controllerName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io/gatewayclass-controller&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get gatewayclass eg-external &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.status.conditions[?(@.type=="Accepted")].status}{"\n"}'&lt;/span&gt;
&lt;span class="c"&gt;# expected: True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2 — Wire cert-manager to Gateway API
&lt;/h2&gt;

&lt;p&gt;cert-manager ships a "gateway shim" that watches &lt;code&gt;Gateway&lt;/code&gt; objects for the &lt;code&gt;cert-manager.io/cluster-issuer&lt;/code&gt; annotation and creates &lt;code&gt;Certificate&lt;/code&gt; resources from the listeners. It's off by default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm upgrade &lt;span class="nt"&gt;--install&lt;/span&gt; cert-manager jetstack/cert-manager &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; cert-manager &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version&lt;/span&gt; v1.16.2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; crds.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; config.apiVersion&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"controller.config.cert-manager.io/v1alpha1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; config.kind&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"ControllerConfiguration"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; config.enableGatewayAPI&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For HTTP-01 solving, cert-manager can create a temporary &lt;code&gt;HTTPRoute&lt;/code&gt; instead of an Ingress:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cert-manager.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterIssuer&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;letsencrypt-prod&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;acme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://acme-v02.api.letsencrypt.org/directory&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;platform@example.com&lt;/span&gt;
    &lt;span class="na"&gt;privateKeySecretRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;letsencrypt-prod-account-key&lt;/span&gt;
    &lt;span class="na"&gt;solvers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;http01&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;gatewayHTTPRoute&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;parentRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps-gw&lt;/span&gt;
                &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway-infra&lt;/span&gt;
                &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Gateway&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Critical detail during coexistence:&lt;/strong&gt; if your existing Ingress NGINX still serves the hostname and holds the DNS record, HTTP-01 challenges will land on NGINX, not on the Gateway. Two safe options: keep issuing certificates through the existing Ingress solver until DNS is switched, or — far better — use DNS-01. DNS-01 is independent of which data plane owns the IP, which removes an entire class of migration failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — The Gateway, reusing the existing certificate
&lt;/h2&gt;

&lt;p&gt;Here's the trick that avoids re-issuing certs on day one: point the new Gateway at the &lt;strong&gt;same Secret&lt;/strong&gt; your Ingress already uses. No new ACME order, no rate-limit risk, identical chain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Gateway&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps-gw&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway-infra&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cert-manager.io/cluster-issuer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;letsencrypt-prod&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gatewayClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eg-external&lt;/span&gt;
  &lt;span class="na"&gt;listeners&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
      &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTP&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.example.com"&lt;/span&gt;
      &lt;span class="na"&gt;allowedRoutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;namespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Selector&lt;/span&gt;
          &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;gateway-access&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https-shop&lt;/span&gt;
      &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPS&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;443&lt;/span&gt;
      &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop.example.com&lt;/span&gt;
      &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Terminate&lt;/span&gt;
        &lt;span class="na"&gt;certificateRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Secret&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop-example-com-tls&lt;/span&gt;
            &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop&lt;/span&gt;
      &lt;span class="na"&gt;allowedRoutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;namespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Selector&lt;/span&gt;
          &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;gateway-access&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice. First, &lt;code&gt;allowedRoutes&lt;/code&gt; uses a label selector, not &lt;code&gt;All&lt;/code&gt; — namespaces must explicitly opt in. Second, the certificate Secret lives in the &lt;code&gt;shop&lt;/code&gt; namespace while the Gateway lives in &lt;code&gt;gateway-infra&lt;/code&gt;. That cross-namespace reference is &lt;strong&gt;denied by default&lt;/strong&gt;. You need a &lt;code&gt;ReferenceGrant&lt;/code&gt; in the namespace that &lt;em&gt;owns the Secret&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ReferenceGrant&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;allow-gw-to-read-shop-tls&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Gateway&lt;/span&gt;
      &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway-infra&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Secret&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop-example-com-tls&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;name&lt;/code&gt; field in &lt;code&gt;to:&lt;/code&gt; — grant access to that one Secret, not to every Secret in the namespace.&lt;/p&gt;

&lt;p&gt;Verify the listener is actually programmed. This is the single most useful command of the whole migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; gateway-infra get gateway apps-gw &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{range .status.listeners[*]}{.name}{"\t"}{range .conditions[*]}{.type}={.status}{" "}{end}{"\n"}{end}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;ResolvedRefs=False&lt;/code&gt; with reason &lt;code&gt;RefNotPermitted&lt;/code&gt; means your ReferenceGrant is missing or mismatched.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — The HTTPRoute
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPRoute&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;parentRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps-gw&lt;/span&gt;
      &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway-infra&lt;/span&gt;
      &lt;span class="na"&gt;sectionName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https-shop&lt;/span&gt;
  &lt;span class="na"&gt;hostnames&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;shop.example.com&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PathPrefix&lt;/span&gt;
            &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/api&lt;/span&gt;
      &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RequestHeaderModifier&lt;/span&gt;
          &lt;span class="na"&gt;requestHeaderModifier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt;
                &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https&lt;/span&gt;
      &lt;span class="na"&gt;backendRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop-api&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PathPrefix&lt;/span&gt;
            &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
      &lt;span class="na"&gt;backendRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop-frontend&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't forget the namespace label required by &lt;code&gt;allowedRoutes&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl label namespace shop gateway-access&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a rule needs to target a Service in another namespace (say &lt;code&gt;payments&lt;/code&gt;), add a &lt;code&gt;ReferenceGrant&lt;/code&gt; in &lt;code&gt;payments&lt;/code&gt; with &lt;code&gt;from.kind: HTTPRoute&lt;/code&gt;. Same mechanism, same least-privilege rule: name the target Service explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — Validate TLS before touching DNS
&lt;/h2&gt;

&lt;p&gt;At this point both data planes serve &lt;code&gt;shop.example.com&lt;/code&gt;, but only NGINX has the DNS record. Test the Gateway directly by resolving the hostname yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;GW_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; envoy-gateway-system get svc &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-l&lt;/span&gt; gateway.envoyproxy.io/owning-gateway-name&lt;span class="o"&gt;=&lt;/span&gt;apps-gw &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.items[0].status.loadBalancer.ingress[0].ip}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

curl &lt;span class="nt"&gt;-sSv&lt;/span&gt; &lt;span class="nt"&gt;--resolve&lt;/span&gt; shop.example.com:443:&lt;span class="nv"&gt;$GW_IP&lt;/span&gt; https://shop.example.com/healthz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then compare the certificate served by each data plane. Identical fingerprints mean zero client-visible change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fp&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;:443"&lt;/span&gt; &lt;span class="nt"&gt;-servername&lt;/span&gt; shop.example.com &amp;lt;/dev/null 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-fingerprint&lt;/span&gt; &lt;span class="nt"&gt;-sha256&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;NGINX_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;dig +short shop.example.com | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"nginx:   &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;fp &lt;span class="nv"&gt;$NGINX_IP&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"gateway: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;fp &lt;span class="nv"&gt;$GW_IP&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also check the full chain and SAN list — a frequent failure mode is a Gateway serving only the leaf certificate because the Secret was built by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; &lt;span class="nv"&gt;$GW_IP&lt;/span&gt;:443 &lt;span class="nt"&gt;-servername&lt;/span&gt; shop.example.com &lt;span class="nt"&gt;-showcerts&lt;/span&gt; &amp;lt;/dev/null 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'s:|i:'&lt;/span&gt;

openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; &lt;span class="nv"&gt;$GW_IP&lt;/span&gt;:443 &lt;span class="nt"&gt;-servername&lt;/span&gt; shop.example.com &amp;lt;/dev/null 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-dates&lt;/span&gt; &lt;span class="nt"&gt;-ext&lt;/span&gt; subjectAltName
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And make sure an unknown SNI doesn't silently fall through to the wrong certificate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; &lt;span class="nv"&gt;$GW_IP&lt;/span&gt;:443 &lt;span class="nt"&gt;-servername&lt;/span&gt; notmydomain.example.com &amp;lt;/dev/null 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 6 — Shift traffic by weight, without DNS
&lt;/h2&gt;

&lt;p&gt;DNS-based cutovers are coarse: TTLs lie, resolvers cache, and rollback takes minutes. A much tighter loop is to make Ingress NGINX itself forward a percentage of traffic to the Gateway, using its canary annotations plus an &lt;code&gt;ExternalName&lt;/code&gt; Service.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gw-bridge&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ExternalName&lt;/span&gt;
  &lt;span class="na"&gt;externalName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-gateway-infra-apps-gw-7f9c2d11.envoy-gateway-system.svc.cluster.local&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;443&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Get the real Service name first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; envoy-gateway-system get svc &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-l&lt;/span&gt; gateway.envoyproxy.io/owning-gateway-name&lt;span class="o"&gt;=&lt;/span&gt;apps-gw &lt;span class="nt"&gt;-o&lt;/span&gt; name
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a canary Ingress on the same hostname:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop-canary-to-gateway&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/canary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/canary-weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5"&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/backend-protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HTTPS"&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/upstream-vhost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shop.example.com"&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/proxy-ssl-server-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on"&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/proxy-ssl-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shop.example.com"&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ingressClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
  &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;shop.example.com&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop-example-com-tls&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop.example.com&lt;/span&gt;
      &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
            &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
            &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gw-bridge&lt;/span&gt;
                &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;443&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you have a real dial. Ramp it with a single patch, watch your SLOs, and roll back in seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;w &lt;span class="k"&gt;in &lt;/span&gt;5 25 50 100&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; shop annotate ingress shop-canary-to-gateway &lt;span class="se"&gt;\&lt;/span&gt;
    nginx.ingress.kubernetes.io/canary-weight&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$w&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--overwrite&lt;/span&gt;
  &lt;span class="nb"&gt;sleep &lt;/span&gt;600
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# instant rollback&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; shop annotate ingress shop-canary-to-gateway &lt;span class="se"&gt;\&lt;/span&gt;
  nginx.ingress.kubernetes.io/canary-weight&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"0"&lt;/span&gt; &lt;span class="nt"&gt;--overwrite&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also pin the canary to a header (&lt;code&gt;nginx.ingress.kubernetes.io/canary-by-header: x-use-gateway&lt;/code&gt;) so QA can target the new path deterministically before any percentage of real users is affected.&lt;/p&gt;

&lt;p&gt;Once 100% has been stable for a few days, switch DNS to the Gateway's IP, wait out the TTL, then delete the canary Ingress and the legacy Ingress. The same weighting primitive exists natively inside Gateway API for backend-level canaries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="na"&gt;backendRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop-frontend&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
          &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;90&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop-frontend-next&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
          &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Security best practices
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Never use &lt;code&gt;allowedRoutes.namespaces.from: All&lt;/code&gt; on an internet-facing Gateway.&lt;/strong&gt; Any namespace in the cluster could then attach a route to your public listener and claim a hostname. Use &lt;code&gt;Same&lt;/code&gt; or a label &lt;code&gt;Selector&lt;/code&gt;, and treat the label as a privileged operation controlled by the platform team.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope every ReferenceGrant.&lt;/strong&gt; Always set &lt;code&gt;to[].name&lt;/code&gt;. A grant without a name allows every Secret (or every Service) of that kind in the namespace — including, potentially, the Secret holding a wildcard certificate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enforce TLS floor and cipher policy.&lt;/strong&gt; Gateway API leaves this to implementation-specific policies. With Envoy Gateway:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClientTrafficPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tls-hardening&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway-infra&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;targetRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Gateway&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps-gw&lt;/span&gt;
  &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;minVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.2"&lt;/span&gt;
    &lt;span class="na"&gt;maxVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.3"&lt;/span&gt;
    &lt;span class="na"&gt;ciphers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ECDHE-ECDSA-AES256-GCM-SHA384&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ECDHE-RSA-AES256-GCM-SHA384&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ECDHE-ECDSA-CHACHA20-POLY1305&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;xForwardedClientCert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Sanitize&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Redirect HTTP to HTTPS at the route level&lt;/strong&gt;, so a misconfigured app can never be served in clear text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPRoute&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https-redirect&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;parentRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps-gw&lt;/span&gt;
      &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway-infra&lt;/span&gt;
      &lt;span class="na"&gt;sectionName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
  &lt;span class="na"&gt;hostnames&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shop.example.com"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RequestRedirect&lt;/span&gt;
          &lt;span class="na"&gt;requestRedirect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;scheme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https&lt;/span&gt;
            &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;301&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Encrypt the last hop too.&lt;/strong&gt; If your backend speaks TLS, use &lt;code&gt;BackendTLSPolicy&lt;/code&gt; with explicit CA validation and hostname verification rather than disabling verification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1alpha3&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BackendTLSPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop-api-upstream-tls&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;targetRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop-api&lt;/span&gt;
      &lt;span class="na"&gt;sectionName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8443"&lt;/span&gt;
  &lt;span class="na"&gt;validation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;caCertificateRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
        &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ConfigMap&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;internal-ca&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop-api.shop.svc.cluster.local&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lock down the data plane with NetworkPolicy.&lt;/strong&gt; The Envoy pods should accept traffic from the LoadBalancer and reach only the namespaces they route to. On OpenShift, confirm the proxy runs under &lt;code&gt;restricted-v2&lt;/code&gt; and not a relaxed SCC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc &lt;span class="nt"&gt;-n&lt;/span&gt; envoy-gateway-system get pod &lt;span class="nt"&gt;-l&lt;/span&gt; app.kubernetes.io/name&lt;span class="o"&gt;=&lt;/span&gt;envoy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{range .items[*]}{.metadata.name}{"\t"}{.metadata.annotations.openshift\.io/scc}{"\n"}{end}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Audit certificate ownership after the switch.&lt;/strong&gt; When you finally delete the legacy Ingress, make sure cert-manager still owns the Certificate object — deleting an Ingress with the &lt;code&gt;cert-manager.io/cluster-issuer&lt;/code&gt; annotation can garbage-collect the Certificate and, depending on your setup, the Secret with it. Create a standalone &lt;code&gt;Certificate&lt;/code&gt; resource explicitly rather than relying on annotation-driven shims for anything production-critical:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cert-manager.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Certificate&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop-example-com&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop-example-com-tls&lt;/span&gt;
  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2160h&lt;/span&gt;
  &lt;span class="na"&gt;renewBefore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;720h&lt;/span&gt;
  &lt;span class="na"&gt;privateKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;algorithm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ECDSA&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;256&lt;/span&gt;
    &lt;span class="na"&gt;rotationPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Always&lt;/span&gt;
  &lt;span class="na"&gt;dnsNames&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;shop.example.com&lt;/span&gt;
  &lt;span class="na"&gt;issuerRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;letsencrypt-prod&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterIssuer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Alert on expiry independently.&lt;/strong&gt; Scrape &lt;code&gt;certmanager_certificate_expiration_timestamp_seconds&lt;/code&gt; and alert at 14 days, plus a black-box probe that checks the certificate actually served on port 443. A valid Secret that the Gateway failed to load is a silent outage waiting for renewal day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Migrating off Ingress NGINX doesn't have to be a maintenance-window event. The key takeaways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Coexist first.&lt;/strong&gt; Gateway API and Ingress can serve the same hostname simultaneously. Build the Gateway, validate it out-of-band with &lt;code&gt;curl --resolve&lt;/code&gt;, and only then send it traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reuse the existing TLS Secret&lt;/strong&gt; for the first cutover. Comparing SHA-256 fingerprints between the old and new data plane is a 10-second check that eliminates the scariest failure mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ReferenceGrant is a security boundary, not a formality.&lt;/strong&gt; Scope it to a named Secret or Service, and keep &lt;code&gt;allowedRoutes&lt;/code&gt; on a label selector.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shift traffic with weights, not DNS.&lt;/strong&gt; The NGINX canary + &lt;code&gt;ExternalName&lt;/code&gt; bridge gives you a percentage dial with sub-second rollback. Move DNS only once 100% has been stable for days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer DNS-01 during the transition&lt;/strong&gt;, so certificate issuance never depends on which data plane currently owns the public IP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Harden explicitly.&lt;/strong&gt; TLS floor, HTTP→HTTPS redirect, upstream TLS validation and NetworkPolicy are all policy objects you have to write — nothing is secure by default just because it's new.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do one low-traffic hostname end to end first. The second one takes an afternoon, and by the fifth you'll be deleting Ingress objects without thinking about it.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>gatewayapi</category>
      <category>certmanager</category>
      <category>envoy</category>
    </item>
    <item>
      <title>Upgrading an OpenShift Cluster Without Waking the On-Call: Channels, MCPs and Controlled Pauses</title>
      <dc:creator>Florient DOGBE</dc:creator>
      <pubDate>Sun, 01 Nov 2026 17:03:07 +0000</pubDate>
      <link>https://dev.to/florient_4262fab5463a691/upgrading-an-openshift-cluster-without-waking-the-on-call-channels-mcps-and-controlled-pauses-29n7</link>
      <guid>https://dev.to/florient_4262fab5463a691/upgrading-an-openshift-cluster-without-waking-the-on-call-channels-mcps-and-controlled-pauses-29n7</guid>
      <description>&lt;p&gt;An OpenShift upgrade is two very different operations wearing the same trench coat. The first one is a control-plane operator rollout driven by the Cluster Version Operator (CVO): fast, mostly invisible, and reversible only in narrow cases. The second one is a fleet-wide node reboot campaign driven by the Machine Config Operator (MCO): slow, disruptive, and entirely at the mercy of your PodDisruptionBudgets.&lt;/p&gt;

&lt;p&gt;On a 12-node lab cluster nobody notices. On a 300-node cluster with stateful workloads, a default &lt;code&gt;maxUnavailable: 1&lt;/code&gt; on the &lt;code&gt;worker&lt;/code&gt; pool means a reboot campaign that runs for 20 hours straight — right across your 3 a.m. quiet window, with no natural checkpoint. That is how upgrades end up paging the on-call.&lt;/p&gt;

&lt;p&gt;This article breaks down the machinery — &lt;code&gt;ClusterVersion&lt;/code&gt;, update channels, &lt;code&gt;MachineConfigPool&lt;/code&gt; — and shows how to turn a monolithic upgrade into a wave-based rollout with real maintenance windows and a usable abort path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context: the two clocks of an OpenShift upgrade
&lt;/h2&gt;

&lt;p&gt;The CVO reconciles a single cluster-scoped object, &lt;code&gt;ClusterVersion/version&lt;/code&gt;. You declare a target payload; the CVO walks the manifests of the release image in dependency order, updating every ClusterOperator. Control-plane nodes get their new RHCOS config from the MCO as part of that process, and because there are only three of them with &lt;code&gt;maxUnavailable: 1&lt;/code&gt; hardcoded, it is bounded and predictable.&lt;/p&gt;

&lt;p&gt;Then the worker pools start. The MCO renders a new &lt;code&gt;MachineConfig&lt;/code&gt; per pool, cordons a node, drains it, applies the OS update via rpm-ostree, reboots, uncordons, and moves to the next. The CVO reports &lt;code&gt;Completed&lt;/code&gt; for the version long before the last worker has rebooted — &lt;code&gt;oc get clusterversion&lt;/code&gt; will happily show &lt;code&gt;4.16.21&lt;/code&gt; while 180 nodes are still pending. That gap is exactly where unplanned incidents live.&lt;/p&gt;

&lt;p&gt;The lever you control is the &lt;code&gt;MachineConfigPool&lt;/code&gt;. Everything below is about using it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CVO side: ClusterVersion and channels
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Reading the graph before touching anything
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc adm upgrade
oc get clusterversion version &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{range .status.conditions[*]}{.type}{"\t"}{.status}{"\t"}{.message}{"\n"}{end}'&lt;/span&gt;
oc get co
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things matter in that output:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Upgradeable=False&lt;/code&gt;&lt;/strong&gt; blocks minor upgrades. The message tells you why: a pending admin ack for removed Kubernetes APIs, an operator in a bad state, or an OLM operator with no compatible version for the target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recommended vs conditional updates.&lt;/strong&gt; &lt;code&gt;oc adm upgrade&lt;/code&gt; only lists recommended targets. Use &lt;code&gt;oc adm upgrade --include-not-recommended&lt;/code&gt; to see conditional ones along with the risk reason (a known bug affecting a specific platform or configuration). Read the risk before you accept it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Degraded ClusterOperators.&lt;/strong&gt; The CVO refuses to start if an operator is &lt;code&gt;Degraded=True&lt;/code&gt;, unless you pass &lt;code&gt;--allow-upgrade-with-warnings&lt;/code&gt;. Don't.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Channel semantics
&lt;/h3&gt;

&lt;p&gt;Channels are not "quality tiers" in the sense people assume. A given &lt;code&gt;4.16.z&lt;/code&gt; release lands in &lt;code&gt;candidate-4.16&lt;/code&gt;, then &lt;code&gt;fast-4.16&lt;/code&gt;, then &lt;code&gt;stable-4.16&lt;/code&gt; after a soak period driven by telemetry. The payload is identical; only the recommendation timing differs. &lt;code&gt;eus-4.16&lt;/code&gt; exists only for even-numbered minors and carries the extended-support stream plus the edges needed for EUS-to-EUS hops.&lt;/p&gt;

&lt;p&gt;A sane fleet policy: non-production on &lt;code&gt;fast-4.y&lt;/code&gt;, production on &lt;code&gt;stable-4.y&lt;/code&gt;, and EUS-pinned clusters on &lt;code&gt;eus-4.y&lt;/code&gt;. Switching channel is declarative and non-destructive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Move to the next minor's channel to expose the upgrade edges&lt;/span&gt;
oc adm upgrade channel stable-4.16

&lt;span class="c"&gt;# Target a specific patch, never "latest"&lt;/span&gt;
oc adm upgrade &lt;span class="nt"&gt;--to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4.16.21
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always pin the exact version. &lt;code&gt;--to-latest&lt;/code&gt; is a moving target between the moment you write the change request and the moment you execute it.&lt;/p&gt;

&lt;p&gt;If you need to abort before any manifest has been applied:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc adm upgrade &lt;span class="nt"&gt;--clear&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This only works while the update has not actually started rolling. Once ClusterOperators begin moving, the forward path is the only path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The MCO side: MachineConfigPool as a rollout controller
&lt;/h2&gt;

&lt;h3&gt;
  
  
  maxUnavailable is your throughput dial
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;worker&lt;/code&gt; pool defaults to &lt;code&gt;maxUnavailable: 1&lt;/code&gt;. On a large cluster, compute your window budget first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;window_duration / (drain_time + reboot_time) * maxUnavailable = nodes per window
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a 12-minute average per node (drain included) and a 4-hour window, &lt;code&gt;maxUnavailable: 1&lt;/code&gt; gives you 20 nodes. &lt;code&gt;maxUnavailable: 10%&lt;/code&gt; on a 200-node pool gives 20 concurrent reboots — far too aggressive for most workloads. Something between 3 and 8 % is usually the sweet spot, but validate against your PDBs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc get pdb &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; custom-columns&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="s1"&gt;'NS:.metadata.namespace,NAME:.metadata.name,MIN:.spec.minAvailable,MAX:.spec.maxUnavailable,ALLOWED:.status.disruptionsAllowed'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any PDB with &lt;code&gt;disruptionsAllowed: 0&lt;/code&gt; will stall a drain indefinitely. The MCO retries the drain for an hour, then marks the pool &lt;code&gt;Degraded&lt;/code&gt; with &lt;code&gt;NodeDrainFailed&lt;/code&gt;. Fix those before the window, not during.&lt;/p&gt;

&lt;h3&gt;
  
  
  Custom pools are your waves
&lt;/h3&gt;

&lt;p&gt;A node carrying a custom role label leaves the &lt;code&gt;worker&lt;/code&gt; pool and joins the matching custom pool, which inherits worker MachineConfigs through &lt;code&gt;machineConfigSelector&lt;/code&gt;. That is the primitive for wave-based rollouts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;machineconfiguration.openshift.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MachineConfigPool&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wave-a&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# Pick up every rendered worker config, plus anything scoped to wave-a&lt;/span&gt;
  &lt;span class="na"&gt;machineConfigSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchExpressions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;machineconfiguration.openshift.io/role&lt;/span&gt;
        &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&lt;/span&gt;
        &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;worker&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;wave-a&lt;/span&gt;
  &lt;span class="na"&gt;nodeSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;node-role.kubernetes.io/wave-a&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
  &lt;span class="na"&gt;maxUnavailable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;paused&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Canary wave: 5 nodes spread across failure domains&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;n &lt;span class="k"&gt;in &lt;/span&gt;worker-a-01 worker-b-01 worker-c-01 worker-a-02 worker-b-02&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;oc label node &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; node-role.kubernetes.io/wave-a&lt;span class="o"&gt;=&lt;/span&gt;
&lt;span class="k"&gt;done

&lt;/span&gt;oc get mcp wave-a &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.status.machineCount}{"\n"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create &lt;code&gt;wave-a&lt;/code&gt;, &lt;code&gt;wave-b&lt;/code&gt;, &lt;code&gt;wave-c&lt;/code&gt; (and leave the remainder in &lt;code&gt;worker&lt;/code&gt;), all with &lt;code&gt;paused: true&lt;/code&gt;. A node must never match two &lt;em&gt;custom&lt;/em&gt; pools — the MCO will mark it degraded with &lt;code&gt;node has been in multiple pools&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One caveat on labelling: moving a node between pools triggers a config change and therefore a reboot. Do the labelling work &lt;em&gt;before&lt;/em&gt; the upgrade, in a quiet period, so that upgrade day only flips &lt;code&gt;paused&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Orchestrating the rollout
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Phase 0 — preflight, D-2
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# etcd backup on a control-plane node&lt;/span&gt;
oc debug node/master-0 &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;chroot&lt;/span&gt; /host /usr/local/bin/cluster-backup.sh /home/core/assets/backup

&lt;span class="c"&gt;# No firing alerts that would mask upgrade symptoms&lt;/span&gt;
oc &lt;span class="nt"&gt;-n&lt;/span&gt; openshift-monitoring &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; prometheus prometheus-k8s-0 &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:9090/api/v1/alerts | jq &lt;span class="s1"&gt;'.data.alerts[] | select(.state=="firing") | .labels.alertname'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;

&lt;span class="c"&gt;# Admin acks for removed APIs (key name is in the Upgradeable message)&lt;/span&gt;
oc &lt;span class="nt"&gt;-n&lt;/span&gt; openshift-config patch cm admin-acks &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;merge &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"data":{"ack-4.15-kube-1.29-api-removals-in-4.16":"true"}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also confirm every OLM-managed operator has a channel compatible with the target minor; an operator stuck on an incompatible channel is a classic post-upgrade outage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 1 — pause the fleet, upgrade the control plane
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;p &lt;span class="k"&gt;in &lt;/span&gt;worker wave-a wave-b wave-c&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;oc patch mcp/&lt;span class="nv"&gt;$p&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;merge &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"spec":{"paused":true}}'&lt;/span&gt;
&lt;span class="k"&gt;done

&lt;/span&gt;oc adm upgrade channel stable-4.16
oc adm upgrade &lt;span class="nt"&gt;--to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4.16.21

&lt;span class="c"&gt;# Follow progress (oc 4.16+ ships a richer status view)&lt;/span&gt;
watch &lt;span class="nt"&gt;-n&lt;/span&gt; 30 &lt;span class="s1"&gt;'oc get clusterversion version; oc get co | grep -v "True        False         False"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With all worker pools paused, the blast radius is the control plane only: three sequential master reboots, operators rolled out, zero worker drain. This typically fits in a 90-minute window.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 2 — canary wave
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc patch mcp/wave-a &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;merge &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"spec":{"paused":false}}'&lt;/span&gt;

watch &lt;span class="nt"&gt;-n&lt;/span&gt; 20 &lt;span class="s1"&gt;'oc get mcp wave-a -o custom-columns=\
NAME:.metadata.name,UPDATED:.status.updatedMachineCount,\
TOTAL:.status.machineCount,DEGRADED:.status.degradedMachineCount'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per-node visibility when something stalls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc get nodes &lt;span class="nt"&gt;-l&lt;/span&gt; node-role.kubernetes.io/wave-a &lt;span class="nt"&gt;-o&lt;/span&gt; custom-columns&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="s1"&gt;'NAME:.metadata.name,STATE:.metadata.annotations.machineconfiguration\.openshift\.io/state,\
CURRENT:.metadata.annotations.machineconfiguration\.openshift\.io/currentConfig,\
DESIRED:.metadata.annotations.machineconfiguration\.openshift\.io/desiredConfig'&lt;/span&gt;

&lt;span class="c"&gt;# machine-config-daemon logs for a stuck node&lt;/span&gt;
oc &lt;span class="nt"&gt;-n&lt;/span&gt; openshift-machine-config-operator logs &lt;span class="nt"&gt;-l&lt;/span&gt; k8s-app&lt;span class="o"&gt;=&lt;/span&gt;machine-config-daemon &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--field-selector&lt;/span&gt; spec.nodeName&lt;span class="o"&gt;=&lt;/span&gt;worker-a-01 &lt;span class="nt"&gt;--tail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then bake. Give the canary 24 to 48 hours of real traffic before opening the next wave. That bake time is the entire point of the exercise.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 3 — remaining waves
&lt;/h3&gt;

&lt;p&gt;Unpause one pool per maintenance window, and re-pause it at the end of the window if it has not finished:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# End of window: freeze whatever is left, mid-flight nodes finish cleanly&lt;/span&gt;
oc patch mcp/wave-b &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;merge &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"spec":{"paused":true}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pausing mid-rollout does not interrupt a node already draining; it only stops the MCO from picking up new ones. That is your natural checkpoint — and the reason the on-call phone stays quiet.&lt;/p&gt;

&lt;h2&gt;
  
  
  EUS-to-EUS: skipping a minor without double-draining
&lt;/h2&gt;

&lt;p&gt;EUS channels let you go from 4.14 to 4.16 with a &lt;strong&gt;single&lt;/strong&gt; worker reboot campaign instead of two. The mechanics are exactly the pause trick, applied across two consecutive control-plane upgrades:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Pause every worker-bearing pool&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;p &lt;span class="k"&gt;in &lt;/span&gt;worker wave-a wave-b wave-c&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;oc patch mcp/&lt;span class="nv"&gt;$p&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;merge &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"spec":{"paused":true}}'&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# 2. 4.14 -&amp;gt; 4.15 (control plane only)&lt;/span&gt;
oc adm upgrade channel eus-4.16
oc adm upgrade &lt;span class="nt"&gt;--to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4.15.44

&lt;span class="c"&gt;# 3. 4.15 -&amp;gt; 4.16 (control plane only)&lt;/span&gt;
oc adm upgrade &lt;span class="nt"&gt;--to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4.16.21

&lt;span class="c"&gt;# 4. Only now, unpause wave by wave&lt;/span&gt;
oc patch mcp/wave-a &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;merge &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"spec":{"paused":false}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two constraints are non-negotiable. First, the control plane can only ever be two minors ahead of the kubelet (the Kubernetes version-skew policy), so you cannot chain three minors. Second, &lt;strong&gt;a paused pool stops receiving the rotated kubelet client CA&lt;/strong&gt;. The &lt;code&gt;MachineConfigControllerPausedPoolKubeletCA&lt;/code&gt; alert fires when that becomes dangerous; if you ignore it long enough, nodes lose their ability to authenticate to the API server. Treat a paused pool as a resource with a TTL measured in days, not weeks, and track it explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rollback: what is actually possible
&lt;/h2&gt;

&lt;p&gt;Be honest with the change advisory board: &lt;strong&gt;a completed minor upgrade is not reversible&lt;/strong&gt;. CRD schema migrations, etcd storage version bumps and operator-managed data changes have no downgrade path. What you do have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Abort before start&lt;/strong&gt; — &lt;code&gt;oc adm upgrade --clear&lt;/code&gt;, valid only while no manifest has been applied.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Z-stream rollback&lt;/strong&gt; — recent &lt;code&gt;oc&lt;/code&gt; builds expose &lt;code&gt;oc adm upgrade rollback&lt;/code&gt;, restricted to returning to the previously installed patch release within the same minor. Check &lt;code&gt;oc adm upgrade --help&lt;/code&gt; on your client version before relying on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial rollback at pool level&lt;/strong&gt; — this is the realistic one. Pools still paused have never left the old OS version. If the canary wave misbehaves, you keep 95 % of the fleet on the known-good RHCOS image while you investigate, and the cluster keeps running in a supported skew.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MachineConfig rollback&lt;/strong&gt; — deleting a custom &lt;code&gt;MachineConfig&lt;/code&gt; makes the MCO render the previous configuration and roll the pool back to it. This covers your own kernel args, chrony or kubelet tweaks, not the OS payload itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On 4.17+, &lt;code&gt;nodeDisruptionPolicy&lt;/code&gt; removes reboots entirely for many file and unit changes, shrinking the rollback surface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;operator.openshift.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MachineConfiguration&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cluster&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nodeDisruptionPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/chrony.conf&lt;/span&gt;
        &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Reload&lt;/span&gt;
            &lt;span class="na"&gt;reload&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;serviceName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chronyd.service&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Security best practices
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Never use &lt;code&gt;--force&lt;/code&gt;.&lt;/strong&gt; It bypasses release-image signature verification and precondition checks. If &lt;code&gt;oc adm upgrade&lt;/code&gt; refuses a target, the answer is to fix the precondition, not to disable the guardrail. For disconnected clusters, verify the mirrored payload explicitly and declare mirrors through &lt;code&gt;ImageDigestMirrorSet&lt;/code&gt; so signature validation still resolves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock down who can write to &lt;code&gt;ClusterVersion&lt;/code&gt; and &lt;code&gt;MachineConfigPool&lt;/code&gt;.&lt;/strong&gt; Both are cluster-scoped and both are production-destroying. &lt;code&gt;patch&lt;/code&gt; on &lt;code&gt;machineconfigpools&lt;/code&gt; is effectively "reboot the fleet" or "silently stop security patches" — audit it like a privileged credential and restrict it to a dedicated upgrade ServiceAccount used by your pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat &lt;code&gt;paused: true&lt;/code&gt; as a security exception.&lt;/strong&gt; A paused pool stops receiving RHCOS CVE fixes &lt;em&gt;and&lt;/em&gt; certificate rotations. Add a monitoring rule that alerts on any pool paused for more than 72 hours, and require a ticket reference in the pool annotations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not use &lt;code&gt;spec.overrides&lt;/code&gt; in &lt;code&gt;ClusterVersion&lt;/code&gt;&lt;/strong&gt; to silence a stuck operator. It makes the CVO stop managing that component — including its security updates — and it sets &lt;code&gt;Upgradeable=False&lt;/code&gt; for everything that follows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Handle admin acks deliberately.&lt;/strong&gt; &lt;code&gt;ack-*-api-removals-*&lt;/code&gt; keys exist because workloads still calling removed APIs will break. Query the &lt;code&gt;apirequestcounts&lt;/code&gt; resource (&lt;code&gt;oc get apirequestcounts -o json&lt;/code&gt;) to identify the callers before acking — not after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Protect the etcd backup.&lt;/strong&gt; It contains every Secret in the cluster in plaintext unless etcd encryption is enabled. Encrypt at rest, restrict access, and set a retention policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ClusterVersion&lt;/code&gt; drives the control plane; &lt;code&gt;MachineConfigPool&lt;/code&gt; drives the nodes. They complete at very different times, and only the second one touches your workloads.&lt;/li&gt;
&lt;li&gt;Pin an exact patch version, read conditional-update risks, and clear &lt;code&gt;Upgradeable=False&lt;/code&gt; properly instead of forcing through it.&lt;/li&gt;
&lt;li&gt;Pre-create custom pools (&lt;code&gt;wave-a&lt;/code&gt;, &lt;code&gt;wave-b&lt;/code&gt;, …) with tuned &lt;code&gt;maxUnavailable&lt;/code&gt;, label nodes ahead of time, and upgrade by unpausing one pool per maintenance window.&lt;/li&gt;
&lt;li&gt;Pausing a pool is a checkpoint, not a parking spot: kubelet CA rotation and CVE patching stop with it.&lt;/li&gt;
&lt;li&gt;EUS-to-EUS halves your reboot campaigns, at the cost of a longer pause window you must actively monitor.&lt;/li&gt;
&lt;li&gt;Rollback is a pool-level concept, not a cluster-level one. Design the waves so that "stop here" is always a valid, supported state.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>openshift</category>
      <category>kubernetes</category>
      <category>devops</category>
      <category>sre</category>
    </item>
    <item>
      <title>OpenShift Pipelines (Tekton): From a Monolithic Jenkinsfile to Reusable Tasks</title>
      <dc:creator>Florient DOGBE</dc:creator>
      <pubDate>Fri, 30 Oct 2026 15:13:24 +0000</pubDate>
      <link>https://dev.to/florient_4262fab5463a691/openshift-pipelines-tekton-from-a-monolithic-jenkinsfile-to-reusable-tasks-1j7c</link>
      <guid>https://dev.to/florient_4262fab5463a691/openshift-pipelines-tekton-from-a-monolithic-jenkinsfile-to-reusable-tasks-1j7c</guid>
      <description>&lt;p&gt;Most teams that adopt OpenShift arrive with luggage: a &lt;code&gt;Jenkinsfile&lt;/code&gt; that started at 60 lines and now weighs 800, with nested &lt;code&gt;script {}&lt;/code&gt; blocks, a shared library nobody dares to touch, and three agents whose Docker images were last rebuilt in 2021. It works — until a new service needs the same build logic, and the only migration path is copy-paste.&lt;/p&gt;

&lt;p&gt;OpenShift Pipelines (the supported downstream of Tekton) changes the unit of reuse. Instead of one file describing the whole flow, you get &lt;code&gt;Task&lt;/code&gt; objects: versioned, namespaced (or clusterwide), testable in isolation, and composed into &lt;code&gt;Pipeline&lt;/code&gt; objects. This article is a pragmatic migration guide: how to cut a monolithic Jenkinsfile into Tasks, how to handle workspaces, dependency resolution and build cache, and how to trigger everything from Git with an &lt;code&gt;EventListener&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context: why the monolith hurts on Kubernetes
&lt;/h2&gt;

&lt;p&gt;A Jenkinsfile is executable Groovy. That is its strength and its curse. Logic, orchestration, and infrastructure assumptions all live in the same file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="s1"&gt;'maven-jdk17'&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;stages&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Checkout'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;checkout&lt;/span&gt; &lt;span class="n"&gt;scm&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Build'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;    &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'mvn -B -s settings.xml clean package'&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Test'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;     &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'mvn -B verify'&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Image'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;    &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'podman build -t $REG/$APP:$TAG . &amp;amp;&amp;amp; podman push ...'&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Deploy'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;   &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'oc apply -f k8s/ &amp;amp;&amp;amp; oc rollout status deploy/$APP'&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three problems surface once you run this at scale on OpenShift:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No reuse boundary.&lt;/strong&gt; &lt;code&gt;stage('Image')&lt;/code&gt; is identical in 40 repositories, but each repo owns its own copy. One CVE in the build tooling means 40 pull requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implicit state.&lt;/strong&gt; Jenkins gives you a persistent workspace directory on the agent, so &lt;code&gt;.m2&lt;/code&gt; cache, &lt;code&gt;node_modules&lt;/code&gt; and the Git checkout "just exist". On Kubernetes, every step is a container in a Pod; nothing persists unless you declare it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent drift.&lt;/strong&gt; The &lt;code&gt;maven-jdk17&lt;/code&gt; label points to a VM or image maintained outside Git. Tekton forces each step to name its own image, which is both more verbose and far more auditable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The goal of migration is not a 1:1 translation. It is to extract the five or six genuinely reusable Tasks your organisation needs, version them, and leave the per-application glue in a thin &lt;code&gt;Pipeline&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Concept mapping
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Jenkins&lt;/th&gt;
&lt;th&gt;Tekton / OpenShift Pipelines&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Task&lt;/code&gt; (or a &lt;code&gt;step&lt;/code&gt; inside a Task)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;agent&lt;/code&gt; / label&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;step.image&lt;/code&gt; + &lt;code&gt;podTemplate&lt;/code&gt; / &lt;code&gt;taskRunTemplate&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workspace directory&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Workspace&lt;/code&gt; backed by PVC, emptyDir, ConfigMap or Secret&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared library&lt;/td&gt;
&lt;td&gt;Catalog of &lt;code&gt;Task&lt;/code&gt; / &lt;code&gt;StepAction&lt;/code&gt; resources, versioned in Git or OCI bundles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Credentials binding&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Secret&lt;/code&gt; + ServiceAccount, or a Secret-backed workspace&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multibranch webhook&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;EventListener&lt;/code&gt; + &lt;code&gt;TriggerBinding&lt;/code&gt; + &lt;code&gt;TriggerTemplate&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build history&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PipelineRun&lt;/code&gt; objects (pruned by the operator)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Getting the platform ready
&lt;/h2&gt;

&lt;p&gt;OpenShift Pipelines ships as an operator. Verify it and install the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Operator and version&lt;/span&gt;
oc get csv &lt;span class="nt"&gt;-n&lt;/span&gt; openshift-operators | &lt;span class="nb"&gt;grep &lt;/span&gt;pipelines

&lt;span class="c"&gt;# Cluster-wide Tasks shipped by Red Hat (git-clone, buildah, s2i, ...)&lt;/span&gt;
oc get clustertasks 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head
&lt;/span&gt;oc get tasks &lt;span class="nt"&gt;-n&lt;/span&gt; openshift-pipelines | &lt;span class="nb"&gt;head&lt;/span&gt;

&lt;span class="c"&gt;# tkn CLI, matched to the cluster&lt;/span&gt;
tkn version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a dedicated namespace and service account for the pipeline — do not reuse &lt;code&gt;pipeline&lt;/code&gt; SA defaults blindly across teams:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc new-project payments-ci
oc create sa pipeline-runner &lt;span class="nt"&gt;-n&lt;/span&gt; payments-ci
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1 — Cut the Jenkinsfile into Tasks
&lt;/h2&gt;

&lt;p&gt;Apply one rule: &lt;strong&gt;a Task is reusable only if it takes everything it needs as &lt;code&gt;params&lt;/code&gt; and &lt;code&gt;workspaces&lt;/code&gt;, and exposes everything downstream needs as &lt;code&gt;results&lt;/code&gt;.&lt;/strong&gt; No hardcoded registry, no hardcoded namespace.&lt;/p&gt;

&lt;p&gt;Here is the Maven build extracted as a Task, with an explicit cache workspace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tekton.dev/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Task&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;maven-build&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-ci&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app.kubernetes.io/version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.4"&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build a Maven project, reusing a persistent ~/.m2 cache.&lt;/span&gt;
  &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GOALS&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;array&lt;/span&gt;
      &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-B"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clean"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;package"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MAVEN_IMAGE&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
      &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;registry.access.redhat.com/ubi9/openjdk-17:1.20&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CONTEXT_DIR&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
      &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;."&lt;/span&gt;
  &lt;span class="na"&gt;workspaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;source&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checked-out sources.&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;maven-cache&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Persistent local repository.&lt;/span&gt;
      &lt;span class="na"&gt;optional&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;maven-settings&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ConfigMap or Secret holding settings.xml.&lt;/span&gt;
      &lt;span class="na"&gt;optional&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;results&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ARTIFACT_VERSION&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Project version read from the POM.&lt;/span&gt;
  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$(params.MAVEN_IMAGE)&lt;/span&gt;
      &lt;span class="na"&gt;workingDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$(workspaces.source.path)/$(params.CONTEXT_DIR)&lt;/span&gt;
      &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$(params.GOALS[*])"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MAVEN_OPTS&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;-Dmaven.repo.local=$(workspaces.maven-cache.path)/repository&lt;/span&gt;
      &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;#!/usr/bin/env bash&lt;/span&gt;
        &lt;span class="s"&gt;set -euo pipefail&lt;/span&gt;
        &lt;span class="s"&gt;SETTINGS=""&lt;/span&gt;
        &lt;span class="s"&gt;if [ -f "$(workspaces.maven-settings.path)/settings.xml" ]; then&lt;/span&gt;
          &lt;span class="s"&gt;SETTINGS="-s $(workspaces.maven-settings.path)/settings.xml"&lt;/span&gt;
        &lt;span class="s"&gt;fi&lt;/span&gt;
        &lt;span class="s"&gt;mvn ${SETTINGS} "$@"&lt;/span&gt;
        &lt;span class="s"&gt;mvn -q help:evaluate -Dexpression=project.version -DforceStdout \&lt;/span&gt;
          &lt;span class="s"&gt;| tee $(results.ARTIFACT_VERSION.path)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;optional: true&lt;/code&gt; workspaces&lt;/strong&gt; let the same Task run with or without a cache. Local developers can start a &lt;code&gt;PipelineRun&lt;/code&gt; with an &lt;code&gt;emptyDir&lt;/code&gt;; CI mounts a PVC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Results are files&lt;/strong&gt;, not environment variables. Write to &lt;code&gt;$(results.&amp;lt;name&amp;gt;.path)&lt;/code&gt; with no trailing newline issues (&lt;code&gt;tee&lt;/code&gt; is fine, Tekton trims trailing whitespace).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Versioning Tasks
&lt;/h3&gt;

&lt;p&gt;Treat Tasks like libraries. Two viable strategies:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Git + resolver.&lt;/strong&gt; Keep Tasks in a &lt;code&gt;platform-tekton-catalog&lt;/code&gt; repo and reference them by tag with the Git resolver. No cluster-side copy to keep in sync.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OCI bundles.&lt;/strong&gt; Push Tasks to your registry with &lt;code&gt;tkn bundle push&lt;/code&gt; and reference them by digest. Best for air-gapped clusters and for signing.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# OCI bundle, immutable reference&lt;/span&gt;
tkn bundle push quay.io/acme/tekton-catalog/maven-build:0.4 &lt;span class="nt"&gt;-f&lt;/span&gt; task-maven-build.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a Pipeline, pin the version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
  &lt;span class="na"&gt;taskRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;resolver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bundles&lt;/span&gt;
    &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bundle&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;quay.io/acme/tekton-catalog/maven-build@sha256:3f1d...&lt;/span&gt;   &lt;span class="c1"&gt;# digest, not tag&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;name&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;maven-build&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kind&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;task&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never reference a mutable tag in production pipelines. A digest is the Tekton equivalent of a pinned shared-library version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Workspaces, dependency resolution and cache
&lt;/h2&gt;

&lt;p&gt;This is where most migrations fail. Jenkins gave you a sticky filesystem; Tekton gives you declarative volumes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Source workspace
&lt;/h3&gt;

&lt;p&gt;Use a &lt;code&gt;volumeClaimTemplate&lt;/code&gt; so each &lt;code&gt;PipelineRun&lt;/code&gt; gets a fresh, automatically deleted PVC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;workspaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;source&lt;/span&gt;
    &lt;span class="na"&gt;volumeClaimTemplate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ReadWriteOnce"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2Gi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With RWO storage, every Task sharing that PVC must be scheduled on the same node. Tekton's &lt;strong&gt;affinity assistant&lt;/strong&gt; handles this automatically (it creates a placeholder StatefulSet that pins the Pods). Check the feature flag before you fight phantom scheduling issues:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc get cm feature-flags &lt;span class="nt"&gt;-n&lt;/span&gt; openshift-pipelines &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.data.coschedule}{"\n"}'&lt;/span&gt;
&lt;span class="c"&gt;# expected: workspaces  (default) — pins Pods sharing a workspace to one node&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Cache workspace
&lt;/h3&gt;

&lt;p&gt;The source workspace dies with the run; the cache must not. Create a long-lived PVC per team and language runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PersistentVolumeClaim&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;maven-cache&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-ci&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ReadWriteMany"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# RWX: concurrent PipelineRuns&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20Gi&lt;/span&gt;
  &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ocs-storagecluster-cephfs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you only have RWO storage (common on bare metal with LVM or on some cloud CSI drivers), you have two options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One cache PVC per pipeline, &lt;code&gt;ReadWriteOnce&lt;/code&gt;&lt;/strong&gt;, and accept serialised runs (&lt;code&gt;PipelineRun&lt;/code&gt; concurrency 1 via a mutex Task or an admission policy).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Externalise the cache&lt;/strong&gt;: a &lt;code&gt;cache-fetch&lt;/code&gt; / &lt;code&gt;cache-upload&lt;/code&gt; Task pair that pulls a tarball from S3/ODF object storage keyed by the hash of &lt;code&gt;pom.xml&lt;/code&gt; or &lt;code&gt;package-lock.json&lt;/code&gt;. Slower cold start, but fully parallel and node-independent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key-based approach is worth the extra two Tasks once you exceed ~10 concurrent builds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cache-key&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;registry.access.redhat.com/ubi9/ubi-minimal:9.4&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;#!/bin/sh&lt;/span&gt;
    &lt;span class="s"&gt;set -e&lt;/span&gt;
    &lt;span class="s"&gt;sha256sum $(workspaces.source.path)/pom.xml | cut -c1-16 \&lt;/span&gt;
      &lt;span class="s"&gt;&amp;gt; $(results.CACHE_KEY.path)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3 — Compose the Pipeline
&lt;/h2&gt;

&lt;p&gt;The Pipeline is now thin: ordering, parameter plumbing, and workspace binding.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tekton.dev/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pipeline&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-api&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-ci&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git-url&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git-revision&lt;/span&gt;
      &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;image&lt;/span&gt;
      &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;image-registry.openshift-image-registry.svc:5000/payments-ci/payments-api&lt;/span&gt;
  &lt;span class="na"&gt;workspaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;source&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;maven-cache&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;maven-settings&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dockerconfig&lt;/span&gt;
  &lt;span class="na"&gt;results&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;image-digest&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$(tasks.build-image.results.IMAGE_DIGEST)&lt;/span&gt;
  &lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;clone&lt;/span&gt;
      &lt;span class="na"&gt;taskRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;git-clone&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;Task&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;url&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$(params.git-url)&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;revision&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$(params.git-revision)&lt;/span&gt;
      &lt;span class="na"&gt;workspaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;output&lt;/span&gt;
          &lt;span class="na"&gt;workspace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;source&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
      &lt;span class="na"&gt;runAfter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clone"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;taskRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;maven-build&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;Task&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GOALS&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-B"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clean"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verify"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;workspaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;source&lt;/span&gt;
          &lt;span class="na"&gt;workspace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;source&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;maven-cache&lt;/span&gt;
          &lt;span class="na"&gt;workspace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;maven-cache&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;maven-settings&lt;/span&gt;
          &lt;span class="na"&gt;workspace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;maven-settings&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build-image&lt;/span&gt;
      &lt;span class="na"&gt;runAfter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;build"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;taskRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;buildah&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;Task&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IMAGE&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$(params.image):$(tasks.build.results.ARTIFACT_VERSION)&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TLSVERIFY&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
      &lt;span class="na"&gt;workspaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;source&lt;/span&gt;
          &lt;span class="na"&gt;workspace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;source&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dockerconfig&lt;/span&gt;
          &lt;span class="na"&gt;workspace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dockerconfig&lt;/span&gt;

  &lt;span class="na"&gt;finally&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;report&lt;/span&gt;
      &lt;span class="na"&gt;taskRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;slack-notify&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;Task&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;status&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$(tasks.status)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;finally&lt;/code&gt; replaces the Jenkins &lt;code&gt;post { always { ... } }&lt;/code&gt; block, and &lt;code&gt;$(tasks.status)&lt;/code&gt; gives you &lt;code&gt;Succeeded&lt;/code&gt;, &lt;code&gt;Failed&lt;/code&gt; or &lt;code&gt;Completed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Run and watch it with the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tkn pipeline start payments-api &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; git-url&lt;span class="o"&gt;=&lt;/span&gt;https://github.com/acme/payments-api.git &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; git-revision&lt;span class="o"&gt;=&lt;/span&gt;main &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;source&lt;/span&gt;,volumeClaimTemplateFile&lt;span class="o"&gt;=&lt;/span&gt;./wsp-source.yaml &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;maven-cache,claimName&lt;span class="o"&gt;=&lt;/span&gt;maven-cache &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;maven-settings,config&lt;span class="o"&gt;=&lt;/span&gt;maven-settings &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dockerconfig,secret&lt;span class="o"&gt;=&lt;/span&gt;registry-push &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--serviceaccount&lt;/span&gt; pipeline-runner &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--use-param-defaults&lt;/span&gt; &lt;span class="nt"&gt;--showlog&lt;/span&gt;

tkn &lt;span class="nb"&gt;pr &lt;/span&gt;list
tkn &lt;span class="nb"&gt;pr &lt;/span&gt;logs &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nt"&gt;--last&lt;/span&gt;
tkn &lt;span class="nb"&gt;pr &lt;/span&gt;describe &lt;span class="nt"&gt;--last&lt;/span&gt;
tkn taskrun logs payments-api-run-abc123-build &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tkn pr logs -f --last&lt;/code&gt; is the command you will type a thousand times; make an alias.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Trigger from Git with an EventListener
&lt;/h2&gt;

&lt;p&gt;Triggers replace the Jenkins multibranch webhook. Three objects: a &lt;code&gt;TriggerBinding&lt;/code&gt; (extract fields from the payload), a &lt;code&gt;TriggerTemplate&lt;/code&gt; (render a &lt;code&gt;PipelineRun&lt;/code&gt;), an &lt;code&gt;EventListener&lt;/code&gt; (HTTP endpoint + interceptors).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;triggers.tekton.dev/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TriggerBinding&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github-push&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-ci&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git-url&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$(body.repository.clone_url)&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git-revision&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$(body.after)&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;triggers.tekton.dev/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TriggerTemplate&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-api-template&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-ci&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git-url&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git-revision&lt;/span&gt;
  &lt;span class="na"&gt;resourcetemplates&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tekton.dev/v1&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PipelineRun&lt;/span&gt;
      &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;generateName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-api-&lt;/span&gt;
      &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;taskRunTemplate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;serviceAccountName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pipeline-runner&lt;/span&gt;
        &lt;span class="na"&gt;pipelineRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-api&lt;/span&gt;
        &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git-url&lt;/span&gt;
            &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$(tt.params.git-url)&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git-revision&lt;/span&gt;
            &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$(tt.params.git-revision)&lt;/span&gt;
        &lt;span class="na"&gt;timeouts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;pipeline&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1h0m0s"&lt;/span&gt;
        &lt;span class="na"&gt;workspaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;source&lt;/span&gt;
            &lt;span class="na"&gt;volumeClaimTemplate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ReadWriteOnce"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
                &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                    &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2Gi&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;maven-cache&lt;/span&gt;
            &lt;span class="na"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;claimName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;maven-cache&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;maven-settings&lt;/span&gt;
            &lt;span class="na"&gt;configMap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;maven-settings&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dockerconfig&lt;/span&gt;
            &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;registry-push&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;triggers.tekton.dev/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EventListener&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-el&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-ci&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;serviceAccountName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tekton-triggers-sa&lt;/span&gt;
  &lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github-push-main&lt;/span&gt;
      &lt;span class="na"&gt;interceptors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github&lt;/span&gt;
          &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secretRef&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github-webhook-secret&lt;/span&gt;
                &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secretToken&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eventTypes&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;push"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cel&lt;/span&gt;
          &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;filter&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body.ref&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;==&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'refs/heads/main'"&lt;/span&gt;
      &lt;span class="na"&gt;bindings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github-push&lt;/span&gt;
      &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-api-template&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expose it with a TLS-terminated route (never plain HTTP for a webhook):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc create secret generic github-webhook-secret &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;secretToken&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 20&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; payments-ci

oc create route edge payments-el &lt;span class="nt"&gt;--service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;el-payments-el &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8080 &lt;span class="nt"&gt;--insecure-policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Redirect &lt;span class="nt"&gt;-n&lt;/span&gt; payments-ci

oc get route payments-el &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.spec.host}{"\n"}'&lt;/span&gt;
tkn eventlistener logs payments-el &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point the GitHub/GitLab webhook at &lt;code&gt;https://&amp;lt;route-host&amp;gt;&lt;/code&gt; with the same secret. The &lt;code&gt;github&lt;/code&gt; interceptor validates the HMAC signature; the CEL interceptor filters branches so you do not start a build for every tag push.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security best practices
&lt;/h2&gt;

&lt;p&gt;CI is the most privileged system in most organisations: it can read all source code and push to production registries. Treat it accordingly.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One ServiceAccount per pipeline, not a shared one.&lt;/strong&gt; Give &lt;code&gt;pipeline-runner&lt;/code&gt; only the RBAC it needs (push to its own ImageStream, deploy to its own namespace). The Triggers SA needs &lt;code&gt;create&lt;/code&gt; on &lt;code&gt;pipelineruns&lt;/code&gt; and nothing else — bind the shipped &lt;code&gt;tekton-triggers-eventlistener-roles&lt;/code&gt; ClusterRole, not &lt;code&gt;edit&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid privileged builds.&lt;/strong&gt; The Red Hat &lt;code&gt;buildah&lt;/code&gt; Task runs rootless with the &lt;code&gt;pipelines-scc&lt;/code&gt; SCC. Do not grant &lt;code&gt;privileged&lt;/code&gt; to work around a storage driver error; set &lt;code&gt;STORAGE_DRIVER=vfs&lt;/code&gt; or use &lt;code&gt;fuse-overlayfs&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never pass secrets as &lt;code&gt;params&lt;/code&gt;.&lt;/strong&gt; Params end up in the &lt;code&gt;PipelineRun&lt;/code&gt; spec, visible to anyone with &lt;code&gt;get pipelineruns&lt;/code&gt;. Mount secrets as workspaces (&lt;code&gt;secret: secretName: ...&lt;/code&gt;) or bind them to the ServiceAccount.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope registry credentials.&lt;/strong&gt; Prefer the internal registry with a ServiceAccount token over a long-lived &lt;code&gt;dockerconfigjson&lt;/code&gt; for quay.io. If you need an external registry, use a robot account restricted to one repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate webhook payloads.&lt;/strong&gt; The &lt;code&gt;github&lt;/code&gt;/&lt;code&gt;gitlab&lt;/code&gt; interceptors verify the HMAC signature — always configure &lt;code&gt;secretRef&lt;/code&gt;. An unauthenticated EventListener is remote code execution with extra steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin Task references by digest&lt;/strong&gt; (OCI bundles) or by Git tag with commit verification. A mutable &lt;code&gt;:latest&lt;/code&gt; catalog Task is a supply-chain backdoor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable Tekton Chains&lt;/strong&gt; for SLSA provenance and image signing; it is bundled with OpenShift Pipelines and signs &lt;code&gt;TaskRun&lt;/code&gt; outputs automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply a NetworkPolicy&lt;/strong&gt; on the CI namespace: allow egress to the registry, Git and artifact proxy; deny lateral traffic to application namespaces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;timeouts&lt;/code&gt; and resource limits.&lt;/strong&gt; A Pipeline without a timeout is a PVC leak and a node-pressure incident waiting to happen. Also configure the operator's pruner to delete old &lt;code&gt;PipelineRun&lt;/code&gt; objects.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Migrating from Jenkins to Tekton is not a file conversion, it is a refactoring exercise. The mental shift is straightforward once you accept it: a &lt;code&gt;Task&lt;/code&gt; is a function with typed inputs (&lt;code&gt;params&lt;/code&gt;, &lt;code&gt;workspaces&lt;/code&gt;) and typed outputs (&lt;code&gt;results&lt;/code&gt;), and a &lt;code&gt;Pipeline&lt;/code&gt; is the call graph.&lt;/p&gt;

&lt;p&gt;Key takeaways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start by extracting 5–6 Tasks&lt;/strong&gt;, not 30. Build, test, image, deploy, notify. Everything else stays inline until a second consumer appears.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Declare your state.&lt;/strong&gt; The source workspace is ephemeral (&lt;code&gt;volumeClaimTemplate&lt;/code&gt;), the dependency cache is persistent (RWX PVC or object storage keyed by lockfile hash). Getting this wrong is the main cause of "Tekton is slower than Jenkins" complaints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version Tasks as artifacts&lt;/strong&gt;, referenced by digest through the bundles or git resolver. That is what finally kills the copy-paste problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Triggers replace the multibranch job&lt;/strong&gt;: &lt;code&gt;EventListener&lt;/code&gt; + interceptors give you signature validation and CEL filtering in a declarative, reviewable form.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live in &lt;code&gt;tkn&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;tkn pr logs -f --last&lt;/code&gt;, &lt;code&gt;tkn pr describe&lt;/code&gt;, &lt;code&gt;tkn eventlistener logs&lt;/code&gt; will cover 90% of your debugging.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run both systems in parallel for a few sprints: let Jenkins keep deploying while Tekton builds and publishes images. When the Tekton &lt;code&gt;PipelineRun&lt;/code&gt; has been green for two weeks, flip the webhook and delete the Jenkinsfile. The diff will be the best part of the migration.&lt;/p&gt;

</description>
      <category>tekton</category>
      <category>openshift</category>
      <category>cicd</category>
      <category>devops</category>
    </item>
    <item>
      <title>Illusory Cloud Backups: Actually Testing Your DR Plan with Velero and a Timed Restore</title>
      <dc:creator>Florient DOGBE</dc:creator>
      <pubDate>Thu, 29 Oct 2026 14:27:28 +0000</pubDate>
      <link>https://dev.to/florient_4262fab5463a691/illusory-cloud-backups-actually-testing-your-dr-plan-with-velero-and-a-timed-restore-1811</link>
      <guid>https://dev.to/florient_4262fab5463a691/illusory-cloud-backups-actually-testing-your-dr-plan-with-velero-and-a-timed-restore-1811</guid>
      <description>&lt;p&gt;Your backup job has been green for 14 months. Your Grafana dashboard shows a reassuring &lt;code&gt;velero_backup_success_total&lt;/code&gt; counter climbing steadily. Your compliance auditor ticked the box. And yet, if your production cluster disappeared tonight, you have no idea whether you could bring the platform back — nor how long it would take.&lt;/p&gt;

&lt;p&gt;This is not a hypothetical. The failure mode is boringly consistent: backups succeed, restores fail. Custom Resource Definitions come back after the Custom Resources that depend on them. PersistentVolumes restore empty because the CSI driver was swapped. Secrets are present but the operator that consumes them crash-loops because an owner reference points at a UID that no longer exists. The &lt;code&gt;Completed&lt;/code&gt; status in &lt;code&gt;velero backup get&lt;/code&gt; tells you a tarball landed in an S3 bucket. It tells you nothing about recoverability.&lt;/p&gt;

&lt;p&gt;This article builds a complete, timed restore scenario — PVs, CRDs, secrets — on OpenShift/Kubernetes with Velero, and turns it into an automated test you can run on a schedule. The goal is simple: replace your &lt;em&gt;declared&lt;/em&gt; RTO/RPO with &lt;em&gt;measured&lt;/em&gt; RTO/RPO.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context: why "backup success" is a meaningless metric
&lt;/h2&gt;

&lt;p&gt;RTO (Recovery Time Objective) and RPO (Recovery Point Objective) are contractual numbers. In most organisations they were written in a Word document by someone who never ran &lt;code&gt;velero restore create&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RPO&lt;/strong&gt; is bounded by your backup schedule &lt;em&gt;and&lt;/em&gt; by the consistency of what was captured. A backup taken every 6 hours with no filesystem quiescing gives you a 6-hour RPO on paper, and an unknown RPO in practice if your database was mid-write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RTO&lt;/strong&gt; is the full wall-clock time from "we decide to restore" to "the service answers requests correctly". That includes cluster provisioning, Velero installation, CRD reconciliation, volume rehydration from object storage, operator convergence, and DNS/ingress propagation. The &lt;code&gt;velero restore&lt;/code&gt; command itself is often the &lt;em&gt;fastest&lt;/em&gt; part.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Velero's architecture explains where the time goes. Kubernetes object manifests are tiny JSON blobs in an S3 bucket — restoring 5,000 objects takes seconds. Volume data is the expensive part, and the mechanism matters enormously:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Backup target&lt;/th&gt;
&lt;th&gt;Restore speed&lt;/th&gt;
&lt;th&gt;Cross-cluster / cross-region&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CSI snapshots (&lt;code&gt;VolumeSnapshotClass&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Storage backend&lt;/td&gt;
&lt;td&gt;Very fast (CoW clone)&lt;/td&gt;
&lt;td&gt;Usually no, unless the snapshot is replicated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File System Backup (restic / Kopia)&lt;/td&gt;
&lt;td&gt;S3 bucket&lt;/td&gt;
&lt;td&gt;Slow, data-size bound&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSI snapshot + data movement&lt;/td&gt;
&lt;td&gt;S3 bucket&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A team that benchmarked restores using local CSI snapshots and then has to rebuild in another region from restic will discover a 20x gap in RTO at the worst possible moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up Velero correctly for a testable DR
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Install with the right plugins
&lt;/h3&gt;

&lt;p&gt;On OpenShift, prefer the OADP operator (&lt;code&gt;OpenShift API for Data Protection&lt;/code&gt;) which packages Velero. On vanilla Kubernetes, the Velero CLI is fine. The key decision is enabling &lt;strong&gt;both&lt;/strong&gt; CSI snapshots and File System Backup, so you can measure both paths.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;velero &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--provider&lt;/span&gt; aws &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--plugins&lt;/span&gt; velero/velero-plugin-for-aws:v1.10.0,velero/velero-plugin-for-csi:v0.7.1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; velero-dr-eu-west-3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--prefix&lt;/span&gt; prod-cluster &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--backup-location-config&lt;/span&gt; &lt;span class="nv"&gt;region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eu-west-3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--snapshot-location-config&lt;/span&gt; &lt;span class="nv"&gt;region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eu-west-3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--secret-file&lt;/span&gt; ./credentials-velero &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--use-node-agent&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--uploader-type&lt;/span&gt; kopia &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--features&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;EnableCSI &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; velero
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--use-node-agent&lt;/code&gt; deploys the DaemonSet that performs File System Backup. &lt;code&gt;--uploader-type kopia&lt;/code&gt; is the modern replacement for restic; restic remains supported and the CLI flags are identical (&lt;code&gt;--default-volumes-to-fs-backup&lt;/code&gt;). If you are on an older Velero, substitute &lt;code&gt;--uploader-type restic&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On OpenShift, the node agent needs a privileged SCC to read pod volumes from the host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc adm policy add-scc-to-user privileged &lt;span class="nt"&gt;-z&lt;/span&gt; velero &lt;span class="nt"&gt;-n&lt;/span&gt; openshift-adp
oc adm policy add-scc-to-user privileged &lt;span class="nt"&gt;-z&lt;/span&gt; node-agent &lt;span class="nt"&gt;-n&lt;/span&gt; openshift-adp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Declare a VolumeSnapshotClass Velero can use
&lt;/h3&gt;

&lt;p&gt;Velero only picks up a &lt;code&gt;VolumeSnapshotClass&lt;/code&gt; that carries the right label. Without it, CSI backups silently fall back to nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;snapshot.storage.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;VolumeSnapshotClass&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;csi-gce-pd-snapclass&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;velero.io/csi-volumesnapshot-class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
&lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pd.csi.storage.gke.io&lt;/span&gt;
&lt;span class="na"&gt;deletionPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Retain&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;deletionPolicy: Retain&lt;/code&gt; matters: with &lt;code&gt;Delete&lt;/code&gt;, removing the &lt;code&gt;VolumeSnapshot&lt;/code&gt; object during cleanup destroys the underlying storage snapshot and your restore point with it.&lt;/p&gt;

&lt;h3&gt;
  
  
  A schedule that encodes your RPO
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;velero.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Schedule&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prod-apps-hourly&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;velero&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
  &lt;span class="na"&gt;useOwnerReferencesInBackup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;includedNamespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;payments&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;identity&lt;/span&gt;
    &lt;span class="na"&gt;excludedResources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;events&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;events.events.k8s.io&lt;/span&gt;
    &lt;span class="na"&gt;snapshotVolumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;defaultVolumesToFsBackup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;storageLocation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
    &lt;span class="na"&gt;volumeSnapshotLocations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
    &lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;168h&lt;/span&gt;
    &lt;span class="na"&gt;hooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pg-consistent-snapshot&lt;/span&gt;
          &lt;span class="na"&gt;includedNamespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;payments&lt;/span&gt;
          &lt;span class="na"&gt;labelSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql&lt;/span&gt;
          &lt;span class="na"&gt;pre&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;exec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql&lt;/span&gt;
                &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/bin/bash&lt;/span&gt;
                  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-c&lt;/span&gt;
                  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;psql&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;postgres&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-c&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;SELECT&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pg_backup_start('velero');&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
                &lt;span class="na"&gt;onError&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Fail&lt;/span&gt;
                &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3m&lt;/span&gt;
          &lt;span class="na"&gt;post&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;exec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql&lt;/span&gt;
                &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/bin/bash&lt;/span&gt;
                  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-c&lt;/span&gt;
                  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;psql&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;postgres&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-c&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;SELECT&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pg_backup_stop();&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
                &lt;span class="na"&gt;onError&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Continue&lt;/span&gt;
                &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hooks are what turn a crash-consistent snapshot into an application-consistent one. &lt;code&gt;onError: Fail&lt;/code&gt; on the pre-hook is deliberate — a backup you cannot trust is worse than a missing backup, because it gives false confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the timed restore scenario
&lt;/h2&gt;

&lt;p&gt;The test must run against a &lt;strong&gt;separate cluster&lt;/strong&gt; (or at minimum a separate namespace with a rewritten namespace mapping). Restoring into the live namespace proves nothing and risks production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: define what "restored" means
&lt;/h3&gt;

&lt;p&gt;Before touching Velero, write the assertions. A restore is successful when:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;All Deployments/StatefulSets in the target namespace are &lt;code&gt;Available&lt;/code&gt; with full replica count.&lt;/li&gt;
&lt;li&gt;The PVC contains a known canary file or database row written just before the backup.&lt;/li&gt;
&lt;li&gt;The application answers a functional HTTP probe (not just a liveness probe).&lt;/li&gt;
&lt;li&gt;CRs reconciled by operators reach their expected &lt;code&gt;status.phase&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Plant the canary &lt;em&gt;before&lt;/em&gt; the backup so you can verify the actual data, not just the volume's existence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc &lt;span class="nt"&gt;-n&lt;/span&gt; payments &lt;span class="nb"&gt;exec &lt;/span&gt;deploy/ledger &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo "canary-$(date -u +%Y%m%dT%H%M%SZ)" &amp;gt; /data/dr-canary.txt'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: the restore manifest
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;velero.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Restore&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dr-drill-20240612&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;velero&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backupName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prod-apps-hourly-20240612010014&lt;/span&gt;
  &lt;span class="na"&gt;includedNamespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;payments&lt;/span&gt;
  &lt;span class="na"&gt;namespaceMapping&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;payments&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-dr&lt;/span&gt;
  &lt;span class="na"&gt;restorePVs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;existingResourcePolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;none&lt;/span&gt;
  &lt;span class="na"&gt;includeClusterResources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;hooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wait-for-db&lt;/span&gt;
        &lt;span class="na"&gt;includedNamespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;payments-dr&lt;/span&gt;
        &lt;span class="na"&gt;labelSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql&lt;/span&gt;
        &lt;span class="na"&gt;postHooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;exec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql&lt;/span&gt;
              &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/bin/bash&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-c&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;until&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;postgres;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;do&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sleep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;done"&lt;/span&gt;
              &lt;span class="na"&gt;execTimeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
              &lt;span class="na"&gt;waitTimeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;includeClusterResources: true&lt;/code&gt; pulls in CRDs, ClusterRoles and PVs. This is the setting most often forgotten — without it, a restore into a fresh cluster produces namespaces full of CRs whose CRDs do not exist, and Velero will log &lt;code&gt;error restoring ...: no matches for kind&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: the timing harness
&lt;/h3&gt;

&lt;p&gt;Here is the core of the drill: a script that measures each phase and emits structured results.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;NS_SRC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"payments"&lt;/span&gt;
&lt;span class="nv"&gt;NS_DST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"payments-dr"&lt;/span&gt;
&lt;span class="nv"&gt;RESTORE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"dr-drill-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y%m%d%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;BACKUP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;velero backup get &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.items[] | select(.status.phase=="Completed")
           | [.metadata.name, .status.completionTimestamp] | @tsv'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-k2&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Using backup: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# --- RPO: age of the restore point at the moment the drill starts&lt;/span&gt;
&lt;span class="nv"&gt;BACKUP_TS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;velero backup get &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.status.completionTimestamp'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;RPO_SECONDS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP_TS&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;

&lt;span class="nv"&gt;T0&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;

velero restore create &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RESTORE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-backup&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace-mappings&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NS_SRC&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NS_DST&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include-cluster-resources&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--wait&lt;/span&gt;

&lt;span class="nv"&gt;T_VELERO&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# --- Wait for workloads to converge&lt;/span&gt;
oc &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NS_DST&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nb"&gt;wait&lt;/span&gt; &lt;span class="nt"&gt;--for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;condition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Available deployment &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;15m
oc &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NS_DST&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; rollout status statefulset/postgresql &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;15m

&lt;span class="nv"&gt;T_WORKLOAD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# --- Functional assertion: canary file present on the restored PV&lt;/span&gt;
&lt;span class="nv"&gt;CANARY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;oc &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NS_DST&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nb"&gt;exec &lt;/span&gt;deploy/ledger &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; /data/dr-canary.txt&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CANARY&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL: canary missing"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# --- Functional assertion: application answers correctly&lt;/span&gt;
oc &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NS_DST&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; run dr-probe &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Never &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;registry.access.redhat.com/ubi9/ubi-minimal:latest &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  curl &lt;span class="nt"&gt;-sf&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 10 http://ledger:8080/healthz/ready &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null

&lt;span class="nv"&gt;T_READY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
{
  "restore": "&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RESTORE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;",
  "backup": "&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;",
  "rpo_seconds": &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RPO_SECONDS&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;,
  "velero_restore_seconds": &lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;T_VELERO &lt;span class="o"&gt;-&lt;/span&gt; T0&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="sh"&gt;,
  "workload_convergence_seconds": &lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;T_WORKLOAD &lt;span class="o"&gt;-&lt;/span&gt; T_VELERO&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="sh"&gt;,
  "functional_ready_seconds": &lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;T_READY &lt;span class="o"&gt;-&lt;/span&gt; T_WORKLOAD&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="sh"&gt;,
  "rto_seconds": &lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;T_READY &lt;span class="o"&gt;-&lt;/span&gt; T0&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="sh"&gt;,
  "canary": "&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CANARY&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"
}
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three measurements, not one. If your RTO regresses, the breakdown tells you whether the cause is Velero (more objects, slower S3), workload convergence (operator reconcile loops, image pulls), or application warm-up (cache rebuild, JIT).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: automate the drill
&lt;/h3&gt;

&lt;p&gt;Run it weekly as a Kubernetes CronJob in the DR cluster, with a ServiceAccount scoped tightly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CronJob&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dr-drill&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;velero&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
  &lt;span class="na"&gt;concurrencyPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Forbid&lt;/span&gt;
  &lt;span class="na"&gt;failedJobsHistoryLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
  &lt;span class="na"&gt;jobTemplate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;backoffLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
      &lt;span class="na"&gt;activeDeadlineSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5400&lt;/span&gt;
      &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;serviceAccountName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dr-drill&lt;/span&gt;
          &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Never&lt;/span&gt;
          &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;drill&lt;/span&gt;
              &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;quay.io/myorg/dr-drill:1.4.0&lt;/span&gt;
              &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/usr/local/bin/dr-drill.sh"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
              &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PUSHGATEWAY_URL&lt;/span&gt;
                  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://prometheus-pushgateway.monitoring:9091&lt;/span&gt;
              &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;100m&lt;/span&gt;
                  &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;256Mi&lt;/span&gt;
                &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512Mi&lt;/span&gt;
              &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;allowPrivilegeEscalation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
                &lt;span class="na"&gt;runAsNonRoot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
                &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALL"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
                &lt;span class="na"&gt;seccompProfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RuntimeDefault&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Push the JSON output to Prometheus Pushgateway and alert on it. The alert that matters is not "backup failed" but:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring.coreos.com/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PrometheusRule&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dr-objectives&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dr&lt;/span&gt;
      &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RTOBudgetExceeded&lt;/span&gt;
          &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dr_drill_rto_seconds &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;1800&lt;/span&gt;
          &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
          &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
          &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Measured&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;RTO&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;({{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$value&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}s)&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exceeds&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;30&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;min&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;objective"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DRDrillStale&lt;/span&gt;
          &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;time() - dr_drill_last_success_timestamp &amp;gt; 10 * 24 * &lt;/span&gt;&lt;span class="m"&gt;3600&lt;/span&gt;
          &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;critical&lt;/span&gt;
          &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;successful&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;DR&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;drill&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;last&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;days"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DRDrillStale&lt;/code&gt; is the single most valuable alert in this article. A DR plan that is not exercised decays silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling the hard parts
&lt;/h2&gt;

&lt;h3&gt;
  
  
  CRDs and ordering
&lt;/h3&gt;

&lt;p&gt;Velero restores resources in a built-in priority order (namespaces, then CRDs, then cluster-scoped, then namespaced). It does &lt;strong&gt;not&lt;/strong&gt; wait for operators to reconcile. If your CR requires a webhook served by a Deployment that is itself being restored, the API server will reject the CR.&lt;/p&gt;

&lt;p&gt;Two mitigations: split the restore into two passes (infrastructure first, workloads second), or use &lt;code&gt;--existing-resource-policy=update&lt;/code&gt; on the second pass. For OLM-managed operators, restoring &lt;code&gt;Subscription&lt;/code&gt; and &lt;code&gt;ClusterServiceVersion&lt;/code&gt; objects is usually a mistake — let OLM reinstall the operator from the catalog and restore only the CRs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;velero restore create dr-infra &lt;span class="nt"&gt;--from-backup&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include-resources&lt;/span&gt; crds,namespaces,storageclasses,volumesnapshotclasses &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--wait&lt;/span&gt;

velero restore create dr-apps &lt;span class="nt"&gt;--from-backup&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude-resources&lt;/span&gt; crds &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace-mappings&lt;/span&gt; payments:payments-dr &lt;span class="nt"&gt;--wait&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Secrets that cannot be restored as-is
&lt;/h3&gt;

&lt;p&gt;Service account tokens, &lt;code&gt;kubernetes.io/service-account-token&lt;/code&gt; secrets and anything bound to a cluster UID must be excluded — they will be regenerated. Externally managed secrets (External Secrets Operator, Vault Agent) should also be excluded and resynced from source of truth.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;velero backup create prod-apps &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include-namespaces&lt;/span&gt; payments &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude-resources&lt;/span&gt; &lt;span class="s1"&gt;'secrets.bootstrap.kubernetes.io'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--selector&lt;/span&gt; &lt;span class="s1"&gt;'velero.io/exclude-from-backup notin (true)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Label the secrets you do not want captured:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc &lt;span class="nt"&gt;-n&lt;/span&gt; payments label secret vault-synced-db-creds velero.io/exclude-from-backup&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  StorageClass mismatch across clusters
&lt;/h3&gt;

&lt;p&gt;A restore into a different cloud region or provider will fail if the PVC references a StorageClass that does not exist. Use a Velero ConfigMap to remap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ConfigMap&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;change-storage-class-config&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;velero&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;velero.io/plugin-config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
    &lt;span class="na"&gt;velero.io/change-storage-class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RestoreItemAction&lt;/span&gt;
&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gp3-encrypted&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;standard-csi&lt;/span&gt;
  &lt;span class="na"&gt;premium-rwo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;standard-csi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Security best practices
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dedicated bucket, dedicated credentials.&lt;/strong&gt; The Velero IAM principal needs &lt;code&gt;s3:PutObject&lt;/code&gt;, &lt;code&gt;s3:GetObject&lt;/code&gt;, &lt;code&gt;s3:DeleteObject&lt;/code&gt;, &lt;code&gt;s3:ListBucket&lt;/code&gt; scoped to one prefix — never &lt;code&gt;s3:*&lt;/code&gt; on &lt;code&gt;*&lt;/code&gt;. Prefer IRSA (EKS), Workload Identity (GKE) or OpenShift's &lt;code&gt;CloudCredential&lt;/code&gt; operator over long-lived access keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Object Lock in compliance mode.&lt;/strong&gt; Enable S3 Object Lock with a retention period at least equal to your backup TTL. This is the only control that survives an attacker with cluster-admin who runs &lt;code&gt;velero backup delete --all&lt;/code&gt;. Note that Velero's own deletion of expired backups will fail against locked objects; manage expiry with S3 lifecycle rules instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypt at rest and in transit.&lt;/strong&gt; SSE-KMS on the bucket with a customer-managed key, and a key policy that denies the Velero role &lt;code&gt;kms:ScheduleKeyDeletion&lt;/code&gt;. Kopia/restic repositories are additionally encrypted with a repository password — store it in a separate secret and back it up out-of-band, because without it your S3 data is unrecoverable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat backups as production data for compliance.&lt;/strong&gt; A backup of a namespace containing PII is PII. Same retention rules, same region constraints, same access logging (&lt;code&gt;s3:GetObject&lt;/code&gt; events in CloudTrail with alerting on unusual read volume).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolate the DR cluster's RBAC.&lt;/strong&gt; The drill ServiceAccount needs read on Velero CRs and write on the target namespace only. It should never hold &lt;code&gt;cluster-admin&lt;/code&gt;, even though the restore operation itself requires broad permissions performed by the Velero controller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never restore into production by accident.&lt;/strong&gt; Guard the drill script with an explicit check on the cluster's API server URL or a well-known ConfigMap flag, and refuse to run if the target namespace lacks a &lt;code&gt;dr-drill=true&lt;/code&gt; label.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A green backup is a &lt;em&gt;necessary&lt;/em&gt; condition for recovery, never a sufficient one. The only evidence of recoverability is a successful restore with functional assertions.&lt;/li&gt;
&lt;li&gt;Measure RTO in three segments — Velero restore, workload convergence, application readiness — so regressions are attributable.&lt;/li&gt;
&lt;li&gt;Measure real RPO as the age of the restore point at drill time, not as the cron interval.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;includeClusterResources: true&lt;/code&gt; plus a two-pass restore (CRDs first, workloads second) solves the large majority of restore failures in operator-heavy clusters.&lt;/li&gt;
&lt;li&gt;CSI snapshots and File System Backup have radically different restore times. Benchmark the path you will actually use in a disaster, which is usually the slow one.&lt;/li&gt;
&lt;li&gt;Alert on drill staleness, not just drill failure. An untested plan degrades to zero value within months.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start small: pick one namespace with a PV, plant a canary, run the timed restore manually this week, and write down the number. Whatever it is, it will be more useful than the number currently in your DR document.&lt;/p&gt;

</description>
      <category>velero</category>
      <category>kubernetes</category>
      <category>devops</category>
      <category>disasterrecovery</category>
    </item>
    <item>
      <title>GCP: Cutting Cross-Region Latency with Cloud Load Balancing and Anycast IP</title>
      <dc:creator>Florient DOGBE</dc:creator>
      <pubDate>Wed, 28 Oct 2026 10:19:20 +0000</pubDate>
      <link>https://dev.to/florient_4262fab5463a691/gcp-cutting-cross-region-latency-with-cloud-load-balancing-and-anycast-ip-1k3</link>
      <guid>https://dev.to/florient_4262fab5463a691/gcp-cutting-cross-region-latency-with-cloud-load-balancing-and-anycast-ip-1k3</guid>
      <description>&lt;p&gt;A user in Frankfurt hitting an API hosted in &lt;code&gt;us-central1&lt;/code&gt; pays a fixed tax: roughly 90–110 ms of round-trip time before a single byte of application logic runs. Add TLS handshake and you have burned 300 ms before your handler is invoked. No amount of code optimization fixes the speed of light.&lt;/p&gt;

&lt;p&gt;Google Cloud's global external Application Load Balancer solves part of this problem by terminating the connection at the closest of ~190 Google Front End (GFE) points of presence, then carrying the request over Google's private backbone to the nearest healthy backend. The entry point is a single &lt;strong&gt;anycast IPv4/IPv6 address&lt;/strong&gt; — one DNS record, no GeoDNS, no per-region hostnames.&lt;/p&gt;

&lt;p&gt;This article walks through building that front end: multi-region backends (MIGs and serverless NEGs), health checks tuned for fast failover, &lt;code&gt;gcloud&lt;/code&gt; and Terraform implementations, GKE standalone NEGs, and a Cloud Armor baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context: why DNS-based geo-routing is not enough
&lt;/h2&gt;

&lt;p&gt;The classic pattern is a weighted/geo DNS record pointing at regional load balancers: &lt;code&gt;api.example.com&lt;/code&gt; resolves to the EU IP for European resolvers, the US IP for American ones. It works, but it has three structural problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TTL is your failover budget.&lt;/strong&gt; If a region dies, clients keep hammering the dead IP until their resolver cache expires. Browsers and JVMs cache aggressively and ignore your 30s TTL. Real failover time is measured in minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resolver location ≠ client location.&lt;/strong&gt; A client using a public DNS resolver may be geolocated hundreds of kilometres away. EDNS Client Subnet helps, but coverage is partial.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TCP and TLS still start at the origin.&lt;/strong&gt; With DNS routing, the three-way handshake and the TLS handshake travel the full distance to the region. With an anycast front end, the handshake terminates at the PoP a few milliseconds away; only the HTTP request crosses the ocean — and it does so on a warm, multiplexed connection over Google's backbone, not the public internet.&lt;/p&gt;

&lt;p&gt;Measured effect on a Paris → &lt;code&gt;us-central1&lt;/code&gt; path: a cold HTTPS request drops from roughly 480 ms to 180–220 ms, mostly because handshake round-trips are localised.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture overview
&lt;/h2&gt;

&lt;p&gt;The target topology:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    api.example.com → 34.x.x.x (global anycast)
                                 │
                        ┌────────┴────────┐
                        │  GFE / PoP      │  TLS termination, Cloud Armor, Cloud CDN
                        └────────┬────────┘
                                 │  Google backbone
          ┌──────────────────────┼──────────────────────┐
          │                      │                      │
   europe-west1            us-central1            asia-southeast1
   MIG / GKE NEG           MIG / GKE NEG          Cloud Run NEG
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One global backend service holds all regional backends. The GFE picks the closest backend that still has capacity and passes health checks; when a region is unhealthy or saturated, traffic spills to the next-closest one automatically.&lt;/p&gt;

&lt;p&gt;Components, in dependency order: health check → backend service → backends (NEGs/MIGs) → URL map → target HTTPS proxy → global forwarding rule + static IP.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the front end with gcloud
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Reserve the anycast address
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PROJECT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;my-project
gcloud config &lt;span class="nb"&gt;set &lt;/span&gt;project &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Global external IPv4 — anycast, announced from every Google PoP&lt;/span&gt;
gcloud compute addresses create api-global-ipv4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ip-version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;IPV4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network-tier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;PREMIUM &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--global&lt;/span&gt;

gcloud compute addresses create api-global-ipv6 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ip-version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;IPV6 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network-tier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;PREMIUM &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--global&lt;/span&gt;

gcloud compute addresses describe api-global-ipv4 &lt;span class="nt"&gt;--global&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'value(address)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PREMIUM&lt;/code&gt; tier is mandatory for the global ALB. Standard tier gives you regional routing over the public internet — exactly what we are trying to avoid.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. A health check that actually fails fast
&lt;/h3&gt;

&lt;p&gt;Default health check settings (5s interval, 2 failures) mean up to 15 seconds of blackholing. Tighten it, and point it at a dedicated endpoint that checks dependencies — not at &lt;code&gt;/&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute health-checks create http hc-api-readyz &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8080 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--request-path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/readyz &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--check-interval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2s &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1s &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--healthy-threshold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--unhealthy-threshold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-logging&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--global&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;2s × 2 = 4s&lt;/code&gt; detection plus propagation, a region is drained in under 10 seconds. &lt;code&gt;--enable-logging&lt;/code&gt; emits a Cloud Logging entry on every health state transition — invaluable during incidents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud logging &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'resource.type="gce_instance_group" AND jsonPayload.healthCheckProbeResult.detailedHealthState!=""'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20 &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'table(timestamp, jsonPayload.healthCheckProbeResult.detailedHealthState)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not go below 1s interval: probes come from every GFE region, so a 1s interval on a 50-instance MIG generates meaningful load.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Backend service with multi-region backends
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute backend-services create api-backend &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--load-balancing-scheme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;EXTERNAL_MANAGED &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--protocol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;HTTP &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--port-name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--health-checks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;hc-api-readyz &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--global-health-checks&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30s &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--connection-draining-timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;60s &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-logging&lt;/span&gt; &lt;span class="nt"&gt;--logging-sample-rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--global&lt;/span&gt;

&lt;span class="c"&gt;# Europe: managed instance group&lt;/span&gt;
gcloud compute backend-services add-backend api-backend &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--instance-group&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api-mig-euw1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--instance-group-region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;europe-west1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--balancing-mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;RATE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-rate-per-instance&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;120 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--capacity-scaler&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--global&lt;/span&gt;

&lt;span class="c"&gt;# US: managed instance group&lt;/span&gt;
gcloud compute backend-services add-backend api-backend &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--instance-group&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api-mig-usc1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--instance-group-region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;us-central1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--balancing-mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;RATE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-rate-per-instance&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;120 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--capacity-scaler&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--global&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;EXTERNAL_MANAGED&lt;/code&gt; selects the modern (Envoy-based) global ALB rather than the classic one — required for advanced traffic management such as outlier detection and weighted routing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--balancing-mode=RATE&lt;/code&gt; with &lt;code&gt;--max-rate-per-instance&lt;/code&gt; defines when a region is "full". Once Europe exceeds its aggregate capacity, overflow goes to the US instead of queuing. &lt;code&gt;--capacity-scaler=0.0&lt;/code&gt; drains a region without removing it — your manual kill switch during a regional rollout.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Serverless NEGs for Cloud Run regions
&lt;/h3&gt;

&lt;p&gt;A serverless NEG maps a region's Cloud Run (or App Engine/Cloud Functions) service into the same backend service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;REGION &lt;span class="k"&gt;in &lt;/span&gt;europe-west1 us-central1 asia-southeast1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;gcloud compute network-endpoint-groups create neg-api-&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REGION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REGION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--network-endpoint-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;SERVERLESS &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--cloud-run-service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api

  gcloud compute backend-services add-backend api-backend &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--network-endpoint-group&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;neg-api-&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REGION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--network-endpoint-group-region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REGION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--global&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Important gotcha:&lt;/strong&gt; serverless NEG backends do not support health checks, balancing modes, or capacity scalers. Availability is handled by the Cloud Run control plane, and the GFE routes to the closest region with a deployed revision. If you need explicit health-based failover semantics, use MIGs or GKE NEGs — or mix: serverless NEGs for low-traffic regions, MIGs where you need capacity control.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. URL map, proxy, forwarding rule
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute url-maps create api-urlmap &lt;span class="nt"&gt;--default-service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api-backend &lt;span class="nt"&gt;--global&lt;/span&gt;

gcloud compute ssl-certificates create api-cert &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--domains&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api.example.com &lt;span class="nt"&gt;--global&lt;/span&gt;

gcloud compute target-https-proxies create api-https-proxy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url-map&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api-urlmap &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ssl-certificates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api-cert &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ssl-policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tls12-modern &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--global&lt;/span&gt;

gcloud compute forwarding-rules create api-fr-https &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--load-balancing-scheme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;EXTERNAL_MANAGED &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--address&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api-global-ipv4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target-https-proxy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api-https-proxy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ports&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;443 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--global&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repeat the forwarding rule for the IPv6 address. Add an HTTP→HTTPS redirect URL map on port 80; never leave port 80 serving content.&lt;/p&gt;

&lt;h2&gt;
  
  
  GKE: standalone zonal NEGs
&lt;/h2&gt;

&lt;p&gt;If your workloads run on GKE in several regions, expose each cluster's pods as &lt;strong&gt;standalone NEGs&lt;/strong&gt; and attach them to the same global backend service. Pods become load balancer endpoints directly — no extra hop through a node port.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cloud.google.com/neg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{"exposed_ports":&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{"8080":&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{"name":&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"neg-api-gke-euw1"}}}'&lt;/span&gt;
    &lt;span class="na"&gt;cloud.google.com/backend-config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{"default":&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"api-backendconfig"}'&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterIP&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
      &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cloud.google.com/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BackendConfig&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-backendconfig&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;timeoutSec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
  &lt;span class="na"&gt;connectionDraining&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;drainingTimeoutSec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
  &lt;span class="na"&gt;healthCheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTP&lt;/span&gt;
    &lt;span class="na"&gt;requestPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/readyz&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
    &lt;span class="na"&gt;checkIntervalSec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
    &lt;span class="na"&gt;timeoutSec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="na"&gt;healthyThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
    &lt;span class="na"&gt;unhealthyThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;securityPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-armor-policy&lt;/span&gt;
  &lt;span class="na"&gt;logging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;sampleRate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The NEG controller creates one zonal NEG per zone where pods run. Attach them all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;ZONE &lt;span class="k"&gt;in &lt;/span&gt;europe-west1-b europe-west1-c europe-west1-d&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;gcloud compute backend-services add-backend api-backend &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--network-endpoint-group&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;neg-api-gke-euw1 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--network-endpoint-group-zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ZONE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--balancing-mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;RATE &lt;span class="nt"&gt;--max-rate-per-endpoint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--global&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make the readiness probe meaningful, since it drives both Kubernetes and GFE decisions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;topologySpreadConstraints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;maxSkew&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
          &lt;span class="na"&gt;topologyKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;topology.kubernetes.io/zone&lt;/span&gt;
          &lt;span class="na"&gt;whenUnsatisfiable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DoNotSchedule&lt;/span&gt;
          &lt;span class="na"&gt;labelSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;europe-docker.pkg.dev/my-project/api/api:1.8.3&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
          &lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/readyz&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8080&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
            &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
          &lt;span class="na"&gt;lifecycle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;preStop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;exec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sleep"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;15"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;250m"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;256Mi"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;512Mi"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;preStop&lt;/code&gt; sleep of 15s covers the window between pod termination and NEG endpoint removal — without it you will see 502s on every rollout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Terraform
&lt;/h2&gt;

&lt;p&gt;The same stack, declaratively. This is the version you actually want in production.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_compute_global_address"&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api-global-ipv4"&lt;/span&gt;
  &lt;span class="nx"&gt;ip_version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"IPV4"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_compute_health_check"&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hc-api-readyz"&lt;/span&gt;
  &lt;span class="nx"&gt;check_interval_sec&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="nx"&gt;timeout_sec&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="nx"&gt;healthy_threshold&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="nx"&gt;unhealthy_threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;

  &lt;span class="nx"&gt;http_health_check&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;port&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8080&lt;/span&gt;
    &lt;span class="nx"&gt;request_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/readyz"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;log_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;enable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_compute_region_network_endpoint_group"&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt;              &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;toset&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s2"&gt;"europe-west1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"us-central1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"asia-southeast1"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"neg-api-${each.key}"&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt;                &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;
  &lt;span class="nx"&gt;network_endpoint_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SERVERLESS"&lt;/span&gt;

  &lt;span class="nx"&gt;cloud_run&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_compute_backend_service"&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api-backend"&lt;/span&gt;
  &lt;span class="nx"&gt;load_balancing_scheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"EXTERNAL_MANAGED"&lt;/span&gt;
  &lt;span class="nx"&gt;protocol&lt;/span&gt;              &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"HTTP"&lt;/span&gt;
  &lt;span class="nx"&gt;timeout_sec&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
  &lt;span class="nx"&gt;security_policy&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;google_compute_security_policy&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="s2"&gt;"backend"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;google_compute_region_network_endpoint_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;
    &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;backend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;log_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;enable&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;sample_rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_compute_url_map"&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api-urlmap"&lt;/span&gt;
  &lt;span class="nx"&gt;default_service&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;google_compute_backend_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_compute_managed_ssl_certificate"&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api-cert"&lt;/span&gt;
  &lt;span class="nx"&gt;managed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;domains&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"api.example.com"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_compute_target_https_proxy"&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api-https-proxy"&lt;/span&gt;
  &lt;span class="nx"&gt;url_map&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;google_compute_url_map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;ssl_certificates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;google_compute_managed_ssl_certificate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;ssl_policy&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;google_compute_ssl_policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_compute_ssl_policy"&lt;/span&gt; &lt;span class="s2"&gt;"modern"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tls12-modern"&lt;/span&gt;
  &lt;span class="nx"&gt;profile&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"MODERN"&lt;/span&gt;
  &lt;span class="nx"&gt;min_tls_version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"TLS_1_2"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_compute_global_forwarding_rule"&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api-fr-https"&lt;/span&gt;
  &lt;span class="nx"&gt;load_balancing_scheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"EXTERNAL_MANAGED"&lt;/span&gt;
  &lt;span class="nx"&gt;ip_address&lt;/span&gt;            &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;google_compute_global_address&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;
  &lt;span class="nx"&gt;port_range&lt;/span&gt;            &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"443"&lt;/span&gt;
  &lt;span class="nx"&gt;target&lt;/span&gt;                &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;google_compute_target_https_proxy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For MIG or GKE NEG backends, add &lt;code&gt;balancing_mode&lt;/code&gt;, &lt;code&gt;max_rate_per_instance&lt;/code&gt; and &lt;code&gt;capacity_scaler&lt;/code&gt; inside the &lt;code&gt;backend&lt;/code&gt; block, plus &lt;code&gt;health_checks = [google_compute_health_check.api.id]&lt;/code&gt; on the backend service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing geographic routing and failover
&lt;/h2&gt;

&lt;p&gt;Have your application echo the serving region (&lt;code&gt;K_SERVICE&lt;/code&gt; / &lt;code&gt;GOOGLE_CLOUD_REGION&lt;/code&gt; on Cloud Run, the node label on GKE) in a response header such as &lt;code&gt;X-Served-By&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Measure from VMs in several regions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;R &lt;span class="k"&gt;in &lt;/span&gt;europe-west1-b us-central1-a asia-southeast1-a&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;gcloud compute instances create probe-&lt;span class="nv"&gt;$R&lt;/span&gt; &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$R&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--machine-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;e2-micro &lt;span class="nt"&gt;--image-family&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;debian-12 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--image-project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;debian-cloud &lt;span class="nt"&gt;--no-address&lt;/span&gt; 2&amp;gt;/dev/null
&lt;span class="k"&gt;done

&lt;/span&gt;gcloud compute ssh probe-europe-west1-b &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;europe-west1-b &lt;span class="nt"&gt;--command&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'curl -s -o /dev/null -D - -w "ttfb=%{time_starttransfer}s total=%{time_total}s\n" https://api.example.com/healthz | grep -Ei "x-served-by|ttfb"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected: the European probe reports a European backend with a TTFB under 30 ms; the Asian probe reports &lt;code&gt;asia-southeast1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Force a failover by blocking health check probes in one region. Google's probes come from &lt;code&gt;35.191.0.0/16&lt;/code&gt; and &lt;code&gt;130.211.0.0/22&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute firewall-rules create deny-hc-euw1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;prod-vpc &lt;span class="nt"&gt;--action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;DENY &lt;span class="nt"&gt;--rules&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tcp:8080 &lt;span class="nt"&gt;--priority&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;900 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source-ranges&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;35.191.0.0/16,130.211.0.0/22 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target-tags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api-euw1

&lt;span class="c"&gt;# Watch the backend flip to UNHEALTHY&lt;/span&gt;
watch &lt;span class="nt"&gt;-n&lt;/span&gt; 2 &lt;span class="s1"&gt;'gcloud compute backend-services get-health api-backend --global \
  --format="table(status.healthStatus[].instance, status.healthStatus[].healthState)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While the rule is active, European clients should transparently receive responses from &lt;code&gt;us-central1&lt;/code&gt; with a higher TTFB. Delete the rule and verify traffic returns. Do this in a staging project first, and script the rollback.&lt;/p&gt;

&lt;p&gt;A less invasive drain, for rollouts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute backend-services update-backend api-backend &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--instance-group&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api-mig-euw1 &lt;span class="nt"&gt;--instance-group-region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;europe-west1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--capacity-scaler&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.0 &lt;span class="nt"&gt;--global&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Security best practices
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Put Cloud Armor in front of every backend service.&lt;/strong&gt; A global anycast IP is reachable by every scanner on the internet within minutes of creation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute security-policies create api-armor-policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Baseline WAF + rate limiting"&lt;/span&gt;

&lt;span class="c"&gt;# OWASP preconfigured rules (sensitivity 1 = low false positives)&lt;/span&gt;
gcloud compute security-policies rules create 1000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--security-policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api-armor-policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--expression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"evaluatePreconfiguredExpr('sqli-v33-stable', ['owasp-crs-v030301-id942251-sqli'])"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;deny-403

gcloud compute security-policies rules create 1100 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--security-policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api-armor-policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--expression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"evaluatePreconfiguredExpr('xss-v33-stable')"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;deny-403

&lt;span class="c"&gt;# Per-IP rate limiting with temporary ban&lt;/span&gt;
gcloud compute security-policies rules create 2000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--security-policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api-armor-policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;rate-based-ban &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--rate-limit-threshold-count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;600 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--rate-limit-threshold-interval-sec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;60 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ban-duration-sec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;300 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--conform-action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;allow &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exceed-action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;deny-429 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enforce-on-key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;IP &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--src-ip-ranges&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*'&lt;/span&gt;

gcloud compute backend-services update api-backend &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--security-policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api-armor-policy &lt;span class="nt"&gt;--global&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always deploy new WAF rules with &lt;code&gt;--preview&lt;/code&gt; first, inspect &lt;code&gt;jsonPayload.enforcedSecurityPolicy.outcome&lt;/code&gt; in the load balancer logs, then enforce.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock down the backends.&lt;/strong&gt; Instances and GKE nodes must only accept traffic from GFE ranges. Everything else is denied:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute firewall-rules create allow-gfe-to-api &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;prod-vpc &lt;span class="nt"&gt;--action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ALLOW &lt;span class="nt"&gt;--rules&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tcp:8080 &lt;span class="nt"&gt;--priority&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source-ranges&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;35.191.0.0/16,130.211.0.0/22 &lt;span class="nt"&gt;--target-tags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api

gcloud compute firewall-rules create deny-all-ingress-api &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;prod-vpc &lt;span class="nt"&gt;--action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;DENY &lt;span class="nt"&gt;--rules&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;all &lt;span class="nt"&gt;--priority&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;65000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source-ranges&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.0.0.0/0 &lt;span class="nt"&gt;--target-tags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Backends should have no external IP; use Cloud NAT for egress. For GKE, use private clusters with authorized networks on the control plane.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TLS and transport.&lt;/strong&gt; Enforce a &lt;code&gt;MODERN&lt;/code&gt; SSL policy with &lt;code&gt;TLS_1_2&lt;/code&gt; minimum, enable HSTS at the application layer, and use managed certificates so renewal is not a human task. If your backends require encryption in transit, switch the backend protocol to &lt;code&gt;HTTPS&lt;/code&gt; or &lt;code&gt;HTTP2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Identity.&lt;/strong&gt; For internal tooling behind the same LB, enable Identity-Aware Proxy instead of IP allowlists. For service-to-service calls into Cloud Run, keep the service private (&lt;code&gt;--no-allow-unauthenticated&lt;/code&gt;) and let the serverless NEG handle ingress with &lt;code&gt;--ingress=internal-and-cloud-load-balancing&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Observability as a control.&lt;/strong&gt; Enable LB logging at 100% during rollouts, export to BigQuery, and alert on &lt;code&gt;backend_latencies&lt;/code&gt; p95 per &lt;code&gt;backend_target_name&lt;/code&gt; and on &lt;code&gt;request_count&lt;/code&gt; filtered by &lt;code&gt;response_code_class=500&lt;/code&gt;. A latency regression in one region is the earliest signal of a partial outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;single global anycast IP&lt;/strong&gt; removes DNS TTL from your failover path and moves TLS termination within a few milliseconds of the user. That is the bulk of the perceived latency win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One global backend service, many regional backends.&lt;/strong&gt; The GFE handles proximity routing and overflow; you only decide capacity through &lt;code&gt;--balancing-mode&lt;/code&gt;, &lt;code&gt;--max-rate-per-instance&lt;/code&gt; and &lt;code&gt;--capacity-scaler&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health checks are the failover SLA.&lt;/strong&gt; A 2s interval with a 2-failure threshold, probing a dependency-aware &lt;code&gt;/readyz&lt;/code&gt;, gives sub-10-second drains. Enable health check logging before you need it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serverless NEGs trade control for simplicity&lt;/strong&gt; — no health checks, no capacity scalers. Mix them with MIG or GKE NEG backends where you need deterministic failover behaviour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GKE standalone NEGs&lt;/strong&gt; route directly to pods; pair them with &lt;code&gt;BackendConfig&lt;/code&gt;, topology spread constraints and a &lt;code&gt;preStop&lt;/code&gt; delay to avoid 502s on rollout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship the whole thing as Terraform&lt;/strong&gt;, and keep &lt;code&gt;capacity-scaler&lt;/code&gt; as an operational escape hatch you can flip from a runbook.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud Armor is not optional&lt;/strong&gt; on an internet-facing anycast IP. Preconfigured OWASP rules in preview mode, rate-based bans, and GFE-only firewall rules are the minimum baseline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Deploy it, then prove it: measure TTFB from three continents, kill a region's health checks, and watch traffic move. An untested failover is a hypothesis, not an architecture.&lt;/p&gt;

</description>
      <category>gcp</category>
      <category>terraform</category>
      <category>kubernetes</category>
      <category>networking</category>
    </item>
    <item>
      <title>Isolating East-West Traffic: Default-Deny NetworkPolicy and Observability with Cilium Hubble</title>
      <dc:creator>Florient DOGBE</dc:creator>
      <pubDate>Mon, 26 Oct 2026 13:36:55 +0000</pubDate>
      <link>https://dev.to/florient_4262fab5463a691/isolating-east-west-traffic-default-deny-networkpolicy-and-observability-with-cilium-hubble-3jlh</link>
      <guid>https://dev.to/florient_4262fab5463a691/isolating-east-west-traffic-default-deny-networkpolicy-and-observability-with-cilium-hubble-3jlh</guid>
      <description>&lt;p&gt;Most Kubernetes clusters I audit are flat networks wearing a service mesh costume. Ingress is locked down with a WAF, mTLS terminates at the edge, secrets live in Vault — and any compromised pod in &lt;code&gt;namespace-a&lt;/code&gt; can open a TCP connection to the PostgreSQL primary in &lt;code&gt;namespace-b&lt;/code&gt;, or to the cloud metadata endpoint at &lt;code&gt;169.254.169.254&lt;/code&gt;, without a single log line being written.&lt;/p&gt;

&lt;p&gt;The fix is well known: default-deny NetworkPolicy. The reason it is rarely applied to an existing cluster is also well known: nobody knows what actually talks to what. Applying a blanket deny to a production namespace and waiting for pager duty to tell you what broke is not a migration strategy.&lt;/p&gt;

&lt;p&gt;This article describes the approach that works: &lt;strong&gt;observe first, generate policies from real traffic, enforce in audit mode, then flip to deny&lt;/strong&gt;. Cilium Hubble gives you the flow data; CiliumNetworkPolicy gives you the enforcement primitives that standard NetworkPolicy lacks (DNS-aware egress, L7 rules, cluster-wide policies).&lt;/p&gt;

&lt;h2&gt;
  
  
  Context: why east-west is the blind spot
&lt;/h2&gt;

&lt;p&gt;Kubernetes has no native network isolation. The default model is "every pod can reach every pod, in every namespace, on every port". &lt;code&gt;NetworkPolicy&lt;/code&gt; is an opt-in allowlist mechanism: as soon as &lt;em&gt;one&lt;/em&gt; policy selects a pod, that pod becomes default-deny &lt;strong&gt;for the direction(s) covered by that policy&lt;/strong&gt;. That nuance is where most teams get it wrong — a policy with only an &lt;code&gt;ingress&lt;/code&gt; block leaves egress wide open.&lt;/p&gt;

&lt;p&gt;The attack path that matters:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An RCE in a Java app pod (Log4Shell-style, or a vulnerable dependency).&lt;/li&gt;
&lt;li&gt;The attacker enumerates the service CIDR and the cluster DNS.&lt;/li&gt;
&lt;li&gt;Lateral movement to the Redis cache with no auth, to an internal admin API, or to the Kubernetes API server with the pod's ServiceAccount token.&lt;/li&gt;
&lt;li&gt;Egress to a C2 server, or to the cloud provider IMDS to steal node credentials.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 2 to 4 are all east-west or egress. None of them touch your Ingress controller. Without network policies, none of them produce a denial you can alert on.&lt;/p&gt;

&lt;p&gt;Three additional reasons this matters now: PCI-DSS 4.0 and SOC2 both explicitly ask for network segmentation between workloads of different trust levels; the CIS Kubernetes Benchmark flags namespaces without NetworkPolicy; and egress filtering is the cheapest reliable detection point for data exfiltration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 0: verify your CNI actually enforces policies
&lt;/h2&gt;

&lt;p&gt;Before anything else, confirm that your cluster's CNI enforces NetworkPolicy. On OpenShift, OVN-Kubernetes does. On a self-managed cluster running flannel, it does &lt;strong&gt;not&lt;/strong&gt; — the API objects are accepted silently and nothing happens. This silent no-op has burned more than one team into thinking they were segmented.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Which CNI?&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nt"&gt;-l&lt;/span&gt; k8s-app&lt;span class="o"&gt;=&lt;/span&gt;cilium &lt;span class="nt"&gt;-o&lt;/span&gt; wide
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; openshift-ovn-kubernetes get pods 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt;

&lt;span class="c"&gt;# Cilium status and whether Hubble is on&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nb"&gt;exec &lt;/span&gt;ds/cilium &lt;span class="nt"&gt;--&lt;/span&gt; cilium-dbg status &lt;span class="nt"&gt;--verbose&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'hubble|policy'&lt;/span&gt;

&lt;span class="c"&gt;# Install the CLI tools if needed&lt;/span&gt;
cilium status &lt;span class="nt"&gt;--wait&lt;/span&gt;
cilium hubble &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--ui&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A quick functional smoke test is worth the two minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create ns np-test
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; np-test run victim &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;registry.access.redhat.com/ubi9/ubi-minimal &lt;span class="nt"&gt;--command&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;3600
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; np-test expose pod victim &lt;span class="nt"&gt;--port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8080
kubectl apply &lt;span class="nt"&gt;-n&lt;/span&gt; np-test &lt;span class="nt"&gt;-f&lt;/span&gt; - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-all-ingress
spec:
  podSelector:
    matchLabels:
      run: victim
  policyTypes:
    - Ingress
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; np-test run probe &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;registry.access.redhat.com/ubi9/ubi-minimal &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Never &lt;span class="nt"&gt;--&lt;/span&gt; curl &lt;span class="nt"&gt;-m&lt;/span&gt; 3 &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; http://victim:8080
&lt;span class="c"&gt;# Expect a timeout / exit 28, not a connection refused.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: observe before you enforce
&lt;/h2&gt;

&lt;p&gt;Hubble is Cilium's flow observability layer. It reads flow events from the eBPF datapath, so it sees every packet decision including the ones a sidecar-based mesh never sees (raw TCP, UDP, DNS, ICMP).&lt;/p&gt;

&lt;p&gt;Enable the Hubble Relay port-forward and start looking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cilium hubble port-forward &amp;amp;

&lt;span class="c"&gt;# Every flow in a namespace, live&lt;/span&gt;
hubble observe &lt;span class="nt"&gt;--namespace&lt;/span&gt; payments &lt;span class="nt"&gt;--follow&lt;/span&gt;

&lt;span class="c"&gt;# Only flows leaving the namespace (the ones that need egress rules)&lt;/span&gt;
hubble observe &lt;span class="nt"&gt;--namespace&lt;/span&gt; payments &lt;span class="nt"&gt;--not&lt;/span&gt; &lt;span class="nt"&gt;--to-namespace&lt;/span&gt; payments &lt;span class="nt"&gt;--follow&lt;/span&gt;

&lt;span class="c"&gt;# DNS resolution performed by a given workload — critical for egress design&lt;/span&gt;
hubble observe &lt;span class="nt"&gt;--namespace&lt;/span&gt; payments &lt;span class="nt"&gt;--pod&lt;/span&gt; checkout &lt;span class="nt"&gt;-t&lt;/span&gt; l7 &lt;span class="nt"&gt;--protocol&lt;/span&gt; dns

&lt;span class="c"&gt;# Anything already being dropped (misconfigured policy, or legitimate noise)&lt;/span&gt;
hubble observe &lt;span class="nt"&gt;--verdict&lt;/span&gt; DROPPED &lt;span class="nt"&gt;--last&lt;/span&gt; 500
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Collecting a representative sample
&lt;/h3&gt;

&lt;p&gt;A ten-minute capture during a quiet afternoon is useless. You need a window that covers your batch jobs, CronJobs, certificate renewals, nightly reconciliation, and your monthly billing run. In practice: &lt;strong&gt;capture for at least a full business cycle, 7 days minimum, 30 if you have monthly jobs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Hubble's in-memory ring buffer is bounded (default 4095 flows per node). For a real capture, export to a file and aggregate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Long-running capture to JSONL&lt;/span&gt;
hubble observe &lt;span class="nt"&gt;--namespace&lt;/span&gt; payments &lt;span class="nt"&gt;--follow&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; jsonl &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /tmp/payments-flows.jsonl

&lt;span class="c"&gt;# Reduce to a unique (source identity -&amp;gt; destination identity : port) tuple set&lt;/span&gt;
jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'select(.flow.verdict=="FORWARDED")
  | [ .flow.source.namespace, (.flow.source.labels | join(",")),
      .flow.destination.namespace, (.flow.destination.labels | join(",")),
      .flow.l4.TCP.destination_port // .flow.l4.UDP.destination_port ]
  | @tsv'&lt;/span&gt; /tmp/payments-flows.jsonl | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For production, do not rely on a port-forward and a shell loop. Enable the Hubble metrics exporter and the file-based flow log on each node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Helm values for cilium (hubble section)&lt;/span&gt;
&lt;span class="na"&gt;hubble&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;relay&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enableOpenMetrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dns:query;ignoreAAAA"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;drop:sourceContext=workload-name;destinationContext=workload-name"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;flow:sourceContext=workload-name;destinationContext=workload-name"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;policy:sourceContext=workload-name;destinationContext=workload-name"&lt;/span&gt;
  &lt;span class="na"&gt;export&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;static&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;filePath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/run/cilium/hubble/events.log&lt;/span&gt;
      &lt;span class="na"&gt;allowList&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{"verdict":["DROPPED","ERROR"]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ship &lt;code&gt;events.log&lt;/code&gt; to Loki or your SIEM with your existing log agent. You now have a durable, queryable flow archive instead of a ring buffer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: derive policies from observed flows
&lt;/h2&gt;

&lt;p&gt;Cilium can do a first pass for you. &lt;code&gt;hubble observe&lt;/code&gt; combined with the policy generation in Cilium Enterprise, or the open-source &lt;code&gt;cilium policy&lt;/code&gt; tooling, produces a draft — but treat the output as a &lt;em&gt;starting point&lt;/em&gt;, never as the final artifact. Generated policies encode accidents (a developer's &lt;code&gt;curl&lt;/code&gt; from a debug pod) as permanent allow rules.&lt;/p&gt;

&lt;p&gt;The discipline that works: generate, then &lt;strong&gt;rewrite by hand using label selectors that express intent&lt;/strong&gt;, not the labels that happen to exist.&lt;/p&gt;

&lt;h3&gt;
  
  
  The baseline: cluster-wide default deny
&lt;/h3&gt;

&lt;p&gt;Start with a &lt;code&gt;CiliumClusterwideNetworkPolicy&lt;/code&gt; that denies everything except the non-negotiable primitives (DNS, health checks). Apply it &lt;strong&gt;in audit mode first&lt;/strong&gt; — see Step 3.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cilium.io/v2&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CiliumClusterwideNetworkPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default-deny-all&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Baseline&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;default-deny&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;namespaces&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;except&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;control&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;plane"&lt;/span&gt;
  &lt;span class="na"&gt;endpointSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchExpressions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;k8s:io.kubernetes.pod.namespace&lt;/span&gt;
        &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NotIn&lt;/span&gt;
        &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kube-system&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cilium-system&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;openshift-dns&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;openshift-ingress&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;openshift-monitoring&lt;/span&gt;
  &lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;fromEntities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;health&lt;/span&gt;
  &lt;span class="na"&gt;egress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;toEndpoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;k8s:io.kubernetes.pod.namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-system&lt;/span&gt;
            &lt;span class="na"&gt;k8s:k8s-app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-dns&lt;/span&gt;
      &lt;span class="na"&gt;toPorts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;53"&lt;/span&gt;
              &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;UDP&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;53"&lt;/span&gt;
              &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
          &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;dns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matchPattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to note. First, the &lt;code&gt;rules.dns&lt;/code&gt; block makes Cilium proxy DNS at L7 — this is what enables &lt;code&gt;toFQDNs&lt;/code&gt; later. Second, excluding the platform namespaces is pragmatic: isolating &lt;code&gt;openshift-monitoring&lt;/code&gt; is a separate project with its own blast radius.&lt;/p&gt;

&lt;h3&gt;
  
  
  Per-workload allowlists
&lt;/h3&gt;

&lt;p&gt;Now express the real flows. Example for a &lt;code&gt;checkout&lt;/code&gt; service that talks to Postgres, Redis, and one external payment provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cilium.io/v2&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CiliumNetworkPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-allow&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;endpointSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app.kubernetes.io/name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout&lt;/span&gt;
  &lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;fromEndpoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;app.kubernetes.io/name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-gateway&lt;/span&gt;
            &lt;span class="na"&gt;k8s:io.kubernetes.pod.namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;edge&lt;/span&gt;
      &lt;span class="na"&gt;toPorts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080"&lt;/span&gt;
              &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
          &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST"&lt;/span&gt;
                &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/v1/checkout.*"&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET"&lt;/span&gt;
                &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/healthz"&lt;/span&gt;
  &lt;span class="na"&gt;egress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;toEndpoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;app.kubernetes.io/name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
            &lt;span class="na"&gt;k8s:io.kubernetes.pod.namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;data&lt;/span&gt;
      &lt;span class="na"&gt;toPorts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5432"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;TCP&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;toEndpoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;app.kubernetes.io/name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis&lt;/span&gt;
            &lt;span class="na"&gt;k8s:io.kubernetes.pod.namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;data&lt;/span&gt;
      &lt;span class="na"&gt;toPorts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;6379"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;TCP&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;toFQDNs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matchName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api.stripe.com"&lt;/span&gt;
      &lt;span class="na"&gt;toPorts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;TCP&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;toFQDNs&lt;/code&gt; is the feature that makes egress filtering practical. Cilium's DNS proxy observes the resolution, caches the returned IPs, and programs them into the eBPF policy map for the TTL duration. You write &lt;code&gt;api.stripe.com&lt;/code&gt;, not a CIDR list that rotates weekly. It requires the DNS L7 rule from the baseline policy to be in effect for that endpoint.&lt;/p&gt;

&lt;p&gt;If you prefer to stay on upstream &lt;code&gt;NetworkPolicy&lt;/code&gt; for portability, the same intent in vanilla form — note that FQDN egress is simply not expressible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NetworkPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-allow&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app.kubernetes.io/name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout&lt;/span&gt;
  &lt;span class="na"&gt;policyTypes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Ingress&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Egress&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;namespaceSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;kubernetes.io/metadata.name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;edge&lt;/span&gt;
          &lt;span class="na"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;app.kubernetes.io/name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-gateway&lt;/span&gt;
      &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
          &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
  &lt;span class="na"&gt;egress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;namespaceSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;kubernetes.io/metadata.name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;data&lt;/span&gt;
          &lt;span class="na"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;app.kubernetes.io/name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5432&lt;/span&gt;
          &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;namespaceSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;kubernetes.io/metadata.name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-system&lt;/span&gt;
          &lt;span class="na"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;k8s-app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-dns&lt;/span&gt;
      &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;53&lt;/span&gt;
          &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;UDP&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remember the &lt;code&gt;kubernetes.io/metadata.name&lt;/code&gt; label is set automatically by the API server on every namespace since 1.21 — use it instead of hand-maintained namespace labels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: audit mode, the no-downtime switch
&lt;/h2&gt;

&lt;p&gt;This is the part that lets you ship to production without an outage. Cilium supports a per-endpoint &lt;strong&gt;policy audit mode&lt;/strong&gt;: policy is evaluated, verdicts are recorded as &lt;code&gt;AUDIT&lt;/code&gt; in Hubble, but packets are still forwarded.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Find the endpoint IDs for a workload on a given node&lt;/span&gt;
&lt;span class="nv"&gt;NODE_POD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system get pod &lt;span class="nt"&gt;-l&lt;/span&gt; k8s-app&lt;span class="o"&gt;=&lt;/span&gt;cilium &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--field-selector&lt;/span&gt; spec.nodeName&lt;span class="o"&gt;=&lt;/span&gt;worker-02 &lt;span class="nt"&gt;-o&lt;/span&gt; name | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nv"&gt;$NODE_POD&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; cilium-dbg endpoint list | &lt;span class="nb"&gt;grep &lt;/span&gt;checkout

&lt;span class="c"&gt;# Enable audit mode on endpoint 1234&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nv"&gt;$NODE_POD&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; cilium-dbg endpoint config 1234 &lt;span class="nv"&gt;PolicyAuditMode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Enabled

&lt;span class="c"&gt;# Watch what *would* be denied&lt;/span&gt;
hubble observe &lt;span class="nt"&gt;--namespace&lt;/span&gt; payments &lt;span class="nt"&gt;--verdict&lt;/span&gt; AUDIT &lt;span class="nt"&gt;--follow&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run in audit mode for a full business cycle. Every &lt;code&gt;AUDIT&lt;/code&gt; verdict is a flow your policy does not cover. Triage each one into two buckets: legitimate (add a rule) or unexpected (you just found an undocumented dependency, or something worse).&lt;/p&gt;

&lt;p&gt;For a cluster-wide rollout, set &lt;code&gt;policyAuditMode: true&lt;/code&gt; in the Cilium Helm values on a canary node pool first, rather than per-endpoint by hand.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Count the AUDIT flows per source/destination pair to prioritise&lt;/span&gt;
hubble observe &lt;span class="nt"&gt;--verdict&lt;/span&gt; AUDIT &lt;span class="nt"&gt;--last&lt;/span&gt; 5000 &lt;span class="nt"&gt;-o&lt;/span&gt; jsonl &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'[.flow.source.pod_name, .flow.destination.pod_name,
            (.flow.l4.TCP.destination_port // .flow.l4.UDP.destination_port)] | @tsv'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the audit stream is empty for 24 hours, disable audit mode and the policy becomes enforcing. Nothing changed in the datapath configuration except one flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: validate and keep it validated
&lt;/h2&gt;

&lt;p&gt;Enforcement without a regression test decays. Add negative tests to CI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Should FAIL (blocked by policy)&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; payments run redteam &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Never &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;registry.access.redhat.com/ubi9/ubi-minimal &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;timeout &lt;/span&gt;5 bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'curl -s -o /dev/null http://169.254.169.254/latest/meta-data/'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL: IMDS reachable"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"OK: IMDS blocked"&lt;/span&gt;

&lt;span class="c"&gt;# Should SUCCEED (allowed path)&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; payments &lt;span class="nb"&gt;exec &lt;/span&gt;deploy/checkout &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;timeout &lt;/span&gt;5 bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo &amp;gt; /dev/tcp/postgres.data.svc.cluster.local/5432'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"OK: DB reachable"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL: DB unreachable"&lt;/span&gt;

&lt;span class="c"&gt;# Cilium's own connectivity test suite&lt;/span&gt;
cilium connectivity &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--test-namespace&lt;/span&gt; cilium-test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alert on drops. A sustained increase in &lt;code&gt;hubble_drop_total&lt;/code&gt; for a workload means either a deployment introduced a new dependency or something is probing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring.coreos.com/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PrometheusRule&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hubble-policy-drops&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;network-policy&lt;/span&gt;
      &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PolicyDropSpike&lt;/span&gt;
          &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;sum by (source_workload, destination_workload) (&lt;/span&gt;
              &lt;span class="s"&gt;rate(hubble_drop_total{reason="POLICY_DENIED"}[5m])&lt;/span&gt;
            &lt;span class="s"&gt;) &amp;gt; 1&lt;/span&gt;
          &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
          &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
          &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Policy&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;drops&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;between&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$labels.source_workload&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$labels.destination_workload&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Security best practices
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Always set &lt;code&gt;policyTypes&lt;/code&gt; explicitly.&lt;/strong&gt; A &lt;code&gt;NetworkPolicy&lt;/code&gt; with only an &lt;code&gt;ingress:&lt;/code&gt; key does not restrict egress at all. Half-isolated workloads are the most common finding in cluster audits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never allow &lt;code&gt;0.0.0.0/0&lt;/code&gt; egress.&lt;/strong&gt; If you must allow broad internet access, use &lt;code&gt;toCIDRSet&lt;/code&gt; with &lt;code&gt;except:&lt;/code&gt; for RFC1918 ranges, link-local (&lt;code&gt;169.254.0.0/16&lt;/code&gt;), and your cloud metadata endpoint. Metadata endpoint access from a pod is an instance-credential theft primitive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protect the DNS path deliberately.&lt;/strong&gt; Allowing port 53 to &lt;code&gt;0.0.0.0/0&lt;/code&gt; recreates a covert channel. Restrict DNS to the cluster resolver and enable the L7 DNS proxy so you get query visibility.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;CiliumClusterwideNetworkPolicy&lt;/code&gt; for the baseline, namespaced policies for exceptions.&lt;/strong&gt; A namespaced default-deny can be deleted by anyone with namespace edit rights; a cluster-wide one cannot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Select on stable, semantic labels&lt;/strong&gt; (&lt;code&gt;app.kubernetes.io/name&lt;/code&gt;, &lt;code&gt;app.kubernetes.io/part-of&lt;/code&gt;), never on &lt;code&gt;pod-template-hash&lt;/code&gt; or Helm release labels that change on upgrade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep policies next to the workload&lt;/strong&gt; in the same Helm chart or Kustomize overlay, so a new dependency and its allow rule land in the same pull request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not treat NetworkPolicy as authentication.&lt;/strong&gt; Labels are mutable by anyone who can patch pods; identity is derived from labels. Combine with mTLS and proper authn/authz for anything that matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;enableDefaultDeny: false&lt;/code&gt; sparingly.&lt;/strong&gt; Cilium 1.14+ lets a policy add allowances without triggering default-deny — useful for shared add-on policies, dangerous if you forget which policy actually establishes the deny.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit egress rules on a schedule.&lt;/strong&gt; &lt;code&gt;toFQDNs&lt;/code&gt; entries accumulate. An allow rule to a SaaS vendor you stopped using three quarters ago is dead weight and attack surface.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Zero-trust networking in an existing cluster is not a big-bang migration; it is a measurement problem followed by a configuration problem. The sequence that works on live production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Verify&lt;/strong&gt; your CNI enforces policy at all — flannel and some managed CNIs silently ignore NetworkPolicy objects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observe&lt;/strong&gt; with Hubble for a full business cycle, exported to durable storage, not a ring buffer. Pay special attention to DNS and to CronJobs that only run monthly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate&lt;/strong&gt; a draft from real flows, then rewrite it by hand with semantic label selectors. Generated policy is evidence, not an artifact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforce in audit mode&lt;/strong&gt; with &lt;code&gt;PolicyAuditMode=Enabled&lt;/code&gt; — this is the step that removes the downtime risk entirely. Drain the &lt;code&gt;AUDIT&lt;/code&gt; verdict stream to zero before flipping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate continuously&lt;/strong&gt; with negative connectivity tests in CI and a Prometheus alert on &lt;code&gt;hubble_drop_total{reason="POLICY_DENIED"}&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The payoff is not only prevention. Once default-deny is in place, every policy drop becomes a high-signal event: legitimate traffic no longer gets denied, so a drop means either an undeclared dependency or an intrusion. You have converted a flat, silent network into one that tells you when something is wrong.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>cilium</category>
      <category>networking</category>
    </item>
    <item>
      <title>Hardening the Control Plane: Auditing a Cluster with kube-bench, kubescape and CIS Rules</title>
      <dc:creator>Florient DOGBE</dc:creator>
      <pubDate>Mon, 26 Oct 2026 13:35:48 +0000</pubDate>
      <link>https://dev.to/florient_4262fab5463a691/hardening-the-control-plane-auditing-a-cluster-with-kube-bench-kubescape-and-cis-rules-219l</link>
      <guid>https://dev.to/florient_4262fab5463a691/hardening-the-control-plane-auditing-a-cluster-with-kube-bench-kubescape-and-cis-rules-219l</guid>
      <description>&lt;p&gt;Most clusters are installed once, pass a smoke test, and are never audited again. Meanwhile the control plane keeps running with whatever flags the installer picked two years ago: no audit log, an API server that still accepts anonymous requests on more endpoints than it should, an etcd data directory readable by anything on the node. None of this shows up in a dashboard. It shows up in a post-incident review.&lt;/p&gt;

&lt;p&gt;This article walks through building an automated CIS compliance audit for a Kubernetes or OpenShift cluster using &lt;strong&gt;kube-bench&lt;/strong&gt; and &lt;strong&gt;kubescape&lt;/strong&gt;, interpreting the results without drowning in false positives, fixing the three most common gaps (&lt;code&gt;anonymous-auth&lt;/code&gt;, audit logging, etcd), and wiring the whole thing into CI so that drift is caught in hours instead of years.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context: what the CIS Benchmark actually measures
&lt;/h2&gt;

&lt;p&gt;The CIS Kubernetes Benchmark is a prescriptive list of configuration checks, grouped by component:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Section 1&lt;/strong&gt; — control plane node configuration: &lt;code&gt;kube-apiserver&lt;/code&gt;, &lt;code&gt;kube-controller-manager&lt;/code&gt;, &lt;code&gt;kube-scheduler&lt;/code&gt; flags and file permissions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Section 2&lt;/strong&gt; — etcd configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Section 3&lt;/strong&gt; — control plane authentication/authorization and logging.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Section 4&lt;/strong&gt; — worker node configuration, mostly kubelet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Section 5&lt;/strong&gt; — policies: RBAC, service accounts, Pod Security, network policies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things matter before you run anything.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;the benchmark must match your distribution&lt;/strong&gt;. The upstream &lt;code&gt;cis-1.x&lt;/code&gt; profile assumes kubeadm-style static pod manifests in &lt;code&gt;/etc/kubernetes/manifests&lt;/code&gt;. On OpenShift, the control plane is managed by operators, files live elsewhere, and roughly a third of the upstream checks are structurally inapplicable. Red Hat maintains a dedicated benchmark (&lt;code&gt;rh-1.0&lt;/code&gt; / &lt;code&gt;rh-1.6&lt;/code&gt; in kube-bench, &lt;code&gt;ocp4-cis&lt;/code&gt; in the Compliance Operator). Running the wrong profile produces a report nobody trusts, and an untrusted report is worse than no report.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;a managed control plane is not yours&lt;/strong&gt;. On EKS, GKE, AKS or ROSA, sections 1 and 2 are the provider's responsibility. Use the &lt;code&gt;eks-1.x&lt;/code&gt;, &lt;code&gt;gke-1.x&lt;/code&gt; or &lt;code&gt;aks-1.x&lt;/code&gt; benchmarks, which skip them and focus on nodes and policies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running kube-bench
&lt;/h2&gt;

&lt;p&gt;kube-bench is a single Go binary that reads the running process arguments and the config files on disk, then compares them to the benchmark. It needs to run &lt;strong&gt;on&lt;/strong&gt; the node, with host PID and host paths mounted.&lt;/p&gt;

&lt;h3&gt;
  
  
  As a one-shot Job on a control plane node
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Job&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-bench-master&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-bench&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backoffLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-bench&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;hostPID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Never&lt;/span&gt;
      &lt;span class="na"&gt;nodeSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;node-role.kubernetes.io/control-plane&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
      &lt;span class="na"&gt;tolerations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node-role.kubernetes.io/control-plane&lt;/span&gt;
          &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Exists&lt;/span&gt;
          &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NoSchedule&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-bench&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker.io/aquasec/kube-bench:v0.10.4&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kube-bench"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--targets=master,etcd,controlplane,policies"&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--json"&lt;/span&gt;
          &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;runAsUser&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
            &lt;span class="na"&gt;allowPrivilegeEscalation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
            &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALL"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;var-lib-etcd&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;  &lt;span class="nv"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/var/lib/etcd&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;  &lt;span class="nv"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;var-lib-kubelet&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/var/lib/kubelet&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;etc-kubernetes&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/etc/kubernetes&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;etc-systemd&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;   &lt;span class="nv"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/etc/systemd&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;   &lt;span class="nv"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;usr-bin&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;       &lt;span class="nv"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/usr/local/mount-from-host/bin&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;var-lib-etcd&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;    &lt;span class="nv"&gt;hostPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/var/lib/etcd&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;var-lib-kubelet&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;hostPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/var/lib/kubelet&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;etc-kubernetes&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;  &lt;span class="nv"&gt;hostPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/etc/kubernetes&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;etc-systemd&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;     &lt;span class="nv"&gt;hostPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/etc/systemd&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;usr-bin&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;         &lt;span class="nv"&gt;hostPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/usr/bin&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create namespace kube-bench
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; kube-bench-master.yaml
kubectl &lt;span class="nb"&gt;wait&lt;/span&gt; &lt;span class="nt"&gt;--for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;condition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;complete &lt;/span&gt;job/kube-bench-master &lt;span class="nt"&gt;-n&lt;/span&gt; kube-bench &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;120s
kubectl logs job/kube-bench-master &lt;span class="nt"&gt;-n&lt;/span&gt; kube-bench &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; kube-bench-master.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For worker nodes, run the same Job as a DaemonSet with &lt;code&gt;--targets=node&lt;/code&gt; and without the control plane nodeSelector.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making the output usable
&lt;/h3&gt;

&lt;p&gt;Raw kube-bench output is a wall of &lt;code&gt;[PASS]&lt;/code&gt; / &lt;code&gt;[FAIL]&lt;/code&gt; / &lt;code&gt;[WARN]&lt;/code&gt;. Extract only what is actionable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'
  .Controls[] |
  .text as $section |
  .tests[].results[] |
  select(.status == "FAIL") |
  [$section, .test_number, .status, .test_desc] | @tsv
'&lt;/span&gt; kube-bench-master.json | column &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt;&lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a one-line verdict for CI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jq &lt;span class="s1"&gt;'[.Controls[].total_fail] | add'&lt;/span&gt; kube-bench-master.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;kube-bench also has &lt;code&gt;--exit-code 1&lt;/code&gt;, which makes the container exit non-zero as soon as one check fails. Use it only once you have triaged your baseline, otherwise every pipeline turns red on day one.&lt;/p&gt;

&lt;h3&gt;
  
  
  On OpenShift
&lt;/h3&gt;

&lt;p&gt;Force the Red Hat benchmark, because auto-detection on RHCOS is not always reliable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kube-bench run &lt;span class="nt"&gt;--benchmark&lt;/span&gt; rh-1.0 &lt;span class="nt"&gt;--targets&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;master,node,policies &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But on OpenShift the native path is better: the &lt;strong&gt;Compliance Operator&lt;/strong&gt; runs OpenSCAP-based &lt;code&gt;ocp4-cis&lt;/code&gt; profiles, produces &lt;code&gt;ComplianceCheckResult&lt;/code&gt; objects in the API, and can auto-remediate through MachineConfig.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;compliance.openshift.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ScanSettingBinding&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cis-compliance&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openshift-compliance&lt;/span&gt;
&lt;span class="na"&gt;profiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ocp4-cis&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Profile&lt;/span&gt;
    &lt;span class="na"&gt;apiGroup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;compliance.openshift.io/v1alpha1&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ocp4-cis-node&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Profile&lt;/span&gt;
    &lt;span class="na"&gt;apiGroup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;compliance.openshift.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;settingsRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
  &lt;span class="na"&gt;apiGroup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;compliance.openshift.io/v1alpha1&lt;/span&gt;
  &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ScanSetting&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc get compliancescan &lt;span class="nt"&gt;-n&lt;/span&gt; openshift-compliance
oc get compliancecheckresult &lt;span class="nt"&gt;-n&lt;/span&gt; openshift-compliance &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-l&lt;/span&gt; compliance.openshift.io/check-status&lt;span class="o"&gt;=&lt;/span&gt;FAIL &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; custom-columns&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'NAME:.metadata.name,SEVERITY:.severity'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Adding kubescape for the workload and RBAC side
&lt;/h2&gt;

&lt;p&gt;kube-bench stops at the node. kubescape talks to the API server and evaluates controls across RBAC, Pod Security, network policies, image provenance and secrets — exactly the part of the CIS benchmark (section 5) that kube-bench marks as "manual".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# list available frameworks for your kubescape version&lt;/span&gt;
kubescape list frameworks

&lt;span class="c"&gt;# CIS scan, JSON + human output, fail the shell on low compliance&lt;/span&gt;
kubescape scan framework cis-v1.23-t1.0.1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt; json &lt;span class="nt"&gt;--output&lt;/span&gt; kubescape-cis.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--compliance-threshold&lt;/span&gt; 80 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--severity-threshold&lt;/span&gt; high &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--verbose&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two flags do the heavy lifting in CI:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--compliance-threshold 80&lt;/code&gt; — exit non-zero if the overall compliance score drops below 80%.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--severity-threshold high&lt;/code&gt; — exit non-zero if any High/Critical control fails, regardless of score.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Legitimate deviations go into an exceptions file instead of being silently ignored:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"allow-host-path-for-csi-driver"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"policyType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"postureExceptionPolicy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"actions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"alertOnly"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"designatorType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Attributes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"attributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"namespace"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openshift-storage"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"posturePolicies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"controlID"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C-0048"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubescape scan framework cis-v1.23-t1.0.1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exceptions&lt;/span&gt; ./exceptions.json &lt;span class="nt"&gt;--format&lt;/span&gt; sarif &lt;span class="nt"&gt;--output&lt;/span&gt; kubescape.sarif
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SARIF output drops straight into GitHub code scanning; &lt;code&gt;--format junit&lt;/code&gt; plugs into GitLab or Jenkins test reporting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing the three failures you will always see
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;--anonymous-auth=true&lt;/code&gt; on the API server
&lt;/h3&gt;

&lt;p&gt;CIS 1.2.1 wants &lt;code&gt;--anonymous-auth=false&lt;/code&gt; on &lt;code&gt;kube-apiserver&lt;/code&gt;. Before you change it, understand the trade-off: anonymous requests are mapped to the user &lt;code&gt;system:anonymous&lt;/code&gt; in group &lt;code&gt;system:unauthenticated&lt;/code&gt;, and many load balancers and monitoring probes hit &lt;code&gt;/livez&lt;/code&gt;, &lt;code&gt;/readyz&lt;/code&gt; or &lt;code&gt;/healthz&lt;/code&gt; without credentials. Setting the flag to &lt;code&gt;false&lt;/code&gt; on a kubeadm cluster will break those probes unless you reconfigure them.&lt;/p&gt;

&lt;p&gt;The real risk is not anonymous authentication itself — it is anonymous authentication combined with a permissive RBAC binding. Audit that first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get clusterrolebindings,rolebindings &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'
  .items[] |
  select(.subjects[]? | .name == "system:unauthenticated" or .name == "system:anonymous") |
  "\(.kind)/\(.metadata.name) -&amp;gt; \(.roleRef.kind)/\(.roleRef.name)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything other than &lt;code&gt;system:public-info-viewer&lt;/code&gt; deserves an explanation. On kubeadm, apply the flag in the static pod manifest (the kubelet restarts the pod automatically):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /etc/kubernetes/manifests/kube-apiserver.yaml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-apiserver&lt;/span&gt;
      &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kube-apiserver&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--anonymous-auth=false&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--authorization-mode=Node,RBAC&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--profiling=false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On OpenShift you do &lt;strong&gt;not&lt;/strong&gt; edit these manifests. The API server is rendered by the &lt;code&gt;kube-apiserver-operator&lt;/code&gt;, and anonymous access is already constrained by the default RBAC. Treat kube-bench's finding as informational and verify the bindings instead.&lt;/p&gt;

&lt;p&gt;The kubelet-side check (CIS 4.2.1) is the one you should always enforce:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /var/lib/kubelet/config.yaml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubelet.config.k8s.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;KubeletConfiguration&lt;/span&gt;
&lt;span class="na"&gt;authentication&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;anonymous&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;webhook&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Webhook&lt;/span&gt;
&lt;span class="na"&gt;readOnlyPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. No audit log
&lt;/h3&gt;

&lt;p&gt;CIS 1.2.19–1.2.23 require an audit policy and retention. Without it you have no record of who read which Secret. A balanced starting policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /etc/kubernetes/audit-policy.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;audit.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Policy&lt;/span&gt;
&lt;span class="na"&gt;omitStages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;RequestReceived&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# Never log request/response bodies of secret-bearing resources&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Metadata&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secrets"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;configmaps"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authentication.k8s.io"&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tokenreviews"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="c1"&gt;# Drop high-volume, low-value read traffic&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;None&lt;/span&gt;
    &lt;span class="na"&gt;users&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system:kube-proxy"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;watch"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;None&lt;/span&gt;
    &lt;span class="na"&gt;userGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system:nodes"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;watch"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;None&lt;/span&gt;
    &lt;span class="na"&gt;nonResourceURLs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/healthz*"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/readyz*"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/livez*"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/version"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/metrics"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="c1"&gt;# Full payload on RBAC and admission changes&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RequestResponse&lt;/span&gt;
    &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;create"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;patch"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delete"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rbac.authorization.k8s.io"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;admissionregistration.k8s.io"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;policy"&lt;/span&gt;

  &lt;span class="c1"&gt;# Everything else: metadata&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Metadata&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /etc/kubernetes/manifests/kube-apiserver.yaml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-apiserver&lt;/span&gt;
      &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kube-apiserver&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--audit-policy-file=/etc/kubernetes/audit-policy.yaml&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--audit-log-path=/var/log/kubernetes/audit/audit.log&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--audit-log-maxage=30&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--audit-log-maxbackup=10&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--audit-log-maxsize=100&lt;/span&gt;
      &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;audit-policy&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/etc/kubernetes/audit-policy.yaml&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;audit-log&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/var/log/kubernetes/audit&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;audit-policy&lt;/span&gt;
      &lt;span class="na"&gt;hostPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/etc/kubernetes/audit-policy.yaml&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;File&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;audit-log&lt;/span&gt;
      &lt;span class="na"&gt;hostPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/var/log/kubernetes/audit&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;DirectoryOrCreate&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On OpenShift, audit logging is on by default and you select a profile declaratively:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc patch apiserver cluster &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;merge &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"spec":{"audit":{"profile":"WriteRequestBodies"}}}'&lt;/span&gt;

&lt;span class="c"&gt;# logs are already collected per control plane node&lt;/span&gt;
oc adm node-logs &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;master &lt;span class="nt"&gt;--path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;kube-apiserver/ | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. etcd exposure
&lt;/h3&gt;

&lt;p&gt;CIS section 2 checks that etcd requires client certificates, uses peer TLS, and that &lt;code&gt;/var/lib/etcd&lt;/code&gt; is &lt;code&gt;0700&lt;/code&gt; and owned by &lt;code&gt;etcd:etcd&lt;/code&gt;. Verify on a control plane node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"%a %U:%G %n"&lt;/span&gt; /var/lib/etcd
ps &lt;span class="nt"&gt;-ef&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'--client-cert-auth=[a-z]*\|--peer-client-cert-auth=[a-z]*\|--auto-tls=[a-z]*'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check kube-bench cannot do for you is &lt;strong&gt;encryption at rest&lt;/strong&gt; (CIS 1.2.31/1.2.34). Secrets are stored in etcd base64-encoded, not encrypted, unless you configure a provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /etc/kubernetes/enc/encryption-config.yaml  -- never commit this file&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apiserver.config.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EncryptionConfiguration&lt;/span&gt;
&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secrets"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;configmaps"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;aescbc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;key-2024-06&lt;/span&gt;
              &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;BASE64_32_BYTE_KEY&amp;gt;&lt;/span&gt;   &lt;span class="c1"&gt;# head -c 32 /dev/urandom | base64&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;identity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;--encryption-provider-config=/etc/kubernetes/enc/encryption-config.yaml&lt;/code&gt; to the API server, then rewrite existing objects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get secrets &lt;span class="nt"&gt;--all-namespaces&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | kubectl replace &lt;span class="nt"&gt;-f&lt;/span&gt; -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On OpenShift this is a single field, and the operator handles key rotation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc patch apiserver cluster &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;merge &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"spec":{"encryption":{"type":"aescbc"}}}'&lt;/span&gt;
oc get apiserver cluster &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.status.conditions[?(@.type=="Encrypted")].reason}{"\n"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Wiring it into CI
&lt;/h2&gt;

&lt;p&gt;Run kubescape on every merge request (it only needs API access) and kube-bench nightly as a CronJob (it needs node access).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .gitlab-ci.yml&lt;/span&gt;
&lt;span class="na"&gt;cluster-compliance&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;audit&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;quay.io/kubescape/kubescape-cli:v3.0.8&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kubescape scan framework cis-v1.23-t1.0.1&lt;/span&gt;
        &lt;span class="s"&gt;--exceptions ./compliance/exceptions.json&lt;/span&gt;
        &lt;span class="s"&gt;--compliance-threshold &lt;/span&gt;&lt;span class="m"&gt;80&lt;/span&gt;
        &lt;span class="s"&gt;--severity-threshold high&lt;/span&gt;
        &lt;span class="s"&gt;--format junit --output kubescape-junit.xml&lt;/span&gt;
        &lt;span class="s"&gt;--format json  --output kubescape.json&lt;/span&gt;
  &lt;span class="na"&gt;artifacts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;reports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;junit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubescape-junit.xml&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;kubescape.json&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$CI_PIPELINE_SOURCE == "merge_request_event"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$CI_PIPELINE_SOURCE == "schedule"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nightly kube-bench run, with results pushed to a bucket and a summary into the daily report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CronJob&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-bench-nightly&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-bench&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
  &lt;span class="na"&gt;concurrencyPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Forbid&lt;/span&gt;
  &lt;span class="na"&gt;successfulJobsHistoryLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&gt;
  &lt;span class="na"&gt;jobTemplate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;hostPID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Never&lt;/span&gt;
          &lt;span class="na"&gt;nodeSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;node-role.kubernetes.io/control-plane&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
          &lt;span class="na"&gt;tolerations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;node-role.kubernetes.io/control-plane&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;Exists&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;NoSchedule&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-bench&lt;/span&gt;
              &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker.io/aquasec/kube-bench:v0.10.4&lt;/span&gt;
              &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--targets=master,etcd,controlplane,policies"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;
                     &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--json"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--outputfile=/reports/kube-bench-$(NODE_NAME).json"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
              &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NODE_NAME&lt;/span&gt;
                  &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;fieldRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;fieldPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;spec.nodeName&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
              &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;reports&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/reports&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;etc-kubernetes&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/etc/kubernetes&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;var-lib-etcd&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/var/lib/etcd&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;reports&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;claimName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;compliance-reports&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;etc-kubernetes&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;hostPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/etc/kubernetes&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;var-lib-etcd&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;hostPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/var/lib/etcd&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A readable Markdown report for the weekly review:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"# CIS audit — &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-I&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; — cluster &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kubectl config current-context&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;echo
  echo&lt;/span&gt; &lt;span class="s2"&gt;"| Check | Severity | Description | Remediation |"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"|---|---|---|---|"&lt;/span&gt;
  jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.Controls[].tests[].results[]
    | select(.status=="FAIL")
    | "| \(.test_number) | \(.scored // false | if . then "scored" else "not scored" end) | \(.test_desc) | \(.remediation | gsub("\n"; " ")) |"'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    kube-bench-master.json
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; report-cis.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Security best practices
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Baseline first, gate second.&lt;/strong&gt; Capture the current failure list, file it as exceptions with an owner and an expiry date, then make CI fail on anything &lt;em&gt;new&lt;/em&gt;. Gating on absolute compliance from day one just teaches people to add &lt;code&gt;|| true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the benchmark version&lt;/strong&gt; in your pipeline (&lt;code&gt;cis-v1.23-t1.0.1&lt;/code&gt;, &lt;code&gt;rh-1.0&lt;/code&gt;, &lt;code&gt;v0.10.4&lt;/code&gt;). A benchmark bump is a change you review, not a surprise red build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never auto-remediate the control plane.&lt;/strong&gt; kube-bench remediations edit static pod manifests; a bad flag means an API server that does not come back. Apply through your installer (kubeadm config, MachineConfig, Cluster API) and roll one node at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship audit logs off the node.&lt;/strong&gt; A log sitting on a compromised control plane node is evidence an attacker can delete. Forward to a write-once destination and alert on &lt;code&gt;secrets&lt;/code&gt; access by non-system identities.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the &lt;code&gt;identity&lt;/code&gt; provider last&lt;/strong&gt; in &lt;code&gt;EncryptionConfiguration&lt;/code&gt; during migration, and rotate keys by prepending a new one and re-running the &lt;code&gt;kubectl replace&lt;/code&gt; pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit the auditor.&lt;/strong&gt; kube-bench runs as root with &lt;code&gt;hostPID&lt;/code&gt; and host mounts. Scope it to a dedicated namespace, a dedicated ServiceAccount, and a restrictive Pod Security / SCC exception — not a blanket &lt;code&gt;privileged&lt;/code&gt; grant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correlate with runtime.&lt;/strong&gt; A passing CIS score with a cluster-admin ServiceAccount token mounted in a public-facing pod is still a breach waiting to happen. Section 5 controls in kubescape are the ones that catch this.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;Control plane hardening is not a one-off project; it is a measurement you take every night. kube-bench tells you what the node configuration looks like, kubescape tells you what the API objects allow, and the two together cover the CIS benchmark end to end. On OpenShift, let the Compliance Operator own sections 1–4 and use kubescape for the workload layer.&lt;/p&gt;

&lt;p&gt;Start with the three findings that appear on nearly every cluster: review who is bound to &lt;code&gt;system:unauthenticated&lt;/code&gt;, turn on a real audit policy with retention, and encrypt Secrets at rest while locking down etcd's files and client certificates. Then convert the remaining failures into a tracked exception list, set &lt;code&gt;--compliance-threshold&lt;/code&gt; and &lt;code&gt;--severity-threshold&lt;/code&gt; in CI, and archive the JSON so you can show a trend line rather than a snapshot. A compliance report that nobody reads is an artifact; a compliance report that fails a pipeline is a control.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>openshift</category>
      <category>devops</category>
    </item>
    <item>
      <title>Priorities, Preemption and QoS: Making Sure Your Critical Pods Survive Saturation</title>
      <dc:creator>Florient DOGBE</dc:creator>
      <pubDate>Mon, 26 Oct 2026 13:25:15 +0000</pubDate>
      <link>https://dev.to/florient_4262fab5463a691/priorities-preemption-and-qos-making-sure-your-critical-pods-survive-saturation-18ni</link>
      <guid>https://dev.to/florient_4262fab5463a691/priorities-preemption-and-qos-making-sure-your-critical-pods-survive-saturation-18ni</guid>
      <description>&lt;p&gt;When a node runs out of memory at 3 a.m., three different subsystems can decide which container dies: the &lt;strong&gt;scheduler&lt;/strong&gt; (preemption at admission time), the &lt;strong&gt;kubelet eviction manager&lt;/strong&gt; (node-pressure eviction at runtime), and the &lt;strong&gt;Linux OOM killer&lt;/strong&gt; (cgroup-level, no Kubernetes semantics at all). They do not share the same ranking rules, they do not read the same fields, and only one of them cares about your &lt;code&gt;PodDisruptionBudget&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This article unpacks that interaction: how &lt;code&gt;PriorityClass&lt;/code&gt;, QoS class and &lt;code&gt;evictionHard&lt;/code&gt; thresholds combine, why your logging DaemonSet gets killed before a batch job, and how to design a priority taxonomy that does not produce cascading preemption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context: three killers, one node
&lt;/h2&gt;

&lt;p&gt;A saturated node is not a single failure mode. You need to distinguish:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Ranking input&lt;/th&gt;
&lt;th&gt;Honors PDB?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scheduler preemption&lt;/td&gt;
&lt;td&gt;A pending pod cannot be scheduled&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PriorityClass.value&lt;/code&gt;, &lt;code&gt;preemptionPolicy&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Best-effort only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubelet node-pressure eviction&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;memory.available&lt;/code&gt;, &lt;code&gt;nodefs.available&lt;/code&gt;, &lt;code&gt;pid.available&lt;/code&gt;… crossing a threshold&lt;/td&gt;
&lt;td&gt;usage vs. requests, then priority, then overage&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kernel OOM killer&lt;/td&gt;
&lt;td&gt;cgroup limit or global memory exhaustion&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;oom_score_adj&lt;/code&gt; derived from QoS&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The classic incident: an operator sets &lt;code&gt;priorityClassName: high-priority&lt;/code&gt; on a batch workload "to make sure it runs", the scheduler preempts a handful of pods on node A, those pods reschedule onto node B, push node B above &lt;code&gt;memory.available&amp;lt;100Mi&lt;/code&gt;, the kubelet evicts there too — and because the evicted pods have no memory requests at all (BestEffort), the cascade continues across the cluster. Priority was set, QoS was not, and nobody looked at &lt;code&gt;evictionHard&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  QoS classes: the only thing the kubelet really ranks on
&lt;/h2&gt;

&lt;p&gt;QoS is derived, never declared. The rules are strict:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Guaranteed&lt;/strong&gt;: every container in the pod sets &lt;code&gt;requests == limits&lt;/code&gt; for &lt;strong&gt;both&lt;/strong&gt; CPU and memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Burstable&lt;/strong&gt;: at least one container sets a CPU or memory request (or limit), but the Guaranteed condition is not met.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BestEffort&lt;/strong&gt;: no requests and no limits anywhere in the pod.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check it, do not assume it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc get pods &lt;span class="nt"&gt;-n&lt;/span&gt; payments &lt;span class="nt"&gt;-o&lt;/span&gt; custom-columns&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="s1"&gt;'NAME:.metadata.name,QOS:.status.qosClass,PRIO:.spec.priority,PC:.spec.priorityClassName'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;QoS drives two things. First, &lt;code&gt;oom_score_adj&lt;/code&gt; on every container process:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Guaranteed → &lt;code&gt;-997&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;BestEffort → &lt;code&gt;1000&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Burstable → &lt;code&gt;min(max(2, 1000 - (1000 × memoryRequest) / nodeAllocatableMemory), 999)&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So under a &lt;em&gt;global&lt;/em&gt; kernel OOM event, a Guaranteed pod is almost the last victim, and a Burstable pod with a large request is far safer than one with a token &lt;code&gt;64Mi&lt;/code&gt;. Priority plays &lt;strong&gt;no role here&lt;/strong&gt; — the kernel has never heard of &lt;code&gt;PriorityClass&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Verify on a node (OpenShift)&lt;/span&gt;
oc debug node/worker-02 &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;chroot&lt;/span&gt; /host /bin/bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'for p in $(pgrep -f "nginx"); do echo -n "$p "; cat /proc/$p/oom_score_adj; done'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, QoS shapes the kubelet's eviction ordering — indirectly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kubelet node-pressure eviction: the real ranking
&lt;/h2&gt;

&lt;p&gt;The eviction manager does &lt;strong&gt;not&lt;/strong&gt; sort by "BestEffort first, then Burstable, then Guaranteed", despite what many blog posts claim. For memory pressure it applies a three-level comparator:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is the pod's memory usage &lt;strong&gt;above its memory request&lt;/strong&gt;? Pods above request sort first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pod priority&lt;/strong&gt; (lower priority sorts first).&lt;/li&gt;
&lt;li&gt;Memory usage &lt;strong&gt;minus&lt;/strong&gt; request (largest overage first).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The practical consequence is elegant: a Guaranteed pod can never exceed its request (request == limit), and a Burstable pod staying inside its request is in the same bucket. They are only considered after every pod that is borrowing memory it never asked for. BestEffort pods have a request of zero, so any usage puts them in bucket 1 — they always die first.&lt;/p&gt;

&lt;p&gt;Priority only breaks ties &lt;strong&gt;inside&lt;/strong&gt; a bucket. A &lt;code&gt;system-cluster-critical&lt;/code&gt; pod that consumes 4 GiB with a 512 MiB request will be evicted before a priority-0 pod that respects its request. Setting a high &lt;code&gt;PriorityClass&lt;/code&gt; on a pod with no memory request buys you almost nothing.&lt;/p&gt;

&lt;p&gt;Disk pressure (&lt;code&gt;nodefs.available&lt;/code&gt;, &lt;code&gt;imagefs.available&lt;/code&gt;, inodes) follows the same pattern using &lt;code&gt;ephemeral-storage&lt;/code&gt; requests and local volume usage. The kubelet first garbage-collects dead containers and unused images; only if that is insufficient does it start evicting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuring thresholds on OpenShift
&lt;/h3&gt;

&lt;p&gt;Thresholds are node-level kubelet configuration, delivered through a &lt;code&gt;KubeletConfig&lt;/code&gt; bound to a MachineConfigPool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;machineconfiguration.openshift.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;KubeletConfig&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;worker-eviction-tuning&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;machineConfigPoolSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;pools.operator.machineconfiguration.openshift.io/worker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
  &lt;span class="na"&gt;kubeletConfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;evictionHard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;memory.available&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;500Mi"&lt;/span&gt;
      &lt;span class="na"&gt;nodefs.available&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10%"&lt;/span&gt;
      &lt;span class="na"&gt;nodefs.inodesFree&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5%"&lt;/span&gt;
      &lt;span class="na"&gt;imagefs.available&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;15%"&lt;/span&gt;
    &lt;span class="na"&gt;evictionSoft&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;memory.available&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1Gi"&lt;/span&gt;
      &lt;span class="na"&gt;nodefs.available&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;15%"&lt;/span&gt;
    &lt;span class="na"&gt;evictionSoftGracePeriod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;memory.available&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1m30s"&lt;/span&gt;
      &lt;span class="na"&gt;nodefs.available&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2m"&lt;/span&gt;
    &lt;span class="na"&gt;evictionMaxPodGracePeriod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
    &lt;span class="na"&gt;evictionPressureTransitionPeriod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
    &lt;span class="na"&gt;evictionMinimumReclaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;memory.available&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;300Mi"&lt;/span&gt;
      &lt;span class="na"&gt;nodefs.available&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2Gi"&lt;/span&gt;
    &lt;span class="na"&gt;systemReserved&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;500m"&lt;/span&gt;
      &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1Gi"&lt;/span&gt;
      &lt;span class="na"&gt;ephemeral-storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2Gi"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc apply &lt;span class="nt"&gt;-f&lt;/span&gt; kubeletconfig-eviction.yaml
oc get mcp worker &lt;span class="nt"&gt;-w&lt;/span&gt;   &lt;span class="c"&gt;# rollout is node-by-node, expect reboots-free kubelet restarts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details that bite in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;memory.available&lt;/code&gt; is computed from cgroup &lt;strong&gt;working set&lt;/strong&gt; (&lt;code&gt;memory.usage_in_bytes&lt;/code&gt; minus inactive file cache), not from &lt;code&gt;free -m&lt;/code&gt;. A node showing "plenty of free memory" in &lt;code&gt;top&lt;/code&gt; can still be under pressure.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;hard&lt;/strong&gt; eviction threshold is subtracted from &lt;code&gt;Allocatable&lt;/code&gt;. Raising &lt;code&gt;memory.available&lt;/code&gt; to &lt;code&gt;500Mi&lt;/code&gt; removes 400 MiB of schedulable capacity per node compared to the default &lt;code&gt;100Mi&lt;/code&gt;. That is the point: you buy headroom so the kubelet reacts before the kernel OOM killer does.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;evictionSoft&lt;/code&gt; gives containers a real &lt;code&gt;SIGTERM&lt;/code&gt; grace period (capped by &lt;code&gt;evictionMaxPodGracePeriod&lt;/code&gt;); &lt;code&gt;evictionHard&lt;/code&gt; kills immediately. Always define both, with the soft threshold more conservative.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# What did the kubelet actually load?&lt;/span&gt;
oc debug node/worker-02 &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;chroot&lt;/span&gt; /host &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;cat&lt;/span&gt; /etc/kubernetes/kubelet.conf | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A12&lt;/span&gt; eviction

&lt;span class="c"&gt;# Who got evicted and why&lt;/span&gt;
oc get events &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;--field-selector&lt;/span&gt; &lt;span class="nv"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Evicted &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; custom-columns&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'TIME:.lastTimestamp,NS:.involvedObject.namespace,POD:.involvedObject.name,MSG:.message'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  PriorityClass and preemption: the scheduler's side
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;PriorityClass&lt;/code&gt; is a cluster-scoped object. Its &lt;code&gt;value&lt;/code&gt; lands in &lt;code&gt;pod.spec.priority&lt;/code&gt; at admission time (mutation is immutable afterwards).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scheduling.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PriorityClass&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;platform-critical&lt;/span&gt;
&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1000000&lt;/span&gt;
&lt;span class="na"&gt;globalDefault&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;preemptionPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PreemptLowerPriority&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Node&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;agents,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ingress,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;logging,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;service&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;mesh&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;plane."&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scheduling.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PriorityClass&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;business-tier1&lt;/span&gt;
&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100000&lt;/span&gt;
&lt;span class="na"&gt;globalDefault&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;preemptionPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PreemptLowerPriority&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Customer-facing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;APIs&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;an&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;SLO."&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scheduling.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PriorityClass&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch-non-preempting&lt;/span&gt;
&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10000&lt;/span&gt;
&lt;span class="na"&gt;globalDefault&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;preemptionPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Never&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Scheduled&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ahead&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;of&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;workloads&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;queue,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;never&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;evicts&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;anyone."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;preemptionPolicy: Never&lt;/code&gt; is the single most underrated field here. The pod keeps its place in the scheduling queue (it is considered before lower-priority pending pods) but it will &lt;strong&gt;never&lt;/strong&gt; trigger victim selection. This is exactly what you want for batch, CI runners, Spark executors and anything that can simply wait.&lt;/p&gt;

&lt;p&gt;Reserved bands: &lt;code&gt;system-cluster-critical&lt;/code&gt; = 2000000000 and &lt;code&gt;system-node-critical&lt;/code&gt; = 2000001000 ship with every cluster. Do not create user classes above 1000000000 — leave the top band to the control plane. On OpenShift you will also find &lt;code&gt;openshift-user-critical&lt;/code&gt; and &lt;code&gt;openshift-monitoring&lt;/code&gt; as managed classes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc get priorityclass &lt;span class="nt"&gt;-o&lt;/span&gt; custom-columns&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'NAME:.metadata.name,VALUE:.value,DEFAULT:.globalDefault,POLICY:.preemptionPolicy'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Avoiding cascading preemption
&lt;/h3&gt;

&lt;p&gt;Preemption cascades happen when high-priority pods are produced faster than capacity, or when victim selection repeatedly fails to actually unblock the pending pod (anti-affinity, topology spread, volume node affinity). Guardrails that work:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Few, wide priority bands.&lt;/strong&gt; Four or five classes with gaps of 10× between values. Dozens of classes with values 100, 101, 102 produce long preemption chains with no operational meaning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Quota the expensive bands.&lt;/strong&gt; A &lt;code&gt;ResourceQuota&lt;/code&gt; with a &lt;code&gt;scopeSelector&lt;/code&gt; on &lt;code&gt;PriorityClass&lt;/code&gt; caps how much a namespace can consume at a given priority:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ResourceQuota&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tier1-budget&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;hard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;64"&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;128Gi&lt;/span&gt;
    &lt;span class="na"&gt;pods&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;120"&lt;/span&gt;
  &lt;span class="na"&gt;scopeSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchExpressions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&lt;/span&gt;
        &lt;span class="na"&gt;scopeName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PriorityClass&lt;/span&gt;
        &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;business-tier1"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform-critical"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Make DaemonSets explicitly critical.&lt;/strong&gt; The kubelet grants no special treatment to DaemonSet pods during eviction; only priority and requests count. Node agents must carry &lt;code&gt;system-node-critical&lt;/code&gt; (or your own high band) &lt;strong&gt;and&lt;/strong&gt; real requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Watch the right metrics.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Scheduler-side&lt;/span&gt;
scheduler_preemption_victims
scheduler_preemption_attempts_total

&lt;span class="c"&gt;# Kubelet-side&lt;/span&gt;
kubelet_evictions&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;eviction_signal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"memory.available"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
kubelet_eviction_stats_age_seconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A PromQL alert worth having:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kubelet_evictions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;15m&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;eviction_signal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  PodDisruptionBudget: what it does and does not protect
&lt;/h2&gt;

&lt;p&gt;This is the most common misconception in the whole topic.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API-initiated eviction&lt;/strong&gt; (&lt;code&gt;oc adm drain&lt;/code&gt;, descheduler, cluster-autoscaler scale-down) goes through the &lt;code&gt;pods/eviction&lt;/code&gt; subresource and &lt;strong&gt;respects&lt;/strong&gt; PDBs. A violated PDB returns &lt;code&gt;429 TooManyRequests&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduler preemption&lt;/strong&gt; tries to pick victims that do not violate PDBs, but if no such set exists it preempts anyway. It is a preference, not a guarantee.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubelet node-pressure eviction bypasses PDBs entirely.&lt;/strong&gt; The kubelet deletes the pod directly. No API eviction call, no budget check, no &lt;code&gt;minAvailable&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;policy/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PodDisruptionBudget&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-api&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;minAvailable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;80%&lt;/span&gt;
  &lt;span class="na"&gt;unhealthyPodEvictionPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AlwaysAllow&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-api&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc get pdb &lt;span class="nt"&gt;-n&lt;/span&gt; payments
oc adm drain worker-02 &lt;span class="nt"&gt;--ignore-daemonsets&lt;/span&gt; &lt;span class="nt"&gt;--delete-emptydir-data&lt;/span&gt; &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;300s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;unhealthyPodEvictionPolicy: AlwaysAllow&lt;/code&gt; (policy/v1, GA since 1.27) prevents the classic deadlock where a PDB blocks a drain because the already-broken pods are counted as "must not be disrupted".&lt;/p&gt;

&lt;p&gt;The takeaway: &lt;strong&gt;PDBs protect against planned disruption only.&lt;/strong&gt; Protection against node saturation comes from requests, limits and eviction thresholds — never from a budget.&lt;/p&gt;

&lt;h3&gt;
  
  
  A reference manifest
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-api&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;checkout-api&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;checkout-api&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;priorityClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;business-tier1&lt;/span&gt;
      &lt;span class="na"&gt;terminationGracePeriodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;45&lt;/span&gt;
      &lt;span class="na"&gt;topologySpreadConstraints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;maxSkew&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
          &lt;span class="na"&gt;topologyKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes.io/hostname&lt;/span&gt;
          &lt;span class="na"&gt;whenUnsatisfiable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ScheduleAnyway&lt;/span&gt;
          &lt;span class="na"&gt;labelSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;checkout-api&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;registry.example.com/payments/checkout:1.14.2&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;500m"&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1Gi"&lt;/span&gt;
              &lt;span class="na"&gt;ephemeral-storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;256Mi"&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;500m"&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1Gi"&lt;/span&gt;
              &lt;span class="na"&gt;ephemeral-storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1Gi"&lt;/span&gt;
          &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;allowPrivilegeEscalation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
            &lt;span class="na"&gt;runAsNonRoot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
            &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALL"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;seccompProfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;RuntimeDefault&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;requests == limits&lt;/code&gt; on CPU &lt;strong&gt;and&lt;/strong&gt; memory → Guaranteed → &lt;code&gt;oom_score_adj: -997&lt;/code&gt;, never in the "above request" eviction bucket, and an explicit &lt;code&gt;ephemeral-storage&lt;/code&gt; request so disk-pressure ranking treats it fairly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security best practices
&lt;/h2&gt;

&lt;p&gt;Priority is a &lt;strong&gt;capacity-theft primitive&lt;/strong&gt;. A tenant able to set &lt;code&gt;priorityClassName: system-cluster-critical&lt;/code&gt; can evict your control-plane add-ons from every node.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lock down PriorityClass creation.&lt;/strong&gt; &lt;code&gt;scheduling.k8s.io/priorityclasses&lt;/code&gt; must be restricted to cluster admins. Audit with &lt;code&gt;oc adm policy who-can create priorityclasses&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restrict consumption, not just creation.&lt;/strong&gt; RBAC cannot filter which &lt;code&gt;priorityClassName&lt;/code&gt; a pod may reference. Use a &lt;code&gt;ValidatingAdmissionPolicy&lt;/code&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;admissionregistration.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ValidatingAdmissionPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;restrict-priority-classes&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;failurePolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Fail&lt;/span&gt;
  &lt;span class="na"&gt;matchConstraints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;resourceRules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;apiVersions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;operations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CREATE"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPDATE"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pods"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;validations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;expression&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="s"&gt;!has(object.spec.priorityClassName) ||&lt;/span&gt;
        &lt;span class="s"&gt;object.spec.priorityClassName in ['batch-non-preempting','business-tier1']&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;This&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;namespace&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;may&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;only&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;use&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;batch-non-preempting&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;or&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;business-tier1."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bind it with a &lt;code&gt;ValidatingAdmissionPolicyBinding&lt;/code&gt; scoped by namespace selector so platform namespaces keep access to the high bands.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never set &lt;code&gt;globalDefault: true&lt;/code&gt; on a high-value class.&lt;/strong&gt; It silently promotes every pod in the cluster, including untrusted ones, and makes preemption meaningless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforce requests with LimitRange.&lt;/strong&gt; BestEffort pods are a denial-of-service vector: they consume memory they never declared and drag the whole node to the eviction threshold. A namespace &lt;code&gt;LimitRange&lt;/code&gt; with &lt;code&gt;defaultRequest&lt;/code&gt; removes the BestEffort class entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reserve capacity for the system.&lt;/strong&gt; &lt;code&gt;systemReserved&lt;/code&gt; plus a non-default &lt;code&gt;evictionHard&lt;/code&gt; keeps the kubelet, CRI-O and sshd alive so you can still reach the node during an incident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit priority changes.&lt;/strong&gt; Add &lt;code&gt;scheduling.k8s.io&lt;/code&gt; resources to the audit policy at &lt;code&gt;RequestResponse&lt;/code&gt; level; a new &lt;code&gt;PriorityClass&lt;/code&gt; with value 2000000001 is a credible escalation signal.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;QoS is derived from requests/limits — it is the primary defense.&lt;/strong&gt; &lt;code&gt;requests == limits&lt;/code&gt; yields Guaranteed, &lt;code&gt;oom_score_adj: -997&lt;/code&gt;, and immunity from the "above request" eviction bucket. No &lt;code&gt;PriorityClass&lt;/code&gt; compensates for missing requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The kubelet ranks by overage first, priority second.&lt;/strong&gt; A high-priority pod exceeding its request dies before a low-priority pod respecting its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The scheduler ranks by priority only.&lt;/strong&gt; &lt;code&gt;preemptionPolicy: Never&lt;/code&gt; lets batch workloads queue ahead without triggering evictions — use it aggressively.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDBs do not apply to node-pressure eviction.&lt;/strong&gt; They cover drains and API-initiated eviction. Stop relying on them for saturation scenarios.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tune &lt;code&gt;evictionHard&lt;/code&gt; / &lt;code&gt;evictionSoft&lt;/code&gt; deliberately.&lt;/strong&gt; Defaults (&lt;code&gt;memory.available&amp;lt;100Mi&lt;/code&gt;) leave almost no reaction time before the kernel OOM killer takes over with no Kubernetes semantics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat priority as a privilege.&lt;/strong&gt; Restrict it through admission policy and &lt;code&gt;ResourceQuota&lt;/code&gt; scope selectors, and never make a high class the global default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Design the taxonomy once — four bands, documented, quota-backed — and enforce requests everywhere. Saturation then becomes a predictable degradation instead of a cluster-wide cascade.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>openshift</category>
      <category>sre</category>
      <category>scheduling</category>
    </item>
    <item>
      <title>Debugging a Stubborn CrashLoopBackOff: Ephemeral Containers, kubectl debug, and Init Pitfalls</title>
      <dc:creator>Florient DOGBE</dc:creator>
      <pubDate>Thu, 22 Oct 2026 08:43:27 +0000</pubDate>
      <link>https://dev.to/florient_4262fab5463a691/debugging-a-stubborn-crashloopbackoff-ephemeral-containers-kubectl-debug-and-init-pitfalls-2jl3</link>
      <guid>https://dev.to/florient_4262fab5463a691/debugging-a-stubborn-crashloopbackoff-ephemeral-containers-kubectl-debug-and-init-pitfalls-2jl3</guid>
      <description>&lt;p&gt;Your deployment rolled out. Thirty seconds later, &lt;code&gt;kubectl get pods&lt;/code&gt; shows &lt;code&gt;CrashLoopBackOff&lt;/code&gt; and a restart counter climbing. You reach for the usual reflex — &lt;code&gt;kubectl exec -it pod/... -- sh&lt;/code&gt; — and get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Welcome to the modern container world: your image is distroless (or &lt;code&gt;scratch&lt;/code&gt;, or a &lt;code&gt;FROM scratch&lt;/code&gt; Go binary), there is no shell, no &lt;code&gt;ps&lt;/code&gt;, no &lt;code&gt;curl&lt;/code&gt;, no &lt;code&gt;cat&lt;/code&gt;. And even if there were, the container is dead most of the time anyway — you can't &lt;code&gt;exec&lt;/code&gt; into something that isn't running.&lt;/p&gt;

&lt;p&gt;This article is a step-by-step method to diagnose that pod without ever rebuilding the image with "debug tools". We'll read exit codes properly, decode init container failures, attach &lt;strong&gt;ephemeral containers&lt;/strong&gt; with &lt;code&gt;kubectl debug&lt;/code&gt;, and clone a pod into a debuggable copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context: why CrashLoopBackOff got harder
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CrashLoopBackOff&lt;/code&gt; is not an error in itself. It's the kubelet telling you: &lt;em&gt;"the container's main process exited, I restarted it, it exited again, so now I'm waiting before trying once more."&lt;/em&gt; The backoff starts at 10 seconds and doubles up to a 5-minute cap. That cap is why a crashing pod feels frozen after a few minutes — you get one short window of life every 300 seconds.&lt;/p&gt;

&lt;p&gt;Two trends made this harder to debug:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Minimal images.&lt;/strong&gt; Distroless, scratch, and Chainguard-style images ship a binary and little else. No package manager, no shell, often no writable filesystem. Great for the attack surface, terrible for &lt;code&gt;kubectl exec&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardened Pod Security.&lt;/strong&gt; On OpenShift, the &lt;code&gt;restricted-v2&lt;/code&gt; SCC forces a random UID, drops all capabilities, and blocks privilege escalation. Your old trick of "just run a privileged busybox" needs explicit authorization.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The good news: Kubernetes shipped &lt;strong&gt;ephemeral containers&lt;/strong&gt; (stable since 1.25) precisely for this. You inject a throwaway container &lt;em&gt;into the running pod&lt;/em&gt; — same network namespace, same volumes, optionally the same process namespace — without restarting anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: read the status before touching anything
&lt;/h2&gt;

&lt;p&gt;Ninety percent of CrashLoopBackOff cases are solved here. Don't skip it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; shop get pod checkout-7d9f6c4b8-xk2lq &lt;span class="nt"&gt;-o&lt;/span&gt; wide
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; shop describe pod checkout-7d9f6c4b8-xk2lq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;describe&lt;/code&gt;, go straight to the &lt;code&gt;Last State&lt;/code&gt; block. But the fastest, most reliable read is JSONPath, because &lt;code&gt;describe&lt;/code&gt; truncates and reorders:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; shop get pod checkout-7d9f6c4b8-xk2lq &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{range .status.containerStatuses[*]}{.name}{"\t"}{.lastState.terminated.exitCode}{"\t"}{.lastState.terminated.reason}{"\t"}{.lastState.terminated.message}{"\n"}{end}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;strong&gt;do the same for init containers&lt;/strong&gt; — this is the single most forgotten command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; shop get pod checkout-7d9f6c4b8-xk2lq &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{range .status.initContainerStatuses[*]}{.name}{"\t"}{.state}{"\n"}{end}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Decoding exitCode correctly
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;exitCode&lt;/code&gt; is not noise. It's a precise signal:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;exitCode&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Typical cause&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Clean exit&lt;/td&gt;
&lt;td&gt;The process finished its job; a &lt;code&gt;Deployment&lt;/code&gt; expects a long-running process. Wrong workload type.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Generic application error&lt;/td&gt;
&lt;td&gt;Unhandled exception, missing env var, bad config. Read the logs.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Shell misuse / CLI arg error&lt;/td&gt;
&lt;td&gt;Bad flag in &lt;code&gt;args&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;126&lt;/td&gt;
&lt;td&gt;Cannot execute&lt;/td&gt;
&lt;td&gt;Binary present but not executable, or wrong architecture (arm64 image on amd64 node).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;127&lt;/td&gt;
&lt;td&gt;Command not found&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;command:&lt;/code&gt; points to a path that doesn't exist in the image — classic on distroless.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;128+N&lt;/td&gt;
&lt;td&gt;Killed by signal N&lt;/td&gt;
&lt;td&gt;See below.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;137&lt;/td&gt;
&lt;td&gt;128+9, SIGKILL&lt;/td&gt;
&lt;td&gt;OOMKilled, or liveness probe + failed graceful shutdown.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;139&lt;/td&gt;
&lt;td&gt;128+11, SIGSEGV&lt;/td&gt;
&lt;td&gt;Native crash, corrupt binary, incompatible CPU instruction set.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;143&lt;/td&gt;
&lt;td&gt;128+15, SIGTERM&lt;/td&gt;
&lt;td&gt;Something asked it to stop: liveness probe, eviction, rollout.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two important distinctions a beginner must internalize:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;137 + &lt;code&gt;reason: OOMKilled&lt;/code&gt;&lt;/strong&gt; means the memory limit was hit. &lt;strong&gt;137 without &lt;code&gt;OOMKilled&lt;/code&gt;&lt;/strong&gt; usually means a liveness probe killed the container after it ignored SIGTERM for &lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;exitCode 0 in CrashLoopBackOff&lt;/strong&gt; is not a bug in Kubernetes. With &lt;code&gt;restartPolicy: Always&lt;/code&gt; (the only option for Deployments), even a successful exit triggers a restart. If your container is a one-shot task, it belongs in a &lt;code&gt;Job&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Logs of the &lt;em&gt;previous&lt;/em&gt; instance
&lt;/h3&gt;

&lt;p&gt;The container that failed is gone. Its logs are not in &lt;code&gt;kubectl logs&lt;/code&gt; — they're one container generation back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; shop logs checkout-7d9f6c4b8-xk2lq &lt;span class="nt"&gt;-c&lt;/span&gt; checkout &lt;span class="nt"&gt;--previous&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; shop logs checkout-7d9f6c4b8-xk2lq &lt;span class="nt"&gt;--all-containers&lt;/span&gt; &lt;span class="nt"&gt;--previous&lt;/span&gt; &lt;span class="nt"&gt;--timestamps&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;--previous&lt;/code&gt; returns nothing, the process died before writing anything — which itself points at exit 126/127 or a missing config file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Make the crash message visible in the status
&lt;/h3&gt;

&lt;p&gt;A trick almost nobody uses: &lt;code&gt;terminationMessagePolicy&lt;/code&gt;. By default the kubelet only reads &lt;code&gt;/dev/termination-log&lt;/code&gt;. Set it to &lt;code&gt;FallbackToLogsOnError&lt;/code&gt; and the last lines of stderr are copied into &lt;code&gt;lastState.terminated.message&lt;/code&gt;, visible in &lt;code&gt;kubectl describe&lt;/code&gt; forever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pod&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;registry.example.com/shop/checkout:1.8.3&lt;/span&gt;
      &lt;span class="na"&gt;terminationMessagePolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;FallbackToLogsOnError&lt;/span&gt;
      &lt;span class="na"&gt;terminationMessagePath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/dev/termination-log&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put this on every container in your base templates. It costs nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: the init container pitfalls
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;kubectl get pod&lt;/code&gt; shows &lt;code&gt;Init:CrashLoopBackOff&lt;/code&gt; or &lt;code&gt;Init:Error&lt;/code&gt;, your app container never even started. Init containers run &lt;strong&gt;sequentially&lt;/strong&gt;, and each one must exit 0 before the next starts. A single failing init container restarts the &lt;strong&gt;whole pod&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here's a realistic failing example — a distroless app behind a busybox init that waits for a database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pod&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runAsNonRoot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;seccompProfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RuntimeDefault&lt;/span&gt;
  &lt;span class="na"&gt;initContainers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wait-for-db&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;busybox:1.36&lt;/span&gt;
      &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;sh&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-c&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;until nc -z postgres.shop.svc.cluster.local 5432; do&lt;/span&gt;
            &lt;span class="s"&gt;echo "waiting for postgres..." &amp;gt;&amp;amp;2&lt;/span&gt;
            &lt;span class="s"&gt;sleep 2&lt;/span&gt;
          &lt;span class="s"&gt;done&lt;/span&gt;
      &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;allowPrivilegeEscalation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="na"&gt;runAsUser&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;65532&lt;/span&gt;
        &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALL"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gcr.io/distroless/static-debian12:nonroot&lt;/span&gt;
      &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--config=/etc/checkout/config.yaml"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;terminationMessagePolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;FallbackToLogsOnError&lt;/span&gt;
      &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;config&lt;/span&gt;
          &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/checkout&lt;/span&gt;
          &lt;span class="na"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;allowPrivilegeEscalation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="na"&gt;runAsUser&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;65532&lt;/span&gt;
        &lt;span class="na"&gt;readOnlyRootFilesystem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALL"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;config&lt;/span&gt;
      &lt;span class="na"&gt;configMap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-config&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Classic traps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Infinite wait, not a crash.&lt;/strong&gt; &lt;code&gt;Init:0/1&lt;/code&gt; that never progresses isn't CrashLoopBackOff — it's a DNS or NetworkPolicy issue. Check with &lt;code&gt;kubectl logs pod/checkout -c wait-for-db&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The init container has a shell, the app doesn't.&lt;/strong&gt; You can debug the init phase easily, which lulls you into thinking the problem is in the app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sidecars as init containers&lt;/strong&gt; (&lt;code&gt;restartPolicy: Always&lt;/code&gt; on an init container, Kubernetes 1.29+) behave differently: they're not expected to exit, and the next init container starts as soon as they're &lt;code&gt;Started&lt;/code&gt;. Mixing a classic init and a native sidecar in the wrong order is a common source of hangs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Init container logs disappear on restart.&lt;/strong&gt; Use &lt;code&gt;--previous&lt;/code&gt; on init containers too: &lt;code&gt;kubectl logs pod/checkout -c wait-for-db --previous&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: attach an ephemeral container with kubectl debug
&lt;/h2&gt;

&lt;p&gt;The pod still has a running sandbox (pause container, network namespace, volumes) even while the app container is dead. That's where you inject your toolbox:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; shop debug &lt;span class="nt"&gt;-it&lt;/span&gt; checkout-7d9f6c4b8-xk2lq &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot:v0.13 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;checkout &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--profile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;general &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--&lt;/span&gt; bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--image&lt;/code&gt; — your debug toolbox. &lt;code&gt;busybox:1.36&lt;/code&gt;, &lt;code&gt;nicolaka/netshoot&lt;/code&gt;, or an internal image with &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;dig&lt;/code&gt;, &lt;code&gt;ss&lt;/code&gt;, &lt;code&gt;strace&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--target=checkout&lt;/code&gt; — share the &lt;strong&gt;process namespace&lt;/strong&gt; of that specific container. Without it you can't see or inspect the app process.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--profile&lt;/code&gt; — &lt;code&gt;general&lt;/code&gt;, &lt;code&gt;baseline&lt;/code&gt;, &lt;code&gt;restricted&lt;/code&gt;, &lt;code&gt;netadmin&lt;/code&gt;, &lt;code&gt;sysadmin&lt;/code&gt;. Always pass it explicitly; the implicit default has changed across versions. Use &lt;code&gt;restricted&lt;/code&gt; on hardened namespaces, &lt;code&gt;netadmin&lt;/code&gt; only when you need &lt;code&gt;NET_ADMIN&lt;/code&gt; / &lt;code&gt;NET_RAW&lt;/code&gt; for tcpdump.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once inside, you are in the same network namespace as the pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Is the config file actually mounted and readable?&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /proc/1/root/etc/checkout/ 2&amp;gt;/dev/null

&lt;span class="c"&gt;# Does the service resolve from inside the pod?&lt;/span&gt;
nslookup postgres.shop.svc.cluster.local
nc &lt;span class="nt"&gt;-vz&lt;/span&gt; postgres.shop.svc.cluster.local 5432

&lt;span class="c"&gt;# Who is listening in this pod's netns?&lt;/span&gt;
ss &lt;span class="nt"&gt;-lntp&lt;/span&gt;

&lt;span class="c"&gt;# What environment did the app actually get?&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/1/environ | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\0'&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/root&lt;/code&gt; trick is the key to distroless debugging: with &lt;code&gt;--target&lt;/code&gt; and a shared PID namespace, you can read the &lt;em&gt;other&lt;/em&gt; container's filesystem through &lt;code&gt;/proc&lt;/code&gt;, even though your debug container has its own image. Find the PID with &lt;code&gt;ps aux&lt;/code&gt; first — it won't always be 1.&lt;/p&gt;

&lt;h3&gt;
  
  
  Limits you must know
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Ephemeral containers &lt;strong&gt;cannot be removed&lt;/strong&gt;. Once added, they stay in the pod spec until the pod is deleted. Debug, then delete the pod.&lt;/li&gt;
&lt;li&gt;They &lt;strong&gt;cannot define &lt;code&gt;resources&lt;/code&gt;, &lt;code&gt;ports&lt;/code&gt;, &lt;code&gt;probes&lt;/code&gt;, or &lt;code&gt;lifecycle&lt;/code&gt;&lt;/strong&gt;. They have no resource requests — mind your node pressure.&lt;/li&gt;
&lt;li&gt;They need the &lt;code&gt;pods/ephemeralcontainers&lt;/code&gt; subresource RBAC verb (see security section).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If the pod has no sandbox at all&lt;/strong&gt; — &lt;code&gt;Pending&lt;/code&gt;, &lt;code&gt;ImagePullBackOff&lt;/code&gt;, &lt;code&gt;CreateContainerConfigError&lt;/code&gt; — ephemeral containers won't help. That's a scheduling/image/config problem; &lt;code&gt;kubectl describe&lt;/code&gt; events are your answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 4: clone the pod when you need to change the command
&lt;/h2&gt;

&lt;p&gt;Sometimes the container dies in 200 ms and you need it &lt;em&gt;alive&lt;/em&gt; to inspect it. You can't change the command of a running pod — but you can copy it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; shop debug checkout-7d9f6c4b8-xk2lq &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--copy-to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;checkout-debug &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--container&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;checkout &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--keep-init-containers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;infinity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates a &lt;strong&gt;new pod&lt;/strong&gt; &lt;code&gt;checkout-debug&lt;/code&gt; with the same volumes, env, and service account, but the &lt;code&gt;checkout&lt;/code&gt; container's command replaced by &lt;code&gt;sleep infinity&lt;/code&gt;. Note: if the image is distroless, &lt;code&gt;sleep&lt;/code&gt; doesn't exist — combine with &lt;code&gt;--set-image&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; shop debug checkout-7d9f6c4b8-xk2lq &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--copy-to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;checkout-debug &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set-image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;checkout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;busybox:1.36 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--keep-init-containers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you have a shell with the &lt;strong&gt;exact same mounted ConfigMaps, Secrets, and ServiceAccount token&lt;/strong&gt;. Validate the hypothesis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /etc/checkout/config.yaml
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-ln&lt;/span&gt; /etc/checkout         &lt;span class="c"&gt;# UID/GID mismatch with runAsUser?&lt;/span&gt;
&lt;span class="nb"&gt;env&lt;/span&gt; | &lt;span class="nb"&gt;sort
cat&lt;/span&gt; /var/run/secrets/kubernetes.io/serviceaccount/namespace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flags that matter on &lt;code&gt;--copy-to&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--keep-labels=false&lt;/code&gt; (the default) strips labels so the copy &lt;strong&gt;does not receive Service traffic&lt;/strong&gt;. Never set it to &lt;code&gt;true&lt;/code&gt; in production.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--keep-init-containers=true&lt;/code&gt; is the default — set it to &lt;code&gt;false&lt;/code&gt; when the init container is exactly what's failing, otherwise your copy crashes the same way.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--keep-liveness&lt;/code&gt;, &lt;code&gt;--keep-readiness&lt;/code&gt;, &lt;code&gt;--keep-startup&lt;/code&gt; default to &lt;code&gt;false&lt;/code&gt;: probes are removed, so nothing kills your debug pod.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--share-processes&lt;/code&gt; makes all containers in the copy share the PID namespace.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Delete the copy when you're done: &lt;code&gt;kubectl -n shop delete pod checkout-debug&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  OpenShift specifics
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;oc debug&lt;/code&gt; predates &lt;code&gt;kubectl debug&lt;/code&gt; and does the copy-and-neutralize dance for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Debug a copy of the pod, probes removed, command replaced by a shell&lt;/span&gt;
oc &lt;span class="nt"&gt;-n&lt;/span&gt; shop debug pod/checkout-7d9f6c4b8-xk2lq &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;registry.access.redhat.com/ubi9/ubi-minimal

&lt;span class="c"&gt;# Debug from a workload controller directly&lt;/span&gt;
oc &lt;span class="nt"&gt;-n&lt;/span&gt; shop debug deployment/checkout &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;registry.access.redhat.com/ubi9/ubi-minimal &lt;span class="nt"&gt;--&lt;/span&gt; /bin/bash

&lt;span class="c"&gt;# Keep the same UID/SCC context instead of asking for root&lt;/span&gt;
oc &lt;span class="nt"&gt;-n&lt;/span&gt; shop debug deployment/checkout &lt;span class="nt"&gt;--as-user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1000650000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the problem looks node-level (disk pressure, CNI, image GC):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc debug node/worker-02
&lt;span class="c"&gt;# then, inside:&lt;/span&gt;
&lt;span class="nb"&gt;chroot&lt;/span&gt; /host
crictl ps &lt;span class="nt"&gt;-a&lt;/span&gt; | &lt;span class="nb"&gt;head
&lt;/span&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; kubelet &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"10 min ago"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-50&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;kubectl&lt;/code&gt; equivalent, with the node filesystem under &lt;code&gt;/host&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl debug node/worker-02 &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;busybox:1.36 &lt;span class="nt"&gt;--profile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sysadmin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Security best practices
&lt;/h2&gt;

&lt;p&gt;Debugging tooling is a privilege-escalation vector. Treat it like one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Scope RBAC to the &lt;code&gt;pods/ephemeralcontainers&lt;/code&gt; subresource.&lt;/strong&gt; Creating an ephemeral container means running arbitrary code inside an existing pod, with that pod's ServiceAccount token. Don't bundle it into a generic &lt;code&gt;edit&lt;/code&gt; role.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Role&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pod-debugger&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pods"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;watch"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pods/log"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pods/exec"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;create"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pods/ephemeralcontainers"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;patch"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bind it to an on-call group, not to every developer, and prefer a time-bound binding you remove after the incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Never default to &lt;code&gt;--profile=sysadmin&lt;/code&gt;.&lt;/strong&gt; It grants &lt;code&gt;privileged: true&lt;/code&gt; and host namespaces. Start with &lt;code&gt;restricted&lt;/code&gt;, escalate to &lt;code&gt;netadmin&lt;/code&gt; only for packet capture, and to &lt;code&gt;sysadmin&lt;/code&gt; only for node-level work. On OpenShift, needing &lt;code&gt;sysadmin&lt;/code&gt; means you need an SCC exception — make it explicit and auditable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Pin and curate your debug images.&lt;/strong&gt; &lt;code&gt;nicolaka/netshoot:v0.13&lt;/code&gt;, not &lt;code&gt;:latest&lt;/code&gt;. Mirror it into your internal registry, scan it, and allow it in your admission policy. A debug image with network tools running in a pod with a mounted ServiceAccount token is a lateral-movement toolkit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Remember that a copied pod inherits the Secrets.&lt;/strong&gt; &lt;code&gt;--copy-to&lt;/code&gt; mounts the same Secrets and SA token. Anyone with shell access in the copy can read them. Delete the copy immediately, and never leave it running overnight "just in case".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Keep labels off the copies.&lt;/strong&gt; The default &lt;code&gt;--keep-labels=false&lt;/code&gt; prevents the debug pod from being picked up by a Service and serving real traffic. Verify with &lt;code&gt;kubectl get endpoints -n shop&lt;/code&gt; before and after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Audit it.&lt;/strong&gt; Ephemeral container creation appears in the API audit log as a &lt;code&gt;patch&lt;/code&gt; on &lt;code&gt;pods/ephemeralcontainers&lt;/code&gt;. Add an alert on it in production namespaces — it's a low-volume, high-signal event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Don't "fix" the problem by shipping a shell.&lt;/strong&gt; Adding &lt;code&gt;busybox&lt;/code&gt; to your production image to make debugging easier undoes the entire point of distroless. The whole workflow above exists so you don't have to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion: the checklist
&lt;/h2&gt;

&lt;p&gt;When a pod loops and you have no shell, run these in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;describe&lt;/code&gt; + JSONPath on &lt;code&gt;lastState.terminated&lt;/code&gt;&lt;/strong&gt; — get the &lt;code&gt;exitCode&lt;/code&gt; and &lt;code&gt;reason&lt;/code&gt;. 127 = wrong command, 137 + OOMKilled = memory limit, 143 = something killed it, 0 = wrong workload type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;kubectl logs --previous&lt;/code&gt;&lt;/strong&gt;, on the app container &lt;em&gt;and&lt;/em&gt; every init container. Enable &lt;code&gt;terminationMessagePolicy: FallbackToLogsOnError&lt;/code&gt; everywhere so the message survives in the status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check &lt;code&gt;initContainerStatuses&lt;/code&gt;&lt;/strong&gt; before blaming the app. &lt;code&gt;Init:&lt;/code&gt; prefixes in the pod status mean the app never ran.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;kubectl debug --target=&amp;lt;container&amp;gt; --profile=general&lt;/code&gt;&lt;/strong&gt; for live inspection: DNS, connectivity, mounted files via &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/root&lt;/code&gt;, real environment via &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/environ&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;kubectl debug --copy-to --set-image --keep-init-containers=false&lt;/code&gt;&lt;/strong&gt; when you need the container to stay alive with a different command.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;kubectl debug node/&amp;lt;node&amp;gt; --profile=sysadmin&lt;/code&gt;&lt;/strong&gt; or &lt;code&gt;oc debug node/...&lt;/code&gt; only when the evidence points below the pod.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the mental model to keep: an exit code tells you &lt;em&gt;how&lt;/em&gt; it died, the logs tell you &lt;em&gt;why&lt;/em&gt;, and the ephemeral container tells you &lt;em&gt;what the environment actually looked like&lt;/em&gt; — which, in nine cases out of ten, is different from what your manifest led you to believe.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>openshift</category>
      <category>debugging</category>
      <category>devops</category>
    </item>
    <item>
      <title>Azure Workload Identity: Remove Secrets From Your AKS Pods in 45 Minutes</title>
      <dc:creator>Florient DOGBE</dc:creator>
      <pubDate>Wed, 21 Oct 2026 14:43:21 +0000</pubDate>
      <link>https://dev.to/florient_4262fab5463a691/azure-workload-identity-remove-secrets-from-your-aks-pods-in-45-minutes-14j0</link>
      <guid>https://dev.to/florient_4262fab5463a691/azure-workload-identity-remove-secrets-from-your-aks-pods-in-45-minutes-14j0</guid>
      <description>&lt;p&gt;If you are still mounting a Kubernetes Secret containing an &lt;code&gt;AZURE_CLIENT_SECRET&lt;/code&gt; into your pods, you are one &lt;code&gt;kubectl get secret -o yaml&lt;/code&gt; away from an incident report. And if you are using AAD Pod Identity, you are running a component that reached end of support in September 2023.&lt;/p&gt;

&lt;p&gt;Azure Workload Identity replaces both. It uses the OIDC issuer built into your AKS cluster to let a Kubernetes ServiceAccount exchange its projected token for an Azure AD (Microsoft Entra ID) access token. No secret, no rotation job, no &lt;code&gt;NodeManagedIdentity&lt;/code&gt; DaemonSet intercepting IMDS traffic.&lt;/p&gt;

&lt;p&gt;This walkthrough takes about 45 minutes end to end, assuming you already have an AKS cluster and the Azure CLI installed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: static credentials are a liability
&lt;/h2&gt;

&lt;p&gt;A typical "pre-Workload-Identity" deployment looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Secret&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-azure-credentials&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments&lt;/span&gt;
&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Opaque&lt;/span&gt;
&lt;span class="na"&gt;stringData&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;AZURE_TENANT_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;00000000-0000-0000-0000-000000000000&lt;/span&gt;
  &lt;span class="na"&gt;AZURE_CLIENT_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;11111111-1111-1111-1111-111111111111&lt;/span&gt;
  &lt;span class="na"&gt;AZURE_CLIENT_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;never&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;do&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;this&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three structural problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Base64 is not encryption.&lt;/strong&gt; Anyone with &lt;code&gt;get secrets&lt;/code&gt; RBAC in that namespace — or read access to your etcd backups, or to the Git repo where the manifest lives — owns that identity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotation is manual.&lt;/strong&gt; Client secrets expire (6, 12, 24 months). Your rotation is a calendar reminder and a pipeline run, so it fails at 2 a.m. on a Sunday.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No blast radius control.&lt;/strong&gt; The same secret works from a laptop, a CI runner, or a compromised pod in another namespace. Nothing ties it to a workload.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;AAD Pod Identity tried to fix this by intercepting calls to the Instance Metadata Service (169.254.169.254) and injecting a managed identity token. It worked, but it was node-scoped, raced at pod startup (&lt;code&gt;AzureIdentityBinding&lt;/code&gt; not reconciled yet → 404 from IMDS), and required privileged DaemonSets with iptables rules. It is deprecated and removed from new clusters.&lt;/p&gt;

&lt;p&gt;Workload Identity takes a different path: &lt;strong&gt;federation&lt;/strong&gt;. The cluster becomes an OIDC identity provider. Entra ID trusts tokens it issues, for one specific subject. No network interception, no DaemonSet, no secret.&lt;/p&gt;

&lt;h3&gt;
  
  
  How the token exchange actually works
&lt;/h3&gt;

&lt;p&gt;Understanding the flow makes debugging trivial later:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The kubelet projects a short-lived (default 1 hour), audience-scoped JWT for the pod's ServiceAccount at &lt;code&gt;/var/run/secrets/azure/tokens/azure-identity-token&lt;/code&gt;. The audience is &lt;code&gt;api://AzureADTokenExchange&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The Azure SDK (or &lt;code&gt;DefaultAzureCredential&lt;/code&gt;) in your app reads that file, plus the &lt;code&gt;AZURE_CLIENT_ID&lt;/code&gt;, &lt;code&gt;AZURE_TENANT_ID&lt;/code&gt; and &lt;code&gt;AZURE_AUTHORITY_HOST&lt;/code&gt; environment variables injected by the mutating webhook.&lt;/li&gt;
&lt;li&gt;The SDK calls Entra ID with a &lt;code&gt;client_assertion&lt;/code&gt; = the projected token.&lt;/li&gt;
&lt;li&gt;Entra ID validates the signature against the cluster's public JWKS (fetched from the OIDC issuer URL), checks &lt;code&gt;iss&lt;/code&gt;, &lt;code&gt;sub&lt;/code&gt; and &lt;code&gt;aud&lt;/code&gt; against the &lt;strong&gt;federated identity credential&lt;/strong&gt; you registered, and returns an Azure access token.&lt;/li&gt;
&lt;li&gt;Your app calls Key Vault / Storage / Cosmos DB with that token.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The only thing you configure is step 4: &lt;em&gt;which issuer, which subject, which audience&lt;/em&gt;. Everything else is plumbing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Enable the OIDC issuer and the webhook
&lt;/h2&gt;

&lt;p&gt;Set your variables first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;RG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;rg-platform-prod
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLUSTER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;aks-platform-prod
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;LOCATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;westeurope
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SUBSCRIPTION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az account show &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both features are flags on the cluster. On an existing cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az aks update &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-oidc-issuer&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-workload-identity&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This takes 5 to 10 minutes and performs a control-plane reconcile. It is non-disruptive for running workloads, but the mutating webhook only applies to &lt;strong&gt;newly created pods&lt;/strong&gt; — existing pods will not get the projected token until they are recreated.&lt;/p&gt;

&lt;p&gt;For a new cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az aks create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCATION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-oidc-issuer&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-workload-identity&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-count&lt;/span&gt; 2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--generate-ssh-keys&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Retrieve the issuer URL — you will need it twice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AKS_OIDC_ISSUER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az aks show &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"oidcIssuerProfile.issuerUrl"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AKS_OIDC_ISSUER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# https://westeurope.oic.prod-aks.azure.com/&amp;lt;tenant-guid&amp;gt;/&amp;lt;cluster-guid&amp;gt;/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sanity check that it is publicly reachable (Entra ID must be able to fetch the JWKS):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;AKS_OIDC_ISSUER&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.well-known/openid-configuration"&lt;/span&gt; | jq .jwks_uri
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm the webhook is running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nt"&gt;-l&lt;/span&gt; azure-workload-identity.io/system&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;span class="c"&gt;# azure-wi-webhook-controller-manager-xxxxx   1/1   Running&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2 — Create the managed identity
&lt;/h2&gt;

&lt;p&gt;Use a &lt;strong&gt;user-assigned managed identity&lt;/strong&gt; (UAMI). You can also federate an App Registration, but a UAMI has no secret at all and lives in your resource group, which keeps RBAC and lifecycle in one place.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;UAMI&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;id-payments-api

az identity create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$UAMI&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCATION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;USER_ASSIGNED_CLIENT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az identity show &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$UAMI&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'clientId'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;USER_ASSIGNED_PRINCIPAL_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az identity show &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$UAMI&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'principalId'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grant it something concrete — read-only access to one Key Vault:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;KV_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;kv-payments-prod
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;KV_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az keyvault show &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KV_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;

az role assignment create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--assignee-object-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER_ASSIGNED_PRINCIPAL_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--assignee-principal-type&lt;/span&gt; ServicePrincipal &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt; &lt;span class="s2"&gt;"Key Vault Secrets User"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KV_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: &lt;code&gt;Key Vault Secrets User&lt;/code&gt; only works if your vault uses &lt;strong&gt;Azure RBAC&lt;/strong&gt; for data plane authorization (&lt;code&gt;--enable-rbac-authorization true&lt;/code&gt;). If it still uses access policies, use &lt;code&gt;az keyvault set-policy --object-id "$USER_ASSIGNED_PRINCIPAL_ID" --secret-permissions get list&lt;/code&gt; instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Create the ServiceAccount
&lt;/h2&gt;

&lt;p&gt;The ServiceAccount is the Kubernetes-side identity. The annotation tells the webhook which client ID to inject.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ServiceAccount&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-api&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;azure.workload.identity/client-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;11111111-1111-1111-1111-111111111111"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply it with the real value substituted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create namespace payments &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;client &lt;span class="nt"&gt;-o&lt;/span&gt; yaml | kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; -

&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt; | kubectl apply -f -
apiVersion: v1
kind: ServiceAccount
metadata:
  name: payments-api
  namespace: payments
  annotations:
    azure.workload.identity/client-id: "&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;USER_ASSIGNED_CLIENT_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Optional annotations worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;azure.workload.identity/tenant-id&lt;/code&gt; — override the tenant (multi-tenant scenarios).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;azure.workload.identity/service-account-token-expiration&lt;/code&gt; — token lifetime in seconds, between 3600 and 86400. Leave it at the default unless you have a specific reason.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 4 — Create the federated identity credential
&lt;/h2&gt;

&lt;p&gt;This is the trust anchor. The &lt;code&gt;subject&lt;/code&gt; must match &lt;strong&gt;exactly&lt;/strong&gt; the format &lt;code&gt;system:serviceaccount:&amp;lt;namespace&amp;gt;:&amp;lt;name&amp;gt;&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az identity federated-credential create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; fc-payments-api &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--identity-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$UAMI&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--issuer&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AKS_OIDC_ISSUER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subject&lt;/span&gt; &lt;span class="s2"&gt;"system:serviceaccount:payments:payments-api"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--audience&lt;/span&gt; &lt;span class="s2"&gt;"api://AzureADTokenExchange"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few rules that bite people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One federated credential = one (issuer, subject) pair. A ServiceAccount in &lt;code&gt;staging&lt;/code&gt; needs its own credential, even for the same identity. You can attach up to 20 per identity.&lt;/li&gt;
&lt;li&gt;The subject is case-sensitive and has no wildcards (except for the newer flexible federated credentials with &lt;code&gt;*&lt;/code&gt; support, which are not GA everywhere — assume exact match).&lt;/li&gt;
&lt;li&gt;Propagation takes a few seconds to a couple of minutes. If your first test fails, wait and retry before debugging.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az identity federated-credential list &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--identity-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$UAMI&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 5 — Label the pod and deploy
&lt;/h2&gt;

&lt;p&gt;The webhook only mutates pods carrying the label &lt;code&gt;azure.workload.identity/use: "true"&lt;/code&gt; &lt;strong&gt;on the pod template&lt;/strong&gt;, not on the Deployment metadata. This is the single most common mistake.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-api&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-api&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-api&lt;/span&gt;
        &lt;span class="na"&gt;azure.workload.identity/use&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;serviceAccountName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-api&lt;/span&gt;
      &lt;span class="na"&gt;automountServiceAccountToken&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;runAsNonRoot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;runAsUser&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10001&lt;/span&gt;
        &lt;span class="na"&gt;seccompProfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RuntimeDefault&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/example/payments-api:1.4.2&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;KEYVAULT_URL&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://kv-payments-prod.vault.azure.net/"&lt;/span&gt;
          &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;allowPrivilegeEscalation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
            &lt;span class="na"&gt;readOnlyRootFilesystem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
            &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALL"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;100m&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;128Mi&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;256Mi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;automountServiceAccountToken: false&lt;/code&gt; disables the &lt;em&gt;default&lt;/em&gt; Kubernetes API token. The Workload Identity projected volume is added independently by the webhook, so Azure auth still works while your pod loses needless API server access.&lt;/p&gt;

&lt;p&gt;Verify the mutation happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; payments get pod &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;payments-api &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.items[0].spec.containers[0].env}'&lt;/span&gt; | jq &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see &lt;code&gt;AZURE_CLIENT_ID&lt;/code&gt;, &lt;code&gt;AZURE_TENANT_ID&lt;/code&gt;, &lt;code&gt;AZURE_FEDERATED_TOKEN_FILE&lt;/code&gt; and &lt;code&gt;AZURE_AUTHORITY_HOST&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing from inside a pod
&lt;/h3&gt;

&lt;p&gt;A quick smoke test without touching your application code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; payments run wi-test &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mcr.microsoft.com/azure-cli:latest &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--labels&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"azure.workload.identity/use=true"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--overrides&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{"spec":{"serviceAccountName":"payments-api"}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Never &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside the pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az login &lt;span class="nt"&gt;--federated-token&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="nv"&gt;$AZURE_FEDERATED_TOKEN_FILE&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--service-principal&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AZURE_CLIENT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AZURE_TENANT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

az keyvault secret show &lt;span class="nt"&gt;--vault-name&lt;/span&gt; kv-payments-prod &lt;span class="nt"&gt;--name&lt;/span&gt; db-password
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that works, &lt;code&gt;DefaultAzureCredential&lt;/code&gt; in your Python, .NET, Java, Go or Node app will work with zero code changes — the SDKs have supported &lt;code&gt;WorkloadIdentityCredential&lt;/code&gt; since 2023.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging token errors
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;AADSTS70021: No matching federated identity record found&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The most frequent error. The subject or issuer does not match. Decode the token the pod actually received:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; payments &lt;span class="nb"&gt;exec &lt;/span&gt;deploy/payments-api &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;cat&lt;/span&gt; /var/run/secrets/azure/tokens/azure-identity-token &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; 2&amp;gt;/dev/null | jq &lt;span class="s1"&gt;'{iss, sub, aud, exp}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare &lt;code&gt;iss&lt;/code&gt; and &lt;code&gt;sub&lt;/code&gt; character by character with the federated credential. Typical causes: namespace mismatch, trailing slash difference on the issuer, or the app was copied from another environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;AZURE_FEDERATED_TOKEN_FILE&lt;/code&gt; is not set
&lt;/h3&gt;

&lt;p&gt;The webhook did not mutate the pod. Check, in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Is the label on the POD, not just the Deployment?&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; payments get pod &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;payments-api &lt;span class="nt"&gt;--show-labels&lt;/span&gt;

&lt;span class="c"&gt;# 2. Is the annotation on the ServiceAccount?&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; payments get sa payments-api &lt;span class="nt"&gt;-o&lt;/span&gt; yaml

&lt;span class="c"&gt;# 3. Is the webhook alive?&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system logs &lt;span class="nt"&gt;-l&lt;/span&gt; azure-workload-identity.io/system&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="nt"&gt;--tail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remember: the mutation happens at pod admission. Changing a ServiceAccount annotation requires a &lt;code&gt;kubectl rollout restart deployment/payments-api&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;AADSTS700016: Application not found in the directory&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;client-id&lt;/code&gt; annotation points to an identity that does not exist, or exists in a different tenant. Re-read it from the source: &lt;code&gt;az identity show --name "$UAMI" -g "$RG" --query clientId -o tsv&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;403 Forbidden&lt;/code&gt; from Key Vault / Storage
&lt;/h3&gt;

&lt;p&gt;Authentication succeeded, authorization failed. The federation is fine — the role assignment is missing or scoped wrong. Check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az role assignment list &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--assignee&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER_ASSIGNED_PRINCIPAL_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also check Key Vault firewall rules: a correct token still gets blocked if your AKS egress IP is not allowed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security best practices
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One identity per workload, not per cluster.&lt;/strong&gt; The temptation is a single &lt;code&gt;id-aks-prod&lt;/code&gt; with Contributor at subscription scope. That rebuilds the exact blast radius problem you were trying to remove. One UAMI per application, scoped to the specific vault, storage account or database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep subjects narrow.&lt;/strong&gt; Avoid wildcard federated credentials if your Azure tenant supports them. &lt;code&gt;system:serviceaccount:payments:payments-api&lt;/code&gt; is auditable; &lt;code&gt;system:serviceaccount:payments:*&lt;/code&gt; means any new ServiceAccount in that namespace silently inherits production access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock down ServiceAccount creation.&lt;/strong&gt; Anyone who can create a ServiceAccount named &lt;code&gt;payments-api&lt;/code&gt; in namespace &lt;code&gt;payments&lt;/code&gt; plus a pod using it can assume the identity. Enforce it with RBAC and an admission policy. Example with a Kyverno-style guard or a plain ValidatingAdmissionPolicy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;admissionregistration.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ValidatingAdmissionPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;restrict-wi-annotation&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;failurePolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Fail&lt;/span&gt;
  &lt;span class="na"&gt;matchConstraints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;resourceRules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;apiVersions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;operations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CREATE"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPDATE"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;serviceaccounts"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;validations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;expression&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="s"&gt;!has(object.metadata.annotations) ||&lt;/span&gt;
        &lt;span class="s"&gt;!('azure.workload.identity/client-id' in object.metadata.annotations) ||&lt;/span&gt;
        &lt;span class="s"&gt;object.metadata.namespace in ['payments', 'billing']&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Workload&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Identity&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;annotations&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;are&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;restricted&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;approved&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;namespaces"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Disable the default API token.&lt;/strong&gt; &lt;code&gt;automountServiceAccountToken: false&lt;/code&gt; as shown above. The projected Azure token is independent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never disable issuer validation.&lt;/strong&gt; If a tool offers to skip JWKS verification, that tool is not production-grade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rotate the OIDC signing keys periodically.&lt;/strong&gt; AKS supports it; it invalidates all outstanding projected tokens, which is exactly what you want after a suspected compromise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az aks oidc-issuer rotate-signing-keys &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expect a short window where existing tokens fail until pods refresh them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit federated credentials like you audit SSH keys.&lt;/strong&gt; They are long-lived trust statements. Export them regularly and diff against your IaC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="nb"&gt;id &lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;az identity list &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"[].name"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"== &lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  az identity federated-credential list &lt;span class="nt"&gt;--identity-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"[].{name:name, subject:subject, issuer:issuer}"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; table
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Codify everything.&lt;/strong&gt; The four moving parts (UAMI, role assignment, federated credential, ServiceAccount annotation) belong in Terraform or Bicep plus GitOps. Manual &lt;code&gt;az&lt;/code&gt; commands are fine for the first 45 minutes; they are not fine for the 50th application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The migration path is short and mechanical:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;az aks update --enable-oidc-issuer --enable-workload-identity&lt;/code&gt; — once per cluster.&lt;/li&gt;
&lt;li&gt;Create a user-assigned managed identity per workload and give it a narrowly scoped Azure role.&lt;/li&gt;
&lt;li&gt;Annotate the ServiceAccount with &lt;code&gt;azure.workload.identity/client-id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Register a federated identity credential matching &lt;code&gt;system:serviceaccount:&amp;lt;ns&amp;gt;:&amp;lt;sa&amp;gt;&lt;/code&gt; with audience &lt;code&gt;api://AzureADTokenExchange&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add the &lt;code&gt;azure.workload.identity/use: "true"&lt;/code&gt; label to the &lt;strong&gt;pod template&lt;/strong&gt; and restart.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three things to remember when it breaks: the label goes on the pod template, the subject string must match exactly, and the webhook only acts at pod creation time — so restart after any annotation change.&lt;/p&gt;

&lt;p&gt;What you gain is not just convenience. You remove an entire class of credentials from Git, from etcd, from CI logs and from your rotation backlog. The token your pod holds lives one hour, is bound to one ServiceAccount in one namespace in one cluster, and is useless anywhere else. That is a meaningfully smaller attack surface for less than an hour of work.&lt;/p&gt;

</description>
      <category>aks</category>
      <category>azure</category>
      <category>kubernetes</category>
      <category>security</category>
    </item>
  </channel>
</rss>
