<?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: Granite</title>
    <description>The latest articles on DEV Community by Granite (granite-so).</description>
    <link>https://dev.to/granite-so</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%2Forganization%2Fprofile_image%2F14307%2F1bade391-52e7-41a8-b485-21d4e2e1251c.jpeg</url>
      <title>DEV Community: Granite</title>
      <link>https://dev.to/granite-so</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/granite-so"/>
    <language>en</language>
    <item>
      <title>Kubernetes Audit Policy: A Checklist Against Common Blind Spots in Your Rules</title>
      <dc:creator>Alex Gorshkov</dc:creator>
      <pubDate>Thu, 17 Sep 2026 10:11:43 +0000</pubDate>
      <link>https://dev.to/granite-so/kubernetes-audit-policy-a-checklist-against-common-blind-spots-in-your-rules-3ko8</link>
      <guid>https://dev.to/granite-so/kubernetes-audit-policy-a-checklist-against-common-blind-spots-in-your-rules-3ko8</guid>
      <description>&lt;p&gt;Hi everyone! We want to share our experience writing and reviewing a Kubernetes audit policy.&lt;/p&gt;

&lt;p&gt;A Kubernetes audit policy is one of those things that gets written once during cluster setup and then lives "as is" for years, accumulating exceptions for new components and almost never being reviewed as a whole. At some point it's no longer a security policy — it's a layer of three-year-old comments.&lt;/p&gt;

&lt;p&gt;We recently sat down to revisit our own 500+ line config. We had assembled it from several sources, primarily based on the Kubernetes Threat Matrix. We want to share not so much the specific findings (those are specific to our cluster) as the principles and common traps that surfaced during the review. If you're writing or reviewing an audit policy for your own cluster, this checklist should save you some time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Audit Policy Works
&lt;/h2&gt;

&lt;p&gt;In short: it's a list of rules, each describing which users/groups/verbs/resources it matches and which logging level to apply — from &lt;code&gt;None&lt;/code&gt; (don't log at all) to &lt;code&gt;RequestResponse&lt;/code&gt; (log the full request and response, including the body).&lt;/p&gt;

&lt;p&gt;A key detail that's easy to miss: &lt;strong&gt;rules are evaluated top to bottom, and the first match wins.&lt;/strong&gt; If a broad exception rule sits above a narrow, security-critical rule, the second one will never fire for the same requests. Most of the problems actually worth hunting for in a review are ordering mistakes, not mistakes in individual rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Principle 1: Separate Noise from Signal
&lt;/h2&gt;

&lt;p&gt;In any reasonably active cluster, 90% of API traffic is &lt;code&gt;get&lt;/code&gt;/&lt;code&gt;list&lt;/code&gt;/&lt;code&gt;watch&lt;/code&gt; from system components: the kubelet polling node status, Prometheus scraping metrics, controllers watching their resources. If you log all of that at a meaningful level, the log drowns in noise and finding a real incident becomes impossible.&lt;/p&gt;

&lt;p&gt;The right pattern is to silence these streams precisely:&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;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:serviceaccount:kube-system:coredns"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Important: silence as narrowly as possible — by specific user, specific verbs, and ideally specific resources. This is where the first common mistake hides.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trap #1: An Exception Without Verb/Resource Restrictions
&lt;/h3&gt;

&lt;p&gt;This one shows up regularly:&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;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:serviceaccount:some-ns:some-controller"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;verbs&lt;/code&gt; and &lt;code&gt;resources&lt;/code&gt;, this rule silences &lt;em&gt;every&lt;/em&gt; action by that user or service account — not just the routine read traffic it was written for, but any mutations that service account is ever granted (accidentally through an RBAC mistake, or intentionally when functionality expands). If that service account's token is compromised, the attack goes entirely unnoticed by the audit log — not because anyone planned a hole, but because the exception was written "by eye" for the component's current behavior, without explicitly fixing its boundaries.&lt;/p&gt;

&lt;p&gt;Practical rule: when adding a new exception, always explicitly list &lt;code&gt;verbs&lt;/code&gt;, even if the component currently only does &lt;code&gt;get&lt;/code&gt;/&lt;code&gt;list&lt;/code&gt;/&lt;code&gt;watch&lt;/code&gt;. This pins down the boundaries — it defines what we see and what matters to us.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trap #2: Stale Comments
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Any actions with secrets and configs (except list/watch) - critical to monitor&lt;/span&gt;
&lt;span class="c1"&gt;# for potential data leaks or unauthorized configuration 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;None&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="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;"&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;users&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;...&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment says "critical to monitor," but the rule actually &lt;em&gt;disables&lt;/em&gt; logging for the listed service accounts.&lt;/p&gt;

&lt;p&gt;This doesn't affect how the policy works, but inconsistencies like this are a direct path to someone reading the comment a year from now, taking it at face value, and spending a day debugging "why aren't events being logged when it says they should be." Reviewing comments is as much a part of code review as reviewing the logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trap #3: Leftover Rules from Past Migrations
&lt;/h3&gt;

&lt;p&gt;The CNI was migrated from kube-router to Cilium six months ago, but the exception for &lt;code&gt;system:kube-router&lt;/code&gt; is still in the file. On its own it's harmless — it simply never fires, because that service account no longer exists. But:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it clutters the file and confuses reviewers;&lt;/li&gt;
&lt;li&gt;if someone ever creates a service account with the same name for a different component, it inherits someone else's stale exemptions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Simple check: periodically (say, once a quarter) compare the list of users and service accounts in your exception rules against the service accounts that actually exist in the cluster — &lt;code&gt;kubectl get sa -A&lt;/code&gt; — and clean up the mismatches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Principle 2: For Sensitive Data, Cap at Metadata
&lt;/h2&gt;

&lt;p&gt;A separate pattern from the reviewed config:&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;# Secrets, ConfigMaps, and TokenReviews can contain sensitive &amp;amp; binary data,&lt;/span&gt;
&lt;span class="c1"&gt;# so only log at the Metadata level.&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;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="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;"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The logic is simple: the fact that a secret was accessed must be recorded (who, when, which secret), but the &lt;em&gt;contents&lt;/em&gt; of the secret must never end up in the audit log under any circumstances — otherwise the audit log itself becomes a secret store, just a less protected one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Principle 3: Use RequestResponse Where It's Justified
&lt;/h2&gt;

&lt;p&gt;On the other end of the spectrum are events where you need to see not just the fact, but the full request body:&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;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;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;pods/exec"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pods/attach"&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/portforward"&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;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="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;clusterrolebindings"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clusterroles"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rolebindings"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;roles"&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;exec&lt;/code&gt;/&lt;code&gt;attach&lt;/code&gt;/&lt;code&gt;portforward&lt;/code&gt; is direct access inside a running container — a classic vector when investigating incidents. RBAC changes are potential privilege escalation. In both cases, skimping on the logging level isn't worth it: the difference in log volume is small, and the value during an investigation is enormous.&lt;/p&gt;

&lt;h2&gt;
  
  
  Principle 4: Use Rule Ordering Deliberately, Not Accidentally
&lt;/h2&gt;

&lt;p&gt;A good example of using ordering intentionally is how &lt;code&gt;events&lt;/code&gt; are handled:&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;# Defense evasion tactic - detect attempts to delete k8s events&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;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;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;"&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;events"&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;events.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;events"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Don't log events requests.&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;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;events"&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;events.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;events"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Events themselves are a very noisy resource, and logging every access to them isn't practical. But deleting events is a classic defense evasion technique (an attacker cleaning up traces of their activity). By placing the narrow &lt;code&gt;delete&lt;/code&gt; rule above the general "silence everything" rule, we get exactly the behavior we want: normal event traffic doesn't clutter the log, but an attempt to delete events is recorded.&lt;/p&gt;

&lt;p&gt;Keep this in mind as a general pattern: if the same resource needs different sensitivity depending on the verb, the specific rule must always come before the general one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mini-Checklist for Reviewing Your Audit Policy
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Is there a rule for &lt;code&gt;system:unauthenticated&lt;/code&gt;, and does it sit above all exception rules?&lt;/li&gt;
&lt;li&gt;Is there any exception rule (&lt;code&gt;level: None&lt;/code&gt;) without explicit &lt;code&gt;verbs&lt;/code&gt; and/or &lt;code&gt;resources&lt;/code&gt;? If so, can it be narrowed?&lt;/li&gt;
&lt;li&gt;Are secrets/configmaps/tokens never logged above &lt;code&gt;Metadata&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Are RBAC changes, &lt;code&gt;exec&lt;/code&gt;/&lt;code&gt;attach&lt;/code&gt;/&lt;code&gt;portforward&lt;/code&gt;, and network policy deletions logged at a sufficient level (&lt;code&gt;Request&lt;/code&gt;/&lt;code&gt;RequestResponse&lt;/code&gt;)?&lt;/li&gt;
&lt;li&gt;Do any exception rules reference users/service accounts that no longer exist in the cluster (leftovers from migrations)?&lt;/li&gt;
&lt;li&gt;Do the comments match what the rules actually do?&lt;/li&gt;
&lt;li&gt;Is there a catch-all rule at the very end of the file (usually &lt;code&gt;level: Metadata&lt;/code&gt;) so nothing slips past the log by default?&lt;/li&gt;
&lt;li&gt;Are there any obviously temporary rules without a date or ticket for their removal?&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;A Kubernetes audit policy is not a "set it and forget it" thing. It grows with the cluster, and every new exception added in a hurry to silence yet another noisy component is a potential blind spot if it isn't explicitly bounded. The difference between "convenient" and "secure" here usually comes down to a single line — an explicit list of &lt;code&gt;verbs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you have your own findings or patterns from working with audit policies, share them in the comments — we'd love to compare approaches.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>security</category>
    </item>
    <item>
      <title>Read-Only Kubernetes Access for AI Agents: Why "Please Don't Delete Anything" Isn't a Security Boundary</title>
      <dc:creator>Alex Gorshkov</dc:creator>
      <pubDate>Tue, 18 Aug 2026 13:36:09 +0000</pubDate>
      <link>https://dev.to/granite-so/read-only-kubernetes-access-for-ai-agents-why-please-dont-delete-anything-isnt-a-security-2cem</link>
      <guid>https://dev.to/granite-so/read-only-kubernetes-access-for-ai-agents-why-please-dont-delete-anything-isnt-a-security-2cem</guid>
      <description>&lt;p&gt;I keep seeing the same pattern: take an LLM, give it access to &lt;code&gt;kubectl&lt;/code&gt; or the k8s API, write something like "you can only read, don't delete or modify anything" in the system prompt or an attached skill, and consider the problem solved. I went through this myself and at some point realized that this isn't a security boundary — it's a polite request.&lt;/p&gt;

&lt;p&gt;This isn't a hypothetical risk: you probably remember how in July 2025, the Replit agent deleted the SaaStr database despite a direct prohibition on making any changes — not Kubernetes and not MCP, but the same pattern. The "don't touch anything" instruction was right there in the context, and there was simply no one to enforce it except the model itself. Giving an agent write access to a k8s cluster means assembling exactly the same construct that already cost SaaStr their database.&lt;/p&gt;

&lt;p&gt;I'm far from the first to cover this topic, and a lot of read-only MCP servers have appeared recently. But it's surprising how often "read-only" is misunderstood in them. For example, in MCP servers for Kubernetes, "read-only" is often implemented as an environment variable that filters the &lt;code&gt;tools/list&lt;/code&gt; response, rather than as the absence of a function in the registry. That's exactly how &lt;code&gt;mcp-server-kubernetes&lt;/code&gt; (20k weekly downloads on npm) worked: the &lt;code&gt;ALLOW_ONLY_READONLY_TOOLS&lt;/code&gt; flag hid mutating tools from the list, while &lt;code&gt;tools/call&lt;/code&gt; still accepted &lt;code&gt;kubectl_delete&lt;/code&gt; directly, bypassing the filter.&lt;/p&gt;

&lt;p&gt;This became CVE-2026-46519, CVSS 8.8 — the same principle this article is about, taken to an actual exploit: a function hidden from the list is not the same as a function that doesn't exist. And this isn't just a community problem — Azure/mcp-kubernetes, Microsoft's official MCP server for Kubernetes, is built exactly the same way: &lt;code&gt;--access-level readonly|readwrite&lt;/code&gt; instead of simply not having mutating tools in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Instructions Don't Work as a Restriction
&lt;/h2&gt;

&lt;p&gt;A model is not a sandbox. If it has &lt;code&gt;delete_pod&lt;/code&gt; or &lt;code&gt;scale_deployment&lt;/code&gt; in its list of available tools, it can technically call it regardless of what's written in the system prompt. For example, an attacker doesn't need cluster access for this — a regular HTTP request with a spoofed header value like User-Agent is enough. Nginx or the app itself will log it as-is: Kubernetes simply captures the container's stdout/stderr without any sanitization.&lt;/p&gt;

&lt;p&gt;Then someone (or the assistant itself) asks the model to "check this pod's logs" — a completely innocuous request — and the model reads that line as part of the context, without distinguishing it from the system prompt. The same story applies to instructions from an attached skill or another plugin, which end up in the context as equally "trusted"; to jailbreaks; to ordinary hallucinations in an attempt to "fix" a problem you just asked it to explain. An instruction — whether in a system prompt, a skill, or a container log — is data that the model interprets, not code that constrains it.&lt;/p&gt;

&lt;p&gt;This means the only boundary that actually holds is which tools exist in the registry available to it. If the &lt;code&gt;delete_pod&lt;/code&gt; function doesn't exist, it doesn't matter what prompt injection, jailbreak, or the model itself in a fit of "helpfulness" says: there's nothing to call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Looks Like in a Tool Registry
&lt;/h2&gt;

&lt;p&gt;Let's take a concrete example: an MCP server for Kubernetes. In its registry, it makes sense to register only read tools — &lt;code&gt;list_pods&lt;/code&gt;, &lt;code&gt;list_deployments&lt;/code&gt;, &lt;code&gt;get_yaml&lt;/code&gt;, &lt;code&gt;get_events&lt;/code&gt;, &lt;code&gt;read_pod_logs&lt;/code&gt;, &lt;code&gt;start_pod_log_stream&lt;/code&gt;, and so on, around thirty in total. And not a single &lt;code&gt;delete_*&lt;/code&gt;, &lt;code&gt;scale_*&lt;/code&gt;, &lt;code&gt;exec_*&lt;/code&gt;, &lt;code&gt;apply_*&lt;/code&gt;, or &lt;code&gt;port_forward_*&lt;/code&gt; — not because they're disabled by some flag, but because those functions simply don't exist in the code.&lt;/p&gt;

&lt;p&gt;All mutating operations — scale, rollout restart, delete, cordon/drain — in this design live in a separate, human path: through a GUI with a confirmation dialog, through a CLI with an explicit flag or a "y/n" prompt. It doesn't matter which — what matters is that a human confirms it, and only then does a direct Kubernetes API call happen, without the model and without the AI tool registry at all. These are two different code paths, not one with a "allowed/forbidden" flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Server, Two Transports
&lt;/h2&gt;

&lt;p&gt;A separate problem arises when an AI assistant is available in two forms: as a built-in panel inside a larger tool, and as a standalone binary for external MCP clients (Claude Desktop, Claude Code, etc.). The temptation is to quickly assemble a separate set of tools for the built-in variant. Over time, the sets diverge, and one accidentally ends up with an extra tool that the other doesn't have.&lt;/p&gt;

&lt;p&gt;It's more reliable to run the same MCP server in both cases and communicate with it genuinely through the MCP protocol, just over different transports: in-memory for the built-in variant, stdio for the external client:&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;server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shutdown&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;mcpserver&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewServer&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;serverTransport&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clientTransport&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewInMemoryTransports&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Connect&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;serverTransport&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="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewClient&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;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Implementation&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"desktop-assistant"&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="n"&gt;session&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;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Connect&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;clientTransport&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="n"&gt;list&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;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ListTools&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="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// the same ListTools any external MCP client would call&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means the codebase physically has one server and one list of read-only tools — not an original and a separate copy for the GUI that someone will forget about. In practice, this eliminates exactly one class of bugs: when six months of refactoring later, a tool gets added to one list and forgotten in the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read-Only Doesn't Mean "Nothing Is Visible"
&lt;/h2&gt;

&lt;p&gt;Read-only solves the problem of state mutation, but not the problem of leaking data that's already in the cluster. If your kubeconfig has access to read Secrets in a namespace, the model theoretically can too by calling &lt;code&gt;get_yaml&lt;/code&gt;. This needs to be addressed at the data level, not the prompt level: Secret values are redacted before the YAML reaches the tool response —&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;sec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&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="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&amp;lt;redacted&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;— and the AI (whether the built-in assistant or the MCP client) physically never sees the decrypted value, because it's replaced with a placeholder before the YAML string is even constructed. In this design, it also makes sense to reject &lt;code&gt;apply&lt;/code&gt; if &lt;code&gt;&amp;lt;redacted&amp;gt;&lt;/code&gt; remains in the YAML — otherwise the placeholder could accidentally overwrite the real value.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Approach Doesn't Solve
&lt;/h2&gt;

&lt;p&gt;The model can still read a lot of data you already have RBAC access to. Read-only limits what can be done, not what can be seen within the same permissions.&lt;/p&gt;

&lt;p&gt;Load on the API server from a chatty tool-calling loop isn't limited by read-only alone — that requires reasonable measures (a limit on the number of iterations in a conversation, a byte limit on tool results, timeouts on log reads, capped and idle-reaped streams), not cryptographic guarantees.&lt;/p&gt;

&lt;p&gt;If keeping prompts and tool outputs on the machine entirely is important, that's a separate configuration (a local model via Ollama/vLLM/LM Studio), not a consequence of read-only architecture by itself.&lt;/p&gt;

&lt;p&gt;A compromised registry or poisoned tool descriptions are a separate issue: tool poisoning works against read-only tools too if the model trusts instructions inside a description just as much as the system prompt. A list of thirty read tools doesn't by itself guarantee that each one does exactly what it says — for a detailed breakdown of this topic, see "MCP and Agent Security."&lt;/p&gt;

&lt;p&gt;The entire approach described here is simple precisely because it deliberately doesn't solve the more general problem — giving an agent any write access at all. If you really need it (for example, for production debugging with the ability to fix things), that's a fundamentally different, much heavier architecture: whitelisting specific commands, rate-limiting, role-based restrictions, an immutable audit log with alerts.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>kubernetes</category>
      <category>security</category>
    </item>
    <item>
      <title>How Kubernetes Storage Actually Works - A Guide for Sysadmins</title>
      <dc:creator>Alex Gorshkov</dc:creator>
      <pubDate>Fri, 07 Aug 2026 22:51:47 +0000</pubDate>
      <link>https://dev.to/granite-so/how-kubernetes-storage-actually-works-a-guide-for-sysadmins-53i9</link>
      <guid>https://dev.to/granite-so/how-kubernetes-storage-actually-works-a-guide-for-sysadmins-53i9</guid>
      <description>&lt;p&gt;Pods are ephemeral. They restart, get rescheduled, get deleted - and everything written inside the container goes with them. That's fine for a stateless API. It's a disaster for a database, a log directory, or a file upload folder.&lt;/p&gt;

&lt;p&gt;Kubernetes solves this with a chain of abstractions that, at first glance, look like bureaucracy: a PVC that asks for a PV, a StorageClass that describes a PV you don't have yet, and a CSI driver that talks to something outside the cluster entirely. Once you see how the pieces hand off to each other, though, the design is straightforward - and, more usefully, so is debugging it.&lt;/p&gt;

&lt;p&gt;Here's the whole chain, end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The big picture
&lt;/h2&gt;

&lt;p&gt;The shortest possible summary of Kubernetes storage is: &lt;strong&gt;Pod → PVC → CSI → physical disk → PV&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you follow a request through the system, it travels along a very specific route. First, an application asks for storage by creating a &lt;strong&gt;PersistentVolumeClaim (PVC)&lt;/strong&gt; - essentially saying, "I need 5 GB that survives restarts." Kubernetes inspects this claim and searches for a compatible &lt;strong&gt;PersistentVolume (PV)&lt;/strong&gt; that already exists in the cluster. If a suitable PV isn't sitting idle, Kubernetes doesn't panic; it calls in a &lt;strong&gt;CSI driver&lt;/strong&gt; as an intermediary. The CSI driver translates the request and talks to the actual storage provider - a cloud block-storage API, a SAN, or an NFS appliance - in whatever language that provider speaks. Once the disk physically exists, the cluster &lt;strong&gt;binds&lt;/strong&gt; the newly created PV to the PVC, flipping the PVC status from &lt;code&gt;Pending&lt;/code&gt; to &lt;code&gt;Bound&lt;/code&gt;. Finally, the pod mounts the volume and gets its own persistent scratchpad for databases, logs, and files.&lt;/p&gt;

&lt;p&gt;The key insight that makes this entire architecture click: &lt;strong&gt;Kubernetes doesn't store your data.&lt;/strong&gt; It manages a reliable route to whoever does. Every component below exists to build and maintain one segment of that route.&lt;/p&gt;

&lt;h2&gt;
  
  
  CSI and StorageClass: who creates the disk
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;Container Storage Interface (CSI)&lt;/strong&gt; is the standard plugin interface that sits between Kubernetes and a storage backend. Kubernetes has no native idea how to carve out a volume on OpenStack Cinder, AWS EBS, or a Ceph cluster, and it doesn't need to. It simply calls the CSI driver and lets the driver deal with vendor-specific API calls. These drivers run as regular pods inside your cluster, and they are typically split into two distinct roles: a &lt;strong&gt;Controller&lt;/strong&gt; plugin, which handles the volume lifecycle (create, delete, resize, snapshot) and runs centrally, and a &lt;strong&gt;Node&lt;/strong&gt; plugin, which handles the messy work of attaching and mounting the volume on a specific host. The Node plugin runs as a DaemonSet, placing one instance on every schedulable node. This split isn't just an implementation detail; it’s the key to debugging. If the disk was never created, you have a Controller problem. If the disk exists but the pod won't start, it's almost certainly a Node problem.&lt;/p&gt;

&lt;p&gt;To tell Kubernetes &lt;em&gt;what kind&lt;/em&gt; of disk you want, you use a &lt;strong&gt;StorageClass&lt;/strong&gt;. Think of it as a configuration profile for your storage. It specifies the provisioner (which CSI driver to call), the &lt;code&gt;reclaimPolicy&lt;/code&gt; (what happens to the disk when the claim is deleted), and the &lt;code&gt;volumeBindingMode&lt;/code&gt;. The &lt;code&gt;volumeBindingMode&lt;/code&gt; is particularly critical because it dictates the timing of the disk creation. &lt;code&gt;Immediate&lt;/code&gt; mode creates the volume as soon as the PVC is created, regardless of whether any pod will ever use it. In a multi-zone cluster, this can be a trap because the volume might be created in an availability zone where the pod can’t run. &lt;code&gt;WaitForFirstConsumer&lt;/code&gt;, on the other hand, delays everything until a pod is scheduled. This ensures the disk lands in the same zone as the pod, avoiding a "volume stuck in the wrong zone" headache. The rest of the StorageClass is a set of parameters - options like disk type, IOPS tier, or replication settings - that are passed directly to the CSI driver.&lt;/p&gt;

&lt;p&gt;To see what your cluster offers, &lt;code&gt;kubectl get storageclass&lt;/code&gt; and &lt;code&gt;kubectl describe storageclass&lt;/code&gt; are your starting points. Pay close attention to which class is marked as default. Any PVC that doesn't explicitly name a &lt;code&gt;storageClassName&lt;/code&gt; silently uses that default, which is a fast track to accidentally provisioning production data onto a slow, non-replicated tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  PV and PVC: the claim and the volume
&lt;/h2&gt;

&lt;p&gt;These two objects are easy to mix up conceptually, so it helps to think of them as physical property vs. a ticket. A &lt;strong&gt;PersistentVolume (PV)&lt;/strong&gt; is the actual, provisioned piece of storage - a cluster-level resource, like a node. It's "the disk." A &lt;strong&gt;PersistentVolumeClaim (PVC)&lt;/strong&gt; is a namespaced request for storage with certain properties. It's "the ticket." The PVC specifies the size, access mode, and StorageClass it needs, and if a matching PV exists, Kubernetes binds them together.&lt;/p&gt;

&lt;p&gt;Binding isn't a fuzzy match; it requires three conditions to line up exactly. The StorageClass must match, the access modes must be compatible, and the capacity must be sufficient. If the PV is 10 GB and the PVC asks for 5 GB, they bind - but you lose access to the extra 5 GB because a PV can only satisfy a single claim. If any of these conditions fails, the PVC doesn't throw an error and it doesn't retry with looser requirements; it just sits in &lt;code&gt;Pending&lt;/code&gt; indefinitely. That silent waiting is the single most common "why won't my pod start" scenario in Kubernetes storage.&lt;/p&gt;

&lt;p&gt;You also need to understand the access modes, because they dictate what topologies your pods can use. &lt;code&gt;ReadWriteOnce&lt;/code&gt; (RWO) creates a volume that can only be mounted as read/write by a single node. This is a crucial distinction: the lock is per &lt;em&gt;node&lt;/em&gt;, not per &lt;em&gt;pod&lt;/em&gt;. Multiple pods on the same node can share an RWO volume just fine. However, if you scale a Deployment beyond one replica and the new pod lands on a different node, it will sit in &lt;code&gt;Pending&lt;/code&gt; forever because it can't reach the volume. &lt;code&gt;ReadWriteMany&lt;/code&gt; (RWX) lifts this restriction, allowing many nodes to mount the volume simultaneously, but this mode is typically only available with shared filesystems like NFS or CephFS, not standard block storage. &lt;code&gt;ReadOnlyMany&lt;/code&gt; (ROX) is the read-only counterpart, mountable across many nodes.&lt;/p&gt;

&lt;p&gt;When the PVC finally goes away, what happens to your data is dictated by the &lt;code&gt;reclaimPolicy&lt;/code&gt; on the StorageClass. The &lt;code&gt;Delete&lt;/code&gt; policy cleans up everything - the PV and the underlying disk in your cloud provider - the moment the PVC is removed. It’s convenient for CI and aggressively unforgiving in production. The &lt;code&gt;Retain&lt;/code&gt; policy preserves the data, leaving the PV in a &lt;code&gt;Released&lt;/code&gt; state. An administrator then has to manually tidy it up or re-attach it, making this the only safe choice for databases or anything containing state you’d be sad to lose. (You might see a &lt;code&gt;Recycle&lt;/code&gt; policy in legacy manifests, which wiped the data and put the PV back in the pool, but it’s deprecated in favor of dynamic provisioning).&lt;/p&gt;

&lt;p&gt;Broadly, provisioning happens in one of two ways. In &lt;strong&gt;Static&lt;/strong&gt; provisioning, an admin creates PV objects by hand, pointing to pre-existing disks - useful when managing an NFS export or specialized hardware outside the cluster. In &lt;strong&gt;Dynamic&lt;/strong&gt; provisioning, the PVC references a StorageClass, and the CSI driver creates the disk on demand without any pre-made PV objects. This is the default operational mode for practically every modern cluster. When things go wrong, &lt;code&gt;kubectl describe pvc &amp;lt;name&amp;gt;&lt;/code&gt; is the most valuable tool you have; the Events section at the bottom usually names the exact reason a binding or provisioning call failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mounting: what happens on the node
&lt;/h2&gt;

&lt;p&gt;Once the binding is resolved, the heavy lifting shifts to &lt;strong&gt;kubelet&lt;/strong&gt;, the agent on each node. When a pod with an attached volume starts, kubelet reads the pod spec, asks the CSI Node driver to attach the disk to the physical server, mounts the storage into the node’s global filesystem, and finally bind-mounts that specific directory into the container's isolated namespace.&lt;/p&gt;

&lt;p&gt;Every Kubernetes volume lands under a predictable, well-hidden path on the node: &lt;code&gt;/var/lib/kubelet/pods/&amp;lt;pod-uuid&amp;gt;/volumes/&lt;/code&gt;. Memorizing this path pays off when a pod claims its data is gone. You can SSH into the node and check the directory. An empty directory at that path means the mount step failed entirely. A populated directory that the container can’t see points to a problem in the container’s mount propagation settings or a simple typo in the &lt;code&gt;mountPath&lt;/code&gt; definition. This distinction saves hours of staring at YAML when you're under pressure.&lt;/p&gt;

&lt;p&gt;The pod spec connects everything with two interlocking sections: the &lt;code&gt;volumes&lt;/code&gt; block names the source, referencing the PVC by its claim name, and the &lt;code&gt;volumeMounts&lt;/code&gt; block inside the container spec declares where that volume appears inside the filesystem tree. The shared &lt;code&gt;name&lt;/code&gt; field is the only glue linking the two.&lt;/p&gt;

&lt;h2&gt;
  
  
  A debugging checklist
&lt;/h2&gt;

&lt;p&gt;When storage misbehaves, walking the chain in order is the fastest way to find the single broken link. Always start by checking if the claim is bound: &lt;code&gt;kubectl get pvc -A&lt;/code&gt; and &lt;code&gt;kubectl describe pvc &amp;lt;name&amp;gt;&lt;/code&gt;, focusing on the Events section. If the PVC is stuck in &lt;code&gt;Pending&lt;/code&gt;, you immediately know the problem is that no matching PV exists, or the CSI Controller can't provision one - so you check the provisioner logs. Next, verify the volume itself with &lt;code&gt;kubectl get pv&lt;/code&gt; and &lt;code&gt;kubectl describe pv &amp;lt;name&amp;gt;&lt;/code&gt;. A mismatch here means looking at the StorageClass definition. If the PVC is &lt;code&gt;Bound&lt;/code&gt; but the pod is stuck in &lt;code&gt;ContainerCreating&lt;/code&gt;, the fault has moved to the node; &lt;code&gt;kubectl describe pod &amp;lt;name&amp;gt;&lt;/code&gt; will show attach or mount errors in its events, and you should be looking at the CSI Node pod logs on that specific node using &lt;code&gt;kubectl logs&lt;/code&gt;. The final fallback is the raw driver diagnostics, found with &lt;code&gt;kubectl -n kube-system get pods | grep csi&lt;/code&gt; to locate the controller and node pods, and &lt;code&gt;kubectl -n kube-system logs&lt;/code&gt; to see the verbose error from the CSI driver itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The whole system is one sequential handoff: you create a PVC; Kubernetes calls a CSI driver via the StorageClass; the CSI driver creates the real disk on the storage backend; a PV appears and binds to the PVC; the pod mounts the volume and starts writing. Once that chain - &lt;strong&gt;Pod → PVC → CSI → external storage → PV&lt;/strong&gt; - is in your head, the layers stop looking like ceremony. Each object owns exactly one segment of the route, which means every failure has exactly one place to look. And it's worth repeating the point that grounds the entire design: Kubernetes never stores your data. It builds and maintains a reliable path to whatever does.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>sysadmin</category>
    </item>
  </channel>
</rss>
