<?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: Infraforge</title>
    <description>The latest articles on DEV Community by Infraforge (@infraforge).</description>
    <link>https://dev.to/infraforge</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.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F13346%2Fde839a31-485a-47dd-92b8-2425002f861b.png</url>
      <title>DEV Community: Infraforge</title>
      <link>https://dev.to/infraforge</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/infraforge"/>
    <language>en</language>
    <item>
      <title>How we recovered tfstate after force-unlock raced a CI apply</title>
      <dc:creator>Muhammad Hassaan Javed</dc:creator>
      <pubDate>Tue, 19 May 2026 22:37:05 +0000</pubDate>
      <link>https://dev.to/infraforge/how-we-recovered-tfstate-after-force-unlock-raced-a-ci-apply-52mj</link>
      <guid>https://dev.to/infraforge/how-we-recovered-tfstate-after-force-unlock-raced-a-ci-apply-52mj</guid>
      <description>&lt;p&gt;The engineer pinged us at 4:48 pm on a Thursday. They had been trying to push a small IAM change to staging, terraform apply had failed with Error acquiring the state lock, and they did what most of us have done at least once: they ran terraform force-unlock with the ID from the error message and re-ran apply. The apply went through. Ten minutes later a teammate on a different branch ran terraform plan and the plan output wanted to destroy and recreate 38 resources that were sitting healthy in AWS, returning 200s, serving traffic. By the time we joined the bridge, the original engineer was halfway convinced they needed to let Terraform rebuild the whole staging environment. They did not. The cloud was fine. The state file was the thing that was broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem signals:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;terraform plan shows -/+ destroy and recreate for resources nobody touched and that are healthy in the cloud&lt;/li&gt;
&lt;li&gt;Teammates see Error: state snapshot was created by Terraform v1.5.7, which is newer than current v1.5.4&lt;/li&gt;
&lt;li&gt;S3 bucket versioning shows two or three tfstate writes inside a 60 to 90 second window&lt;/li&gt;
&lt;li&gt;The DynamoDB lock table is empty but the state file timestamps do not line up with anyone's apply log&lt;/li&gt;
&lt;li&gt;Someone on the team ran terraform force-unlock in the last hour&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A stale lock from a dead CI job
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What the engineer thought it was&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first wrong model was reasonable. The engineer saw Error acquiring the state lock, looked at the lock ID, did not recognize it, and assumed it was a leftover from a CI job that had crashed earlier in the week. They had seen stale locks before. The fix last time was force-unlock. So they ran it again.&lt;/p&gt;

&lt;p&gt;What they did not check was whether the lock holder was actually still alive. The CI job that held the lock was a scheduled terraform plan cycle running on a 15-minute cadence, and that particular run was on the slow side because the workspace had grown to about 600 resources. It was not stuck. It was just working. The force-unlock removed the lock entry from DynamoDB while the CI process was still very much holding an in-memory version of the state file, mid-refresh. Two writers, no coordination.&lt;/p&gt;

&lt;p&gt;When the engineer's apply finished, it wrote its version of the state to S3. About forty seconds later, the CI run finished its refresh and wrote its version of the state to S3 on top of that. Two non-linear writes, each thinking it had the latest state, each clobbering parts of the other. S3 versioning preserved both, but the live state pointer was pointing at a Frankenstein.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three S3 versions in 90 seconds, and a plan that wanted to destroy healthy infrastructure
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;The moment the real cause became visible&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We pulled the S3 object versions for the state file first. That is the single most useful command in a Terraform state incident, and most teams do not run it until someone external suggests it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3api list-object-versions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; acme-tfstate-staging &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--prefix&lt;/span&gt; &lt;span class="nb"&gt;env&lt;/span&gt;/staging/terraform.tfstate &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Versions[?LastModified&amp;gt;=`2024-01-18T16:45:00Z`].[VersionId,LastModified,Size]'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table

&lt;span class="c"&gt;# Output (abridged):&lt;/span&gt;
&lt;span class="c"&gt;# VersionId                          LastModified               Size&lt;/span&gt;
&lt;span class="c"&gt;# 9f3aV2.JqL...                      2024-01-18T16:51:12Z       412847&lt;/span&gt;
&lt;span class="c"&gt;# 8h2nB1.KpM...                      2024-01-18T16:50:31Z       408992&lt;/span&gt;
&lt;span class="c"&gt;# 7g1mA0.LoN...                      2024-01-18T16:49:48Z       411203&lt;/span&gt;
&lt;span class="c"&gt;# 6f0lZ9.MnO...                      2024-01-18T16:42:15Z       411198   &amp;lt;-- last known good&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Three writes inside 84 seconds. The 16:42 version was the last clean write before the collision.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three writes in 84 seconds was the smoking gun. A healthy workspace writes state once per apply, and the next write is usually hours away. Three writes that close together meant at least two processes had been racing. We cross-checked against the CI logs and the engineer's shell history and confirmed: the CI plan cycle had been refreshing state from 16:49:48 onwards, the engineer's force-unlock landed at 16:50:18, the engineer's apply wrote state at 16:50:31, and the CI refresh wrote its stale view back at 16:51:12. The 16:51 write was the one Terraform was now reading, and it had been built from a refresh that started before half the engineer's changes existed.&lt;/p&gt;

&lt;p&gt;That explained the plan output. The state Terraform was reading said the resources had attributes that did not match reality. Plan diffed state against the cloud, saw the mismatch, and proposed the only thing it knows how to propose: destroy and recreate. The cloud was correct. The state was lying. If we had let the apply run, we would have taken a healthy staging environment offline for somewhere between 40 minutes and two hours to rebuild things that did not need rebuilding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restore the pre-collision state version, then import only what actually drifted
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;How we worked through it&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The recovery had two parts and an order that mattered. First, replace the corrupted live state with the last clean S3 version. Second, figure out which resources genuinely changed during the collision window and re-import only those. Skipping the second step is how teams end up with the same incident a week later, because real changes from the engineer's apply have been silently rolled back.&lt;/p&gt;

&lt;p&gt;Before touching anything we pulled a local backup of the current (broken) state. If our restore went wrong, we wanted a way back.&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. Backup the current broken state to local disk&lt;/span&gt;
aws s3api get-object &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; acme-tfstate-staging &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key&lt;/span&gt; &lt;span class="nb"&gt;env&lt;/span&gt;/staging/terraform.tfstate &lt;span class="se"&gt;\&lt;/span&gt;
  ./tfstate.broken.&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;.json

&lt;span class="c"&gt;# 2. Restore the last known good version in place&lt;/span&gt;
aws s3api copy-object &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; acme-tfstate-staging &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key&lt;/span&gt; &lt;span class="nb"&gt;env&lt;/span&gt;/staging/terraform.tfstate &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--copy-source&lt;/span&gt; &lt;span class="s1"&gt;'acme-tfstate-staging/env/staging/terraform.tfstate?versionId=6f0lZ9.MnO...'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--metadata-directive&lt;/span&gt; REPLACE

&lt;span class="c"&gt;# 3. Confirm the active version is now the restored one&lt;/span&gt;
aws s3api head-object &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; acme-tfstate-staging &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key&lt;/span&gt; &lt;span class="nb"&gt;env&lt;/span&gt;/staging/terraform.tfstate &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'VersionId'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The copy-object call writes the old version as a new current version. Do not delete versions; you want the audit trail intact.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;With the state restored, we ran terraform plan. The output was much shorter, around six resources, and they were the ones the engineer had actually changed in their apply. That was the divergence window: changes that had been made for real in AWS but that the restored state did not know about. Each of those needed a terraform import to reattach the live resource to the state. We did them one at a time, ran plan between each, and watched the diff shrink.&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;# Example: the engineer had created a new IAM role during their apply.&lt;/span&gt;
&lt;span class="c"&gt;# The restored state predates it, but the role exists in AWS.&lt;/span&gt;

terraform import &lt;span class="se"&gt;\&lt;/span&gt;
  module.platform.aws_iam_role.svc_runner &lt;span class="se"&gt;\&lt;/span&gt;
  acme-staging-svc-runner

&lt;span class="c"&gt;# After each import, re-run plan and confirm the resource is no longer in the diff.&lt;/span&gt;
terraform plan &lt;span class="nt"&gt;-out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/plan.out

&lt;span class="c"&gt;# Repeat for each resource genuinely changed during the divergence window:&lt;/span&gt;
&lt;span class="c"&gt;# - 1 IAM role&lt;/span&gt;
&lt;span class="c"&gt;# - 1 IAM role policy attachment&lt;/span&gt;
&lt;span class="c"&gt;# - 2 security group rules&lt;/span&gt;
&lt;span class="c"&gt;# - 1 SSM parameter&lt;/span&gt;
&lt;span class="c"&gt;# - 1 Lambda permission&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Import surgically. Do not bulk-import; you want a clean plan after each step so you can spot collateral damage.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;After the sixth import, terraform plan returned No changes. That was the success signal. The state matched the cloud, the engineer's intended changes were preserved, and nothing healthy had been destroyed. Total time on the bridge from first page to clean plan was 2 hours 40 minutes. About 45 minutes of that was the investigation; the rest was careful, slow imports with verification between each one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
  A[terraform plan shows mass destroy/recreate] --&amp;gt; B{Are the resources actually broken in cloud?}
  B -- No, healthy --&amp;gt; C[State file is the problem, not cloud]
  B -- Yes, broken --&amp;gt; Z[Different incident; investigate cloud-side]
  C --&amp;gt; D[list-object-versions on tfstate]
  D --&amp;gt; E{Multiple writes in short window?}
  E -- Yes --&amp;gt; F[Identify last clean version pre-collision]
  E -- No --&amp;gt; Y[Investigate other corruption causes]
  F --&amp;gt; G[Backup current broken state locally]
  G --&amp;gt; H[copy-object to restore clean version]
  H --&amp;gt; I[terraform plan: short diff = divergence window]
  I --&amp;gt; J[terraform import each drifted resource]
  J --&amp;gt; K{Plan empty?}
  K -- No --&amp;gt; J
  K -- Yes --&amp;gt; L[Recovery complete; write postmortem]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Decision flow we use for any state-collision incident. The first branch matters most: confirm the cloud is healthy before touching state.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Diagram renders at the &lt;a href="https://infraforge.agency/insights/terraform-force-unlock-state-divergence-recovery/#diagram" rel="noopener noreferrer"&gt;canonical version&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two tempting shortcuts that would have made it worse
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we tried that we will not try again&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two shortcuts came up on the bridge that we ruled out. They are worth naming because both of them sound reasonable when you are tired.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1. Let terraform apply rebuild everything&lt;/strong&gt;, The plan was already there. Just type yes. This would have caused 30 to 90 minutes of staging downtime for resources that did not need rebuilding, broken any data-layer resources with state of their own, and lost the audit trail of what had actually changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2. terraform refresh to fix the state&lt;/strong&gt;, Refresh updates state from the live infrastructure for known resources. It does not learn about resources the state has forgotten, and it cannot undo a structurally corrupted state. Refresh on a Frankenstein state can deepen the damage by writing the merged view back as the new truth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We have written about the broader pattern in &lt;a href="https://dev.to/terraform-state-recovery/"&gt;the Terraform state recovery playbook&lt;/a&gt;, specifically the rule we now apply on every state incident: the state file is the suspect until proven otherwise. Cloud is healthy until you have evidence it is not. That ordering keeps you from running destructive applies under time pressure.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pre-apply lock check that prints the holder's age
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we changed afterwards&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The team made two changes the week after the incident. Both are small. Both have already paid for themselves.&lt;/p&gt;

&lt;p&gt;The first change is a pre-apply wrapper script that reads the DynamoDB lock table before terraform apply runs. If a lock exists, the script prints the lock holder, when the lock was acquired, and how long ago that was. If the lock is younger than the workspace's typical apply duration plus a safety margin, the script refuses to run and tells the engineer to wait. If the lock is genuinely old (older than any plausible live process), the script still does not force-unlock automatically; it prints the exact force-unlock command and makes the engineer paste it. The friction is the point.&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="c"&gt;# pre-apply-lock-check.sh&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;WORKSPACE&lt;/span&gt;&lt;span class="o"&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;1&lt;/span&gt;:?workspace&lt;span class="p"&gt; name required&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;LOCK_TABLE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"acme-tfstate-locks"&lt;/span&gt;
&lt;span class="nv"&gt;MAX_PLAUSIBLE_APPLY_SECONDS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1800  &lt;span class="c"&gt;# 30 minutes&lt;/span&gt;

&lt;span class="nv"&gt;LOCK_ITEM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws dynamodb get-item &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--table-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCK_TABLE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;LockID&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;S&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;acme-tfstate-staging/env/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;WORKSPACE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/terraform.tfstate-md5&lt;/span&gt;&lt;span class="se"&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;--output&lt;/span&gt; json 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&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;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCK_ITEM&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;'.Item // empty'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&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;"No lock. Safe to proceed."&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nv"&gt;HOLDER&lt;/span&gt;&lt;span class="o"&gt;=&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;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCK_ITEM&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;'.Item.Info.S'&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.Who + " @ " + .Operation'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;CREATED&lt;/span&gt;&lt;span class="o"&gt;=&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;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCK_ITEM&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;'.Item.Info.S'&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.Created'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;AGE&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; +%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;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CREATED&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="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Lock present."&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  Holder:  &lt;/span&gt;&lt;span class="nv"&gt;$HOLDER&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;"  Created: &lt;/span&gt;&lt;span class="nv"&gt;$CREATED&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;"  Age:     &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;AGE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt; AGE &amp;lt; MAX_PLAUSIBLE_APPLY_SECONDS &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
  echo&lt;/span&gt; &lt;span class="s2"&gt;"REFUSING TO PROCEED. Lock is younger than max plausible apply duration."&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Wait for the current holder to finish, or confirm out-of-band that it is dead."&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;echo
echo&lt;/span&gt; &lt;span class="s2"&gt;"Lock is older than &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MAX_PLAUSIBLE_APPLY_SECONDS&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s. It may be stale."&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"To force-unlock, run manually (do NOT automate this):"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  terraform force-unlock &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;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCK_ITEM&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;'.Item.Info.S'&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.ID'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;We run this from CI and from a pre-apply git hook on engineer laptops. Same script, same rules, both places.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The second change is operational. The team's runbook now says: if you ever run force-unlock, page the on-call channel immediately with the lock ID and the reason. That single message would have caught this incident before it became one. The CI job would have replied within seconds that it was still running, and the engineer would have known to wait the eight minutes instead of clobbering the state.&lt;/p&gt;

&lt;p&gt;We have stopped recommending that teams treat force-unlock as a routine command. It is a recovery command. It belongs in the same mental category as DROP TABLE: technically available, occasionally necessary, never the first thing you reach for. The TTL on the lock is generous on purpose. Wait it out, or confirm the holder is dead. Those are the only two paths.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the state file is the suspect and the clock is running
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;If you are looking at a destroy plan you do not trust&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The hard part of state-collision incidents is not the recovery commands. The commands are mechanical once you know the shape of the problem. The hard part is the 20 minutes before that, when an apply plan is sitting in your terminal showing 30+ destroys, someone senior is asking on Slack whether you can just run it, and you have to decide whether the cloud is broken or the state is. Get that wrong under pressure and you cause the outage you were trying to prevent.&lt;/p&gt;

&lt;p&gt;We run these recovery engagements every week. The force-unlock-collision pattern has shown up four times this quarter alone, in three different shapes: a CI plan racing an engineer apply (this one), two engineers applying simultaneously after a Slack misunderstanding, and a long-running import operation that an engineer killed because they thought it had hung. The recovery shape is the same. The diagnostic discipline of confirming the cloud is healthy before touching state is the same. The thing that changes is which version of state is the right one to restore to, and that takes practice to spot quickly.&lt;/p&gt;

&lt;p&gt;If you are staring at a terraform plan that wants to destroy resources you know are healthy, do not run apply. &lt;a href="https://dev.to/review/"&gt;Book an infrastructure review with our team&lt;/a&gt; and we will be on a bridge with you the same day to work through the state restore and the surgical imports. We have done this enough times that we can usually have you back to an empty plan inside three hours.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;a href="https://infraforge.agency/insights/terraform-force-unlock-state-divergence-recovery/" rel="noopener noreferrer"&gt;https://infraforge.agency/insights/terraform-force-unlock-state-divergence-recovery/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your team is dealing with similar infrastructure debt, we offer infrastructure reviews and recovery engagements — &lt;a href="https://infraforge.agency/review/" rel="noopener noreferrer"&gt;see /review&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>state</category>
      <category>recovery</category>
      <category>terraformstate</category>
    </item>
    <item>
      <title>Why a forgotten RDS replica added $8,600 to one AWS bill</title>
      <dc:creator>Muhammad Hassaan Javed</dc:creator>
      <pubDate>Tue, 19 May 2026 17:23:31 +0000</pubDate>
      <link>https://dev.to/infraforge/why-a-forgotten-rds-replica-added-8600-to-one-aws-bill-2k4d</link>
      <guid>https://dev.to/infraforge/why-a-forgotten-rds-replica-added-8600-to-one-aws-bill-2k4d</guid>
      <description>&lt;p&gt;The finance lead forwarded the AWS bill on a Monday morning with three question marks in the subject line. The number had gone from a steady $3,200/month to $11,800 in six days. The on-call engineer's first guess, sensible enough, was that a data scientist had left a cross-region Athena job running over the weekend. It was not. It was an RDS read replica in a different AZ from its primary, provisioned a month earlier for a one-off load test, never decommissioned, retrying a replication-stream write every 50 milliseconds because somebody had flipped the primary's binlog format mid-stream. Nobody had read from the replica in three weeks. It had been quietly burning cross-AZ data transfer the whole time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem signals:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AWS bill jumped 2-4x in under a week with no traffic or feature change&lt;/li&gt;
&lt;li&gt;Cost Explorer concentrates the spike on DataTransfer-Regional-Bytes and RDSInstance line items&lt;/li&gt;
&lt;li&gt;An RDS read replica sits in a different AZ than its primary and shows jagged ReplicaLag (spikes to 30s, drops to 0.5s, repeats)&lt;/li&gt;
&lt;li&gt;No application config or BI tool actually points at the replica's endpoint&lt;/li&gt;
&lt;li&gt;Recent schema or replication change on the primary that nobody coordinated with replica owners&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Chasing the analytics query that did not exist
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we thought it was first&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Almost every cost spike I have seen in the last three years gets blamed on analytics first. There is usually a junior data person, a notebook, a forgotten SELECT *, and a story everyone tells themselves. So we did the natural thing. We pulled the Athena query history for the previous ten days. Nothing unusual. We checked Redshift, which the team barely uses. Idle. We checked the data warehouse cluster's autoscaling history. Flat.&lt;/p&gt;

&lt;p&gt;The clue was in Cost Explorer, but only when we grouped by usage type instead of by service. The RDS line item was up, sure, but the line item that had really moved was DataTransfer-Regional-Bytes. That is the meter for cross-AZ traffic inside a single region. Analytics queries do not typically light that meter up unless somebody has put a compute node in one AZ and the data in another, which would have been a much weirder problem.&lt;/p&gt;

&lt;p&gt;Cross-AZ data transfer at that volume meant something was constantly shipping bytes between two availability zones. The shape of the bill said: find the thing that talks to itself across AZs at high frequency.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we found the orphan replica
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;The diagnostic turn&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We listed every RDS instance in the account and compared the AZ of each replica to its primary. One read replica was in us-east-1b while its primary was in us-east-1a. That alone is not a problem; cross-AZ replicas exist for legitimate HA reasons. What was odd was that this replica was tagged with nothing. No Owner. No Purpose. No Environment. Just the default Name tag, which read load-test-replica-temp.&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 replicas with their AZ and their primary's AZ&lt;/span&gt;
aws rds describe-db-instances &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'DBInstances[?ReadReplicaSourceDBInstanceIdentifier!=`null`].[DBInstanceIdentifier,AvailabilityZone,ReadReplicaSourceDBInstanceIdentifier,DBInstanceStatus]'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table

&lt;span class="c"&gt;# Then for each primary, get its AZ&lt;/span&gt;
aws rds describe-db-instances &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-instance-identifier&lt;/span&gt; &amp;lt;primary-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'DBInstances[0].AvailabilityZone'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The two commands that surfaced the orphan in about 30 seconds.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The replica's CloudWatch ReplicaLag metric was the giveaway that this was not a healthy idle replica. It would spike to 30 seconds, drop to 0.5 seconds, spike again, every minute or so. That sawtooth pattern means the replication thread is failing and retrying. We pulled the replica's error log and found the same line repeating roughly every 50 milliseconds: a binlog format mismatch. Someone had changed the primary from MIXED to ROW format three weeks earlier, and the replica had been retrying the broken stream ever since.&lt;/p&gt;

&lt;p&gt;Every retry shipped a chunk of binlog across the AZ boundary. At 50ms intervals, 24 hours a day, for three weeks. That was the bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five-minute check that prevents the worse outcome
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we did before deleting anything&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The instinct, when you have found the thing burning money, is to kill it immediately. We did not. The worse outcome here is not 'replica costs another hour of cross-AZ transfer'. The worse outcome is 'replica gets deleted, a quarterly BI dashboard breaks on Friday, and finance is back in your inbox with a different question'.&lt;/p&gt;

&lt;p&gt;So we did the cheap verification first. We grepped the application monorepo for the replica's endpoint hostname. Zero hits. We checked the BI tool's data sources (Metabase in this case). Nothing pointed at it. We checked the data team's Airflow DAGs. Clean. We checked Terraform state to see how it had been created. It was in a workspace tagged load-test that had not been touched in a month, and the engineer who created it had left the company three weeks earlier.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;If something had pointed at it&lt;/strong&gt;, The right move would have been to keep the replica, fix the binlog format, and decide whether the read pattern actually justified cross-AZ. Deletion would have caused a worse incident than the cost spike.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing pointed at it&lt;/strong&gt;, Delete with --skip-final-snapshot. The replica was already corrupted by the binlog mismatch; a final snapshot was worthless. Cost stopped accruing within minutes.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws rds delete-db-instance &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-instance-identifier&lt;/span&gt; load-test-replica-temp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--skip-final-snapshot&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The actual delete, once we were confident nothing depended on the replica.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tag hygiene, expiration sweeps, and an anomaly budget that would have caught this on day 2
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we changed afterwards&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Forgotten resources are the largest single category of cloud waste I see in client accounts. Bigger than oversized instances. Bigger than reserved-instance gaps. The fix is mechanical. Every cost-generating resource needs three tags: Owner, Purpose, ExpiresAt. ExpiresAt is the one most teams skip and the one that does the work.&lt;/p&gt;

&lt;p&gt;We deployed a small Lambda on a weekly schedule that walks RDS, EC2, ELB, ElastiCache, and OpenSearch, finds resources past their ExpiresAt date or missing tags entirely, and posts to a Slack channel pinging the Owner. The owner has two weeks to either re-tag with a new ExpiresAt or delete. Resources with no Owner go to the platform team's queue. The first sweep flagged 47 resources across the account. Six of them were costing real money.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
  A[Weekly Lambda runs] --&amp;gt; B{Resource has&amp;lt;br/&amp;gt;Owner, Purpose,&amp;lt;br/&amp;gt;ExpiresAt tags?}
  B -- no --&amp;gt; C[Post to platform team queue]
  B -- yes --&amp;gt; D{ExpiresAt&amp;lt;br/&amp;gt;in past?}
  D -- no --&amp;gt; E[Skip]
  D -- yes --&amp;gt; F[DM the Owner in Slack]
  F --&amp;gt; G{Owner responds&amp;lt;br/&amp;gt;within 14 days?}
  G -- extends --&amp;gt; H[Update ExpiresAt]
  G -- no response --&amp;gt; I[Auto-tag for deletion&amp;lt;br/&amp;gt;review next sweep]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The sweep logic. About 180 lines of Python in practice.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Diagram renders at the &lt;a href="https://infraforge.agency/insights/forgotten-rds-replica-cross-az-cost-spike/#diagram" rel="noopener noreferrer"&gt;canonical version&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The second change was AWS Budgets with anomaly detection scoped per service. The team had a single account-wide budget set at $5,000/month, which is useless for catching this kind of incident because the spike was concentrated in one service and the account total only crossed $5,000 on day five. A per-service budget on RDS set at $4,000 with a 20% variance threshold would have fired on day 2. The alert that matters is the one that fires before you have spent the money, not after.&lt;/p&gt;

&lt;p&gt;The third change was a process one. The original binlog format change had been an uncoordinated database tweak from a senior engineer who had not realized a replica existed. Schema and replication changes now require a checklist that includes 'list all replicas of this primary and confirm they support the new config' as a pre-flight step. It is not glamorous. It would have prevented the entire incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where cost spike triage gets stuck
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;If your AWS bill just jumped and you do not know why&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The hard part of a cost spike is not finding the resource. It is being confident enough to delete it. Most teams we work with have at least one orphan RDS, ElastiCache, or NAT gateway they are afraid to touch because nobody remembers what depends on it. The triage takes a day; the courage to act takes a week of meetings. By then the bill has run another $2,000.&lt;/p&gt;

&lt;p&gt;We run cost spike triage engagements every month. We have seen the orphan-replica case four times this year, the NAT-gateway-in-the-wrong-AZ case more often than that, and a half dozen variants of 'load test that never got cleaned up' across CloudWatch Logs, OpenSearch, and Aurora Serverless. The pattern is almost always the same: a resource that nobody owns, a tag policy that was never enforced, and a budget alert tuned too coarse to catch concentration in a single service. We have written more on the underlying patterns in &lt;a href="https://dev.to/problems/cloud-cost-spikes/"&gt;the cloud cost spikes problem brief&lt;/a&gt; and across &lt;a href="https://dev.to/services/"&gt;our services&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your AWS bill jumped this month and you cannot point at the resource with confidence, &lt;a href="https://dev.to/review/"&gt;book an infrastructure review with our team&lt;/a&gt; and we will start with a 30-minute diagnostic call this week. Cost stops accruing the day we find the orphan.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;a href="https://infraforge.agency/insights/forgotten-rds-replica-cross-az-cost-spike/" rel="noopener noreferrer"&gt;https://infraforge.agency/insights/forgotten-rds-replica-cross-az-cost-spike/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your team is dealing with similar infrastructure debt, we offer infrastructure reviews and recovery engagements — &lt;a href="https://infraforge.agency/review/" rel="noopener noreferrer"&gt;see /review&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>cost</category>
      <category>spike</category>
      <category>triage</category>
      <category>costspikes</category>
    </item>
    <item>
      <title>Why terraform apply fails when plan passes: the map(any) trap</title>
      <dc:creator>Muhammad Hassaan Javed</dc:creator>
      <pubDate>Tue, 19 May 2026 17:14:53 +0000</pubDate>
      <link>https://dev.to/infraforge/why-terraform-apply-fails-when-plan-passes-the-mapany-trap-50dg</link>
      <guid>https://dev.to/infraforge/why-terraform-apply-fails-when-plan-passes-the-mapany-trap-50dg</guid>
      <description>&lt;p&gt;The on-call engineer pinged me at 4:42pm on a Friday with the release window open until 5:30. terraform apply against the staging workspace had failed with &lt;code&gt;Error: Unsupported argument&lt;/code&gt; deep inside a child module nobody on the team had touched in seven months. terraform plan against the same workspace ran clean. They had already re-run plan twice and got fresh no-op output both times. The shape of the failure was off. plan and apply diverging is rare in the way they were describing, and you mostly see it on data sources that resolve at apply time, not on a static &lt;code&gt;merge()&lt;/code&gt; call inside a module whose code had not changed in six months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem signals:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;terraform plan succeeds locally but terraform apply fails on a specific environment&lt;/li&gt;
&lt;li&gt;The error is &lt;code&gt;Error: Unsupported argument&lt;/code&gt; or &lt;code&gt;Inappropriate value&lt;/code&gt; deep inside a child module&lt;/li&gt;
&lt;li&gt;The traceback points at a &lt;code&gt;merge()&lt;/code&gt; or &lt;code&gt;lookup()&lt;/code&gt; call inside a module that has not been edited in months&lt;/li&gt;
&lt;li&gt;Your root module input list has crossed 20 variables and several are typed &lt;code&gt;any&lt;/code&gt; or &lt;code&gt;map(any)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;There is no CI job that runs terraform plan against every environment on every PR&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Three hypotheses, three dead ends, twenty-two minutes left in the release window
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we ruled out in the first 18 minutes&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first thing the on-call lead suggested was state drift. Someone, somewhere, had &lt;code&gt;terraform import&lt;/code&gt;-ed a resource by hand. We checked the audit log. No &lt;code&gt;import&lt;/code&gt; events in the past 30 days. We checked the lock table in DynamoDB. The lock had been released cleanly by the previous successful apply at 2:11pm.&lt;/p&gt;

&lt;p&gt;The second hypothesis was provider version drift. The team had recently bumped &lt;code&gt;hashicorp/aws&lt;/code&gt; from 5.62 to 5.71 in &lt;code&gt;versions.tf&lt;/code&gt;. A breaking change in a resource schema can absolutely cause an &lt;code&gt;Unsupported argument&lt;/code&gt; error if apply pulls a newer provider than plan resolved against. We pinned both runs to 5.71 explicitly, deleted &lt;code&gt;.terraform/&lt;/code&gt;, re-ran &lt;code&gt;init&lt;/code&gt;, then &lt;code&gt;plan&lt;/code&gt;, then &lt;code&gt;apply&lt;/code&gt;. Same error, same module, same line.&lt;/p&gt;

&lt;p&gt;The third hypothesis was a stale workspace. terraform workspaces sometimes diverge from the configuration if &lt;code&gt;workspace select&lt;/code&gt; was bypassed by an engineer who exported &lt;code&gt;TF_WORKSPACE&lt;/code&gt; and forgot. We ran &lt;code&gt;terraform workspace show&lt;/code&gt; and verified it matched the intended target. The plan output even confirmed the right resource addresses.&lt;/p&gt;

&lt;p&gt;Three explanations, three dead ends, twenty-eight minutes burned. The release window was now twenty-two minutes wide and shrinking. The on-call lead asked whether we should just roll back the deploy and figure it out Monday. I asked one more question first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 15th map(any) input that had been silently incubating for three weeks
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Where the collision actually lived&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I asked the on-call lead to walk me through what had merged into the workspace in the past two weeks. There were six commits. Five were obvious changes (image tags, a new IAM policy, a security group port). The sixth was a feature flag, added as a 15th &lt;code&gt;map(any)&lt;/code&gt; input on the root module by an engineer who had joined six weeks earlier.&lt;/p&gt;

&lt;p&gt;That was the lead.&lt;/p&gt;

&lt;p&gt;The root module had 28 input variables. 14 of them were &lt;code&gt;any&lt;/code&gt;-typed or &lt;code&gt;map(any)&lt;/code&gt; to absorb per-environment overrides accumulated over six years of feature additions. The new feature flag added a 15th &lt;code&gt;map(any)&lt;/code&gt; input named &lt;code&gt;feature_overrides&lt;/code&gt;. Its values flowed through a &lt;code&gt;merge()&lt;/code&gt; chain down to the database child module, which did its own &lt;code&gt;merge(var.feature_overrides, local.legacy_db_flags)&lt;/code&gt; inside &lt;code&gt;modules/services/database/locals.tf&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The two maps had a key collision. Both contained a key named &lt;code&gt;read_replica_routing&lt;/code&gt;. The new input's value was a &lt;code&gt;string&lt;/code&gt;. The legacy local's value was a &lt;code&gt;map(object({ host = string, weight = number }))&lt;/code&gt;. &lt;code&gt;merge()&lt;/code&gt; resolves collisions by taking the last argument's value, but the argument order in this case depended on which input was non-empty at apply time, and the new feature flag was only non-empty in staging.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sequenceDiagram
  participant Op as Operator
  participant Plan as terraform plan
  participant Apply as terraform apply
  participant Child as child module
  Op-&amp;gt;&amp;gt;Plan: feature_overrides (map(any))
  Plan-&amp;gt;&amp;gt;Child: merge(map(any), map(any))
  Child--&amp;gt;&amp;gt;Plan: any (type-check deferred)
  Plan--&amp;gt;&amp;gt;Op: 0 to add, 0 to change (PASS)
  Op-&amp;gt;&amp;gt;Apply: same input
  Apply-&amp;gt;&amp;gt;Child: merge resolved to concrete value
  Child--&amp;gt;&amp;gt;Apply: Error: Unsupported argument
  Apply--&amp;gt;&amp;gt;Op: FAIL at 4:42pm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;How &lt;code&gt;map(any)&lt;/code&gt; defers type-checking past plan and surfaces it at apply&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Diagram renders at the &lt;a href="https://infraforge.agency/insights/terraform-apply-fails-map-any-trap/#diagram" rel="noopener noreferrer"&gt;canonical version&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The collision had been latent for three weeks. plan succeeded because terraform's planner walked the call graph with both maps' element types collapsed to &lt;code&gt;any&lt;/code&gt;. The merged value passed type-check as &lt;code&gt;any&lt;/code&gt;, which type-checks against anything. apply, which actually constructs the resource, evaluated the merged value against the receiving attribute's concrete type signature and discovered the value was a string where an object was required.&lt;/p&gt;

&lt;p&gt;That is the part that hurts. Terraform's &lt;code&gt;any&lt;/code&gt; type defers all type-checking until apply. Every &lt;code&gt;map(any)&lt;/code&gt; input on a root module is a future apply-time failure waiting on a contributor who does not know the implicit shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three options, one open release window, seven minutes to pick
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we did before running apply again&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We had three options and one open release window. I walked the on-call lead through them on the bridge call.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1. Delete the legacy key&lt;/strong&gt;, Fastest. Also the riskiest: the legacy &lt;code&gt;read_replica_routing&lt;/code&gt; key was referenced by three modules-of-modules three layers down. Deleting it would have moved the failure from staging to production an hour later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2. Rename the new key&lt;/strong&gt;, Safe-feeling. Left the underlying &lt;code&gt;any&lt;/code&gt;-typed contract intact. Two months later a different contributor would add another &lt;code&gt;map(any)&lt;/code&gt; input and we would be back on a Friday afternoon with the same shape of failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3. Rename plus add validation&lt;/strong&gt;, Slower. Renamed the new key to &lt;code&gt;feature_routing_overrides&lt;/code&gt; AND added a &lt;code&gt;validation&lt;/code&gt; block on the input that explicitly rejected the colliding shape at plan time going forward. Stopped the immediate reoccurrence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Option three carried the day. The rename took seven minutes. The validation block took twelve. apply succeeded at 5:14pm with sixteen minutes to spare on the release window. The release shipped on time.&lt;/p&gt;

&lt;p&gt;The audit work behind option one (the one we did NOT take) is what stuck with me. The next morning, we grep-ed the entire &lt;code&gt;terraform/&lt;/code&gt; tree for &lt;code&gt;read_replica_routing&lt;/code&gt; to map every consumer. Seven references across four modules. Three in &lt;code&gt;modules/services/database/locals.tf&lt;/code&gt; itself. One in &lt;code&gt;modules/monitoring/cloudwatch.tf&lt;/code&gt;. One in &lt;code&gt;modules/services/cache/lookups.tf&lt;/code&gt;, which read the value to construct its own routing decision and would have broken silently if we had deleted the legacy key the night before. The remaining two were in a state-recovery helper module the team had forgotten existed. We had nearly fired the second shot of our own foot.&lt;/p&gt;

&lt;p&gt;We left a tombstone comment on the legacy key and an open PR that would, the following week, replace its &lt;code&gt;map(any)&lt;/code&gt; type with a proper &lt;code&gt;object({ ... })&lt;/code&gt; schema. That work landed five days later. The downstream consumers caught the change at plan time, and three of them needed minor patches before the type tightening could merge. None of those patches would have caught the original collision. They all caught real existing bugs the &lt;code&gt;any&lt;/code&gt; type had been hiding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two policy changes and one structural fix
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we changed afterwards&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two policy changes came out of that night, and one structural fix took longer.&lt;/p&gt;

&lt;p&gt;The first policy: no new &lt;code&gt;map(any)&lt;/code&gt; or &lt;code&gt;any&lt;/code&gt;-typed inputs on root modules. The team's &lt;code&gt;terraform/&lt;/code&gt; directory has a pre-commit hook (8 lines of grep) that fails the commit if any new &lt;code&gt;variable&lt;/code&gt; block contains &lt;code&gt;type = any&lt;/code&gt; or &lt;code&gt;type = map(any)&lt;/code&gt;. Existing instances are grandfathered, with a TODO list tracked against each module. Three of the original 14 have been converted to typed objects so far. The hook has fired four times in the six weeks since.&lt;/p&gt;

&lt;p&gt;The second policy: every PR runs &lt;code&gt;terraform plan&lt;/code&gt; against every environment, not just the one the contributor cares about. A matrix job in CI runs &lt;code&gt;plan -var-file=envs/&amp;lt;env&amp;gt;.tfvars&lt;/code&gt; across all four environments and fails the PR if any of them errors. This would not have caught the original collision (plan succeeded everywhere), but it catches a different class of failure where one environment's tfvars hits an unwritten code path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Before: latent any-typed input&lt;/span&gt;
&lt;span class="k"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"feature_overrides"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Per-environment feature flag overrides"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# In modules/services/database/locals.tf&lt;/span&gt;
&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;merged_flags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;legacy_db_flags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feature_overrides&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="c1"&gt;# Above passes plan even when the two maps have a key&lt;/span&gt;
&lt;span class="c1"&gt;# whose value types disagree. The mismatch surfaces only&lt;/span&gt;
&lt;span class="c1"&gt;# at apply, when the receiving attribute is evaluated.&lt;/span&gt;

&lt;span class="c1"&gt;# After: typed, explicit, errors at plan time&lt;/span&gt;
&lt;span class="k"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"feature_overrides"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;enabled&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bool&lt;/span&gt;
    &lt;span class="nx"&gt;rollout_pct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;routing&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Per-environment feature flag overrides"&lt;/span&gt;

  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alltrue&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
      &lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feature_overrides&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt;
      &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rollout_pct&lt;/span&gt; &lt;span class="err"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="err"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rollout_pct&lt;/span&gt; &lt;span class="err"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
    &lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"rollout_pct must be between 0 and 100."&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;em&gt;The same variable, before and after. The lower form fails plan, not apply, when a contributor passes the wrong shape.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The structural fix took longer. A 28-input root module is not a configuration problem, it is a service-boundary problem. The team running the database stack should own a &lt;code&gt;database/&lt;/code&gt; root module with four inputs, not a 14-input subtree of a shared 28-input root. We split the original root into three roots along ownership boundaries (network, services, observability) using a thin terragrunt overlay for the cross-cutting variables. The split took six weeks of careful state-mv work to land without downtime. We have written more on the structural fix in &lt;a href="https://dev.to/terraform-iac-debt/"&gt;the Terraform and IaC debt playbook&lt;/a&gt;, which covers when a shared root module starts costing more than the consistency it buys.&lt;/p&gt;

&lt;p&gt;What we tell every team now: strong types in Terraform are not bureaucracy, they are the documentation. The half-day cost to write &lt;code&gt;object({ name = string, enabled = bool, ... })&lt;/code&gt; instead of &lt;code&gt;map(any)&lt;/code&gt; buys you a plan-time failure instead of an apply-time failure, and apply-time failures land at 4:42pm on Fridays. We have stopped accepting &lt;code&gt;map(any)&lt;/code&gt; inputs in any client engagement that involves an IaC audit, and we have not had a single contributor push back once they saw the cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you are looking at a 28-input root with map(any) sprinkled through it
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;When your own root module is past 20 inputs&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you are reading this and your &lt;code&gt;terraform/&lt;/code&gt; directory has a root module past 20 inputs with several &lt;code&gt;map(any)&lt;/code&gt; types in the input list, the failure you are heading toward is not a surprise. It is a scheduled event. The trigger will be a new contributor who does not know the implicit contract, plus one bad-enough Friday. The hardest part of cleaning it up is not the typing work itself; it is the audit of downstream consumers that have been silently depending on the loose contract for years. Two layers of modules-of-modules can hide a reference that breaks the moment you tighten the type, and your CI will not warn you because plan will keep passing right up to the apply that surfaces it.&lt;/p&gt;

&lt;p&gt;We run these recovery and audit engagements every week. The &lt;code&gt;map(any)&lt;/code&gt; collision pattern is the third-most-common shape we see in seed-to-Series-B SaaS Terraform repos, right after stale state lock holders and provider-version-drift cascades. It is one variant of the broader &lt;a href="https://dev.to/problems/terraform-apply-fear/"&gt;terraform apply fear&lt;/a&gt; problem we engage on most weeks. On a typical engagement we map every &lt;code&gt;any&lt;/code&gt;-typed input in your root modules within the first day, prioritize them by blast radius, and either convert them in-place or split the root if the input count is the real problem. If you are looking at a Terraform root with &lt;code&gt;map(any)&lt;/code&gt; sprinkled through it and a release window that does not forgive a 4pm apply failure, &lt;a href="https://dev.to/review/"&gt;book an infrastructure review with our team&lt;/a&gt; and we will start with a 30-minute diagnostic call this week.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;a href="https://infraforge.agency/insights/terraform-apply-fails-map-any-trap/" rel="noopener noreferrer"&gt;https://infraforge.agency/insights/terraform-apply-fails-map-any-trap/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your team is dealing with similar infrastructure debt, we offer infrastructure reviews and recovery engagements — &lt;a href="https://infraforge.agency/review/" rel="noopener noreferrer"&gt;see /review&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>iac</category>
      <category>recovery</category>
      <category>terraformiacdebt</category>
    </item>
    <item>
      <title>Init container cascade when every kubectl patch reverts in 10 seconds</title>
      <dc:creator>Muhammad Hassaan Javed</dc:creator>
      <pubDate>Fri, 15 May 2026 20:15:23 +0000</pubDate>
      <link>https://dev.to/infraforge/init-container-cascade-when-every-kubectl-patch-reverts-in-10-seconds-3ibl</link>
      <guid>https://dev.to/infraforge/init-container-cascade-when-every-kubectl-patch-reverts-in-10-seconds-3ibl</guid>
      <description>&lt;p&gt;The Slack ping came in at 2:14 am. Two replicas of the fanout service were stuck in Init:1/3 and the deploy queue behind them had grown to seven changes. The on-call engineer had already tried the obvious move, kubectl edit deployment, and the changes had reverted within ten seconds. By the time we joined the bridge, they had patched the same field four times in twenty minutes and were starting to wonder if etcd was corrupted. The shape of the failure was wrong though. Init containers do not normally cascade across three different upstream dependencies at once; either something upstream was common, or the spec was being rewritten under us.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem signals:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pods stuck in Init:0/3 or Init:1/3 with no forward progress and no clear log story&lt;/li&gt;
&lt;li&gt;kubectl edit deployment changes revert within ten to fifteen seconds, every time&lt;/li&gt;
&lt;li&gt;Three init containers each failing in a different protocol layer (TCP dial timeout, NXDOMAIN, AMQP ACCESS_REFUSED)&lt;/li&gt;
&lt;li&gt;A topology or schema ConfigMap claims state that the live broker or database disagrees with&lt;/li&gt;
&lt;li&gt;No activeDeadlineSeconds set on init containers, so transient failures wedge the Pod indefinitely&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two replicas wedged, seven changes queued, four failed patches
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;The 2 am page&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When we joined the bridge, the on-call engineer had already burned forty minutes on what looked like a config drift bug. The fanout service in the platform namespace had two replicas, both stuck in Init:1/3. The init container chain had three steps (wait-for-redis, wait-for-mongodb, wait-for-rabbitmq) and the redis step was failing on a hardcoded IPv4 address that did not match the live Service. They patched the env var on the Deployment. The init container restarted. Ten seconds later the IP was back. They patched it again. Same thing.&lt;/p&gt;

&lt;p&gt;Their working hypothesis was etcd corruption or a faulty kube-apiserver caching layer. We have seen both before, but neither matches the symptom shape here. Etcd corruption surfaces as 5xx responses to kubectl, not as silent successful PATCHes that revert. We needed to find what was doing the reverting before we wasted any more time on the symptoms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two wrong guesses before the real culprit became visible
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we thought it was first&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first guess was a GitOps controller with self-heal enabled. ArgoCD does this with syncPolicy.automated.selfHeal: true. Flux does this with its Kustomization controller. Both will revert a kubectl patch within seconds if the live spec drifts from the source of truth in git. We checked the cluster for both. No Argo Application referenced the fanout namespace. Flux was not installed at all.&lt;/p&gt;

&lt;p&gt;The second guess was a mutating admission webhook. A custom webhook that rewrites init container specs at admission time could in theory produce this pattern, except admission webhooks fire on create and update, not on a ten-second timer. We ran kubectl get mutatingwebhookconfigurations and the output was empty. That ruled it out.&lt;/p&gt;

&lt;p&gt;The reverting was not coming from inside the cluster. It had to be coming from the node itself. We SSHed to the node where one of the fanout pods was scheduled and went looking. Within two minutes we had it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ssh node-01 &lt;span class="s1"&gt;'ps -ef | grep admission'&lt;/span&gt;
&lt;span class="go"&gt;root  1842  ... /usr/bin/supervisord -c /etc/supervisor/conf.d/admission.conf
root  2104  ... /bin/bash /var/lib/apex/admission.sh

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ssh node-01 &lt;span class="s1"&gt;'cat /etc/supervisor/conf.d/admission.conf'&lt;/span&gt;
&lt;span class="go"&gt;[program:admission]
command=/var/lib/apex/admission.sh
autorestart=true
startsecs=5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;A supervisord-managed script on the node was the reverter. autorestart=true meant killing it bought us at most a few seconds.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The stored ConfigMap was the source of truth, not the live Deployment
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What was actually overwriting our patches&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The script at /var/lib/apex/admission.sh ran every ten seconds. It read three fields (redis-host, mongodb-host, amqp-uri) from a ConfigMap called fanout-init-config and patched them straight into the init container env vars on the live Deployment. The ConfigMap was the source of truth. The Deployment was a downstream artifact. Patching the Deployment was about as durable as writing in pencil.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sequenceDiagram
  participant Engineer
  participant Deployment
  participant Admission as node script
  participant ConfigMap as fanout-init-config
  Engineer-&amp;gt;&amp;gt;Deployment: kubectl edit (fix redis-host)
  Deployment--&amp;gt;&amp;gt;Engineer: spec updated
  Note over Admission: tick every 10s
  Admission-&amp;gt;&amp;gt;ConfigMap: read fields
  ConfigMap--&amp;gt;&amp;gt;Admission: stale values
  Admission-&amp;gt;&amp;gt;Deployment: patch init container env
  Deployment--&amp;gt;&amp;gt;Engineer: changes reverted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The reverting loop. Edit the ConfigMap, not the Deployment.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Diagram renders at the &lt;a href="https://infraforge.agency/insights/init-container-cascade-reverting-patches/#diagram" rel="noopener noreferrer"&gt;canonical version&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This pattern shows up in places where the original GitOps story had gaps and someone wrote a node-side enforcer as a stopgap. Then the team rotated, the wiki page got out of date, and the enforcer kept running. We have seen this exact shape three times in the last year. Twice with supervisord scripts. Once with a systemd timer. The fix is always the same: find the source of truth before patching anything, and if you cannot find it in under fifteen minutes, stop and look on the nodes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each failure actually told us, and the fourth fix that did not show in any log
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Three init containers, three different protocols&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Once we knew to edit the ConfigMap, we still had three concurrent faults to diagnose. Each init container was failing in a different layer of the network stack, and each one had its own diagnostic signature.&lt;/p&gt;

&lt;p&gt;The redis init container was dialing 10.43.181.44 on port 6379 and getting i/o timeout after thirty seconds. We compared against the live Service and got back a different ClusterIP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kubectl get svc redis &lt;span class="nt"&gt;-n&lt;/span&gt; platform &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.clusterIP}'&lt;/span&gt;
&lt;span class="go"&gt;10.43.218.92

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kubectl logs fanout-7d4b9c-xx &lt;span class="nt"&gt;-c&lt;/span&gt; wait-for-redis &lt;span class="nt"&gt;-n&lt;/span&gt; platform | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-3&lt;/span&gt;
&lt;span class="go"&gt;dial tcp 10.43.181.44:6379: i/o timeout
dial tcp 10.43.181.44:6379: i/o timeout
dial tcp 10.43.181.44:6379: i/o timeout
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The hardcoded IP had no relationship to the live Service. ClusterIPs are not stable across Service recreation. Hardcoding one is a time bomb.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The mongodb init container was logging 'lookup mongo.platform.svc.cluster.local on 10.43.0.10:53: no such host'. The live Service was named mongodb, not mongo. One character off, NXDOMAIN. We caught it by running kubectl get svc -n platform and reading the actual Service name out loud. The hostname in the ConfigMap had been typed from memory by someone who remembered the team's old naming convention.&lt;/p&gt;

&lt;p&gt;The rabbitmq init container was the most interesting of the three. The TCP connection succeeded. The AMQP frame negotiation succeeded. Authentication succeeded. The vhost open returned ACCESS_REFUSED. The URI was amqp://app:app@rabbitmq:5672/fanout-internal. We port-forwarded to the management API and listed valid vhosts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kubectl port-forward &lt;span class="nt"&gt;-n&lt;/span&gt; platform svc/rabbitmq 15672:15672 &amp;amp;
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; app:app http://localhost:15672/api/vhosts | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[].name'&lt;/span&gt;
&lt;span class="go"&gt;/
/platform

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;fanout-internal does not exist on this broker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The URI parsed cleanly and authenticated cleanly. The failure was at vhost open. Always enumerate vhosts before assuming auth or credentials.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There was a fourth fix that did not show up in any log. None of the init containers had activeDeadlineSeconds set, and neither did the Pod spec. Even after the three protocol bugs were resolved, a transient DNS hiccup or broker restart would have hung an init container indefinitely instead of failing fast and letting the kubelet retry the Pod. We added activeDeadlineSeconds: 120 on every init container and 600 at the Pod level. Defense in depth, because init container deadlines do not always catch the case where the kubelet keeps reconciling a stuck container.&lt;/p&gt;

&lt;h2&gt;
  
  
  A second ConfigMap with the same shape, intentionally broken, was a load-bearing canary
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;The look-alike ConfigMap we almost broke&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Before we patched fanout-init-config, we almost made one more mistake. There was a second ConfigMap in the same namespace called fanout-init-config-canary. Same shape, same broken-looking IP, same broken-looking AMQP URI. It was labeled role: protected and annotated with purpose: chaos-canary. A drift-detection job in the cluster read it every fifteen minutes to confirm its own detection logic still fired on broken inputs. If we had run a sed-style global replace across all matching ConfigMaps (which is exactly what a tired engineer at 3 am tends to do) we would have silenced the canary and the team would have learned about the next round of real drift only when a customer noticed.&lt;/p&gt;

&lt;p&gt;When you patch infrastructure under pressure, target the named resource, not the pattern. Read the labels and annotations of every resource you are about to touch. A surprising number of clusters have load-bearing decoys you do not know about until you break them. We have written more on this in &lt;a href="https://dev.to/kubernetes-cicd/"&gt;the Kubernetes and CI/CD stabilization pillar&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source-of-truth guard, deadline defense, a validation Job, and convergence checks
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;What we changed afterwards&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The fanout service was the visible failure, but the recovery exposed five underlying gaps in the team's release flow. We left four durable changes in place before disconnecting from the bridge.&lt;/p&gt;

&lt;p&gt;The fanout-init-config ConfigMap is now committed in git and synced via a real GitOps controller, and the node-side admission script was rewritten to refuse to overwrite a Deployment if the ConfigMap's content hash does not match a known-good baseline annotation. The script can still enforce, but it cannot enforce a broken state.&lt;/p&gt;

&lt;p&gt;Every Deployment in the platform namespace now has activeDeadlineSeconds set at both the init container level (120 seconds) and the Pod level (600 seconds). The pair matters. Init container deadlines fail-fast the individual container; the Pod-level deadline prevents the kubelet from looping retries on a Pod that is structurally wrong.&lt;/p&gt;

&lt;p&gt;A pre-deployment validation Job runs as part of the release flow. It carries label validation: predeploy, restartPolicy: OnFailure, activeDeadlineSeconds: 120, and a validator that does three real checks: redis, mongodb, and rabbitmq Services each have non-empty Endpoints, AND the broker reports every binding the topology ConfigMap claims to have declared. Topology drift was the other half of this incident; the binding count had silently dropped from five to three after a partial migration three weeks earlier, and nobody had noticed because the topology-version annotation still said 5.&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;# Snippet from the topology-reconcile Job that fixed the broker drift&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;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;topology-reconcile-2026-05-15&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;validation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;predeploy&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;activeDeadlineSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;120&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;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;reconcile&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;rabbitmq:3.13-management&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;/bin/bash"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&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="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;set -euo pipefail&lt;/span&gt;
            &lt;span class="s"&gt;EXPECTED=$(yq '.bindings | length' /config/topology.yaml)&lt;/span&gt;
            &lt;span class="s"&gt;for b in $(yq -o=json '.bindings[]' /config/topology.yaml | jq -c .); do&lt;/span&gt;
              &lt;span class="s"&gt;EX=$(echo $b | jq -r .exchange)&lt;/span&gt;
              &lt;span class="s"&gt;QU=$(echo $b | jq -r .queue)&lt;/span&gt;
              &lt;span class="s"&gt;RK=$(echo $b | jq -r ."routing-key")&lt;/span&gt;
              &lt;span class="s"&gt;rabbitmqadmin declare binding source=$EX destination=$QU routing_key=$RK&lt;/span&gt;
            &lt;span class="s"&gt;done&lt;/span&gt;
            &lt;span class="s"&gt;ACTUAL=$(curl -s -u $USER:$PASS http://rabbitmq:15672/api/bindings | jq 'length')&lt;/span&gt;
            &lt;span class="s"&gt;[ "$ACTUAL" -ge "$EXPECTED" ] || exit 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Reconcile via Job, not via kubectl exec. The Job is observable, retryable, and leaves an audit record.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The team's rollback runbook now requires two consecutive green health observations twenty seconds apart before a rollout is declared finished. Single-shot green is not enough on a cluster that has a ten-second admission tick, because you can catch the Pod between reverts and declare victory ninety seconds before the next failure cascade. We learned to distrust single-shot green the hard way on a different engagement, and that is now the default in every recovery handover we ship.&lt;/p&gt;

&lt;p&gt;If you are looking at a cluster where every patch reverts within seconds, do not patch faster. Stop patching and find what is doing the reverting. The fix itself is usually ten minutes once you know where the source of truth lives. Finding the source of truth is what takes the hour. If you want a second pair of eyes on a system that is in this state, &lt;a href="https://dev.to/review/"&gt;request an infrastructure review&lt;/a&gt; and we will be on a bridge with you the same day.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;a href="https://infraforge.agency/insights/init-container-cascade-reverting-patches/" rel="noopener noreferrer"&gt;https://infraforge.agency/insights/init-container-cascade-reverting-patches/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your team is dealing with similar infrastructure debt, we offer infrastructure reviews and recovery engagements — &lt;a href="https://infraforge.agency/review/" rel="noopener noreferrer"&gt;see /review&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>recovery</category>
      <category>kubernetescicd</category>
    </item>
  </channel>
</rss>
