<?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: bruce way</title>
    <description>The latest articles on DEV Community by bruce way (@abestdev).</description>
    <link>https://dev.to/abestdev</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%2F3876021%2F75c0fe1d-a655-455c-af8c-514d64de6cfe.jpg</url>
      <title>DEV Community: bruce way</title>
      <link>https://dev.to/abestdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/abestdev"/>
    <language>en</language>
    <item>
      <title>Fixing AKS NodePressure Memory Eviction with Azure Disk CSI: Root Cause &amp; Recovery Guide</title>
      <dc:creator>bruce way</dc:creator>
      <pubDate>Fri, 17 Jul 2026 02:37:58 +0000</pubDate>
      <link>https://dev.to/abestdev/fixing-aks-nodepressure-memory-eviction-with-azure-disk-csi-root-cause-recovery-guide-2egp</link>
      <guid>https://dev.to/abestdev/fixing-aks-nodepressure-memory-eviction-with-azure-disk-csi-root-cause-recovery-guide-2egp</guid>
      <description>&lt;h2&gt;
  
  
  The Incident (What Does the Error Mean?)
&lt;/h2&gt;

&lt;p&gt;You will see this in &lt;code&gt;kubectl describe pod &amp;lt;pod-name&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nix"&gt;&lt;code&gt;&lt;span class="nv"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="nv"&gt;Failed&lt;/span&gt;
&lt;span class="nv"&gt;Reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="nv"&gt;Evicted&lt;/span&gt;
&lt;span class="nv"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nv"&gt;The&lt;/span&gt; &lt;span class="nv"&gt;node&lt;/span&gt; &lt;span class="nv"&gt;was&lt;/span&gt; &lt;span class="nv"&gt;low&lt;/span&gt; &lt;span class="nv"&gt;on&lt;/span&gt; &lt;span class="nv"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
            &lt;span class="nv"&gt;Threshold&lt;/span&gt; &lt;span class="nv"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="nv"&gt;Mi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;available&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="nv"&gt;Mi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
            &lt;span class="nv"&gt;Container&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;was&lt;/span&gt; &lt;span class="nv"&gt;using&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="nv"&gt;Gi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;request&lt;/span&gt; &lt;span class="nv"&gt;is&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And on the node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kubectl describe node &amp;lt;node-name&amp;gt;

Conditions:
  Type                 Status
  MemoryPressure       True     # &amp;lt;-- kubelet has declared war
  DiskPressure         False
  PIDPressure          False
  Ready                False
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Immediate consequence:&lt;/strong&gt; Kubelet terminates pods in &lt;br&gt;
priority order — BestEffort first (no requests/limits set), then &lt;br&gt;
Burstable, then Guaranteed. If your pod had no &lt;code&gt;resources.requests&lt;/code&gt;, it is BestEffort and dies first, every time. The Azure Disk CSI driver then fails to detach the &lt;code&gt;PersistentVolume&lt;/code&gt; cleanly because the pod was force-killed, leaving a &lt;code&gt;VolumeAttachment&lt;/code&gt; object in &lt;code&gt;Terminating&lt;/code&gt; state that blocks the pod from starting on a new node.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Attack Vector / Blast Radius
&lt;/h2&gt;

&lt;p&gt;This is not a single-pod problem. The cascade goes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One noisy-neighbor pod&lt;/strong&gt; (no memory limit) balloons and consumes node memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubelet evicts BestEffort pods&lt;/strong&gt; — likely your stateful workloads with CSI disks, since those are often misconfigured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure Disk CSI &lt;code&gt;VolumeAttachment&lt;/code&gt; gets stuck&lt;/strong&gt; — the &lt;code&gt;external-attacher&lt;/code&gt; sidecar in the CSI controller can't issue a &lt;code&gt;ControllerUnpublishVolume&lt;/code&gt; to the Azure API fast enough before the node is marked &lt;code&gt;NotReady&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pod reschedule is blocked&lt;/strong&gt; — Kubernetes scheduler sees the PV still attached to the dead node. The pod sits in &lt;code&gt;ContainerCreating&lt;/code&gt; or &lt;code&gt;Pending&lt;/code&gt; indefinitely with &lt;code&gt;Multi-Attach error for volume&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you have no PodDisruptionBudget&lt;/strong&gt;, a rolling eviction can take down your entire Deployment simultaneously.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node pool autoscaler&lt;/strong&gt; may spin up a new node, but the stuck &lt;code&gt;VolumeAttachment&lt;/code&gt; prevents the PV from binding to it — so you now have a new node and still a broken pod.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Blast radius:&lt;/strong&gt; Full service outage for any stateful workload (databases, message queues, file processors) running on the affected node pool.&lt;/p&gt;


&lt;h2&gt;
  
  
  How to Fix It
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Step 1: Unblock the Stuck VolumeAttachment (Immediate)
&lt;/h3&gt;

&lt;p&gt;If your pod is stuck in &lt;code&gt;ContainerCreating&lt;/code&gt; on a new 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="c"&gt;# Identify the stuck attachment&lt;/span&gt;
kubectl get volumeattachment

&lt;span class="c"&gt;# Force-delete the finalizer — do this ONLY if the source node is confirmed dead/drained&lt;/span&gt;
kubectl patch volumeattachment &amp;lt;va-name&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"metadata":{"finalizers":null}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;merge
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; Only remove the finalizer if you have &lt;br&gt;
confirmed the Azure Disk is not actually attached to any running VM. &lt;br&gt;
Check in the Azure Portal under the disk's "Managed by" field.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 2: Fix the Root Cause — Resource Requests &amp;amp; Limits
&lt;/h3&gt;

&lt;p&gt;The BestEffort QoS class is the direct cause of priority eviction. Every container needs explicit requests.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; apiVersion: apps/v1
 kind: Deployment
 metadata:
   name: my-stateful-app
 spec:
   template:
     spec:
       containers:
       - name: app
         image: myrepo/app:1.4.2
&lt;span class="gd"&gt;-        # No resources defined — BestEffort QoS, evicted first
&lt;/span&gt;&lt;span class="gi"&gt;+        resources:
+          requests:
+            memory: "512Mi"
+            cpu: "250m"
+          limits:
+            memory: "1Gi"
+            cpu: "1000m"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Enterprise Best Practice:&lt;/strong&gt; Use &lt;code&gt;Guaranteed&lt;/code&gt; QoS by setting &lt;code&gt;requests == limits&lt;/code&gt;. This makes kubelet treat your pod as the last resort for eviction.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;         resources:
           requests:
&lt;span class="gd"&gt;-            memory: "512Mi"
-            cpu: "250m"
&lt;/span&gt;&lt;span class="gi"&gt;+            memory: "1Gi"
+            cpu: "500m"
&lt;/span&gt;           limits:
             memory: "1Gi"
&lt;span class="gd"&gt;-            cpu: "1000m"
&lt;/span&gt;&lt;span class="gi"&gt;+            cpu: "500m"
+        # requests == limits =&amp;gt; Guaranteed QoS class
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Tune Kubelet Eviction Thresholds via AKS KubeletConfig
&lt;/h3&gt;

&lt;p&gt;The default &lt;code&gt;memory.available&amp;lt;100Mi&lt;/code&gt; hard eviction threshold is dangerously low for nodes running CSI &lt;br&gt;
workloads. Raise the soft threshold so kubelet starts reclaiming memory &lt;br&gt;
gracefully before hitting the hard wall.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; apiVersion: v1
 kind: ConfigMap
 metadata:
   name: kubelet-config
 # Apply via AKS node pool KubeletConfig in the ARM template or Bicep:
&lt;span class="p"&gt;---
&lt;/span&gt; apiVersion: 2023-01-01
 type: Microsoft.ContainerService/managedClusters/agentPools
 properties:
   kubeletConfig:
&lt;span class="gd"&gt;-    # Default: no custom eviction thresholds
&lt;/span&gt;&lt;span class="gi"&gt;+    evictionSoft:
+      memory.available: "300Mi"
+      nodefs.available: "10%"
+    evictionSoftGracePeriod:
+      memory.available: "2m"
+      nodefs.available: "2m"
+    evictionHard:
+      memory.available: "150Mi"
+      nodefs.available: "5%"
+    evictionMaxPodGracePeriod: 90
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply via Azure CLI:&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 nodepool update &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &amp;lt;rg&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cluster-name&lt;/span&gt; &amp;lt;cluster&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &amp;lt;nodepool&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--kubelet-config&lt;/span&gt; kubelet-config.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: Add a PodDisruptionBudget
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gi"&gt;+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: my-stateful-app-pdb
+spec:
+  minAvailable: 1
+  selector:
+    matchLabels:
+      app: my-stateful-app
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Prevention in CI/CD
&lt;/h2&gt;

&lt;h3&gt;
  
  
  OPA/Gatekeeper Policy — Block BestEffort QoS at Admission
&lt;/h3&gt;

&lt;p&gt;Deploy this &lt;code&gt;ConstraintTemplate&lt;/code&gt; to reject any pod without resource requests:&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;templates.gatekeeper.sh/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;ConstraintTemplate&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;requireresourcelimits&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;crd&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;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;RequireResourceLimits&lt;/span&gt;
  &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;admission.k8s.gatekeeper.sh&lt;/span&gt;
      &lt;span class="na"&gt;rego&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;        &lt;span class="err"&gt;package&lt;/span&gt; &lt;span class="err"&gt;requireresourcelimits&lt;/span&gt;        &lt;span class="err"&gt;violation[{"msg":&lt;/span&gt; &lt;span class="err"&gt;msg}]&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;          &lt;span class="err"&gt;container&lt;/span&gt; &lt;span class="err"&gt;:=&lt;/span&gt; &lt;span class="err"&gt;input.review.object.spec.containers[_]&lt;/span&gt;          &lt;span class="err"&gt;not&lt;/span&gt; &lt;span class="err"&gt;container.resources.requests.memory&lt;/span&gt;          &lt;span class="err"&gt;msg&lt;/span&gt; &lt;span class="err"&gt;:=&lt;/span&gt; &lt;span class="err"&gt;sprintf("Container&lt;/span&gt; &lt;span class="err"&gt;'%v'&lt;/span&gt; &lt;span class="err"&gt;missing&lt;/span&gt; &lt;span class="err"&gt;memory&lt;/span&gt; &lt;span class="err"&gt;request.&lt;/span&gt; &lt;span class="err"&gt;BestEffort&lt;/span&gt; &lt;span class="err"&gt;QoS&lt;/span&gt; &lt;span class="err"&gt;is&lt;/span&gt; &lt;span class="err"&gt;prohibited.",&lt;/span&gt; &lt;span class="err"&gt;[container.name])&lt;/span&gt;        &lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Checkov — Scan IaC Before Apply
&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;# In your CI pipeline (GitHub Actions, Azure DevOps)&lt;/span&gt;
checkov &lt;span class="nt"&gt;-d&lt;/span&gt; ./k8s-manifests &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--check&lt;/span&gt; CKV_K8S_11 &lt;span class="se"&gt;\ &lt;/span&gt;&lt;span class="c"&gt;# CPU limits&lt;/span&gt;
  &lt;span class="nt"&gt;--check&lt;/span&gt; CKV_K8S_13 &lt;span class="se"&gt;\ &lt;/span&gt;&lt;span class="c"&gt;# Memory limits&lt;/span&gt;
  &lt;span class="nt"&gt;--check&lt;/span&gt; CKV_K8S_10 &lt;span class="se"&gt;\ &lt;/span&gt;&lt;span class="c"&gt;# CPU requests&lt;/span&gt;
  &lt;span class="nt"&gt;--check&lt;/span&gt; CKV_K8S_12    &lt;span class="c"&gt;# Memory requests&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Node Pool Sizing — Right-Size Before You Tune Thresholds
&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;# Check actual memory consumption vs allocatable&lt;/span&gt;
kubectl describe node &amp;lt;node&amp;gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A5&lt;/span&gt; &lt;span class="s2"&gt;"Allocated resources"&lt;/span&gt;

&lt;span class="c"&gt;# If requests &amp;gt; 80% of allocatable memory, scale the node pool&lt;/span&gt;
az aks nodepool scale &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &amp;lt;rg&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cluster-name&lt;/span&gt; &amp;lt;cluster&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &amp;lt;nodepool&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-count&lt;/span&gt; &amp;lt;n+2&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rule of thumb:&lt;/strong&gt; Keep total pod memory &lt;code&gt;requests&lt;/code&gt; below 70% of node allocatable memory to leave headroom for kubelet, &lt;br&gt;
system daemons, and CSI driver sidecars (which themselves consume &lt;br&gt;
50–150Mi per node).&lt;/p&gt;

&lt;h3&gt;
  
  
  Related Diagnostics
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://stackengine.dev/aks-node-info-scale-up-failure" rel="noopener noreferrer"&gt;Fixing AKS ‘Failed to Get Node Info’ During NodePool Scale-Up: Root Cause &amp;amp; Resolution&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>How to Fix PostgreSQL 'cannot execute CREATE TABLE in a read-only transaction' Error</title>
      <dc:creator>bruce way</dc:creator>
      <pubDate>Fri, 17 Jul 2026 02:28:41 +0000</pubDate>
      <link>https://dev.to/abestdev/how-to-fix-postgresql-cannot-execute-create-table-in-a-read-only-transaction-error-3klj</link>
      <guid>https://dev.to/abestdev/how-to-fix-postgresql-cannot-execute-create-table-in-a-read-only-transaction-error-3klj</guid>
      <description>&lt;p&gt;The Incident&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ERROR:  cannot execute CREATE TABLE in a read-only transaction&lt;br&gt;
CONTEXT:  SQL function "migrate_schema" during inlining&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Postgres enforces a hard wall: no DDL, no DML writes, no sequence increments are permitted inside a read-only transaction. The moment the executor hits CREATE TABLE, it throws and rolls back the entire transaction block. If this fires mid-migration (Flyway, Liquibase, Alembic, Django), your schema is left in a partial state — the migration tool may mark the version as failed, requiring manual repair of the migration history table before the next deploy can proceed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Attack Vector / Blast Radius
&lt;/h2&gt;

&lt;p&gt;This is not a security exploit in the traditional sense — but the blast radius in a production pipeline is severe:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Replica misrouting (most common prod incident): A connection pooler (PgBouncer, RDS Proxy, HAProxy) routes the migration runner to a read replica instead of the primary. Every DDL fails. The migration tool may retry in a loop, hammering the replica and spiking its replication lag.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;default_transaction_read_only = on at the role or database level: A DBA hardened a reporting role or an entire database cluster with ALTER ROLE reporter SET default_transaction_read_only = on. If your migration runner inherits this role or connects to that DB, every transaction opens read-only silently. No warning. Just the error at DDL time.&lt;br&gt;
    Explicit SET TRANSACTION READ ONLY in a wrapper: A shared DB utility function or ORM hook sets SET TRANSACTION READ ONLY for "safe" read operations and the DDL accidentally executes inside that context.&lt;br&gt;
    RDS/Aurora Multi-AZ failover window: During failover, the old primary becomes a replica. Connections that survive the failover are now on a read-only node. Migrations queued during this window fail with exactly this error.&lt;/p&gt;

&lt;p&gt;Cascading risk: Liquibase/Flyway lock the DATABASECHANGELOGLOCK table at migration start. If the transaction fails mid-flight, the lock row may remain set to TRUE, blocking all future migration runs until manually cleared.&lt;br&gt;
How to Fix It&lt;br&gt;
Basic Fix — Verify and Override Transaction Mode&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;-- Implicit read-only context inherited from role or pooler&lt;/li&gt;
&lt;li&gt;BEGIN;&lt;/li&gt;
&lt;li&gt;CREATE TABLE orders (id SERIAL PRIMARY KEY, total NUMERIC);&lt;/li&gt;
&lt;li&gt;&lt;p&gt;COMMIT;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;-- Explicitly assert READ WRITE before DDL&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;BEGIN;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;SET LOCAL default_transaction_read_only = off;  -- override session GUC for this txn&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;SET TRANSACTION READ WRITE;                      -- belt-and-suspenders&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;CREATE TABLE orders (id SERIAL PRIMARY KEY, total NUMERIC);&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;COMMIT;&lt;/p&gt;

&lt;p&gt;Note: SET LOCAL only survives the current transaction block. This is intentional — do not use SET (session-level) in a shared connection pool.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Verify You Are on the Primary&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;-- Blindly running migration against pool VIP&lt;/li&gt;
&lt;li&gt;&lt;p&gt;psql -h db-pool.internal -U migrator -d appdb -f migrate.sql&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;-- Confirm primary before running DDL&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;psql -h db-pool.internal -U migrator -d appdb \&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;-c "SELECT CASE WHEN pg_is_in_recovery() THEN 'REPLICA - ABORT' ELSE 'PRIMARY - OK' END AS node_role;"&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;h1&gt;
  
  
  Only proceed if output is 'PRIMARY - OK'
&lt;/h1&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;psql -h db-pool.internal -U migrator -d appdb -f migrate.sql&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Enterprise Best Practice — Role Hygiene + Connection Routing&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;-- Migration runner uses shared 'app_user' role with read-only default&lt;/li&gt;
&lt;li&gt;&lt;p&gt;ALTER ROLE app_user SET default_transaction_read_only = on;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;-- Dedicated migration role, never read-only, explicitly scoped&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;CREATE ROLE migrator NOINHERIT LOGIN PASSWORD '...';&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;GRANT CREATE, CONNECT ON DATABASE appdb TO migrator;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;-- DO NOT set default_transaction_read_only on this role&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;-- In PgBouncer: route migrator to primary_dsn ONLY&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;; pgbouncer.ini&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;[databases]&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;appdb_migrate = host=primary.db.internal port=5432 dbname=appdb auth_user=migrator&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;appdb         = host=pool.db.internal   port=5432 dbname=appdb&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In RDS/Aurora, always resolve the cluster writer endpoint (cluster.cluster-xxxx.rds.amazonaws.com) — never the reader endpoint — for migration runners. Enforce this in your Terraform:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;db_host = aws_rds_cluster.main.reader_endpoint&lt;/li&gt;
&lt;li&gt;db_host = aws_rds_cluster.main.endpoint  # writer/primary endpoint&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Prevention in CI/CD&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pre-migration node role check (shell gate):&lt;/li&gt;
&lt;/ol&gt;

&lt;h1&gt;
  
  
  !/bin/bash
&lt;/h1&gt;

&lt;p&gt;ROLE=$(psql "$DATABASE_URL" -tAc "SELECT pg_is_in_recovery();")&lt;br&gt;
if [ "$ROLE" = "t" ]; then&lt;br&gt;
  echo "FATAL: Connected to a replica. Aborting migration."&lt;br&gt;
  exit 1&lt;br&gt;
fi&lt;/p&gt;

&lt;p&gt;Drop this as a pre-step in your GitHub Actions / GitLab CI job before Flyway or Alembic runs.&lt;br&gt;
Become a Medium member&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Checkov / kics policy for Terraform RDS:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Ensure your Terraform never wires a migration runner to a reader endpoint. Write a custom Checkov check or use OPA:&lt;/p&gt;

&lt;h1&gt;
  
  
  OPA: deny migration jobs targeting reader endpoints
&lt;/h1&gt;

&lt;p&gt;deny[msg] {&lt;br&gt;
  input.resource.type == "kubernetes_deployment"&lt;br&gt;
  input.resource.spec.template.spec.containers[_].env[e]&lt;br&gt;
  e.name == "DATABASE_URL"&lt;br&gt;
  contains(e.value, "reader")&lt;br&gt;
  msg := "Migration workload must not target a reader/replica endpoint."&lt;br&gt;
}&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Liquibase / Flyway — fail fast on read-only:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Flyway 9+: set flyway.connectRetries=0 and flyway.validateOnMigrate=true. Do not let it retry silently into a replica loop.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Audit role defaults in your IaC:&lt;/li&gt;
&lt;/ol&gt;

&lt;h1&gt;
  
  
  Detect any role with read-only default in your DB
&lt;/h1&gt;

&lt;p&gt;psql -c "SELECT rolname, rolconfig FROM pg_roles WHERE 'default_transaction_read_only=on' = ANY(rolconfig);"&lt;/p&gt;

&lt;p&gt;Run this as a nightly CI job and alert if your migration role appears in the output.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>How I built a pure client-side sanitizer to stop leaking Stripe tokens to ChatGPT.</title>
      <dc:creator>bruce way</dc:creator>
      <pubDate>Sat, 16 May 2026 14:26:02 +0000</pubDate>
      <link>https://dev.to/abestdev/how-i-built-a-pure-client-side-sanitizer-to-stop-leaking-stripe-tokens-to-chatgpt-2i4b</link>
      <guid>https://dev.to/abestdev/how-i-built-a-pure-client-side-sanitizer-to-stop-leaking-stripe-tokens-to-chatgpt-2i4b</guid>
      <description>&lt;p&gt;Like most developers, my go-to debugging strategy often involves dumping massive Nginx configs or React error traces straight into AI tools like Claude and ChatGPT. A few weeks ago, I made a classic mistake: I accidentally included a production DB URI and a Stripe token in my prompt. It made me realize how dangerously easy it is to leak credentials when you're moving fast and frustrated by a bug.&lt;/p&gt;

&lt;p&gt;I searched around for a lightweight client-side scrubber to sanitize my text, but came up empty. The tools I found either choked on multi-line secrets (like RSA private keys) or completely failed on greedy regex traps (like the @ symbol in database credentials).&lt;/p&gt;

&lt;p&gt;So I spent the last couple of weekends building&amp;nbsp;&lt;strong&gt;GhostSanitizer&lt;/strong&gt;&amp;nbsp;— a pure TypeScript/Regex engine that runs entirely in the browser. It intercepts your text, tokenizes any high-entropy secrets (AWS keys, JWTs, URIs) into dummy tokens (e.g.,&amp;nbsp;&lt;strong&gt;STACK_SEC_1&lt;/strong&gt;), and only sends the safe structure to the LLM. When the LLM replies with the refactored code, the browser locally maps the tokens back to your real secrets so you can 1-click copy it.&lt;/p&gt;

&lt;p&gt;I open-sourced the core sanitizer logic here if anyone wants to use it for their own AI wrappers:&lt;br&gt;&lt;br&gt;
&lt;a href="https://github.com/abests/ghost-sanitizer-js" rel="noopener noreferrer"&gt;https://github.com/abests/ghost-sanitizer-js&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Live Sandbox:&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
To actually make it useful for myself, I wrapped it into a UI and used a Python script to map out and generate specific pre-loaded prompts for about 500 annoying WebDev/DevOps error scenarios (mostly React hydration errors, Nginx 502s/CORS issues). Yes, it's a generated matrix, but it forces the LLM to stay highly context-specific instead of giving generic advice.&lt;/p&gt;

&lt;p&gt;You can test the live decryption theater (press F12 to watch it redact before the network request) here:  &lt;strong&gt;&lt;a href="https://stackengine.dev" rel="noopener noreferrer"&gt;stackengine.dev&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
(Link to a specific issue like&amp;nbsp;&lt;a href="https://stackengine.dev/postgres-deadlock-detected-sharelock-transaction" rel="noopener noreferrer"&gt;PostgreSQL Deadlock ShareLock&lt;/a&gt;&amp;nbsp;if you want to see the specific system prompts in action).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full transparency on the model:&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
I threw in a tiny free tier (3 uses) routed through my own Claude 3.5 Haiku key just so people can test the decryption UI. After that, you have to drop in your own OpenAI/Anthropic key (BYOK). When you use your own key, the requests go straight from your browser to the LLM provider. I have zero backend database and literally store nothing.&lt;/p&gt;

&lt;p&gt;Let me know if you manage to break the local regex mask. I'm actively trying to find edge cases to patch in the repo. Cheers!&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>postgres</category>
      <category>aws</category>
    </item>
    <item>
      <title>IAM Access Analyzer nuked our prod hotfix because I fundamentally misunderstood how Zelkova evaluates wildcards</title>
      <dc:creator>bruce way</dc:creator>
      <pubDate>Sat, 02 May 2026 13:30:49 +0000</pubDate>
      <link>https://dev.to/abestdev/iam-access-analyzer-nuked-our-prod-hotfix-because-i-fundamentally-misunderstood-how-zelkova-384d</link>
      <guid>https://dev.to/abestdev/iam-access-analyzer-nuked-our-prod-hotfix-because-i-fundamentally-misunderstood-how-zelkova-384d</guid>
      <description>&lt;p&gt;TL;DR: Spent 6 hours debugging why our GitOps pipeline kept blocking a critical deployment. Turns out IAM Access Analyzer doesn't care about your Permission Boundaries when evaluating trust policies. &lt;code&gt;Principal: "AWS: *"&lt;/code&gt; + a &lt;code&gt;StringLike&lt;/code&gt; ARN condition is still globally exploitable. Fixed it with &lt;code&gt;aws:PrincipalOrgID&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Incident
&lt;/h2&gt;

&lt;p&gt;Our zero-critical-finding security gate hard-blocked a hotfix for our order processing engine. The Terraform pipeline died during validation with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[FATAL] IAM Access Analyzer finding [RESOURCE_PUBLICLY_ACCESSIBLE] 
detected on aws_iam_role.cross_account_event_bus. 
Trust policy allows Principal 'AWS:*'. Deployment halted.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The False Leads (aka me being an idiot)
&lt;/h2&gt;

&lt;p&gt;First, I assumed IAM eventual consistency was screwing with us. Forced a state refresh, manually triggered aws accessanalyzer start-resource-scan, finding came right back.&lt;/p&gt;

&lt;p&gt;Second hypothesis: I had a strict Permission Boundary on the role with aws:SourceVpc and aws:SourceIp conditions. I thought Zelkova (the automated reasoning engine behind Access Analyzer) would be smart enough to calculate the intersection of the trust policy + boundary and realize no external actor could satisfy the network requirements.&lt;/p&gt;

&lt;p&gt;Wrong. Access Analyzer evaluates trust policies in complete isolation. It doesn't aggregate Permission Boundaries or SCPs when determining if something is publicly accessible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Root Cause
&lt;/h2&gt;

&lt;p&gt;The role's trust policy had Principal: { "AWS": "*" } to support dynamic cross-account access for worker nodes across sub-accounts. To "secure" it, I added:&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="err"&gt;Condition:&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="err"&gt;StringLike:&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;"aws:PrincipalArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::*:role/worker-node-*"&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;p&gt;Looks safe, right? Nope. Zelkova uses formal logic. Since AWS account IDs are globally addressable, any attacker could create a role named worker-node-exploit in their own AWS account. That ARN would match the StringLike condition. Zelkova correctly flagged this as exploitable by the entire AWS universe.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Replaced the non-deterministic ARN wildcard with &lt;code&gt;aws:PrincipalOrgID&lt;/code&gt;:&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;condition&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;test&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"StringEquals"&lt;/span&gt;
  &lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"aws:PrincipalOrgID"&lt;/span&gt;
  &lt;span class="nx"&gt;values&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"o-xyz123abc9"&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;This mathematically proves to Zelkova that &lt;code&gt;AWS: *&lt;/code&gt; is bounded to our AWS Organization. Finding cleared instantly.&lt;/p&gt;

&lt;p&gt;Honestly, I got so annoyed debugging IAM policy logic at 2 AM that I refuse to paste our configs into ChatGPT or third-party linters because of compliance. So over the weekend, I just hacked together a pure client-side WASM utility that runs locally in the browser and redacts secrets before checking for this exact issue. Put it up here if anyone else wants to validate their trust policies without leaking data to the cloud:&lt;br&gt;
&lt;a href="https://stackengine.dev/aws-iam-access-analyzer-publicly-accessible" rel="noopener noreferrer"&gt;IAM Access Analyzer Public Principal Auditor&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>devops</category>
      <category>terraform</category>
    </item>
  </channel>
</rss>
