<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Terraform on Tarragon</title><link>https://tarrragon.github.io/blog/tags/terraform/</link><description>Recent content in Terraform on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 26 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/terraform/index.xml" rel="self" type="application/rss+xml"/><item><title>IaC 工具選型與 state 地基</title><link>https://tarrragon.github.io/blog/infra/01-minimal-iac/iac-tool-state-backend/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/01-minimal-iac/iac-tool-state-backend/</guid><description>&lt;h2 id="動手前的前提">動手前的前提&lt;/h2>
&lt;p>以下步驟是寫第一行 IaC 之前需要就位的前置條件。如果已經備妥可以跳過。如果是第一次接觸雲端帳號，先讀&lt;a href="https://tarrragon.github.io/blog/infra/00-infra-mindset/first-day-with-cloud-account/" data-link-title="拿到雲端帳號的第一天" data-link-desc="被指派 infra 工作、拿到 AWS 或 GCP 帳號、不確定該先做什麼時讀 — 第一小時安全底線、帳號現況判讀、後續學習路線分流">拿到雲端帳號的第一天&lt;/a>做安全底線和帳號現況判讀。&lt;/p>
&lt;p>&lt;strong>雲端帳號&lt;/strong>。需要一個 AWS 帳號（或 GCP / Azure，本模組以 AWS 為主要範例）。註冊完成後立刻對 root 帳號啟用 MFA（Multi-Factor Authentication）——root 帳號是整個雲端環境的最高權限，沒有 MFA 等於大門沒鎖。啟用路徑：AWS Console → 右上角帳號名稱 → Security credentials → Multi-factor authentication (MFA) → Assign MFA device。日常操作用 IAM user 或 IAM Identity Center 登入，root 帳號只在需要 root-only 操作時使用。&lt;/p>
&lt;p>&lt;strong>本機工具&lt;/strong>。安裝 IaC CLI（Terraform 或 OpenTofu）和雲端 CLI（AWS CLI）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># macOS&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">brew install opentofu awscli
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># Arch Linux（opentofu 和 aws-cli-v2 在 AUR，需要 AUR helper）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">yay -S opentofu-bin aws-cli-v2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1"># 驗證安裝&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">tofu --version
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">aws --version&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>雲端認證&lt;/strong>。本機需要能對雲端 API 認證。最直接的方式是用 AWS CLI 設定 credentials：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">aws configure
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1"># 輸入 Access Key ID、Secret Access Key、預設 region（如 ap-northeast-1）&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這組 access key 來自 IAM user。如果帳號裡還沒有 IAM user，到 AWS Console → IAM → Users 建立一個、附加 &lt;code>AdministratorAccess&lt;/code> policy、在 Security credentials 分頁建立 access key。正式環境應該用 SSO 或 short-lived credentials 取代長期 key（&lt;a href="https://tarrragon.github.io/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二&lt;/a>會展開），但起步階段一組 IAM user key 足以讓 &lt;code>tofu apply&lt;/code> 跑起來。&lt;/p>
&lt;p>&lt;strong>Git repo&lt;/strong>。IaC 程式碼從 day 1 就應該在版本控制裡——這是&lt;a href="https://tarrragon.github.io/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">模組零&lt;/a>「可重建路徑」的落地前提。建一個 Git repo，後續所有 &lt;code>.tf&lt;/code> 檔都放在這裡：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">mkdir infra &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nb">cd&lt;/span> infra
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">git init
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nb">echo&lt;/span> &lt;span class="s1">&amp;#39;.terraform/&amp;#39;&lt;/span> &amp;gt; .gitignore
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="nb">echo&lt;/span> &lt;span class="s1">&amp;#39;*.tfstate&amp;#39;&lt;/span> &amp;gt;&amp;gt; .gitignore
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="nb">echo&lt;/span> &lt;span class="s1">&amp;#39;*.tfstate.*&amp;#39;&lt;/span> &amp;gt;&amp;gt; .gitignore
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">git add .gitignore &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> git commit -m &lt;span class="s2">&amp;#34;init: gitignore for terraform&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>.gitignore&lt;/code> 排除 &lt;code>.terraform/&lt;/code>（provider 快取）和 &lt;code>*.tfstate&lt;/code>（state 檔含敏感值，存放策略見下方 remote state 段落）。&lt;/p>
&lt;hr>
&lt;p>踏上&lt;a href="https://tarrragon.github.io/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">成熟度階梯&lt;/a>（從全手動到全程式碼治理的五階分級）第二階（宣告式 IaC，也就是 state 檔誕生那一階）的最小路徑，從兩件事開始：選對工具、把 state 管好。工具決定用什麼語言描述基礎設施，state 則是工具對雲端現實的唯一記憶。這份記憶存在哪、怎麼保護、怎麼防止並行寫壞，是整套 IaC 能不能站穩的地基。&lt;/p></description><content:encoded><![CDATA[<h2 id="動手前的前提">動手前的前提</h2>
<p>以下步驟是寫第一行 IaC 之前需要就位的前置條件。如果已經備妥可以跳過。如果是第一次接觸雲端帳號，先讀<a href="/blog/infra/00-infra-mindset/first-day-with-cloud-account/" data-link-title="拿到雲端帳號的第一天" data-link-desc="被指派 infra 工作、拿到 AWS 或 GCP 帳號、不確定該先做什麼時讀 — 第一小時安全底線、帳號現況判讀、後續學習路線分流">拿到雲端帳號的第一天</a>做安全底線和帳號現況判讀。</p>
<p><strong>雲端帳號</strong>。需要一個 AWS 帳號（或 GCP / Azure，本模組以 AWS 為主要範例）。註冊完成後立刻對 root 帳號啟用 MFA（Multi-Factor Authentication）——root 帳號是整個雲端環境的最高權限，沒有 MFA 等於大門沒鎖。啟用路徑：AWS Console → 右上角帳號名稱 → Security credentials → Multi-factor authentication (MFA) → Assign MFA device。日常操作用 IAM user 或 IAM Identity Center 登入，root 帳號只在需要 root-only 操作時使用。</p>
<p><strong>本機工具</strong>。安裝 IaC CLI（Terraform 或 OpenTofu）和雲端 CLI（AWS CLI）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># macOS</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">brew install opentofu awscli
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Arch Linux（opentofu 和 aws-cli-v2 在 AUR，需要 AUR helper）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">yay -S opentofu-bin aws-cli-v2
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># 驗證安裝</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">tofu --version
</span></span><span class="line"><span class="ln">9</span><span class="cl">aws --version</span></span></code></pre></div><p><strong>雲端認證</strong>。本機需要能對雲端 API 認證。最直接的方式是用 AWS CLI 設定 credentials：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws configure
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 輸入 Access Key ID、Secret Access Key、預設 region（如 ap-northeast-1）</span></span></span></code></pre></div><p>這組 access key 來自 IAM user。如果帳號裡還沒有 IAM user，到 AWS Console → IAM → Users 建立一個、附加 <code>AdministratorAccess</code> policy、在 Security credentials 分頁建立 access key。正式環境應該用 SSO 或 short-lived credentials 取代長期 key（<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二</a>會展開），但起步階段一組 IAM user key 足以讓 <code>tofu apply</code> 跑起來。</p>
<p><strong>Git repo</strong>。IaC 程式碼從 day 1 就應該在版本控制裡——這是<a href="/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">模組零</a>「可重建路徑」的落地前提。建一個 Git repo，後續所有 <code>.tf</code> 檔都放在這裡：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">mkdir infra <span class="o">&amp;&amp;</span> <span class="nb">cd</span> infra
</span></span><span class="line"><span class="ln">2</span><span class="cl">git init
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;.terraform/&#39;</span> &gt; .gitignore
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;*.tfstate&#39;</span>  &gt;&gt; .gitignore
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;*.tfstate.*&#39;</span> &gt;&gt; .gitignore
</span></span><span class="line"><span class="ln">6</span><span class="cl">git add .gitignore <span class="o">&amp;&amp;</span> git commit -m <span class="s2">&#34;init: gitignore for terraform&#34;</span></span></span></code></pre></div><p><code>.gitignore</code> 排除 <code>.terraform/</code>（provider 快取）和 <code>*.tfstate</code>（state 檔含敏感值，存放策略見下方 remote state 段落）。</p>
<hr>
<p>踏上<a href="/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">成熟度階梯</a>（從全手動到全程式碼治理的五階分級）第二階（宣告式 IaC，也就是 state 檔誕生那一階）的最小路徑，從兩件事開始：選對工具、把 state 管好。工具決定用什麼語言描述基礎設施，state 則是工具對雲端現實的唯一記憶。這份記憶存在哪、怎麼保護、怎麼防止並行寫壞，是整套 IaC 能不能站穩的地基。</p>
<h2 id="iac-工具選型宣告式狀態管理-vs-程式語言抽象">IaC 工具選型：宣告式狀態管理 vs 程式語言抽象</h2>
<p>IaC 工具的核心職責是把「我要的基礎設施長什麼樣」描述成可版本控制的程式碼，再由工具負責算出現況與目標的差異並收斂。市場上的工具分成兩條路線，差別落在「用什麼語言描述」與「狀態由誰持有」這兩個軸上，而非功能多寡。</p>
<h3 id="宣告式-dsl-路線">宣告式 DSL 路線</h3>
<p>第一條路線的代表是 Terraform 與其開源分支 OpenTofu。寫的是 HCL（HashiCorp Configuration Language），描述的是資源的最終樣貌，工具自己維護一份 state 來追蹤每個資源的真實 ID 與屬性。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_s3_bucket&#34; &#34;artifacts&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  bucket</span> <span class="o">=</span> <span class="s2">&#34;acme-deploy-artifacts&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">}
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_s3_bucket_versioning&#34; &#34;artifacts&#34;</span> {
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  bucket</span> <span class="o">=</span> <span class="k">aws_s3_bucket</span><span class="p">.</span><span class="k">artifacts</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="k">versioning_configuration</span> {
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">    status</span> <span class="o">=</span> <span class="s2">&#34;Enabled&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  }
</span></span><span class="line"><span class="ln">10</span><span class="cl">}</span></span></code></pre></div><p>這段 HCL 描述的是「一個開了 versioning 的 S3 bucket 應該存在」。第一次 apply 時工具建立它，之後每次 apply 時工具比對 state 與雲端現況，只做差異收斂。讀的人看 HCL 就知道最終結果長什麼樣，不需要在腦中追蹤執行順序。</p>
<p>這條路線適合團隊成員背景混雜、需要讓非專職後端的人也能讀懂 infra 定義的情境 — HCL 的閱讀門檻低，diff 直觀，review 時看得出「這個 PR 會新增一個 RDS、改掉一條 security group」。缺點是 HCL 的表達力有限：遇到需要大量條件分支或動態生成的場景時，語法會變得笨拙，<code>count</code>、<code>for_each</code>、<code>dynamic</code> 區塊很快就堆出難以閱讀的嵌套。</p>
<h3 id="程式語言路線">程式語言路線</h3>
<p>第二條路線的代表是 AWS CDK 與 Pulumi。寫的是 TypeScript、Python、Go 這類語言，靠迴圈、函式、類別來生成資源。這條路線適合 infra 邏輯本身複雜、需要大量條件分支與抽象複用的團隊，例如要根據環境清單動態生成數十組對稱資源。</p>
<p>代價是 review 難度上升。一段 <code>for</code> 迴圈展開後到底建了哪些東西，得在腦中執行程式才看得出來，diff 不再等於變更本身。一個抽象類別改了一行建構子參數，展開後可能影響所有繼承它的資源，而 PR diff 上只看到那一行。對跨職能 review（PM、SRE、安全團隊都要看的變更）來說，這是可感知的閱讀成本。</p>
<h3 id="cdk-vs-pulumi狀態由誰持有">CDK vs Pulumi：狀態由誰持有</h3>
<p>CDK 與 Pulumi 同屬程式語言路線，但「狀態由誰持有」這個軸把它們再分開。</p>
<p>CDK 把程式碼 synth 成 CloudFormation 模板，再交給 CloudFormation 服務端執行與追蹤。state 由 AWS 代管 — 沒有一份 tfstate 檔要自己存放、加密、回捲，也不需要額外的鎖表來防並行。這份「狀態維運外包給雲端」正是 CDK 在 AWS 生態內的賣點之一。代價是綁定 CloudFormation 與單一雲 — CloudFormation 的更新速度、resource coverage、錯誤訊息品質都由 AWS 控制，團隊的 debug 能力受限於 CloudFormation 的回報粒度。</p>
<p>Pulumi 走另一邊：它維護一份自己的 state，預設交給 Pulumi Cloud 託管，也能改用 S3 之類的後端自管。形態上更接近 Terraform 的 state 模型，state 的存放、保護與並行控制重回團隊手上。同一條程式語言路線，選 CDK 等於把 state 責任讓給雲端，選 Pulumi 則保留對 state 落點的掌控。</p>
<h3 id="選型判準">選型判準</h3>
<p>選型看的是團隊組成與變更的審查需求，可以用一張決策表歸納：</p>
<table>
  <thead>
      <tr>
          <th>判準</th>
          <th>宣告式 DSL（Terraform / OpenTofu）</th>
          <th>程式語言（CDK / Pulumi）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>diff 可讀性</td>
          <td>HCL diff 即是資源變更</td>
          <td>程式碼 diff，要展開才知道結果</td>
      </tr>
      <tr>
          <td>跨職能 review</td>
          <td>適合</td>
          <td>需要讀者熟悉程式語言</td>
      </tr>
      <tr>
          <td>抽象複用</td>
          <td>有限，靠 module + for_each</td>
          <td>完整程式語言能力</td>
      </tr>
      <tr>
          <td>state 管理</td>
          <td>自管或託管皆可</td>
          <td>CDK 交 AWS；Pulumi 自管或託管</td>
      </tr>
      <tr>
          <td>跨雲</td>
          <td>provider 生態支援多雲</td>
          <td>CDK 限 AWS；Pulumi 支援多雲</td>
      </tr>
      <tr>
          <td>學習曲線</td>
          <td>HCL 語法簡單，概念模型需適應</td>
          <td>語言本身熟悉，IaC 概念需適應</td>
      </tr>
  </tbody>
</table>
<p>若多數變更要跨職能 review、希望 diff 一眼可讀，宣告式 DSL 較划算；若 infra 由專職平台團隊維護、抽象複用的收益大於審查透明度的損失，程式語言路線較划算。</p>
<p>Terraform 與 OpenTofu 之間，OpenTofu 是授權變更後社群分叉出的相容實作，HCL 與 provider 生態幾乎共用；選擇主要看對授權條款與治理模式的偏好，技術判準在這一階沒有實質差異。本模組後續一律以 HCL 示意，換成任一宣告式工具判準仍成立。</p>
<p>上述兩條路線之外，還有兩類工具走不同的運作模型。Kubernetes-native 路線（代表是 Crossplane）用 CRD 描述雲資源、由 controller 持續收斂，state 由 Kubernetes 的 etcd 持有，適合已經重度投入 Kubernetes 的團隊。Serverless-first 框架（代表是 SST）把部署與 IaC 合一，適合全 serverless 架構。這兩條路線的 state 模型與 CLI 驅動的 plan/apply 流程不同，本系列不展開。</p>
<h2 id="state-是工具對現實的唯一記憶">state 是工具對現實的唯一記憶</h2>
<p>state 是 IaC 工具用來記錄「上一次 apply 之後，每個資源在雲端真實長什麼樣」的快照。它的作用是讓工具能算出「現況」與「目標」之間的最小差異。沒有 state，工具每次都得把所有資源重新查一遍才知道該不該動，而且無法分辨「這個資源是我建的、該由我管」還是「別人手動建的、不歸我管」。</p>
<p>一份 state 的實際內容大致長這樣（簡化版）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nt">&#34;resources&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">      <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;aws_s3_bucket&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;artifacts&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">      <span class="nt">&#34;instances&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">          <span class="nt">&#34;attributes&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;acme-deploy-artifacts&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="nt">&#34;arn&#34;</span><span class="p">:</span> <span class="s2">&#34;arn:aws:s3:::acme-deploy-artifacts&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="nt">&#34;bucket&#34;</span><span class="p">:</span> <span class="s2">&#34;acme-deploy-artifacts&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="nt">&#34;tags&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;env&#34;</span><span class="p">:</span> <span class="s2">&#34;prod&#34;</span><span class="p">,</span> <span class="nt">&#34;owner&#34;</span><span class="p">:</span> <span class="s2">&#34;platform&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">          <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">      <span class="p">]</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>state 裡通常含有資源的真實 ID、相依關係，以及部分敏感屬性 — 例如資料庫的初始密碼、private key 的輸出值、加密金鑰的 ARN。這帶來兩條硬邊界，違反任一條都會在未來製造代價高昂的事故。</p>
<h3 id="state-絕不能進-git">state 絕不能進 git</h3>
<p>state 含明文敏感值，一旦推進版控就等於把密碼寫進每個 clone 的歷史裡。事後 rotate 密碼也清不掉 git 歷史 — 因為 git 是 append-only 的，舊版本的 state 永遠留在 commit 裡，除非用 <code>git filter-branch</code> 或 <code>git filter-repo</code> 重寫整條歷史（這本身是一個破壞性操作，會影響所有已經 clone 的副本）。</p>
<p>在 <code>.gitignore</code> 裡搜尋 <code>*.tfstate</code> 和 <code>*.tfstate.backup</code>——如果這兩行不在，state 有進版控的風險。在 repo 根目錄執行一次搜索確認：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git log --all --diff-filter<span class="o">=</span>A -- <span class="s1">&#39;*.tfstate&#39;</span></span></span></code></pre></div><p>如果有任何結果，代表 state 曾經被 commit 過，那些 commit 裡的敏感值已經暴露。</p>
<h3 id="state-不能只放本地">state 不能只放本地</h3>
<p>本地 state 的失敗模式是它把整份基礎設施的記憶綁在一台筆電上 — 換人接手、換台機器、或多人同時 apply 時，記憶就分裂了。</p>
<p>具體場景：工程師 A 在自己的筆電 apply 了一次，state 記住「已經建了 3 個 security group」。工程師 B 在另一台筆電上拉了同一份 code，但她的本地沒有 state（或有一份過時的 state），apply 時工具以為那 3 個 security group 不存在，又建了 3 個重複的。更糟的場景是 B 的 state 比 A 舊，工具對比後認為 A 後來新增的 security group「不在記憶裡、是多餘的」，於是 apply 時把它們刪掉 — 而 A 還以為那些規則還在保護服務。</p>
<p>這兩條邊界共同指向同一個結論：state 需要一個團隊共享、有版本、有存取控制、且能防止同時寫入的存放處。這就是 remote state backend 要解的問題。</p>
<h2 id="remote-state-backend自管-vs-託管">remote state backend：自管 vs 託管</h2>
<p>remote state backend 是把 state 從本地移到團隊共享儲存的機制，它要同時滿足三件事：持久保存、防止並行寫入衝突、以及保護敏感內容。達成方式分成自管儲存與託管服務兩種，差別在維運責任落在誰身上。</p>
<h3 id="自管-backend">自管 backend</h3>
<p>自管路線以雲端物件儲存加鎖機制為典型組合。以 AWS 為例，state 檔放 S3、用一張 DynamoDB 鎖表防止兩個人同時 apply：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">terraform</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">backend</span> <span class="s2">&#34;s3&#34;</span> {
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">    bucket</span>         <span class="o">=</span> <span class="s2">&#34;acme-tf-state&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">    key</span>            <span class="o">=</span> <span class="s2">&#34;prod/network/terraform.tfstate&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">    region</span>         <span class="o">=</span> <span class="s2">&#34;ap-northeast-1&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">    encrypt</span>        <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="n">    dynamodb_table</span> <span class="o">=</span> <span class="s2">&#34;acme-tf-lock&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  }
</span></span><span class="line"><span class="ln">9</span><span class="cl">}</span></span></code></pre></div><p>這段設定的每一項都對應前一節的一條邊界：</p>
<p><strong><code>encrypt = true</code></strong> 讓 state 在 S3 落地時加密，回應「state 含敏感值」的風險。加密用的是 S3 的 server-side encryption，搭配 KMS key 可以進一步控制誰能解密。</p>
<p><strong>bucket versioning</strong> 是這段設定裡沒有出現、但在建立 bucket 時就該開的屬性。apply 寫壞或誤刪 state 時，versioning 是把記憶回捲到上一個正確版本的唯一退路。沒開的話一次壞寫就讓工具失去對現實的記憶，而回復的唯一方式是從雲端逐個資源重新 import。建立 state bucket 的 HCL 應該同時開 versioning 與刪除保護：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_s3_bucket_versioning&#34; &#34;state&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  bucket</span> <span class="o">=</span> <span class="k">aws_s3_bucket</span><span class="p">.</span><span class="k">tf_state</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">versioning_configuration</span> {
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">    status</span> <span class="o">=</span> <span class="s2">&#34;Enabled&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  }
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">}
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_s3_bucket_lifecycle_configuration&#34; &#34;state&#34;</span> {
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  bucket</span> <span class="o">=</span> <span class="k">aws_s3_bucket</span><span class="p">.</span><span class="k">tf_state</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="k">rule</span> {
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">    id</span>     <span class="o">=</span> <span class="s2">&#34;retain-old-versions&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">    status</span> <span class="o">=</span> <span class="s2">&#34;Enabled&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">noncurrent_version_expiration</span> {
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="n">      noncurrent_days</span> <span class="o">=</span> <span class="m">90</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    }
</span></span><span class="line"><span class="ln">18</span><span class="cl">  }
</span></span><span class="line"><span class="ln">19</span><span class="cl">}</span></span></code></pre></div><p>舊版本的保留天數是成本與安全的取捨。90 天足以涵蓋大多數「發現 state 壞了再回去找正確版本」的時間差 — 超過 90 天才發現的 state 問題通常已經被後續 apply 覆蓋，回捲到更早的版本反而引入更大的落差。</p>
<p><strong><code>dynamodb_table</code></strong> 指向一張鎖表。apply 開始時寫入一筆鎖、結束才釋放，第二個人同時跑就會被擋下並提示鎖被誰持有。這正是本地 state 無法提供、卻是多人協作底線的並行保護。鎖表本身的建立只需要幾行 HCL：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_dynamodb_table&#34; &#34;tf_lock&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  name</span>         <span class="o">=</span> <span class="s2">&#34;acme-tf-lock&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  billing_mode</span> <span class="o">=</span> <span class="s2">&#34;PAY_PER_REQUEST&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  hash_key</span>     <span class="o">=</span> <span class="s2">&#34;LockID&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="k">attribute</span> {
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">    name</span> <span class="o">=</span> <span class="s2">&#34;LockID&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">    type</span> <span class="o">=</span> <span class="s2">&#34;S&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  }
</span></span><span class="line"><span class="ln">10</span><span class="cl">}</span></span></code></pre></div><p>鎖表用 PAY_PER_REQUEST 模式足夠，因為它的讀寫頻率很低（只在 apply 開始和結束時各一次）。鎖卡住時（apply 中途失敗、沒有正常釋放鎖），用 <code>terraform force-unlock &lt;lock-id&gt;</code> 手動釋放，但前提是確認沒有其他 apply 正在執行。</p>
<p><strong><code>key</code></strong> 是 state 在 bucket 內的路徑，這裡先用 <code>prod/network</code> 之類的分層命名。實際怎麼依環境切分 state 留待<a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>展開。</p>
<h3 id="自管-backend-的雞生蛋問題">自管 backend 的雞生蛋問題</h3>
<p>自管 backend 有一個啟動悖論：state bucket 和 lock table 本身也是雲端資源，它們該由誰來管理？用 Terraform 管理 Terraform 的 backend？</p>
<p>務實的做法是接受這個循環：用一份獨立的、最小化的 Terraform code 來建立 state bucket 和 lock table，這份 code 用 local state（因為它只在啟動那一次跑）。建立完成後，所有後續的 Terraform code 都指向這個 remote backend。這份啟動 code 的 local state 可以 commit 進 repo（它不含敏感值，只有 bucket 和 DynamoDB table 的 ID），或直接在跑完後丟棄 — 因為這些資源如果需要重建，幾行 CLI 就能做到。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># bootstrap/main.tf — 只用一次，建立 state 基礎設施
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">terraform</span> {<span class="c1">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1">  # 刻意用 local state，因為 remote backend 還不存在
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>}
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_s3_bucket&#34; &#34;tf_state&#34;</span> {
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">  bucket</span> <span class="o">=</span> <span class="s2">&#34;acme-tf-state&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">}
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="err">#</span> <span class="p">...</span> <span class="k">versioning</span><span class="p">,</span> <span class="k">encryption</span><span class="p">,</span> <span class="k">lock</span> <span class="k">table</span></span></span></code></pre></div><h3 id="託管-backend">託管 backend</h3>
<p>託管路線把上述維運細節包起來，由 Terraform Cloud、Spacelift、env0 這類平台代管 state、鎖與加密，附帶 web UI 與 audit log。</p>
<p>判讀訊號是團隊規模與維運餘裕。自管 backend 的成本是要自己把 bucket versioning、加密、鎖表、IAM 權限配對，配錯任何一項都可能讓 state 失去保護 — 例如忘了開 versioning，一次壞寫就回不去。託管服務用月費換掉這份配置與維運負擔，代價是 state 託付給第三方、且進階治理功能常綁在付費級距。</p>
<p>小團隊起步、不想第一週就花在配 backend 上，託管較划算。對 state 存放位置有合規或主權要求、或希望基礎設施盡量自持的團隊，自管較划算。託管服務（Terraform Cloud / Spacelift）的免費方案涵蓋基本功能，付費級距約 $20-70/user/月；自管 backend 的成本是初次配置半天到一天的工程師時間，加上持續的 IAM 權限與 versioning 維護。</p>
<p>導入時程參考：最小可行 IaC（state backend + 第一批地基資源）的導入約需 2-3 天工程師時間。第一個可見里程碑是「一條指令能在新帳號重建整個地基環境」。之後每批服務的納管約 1-2 天/批，依資源複雜度而定。</p>
<p>State 地基設好後，下一步是立 Console 唯讀鐵律、並用最小可行資源集合驗證整條鏈路，見<a href="/blog/infra/01-minimal-iac/console-readonly-minimal-viable/" data-link-title="Console 唯讀鐵律與最小可行資源集合" data-link-desc="Console 只用來看不用來改的操作紀律、drift 的延遲浮現與偵測，以及能跑出第一個完整 apply 迴路的最小資源集合">Console 唯讀鐵律與最小可行資源集合</a>。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/01-minimal-iac/console-readonly-minimal-viable/" data-link-title="Console 唯讀鐵律與最小可行資源集合" data-link-desc="Console 只用來看不用來改的操作紀律、drift 的延遲浮現與偵測，以及能跑出第一個完整 apply 迴路的最小資源集合">Console 唯讀鐵律與最小可行資源集合</a>：state 管好之後，Console 唯讀紀律與最小 apply 閉環</li>
<li>→ <a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>：Console 唯讀鐵律靠權限落地</li>
<li>→ <a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>：state 的 key 怎麼依環境切分</li>
</ul>
]]></content:encoded></item><item><title>模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律</title><link>https://tarrragon.github.io/blog/infra/01-minimal-iac/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/01-minimal-iac/</guid><description>&lt;p>踏上成熟度階梯第二階（宣告式 IaC，也就是 state 檔誕生那一階，見模組零：infra 是什麼）的最小路徑，只做兩件具體的事：把 state 管好，並立下所有資源都走程式碼的鐵律。這兩件事決定了往後每一階的地基穩不穩 — state 是 IaC 工具對現實的唯一記憶，Console 唯讀鐵律則保證這份記憶不會在背後被偷偷改掉。其餘的網路、身分、服務都還沒上場，先把這兩件事釘死，後面的擴張才有可重現的起點。&lt;/p>
&lt;h2 id="iac-工具選型宣告式狀態管理-vs-程式語言抽象">IaC 工具選型：宣告式狀態管理 vs 程式語言抽象&lt;/h2>
&lt;p>IaC 工具的核心職責是把「我要的基礎設施長什麼樣」描述成可版本控制的程式碼，再由工具負責算出現況與目標的差異並收斂。市場上的工具分成兩條路線，差別落在「用什麼語言描述」與「狀態由誰持有」這兩個軸上，而非功能多寡。&lt;/p>
&lt;p>第一條路線是宣告式 DSL，代表是 Terraform 與其開源分支 OpenTofu。寫的是 HCL，描述的是資源的最終樣貌，工具自己維護一份 state 來追蹤每個資源的真實 ID 與屬性。這條路線適合團隊成員背景混雜、需要讓非專職後端的人也能讀懂 infra 定義的情境 — HCL 的閱讀門檻低，diff 直觀，review 時看得出「這個 PR 會新增一個 RDS、改掉一條 security group」。&lt;/p>
&lt;p>第二條路線是用通用程式語言寫 infra，代表是 AWS CDK 與 Pulumi。寫的是 TypeScript、Python、Go 這類語言，靠迴圈、函式、類別來生成資源。這條路線適合 infra 邏輯本身複雜、需要大量條件分支與抽象複用的團隊，例如要根據環境清單動態生成數十組對稱資源。代價是 review 難度上升：一段 &lt;code>for&lt;/code> 迴圈展開後到底建了哪些東西，得在腦中執行程式才看得出來，diff 不再等於變更本身。&lt;/p>
&lt;p>CDK 與 Pulumi 同屬程式語言路線，但「狀態由誰持有」這個軸把它們再分開。CDK 把程式碼 synth 成 CloudFormation 模板，再交給 CloudFormation 服務端執行與追蹤，state 由 AWS 代管 — 沒有一份 tfstate 檔要自己存放、加密、回捲，也不需要額外的鎖表來防並行，這份「狀態維運外包給雲端」正是 CDK 在 AWS 生態內的賣點之一，代價是綁定 CloudFormation 與單一雲。Pulumi 走的是另一邊：它維護一份自己的 state，預設交給 Pulumi Cloud 託管，也能改用 S3 之類的後端自管 — 形態上更接近 Terraform 的 state 模型，state 的存放、保護與並行控制重回團隊手上。同一條程式語言路線，選 CDK 等於把 state 責任讓給雲端，選 Pulumi 則保留對 state 落點的掌控。&lt;/p>
&lt;p>選型看的是團隊組成與變更的審查需求。若多數變更要跨職能 review、希望 diff 一眼可讀，宣告式 DSL 較划算；若 infra 由專職平台團隊維護、抽象複用的收益大於審查透明度的損失，程式語言路線較划算。Terraform 與 OpenTofu 之間，OpenTofu 是授權變更後社群分叉出的相容實作，HCL 與 provider 生態幾乎共用；選擇主要看對授權條款與治理模式的偏好，技術判準在這一階沒有實質差異。本模組後續一律以 HCL 示意，換成任一宣告式工具判準仍成立。&lt;/p>
&lt;h2 id="state-是工具對現實的唯一記憶">state 是工具對現實的唯一記憶&lt;/h2>
&lt;p>state 是 IaC 工具用來記錄「上一次 apply 之後，每個資源在雲端真實長什麼樣」的快照，它的作用是讓工具能算出「現況」與「目標」之間的最小差異。沒有 state，工具每次都得把所有資源重新查一遍才知道該不該動，而且無法分辨「這個資源是我建的、該由我管」還是「別人手動建的、不歸我管」。&lt;/p>
&lt;p>state 裡通常含有資源的真實 ID、相依關係，以及部分敏感屬性 — 例如資料庫的初始密碼、private key 的輸出值。這帶來兩條邊界。&lt;/p>
&lt;p>第一條：state 絕不能進 git。state 含明文敏感值，一旦推進版控就等於把密碼寫進每個 clone 的歷史裡，事後 rotate 也清不掉 git 歷史。&lt;/p>
&lt;p>第二條：state 不能只放本地。本地 state 的失敗模式是它把整份基礎設施的記憶綁在一台筆電上 — 換人接手、換台機器、或多人同時 apply 時，記憶就分裂了。兩個人各自拿著不同版本的本地 state 去 apply，工具會用各自過時的記憶去算差異，互相把對方建的資源判定成「不該存在、刪掉」，基礎設施被反覆來回破壞。&lt;/p>
&lt;p>這兩條邊界共同指向同一個結論：state 需要一個團隊共享、有版本、有存取控制、且能防止同時寫入的存放處。這就是 remote state backend 要解的問題。&lt;/p>
&lt;h2 id="remote-state-backend自管-vs-託管">remote state backend：自管 vs 託管&lt;/h2>
&lt;p>remote state backend 是把 state 從本地移到團隊共享儲存的機制，它要同時滿足三件事：持久保存、防止並行寫入衝突、以及保護敏感內容。達成方式分成自管儲存與託管服務兩種，差別在維運責任落在誰身上。&lt;/p>
&lt;p>自管路線以雲端物件儲存加鎖機制為典型組合。以 AWS 為例，state 檔放 S3、用一張鎖表防止兩個人同時 apply：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">terraform&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">backend&lt;/span> &lt;span class="s2">&amp;#34;s3&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="n"> bucket&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;acme-tf-state&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="n"> key&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;prod/network/terraform.tfstate&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="n"> region&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;ap-northeast-1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="n"> encrypt&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kt">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="n"> dynamodb_table&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;acme-tf-lock&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段設定的每一項都對應前一節的一條邊界，值得逐項拆開。&lt;code>encrypt = true&lt;/code> 讓 state 在 S3 落地時加密，回應「state 含敏感值」的風險。承載 state 的 bucket 必須開 versioning：apply 寫壞或誤刪 state 時，versioning 是把記憶回捲到上一個正確版本的唯一退路，沒開的話一次壞寫就讓工具失去對現實的記憶。&lt;code>dynamodb_table&lt;/code> 指向一張鎖表，apply 開始時寫入一筆鎖、結束才釋放，第二個人同時跑就會被擋下並提示鎖被誰持有 — 這正是本地 state 無法提供、卻是多人協作底線的並行保護。&lt;code>key&lt;/code> 則是 state 在 bucket 內的路徑，這裡先用 &lt;code>prod/network&lt;/code> 之類的分層命名，實際怎麼依環境切分 state 留待模組四：環境分離與模組化展開。&lt;/p></description><content:encoded><![CDATA[<p>踏上成熟度階梯第二階（宣告式 IaC，也就是 state 檔誕生那一階，見模組零：infra 是什麼）的最小路徑，只做兩件具體的事：把 state 管好，並立下所有資源都走程式碼的鐵律。這兩件事決定了往後每一階的地基穩不穩 — state 是 IaC 工具對現實的唯一記憶，Console 唯讀鐵律則保證這份記憶不會在背後被偷偷改掉。其餘的網路、身分、服務都還沒上場，先把這兩件事釘死，後面的擴張才有可重現的起點。</p>
<h2 id="iac-工具選型宣告式狀態管理-vs-程式語言抽象">IaC 工具選型：宣告式狀態管理 vs 程式語言抽象</h2>
<p>IaC 工具的核心職責是把「我要的基礎設施長什麼樣」描述成可版本控制的程式碼，再由工具負責算出現況與目標的差異並收斂。市場上的工具分成兩條路線，差別落在「用什麼語言描述」與「狀態由誰持有」這兩個軸上，而非功能多寡。</p>
<p>第一條路線是宣告式 DSL，代表是 Terraform 與其開源分支 OpenTofu。寫的是 HCL，描述的是資源的最終樣貌，工具自己維護一份 state 來追蹤每個資源的真實 ID 與屬性。這條路線適合團隊成員背景混雜、需要讓非專職後端的人也能讀懂 infra 定義的情境 — HCL 的閱讀門檻低，diff 直觀，review 時看得出「這個 PR 會新增一個 RDS、改掉一條 security group」。</p>
<p>第二條路線是用通用程式語言寫 infra，代表是 AWS CDK 與 Pulumi。寫的是 TypeScript、Python、Go 這類語言，靠迴圈、函式、類別來生成資源。這條路線適合 infra 邏輯本身複雜、需要大量條件分支與抽象複用的團隊，例如要根據環境清單動態生成數十組對稱資源。代價是 review 難度上升：一段 <code>for</code> 迴圈展開後到底建了哪些東西，得在腦中執行程式才看得出來，diff 不再等於變更本身。</p>
<p>CDK 與 Pulumi 同屬程式語言路線，但「狀態由誰持有」這個軸把它們再分開。CDK 把程式碼 synth 成 CloudFormation 模板，再交給 CloudFormation 服務端執行與追蹤，state 由 AWS 代管 — 沒有一份 tfstate 檔要自己存放、加密、回捲，也不需要額外的鎖表來防並行，這份「狀態維運外包給雲端」正是 CDK 在 AWS 生態內的賣點之一，代價是綁定 CloudFormation 與單一雲。Pulumi 走的是另一邊：它維護一份自己的 state，預設交給 Pulumi Cloud 託管，也能改用 S3 之類的後端自管 — 形態上更接近 Terraform 的 state 模型，state 的存放、保護與並行控制重回團隊手上。同一條程式語言路線，選 CDK 等於把 state 責任讓給雲端，選 Pulumi 則保留對 state 落點的掌控。</p>
<p>選型看的是團隊組成與變更的審查需求。若多數變更要跨職能 review、希望 diff 一眼可讀，宣告式 DSL 較划算；若 infra 由專職平台團隊維護、抽象複用的收益大於審查透明度的損失，程式語言路線較划算。Terraform 與 OpenTofu 之間，OpenTofu 是授權變更後社群分叉出的相容實作，HCL 與 provider 生態幾乎共用；選擇主要看對授權條款與治理模式的偏好，技術判準在這一階沒有實質差異。本模組後續一律以 HCL 示意，換成任一宣告式工具判準仍成立。</p>
<h2 id="state-是工具對現實的唯一記憶">state 是工具對現實的唯一記憶</h2>
<p>state 是 IaC 工具用來記錄「上一次 apply 之後，每個資源在雲端真實長什麼樣」的快照，它的作用是讓工具能算出「現況」與「目標」之間的最小差異。沒有 state，工具每次都得把所有資源重新查一遍才知道該不該動，而且無法分辨「這個資源是我建的、該由我管」還是「別人手動建的、不歸我管」。</p>
<p>state 裡通常含有資源的真實 ID、相依關係，以及部分敏感屬性 — 例如資料庫的初始密碼、private key 的輸出值。這帶來兩條邊界。</p>
<p>第一條：state 絕不能進 git。state 含明文敏感值，一旦推進版控就等於把密碼寫進每個 clone 的歷史裡，事後 rotate 也清不掉 git 歷史。</p>
<p>第二條：state 不能只放本地。本地 state 的失敗模式是它把整份基礎設施的記憶綁在一台筆電上 — 換人接手、換台機器、或多人同時 apply 時，記憶就分裂了。兩個人各自拿著不同版本的本地 state 去 apply，工具會用各自過時的記憶去算差異，互相把對方建的資源判定成「不該存在、刪掉」，基礎設施被反覆來回破壞。</p>
<p>這兩條邊界共同指向同一個結論：state 需要一個團隊共享、有版本、有存取控制、且能防止同時寫入的存放處。這就是 remote state backend 要解的問題。</p>
<h2 id="remote-state-backend自管-vs-託管">remote state backend：自管 vs 託管</h2>
<p>remote state backend 是把 state 從本地移到團隊共享儲存的機制，它要同時滿足三件事：持久保存、防止並行寫入衝突、以及保護敏感內容。達成方式分成自管儲存與託管服務兩種，差別在維運責任落在誰身上。</p>
<p>自管路線以雲端物件儲存加鎖機制為典型組合。以 AWS 為例，state 檔放 S3、用一張鎖表防止兩個人同時 apply：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">terraform</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">backend</span> <span class="s2">&#34;s3&#34;</span> {
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">    bucket</span>         <span class="o">=</span> <span class="s2">&#34;acme-tf-state&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">    key</span>            <span class="o">=</span> <span class="s2">&#34;prod/network/terraform.tfstate&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">    region</span>         <span class="o">=</span> <span class="s2">&#34;ap-northeast-1&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">    encrypt</span>        <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="n">    dynamodb_table</span> <span class="o">=</span> <span class="s2">&#34;acme-tf-lock&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  }
</span></span><span class="line"><span class="ln">9</span><span class="cl">}</span></span></code></pre></div><p>這段設定的每一項都對應前一節的一條邊界，值得逐項拆開。<code>encrypt = true</code> 讓 state 在 S3 落地時加密，回應「state 含敏感值」的風險。承載 state 的 bucket 必須開 versioning：apply 寫壞或誤刪 state 時，versioning 是把記憶回捲到上一個正確版本的唯一退路，沒開的話一次壞寫就讓工具失去對現實的記憶。<code>dynamodb_table</code> 指向一張鎖表，apply 開始時寫入一筆鎖、結束才釋放，第二個人同時跑就會被擋下並提示鎖被誰持有 — 這正是本地 state 無法提供、卻是多人協作底線的並行保護。<code>key</code> 則是 state 在 bucket 內的路徑，這裡先用 <code>prod/network</code> 之類的分層命名，實際怎麼依環境切分 state 留待模組四：環境分離與模組化展開。</p>
<p>託管路線把這些維運細節包起來，由 Terraform Cloud、Spacelift 這類平台代管 state、鎖與加密，附帶 web UI 與 audit log。判讀訊號是團隊規模與維運餘裕：自管 backend 的成本是要自己把 bucket versioning、加密、鎖表、IAM 權限配對，配錯任何一項都可能讓 state 失去保護；託管服務用月費換掉這份配置與維運負擔，代價是 state 託付給第三方、且進階治理功能常綁在付費級距。小團隊起步、不想第一週就花在配 backend 上，託管較划算；對 state 存放位置有合規或主權要求、或希望基礎設施盡量自持的團隊，自管較划算。</p>
<h2 id="console-唯讀鐵律把-console-當儀表板不當方向盤">Console 唯讀鐵律：把 Console 當儀表板，不當方向盤</h2>
<p>Console 唯讀鐵律是一條操作紀律：雲端 Console 只用來觀察與排查，所有會改變資源的動作都回到程式碼走 apply。這條紀律維護的是 state 與現實的一致 — IaC 工具能正確運作的前提，是它的 state 反映得了真實世界，而每一次在 Console 點按鈕改設定，都是在 state 不知情的情況下動了現實。</p>
<p>這種 state 與現實的分歧叫 drift。drift 的代價會延遲引爆，而非當下浮現。某人在 Console 臨時把一條 security group 規則打開救火，state 並不知道；下一次別人為了不相干的變更跑 apply，工具拿過時的 state 去比對，會把那條手動規則判定成「不在我的記憶裡、刪掉」，於是悄悄關掉，救火的洞重新出現，而且沒人在 PR 裡看得到這件事發生過。Console 改得越多、與程式碼分歧越久，某次例行 apply 就越可能掃掉一批沒人記得的手動設定。</p>
<p>鐵律越早立越好，因為回頭納管的代價隨時間累積。手動建的資源要納入 IaC，得先用 <code>terraform import</code> 把現實資源綁進 state，再補一段與現實完全吻合的 HCL：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">terraform import aws_security_group.web sg-0abc123def456</span></span></code></pre></div><p>import 只把資源 ID 寫進 state，不會幫忙生程式碼。那個資源在 Console 上被點出來的每一個屬性 — 每條 ingress 規則、每個 tag、每項關聯設定 — 都得一字不差地補成 HCL，任何一項對不上，下次 apply 就會試圖把現實改回程式碼寫的版本。一個資源還能忍，等到累積了幾十個各自手動微調過的資源才想納管，逆向工程的工作量會大到讓人乾脆放棄，基礎設施就此分裂成「程式碼管的」與「沒人敢動的」兩塊。第一天就立鐵律，要納管的存量永遠是零。</p>
<p>讓鐵律落地靠的是權限、不是自律。光靠約定「別在 Console 改」撐不久，救火當下手最快的永遠是 Console。真正讓鐵律站得住的，是把人的日常身分收斂成唯讀、把寫入權限留給跑 apply 的自動化身分，讓「在 Console 改不動」變成預設狀態 — 這道權限地基屬於模組二：身分與憑證地基的範圍，本階先確立紀律方向。</p>
<h2 id="最小可行能-apply-出一個完整環境的最小資源集合">最小可行：能 apply 出一個完整環境的最小資源集合</h2>
<p>最小可行 IaC 的目標是用最少的資源，跑出一條「改程式碼 → review → apply → 環境真的變了」的完整迴路。它承擔的責任是驗證地基本身能動，把所有服務都搬上來是後面的事。判準是這套程式碼能獨立 apply 出一個雖小但自洽、別人能重現的環境。</p>
<p>這一階的最小集合通常包含：一個設定好 versioning、加密與鎖表的 remote state backend；一個收斂後人類唯讀的身分權限基線；一個能放東西的網路骨架（一個 VPC 加最少的 subnet）；以及一個微不足道但真實存在的資源（例如一個 S3 bucket 或一台最小的測試機），用來證明 apply 確實作用到了雲端。把這個微小資源刻意留在最小集合裡，是因為它是最便宜的端到端驗證 — apply 跑完後它真的出現、<code>terraform destroy</code> 後它真的消失，就證明從程式碼到雲端的整條鏈路是通的。</p>
<p>刻意不放進來的東西同樣重要：正式的應用服務、資料庫、跨環境的複製、複雜的模組抽象，全部留到地基驗證通過之後。在 state 與 Console 唯讀都還沒站穩前就堆服務，等於把房子蓋在還沒灌漿的地基上。網路骨架怎麼長、身分怎麼切，分別由模組三：網路地基與模組二：身分與憑證地基接手深入；這一階只需要它們各自最薄的一層，湊出一個能 apply、能 destroy、能交接的閉環。</p>
<p>第一篇文章開頭有一段「動手前的前提」，列出寫第一行 IaC 之前需要就位的前置條件（雲端帳號 + MFA、CLI 工具安裝、雲端認證、Git repo 初始化）。已經備妥的讀者可以跳過。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/infra/01-minimal-iac/iac-tool-state-backend/" data-link-title="IaC 工具選型與 state 地基" data-link-desc="Terraform / OpenTofu / CDK / Pulumi 的選型判準，state 作為 IaC 工具對現實的唯一記憶，以及 remote state backend 的自管與託管路線">IaC 工具選型與 state 地基</a></td>
          <td>Terraform / OpenTofu / CDK / Pulumi 選型判準，state 作為唯一記憶，remote state backend 的自管與託管路線</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/01-minimal-iac/console-readonly-minimal-viable/" data-link-title="Console 唯讀鐵律與最小可行資源集合" data-link-desc="Console 只用來看不用來改的操作紀律、drift 的延遲浮現與偵測，以及能跑出第一個完整 apply 迴路的最小資源集合">Console 唯讀鐵律與最小可行資源集合</a></td>
          <td>Console 唯讀的操作紀律、drift 的延遲引爆與偵測，以及第一個完整 apply 迴路的最小資源集合</td>
      </tr>
  </tbody>
</table>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>：Console 唯讀鐵律靠權限落地，人類唯讀、自動化身分持有寫入權</li>
<li>→ <a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>：最小集合裡的 VPC 與 subnet 怎麼設計</li>
<li>→ <a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>：state 的 key 怎麼依環境切分、state 跟環境怎麼對應</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/" data-link-title="模組七：infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">模組七：infra 走 PR 流程</a>：state 變更與 apply 怎麼納入 review</li>
<li>→ <a href="/blog/infra/takeover/" data-link-title="接手維運：別人建的環境怎麼接管" data-link-desc="接手前人的專案時，怎麼在不搞壞東西的前提下盤點現況、建立維運能力、逐步正規化 — 從無 SSH 的 FTP 環境到有半套 IaC 的雲端環境都適用">接手維運</a>：接手既有環境後的 IaC 導入路徑</li>
</ul>
]]></content:encoded></item><item><title>Console 唯讀鐵律與最小可行資源集合</title><link>https://tarrragon.github.io/blog/infra/01-minimal-iac/console-readonly-minimal-viable/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/01-minimal-iac/console-readonly-minimal-viable/</guid><description>&lt;p>state 管好之後，下一件要釘死的事是保證 state 與現實不會分歧。&lt;a href="https://tarrragon.github.io/blog/infra/01-minimal-iac/iac-tool-state-backend/" data-link-title="IaC 工具選型與 state 地基" data-link-desc="Terraform / OpenTofu / CDK / Pulumi 的選型判準，state 作為 IaC 工具對現實的唯一記憶，以及 remote state backend 的自管與託管路線">IaC 工具選型與 state 地基&lt;/a>建立了 state 作為工具記憶的角色，這篇處理的是怎麼讓這份記憶不被背後偷改 — Console 唯讀鐵律，以及怎麼用最小資源集合驗證整條 IaC 鏈路端到端可運作。&lt;/p>
&lt;h2 id="console-唯讀鐵律把-console-當儀表板不當方向盤">Console 唯讀鐵律：把 Console 當儀表板，不當方向盤&lt;/h2>
&lt;p>Console 唯讀鐵律是一條操作紀律：雲端 Console 只用來觀察與排查，所有會改變資源的動作都回到程式碼走 apply。這條紀律維護的是 state 與現實的一致 — IaC 工具能正確運作的前提，是它的 state 反映得了真實世界，而每一次在 Console 點按鈕改設定，都是在 state 不知情的情況下動了現實。&lt;/p>
&lt;h3 id="drift-的延遲浮現">drift 的延遲浮現&lt;/h3>
&lt;p>state 與現實的分歧叫 drift。drift 的後果在後續某次 apply 時才浮現——工具用過時的 state 比對雲端現況、把手動設定判定為「不該存在」並覆蓋掉，手動改的當下一切正常。手動改的當下一切正常，後果要等到下一次不相關的 apply 才出現。&lt;/p>
&lt;p>常見的 drift 路徑：在 Console 手動加了一條 security group 規則（例如讓外部監控系統連進來），state 不知道這條規則存在。後續某次 apply 時，工具比對 state 和雲端現況、把這條規則判定為「不在記憶裡」而刪除。同樣的機制也發生在手動調整的 RDS parameter group（例如增加 &lt;code>max_connections&lt;/code>）— 後續 apply 會把參數重設回程式碼裡的值。&lt;/p>
&lt;p>Console 改得越多、與程式碼分歧越久，某次例行 apply 就越可能掃掉一批沒人記得的手動設定。drift 的累積是單調遞增的 — 每一次手動改動都加一筆，沒有任何自然機制會讓它減少。&lt;/p>
&lt;h3 id="drift-偵測">drift 偵測&lt;/h3>
&lt;p>主動偵測 drift 的方式是定期跑 &lt;code>terraform plan&lt;/code> 而不做 apply — plan 的輸出會列出「code 描述的狀態」與「雲端現況」之間的差異。如果 plan 在沒有 code 變更的情況下顯示非零差異，代表有人在背後動了資源。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 定期 drift 偵測：plan 結果非零就告警&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">terraform plan -detailed-exitcode
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1"># exit code 0 = 無差異, 1 = 錯誤, 2 = 有差異&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>把這個 plan 接進 CI，讓 drift 在累積之前就被發現。判讀 plan 輸出時，重點看那些「會被 Terraform 改回去」的差異 — 它們就是手動變更的痕跡。&lt;/p>
&lt;h3 id="import-的痛苦">import 的痛苦&lt;/h3>
&lt;p>鐵律越早立越好，因為回頭納管的代價隨時間累積。手動建的資源要納入 IaC，得先用 &lt;code>terraform import&lt;/code> 把現實資源綁進 state，再補一段與現實完全吻合的 HCL：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">terraform import aws_security_group.web sg-0abc123def456&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>import 只把資源 ID 寫進 state，不會幫忙生程式碼。那個資源在 Console 上被點出來的每一個屬性 — 每條 ingress 規則、每個 tag、每項關聯設定 — 都得一字不差地補成 HCL。任何一項對不上，下次 apply 就會試圖把現實改回程式碼寫的版本 — 對 security group 來說可能是把一條正在用的規則刪掉，對 RDS 來說可能是觸發一次重啟。&lt;/p>
&lt;p>Terraform 1.5 之後提供了 &lt;code>import&lt;/code> 區塊，可以在 HCL 裡宣告式地寫 import，配合 &lt;code>terraform plan -generate-config-out=generated.tf&lt;/code> 自動生成對應的資源描述。這比手寫減少了大量逆向工程，但生成的 code 仍然需要人工確認每一個屬性是否正確 — 自動生成是起點，不是終點。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">import&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="n"> to&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_security_group&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">web&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="n"> id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;sg-0abc123def456&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>import 成本隨資源數量非線性增長。一個資源的逆向工程可控，幾十個各自手動微調過的資源累積起來，團隊會停止嘗試納管，環境分裂成 IaC 管理的部分和手動管理的部分。第一天就立鐵律，要納管的存量永遠是零。&lt;/p></description><content:encoded><![CDATA[<p>state 管好之後，下一件要釘死的事是保證 state 與現實不會分歧。<a href="/blog/infra/01-minimal-iac/iac-tool-state-backend/" data-link-title="IaC 工具選型與 state 地基" data-link-desc="Terraform / OpenTofu / CDK / Pulumi 的選型判準，state 作為 IaC 工具對現實的唯一記憶，以及 remote state backend 的自管與託管路線">IaC 工具選型與 state 地基</a>建立了 state 作為工具記憶的角色，這篇處理的是怎麼讓這份記憶不被背後偷改 — Console 唯讀鐵律，以及怎麼用最小資源集合驗證整條 IaC 鏈路端到端可運作。</p>
<h2 id="console-唯讀鐵律把-console-當儀表板不當方向盤">Console 唯讀鐵律：把 Console 當儀表板，不當方向盤</h2>
<p>Console 唯讀鐵律是一條操作紀律：雲端 Console 只用來觀察與排查，所有會改變資源的動作都回到程式碼走 apply。這條紀律維護的是 state 與現實的一致 — IaC 工具能正確運作的前提，是它的 state 反映得了真實世界，而每一次在 Console 點按鈕改設定，都是在 state 不知情的情況下動了現實。</p>
<h3 id="drift-的延遲浮現">drift 的延遲浮現</h3>
<p>state 與現實的分歧叫 drift。drift 的後果在後續某次 apply 時才浮現——工具用過時的 state 比對雲端現況、把手動設定判定為「不該存在」並覆蓋掉，手動改的當下一切正常。手動改的當下一切正常，後果要等到下一次不相關的 apply 才出現。</p>
<p>常見的 drift 路徑：在 Console 手動加了一條 security group 規則（例如讓外部監控系統連進來），state 不知道這條規則存在。後續某次 apply 時，工具比對 state 和雲端現況、把這條規則判定為「不在記憶裡」而刪除。同樣的機制也發生在手動調整的 RDS parameter group（例如增加 <code>max_connections</code>）— 後續 apply 會把參數重設回程式碼裡的值。</p>
<p>Console 改得越多、與程式碼分歧越久，某次例行 apply 就越可能掃掉一批沒人記得的手動設定。drift 的累積是單調遞增的 — 每一次手動改動都加一筆，沒有任何自然機制會讓它減少。</p>
<h3 id="drift-偵測">drift 偵測</h3>
<p>主動偵測 drift 的方式是定期跑 <code>terraform plan</code> 而不做 apply — plan 的輸出會列出「code 描述的狀態」與「雲端現況」之間的差異。如果 plan 在沒有 code 變更的情況下顯示非零差異，代表有人在背後動了資源。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 定期 drift 偵測：plan 結果非零就告警</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">terraform plan -detailed-exitcode
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># exit code 0 = 無差異, 1 = 錯誤, 2 = 有差異</span></span></span></code></pre></div><p>把這個 plan 接進 CI，讓 drift 在累積之前就被發現。判讀 plan 輸出時，重點看那些「會被 Terraform 改回去」的差異 — 它們就是手動變更的痕跡。</p>
<h3 id="import-的痛苦">import 的痛苦</h3>
<p>鐵律越早立越好，因為回頭納管的代價隨時間累積。手動建的資源要納入 IaC，得先用 <code>terraform import</code> 把現實資源綁進 state，再補一段與現實完全吻合的 HCL：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">terraform import aws_security_group.web sg-0abc123def456</span></span></code></pre></div><p>import 只把資源 ID 寫進 state，不會幫忙生程式碼。那個資源在 Console 上被點出來的每一個屬性 — 每條 ingress 規則、每個 tag、每項關聯設定 — 都得一字不差地補成 HCL。任何一項對不上，下次 apply 就會試圖把現實改回程式碼寫的版本 — 對 security group 來說可能是把一條正在用的規則刪掉，對 RDS 來說可能是觸發一次重啟。</p>
<p>Terraform 1.5 之後提供了 <code>import</code> 區塊，可以在 HCL 裡宣告式地寫 import，配合 <code>terraform plan -generate-config-out=generated.tf</code> 自動生成對應的資源描述。這比手寫減少了大量逆向工程，但生成的 code 仍然需要人工確認每一個屬性是否正確 — 自動生成是起點，不是終點。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">import</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  to</span> <span class="o">=</span> <span class="k">aws_security_group</span><span class="p">.</span><span class="k">web</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  id</span> <span class="o">=</span> <span class="s2">&#34;sg-0abc123def456&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">}</span></span></code></pre></div><p>import 成本隨資源數量非線性增長。一個資源的逆向工程可控，幾十個各自手動微調過的資源累積起來，團隊會停止嘗試納管，環境分裂成 IaC 管理的部分和手動管理的部分。第一天就立鐵律，要納管的存量永遠是零。</p>
<h3 id="鐵律靠權限落地不靠自律">鐵律靠權限落地，不靠自律</h3>
<p>光靠約定「別在 Console 改」撐不久，救火當下手最快的永遠是 Console。真正讓鐵律站得住的，是把人的日常身分收斂成唯讀、把寫入權限留給跑 apply 的自動化身分，讓「在 Console 改不動」變成預設狀態。</p>
<p>這道權限地基屬於<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>的範圍，本階先確立紀律方向：人類日常用的 IAM 身分只有 <code>ReadOnlyAccess</code>，寫入權限只存在於 CI pipeline 使用的 role，這個 role 靠 OIDC 取得短期憑證（不存長期 key）。具體的 IAM 設計和 OIDC 信任關係在模組二展開。</p>
<h2 id="最小可行能-apply-出一個完整環境的最小資源集合">最小可行：能 apply 出一個完整環境的最小資源集合</h2>
<p>最小可行 IaC 的目標是用最少的資源，跑出一條「改程式碼 → review → apply → 環境真的變了」的完整迴路。它承擔的責任是驗證地基本身能動，把所有服務都搬上來是後面的事。判準是這套程式碼能獨立 apply 出一個雖小但自洽、別人能重現的環境。</p>
<h3 id="最小集合的組成">最小集合的組成</h3>
<table>
  <thead>
      <tr>
          <th>資源</th>
          <th>職責</th>
          <th>驗證標準</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>S3 bucket + DynamoDB（鎖表）</td>
          <td>remote state backend</td>
          <td>state 能寫入、鎖能取得和釋放</td>
      </tr>
      <tr>
          <td>IAM role（唯讀 + apply）</td>
          <td>人類唯讀、自動化寫入的身分基線</td>
          <td>人登入後 Console 改不動東西</td>
      </tr>
      <tr>
          <td>VPC + 最少的 subnet</td>
          <td>網路骨架</td>
          <td>資源能被放進正確的 subnet</td>
      </tr>
      <tr>
          <td>一個微小的真實資源</td>
          <td>端到端驗證</td>
          <td>apply 出現、destroy 消失</td>
      </tr>
  </tbody>
</table>
<p>把一個微小資源（例如一個 S3 bucket 或一台最小的測試 EC2）刻意留在最小集合裡，是因為它是最便宜的端到端驗證。apply 跑完後它確實出現、<code>terraform destroy</code> 後它確實消失，就證明從程式碼到雲端的整條鏈路是通的。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_s3_bucket&#34; &#34;smoke_test&#34;</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  bucket</span> <span class="o">=</span> <span class="s2">&#34;acme-smoke-test-${var.env}&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  tags</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">    purpose</span> <span class="o">=</span> <span class="s2">&#34;validate-iac-pipeline&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">    env</span>     <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">env</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="n">    owner</span>   <span class="o">=</span> <span class="s2">&#34;platform&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  }
</span></span><span class="line"><span class="ln">9</span><span class="cl">}</span></span></code></pre></div><h3 id="刻意不放進來的東西">刻意不放進來的東西</h3>
<p>正式的應用服務、資料庫、跨環境的複製、複雜的模組抽象，全部留到地基驗證通過之後。在 state 與 Console 唯讀都還沒站穩前就堆服務，等於把房子蓋在還沒灌漿的地基上。</p>
<p>常見的過早引入包括：在最小集合裡就加 RDS（state 操作出問題時資料庫可能被影響）、在還沒有環境分離前就建多層 module 嵌套（驗證地基的複雜度不應該來自抽象層）、在一個人開發時就配好 Atlantis 或 Terraform Cloud 的完整 PR 流程（固定成本太高、且需要<a href="/blog/infra/07-infra-as-pr/" data-link-title="模組七：infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">模組七</a>的完整護欄才能發揮價值）。</p>
<p>網路骨架怎麼長、身分怎麼切，分別由<a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>與<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>接手深入；這一階只需要它們各自最薄的一層，湊出一個能 apply、能 destroy、能交接的閉環。</p>
<h3 id="驗證閉環">驗證閉環</h3>
<p>最小集合就位後的驗證步驟：</p>
<ol>
<li><code>terraform init</code> — 確認 backend 設定正確、provider 能下載</li>
<li><code>terraform plan</code> — 確認 plan 輸出符合預期、沒有意外的 destroy 或 replace</li>
<li><code>terraform apply</code> — 確認資源在雲端確實出現</li>
<li><code>terraform plan</code>（再跑一次）— 確認輸出是零差異，代表 state 與現實一致</li>
<li><code>terraform destroy</code> — 確認資源能被乾淨拆除（smoke test 資源）</li>
</ol>
<p>第四步「再跑一次 plan」是容易被跳過卻最關鍵的一步。如果第一次 apply 之後立刻 plan 就出現差異，代表 provider 的行為和 HCL 描述之間有落差（例如某些屬性是雲端自動設的、HCL 沒寫），這類落差要在最小集合階段就修掉，等到正式服務上線後再修，成本會高很多。</p>
<p>最小可行 IaC 跑通後，下一步是收斂身分與憑證——把 Console 唯讀鐵律從紀律升級成權限限制，見<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/01-minimal-iac/iac-tool-state-backend/" data-link-title="IaC 工具選型與 state 地基" data-link-desc="Terraform / OpenTofu / CDK / Pulumi 的選型判準，state 作為 IaC 工具對現實的唯一記憶，以及 remote state backend 的自管與託管路線">IaC 工具選型與 state 地基</a>：state 怎麼管、backend 怎麼選</li>
<li>→ <a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>：Console 唯讀鐵律靠權限落地，人類唯讀、自動化身分持有寫入權</li>
<li>→ <a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>：最小集合裡的 VPC 與 subnet 怎麼設計</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/" data-link-title="模組七：infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">模組七：infra 走 PR 流程</a>：state 變更與 apply 怎麼納入 review</li>
</ul>
]]></content:encoded></item><item><title>State（IaC 狀態檔）</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/state/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/state/</guid><description>&lt;p>State 是 IaC 工具用來記錄「上一次 apply 之後，每個資源在雲端長什麼樣」的快照。它的作用是讓工具能算出「程式碼描述的目標」與「雲端上的現況」之間的最小差異。沒有 state，工具每次都得把所有資源重新查一遍才知道該不該動，而且無法分辨「這個資源是我建的、該由我管」還是「別人手動建的、不歸我管」。&lt;/p>
&lt;p>State 裡通常含有資源的真實 ID、相依關係，以及部分敏感屬性（例如資料庫的初始密碼、private key 的輸出值）。這帶來兩條硬邊界：state 不能進 git（含敏感值，推進版控等於把密碼寫進每個 clone 的歷史）、state 不能只放本地（本地 state 的失敗模式是記憶綁在一台筆電上，多人並行 apply 會互相覆蓋）。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>State 是 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC&lt;/a> 的記憶機制。&lt;a href="https://tarrragon.github.io/blog/infra/01-minimal-iac/iac-tool-state-backend/" data-link-title="IaC 工具選型與 state 地基" data-link-desc="Terraform / OpenTofu / CDK / Pulumi 的選型判準，state 作為 IaC 工具對現實的唯一記憶，以及 remote state backend 的自管與託管路線">模組一：最小可行 IaC&lt;/a> 的核心主題就是怎麼把 state 管好——remote backend、加密、鎖機制。State 管不好，後續所有 IaC 操作都建立在不可靠的記憶上。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>State 出問題的訊號包括：&lt;code>terraform plan&lt;/code> 顯示大量非預期的變更（state 與現實不一致）、兩個人同時 apply 後環境出現矛盾狀態、&lt;code>state list&lt;/code> 的資源數與 Console 上看到的不一致。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>管理 state 時要決定：&lt;/p>
&lt;ul>
&lt;li>存放位置：S3 + DynamoDB（自管）vs Terraform Cloud（託管），取捨在維運負擔 vs 控制權&lt;/li>
&lt;li>加密：state 含敏感值，落地加密（S3 SSE）是底線&lt;/li>
&lt;li>版本保留：bucket versioning 讓 state 損壞時能回捲到上一個正確版本&lt;/li>
&lt;li>鎖機制：防止兩個人同時 apply 互相覆蓋&lt;/li>
&lt;li>分割策略：一個大 state vs 多個小 state，取捨在引用便利性 vs 影響範圍控制&lt;/li>
&lt;/ul>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC&lt;/a> — state 是 IaC 工具的核心依賴&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/drift/" data-link-title="Drift（設定漂移）" data-link-desc="IaC 的 state 與雲端實際狀態之間的不一致，通常因為有人繞過 IaC 直接在 Console 改設定">Drift&lt;/a> — state 與現實的落差&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>State 是 IaC 工具用來記錄「上一次 apply 之後，每個資源在雲端長什麼樣」的快照。它的作用是讓工具能算出「程式碼描述的目標」與「雲端上的現況」之間的最小差異。沒有 state，工具每次都得把所有資源重新查一遍才知道該不該動，而且無法分辨「這個資源是我建的、該由我管」還是「別人手動建的、不歸我管」。</p>
<p>State 裡通常含有資源的真實 ID、相依關係，以及部分敏感屬性（例如資料庫的初始密碼、private key 的輸出值）。這帶來兩條硬邊界：state 不能進 git（含敏感值，推進版控等於把密碼寫進每個 clone 的歷史）、state 不能只放本地（本地 state 的失敗模式是記憶綁在一台筆電上，多人並行 apply 會互相覆蓋）。</p>
<h2 id="概念位置">概念位置</h2>
<p>State 是 <a href="/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC</a> 的記憶機制。<a href="/blog/infra/01-minimal-iac/iac-tool-state-backend/" data-link-title="IaC 工具選型與 state 地基" data-link-desc="Terraform / OpenTofu / CDK / Pulumi 的選型判準，state 作為 IaC 工具對現實的唯一記憶，以及 remote state backend 的自管與託管路線">模組一：最小可行 IaC</a> 的核心主題就是怎麼把 state 管好——remote backend、加密、鎖機制。State 管不好，後續所有 IaC 操作都建立在不可靠的記憶上。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>State 出問題的訊號包括：<code>terraform plan</code> 顯示大量非預期的變更（state 與現實不一致）、兩個人同時 apply 後環境出現矛盾狀態、<code>state list</code> 的資源數與 Console 上看到的不一致。</p>
<h2 id="設計責任">設計責任</h2>
<p>管理 state 時要決定：</p>
<ul>
<li>存放位置：S3 + DynamoDB（自管）vs Terraform Cloud（託管），取捨在維運負擔 vs 控制權</li>
<li>加密：state 含敏感值，落地加密（S3 SSE）是底線</li>
<li>版本保留：bucket versioning 讓 state 損壞時能回捲到上一個正確版本</li>
<li>鎖機制：防止兩個人同時 apply 互相覆蓋</li>
<li>分割策略：一個大 state vs 多個小 state，取捨在引用便利性 vs 影響範圍控制</li>
</ul>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC</a> — state 是 IaC 工具的核心依賴</li>
<li><a href="/blog/infra/knowledge-cards/drift/" data-link-title="Drift（設定漂移）" data-link-desc="IaC 的 state 與雲端實際狀態之間的不一致，通常因為有人繞過 IaC 直接在 Console 改設定">Drift</a> — state 與現實的落差</li>
</ul>
]]></content:encoded></item><item><title>Terraform CI Pipeline 設定指南</title><link>https://tarrragon.github.io/blog/infra/07-infra-as-pr/terraform-ci-pipeline-setup/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/07-infra-as-pr/terraform-ci-pipeline-setup/</guid><description>&lt;p>Terraform 的 PR 流程要發揮價值，plan 和 apply 需要在 CI 裡自動執行，而非在工程師的本機跑。本篇用 GitHub Actions 建立一條完整的 pipeline：PR 開啟時跑檢查和 plan、plan 結果貼回 PR comment 讓 reviewer 看、合併到主幹後才 apply。整條管線的 credential 用 OIDC 取得短期 token（見 &lt;a href="https://tarrragon.github.io/blog/infra/02-identity-credentials/oidc-trust-policy-setup/" data-link-title="OIDC Trust Policy 設定指南" data-link-desc="GitHub Actions 與 AWS 之間的 OIDC 聯合設定：建立 provider、設計 trust policy 的 claim 收斂、plan 與 apply role 分離、常見錯誤排查">OIDC Trust Policy 設定&lt;/a>），不存任何長期 key。&lt;/p>
&lt;h2 id="pipeline-的兩個階段">Pipeline 的兩個階段&lt;/h2>
&lt;p>整條 pipeline 分成兩個觸發時機，各自承擔不同責任：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>階段&lt;/th>
 &lt;th>觸發條件&lt;/th>
 &lt;th>責任&lt;/th>
 &lt;th>失敗時&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Plan&lt;/td>
 &lt;td>PR 開啟或更新&lt;/td>
 &lt;td>檢查格式、驗證語法、靜態掃描、產出 plan diff&lt;/td>
 &lt;td>PR 無法合併&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Apply&lt;/td>
 &lt;td>合併到 main&lt;/td>
 &lt;td>把 plan 過的變更套用到雲端&lt;/td>
 &lt;td>需要人工介入&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩個階段用不同的 IAM role：plan role 只有唯讀權限（能跑 &lt;code>terraform plan&lt;/code> 但不能改任何資源），apply role 有寫入權限。這個分離確保 PR 階段的任何 code 都沒辦法偷偷改動雲端資源。&lt;/p>
&lt;h2 id="plan-階段的完整-workflow">Plan 階段的完整 workflow&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Terraform Plan&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">on&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pull_request&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">paths&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s1">&amp;#39;infra/**&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">permissions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">id-token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">write&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">contents&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">read&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pull-requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">write&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">jobs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">plan&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">runs-on&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ubuntu-latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">defaults&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">run&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">working-directory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">infra/environments/prod&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">steps&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">uses&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">actions/checkout@v4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">uses&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">aws-actions/configure-aws-credentials@v4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">role-to-assume&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">arn:aws:iam::123456789012:role/infra-plan&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">aws-region&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ap-northeast-1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">uses&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hashicorp/setup-terraform@v3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">terraform_version&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.9.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Format check&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">run&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">terraform fmt -check -recursive -diff&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">33&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">34&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Init&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">35&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">run&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">terraform init -input=false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">36&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">37&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Validate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">38&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">run&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">terraform validate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">39&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">40&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TFLint&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">41&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">uses&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">terraform-linters/setup-tflint@v4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">42&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">43&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tflint_version&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">44&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">run&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">tflint --recursive --format compact&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">45&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">46&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Plan&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">47&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">plan&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">48&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">run&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">49&lt;/span>&lt;span class="cl">&lt;span class="sd"> terraform plan -no-color -input=false -out=tfplan \
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">50&lt;/span>&lt;span class="cl">&lt;span class="sd"> -detailed-exitcode 2&amp;gt;&amp;amp;1 | tee plan-output.txt&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">51&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">continue-on-error&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">52&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">53&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Comment plan on PR&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">54&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">uses&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">actions/github-script@v7&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">55&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">56&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">script&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">57&lt;/span>&lt;span class="cl">&lt;span class="sd"> const fs = require(&amp;#39;fs&amp;#39;);
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">58&lt;/span>&lt;span class="cl">&lt;span class="sd"> const plan = fs.readFileSync(&amp;#39;infra/environments/prod/plan-output.txt&amp;#39;, &amp;#39;utf8&amp;#39;);
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">59&lt;/span>&lt;span class="cl">&lt;span class="sd"> const truncated = plan.length &amp;gt; 60000
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">60&lt;/span>&lt;span class="cl">&lt;span class="sd"> ? plan.substring(0, 60000) + &amp;#39;\n\n... (truncated)&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">61&lt;/span>&lt;span class="cl">&lt;span class="sd"> : plan;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">62&lt;/span>&lt;span class="cl">&lt;span class="sd"> await github.rest.issues.createComment({
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">63&lt;/span>&lt;span class="cl">&lt;span class="sd"> owner: context.repo.owner,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">64&lt;/span>&lt;span class="cl">&lt;span class="sd"> repo: context.repo.repo,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">65&lt;/span>&lt;span class="cl">&lt;span class="sd"> issue_number: context.issue.number,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">66&lt;/span>&lt;span class="cl">&lt;span class="sd"> body: `### Terraform Plan\n\`\`\`\n${truncated}\n\`\`\``
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">67&lt;/span>&lt;span class="cl">&lt;span class="sd"> });&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">68&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">69&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Fail if plan errored&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">70&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">if&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">steps.plan.outcome == &amp;#39;failure&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">71&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">run&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">exit 1&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="各步驟的職責">各步驟的職責&lt;/h3>
&lt;p>&lt;strong>Format check&lt;/strong> 驗證 HCL 是否符合標準排版。它不影響功能，但消除 diff 噪音——排版不一致時 PR diff 會混入純格式變更，reviewer 分不清哪些是邏輯改動。&lt;code>-diff&lt;/code> flag 讓 CI 輸出具體哪幾行不符合，作者在本地跑 &lt;code>terraform fmt&lt;/code> 就能修。&lt;/p></description><content:encoded><![CDATA[<p>Terraform 的 PR 流程要發揮價值，plan 和 apply 需要在 CI 裡自動執行，而非在工程師的本機跑。本篇用 GitHub Actions 建立一條完整的 pipeline：PR 開啟時跑檢查和 plan、plan 結果貼回 PR comment 讓 reviewer 看、合併到主幹後才 apply。整條管線的 credential 用 OIDC 取得短期 token（見 <a href="/blog/infra/02-identity-credentials/oidc-trust-policy-setup/" data-link-title="OIDC Trust Policy 設定指南" data-link-desc="GitHub Actions 與 AWS 之間的 OIDC 聯合設定：建立 provider、設計 trust policy 的 claim 收斂、plan 與 apply role 分離、常見錯誤排查">OIDC Trust Policy 設定</a>），不存任何長期 key。</p>
<h2 id="pipeline-的兩個階段">Pipeline 的兩個階段</h2>
<p>整條 pipeline 分成兩個觸發時機，各自承擔不同責任：</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>觸發條件</th>
          <th>責任</th>
          <th>失敗時</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Plan</td>
          <td>PR 開啟或更新</td>
          <td>檢查格式、驗證語法、靜態掃描、產出 plan diff</td>
          <td>PR 無法合併</td>
      </tr>
      <tr>
          <td>Apply</td>
          <td>合併到 main</td>
          <td>把 plan 過的變更套用到雲端</td>
          <td>需要人工介入</td>
      </tr>
  </tbody>
</table>
<p>兩個階段用不同的 IAM role：plan role 只有唯讀權限（能跑 <code>terraform plan</code> 但不能改任何資源），apply role 有寫入權限。這個分離確保 PR 階段的任何 code 都沒辦法偷偷改動雲端資源。</p>
<h2 id="plan-階段的完整-workflow">Plan 階段的完整 workflow</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Terraform Plan</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">paths</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">      </span>- <span class="s1">&#39;infra/**&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">  </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">  </span><span class="nt">pull-requests</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">  </span><span class="nt">plan</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">    </span><span class="nt">defaults</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">      </span><span class="nt">run</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">        </span><span class="nt">working-directory</span><span class="p">:</span><span class="w"> </span><span class="l">infra/environments/prod</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">aws-actions/configure-aws-credentials@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w">          </span><span class="nt">role-to-assume</span><span class="p">:</span><span class="w"> </span><span class="l">arn:aws:iam::123456789012:role/infra-plan</span><span class="w">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="w">          </span><span class="nt">aws-region</span><span class="p">:</span><span class="w"> </span><span class="l">ap-northeast-1</span><span class="w">
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">hashicorp/setup-terraform@v3</span><span class="w">
</span></span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="w">          </span><span class="nt">terraform_version</span><span class="p">:</span><span class="w"> </span><span class="m">1.9.0</span><span class="w">
</span></span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">31</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Format check</span><span class="w">
</span></span></span><span class="line"><span class="ln">32</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform fmt -check -recursive -diff</span><span class="w">
</span></span></span><span class="line"><span class="ln">33</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">34</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Init</span><span class="w">
</span></span></span><span class="line"><span class="ln">35</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform init -input=false</span><span class="w">
</span></span></span><span class="line"><span class="ln">36</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">37</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Validate</span><span class="w">
</span></span></span><span class="line"><span class="ln">38</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform validate</span><span class="w">
</span></span></span><span class="line"><span class="ln">39</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">40</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">TFLint</span><span class="w">
</span></span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">terraform-linters/setup-tflint@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">42</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">43</span><span class="cl"><span class="w">          </span><span class="nt">tflint_version</span><span class="p">:</span><span class="w"> </span><span class="l">latest</span><span class="w">
</span></span></span><span class="line"><span class="ln">44</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">tflint --recursive --format compact</span><span class="w">
</span></span></span><span class="line"><span class="ln">45</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">46</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Plan</span><span class="w">
</span></span></span><span class="line"><span class="ln">47</span><span class="cl"><span class="w">        </span><span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">plan</span><span class="w">
</span></span></span><span class="line"><span class="ln">48</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="ln">49</span><span class="cl"><span class="sd">          terraform plan -no-color -input=false -out=tfplan \
</span></span></span><span class="line"><span class="ln">50</span><span class="cl"><span class="sd">            -detailed-exitcode 2&gt;&amp;1 | tee plan-output.txt</span><span class="w">
</span></span></span><span class="line"><span class="ln">51</span><span class="cl"><span class="w">        </span><span class="nt">continue-on-error</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="ln">52</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">53</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Comment plan on PR</span><span class="w">
</span></span></span><span class="line"><span class="ln">54</span><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/github-script@v7</span><span class="w">
</span></span></span><span class="line"><span class="ln">55</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">56</span><span class="cl"><span class="w">          </span><span class="nt">script</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="ln">57</span><span class="cl"><span class="sd">            const fs = require(&#39;fs&#39;);
</span></span></span><span class="line"><span class="ln">58</span><span class="cl"><span class="sd">            const plan = fs.readFileSync(&#39;infra/environments/prod/plan-output.txt&#39;, &#39;utf8&#39;);
</span></span></span><span class="line"><span class="ln">59</span><span class="cl"><span class="sd">            const truncated = plan.length &gt; 60000
</span></span></span><span class="line"><span class="ln">60</span><span class="cl"><span class="sd">              ? plan.substring(0, 60000) + &#39;\n\n... (truncated)&#39;
</span></span></span><span class="line"><span class="ln">61</span><span class="cl"><span class="sd">              : plan;
</span></span></span><span class="line"><span class="ln">62</span><span class="cl"><span class="sd">            await github.rest.issues.createComment({
</span></span></span><span class="line"><span class="ln">63</span><span class="cl"><span class="sd">              owner: context.repo.owner,
</span></span></span><span class="line"><span class="ln">64</span><span class="cl"><span class="sd">              repo: context.repo.repo,
</span></span></span><span class="line"><span class="ln">65</span><span class="cl"><span class="sd">              issue_number: context.issue.number,
</span></span></span><span class="line"><span class="ln">66</span><span class="cl"><span class="sd">              body: `### Terraform Plan\n\`\`\`\n${truncated}\n\`\`\``
</span></span></span><span class="line"><span class="ln">67</span><span class="cl"><span class="sd">            });</span><span class="w">
</span></span></span><span class="line"><span class="ln">68</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">69</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Fail if plan errored</span><span class="w">
</span></span></span><span class="line"><span class="ln">70</span><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">steps.plan.outcome == &#39;failure&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">71</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">exit 1</span></span></span></code></pre></div><h3 id="各步驟的職責">各步驟的職責</h3>
<p><strong>Format check</strong> 驗證 HCL 是否符合標準排版。它不影響功能，但消除 diff 噪音——排版不一致時 PR diff 會混入純格式變更，reviewer 分不清哪些是邏輯改動。<code>-diff</code> flag 讓 CI 輸出具體哪幾行不符合，作者在本地跑 <code>terraform fmt</code> 就能修。</p>
<p><strong>Init</strong> 初始化 provider 和 backend。<code>-input=false</code> 避免 CI 卡在等待互動式輸入。如果 backend 設定錯了（bucket 不存在、權限不足），這一步就會失敗，不會跑到後面浪費時間。</p>
<p><strong>Validate</strong> 檢查 HCL 的語法和內部一致性——變數沒宣告、型別不匹配、必填參數缺漏。它不連線雲端，只讀 code，所以不需要 AWS credential 也能跑（但放在 init 之後是因為 validate 需要 provider schema）。</p>
<p><strong>TFLint</strong> 做 provider 層的正確性檢查：instance type 在該 region 不存在、已棄用的參數、命名不符規範。它補的是 validate 抓不到的「語法對但值不對」的問題。</p>
<p><strong>Plan</strong> 是整條 pipeline 的核心產出。<code>-detailed-exitcode</code> 讓 exit code 區分三種狀態：0 = 無差異、1 = 錯誤、2 = 有差異。<code>-out=tfplan</code> 把 plan 結果存成二進位檔，apply 階段可以直接用這份 plan 執行，避免 plan 和 apply 之間的時間差導致不一致。</p>
<p><strong>Comment</strong> 把 plan 輸出貼回 PR，reviewer 看 code diff 的同時看到 plan 的實際變更。plan 輸出可能很長（幾百行），超過 GitHub comment 上限時截斷，但保留開頭（通常包含 add/change/destroy 的摘要行）。</p>
<h2 id="apply-階段">Apply 階段</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Terraform Apply</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">paths</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">      </span>- <span class="s1">&#39;infra/**&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">  </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">  </span><span class="nt">apply</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w"> </span><span class="l">production</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">    </span><span class="nt">defaults</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">      </span><span class="nt">run</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">        </span><span class="nt">working-directory</span><span class="p">:</span><span class="w"> </span><span class="l">infra/environments/prod</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">aws-actions/configure-aws-credentials@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="w">          </span><span class="nt">role-to-assume</span><span class="p">:</span><span class="w"> </span><span class="l">arn:aws:iam::123456789012:role/infra-apply</span><span class="w">
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="w">          </span><span class="nt">aws-region</span><span class="p">:</span><span class="w"> </span><span class="l">ap-northeast-1</span><span class="w">
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">hashicorp/setup-terraform@v3</span><span class="w">
</span></span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="w">          </span><span class="nt">terraform_version</span><span class="p">:</span><span class="w"> </span><span class="m">1.9.0</span><span class="w">
</span></span></span><span class="line"><span class="ln">31</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">32</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Init</span><span class="w">
</span></span></span><span class="line"><span class="ln">33</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform init -input=false</span><span class="w">
</span></span></span><span class="line"><span class="ln">34</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">35</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Plan (verify)</span><span class="w">
</span></span></span><span class="line"><span class="ln">36</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform plan -no-color -input=false -detailed-exitcode</span><span class="w">
</span></span></span><span class="line"><span class="ln">37</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">38</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Apply</span><span class="w">
</span></span></span><span class="line"><span class="ln">39</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform apply -auto-approve -input=false</span></span></span></code></pre></div><h3 id="environment-protection-rule">environment protection rule</h3>
<p><code>environment: production</code> 這一行啟用 GitHub 的環境保護功能。在 repo 的 Settings → Environments → production 設定：</p>
<ul>
<li><strong>Required reviewers</strong>：指定至少一個人 approve 才能執行 apply job</li>
<li><strong>Wait timer</strong>：合併後等 N 分鐘才開始 apply（給人反應時間）</li>
<li><strong>Deployment branches</strong>：限定只有 main branch 能觸發</li>
</ul>
<p>這層保護讓高風險的變更（plan 顯示 destroy 或 replace）在 apply 前多一道人工確認。日常低風險變更（加一個 tag、調一個參數）可以直接通過。取捨點是：每次 apply 都要人按確認會拖慢頻繁的小變更，可以用 deployment rule 的條件只攔 production 環境。</p>
<h3 id="apply-階段重跑-plan-的理由">Apply 階段重跑 plan 的理由</h3>
<p>apply 之前重跑一次 plan，是為了驗證合併後的現實跟 PR review 時看到的一致。PR 從開啟到合併可能隔了幾小時或幾天，期間有人可能手動改了雲端資源（drift）或別的 PR 先 apply 了。重跑 plan 確認差異跟預期一致，不一致就停下來而非盲目 apply。</p>
<p>如果使用了 plan 階段的 <code>-out=tfplan</code> 保存 plan 檔，apply 可以改為 <code>terraform apply tfplan</code> 直接執行已 review 過的 plan。代價是 plan 檔需要跨 job 傳遞（GitHub Actions 的 artifact），且 plan 檔有時效——state 在 plan 之後被修改，apply 會拒絕執行。</p>
<h2 id="多環境的-pipeline-設計">多環境的 pipeline 設計</h2>
<p>管理 dev / staging / prod 三個環境時，pipeline 有兩種常見結構：</p>
<p><strong>單 workflow 加 matrix</strong>：一份 YAML 用 <code>strategy.matrix</code> 跑三個環境，每個環境有自己的 working directory 和 IAM role。好處是維護一份 YAML；代價是三個環境的 plan 都在同一次 PR run 裡，reviewer 要看三份 plan 輸出。</p>
<p><strong>每環境獨立 workflow</strong>：三份 YAML 各自觸發在對應環境目錄的變更上（<code>paths: ['infra/environments/dev/**']</code>）。好處是只有改到的環境才跑、PR comment 乾淨；代價是三份 YAML 有重複。</p>
<p>多數團隊起步時用單 workflow + matrix，環境數量超過三個或各環境的 apply 策略不同（dev 自動、prod 要 approval）時切到獨立 workflow。</p>
<h2 id="安全邊界">安全邊界</h2>
<p>CI pipeline 是 infra 變更的自動化執行者，它的安全性等同於 apply role 的權限。幾個邊界要守住：</p>
<p><strong>OIDC claim 收斂</strong>：apply role 的 trust policy 只允許特定 repo 的 main branch 假扮（見 <a href="/blog/infra/02-identity-credentials/oidc-trust-policy-setup/" data-link-title="OIDC Trust Policy 設定指南" data-link-desc="GitHub Actions 與 AWS 之間的 OIDC 聯合設定：建立 provider、設計 trust policy 的 claim 收斂、plan 與 apply role 分離、常見錯誤排查">OIDC Trust Policy 設定</a>）。如果 claim 只驗 repo 不驗 branch，任何人在 feature branch 推一個修改過的 workflow 就能觸發 apply。</p>
<p><strong>Workflow 修改的 review</strong>：<code>.github/workflows/</code> 底下的 YAML 變更應該跟 infra code 一樣走 PR review。修改 workflow 等於修改 pipeline 的行為——加一個 <code>terraform destroy</code> step 就能在合併時清掉整個環境。GitHub 的 CODEOWNERS 功能可以強制特定人 review workflow 變更。</p>
<p><strong>Secret 與 environment variable</strong>：OIDC 取代了存在 repo secrets 裡的 access key，但 workflow 可能還用到其他 secret（Terraform Cloud token、Slack webhook URL）。這些 secret 要限定在特定 environment 才能存取，不開放給所有 branch。</p>
<p>本篇聚焦 GitHub Actions。如果團隊選擇 Atlantis（常駐服務、內建 state lock 與 apply 語意），見<a href="/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">主文章的 Atlantis 段</a>的選型討論。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/02-identity-credentials/oidc-trust-policy-setup/" data-link-title="OIDC Trust Policy 設定指南" data-link-desc="GitHub Actions 與 AWS 之間的 OIDC 聯合設定：建立 provider、設計 trust policy 的 claim 收斂、plan 與 apply role 分離、常見錯誤排查">OIDC Trust Policy 設定</a>：pipeline 的 credential 來源</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/checkov-tfsec-rule-customization/" data-link-title="checkov 與 tfsec 規則配置" data-link-desc="靜態掃描工具的規則選擇策略、自訂規則、豁免管理、false positive 處理與 CI 整合，讓掃描從噪音來源變成可信的品質關卡">checkov / tfsec 規則配置</a>：pipeline 裡的靜態安全掃描怎麼配</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">infra 走 PR 流程與自動化護欄</a>：pipeline 背後的審查原則</li>
<li>→ <a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>：多環境的目錄結構決定 pipeline 的 working directory</li>
</ul>
]]></content:encoded></item><item><title>斷網環境的 IaC</title><link>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-iac/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-iac/</guid><description>&lt;p>Terraform 在連網環境執行 &lt;code>init&lt;/code> 時會自動從 HashiCorp 的 registry 下載 provider plugin 和 module。斷網環境沒有這個路徑——provider、module、&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">state&lt;/a> backend 全部要用離線替代。&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC&lt;/a> 的核心價值（宣告式描述 + state 追蹤 + &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/terraform-plan-apply/" data-link-title="terraform plan / apply" data-link-desc="IaC 的兩個核心操作：plan 只看不動（產出差異報告）、apply 真的動（執行差異）">plan 預覽&lt;/a>）不因斷網而改變，改變的只是依賴的取得方式和 state 的存放位置。&lt;/p>
&lt;h2 id="provider-離線管理">Provider 離線管理&lt;/h2>
&lt;h3 id="provider-mirror">Provider Mirror&lt;/h3>
&lt;p>Terraform 的 &lt;code>providers mirror&lt;/code> 指令在有網路的環境把指定 provider 的二進位檔下載到本地目錄，產出符合 filesystem mirror 結構的檔案：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 在有網路的工作站執行&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">mkdir -p /path/to/mirror
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">terraform providers mirror -platform&lt;span class="o">=&lt;/span>linux_amd64 /path/to/mirror
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1"># mirror 目錄結構&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1"># /path/to/mirror/&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1"># └── registry.terraform.io/&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1"># └── hashicorp/&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1"># └── aws/&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1"># └── 5.50.0/&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1"># └── terraform-provider-aws_5.50.0_linux_amd64.zip&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>把整個 mirror 目錄搬進隔離網路後，在 Terraform 設定裡指定 filesystem mirror：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># ~/.terraformrc 或 terraform.rc（Windows）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">provider_installation&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">filesystem_mirror&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="n"> path&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;/opt/terraform/providers&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="n"> include&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;registry.terraform.io/*/*&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">direct&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="n"> exclude&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;registry.terraform.io/*/*&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>direct&lt;/code> 區塊的 &lt;code>exclude&lt;/code> 確保 Terraform 不會嘗試連網下載——如果 mirror 裡沒有某個 provider，init 會直接報錯而非 hang 在網路連線。&lt;/p>
&lt;h3 id="plugin-cache">Plugin Cache&lt;/h3>
&lt;p>替代 mirror 的另一個做法是 plugin cache directory。在有網路的環境跑過 &lt;code>init&lt;/code> 後，&lt;code>.terraform/providers/&lt;/code> 裡會有已下載的 plugin。把這整個目錄搬進隔離網路，用 &lt;code>TF_PLUGIN_CACHE_DIR&lt;/code> 環境變數指向它：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">TF_PLUGIN_CACHE_DIR&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;/opt/terraform/plugin-cache&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">terraform init&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>mirror 跟 plugin cache 的差別：mirror 是正式的離線分發機制（有版本結構、支援多平台）、plugin cache 是快取機制（省重複下載、但目錄結構跟 mirror 不同）。長期運作用 mirror，臨時驗證用 cache。&lt;/p>
&lt;h3 id="provider-版本鎖定">Provider 版本鎖定&lt;/h3>
&lt;p>斷網環境的 provider 版本管理比連網更嚴格——升級一個 provider 代表要重新搬運整個 provider binary。在 &lt;code>versions.tf&lt;/code> 裡鎖定精確版本（&lt;code>= 5.50.0&lt;/code> 而非 &lt;code>~&amp;gt; 5.50&lt;/code>），避免 &lt;code>init&lt;/code> 期待一個 mirror 裡沒有的版本：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">terraform&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">required_providers&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="n"> aws&lt;/span> &lt;span class="o">=&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="n"> source&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;hashicorp/aws&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="n"> version&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> &amp;#34;&lt;/span>&lt;span class="o">=&lt;/span> &lt;span class="m">5&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="m">50&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="err">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="module-離線來源">Module 離線來源&lt;/h2>
&lt;p>連網環境的 module source 常指向 Terraform Registry 或 GitHub：&lt;code>source = &amp;quot;terraform-aws-modules/vpc/aws&amp;quot;&lt;/code>。斷網環境要改成本地路徑或內部 git server。&lt;/p></description><content:encoded><![CDATA[<p>Terraform 在連網環境執行 <code>init</code> 時會自動從 HashiCorp 的 registry 下載 provider plugin 和 module。斷網環境沒有這個路徑——provider、module、<a href="/blog/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">state</a> backend 全部要用離線替代。<a href="/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC</a> 的核心價值（宣告式描述 + state 追蹤 + <a href="/blog/infra/knowledge-cards/terraform-plan-apply/" data-link-title="terraform plan / apply" data-link-desc="IaC 的兩個核心操作：plan 只看不動（產出差異報告）、apply 真的動（執行差異）">plan 預覽</a>）不因斷網而改變，改變的只是依賴的取得方式和 state 的存放位置。</p>
<h2 id="provider-離線管理">Provider 離線管理</h2>
<h3 id="provider-mirror">Provider Mirror</h3>
<p>Terraform 的 <code>providers mirror</code> 指令在有網路的環境把指定 provider 的二進位檔下載到本地目錄，產出符合 filesystem mirror 結構的檔案：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 在有網路的工作站執行</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">mkdir -p /path/to/mirror
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">terraform providers mirror -platform<span class="o">=</span>linux_amd64 /path/to/mirror
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># mirror 目錄結構</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># /path/to/mirror/</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># └── registry.terraform.io/</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1">#     └── hashicorp/</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1">#         └── aws/</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">#             └── 5.50.0/</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">#                 └── terraform-provider-aws_5.50.0_linux_amd64.zip</span></span></span></code></pre></div><p>把整個 mirror 目錄搬進隔離網路後，在 Terraform 設定裡指定 filesystem mirror：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># ~/.terraformrc 或 terraform.rc（Windows）
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">provider_installation</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">filesystem_mirror</span> {
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">    path</span>    <span class="o">=</span> <span class="s2">&#34;/opt/terraform/providers&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">    include</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;registry.terraform.io/*/*&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  }
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="k">direct</span> {
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">    exclude</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;registry.terraform.io/*/*&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  }
</span></span><span class="line"><span class="ln">10</span><span class="cl">}</span></span></code></pre></div><p><code>direct</code> 區塊的 <code>exclude</code> 確保 Terraform 不會嘗試連網下載——如果 mirror 裡沒有某個 provider，init 會直接報錯而非 hang 在網路連線。</p>
<h3 id="plugin-cache">Plugin Cache</h3>
<p>替代 mirror 的另一個做法是 plugin cache directory。在有網路的環境跑過 <code>init</code> 後，<code>.terraform/providers/</code> 裡會有已下載的 plugin。把這整個目錄搬進隔離網路，用 <code>TF_PLUGIN_CACHE_DIR</code> 環境變數指向它：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">export</span> <span class="nv">TF_PLUGIN_CACHE_DIR</span><span class="o">=</span><span class="s2">&#34;/opt/terraform/plugin-cache&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">terraform init</span></span></code></pre></div><p>mirror 跟 plugin cache 的差別：mirror 是正式的離線分發機制（有版本結構、支援多平台）、plugin cache 是快取機制（省重複下載、但目錄結構跟 mirror 不同）。長期運作用 mirror，臨時驗證用 cache。</p>
<h3 id="provider-版本鎖定">Provider 版本鎖定</h3>
<p>斷網環境的 provider 版本管理比連網更嚴格——升級一個 provider 代表要重新搬運整個 provider binary。在 <code>versions.tf</code> 裡鎖定精確版本（<code>= 5.50.0</code> 而非 <code>~&gt; 5.50</code>），避免 <code>init</code> 期待一個 mirror 裡沒有的版本：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">terraform</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">required_providers</span> {
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">    aws</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">      source</span>  <span class="o">=</span> <span class="s2">&#34;hashicorp/aws&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">      version</span> <span class="o">=</span><span class="n"> &#34;</span><span class="o">=</span> <span class="m">5</span><span class="p">.</span><span class="m">50</span><span class="p">.</span><span class="m">0</span><span class="err">&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    }
</span></span><span class="line"><span class="ln">7</span><span class="cl">  }
</span></span><span class="line"><span class="ln">8</span><span class="cl">}</span></span></code></pre></div><h2 id="module-離線來源">Module 離線來源</h2>
<p>連網環境的 module source 常指向 Terraform Registry 或 GitHub：<code>source = &quot;terraform-aws-modules/vpc/aws&quot;</code>。斷網環境要改成本地路徑或內部 git server。</p>
<h3 id="本地路徑">本地路徑</h3>
<p>最簡單——module 放在同一個 repo 或共用檔案系統的目錄裡：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">module</span> <span class="s2">&#34;network&#34;</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  source</span> <span class="o">=</span> <span class="s2">&#34;../../modules/network&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">}</span></span></code></pre></div><h3 id="內部-git-server">內部 Git Server</h3>
<p>如果有架 Gitea 或 GitLab CE（見<a href="/blog/infra/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">斷網通用原則</a>），module 可以指向內部的 git repo：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">module</span> <span class="s2">&#34;network&#34;</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  source</span> <span class="o">=</span><span class="n"> &#34;git::http://gitea.internal/infra/modules.git//network?ref</span><span class="o">=</span><span class="k">v1</span><span class="p">.</span><span class="m">2</span><span class="p">.</span><span class="m">0</span><span class="err">&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">}</span></span></code></pre></div><p><code>ref=v1.2.0</code> 鎖定版本。內部 git server 的 module repo 用 git bundle 從外部搬運更新。</p>
<h2 id="state-backend沒有-s3-時的替代">State Backend：沒有 S3 時的替代</h2>
<p>連網環境的 state 通常放 S3 + DynamoDB lock。斷網環境如果沒有 AWS（地端機房或隔離網路），state backend 的替代選項：</p>
<table>
  <thead>
      <tr>
          <th>Backend</th>
          <th>適用情境</th>
          <th>Lock 機制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>本地檔案 + 共用磁碟</td>
          <td>小團隊、單人操作</td>
          <td>無（靠紀律避免並行 apply）</td>
      </tr>
      <tr>
          <td>Consul</td>
          <td>內網有 Consul cluster</td>
          <td>內建 lock</td>
      </tr>
      <tr>
          <td>PostgreSQL</td>
          <td>內網有 PostgreSQL</td>
          <td>內建 lock</td>
      </tr>
      <tr>
          <td>GitLab managed state</td>
          <td>內網有 GitLab CE</td>
          <td>內建 lock</td>
      </tr>
      <tr>
          <td>HTTP backend</td>
          <td>自建簡易 API</td>
          <td>自建 lock</td>
      </tr>
  </tbody>
</table>
<p>最常見的組合是 <strong>PostgreSQL backend</strong>——多數環境已經有 PostgreSQL，不需要額外裝服務：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">terraform</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">backend</span> <span class="s2">&#34;pg&#34;</span> {
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">    conn_str</span> <span class="o">=</span><span class="n"> &#34;postgres://terraform:password@db.internal/terraform_state?sslmode</span><span class="o">=</span><span class="k">disable</span><span class="err">&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  }
</span></span><span class="line"><span class="ln">5</span><span class="cl">}</span></span></code></pre></div><p>PostgreSQL backend 的 lock 機制用 PostgreSQL 的 advisory lock，多人同時 apply 時第二個人會被擋住。</p>
<p>state 的備份紀律不變——定期 <code>terraform state pull &gt; backup.json</code>，backup 存在版本控制或另一台機器上。</p>
<h2 id="plan--apply-流程">Plan / Apply 流程</h2>
<p>斷網不影響 plan 和 apply 的執行——它們操作的是本地 provider 和目標基礎設施（地端伺服器、內部雲、VMware vSphere 等）。影響的是 provider 初始化和 module 取得，這些在前面幾節已處理。</p>
<h3 id="沒有雲端-api-的情境">沒有雲端 API 的情境</h3>
<p>如果基礎設施不是雲端（地端 VMware、OpenStack、裸機），Terraform 有對應的 provider：</p>
<ul>
<li>VMware vSphere：<code>hashicorp/vsphere</code></li>
<li>OpenStack：<code>terraform-provider-openstack/openstack</code></li>
<li>Proxmox：<code>telmate/proxmox</code>（社群維護）</li>
<li>裸機管理：用 <code>null_resource</code> + <code>local-exec</code> 呼叫 Ansible 或 shell script</li>
</ul>
<p>provider 的離線管理方式相同——mirror 或 plugin cache。</p>
<h3 id="plan-輸出的離線-review">Plan 輸出的離線 Review</h3>
<p>沒有 GitHub PR 的環境，plan 輸出用檔案分享 review：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 產出 plan 並存成可讀格式</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">terraform plan -out<span class="o">=</span>plan.tfplan
</span></span><span class="line"><span class="ln">3</span><span class="cl">terraform show plan.tfplan &gt; plan-review-<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>.txt
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 把 review 檔放到內部共用位置供 reviewer 閱讀</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">cp plan-review-*.txt /shared/reviews/</span></span></code></pre></div><p>reviewer 讀完後以 email、內部 chat、或直接在 review 檔旁邊放一個 <code>approved-by-alice-20260626.txt</code> 標記核准。不優雅但可追溯。</p>
<h2 id="內網-cicd">內網 CI/CD</h2>
<p>斷網環境的 CI/CD 用自架的 CI server：</p>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>特性</th>
          <th>適用規模</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>GitLab CE + Runner</td>
          <td>完整的 git + CI + review，功能最豐富</td>
          <td>中大團隊</td>
      </tr>
      <tr>
          <td>Gitea + Drone / Woodpecker</td>
          <td>輕量 git + 輕量 CI</td>
          <td>小團隊</td>
      </tr>
      <tr>
          <td>Jenkins</td>
          <td>老牌 CI、plugin 生態豐富</td>
          <td>任何規模（但維護成本高）</td>
      </tr>
  </tbody>
</table>
<p>CI server 本身也需要離線安裝——GitLab CE 有 offline 安裝指南（<code>.deb</code> / <code>.rpm</code> 包）、Gitea 是單一二進位。CI runner 執行 Terraform 時使用內部的 provider mirror 和 module source。</p>
<p>CI workflow 的離線版本跟連網版本結構相同（init → fmt → validate → plan → review → apply），差別在 init 用 <code>-plugin-dir</code> 而非連網下載。</p>
<p>時程參考：內網 CI server 的初次建置（含 git server + CI runner + Terraform 離線環境）約需 3-5 天。之後的維護主要是 provider 版本更新的搬運（每次 1-2 小時）。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">斷網環境的通用原則</a>：provider 和 module 的搬運走 content ferry 模式</li>
<li>→ <a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一：最小可行 IaC</a>：連網環境的 IaC 選型和 state 管理</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/" data-link-title="模組七：infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">模組七：infra 走 PR 流程</a>：連網環境的 CI pipeline 設定</li>
</ul>
]]></content:encoded></item><item><title>有半套 IaC 但文件缺失的環境接管</title><link>https://tarrragon.github.io/blog/infra/takeover/partial-iac-no-docs/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/takeover/partial-iac-no-docs/</guid><description>&lt;p>接手一個有半套 IaC 的環境，比接手全手動的環境更難處理。全手動環境的規則簡單：所有東西都在 Console，逐一盤點就好。半套 IaC 的環境則有兩套真相並存 — 有些資源由程式碼管理、有些是手動加的、有些曾經由程式碼管理但後來被手動改過。&lt;code>terraform plan&lt;/code> 跑出來一長串 diff，哪些是該收進來的手動變更、哪些是該回退的設定漂移、哪些資源根本不在 state 裡，都要逐一判斷。在搞清楚這些之前，任何 &lt;code>apply&lt;/code> 都可能覆蓋正在服務客戶的設定。&lt;/p>
&lt;p>本篇的操作流程從盤點差距開始，經過 state 健康檢查、drift 收斂、文件重建，到最後排出收斂的優先序。每一步都在不影響線上服務的前提下進行。&lt;/p>
&lt;h2 id="state-與現實的差距盤點">state 與現實的差距盤點&lt;/h2>
&lt;p>盤點的第一步是跑 &lt;code>terraform plan&lt;/code> 但不 apply — plan 的輸出就是程式碼描述的狀態與雲端現實之間的完整差距清單。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">terraform plan -no-color &amp;gt; plan-baseline-&lt;span class="k">$(&lt;/span>date +%Y%m%d&lt;span class="k">)&lt;/span>.txt&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>把這份輸出存進 repo，它是接手時的基線快照。之後每一次收斂動作的效果都用「跟這份基線比少了幾項 diff」來衡量。&lt;/p>
&lt;h3 id="三類-diff-的判讀">三類 diff 的判讀&lt;/h3>
&lt;p>plan 輸出的每一項 diff 歸屬三類，各自的風險等級與處理方式不同：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>diff 類型&lt;/th>
 &lt;th>plan 標記&lt;/th>
 &lt;th>含義&lt;/th>
 &lt;th>風險&lt;/th>
 &lt;th>處理方式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>要改&lt;/td>
 &lt;td>&lt;code>~&lt;/code> (update in-place)&lt;/td>
 &lt;td>資源存在於 state 與雲端，但屬性不一致&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>逐項判斷是採納手動變更還是回退&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>要建&lt;/td>
 &lt;td>&lt;code>+&lt;/code> (create)&lt;/td>
 &lt;td>資源在程式碼裡但雲端不存在&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>通常是前人寫了但沒 apply、或曾 destroy&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>要刪&lt;/td>
 &lt;td>&lt;code>-&lt;/code> (destroy)&lt;/td>
 &lt;td>資源在 state 裡但雲端不存在、或雲端有但程式碼想移除&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>絕對不要盲目 apply — 先確認資源是否仍在使用&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「要刪」是最危險的一類。常見成因是：前人在 Console 手動刪了某個資源但沒同步從程式碼移除（state 裡還有紀錄），或者前人在程式碼裡移除了某段 HCL 但沒跑 apply（雲端資源還在、state 記得它）。兩種情況都需要先確認該資源在雲端是否存在、是否仍被服務依賴，再決定是從 state 移除（&lt;code>terraform state rm&lt;/code>）還是補回 HCL。&lt;/p>
&lt;p>另一個需要留意的標記是 &lt;code>-/+&lt;/code>（forces replacement）— 它代表 Terraform 判定這個屬性的變更無法原地更新，必須先刪除再重建。對 stateful 資源（RDS、EBS volume）來說這等於資料遺失，在接手階段看到這個標記要先暫停、查清楚是哪個屬性觸發了 replacement。&lt;/p>
&lt;h2 id="哪些資源在-state-裡哪些不在">哪些資源在 state 裡、哪些不在&lt;/h2>
&lt;p>&lt;code>terraform state list&lt;/code> 列出所有被 IaC 管理的資源。配合 &lt;code>terraform show -json&lt;/code> 可以取得更結構化的 managed resource 摘要：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># state 裡有什麼（清單）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">terraform state list &amp;gt; managed-resources.txt
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># state 裡有什麼（結構化摘要：type + name + provider）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">terraform show -json &lt;span class="p">|&lt;/span> jq &lt;span class="s1">&amp;#39;.values.root_module.resources[] | {type, name, provider}&amp;#39;&lt;/span> &amp;gt; managed-summary.json&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>但 state 只是一份已知的清單 — 雲端上可能還有大量不在這份清單裡的資源。用 CLI 列舉雲端資源跟 state 做比對：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1"># 雲端上有什麼（以 EC2 + RDS + SG 為例）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">aws ec2 describe-instances --query &lt;span class="s1">&amp;#39;Reservations[].Instances[].InstanceId&amp;#39;&lt;/span> --output text &amp;gt; cloud-ec2.txt
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">aws rds describe-db-instances --query &lt;span class="s1">&amp;#39;DBInstances[].DBInstanceIdentifier&amp;#39;&lt;/span> --output text &amp;gt; cloud-rds.txt
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">aws ec2 describe-security-groups --query &lt;span class="s1">&amp;#39;SecurityGroups[].GroupId&amp;#39;&lt;/span> --output text &amp;gt; cloud-sg.txt&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>用這兩份清單做比對，分成三類：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>類別&lt;/th>
 &lt;th>定義&lt;/th>
 &lt;th>下一步&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>已管理&lt;/td>
 &lt;td>state 裡有、雲端也有&lt;/td>
 &lt;td>處理 drift（上一節的 diff）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>未管理&lt;/td>
 &lt;td>雲端有、state 裡沒有&lt;/td>
 &lt;td>評估是否需要 import&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>孤兒&lt;/td>
 &lt;td>state 裡有、雲端沒有&lt;/td>
 &lt;td>&lt;code>terraform state rm&lt;/code> 清除過時紀錄&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>未管理的資源需要逐一判斷：這個資源是前人刻意排除在 IaC 外的（例如一個還在實驗的測試機），還是應該納管但漏了？判斷依據是它的角色 — security group、IAM role、VPC 這類地基資源應該優先 import；一台跑完就該關的測試 EC2 可以暫時留在手動。&lt;/p></description><content:encoded><![CDATA[<p>接手一個有半套 IaC 的環境，比接手全手動的環境更難處理。全手動環境的規則簡單：所有東西都在 Console，逐一盤點就好。半套 IaC 的環境則有兩套真相並存 — 有些資源由程式碼管理、有些是手動加的、有些曾經由程式碼管理但後來被手動改過。<code>terraform plan</code> 跑出來一長串 diff，哪些是該收進來的手動變更、哪些是該回退的設定漂移、哪些資源根本不在 state 裡，都要逐一判斷。在搞清楚這些之前，任何 <code>apply</code> 都可能覆蓋正在服務客戶的設定。</p>
<p>本篇的操作流程從盤點差距開始，經過 state 健康檢查、drift 收斂、文件重建，到最後排出收斂的優先序。每一步都在不影響線上服務的前提下進行。</p>
<h2 id="state-與現實的差距盤點">state 與現實的差距盤點</h2>
<p>盤點的第一步是跑 <code>terraform plan</code> 但不 apply — plan 的輸出就是程式碼描述的狀態與雲端現實之間的完整差距清單。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">terraform plan -no-color &gt; plan-baseline-<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>.txt</span></span></code></pre></div><p>把這份輸出存進 repo，它是接手時的基線快照。之後每一次收斂動作的效果都用「跟這份基線比少了幾項 diff」來衡量。</p>
<h3 id="三類-diff-的判讀">三類 diff 的判讀</h3>
<p>plan 輸出的每一項 diff 歸屬三類，各自的風險等級與處理方式不同：</p>
<table>
  <thead>
      <tr>
          <th>diff 類型</th>
          <th>plan 標記</th>
          <th>含義</th>
          <th>風險</th>
          <th>處理方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>要改</td>
          <td><code>~</code> (update in-place)</td>
          <td>資源存在於 state 與雲端，但屬性不一致</td>
          <td>中</td>
          <td>逐項判斷是採納手動變更還是回退</td>
      </tr>
      <tr>
          <td>要建</td>
          <td><code>+</code> (create)</td>
          <td>資源在程式碼裡但雲端不存在</td>
          <td>低</td>
          <td>通常是前人寫了但沒 apply、或曾 destroy</td>
      </tr>
      <tr>
          <td>要刪</td>
          <td><code>-</code> (destroy)</td>
          <td>資源在 state 裡但雲端不存在、或雲端有但程式碼想移除</td>
          <td>高</td>
          <td>絕對不要盲目 apply — 先確認資源是否仍在使用</td>
      </tr>
  </tbody>
</table>
<p>「要刪」是最危險的一類。常見成因是：前人在 Console 手動刪了某個資源但沒同步從程式碼移除（state 裡還有紀錄），或者前人在程式碼裡移除了某段 HCL 但沒跑 apply（雲端資源還在、state 記得它）。兩種情況都需要先確認該資源在雲端是否存在、是否仍被服務依賴，再決定是從 state 移除（<code>terraform state rm</code>）還是補回 HCL。</p>
<p>另一個需要留意的標記是 <code>-/+</code>（forces replacement）— 它代表 Terraform 判定這個屬性的變更無法原地更新，必須先刪除再重建。對 stateful 資源（RDS、EBS volume）來說這等於資料遺失，在接手階段看到這個標記要先暫停、查清楚是哪個屬性觸發了 replacement。</p>
<h2 id="哪些資源在-state-裡哪些不在">哪些資源在 state 裡、哪些不在</h2>
<p><code>terraform state list</code> 列出所有被 IaC 管理的資源。配合 <code>terraform show -json</code> 可以取得更結構化的 managed resource 摘要：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># state 裡有什麼（清單）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">terraform state list &gt; managed-resources.txt
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># state 裡有什麼（結構化摘要：type + name + provider）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">terraform show -json <span class="p">|</span> jq <span class="s1">&#39;.values.root_module.resources[] | {type, name, provider}&#39;</span> &gt; managed-summary.json</span></span></code></pre></div><p>但 state 只是一份已知的清單 — 雲端上可能還有大量不在這份清單裡的資源。用 CLI 列舉雲端資源跟 state 做比對：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 雲端上有什麼（以 EC2 + RDS + SG 為例）</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">aws ec2 describe-instances --query <span class="s1">&#39;Reservations[].Instances[].InstanceId&#39;</span> --output text &gt; cloud-ec2.txt
</span></span><span class="line"><span class="ln">4</span><span class="cl">aws rds describe-db-instances --query <span class="s1">&#39;DBInstances[].DBInstanceIdentifier&#39;</span> --output text &gt; cloud-rds.txt
</span></span><span class="line"><span class="ln">5</span><span class="cl">aws ec2 describe-security-groups --query <span class="s1">&#39;SecurityGroups[].GroupId&#39;</span> --output text &gt; cloud-sg.txt</span></span></code></pre></div><p>用這兩份清單做比對，分成三類：</p>
<table>
  <thead>
      <tr>
          <th>類別</th>
          <th>定義</th>
          <th>下一步</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>已管理</td>
          <td>state 裡有、雲端也有</td>
          <td>處理 drift（上一節的 diff）</td>
      </tr>
      <tr>
          <td>未管理</td>
          <td>雲端有、state 裡沒有</td>
          <td>評估是否需要 import</td>
      </tr>
      <tr>
          <td>孤兒</td>
          <td>state 裡有、雲端沒有</td>
          <td><code>terraform state rm</code> 清除過時紀錄</td>
      </tr>
  </tbody>
</table>
<p>未管理的資源需要逐一判斷：這個資源是前人刻意排除在 IaC 外的（例如一個還在實驗的測試機），還是應該納管但漏了？判斷依據是它的角色 — security group、IAM role、VPC 這類地基資源應該優先 import；一台跑完就該關的測試 EC2 可以暫時留在手動。</p>
<p>手動比對 state list 與 CLI 輸出的效率有限，driftctl（現由 Snyk 維護、開源）可以自動掃描雲端資源與 Terraform state 的差異，一次列出所有 unmanaged resource。它跟 <code>terraform plan</code> 的差別在於 plan 只看已管理資源的 drift，driftctl 同時涵蓋根本不在 state 裡的資源。兩者互補：先用 driftctl 產出完整的 unmanaged 清單，再用 plan 處理已管理資源的 drift。</p>
<h2 id="state-的健康檢查">state 的健康檢查</h2>
<p>state 本身的存放方式決定了後續所有操作的安全性。接手後第一件事是確認 state 的健康狀態。</p>
<h3 id="存放位置">存放位置</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 查看 backend 設定</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">grep -A <span class="m">10</span> <span class="s1">&#39;backend&#39;</span> *.tf</span></span></code></pre></div><p>如果 backend 是 <code>local</code>（或沒有 backend 設定），state 檔只存在某台機器的磁碟上。這代表如果有第二個人從自己的機器跑 <code>apply</code>，兩人會用不同版本的 state 互相覆蓋。把 state 搬到 remote backend（S3 + DynamoDB lock）是接手後的第一優先事項，做法見<a href="/blog/infra/01-minimal-iac/iac-tool-state-backend/" data-link-title="IaC 工具選型與 state 地基" data-link-desc="Terraform / OpenTofu / CDK / Pulumi 的選型判準，state 作為 IaC 工具對現實的唯一記憶，以及 remote state backend 的自管與託管路線">IaC 工具選型與 state 地基</a>。</p>
<h3 id="加密與版本控制">加密與版本控制</h3>
<p>如果 state 已經在 S3，確認三件事：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># bucket 有沒有 versioning</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws s3api get-bucket-versioning --bucket &lt;state-bucket&gt;
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># bucket 有沒有加密</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">aws s3api get-bucket-encryption --bucket &lt;state-bucket&gt;
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># 有沒有 lock table</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">aws dynamodb describe-table --table-name &lt;lock-table&gt; 2&gt;/dev/null</span></span></code></pre></div><p>versioning 沒開的話，一次壞掉的 apply 寫壞 state 就回不去了。加密沒開的話，state 裡的敏感值（資料庫密碼、private key 輸出）以明文存在 S3。</p>
<h3 id="state-裡的敏感值">state 裡的敏感值</h3>
<p>state 檔經常包含不該暴露的值。確認 state 有沒有在 Git 歷史裡：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git log --all --diff-filter<span class="o">=</span>A -- <span class="s1">&#39;*.tfstate&#39;</span> <span class="s1">&#39;*.tfstate.backup&#39;</span></span></span></code></pre></div><p>如果命中，代表 state 曾經被推進 repo。此時 Git 歷史裡的敏感值已經無法徹底清除（<code>git filter-branch</code> 或 <code>git filter-repo</code> 可以嘗試，但無法保證所有 clone 都更新）。務實的處理是：列出 state 裡的敏感值，全部輪替。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 用 jq 從 state JSON 撈敏感值候選</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">terraform show -json <span class="p">|</span> jq -r <span class="s1">&#39;
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s1">  [.. | objects | to_entries[] |
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s1">   select(.key | test(&#34;password|secret|key|token&#34;; &#34;i&#34;))] |
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s1">  unique_by(.key) | .[] | &#34;\(.key): \(.value)&#34;
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s1">&#39;</span> 2&gt;/dev/null</span></span></code></pre></div><p>這個 jq 查詢會遞迴掃描 state JSON 裡所有欄位名稱含 password / secret / key / token 的值。命中的每一筆都要確認是否為真實密鑰、是否需要輪替。</p>
<h2 id="drift-收斂策略">drift 收斂策略</h2>
<p>盤點完差距、確認 state 健康之後，逐項收斂 drift。對 plan 輸出的每一項 diff 做一個二選一的決定：採納手動變更（改 HCL 去符合現實），或回退到程式碼版本（讓下一次 apply 把現實改回來）。</p>
<h3 id="採納-vs-回退的判斷">採納 vs 回退的判斷</h3>
<p>多數 drift 應該採納。前人在 Console 手動改設定通常有一個操作理由（即使沒有記錄下來）— 加了一條 security group 規則可能是為了讓某個新服務連進來，改了 RDS 的 <code>max_connections</code> 可能是為了解決連線數不足。在沒有充分理解這些改動的背景之前，回退它們等於撤銷一個可能正在支撐服務運作的設定。</p>
<p>回退適用的情境是：drift 明顯是誤操作（例如 <code>0.0.0.0/0</code> 打開了不該打開的埠）、或 drift 的屬性是有標準答案的（例如 S3 的 <code>block_public_access</code> 被關掉了）。</p>
<h3 id="操作步驟">操作步驟</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 1. 刷新 state 到最新雲端狀態（不改資源、只更新 state 的快照）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">terraform apply -refresh-only
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 2. 再跑一次 plan — 刷新後 diff 會減少（純 state 過期的 diff 消失）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">terraform plan -no-color &gt; plan-after-refresh.txt
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># 3. 對剩餘的 diff 逐項處理</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">#    採納：改 HCL 讓程式碼跟現實一致 → plan 確認該項 diff 消失</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1">#    回退：不改 HCL、讓 apply 把現實改回程式碼版本 → 先確認影響</span></span></span></code></pre></div><p><code>-refresh-only</code> 是安全的操作 — 它只更新 state 裡的屬性快照，不會改動任何雲端資源。但它會把手動變更「記進」state，讓後續 plan 的 diff 只剩程式碼與 state 的差異（而非程式碼與雲端的差異）。刷新後 plan 的 diff 更精確、更少、更容易逐項處理。</p>
<h3 id="import-未管理的資源">import 未管理的資源</h3>
<p>對未管理的資源，用 <code>import</code> 區塊一次處理一個，每次 import 後都跑 plan 確認零新增 diff：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">import</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  to</span> <span class="o">=</span> <span class="k">aws_security_group</span><span class="p">.</span><span class="k">legacy_app</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  id</span> <span class="o">=</span> <span class="s2">&#34;sg-0abc123def456&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">}</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 生成對應的 HCL</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">terraform plan -generate-config-out<span class="o">=</span>generated_legacy_app.tf
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 確認生成的 HCL 跟現實一致</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">terraform plan
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># 預期：只有 import 動作、沒有 change/destroy</span></span></span></code></pre></div><p>生成的 HCL 需要人工確認 — 有些屬性是雲端自動設的預設值，Terraform 會把它們全部列出來，造成 HCL 冗長。移除純預設值的屬性、只保留有意義的設定，讓 HCL 反映設計意圖而非雲端預設。</p>
<p>對於大量未管理資源需要一次性反推 HCL 的情境，Former2 可以從現有 AWS 資源批量生成 Terraform code。它掃描帳號裡的資源、產出對應的 HCL，品質不完美（命名會用資源 ID 而非有意義的名稱、屬性可能包含大量預設值），但作為起點比從零手寫每個資源快得多。產出後仍需逐檔清理命名與移除預設值。</p>
<h2 id="文件重建">文件重建</h2>
<p>接手的環境通常沒有文件、或者文件已經過時到比沒有更糟（記載的是兩個版本前的架構）。文件重建的目標是讓下一個接手者不需要重複同樣的盤點過程，而非追求一份完美的架構文件。</p>
<h3 id="來源">來源</h3>
<p>能重建的資訊來源有限，但每個都有價值：</p>
<table>
  <thead>
      <tr>
          <th>來源</th>
          <th>能找到什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Git log</td>
          <td>commit 訊息裡可能有「為什麼這樣改」的線索</td>
      </tr>
      <tr>
          <td>PR 歷史</td>
          <td>review 討論裡可能有決策脈絡</td>
      </tr>
      <tr>
          <td>HCL 程式碼</td>
          <td>變數命名、module 結構反映架構意圖</td>
      </tr>
      <tr>
          <td>CloudTrail</td>
          <td>過去 90 天的 API 呼叫紀錄</td>
      </tr>
      <tr>
          <td>帳單</td>
          <td>哪些服務在花錢、量級多大</td>
      </tr>
      <tr>
          <td>terraform-docs</td>
          <td>從 HCL 自動產出 module 文件（inputs/outputs）</td>
      </tr>
      <tr>
          <td>Inframap</td>
          <td>從 state 產出依賴關係視覺化圖</td>
      </tr>
  </tbody>
</table>
<p>terraform-docs 用一條指令就能從現有 HCL 產出每個 module 的 inputs、outputs 和 resources 清單，省去手動整理 module 介面的時間。Inframap 從 state 或 HCL 產出依賴關係圖，比 <code>terraform graph | dot</code> 好用的地方在於它自動過濾掉 provider 和 data source 的噪音，大型 state 也能產出可讀的圖。</p>
<h3 id="最小可行文件">最小可行文件</h3>
<p>寫一份 <code>INFRA-STATE.md</code> 放在 repo 根目錄，包含：</p>
<ul>
<li><strong>管理範圍</strong>：哪些資源由 IaC 管理、哪些是手動的、為什麼手動的沒有 import（例：還在實驗、不穩定、計畫廢棄）</li>
<li><strong>已知 drift</strong>：目前 plan 輸出裡還有哪些未處理的 diff、每個 diff 的處理方向（採納/回退/待調查）</li>
<li><strong>state 存放位置</strong>：backend 設定、bucket 名稱、lock table 名稱</li>
<li><strong>credential 狀態</strong>：有幾把 access key、哪些還在用、上次輪替時間</li>
<li><strong>接手日期與盤點結果</strong>：盤點時的資源數量、覆蓋率（managed / total）</li>
</ul>
<p>這份文件不需要精美，需要的是準確且持續更新。每次收斂一項 drift 或 import 一個資源，就更新對應的段落。前任團隊的知識已經不在了，這份文件取代它成為環境的記憶。</p>
<h2 id="收斂到完整-iac-的優先序">收斂到完整 IaC 的優先序</h2>
<p>把整個收斂過程排成四個階段，每個階段都能獨立交付價值：</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>目標</th>
          <th>交付物</th>
          <th>預估時間</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>state 健康</td>
          <td>remote backend + 加密 + versioning + lock</td>
          <td>1-2 天</td>
      </tr>
      <tr>
          <td>2</td>
          <td>地基 import</td>
          <td>security group、IAM role、VPC 納管</td>
          <td>1-2 週</td>
      </tr>
      <tr>
          <td>3</td>
          <td>drift 收斂</td>
          <td>已管理資源的 plan 歸零</td>
          <td>1-2 週</td>
      </tr>
      <tr>
          <td>4</td>
          <td>覆蓋率提升</td>
          <td>應用層資源逐批 import</td>
          <td>持續</td>
      </tr>
  </tbody>
</table>
<p>每個階段的驗證方式相同：<code>terraform plan</code> 的輸出是否比上一階段乾淨。階段一完成後，plan 的可信度才成立；階段二和三是把 plan 的 diff 清到零；階段四是擴大 plan 的管轄範圍。</p>
<p>每一步操作之前都先備份 state：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 手動備份 state（不論 bucket 有沒有 versioning 都先拉一份）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">terraform state pull &gt; state-backup-<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>.json</span></span></code></pre></div><p>state 操作失敗時的回退路徑是 <code>terraform state push state-backup.json</code> 從備份還原 — 資源本身不受影響，只是工具對現實的記憶回到上一個正確的版本。<code>state push</code> 是覆寫操作，只在確認備份版本正確時使用。</p>
<p>需要搬移資源在 state 裡的位址時（例如重構 module 結構），優先用 <code>moved {}</code> 區塊而非 <code>terraform state mv</code>。<code>moved</code> 是宣告式的、寫在 HCL 裡、可以被 PR review、plan 時會顯示搬移動作。<code>state mv</code> 是指令式的、直接改 state、沒有 review 機制、操作紀錄只在 CLI 歷史裡。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">moved</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  from</span> <span class="o">=</span> <span class="k">aws_security_group</span><span class="p">.</span><span class="k">old_name</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  to</span>   <span class="o">=</span> <span class="k">module</span><span class="p">.</span><span class="k">network</span><span class="p">.</span><span class="k">aws_security_group</span><span class="p">.</span><span class="k">app</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">}</span></span></code></pre></div><h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/01-minimal-iac/iac-tool-state-backend/" data-link-title="IaC 工具選型與 state 地基" data-link-desc="Terraform / OpenTofu / CDK / Pulumi 的選型判準，state 作為 IaC 工具對現實的唯一記憶，以及 remote state backend 的自管與託管路線">IaC 工具選型與 state 地基</a>：state 怎麼從 local 搬到 remote backend</li>
<li>→ <a href="/blog/infra/01-minimal-iac/console-readonly-minimal-viable/" data-link-title="Console 唯讀鐵律與最小可行資源集合" data-link-desc="Console 只用來看不用來改的操作紀律、drift 的延遲浮現與偵測，以及能跑出第一個完整 apply 迴路的最小資源集合">Console 唯讀鐵律</a>：drift 的來源與偵測</li>
<li>→ <a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">環境分離與模組化</a>：收斂完成後怎麼把單環境拆成 per-env module</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/" data-link-title="模組七：infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">infra 走 PR 流程</a>：收斂完成後的變更怎麼走 review</li>
<li>→ <a href="/blog/infra/takeover/partial-iac-state-repair/" data-link-title="State 修復與清理" data-link-desc="接手的 Terraform state 損壞、有 orphaned entry、或需要搬遷時，怎麼診斷問題、安全操作、以及從錯誤中回復">State 修復與清理</a>：state 損壞的操作修復步驟</li>
<li>→ <a href="/blog/infra/takeover/partial-iac-drift-triage/" data-link-title="Drift 分類處理指南" data-link-desc="接手半套 IaC 環境時，怎麼讀 plan 輸出分類 drift、判斷保留還是回退、處理 stateful 資源的高風險漂移，以及批次收斂的工作流">Drift 分類處理</a>：逐項判斷 adopt vs revert</li>
<li>→ <a href="/blog/infra/takeover/partial-iac-bulk-import/" data-link-title="Unmanaged Resource 批次 Import 工作流" data-link-desc="把 Terraform state 外的雲端資源有系統地納入 IaC 管理：優先序判斷、import block 語法、generated HCL 的 review 要點、批次策略與常見失敗處理">批次 Import 工作流</a>：unmanaged resource 的 import 操作</li>
<li>→ <a href="/blog/infra/takeover/partial-iac-dual-truth-operation/" data-link-title="兩套真相並存的過渡期操作" data-link-desc="部分資源在 IaC、部分在手動時，怎麼安全操作避免比全手動更危險，以及怎麼縮短這個過渡期">過渡期操作</a>：兩套真相並存時的安全操作規則</li>
</ul>
]]></content:encoded></item><item><title>Terraform → OpenTofu：HCL 跟 state file 級 drop-in、CI runner 切 binary 完成</title><link>https://tarrragon.github.io/blog/backend/05-deployment-platform/vendors/terraform/migrate-to-opentofu/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/05-deployment-platform/vendors/terraform/migrate-to-opentofu/</guid><description>&lt;blockquote>
&lt;p>本文是跨 vendor migration playbook、cross-link &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/vendors/terraform/" data-link-title="Terraform / OpenTofu" data-link-desc="Infrastructure as Code 主流工具">Terraform&lt;/a>（source）跟 OpenTofu（target）。Type B drop-in migration 標準形態、跑 &lt;a href="https://tarrragon.github.io/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">migration-playbook-methodology 6 維 audit&lt;/a> 後對映 &lt;em>6 維皆 Low → Type B drop-in&lt;/em>；本文驗證 skill 的 Type B anatomy 在 IaC 領域成立。&lt;/p>&lt;/blockquote>
&lt;h2 id="hcl--state-file--provider-三層-diff-sample">HCL / state file / provider 三層 diff sample&lt;/h2>
&lt;p>跟前批 &lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/vendors/redis/migrate-to-dragonflydb/" data-link-title="Redis → DragonflyDB：drop-in 相容下的容量躍升 &amp;#43; 5 個踩雷" data-link-desc="DragonflyDB 號稱 Redis drop-in 替代、單機 throughput 25x、記憶體效率 30% 提升；遷移流程簡單但有 5 個 production 踩雷（RDB 版本差 / Lua 腳本不全支援 / Pub-Sub fanout 行為差異 / Cluster mode 兼容度 / Modules 不支援）、跟 Sentinel / Cluster 模式對位">Redis → DragonflyDB&lt;/a> 同為 Type B drop-in、本文用 code-led entry — 直接給 3 種 diff sample 證明「真 drop-in」：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 1. HCL syntax: 完全相同 (Terraform 1.5.x baseline)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_s3_bucket&amp;#34; &amp;#34;logs&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="n"> bucket&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;myapp-logs&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="n"> tags&lt;/span> &lt;span class="o">=&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="n"> Env&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;production&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="err">#&lt;/span> &lt;span class="k">兩家&lt;/span> &lt;span class="k">binary&lt;/span> &lt;span class="k">都接受&lt;/span>&lt;span class="err">、&lt;/span>&lt;span class="k">執行結果一致&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 2. State file: 完全相同 schema&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">$ cat terraform.tfstate &lt;span class="p">|&lt;/span> jq &lt;span class="s1">&amp;#39;.version, .terraform_version&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="m">4&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="s2">&amp;#34;1.5.7&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1"># 切 OpenTofu 後 re-init、state 保留&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">$ tofu init
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">$ cat terraform.tfstate &lt;span class="p">|&lt;/span> jq &lt;span class="s1">&amp;#39;.version, .terraform_version&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="m">4&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="s2">&amp;#34;1.6.0&amp;#34;&lt;/span> &lt;span class="c1"># tool version 標記變、其他不變&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 3. Provider: registry 路徑唯一明顯差異
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">terraform&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">required_providers&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="n"> aws&lt;/span> &lt;span class="o">=&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="n"> source&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;hashicorp/aws&amp;#34;&lt;/span>&lt;span class="c1"> # 兩家共用 source 字串
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n"> version&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;~&amp;gt; 5.0&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1"># Terraform 從 registry.terraform.io 拉
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="err">#&lt;/span> &lt;span class="k">OpenTofu&lt;/span> &lt;span class="k">預設從&lt;/span> &lt;span class="k">registry&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">opentofu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">org&lt;/span> &lt;span class="k">拉&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="k">fallback&lt;/span> &lt;span class="k">到&lt;/span> &lt;span class="k">terraform&lt;/span> &lt;span class="k">registry&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>3 層 diff sample 顯示：HCL / state schema / 主流 provider 配置完全相容；唯一明顯差異在 &lt;em>registry routing&lt;/em>。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是跨 vendor migration playbook、cross-link <a href="/blog/backend/05-deployment-platform/vendors/terraform/" data-link-title="Terraform / OpenTofu" data-link-desc="Infrastructure as Code 主流工具">Terraform</a>（source）跟 OpenTofu（target）。Type B drop-in migration 標準形態、跑 <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">migration-playbook-methodology 6 維 audit</a> 後對映 <em>6 維皆 Low → Type B drop-in</em>；本文驗證 skill 的 Type B anatomy 在 IaC 領域成立。</p></blockquote>
<h2 id="hcl--state-file--provider-三層-diff-sample">HCL / state file / provider 三層 diff sample</h2>
<p>跟前批 <a href="/blog/backend/02-cache-redis/vendors/redis/migrate-to-dragonflydb/" data-link-title="Redis → DragonflyDB：drop-in 相容下的容量躍升 &#43; 5 個踩雷" data-link-desc="DragonflyDB 號稱 Redis drop-in 替代、單機 throughput 25x、記憶體效率 30% 提升；遷移流程簡單但有 5 個 production 踩雷（RDB 版本差 / Lua 腳本不全支援 / Pub-Sub fanout 行為差異 / Cluster mode 兼容度 / Modules 不支援）、跟 Sentinel / Cluster 模式對位">Redis → DragonflyDB</a> 同為 Type B drop-in、本文用 code-led entry — 直接給 3 種 diff sample 證明「真 drop-in」：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 1. HCL syntax: 完全相同 (Terraform 1.5.x baseline)
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">resource</span> <span class="s2">&#34;aws_s3_bucket&#34; &#34;logs&#34;</span> {
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  bucket</span> <span class="o">=</span> <span class="s2">&#34;myapp-logs&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  tags</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">    Env</span> <span class="o">=</span> <span class="s2">&#34;production&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  }
</span></span><span class="line"><span class="ln">7</span><span class="cl">}
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="err">#</span> <span class="k">兩家</span> <span class="k">binary</span> <span class="k">都接受</span><span class="err">、</span><span class="k">執行結果一致</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 2. State file: 完全相同 schema</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">$ cat terraform.tfstate <span class="p">|</span> jq <span class="s1">&#39;.version, .terraform_version&#39;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="m">4</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s2">&#34;1.5.7&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># 切 OpenTofu 後 re-init、state 保留</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">$ tofu init
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">$ cat terraform.tfstate <span class="p">|</span> jq <span class="s1">&#39;.version, .terraform_version&#39;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="m">4</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s2">&#34;1.6.0&#34;</span>  <span class="c1"># tool version 標記變、其他不變</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 3. Provider: registry 路徑唯一明顯差異
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">terraform</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">required_providers</span> {
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">    aws</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">      source</span>  <span class="o">=</span> <span class="s2">&#34;hashicorp/aws&#34;</span><span class="c1">     # 兩家共用 source 字串
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="n">      version</span> <span class="o">=</span> <span class="s2">&#34;~&gt; 5.0&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    }
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  }
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">}<span class="c1">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"># Terraform 從 registry.terraform.io 拉
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="err">#</span> <span class="k">OpenTofu</span> <span class="k">預設從</span> <span class="k">registry</span><span class="p">.</span><span class="k">opentofu</span><span class="p">.</span><span class="k">org</span> <span class="k">拉</span> <span class="p">(</span><span class="k">fallback</span> <span class="k">到</span> <span class="k">terraform</span> <span class="k">registry</span><span class="p">)</span></span></span></code></pre></div><p>3 層 diff sample 顯示：HCL / state schema / 主流 provider 配置完全相容；唯一明顯差異在 <em>registry routing</em>。</p>
<p>跑 <a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">6 維 diff dimension audit</a>：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>評估</th>
          <th>等級</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Schema / API</td>
          <td>HCL 完全相容、CLI command 對映 (terraform → tofu)</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Operational model</td>
          <td>同 workflow (init / plan / apply)</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Paradigm</td>
          <td>同 IaC declarative</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Components</td>
          <td>同 single binary</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Application change</td>
          <td>無（不是 application、是 infrastructure tool）</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Data topology</td>
          <td>同 single state file backend</td>
          <td>Low</td>
      </tr>
  </tbody>
</table>
<p>6 維皆 Low → Type B drop-in。</p>
<h2 id="為什麼遷license--governance--community-三條-driver">為什麼遷：license / governance / community 三條 driver</h2>
<p>跟前批 <a href="/blog/backend/02-cache-redis/vendors/redis/migrate-to-dragonflydb/" data-link-title="Redis → DragonflyDB：drop-in 相容下的容量躍升 &#43; 5 個踩雷" data-link-desc="DragonflyDB 號稱 Redis drop-in 替代、單機 throughput 25x、記憶體效率 30% 提升；遷移流程簡單但有 5 個 production 踩雷（RDB 版本差 / Lua 腳本不全支援 / Pub-Sub fanout 行為差異 / Cluster mode 兼容度 / Modules 不支援）、跟 Sentinel / Cluster 模式對位">Redis → DragonflyDB</a> 不同（cost / performance driver）、Terraform → OpenTofu 主要 driver 在 governance：</p>
<table>
  <thead>
      <tr>
          <th>Driver</th>
          <th>觸發場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>License</strong></td>
          <td>Terraform 在 2023-08 改 BSL（Business Source License）、商業使用限制；OpenTofu 維持 MPL 2.0 開源</td>
      </tr>
      <tr>
          <td><strong>Vendor neutrality</strong></td>
          <td>多雲 / 多客戶情境想避免 HashiCorp lock-in、用 Linux Foundation 治理的 OpenTofu</td>
      </tr>
      <tr>
          <td><strong>Community / feature</strong></td>
          <td>OpenTofu 1.6+ 加 state encryption、跟 Terraform 商業版差異化、社群驅動 feature</td>
      </tr>
  </tbody>
</table>
<p>反向 driver（OpenTofu → Terraform）：</p>
<ul>
<li>Terraform Cloud / Enterprise 特定 feature 依賴（policy as code 用 Sentinel、跟 OpenTofu 自家 OPA 不對等）</li>
<li>既有 module 在 Terraform registry 維護、未同步 OpenTofu registry</li>
</ul>
<h2 id="相容性-audit">相容性 audit</h2>
<p>Pre-cutover 必跑：</p>
<table>
  <thead>
      <tr>
          <th>議題</th>
          <th>處理方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Terraform version pin（<code>required_version = &quot;&gt;= 1.5.0, &lt; 1.6.0&quot;</code>）</td>
          <td>改 <code>&gt;= 1.6.0</code> 涵蓋 OpenTofu / 移除 upper bound</td>
      </tr>
      <tr>
          <td>Provider 來源 (registry path)</td>
          <td>主流 provider（aws / azurerm / gcp / k8s）都同源、自家 / 第三方 provider 確認 OpenTofu registry mirror</td>
      </tr>
      <tr>
          <td>Terraform Cloud / Enterprise feature</td>
          <td>Sentinel policy → OpenTofu OPA / Conftest；workspace API 對等性逐項 check</td>
      </tr>
      <tr>
          <td>CLI binary name 在 CI pipeline</td>
          <td><code>terraform plan</code> → <code>tofu plan</code>、或 alias <code>terraform=tofu</code> 保留兼容</td>
      </tr>
      <tr>
          <td>State backend (S3 / GCS / Azure / Consul / Terraform Cloud)</td>
          <td>S3/GCS/Azure 完全相容；Consul backend 兩家都支援；Terraform Cloud 走自家 remote backend、不直通</td>
      </tr>
      <tr>
          <td>Module source</td>
          <td>git-based module 完全相容；registry module 確認 OpenTofu registry 有 mirror</td>
      </tr>
  </tbody>
</table>
<p>Audit output：列「100% drop-in」block + 「需處理」block；後者通常 &lt; 5% 範圍。</p>
<h2 id="step-by-step-cutover">Step-by-step cutover</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 1. Install OpenTofu (跨 OS)</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">brew install opentofu                <span class="c1"># macOS</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">snap install --classic opentofu      <span class="c1"># Ubuntu</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># https://opentofu.org/docs/intro/install/</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># 2. 在 workspace 跑 tofu init</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">$ <span class="nb">cd</span> terraform-workspace/
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">$ tofu init -upgrade
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># 升級 provider / module、re-init backend、保留 state</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># 3. Plan diff（應該 = 0 changes）</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">$ tofu plan
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"># Plan: 0 to add, 0 to change, 0 to destroy.</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"># 如果有 diff、表示 provider version 不對齊、檢查 lock file</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1"># 4. Apply（保險起見、staging 先跑）</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">$ tofu apply
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"># 5. CI / CD pipeline 切 binary</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="c1"># Before</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">terraform init
</span></span><span class="line"><span class="ln">22</span><span class="cl">terraform plan -out<span class="o">=</span>tfplan
</span></span><span class="line"><span class="ln">23</span><span class="cl">terraform apply tfplan
</span></span><span class="line"><span class="ln">24</span><span class="cl">
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="c1"># After</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">tofu init
</span></span><span class="line"><span class="ln">27</span><span class="cl">tofu plan -out<span class="o">=</span>tfplan
</span></span><span class="line"><span class="ln">28</span><span class="cl">tofu apply tfplan
</span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="c1"># 或保留 terraform 字面、用 alias / symlink</span></span></span></code></pre></div><p>整個 cutover 通常 &lt; 1 天（單 workspace）；多 workspace organization 視規模 1-4 週逐個切。</p>
<h2 id="production-故障演練">Production 故障演練</h2>
<h3 id="case-1provider-version-driftstaging-plan-出現意外-diff">Case 1：Provider version drift、staging plan 出現意外 diff</h3>
<p><strong>徵兆</strong>：<code>tofu plan</code> 顯示 100+ resource 有 in-place update、實際業務沒改任何 config。</p>
<p><strong>根因</strong>：<code>.terraform.lock.hcl</code> 鎖的 provider version 在 Terraform / OpenTofu registry 不一致（同 version 但 binary checksum 微差）；OpenTofu 在 init 時拉新 checksum、視為「provider 變了」。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>預先對齊</strong>：<code>tofu init -upgrade</code> 重建 lock file、把 OpenTofu 端 checksum 寫進去</li>
<li><strong>CI lockfile commit</strong>：lock file 進版控、不同 binary 端跑前先 lockfile 對齊</li>
<li><strong>若 plan 仍有差異</strong>：通常是 provider 內部 schema 對 nil 值處理不同、用 <code>lifecycle.ignore_changes</code> 暫忽略、後續逐項 fix</li>
</ol>
<h3 id="case-2state-file-lock-機制微差">Case 2：State file lock 機制微差</h3>
<p><strong>徵兆</strong>：兩個 CI pipeline 同時跑 <code>tofu apply</code>、其中一個應該 lock 拒絕、實際兩個都跑、production 端 race condition。</p>
<p><strong>根因</strong>：Terraform DynamoDB lock 跟 OpenTofu lock 用相同 schema 但 lock_id 規則略不同；舊 lock entry 殘留時 OpenTofu 端解析失敗、視為「無 lock」繼續跑。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>DynamoDB lock table 手動清舊 entry</strong>：cutover 期間先 <code>aws dynamodb delete-item</code> 清舊 lock</li>
<li><strong>單向流量切換</strong>：cutover 期間 freeze 所有 CI、只一個 pipeline 跑、避免 race</li>
<li><strong>架構</strong>：用 <em>fully replicated lock backend</em>（如 Consul）avoid backend-specific lock 怪異</li>
</ol>
<h3 id="case-3terraform-cloud-workspace-不能直接搬">Case 3：Terraform Cloud workspace 不能直接搬</h3>
<p><strong>徵兆</strong>：team 已用 Terraform Cloud workspace 跑 100+ pipeline、想切 OpenTofu、發現 <code>terraform login</code> / workspace API / VCS integration 全 HashiCorp-specific。</p>
<p><strong>根因</strong>：OpenTofu 沒對等 Terraform Cloud 服務；自家 backend 用 S3 + Atlantis / Spacelift / env0 等第三方 platform 對接、不是 1:1 替代。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>保留 Terraform Cloud 跑 production</strong>（OpenTofu 不替代）、用 OpenTofu 跑 dev / sandbox</li>
<li><strong>遷出 Terraform Cloud</strong>：state 遷 S3 + 用 Atlantis 跑 PR-based plan/apply（mature open source）</li>
<li><strong>評估 Spacelift / env0</strong> 商業替代、支援 OpenTofu + 對等 workspace feature</li>
</ol>
<h3 id="case-4ci-pipeline-寫死-terraform-binary-name">Case 4：CI pipeline 寫死 <code>terraform</code> binary name</h3>
<p><strong>徵兆</strong>：cutover 後 CI 跑 <code>terraform plan</code> 報「command not found」；team 100+ pipeline / GitHub Action / GitLab CI / shell script 都寫死 <code>terraform</code>。</p>
<p><strong>根因</strong>：rollout 計畫沒 grep 全 organization 找 binary name 引用。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>Alias 策略</strong>：CI image 內 <code>ln -s /usr/local/bin/tofu /usr/local/bin/terraform</code>、保留兼容 1-3 個月</li>
<li><strong>逐步改 <code>tofu</code></strong>：跟著 IaC team 修 pipeline file、target 100% 改完才 remove alias</li>
<li><strong>架構</strong>：避免在 pipeline / script 寫死 binary、用 env variable <code>IAC_BINARY=${IAC_BINARY:-tofu}</code></li>
</ol>
<h3 id="case-5registry-routing自家-module-拉不到">Case 5：Registry routing、自家 module 拉不到</h3>
<p><strong>徵兆</strong>：cutover 後 <code>tofu init</code> 對自家 private module 報「not found」；同 module 在 Terraform 端跑得好好的。</p>
<p><strong>根因</strong>：private module 註冊在 <em>Terraform Cloud private registry</em>、OpenTofu 預設不知道這個 endpoint；需要顯式設 registry source URL。</p>
<p><strong>修法</strong>：</p>
<ol>
<li><strong>顯式 source URL</strong>：<code>source = &quot;app.terraform.io/myorg/myapp/aws&quot;</code> 改 git source 或自架 module registry</li>
<li><strong>架構</strong>：用 git-based module source（<code>source = &quot;git::ssh://git@github.com/myorg/myapp.git&quot;</code>）、避開 registry lock-in</li>
<li><strong>長期</strong>：自家 module 同時 publish 到 OpenTofu registry / Terraform Cloud / git、跨 tool 兼容</li>
</ol>
<h2 id="capacity--cost">Capacity / cost</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Terraform</th>
          <th>OpenTofu</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Binary cost</td>
          <td>免費 (community edition)</td>
          <td>免費（永遠）</td>
      </tr>
      <tr>
          <td>Terraform Cloud cost</td>
          <td>$20 / user / month、enterprise 高</td>
          <td>無對等服務（用 Atlantis / Spacelift / env0）</td>
      </tr>
      <tr>
          <td>State storage</td>
          <td>S3 / 自家 backend、低</td>
          <td>S3 / 自家 backend、低</td>
      </tr>
      <tr>
          <td>Migration cost</td>
          <td>-</td>
          <td>1-5 person-day（含 audit + cutover + CI 改）</td>
      </tr>
      <tr>
          <td>License risk</td>
          <td>BSL 限制商業使用</td>
          <td>MPL 2.0 開源、無 license risk</td>
      </tr>
      <tr>
          <td>Long-term governance</td>
          <td>HashiCorp 單一供應商</td>
          <td>Linux Foundation + 多廠商貢獻</td>
      </tr>
  </tbody>
</table>
<p><strong>判讀</strong>：純 IaC 用戶切 OpenTofu 風險低 + 省 license 風險；重度依賴 Terraform Cloud feature 的 organization 保留或評估 commercial alternatives（Spacelift / env0）。</p>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="跟-atlantis--spacelift--env0-整合">跟 <a href="https://www.runatlantis.io/">Atlantis / Spacelift / env0</a> 整合</h3>
<p>OpenTofu 沒對等 Terraform Cloud、需要 third-party orchestrator：</p>
<ul>
<li><strong>Atlantis</strong>：自架、開源、輕量、適合中小型 team</li>
<li><strong>Spacelift</strong>：SaaS、policy as code、支援 OpenTofu first-class</li>
<li><strong>env0</strong>：SaaS、cost estimation、workflow 完整</li>
</ul>
<h3 id="跟-terragrunt-整合">跟 <a href="https://terragrunt.gruntwork.io/">Terragrunt</a> 整合</h3>
<p>Terragrunt（OpenTofu / Terraform 共用 wrapper）已支援 OpenTofu 1.6+；多環境配置抽象保留、底層 binary 切換無感。</p>
<h3 id="反向-migrationopentofu--terraform">反向 migration（OpenTofu → Terraform）</h3>
<p>罕見、通常是 organization 走商業合約綁 HashiCorp Enterprise 才會做；流程鏡像對稱、注意 OpenTofu 1.6+ 自家 feature（state encryption / provider for_each）在 Terraform 端可能缺。</p>
<h3 id="下一步議題">下一步議題</h3>
<ul>
<li><strong>State encryption（OpenTofu 1.7+）</strong>：sensitive state 加密、Terraform 商業版才有對等 feature</li>
<li><strong>跨 IaC tool（Pulumi / CDK）</strong>：Pulumi / AWS CDK 是不同 paradigm（imperative）、不在本 migration scope</li>
<li><strong>Provider ecosystem 長期分裂</strong>：兩家 registry 自我演化、需要 quarterly review provider compat</li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>Source vendor：<a href="/blog/backend/05-deployment-platform/vendors/terraform/" data-link-title="Terraform / OpenTofu" data-link-desc="Infrastructure as Code 主流工具">Terraform</a></li>
<li>平行 migration playbook（Type B）：<a href="/blog/backend/02-cache-redis/vendors/redis/migrate-to-dragonflydb/" data-link-title="Redis → DragonflyDB：drop-in 相容下的容量躍升 &#43; 5 個踩雷" data-link-desc="DragonflyDB 號稱 Redis drop-in 替代、單機 throughput 25x、記憶體效率 30% 提升；遷移流程簡單但有 5 個 production 踩雷（RDB 版本差 / Lua 腳本不全支援 / Pub-Sub fanout 行為差異 / Cluster mode 兼容度 / Modules 不支援）、跟 Sentinel / Cluster 模式對位">Redis → DragonflyDB</a></li>
<li>Methodology：<a href="/blog/posts/migration-playbook-%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84stage-0-variant-%E8%A6%8F%E5%8A%83%E6%8A%8A-collapse-%E7%8E%87%E5%BE%9E-60-%E9%99%8D%E5%88%B0-0/" data-link-title="Migration Playbook 方法論的演化紀錄：Stage 0 variant 規劃把 collapse 率從 60% 降到 0%" data-link-desc="跨 vendor migration playbook 需要獨立寫作方法論的依據，以及這套方法論從三輪 batch dogfood 中演化出來的驗證證據。">Migration playbook methodology</a> / <a href="/blog/report/content-structure-by-max-diff-dimension/" data-link-title="Process content 結構由最大差異維度決定、不是 universal phased" data-link-desc="跨 X process content（migration / upgrade / rollout / playbook）的結構由 source / target 之間 *差異維度組合* 決定、不存在 universal phased 模板；6 種 migration / process type 實證（schema 差 / drop-in / operational / multi-tool / paradigm / topology re-layout）跑出 6 種不同結構；寫作前必須做 *6 維 diff dimension audit* 才能決定結構、跳過會套錯模板">#127 Process content 結構由最大差異維度決定</a></li>
</ul>
]]></content:encoded></item><item><title>State 修復與清理</title><link>https://tarrragon.github.io/blog/infra/takeover/partial-iac-state-repair/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/takeover/partial-iac-state-repair/</guid><description>&lt;p>接手一個有半套 IaC 的環境時，state 是工具對現實的唯一記憶，但這份記憶可能已經失真——有些記錄對應的雲端資源已經不存在、有些雲端資源從來沒被記錄、有些記錄的屬性跟現實對不上。在動任何資源之前，先把 state 修到一個可信的狀態，是所有後續操作的前提。&lt;/p>
&lt;h2 id="診斷-state-的健康狀態">診斷 state 的健康狀態&lt;/h2>
&lt;p>&lt;code>terraform plan&lt;/code> 的輸出是診斷 state 健康度的主要工具。在不做任何 code 變更的前提下跑 plan，輸出的每一行差異都代表 state 與現實的落差：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">terraform plan -detailed-exitcode -no-color &amp;gt; plan-diagnosis.txt 2&amp;gt;&lt;span class="p">&amp;amp;&lt;/span>&lt;span class="m">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1"># exit code: 0=無差異, 1=錯誤, 2=有差異&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Plan 的差異分三類，每一類的處理方式不同：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Plan 顯示&lt;/th>
 &lt;th>意義&lt;/th>
 &lt;th>處理方式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>~ update in-place&lt;/code>&lt;/td>
 &lt;td>state 記錄的屬性跟雲端不同（drift）&lt;/td>
 &lt;td>判斷要保留手動改的值還是回退到 code&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>+ create&lt;/code>&lt;/td>
 &lt;td>code 裡有但 state 裡沒有（漏 import）&lt;/td>
 &lt;td>確認資源是否已存在於雲端，是則 import&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>- destroy&lt;/code>&lt;/td>
 &lt;td>state 裡有但 code 裡沒有（orphan）&lt;/td>
 &lt;td>確認資源是否還在雲端、是否還在用&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Plan 跑到一半報錯（exit code 1）而非產出差異，通常代表更嚴重的問題：provider 版本不相容、state 格式損壞、或 state 引用的資源 ID 在雲端已經不存在。錯誤訊息裡的 resource address 指向問題所在。&lt;/p>
&lt;h3 id="orphaned-entry-的辨認">Orphaned entry 的辨認&lt;/h3>
&lt;p>State 裡有一筆資源記錄，但雲端已經沒有對應的資源（手動刪除、帳號切換、或 region 不對），plan 會顯示 &lt;code>- destroy&lt;/code> 或直接報 &lt;code>Error: reading ... NotFound&lt;/code>。這種 orphaned entry 需要從 state 移除，否則每次 plan 都會嘗試操作一個不存在的目標。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 列出 state 裡所有資源，逐一確認是否還存在&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">terraform state list &lt;span class="p">|&lt;/span> &lt;span class="k">while&lt;/span> &lt;span class="nb">read&lt;/span> addr&lt;span class="p">;&lt;/span> &lt;span class="k">do&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;Checking: &lt;/span>&lt;span class="nv">$addr&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> terraform state show &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$addr&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &amp;gt; /dev/null 2&amp;gt;&lt;span class="p">&amp;amp;&lt;/span>&lt;span class="m">1&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34; POSSIBLY ORPHANED: &lt;/span>&lt;span class="nv">$addr&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="k">done&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個腳本不連雲端驗證（只檢查 state 內部一致性），真正的驗證要靠 plan 輸出。如果 plan 對某個資源報 NotFound，那就是 orphaned。&lt;/p>
&lt;h2 id="state-操作前的備份">State 操作前的備份&lt;/h2>
&lt;p>所有 state 操作（rm、mv、push、import）都是直接改寫 state 檔的破壞性操作。操作前的備份是唯一的回退路徑。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 從遠端 backend 拉一份完整的 state 到本地&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">terraform state pull &amp;gt; state-backup-&lt;span class="k">$(&lt;/span>date +%Y%m%d-%H%M&lt;span class="k">)&lt;/span>.json
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 確認備份可用：檢查 JSON 格式和 resource 數量&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">jq &lt;span class="s1">&amp;#39;.resources | length&amp;#39;&lt;/span> state-backup-*.json&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果 state 存在 S3 且 bucket 有開 versioning（應該有，見&lt;a href="https://tarrragon.github.io/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一&lt;/a>），S3 的版本歷史是第二道保險。但 &lt;code>state pull&lt;/code> 的本地備份更可控——S3 versioning 的回復要操作 bucket、權限要對、而且版本 ID 需要另外查。&lt;/p>
&lt;h2 id="移除-orphaned-entrystate-rm">移除 orphaned entry：state rm&lt;/h2>
&lt;p>&lt;code>terraform state rm&lt;/code> 把一筆資源從 state 裡移除，但不觸碰雲端的實際資源。用途是清理 state 裡對應不到雲端的記錄，讓 plan 不再嘗試操作不存在的目標。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 移除單一 orphaned resource&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">terraform state rm &lt;span class="s1">&amp;#39;aws_instance.old_bastion&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 移除整個 module 的記錄（module 被拆掉但資源還在雲端、要重新 import）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">terraform state rm &lt;span class="s1">&amp;#39;module.legacy_network&amp;#39;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>移除後立刻跑 plan 驗證：原本針對這個資源的 destroy / error 應該消失。如果移除後 plan 反而出現 &lt;code>+ create&lt;/code>（想重建這個資源），代表 code 裡還有對應的 resource block——要麼也刪 code，要麼這個資源需要 import 而不是 rm。&lt;/p></description><content:encoded><![CDATA[<p>接手一個有半套 IaC 的環境時，state 是工具對現實的唯一記憶，但這份記憶可能已經失真——有些記錄對應的雲端資源已經不存在、有些雲端資源從來沒被記錄、有些記錄的屬性跟現實對不上。在動任何資源之前，先把 state 修到一個可信的狀態，是所有後續操作的前提。</p>
<h2 id="診斷-state-的健康狀態">診斷 state 的健康狀態</h2>
<p><code>terraform plan</code> 的輸出是診斷 state 健康度的主要工具。在不做任何 code 變更的前提下跑 plan，輸出的每一行差異都代表 state 與現實的落差：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">terraform plan -detailed-exitcode -no-color &gt; plan-diagnosis.txt 2&gt;<span class="p">&amp;</span><span class="m">1</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># exit code: 0=無差異, 1=錯誤, 2=有差異</span></span></span></code></pre></div><p>Plan 的差異分三類，每一類的處理方式不同：</p>
<table>
  <thead>
      <tr>
          <th>Plan 顯示</th>
          <th>意義</th>
          <th>處理方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>~ update in-place</code></td>
          <td>state 記錄的屬性跟雲端不同（drift）</td>
          <td>判斷要保留手動改的值還是回退到 code</td>
      </tr>
      <tr>
          <td><code>+ create</code></td>
          <td>code 裡有但 state 裡沒有（漏 import）</td>
          <td>確認資源是否已存在於雲端，是則 import</td>
      </tr>
      <tr>
          <td><code>- destroy</code></td>
          <td>state 裡有但 code 裡沒有（orphan）</td>
          <td>確認資源是否還在雲端、是否還在用</td>
      </tr>
  </tbody>
</table>
<p>Plan 跑到一半報錯（exit code 1）而非產出差異，通常代表更嚴重的問題：provider 版本不相容、state 格式損壞、或 state 引用的資源 ID 在雲端已經不存在。錯誤訊息裡的 resource address 指向問題所在。</p>
<h3 id="orphaned-entry-的辨認">Orphaned entry 的辨認</h3>
<p>State 裡有一筆資源記錄，但雲端已經沒有對應的資源（手動刪除、帳號切換、或 region 不對），plan 會顯示 <code>- destroy</code> 或直接報 <code>Error: reading ... NotFound</code>。這種 orphaned entry 需要從 state 移除，否則每次 plan 都會嘗試操作一個不存在的目標。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 列出 state 裡所有資源，逐一確認是否還存在</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">terraform state list <span class="p">|</span> <span class="k">while</span> <span class="nb">read</span> addr<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;Checking: </span><span class="nv">$addr</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  terraform state show <span class="s2">&#34;</span><span class="nv">$addr</span><span class="s2">&#34;</span> &gt; /dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s2">&#34;  POSSIBLY ORPHANED: </span><span class="nv">$addr</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">done</span></span></span></code></pre></div><p>這個腳本不連雲端驗證（只檢查 state 內部一致性），真正的驗證要靠 plan 輸出。如果 plan 對某個資源報 NotFound，那就是 orphaned。</p>
<h2 id="state-操作前的備份">State 操作前的備份</h2>
<p>所有 state 操作（rm、mv、push、import）都是直接改寫 state 檔的破壞性操作。操作前的備份是唯一的回退路徑。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 從遠端 backend 拉一份完整的 state 到本地</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">terraform state pull &gt; state-backup-<span class="k">$(</span>date +%Y%m%d-%H%M<span class="k">)</span>.json
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 確認備份可用：檢查 JSON 格式和 resource 數量</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">jq <span class="s1">&#39;.resources | length&#39;</span> state-backup-*.json</span></span></code></pre></div><p>如果 state 存在 S3 且 bucket 有開 versioning（應該有，見<a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一</a>），S3 的版本歷史是第二道保險。但 <code>state pull</code> 的本地備份更可控——S3 versioning 的回復要操作 bucket、權限要對、而且版本 ID 需要另外查。</p>
<h2 id="移除-orphaned-entrystate-rm">移除 orphaned entry：state rm</h2>
<p><code>terraform state rm</code> 把一筆資源從 state 裡移除，但不觸碰雲端的實際資源。用途是清理 state 裡對應不到雲端的記錄，讓 plan 不再嘗試操作不存在的目標。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 移除單一 orphaned resource</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">terraform state rm <span class="s1">&#39;aws_instance.old_bastion&#39;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 移除整個 module 的記錄（module 被拆掉但資源還在雲端、要重新 import）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">terraform state rm <span class="s1">&#39;module.legacy_network&#39;</span></span></span></code></pre></div><p>移除後立刻跑 plan 驗證：原本針對這個資源的 destroy / error 應該消失。如果移除後 plan 反而出現 <code>+ create</code>（想重建這個資源），代表 code 裡還有對應的 resource block——要麼也刪 code，要麼這個資源需要 import 而不是 rm。</p>
<p>判斷「該 rm 還是該 import」的依據：資源在雲端還存在嗎？存在就 import（讓 state 重新追蹤它），不存在就 rm（清掉過時的記錄）。</p>
<h2 id="搬移資源state-mv-與-moved-block">搬移資源：state mv 與 moved block</h2>
<p>重構 Terraform code（把資源搬進 module、改 resource name、改 module 結構）時，state 裡的 resource address 會跟著變。如果不處理，plan 會判定「舊 address 要 destroy、新 address 要 create」——對 stateless 資源只是多等一次重建，對 RDS 這類 stateful 資源是資料遺失。</p>
<p>Terraform 1.1+ 的 <code>moved</code> block 是宣告式的搬遷，寫在 HCL 裡、可 review、可回滾：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">moved</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  from</span> <span class="o">=</span> <span class="k">aws_security_group</span><span class="p">.</span><span class="k">web</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  to</span>   <span class="o">=</span> <span class="k">module</span><span class="p">.</span><span class="k">network</span><span class="p">.</span><span class="k">aws_security_group</span><span class="p">.</span><span class="k">web</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">}</span></span></code></pre></div><p>跑 plan 時 Terraform 會把 state 裡的舊 address 自動對應到新 address，plan 顯示 <code>(moved)</code> 而非 <code>destroy + create</code>。驗證 plan 為零變更後 apply，moved block 生效後可以從 code 裡刪掉。</p>
<p><code>terraform state mv</code> 是指令式的搬遷，直接操作 state 檔。它比 moved block 靈活（可以跨 state 搬）、但不可 review、不進版本控制、操作錯了只能靠備份回退。</p>
<table>
  <thead>
      <tr>
          <th>操作</th>
          <th>moved block</th>
          <th>state mv</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>可 review</td>
          <td>是（寫在 HCL）</td>
          <td>否（直接改 state）</td>
      </tr>
      <tr>
          <td>可回滾</td>
          <td>是（刪 moved block）</td>
          <td>否（靠備份）</td>
      </tr>
      <tr>
          <td>跨 state 搬遷</td>
          <td>不支援</td>
          <td>支援</td>
      </tr>
      <tr>
          <td>適用情境</td>
          <td>同 state 內的重構</td>
          <td>跨 state 搬遷、moved 表達不了的複雜搬移</td>
      </tr>
  </tbody>
</table>
<p>優先用 moved block，state mv 留給 moved 做不到的場景。</p>
<h2 id="手動編輯-statepull--改--push">手動編輯 state：pull → 改 → push</h2>
<p>極少數情況需要直接編輯 state JSON——例如修正一個 resource 的 ID（某次 import 用了錯的 ID）、或手動修改一個 attribute 讓 plan 不再觸發不必要的變更。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 拉到本地</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">terraform state pull &gt; state-edit.json
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 編輯（用 jq 或文字編輯器，改目標 resource 的 attributes）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 極度小心：改錯任何欄位都可能讓 plan 產生破壞性差異</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># 推回遠端</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">terraform state push state-edit.json</span></span></code></pre></div><p><code>state push</code> 有 lineage 和 serial 檢查——如果本地的 state 跟遠端的 lineage 不同（來自不同的 init），push 會被拒絕。加 <code>-force</code> 可以繞過，但這意味著覆蓋遠端、丟棄遠端從你 pull 之後的所有變更。</p>
<p>手動編輯 state 的操作規則：備份 → 改一個欄位 → push → plan 驗證 → 確認只有預期的變化。批次改多個欄位時，每改一個就走一輪 push + plan，不要累積修改。</p>
<h2 id="從錯誤的-state-push-回復">從錯誤的 state push 回復</h2>
<p>如果 <code>state push</code> 推了一個錯誤的 state，回復路徑取決於 backend 有沒有版本歷史。</p>
<h3 id="s3-backend-有-versioning">S3 backend 有 versioning</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 列出 state 檔的所有版本</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">aws s3api list-object-versions <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --bucket acme-tf-state <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --prefix prod/network/terraform.tfstate <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;Versions[].{VersionId:VersionId,LastModified:LastModified,Size:Size}&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --output table
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 下載上一個正確的版本</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">aws s3api get-object <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --bucket acme-tf-state <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --key prod/network/terraform.tfstate <span class="se">\
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="se"></span>  --version-id <span class="s2">&#34;correct-version-id&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="se"></span>  state-recovered.json
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"># 用 terraform state push 推回</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">terraform state push state-recovered.json</span></span></code></pre></div><h3 id="沒有-versioning">沒有 versioning</h3>
<p>如果 bucket 沒開 versioning、又沒有本地備份，state 的上一個版本就沒了。這時候的選項：</p>
<ol>
<li>從 plan 的輸出反推哪些 resource 的 state 記錄是錯的，逐一用 <code>state rm</code> + <code>import</code> 修正</li>
<li>作為最後手段，刪掉整份 state、從零 import 所有資源——這等於重做一次完整的 IaC 導入</li>
</ol>
<p>這正是<a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一</a>要求 state bucket 開 versioning 的理由——沒有版本歷史的 state backend，一次 push 錯誤就沒有回退路徑。</p>
<h2 id="state-backend-搬遷">State backend 搬遷</h2>
<p>接手的環境可能用本地 state（<code>.terraform/terraform.tfstate</code>）或者 state 放在不符合安全要求的位置（沒加密的 S3、沒有鎖表、甚至存在某個人的桌機上）。搬遷到正規的遠端 backend 是接手後的優先工作。</p>
<h3 id="本地--s3--dynamodb">本地 → S3 + DynamoDB</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 在 backend.tf 加上遠端 backend 設定
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">terraform</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">backend</span> <span class="s2">&#34;s3&#34;</span> {
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">    bucket</span>         <span class="o">=</span> <span class="s2">&#34;acme-tf-state&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">    key</span>            <span class="o">=</span> <span class="s2">&#34;prod/network/terraform.tfstate&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">    region</span>         <span class="o">=</span> <span class="s2">&#34;ap-northeast-1&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">    encrypt</span>        <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">    dynamodb_table</span> <span class="o">=</span> <span class="s2">&#34;acme-tf-lock&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  }
</span></span><span class="line"><span class="ln">10</span><span class="cl">}</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 重新初始化，Terraform 會偵測到 backend 變更並提示搬遷</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">terraform init -migrate-state
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 確認搬遷成功</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">terraform plan  <span class="c1"># 應該顯示零變更</span></span></span></code></pre></div><p><code>-migrate-state</code> 會把本地 state 的內容寫入新的遠端 backend。搬遷後本地的 <code>.terraform/terraform.tfstate</code> 變成一個指向遠端 backend 的指標，不再存放實際 state 內容。</p>
<h3 id="舊-s3--新-s3">舊 S3 → 新 S3</h3>
<p>跟本地搬遷流程相同——改 backend.tf 的 bucket/key/region，跑 <code>terraform init -migrate-state</code>。Terraform 會從舊 backend 讀 state、寫入新 backend。</p>
<p>搬遷後驗證：plan 為零變更、新 bucket 裡有 state 檔、舊 bucket 的 state 檔可以保留一段時間作為備份。搬遷過程中 DynamoDB 的 lock 會確保沒有人同時 apply。</p>
<p>搬遷期間的風險：如果有人在你改 backend.tf 之後、跑 init 之前，用舊 backend 跑了 apply，新 backend 的 state 會缺少那次變更。搬遷時通知團隊暫停所有 Terraform 操作，搬遷完成後再恢復。</p>
<p>時程參考：單一 orphaned entry 的 rm 操作約 15-30 分鐘（含備份和驗證）。Backend migration 約 1-2 小時。5-10 個問題項的完整 state 整理約半天到一天。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/takeover/partial-iac-no-docs/" data-link-title="有半套 IaC 但文件缺失的環境接管" data-link-desc="IaC 覆蓋不完整、部分資源在 state 外、文件缺失的環境怎麼盤點差距、修復 state 健康、收斂 drift 並重建文件">有半套 IaC 但文件缺失的環境接管</a>：本篇的上層操作流程</li>
<li>→ <a href="/blog/infra/takeover/partial-iac-drift-triage/" data-link-title="Drift 分類處理指南" data-link-desc="接手半套 IaC 環境時，怎麼讀 plan 輸出分類 drift、判斷保留還是回退、處理 stateful 資源的高風險漂移，以及批次收斂的工作流">Drift 分類處理</a>：state 修復完成後，下一步是處理 managed resource 的 drift</li>
<li>→ <a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一：最小可行 IaC</a>：state backend 的設計與 versioning 要求</li>
<li>→ <a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>：moved block 在環境拆分 retrofit 裡的角色</li>
</ul>
]]></content:encoded></item><item><title>Drift 分類處理指南</title><link>https://tarrragon.github.io/blog/infra/takeover/partial-iac-drift-triage/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/takeover/partial-iac-drift-triage/</guid><description>&lt;p>&lt;code>terraform plan&lt;/code> 跑完後如果出現非零差異，每一行差異都需要判斷：這是該保留的手動改動，還是該回退的意外漂移。這些差異就是 drift — state 記錄的狀態跟雲端實際狀態之間的落差。判斷錯誤的代價從「設定被覆蓋」到「stateful 資源被重建導致資料遺失」不等，所以分類要在 apply 之前完成。半套 IaC 環境的 drift 通常比全 IaC 環境更多，因為有人在 Console 改了 state 不知道的資源。&lt;/p>
&lt;h2 id="讀-plan-輸出三種變更類型">讀 plan 輸出：三種變更類型&lt;/h2>
&lt;p>&lt;code>terraform plan&lt;/code> 的輸出用符號標示每個資源的預期變更。三種類型的風險等級不同，處理方式也不同：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl"># in-place update（~）：修改屬性，資源本身不動
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">~ resource &amp;#34;aws_security_group_rule&amp;#34; &amp;#34;api_ingress&amp;#34; {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> ~ cidr_blocks = [&amp;#34;10.0.0.0/16&amp;#34;] -&amp;gt; [&amp;#34;10.0.1.0/24&amp;#34;]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"># forces replacement（-/+）：刪除後重建，新資源取得新 ID
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">-/+ resource &amp;#34;aws_db_instance&amp;#34; &amp;#34;primary&amp;#34; {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> ~ identifier = &amp;#34;app-prod&amp;#34; -&amp;gt; &amp;#34;app-prod-v2&amp;#34; # forces replacement
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"># destroy（-）：刪除資源
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">- resource &amp;#34;aws_security_group&amp;#34; &amp;#34;legacy_api&amp;#34; {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> }&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>符號&lt;/th>
 &lt;th>意義&lt;/th>
 &lt;th>風險等級&lt;/th>
 &lt;th>處理原則&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>~&lt;/code>&lt;/td>
 &lt;td>in-place update&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>逐項判斷，多數可安全 apply&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>-/+&lt;/code>&lt;/td>
 &lt;td>forces replacement&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>stateful 資源絕對不能直接 apply&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>-&lt;/code>&lt;/td>
 &lt;td>destroy&lt;/td>
 &lt;td>極高&lt;/td>
 &lt;td>代表雲端有但 code 沒有，apply 會刪除&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>-&lt;/code>（destroy）是最危險的類型。它代表某個資源存在於雲端但不在 Terraform code 裡——可能是手動建的、可能是從 state 被 &lt;code>state rm&lt;/code> 移除過、也可能是前任維護者刪了 code 但沒跑 apply。不論原因，直接 apply 會把這個資源從雲端刪除。&lt;/p>
&lt;p>&lt;code>-/+&lt;/code>（forces replacement）的危險在於它看起來像修改但實際是先刪後建。對 stateless 資源（security group rule、IAM policy）影響有限，對 stateful 資源（RDS、EBS volume）意味著資料遺失。&lt;/p>
&lt;h2 id="故意的-drift-vs-意外的-drift">故意的 drift vs 意外的 drift&lt;/h2>
&lt;p>不是所有 drift 都是問題。接手的環境裡，手動改動可能有兩種來源：&lt;/p>
&lt;p>&lt;strong>故意的改動&lt;/strong>是前任維護者為了解決特定問題而做的。常見形態：臨時開了一條 security group 規則讓外部監控系統連進來、調高了 RDS 的 &lt;code>max_connections&lt;/code> 參數來應對流量成長、手動把 instance type 從 &lt;code>t3.small&lt;/code> 升到 &lt;code>t3.medium&lt;/code> 因為記憶體不夠。這類改動通常是正確的操作決策，只是沒有同步回 code。&lt;/p>
&lt;p>&lt;strong>意外的漂移&lt;/strong>是無意中造成的。常見形態：在 Console 測試時改了某個設定但忘了改回來、另一個 Terraform workspace 的 apply 動到了共用的資源、AWS 自動更新了某些屬性（如 default security group 的描述）。&lt;/p>
&lt;p>區分兩者的方法是查 CloudTrail——看這個改動是誰做的、什麼時候、有沒有對應的 ticket 或 changelog 記錄。如果 CloudTrail 顯示改動發生在一次事故期間、由當時的值班工程師執行，大概率是故意的。如果改動來自一個不認識的 IAM user、或時間點跟任何已知事件對不上，可能是意外。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">aws cloudtrail lookup-events &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --lookup-attributes &lt;span class="nv">AttributeKey&lt;/span>&lt;span class="o">=&lt;/span>ResourceName,AttributeValue&lt;span class="o">=&lt;/span>sg-0abc123 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --start-time 2026-01-01 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --query &lt;span class="s1">&amp;#39;Events[].[EventTime,Username,EventName]&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --output table&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="每條-drift-的處理決策">每條 drift 的處理決策&lt;/h2>
&lt;p>每條 plan 差異都需要一個明確的決定：保留手動改動（更新 HCL）、回退到 code 的版本（apply）、還是暫時擱置（不動）。&lt;/p></description><content:encoded><![CDATA[<p><code>terraform plan</code> 跑完後如果出現非零差異，每一行差異都需要判斷：這是該保留的手動改動，還是該回退的意外漂移。這些差異就是 drift — state 記錄的狀態跟雲端實際狀態之間的落差。判斷錯誤的代價從「設定被覆蓋」到「stateful 資源被重建導致資料遺失」不等，所以分類要在 apply 之前完成。半套 IaC 環境的 drift 通常比全 IaC 環境更多，因為有人在 Console 改了 state 不知道的資源。</p>
<h2 id="讀-plan-輸出三種變更類型">讀 plan 輸出：三種變更類型</h2>
<p><code>terraform plan</code> 的輸出用符號標示每個資源的預期變更。三種類型的風險等級不同，處理方式也不同：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl"># in-place update（~）：修改屬性，資源本身不動
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">~ resource &#34;aws_security_group_rule&#34; &#34;api_ingress&#34; {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    ~ cidr_blocks = [&#34;10.0.0.0/16&#34;] -&gt; [&#34;10.0.1.0/24&#34;]
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  }
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"># forces replacement（-/+）：刪除後重建，新資源取得新 ID
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">-/+ resource &#34;aws_db_instance&#34; &#34;primary&#34; {
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    ~ identifier = &#34;app-prod&#34; -&gt; &#34;app-prod-v2&#34; # forces replacement
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  }
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"># destroy（-）：刪除資源
</span></span><span class="line"><span class="ln">12</span><span class="cl">- resource &#34;aws_security_group&#34; &#34;legacy_api&#34; {
</span></span><span class="line"><span class="ln">13</span><span class="cl">  }</span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>符號</th>
          <th>意義</th>
          <th>風險等級</th>
          <th>處理原則</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>~</code></td>
          <td>in-place update</td>
          <td>中</td>
          <td>逐項判斷，多數可安全 apply</td>
      </tr>
      <tr>
          <td><code>-/+</code></td>
          <td>forces replacement</td>
          <td>高</td>
          <td>stateful 資源絕對不能直接 apply</td>
      </tr>
      <tr>
          <td><code>-</code></td>
          <td>destroy</td>
          <td>極高</td>
          <td>代表雲端有但 code 沒有，apply 會刪除</td>
      </tr>
  </tbody>
</table>
<p><code>-</code>（destroy）是最危險的類型。它代表某個資源存在於雲端但不在 Terraform code 裡——可能是手動建的、可能是從 state 被 <code>state rm</code> 移除過、也可能是前任維護者刪了 code 但沒跑 apply。不論原因，直接 apply 會把這個資源從雲端刪除。</p>
<p><code>-/+</code>（forces replacement）的危險在於它看起來像修改但實際是先刪後建。對 stateless 資源（security group rule、IAM policy）影響有限，對 stateful 資源（RDS、EBS volume）意味著資料遺失。</p>
<h2 id="故意的-drift-vs-意外的-drift">故意的 drift vs 意外的 drift</h2>
<p>不是所有 drift 都是問題。接手的環境裡，手動改動可能有兩種來源：</p>
<p><strong>故意的改動</strong>是前任維護者為了解決特定問題而做的。常見形態：臨時開了一條 security group 規則讓外部監控系統連進來、調高了 RDS 的 <code>max_connections</code> 參數來應對流量成長、手動把 instance type 從 <code>t3.small</code> 升到 <code>t3.medium</code> 因為記憶體不夠。這類改動通常是正確的操作決策，只是沒有同步回 code。</p>
<p><strong>意外的漂移</strong>是無意中造成的。常見形態：在 Console 測試時改了某個設定但忘了改回來、另一個 Terraform workspace 的 apply 動到了共用的資源、AWS 自動更新了某些屬性（如 default security group 的描述）。</p>
<p>區分兩者的方法是查 CloudTrail——看這個改動是誰做的、什麼時候、有沒有對應的 ticket 或 changelog 記錄。如果 CloudTrail 顯示改動發生在一次事故期間、由當時的值班工程師執行，大概率是故意的。如果改動來自一個不認識的 IAM user、或時間點跟任何已知事件對不上，可能是意外。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws cloudtrail lookup-events <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --lookup-attributes <span class="nv">AttributeKey</span><span class="o">=</span>ResourceName,AttributeValue<span class="o">=</span>sg-0abc123 <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --start-time 2026-01-01 <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;Events[].[EventTime,Username,EventName]&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --output table</span></span></code></pre></div><h2 id="每條-drift-的處理決策">每條 drift 的處理決策</h2>
<p>每條 plan 差異都需要一個明確的決定：保留手動改動（更新 HCL）、回退到 code 的版本（apply）、還是暫時擱置（不動）。</p>
<h3 id="保留adopt-into-hcl">保留（adopt into HCL）</h3>
<p>適用條件：手動改動是正確的操作決策，雲端的現況是期望狀態。處理方式是把 HCL 改成跟雲端一致，讓下次 plan 對這項顯示零差異。</p>
<p>多數 drift 應該走這條路。前任維護者調大了 instance type、加了一條 security group 規則、改了 RDS parameter——這些改動通常有操作上的理由。把 code 對齊現實，比把現實改回 code 安全。</p>
<h3 id="回退apply-to-revert">回退（apply to revert）</h3>
<p>適用條件：手動改動是錯誤的、或已經不再需要（如臨時開的除錯 port）。確認回退不會影響運行中的服務後，讓 Terraform apply 把設定改回 code 描述的版本。</p>
<p>回退前要確認的事：這條規則還有沒有服務在用？這個參數改回去會不會讓連線斷開？如果不確定，先 adopt 再說——adopt 的成本是改一行 HCL，回退錯誤的成本可能是服務中斷。</p>
<h3 id="擱置defer">擱置（defer）</h3>
<p>適用條件：目前無法判斷該保留還是回退（缺乏 context），或改動涉及 stateful 資源的 forces replacement 需要更多準備。擱置的做法是在 code 裡加 <code>lifecycle { ignore_changes = [...] }</code> 暫時跳過這項差異，並留下註解說明為什麼擱置、預計什麼時候處理。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_db_instance&#34; &#34;primary&#34;</span> {<span class="c1">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">  # drift: identifier 被手動改過，forces replacement
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">  # 擱置原因：直接 apply 會觸發 RDS 重建、資料遺失
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">  # 預計處理：確認新 identifier 後更新 HCL + 用 moved block
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span>  <span class="k">lifecycle</span> {
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">    ignore_changes</span> <span class="o">=</span> <span class="p">[</span><span class="k">identifier</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  }
</span></span><span class="line"><span class="ln">8</span><span class="cl">}</span></span></code></pre></div><p>擱置不是永久解法。<code>ignore_changes</code> 會讓這個屬性脫離 IaC 管理，累積越多就越接近「回到手動」。定期回顧擱置清單，逐項決定保留或回退。</p>
<h2 id="stateful-資源的高風險-drift">Stateful 資源的高風險 drift</h2>
<p>stateful 資源（RDS、EBS volume、DynamoDB table）的 drift 需要特別處理，因為 forces replacement 意味著資料遺失。以下屬性的改動在 plan 裡會顯示 <code>-/+</code>（forces replacement），直接 apply 會先刪除再重建：</p>
<table>
  <thead>
      <tr>
          <th>資源類型</th>
          <th>觸發 replacement 的屬性</th>
          <th>後果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>RDS</td>
          <td><code>identifier</code>、<code>engine</code>、某些 <code>storage_type</code> 變更</td>
          <td>資料庫被刪除重建，資料遺失</td>
      </tr>
      <tr>
          <td>EBS volume</td>
          <td><code>availability_zone</code>、<code>size</code>（縮小）</td>
          <td>volume 被刪除重建，資料遺失</td>
      </tr>
      <tr>
          <td>DynamoDB</td>
          <td><code>hash_key</code>、<code>range_key</code></td>
          <td>table 被刪除重建，資料遺失</td>
      </tr>
  </tbody>
</table>
<p>發現 stateful 資源的 forces replacement 時，處理步驟：</p>
<ol>
<li>在 <code>lifecycle</code> 加 <code>ignore_changes</code> 暫時跳過</li>
<li>備份資源（RDS snapshot、EBS snapshot）</li>
<li>確認正確的目標狀態後，用 <code>moved</code> block 或 <code>terraform state mv</code> 處理 identity 變更</li>
<li>用 <code>terraform plan</code> 驗證變更類型從 <code>-/+</code> 變成 <code>~</code>（in-place）或零差異</li>
<li>移除 <code>ignore_changes</code></li>
</ol>
<h2 id="refresh-only安全的-state-同步">refresh-only：安全的 state 同步</h2>
<p><code>terraform apply -refresh-only</code> 只更新 state 來反映雲端現況，不改變任何雲端資源。它適用於「雲端被手動改了、想讓 state 跟上現實但還沒準備好改 HCL」的情境。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">terraform apply -refresh-only</span></span></code></pre></div><p>refresh-only 之後，state 跟雲端一致了，但 state 跟 HCL 之間的差異仍然存在——下次跑 plan 仍會看到 drift。它解的是「state 過時」的問題，不是「code 跟現實不一致」的問題。兩者要分開處理：先 refresh-only 讓 state 乾淨，再逐項決定 HCL 要不要對齊。</p>
<p>使用 refresh-only 的前提是確認 state backend 有 versioning——如果 refresh-only 把 state 改壞了（例如併發操作導致 state 衝突），需要能回捲到上一個版本。</p>
<h2 id="批次-drift-收斂工作流">批次 drift 收斂工作流</h2>
<p>接手環境的 drift 通常不是一兩條，可能有幾十條。逐條處理可以但效率低，按類型批次處理比較實際：</p>
<p><strong>第一批：安全類</strong>。security group 規則、IAM policy 的 drift 優先處理，因為它們直接影響存取邊界。全開的規則該關就關（回退），故意開的規則 adopt 進 code。</p>
<p><strong>第二批：stateless 資源的 in-place drift</strong>。tag 不一致、description 不一致、非關鍵屬性的變更。這類 drift 風險低，可以批次 adopt（把 HCL 改成跟雲端一致）然後一次 apply 驗證。</p>
<p><strong>第三批：stateful 資源</strong>。RDS parameter、backup retention、instance class 的變更。逐個處理，每個都要確認是 in-place update 而非 forces replacement。</p>
<p><strong>第四批：擱置項</strong>。forces replacement、無法判斷的改動。加 <code>ignore_changes</code> 暫緩，排進 backlog 定期回顧。</p>
<p>每一批處理完後跑一次 plan，確認該批的 drift 消失、其他批次的 drift 沒被影響。不要一次 apply 所有批次——分批的目的是控制每次 apply 的影響範圍。</p>
<p>整個 drift 收斂流程的時程取決於 drift 數量和 stateful 資源的比例。20 條以內的 drift、多數是 stateless 的 in-place 變更，2-3 天可以收完。50 條以上、含多個 stateful 資源的 forces replacement，需要 1-2 週分階段處理。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/takeover/partial-iac-no-docs/" data-link-title="有半套 IaC 但文件缺失的環境接管" data-link-desc="IaC 覆蓋不完整、部分資源在 state 外、文件缺失的環境怎麼盤點差距、修復 state 健康、收斂 drift 並重建文件">有半套 IaC 但文件缺失的環境接管</a>：本文的上層總覽</li>
<li>→ <a href="/blog/infra/takeover/partial-iac-state-repair/" data-link-title="State 修復與清理" data-link-desc="接手的 Terraform state 損壞、有 orphaned entry、或需要搬遷時，怎麼診斷問題、安全操作、以及從錯誤中回復">State 修復與清理</a>：drift 處理前先確認 state 本身是健康的</li>
<li>→ <a href="/blog/infra/takeover/partial-iac-bulk-import/" data-link-title="Unmanaged Resource 批次 Import 工作流" data-link-desc="把 Terraform state 外的雲端資源有系統地納入 IaC 管理：優先序判斷、import block 語法、generated HCL 的 review 要點、批次策略與常見失敗處理">Unmanaged resource 批次 import</a>：drift 收斂完成後，開始 import unmanaged resource</li>
<li>→ <a href="/blog/infra/01-minimal-iac/console-readonly-minimal-viable/" data-link-title="Console 唯讀鐵律與最小可行資源集合" data-link-desc="Console 只用來看不用來改的操作紀律、drift 的延遲浮現與偵測，以及能跑出第一個完整 apply 迴路的最小資源集合">Console 唯讀鐵律</a>：drift 的根本防線</li>
<li>→ <a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>：drift 收斂後的環境拆分路徑</li>
</ul>
]]></content:encoded></item><item><title>Unmanaged Resource 批次 Import 工作流</title><link>https://tarrragon.github.io/blog/infra/takeover/partial-iac-bulk-import/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/takeover/partial-iac-bulk-import/</guid><description>&lt;p>盤點階段產出的 managed vs unmanaged 兩欄清單裡（見&lt;a href="https://tarrragon.github.io/blog/infra/takeover/partial-iac-no-docs/" data-link-title="有半套 IaC 但文件缺失的環境接管" data-link-desc="IaC 覆蓋不完整、部分資源在 state 外、文件缺失的環境怎麼盤點差距、修復 state 健康、收斂 drift 並重建文件">盤點流程&lt;/a>），unmanaged 那一欄的每個資源都要決定：納入 Terraform 管理、還是維持手動並記錄原因。這篇處理的是「決定要納管」的資源怎麼有系統地 import，而不是一次全部倒進去。&lt;/p>
&lt;h2 id="優先序先-import-什麼">優先序：先 import 什麼&lt;/h2>
&lt;p>不是所有 unmanaged resource 都值得立刻 import。判斷依據是「這個資源不在 IaC 裡的風險有多高」和「import 的操作複雜度有多低」的交集。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>優先級&lt;/th>
 &lt;th>資源類型&lt;/th>
 &lt;th>理由&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>Security group、IAM role / policy&lt;/td>
 &lt;td>安全邊界資源，手動改動的風險最高，且 import 後 plan 驗證直覺&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>VPC、subnet、route table&lt;/td>
 &lt;td>網路地基，其他資源依賴它們，import 後上層資源的引用才能從 hardcode 換成引用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>RDS、ElastiCache&lt;/td>
 &lt;td>有狀態資源，import 操作本身不改資源，但 plan 不匹配時的修正要謹慎&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>4&lt;/td>
 &lt;td>S3 bucket、CloudWatch log group&lt;/td>
 &lt;td>低風險、低依賴，但數量可能很多，適合最後批次處理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>5&lt;/td>
 &lt;td>EC2 instance、Lambda&lt;/td>
 &lt;td>變動頻繁、生命週期短，import 的 ROI 低——考慮是否改用 IaC 重建而非 import&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>優先級 1-2 的資源是地基層，import 後能讓後續的 IaC 引用鏈從 hardcode ID 換成資源屬性引用，這是 import 的結構性收益。優先級 5 的資源如果生命週期短（隨部署替換），用 IaC 重新定義再 apply 比逆向 import 划算。&lt;/p>
&lt;h2 id="import-block-語法terraform-15">import block 語法（Terraform 1.5+）&lt;/h2>
&lt;p>Terraform 1.5 引入了宣告式 import block，取代舊版的 &lt;code>terraform import&lt;/code> CLI 指令。宣告式的優勢是 import 本身進版本控制、可 review、可回滾。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">import&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="n"> to&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_security_group&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">api&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="n"> id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;sg-0abc123def456&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="k">import&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="n"> to&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_db_instance&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">primary&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="n"> id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;app-prod-primary&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>to&lt;/code> 是 Terraform 裡的資源地址（resource type + name），&lt;code>id&lt;/code> 是雲端的資源識別碼。每種資源的 id 格式不同：security group 用 &lt;code>sg-xxx&lt;/code>、RDS 用 DB identifier、S3 用 bucket name、IAM role 用 role name。格式查 Terraform provider 文件的 Import 段。&lt;/p>
&lt;p>多個 import block 可以寫在同一個檔案裡（如 &lt;code>imports.tf&lt;/code>），一次 plan/apply 處理整批。apply 完成後這些 import block 可以刪除——它們的作用是觸發 import 動作，import 完成後 state 已經記住了對應關係。&lt;/p>
&lt;h2 id="generate-config-out-工作流">generate-config-out 工作流&lt;/h2>
&lt;p>import block 只把資源綁進 state，不會自動產生對應的 HCL 定義。Terraform 1.5+ 提供 &lt;code>-generate-config-out&lt;/code> flag 自動反推 HCL：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">terraform plan -generate-config-out&lt;span class="o">=&lt;/span>generated_resources.tf&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個指令會：&lt;/p>
&lt;ol>
&lt;li>讀取所有 import block&lt;/li>
&lt;li>查詢每個資源在雲端的真實屬性&lt;/li>
&lt;li>把屬性寫成 HCL 資源定義，輸出到指定檔案&lt;/li>
&lt;li>在 plan 輸出中標示每個資源為 &lt;code>import&lt;/code>（不是 create/change/destroy）&lt;/li>
&lt;/ol>
&lt;p>生成的 HCL 是起點，需要人工 review 後才能正式使用。&lt;/p></description><content:encoded><![CDATA[<p>盤點階段產出的 managed vs unmanaged 兩欄清單裡（見<a href="/blog/infra/takeover/partial-iac-no-docs/" data-link-title="有半套 IaC 但文件缺失的環境接管" data-link-desc="IaC 覆蓋不完整、部分資源在 state 外、文件缺失的環境怎麼盤點差距、修復 state 健康、收斂 drift 並重建文件">盤點流程</a>），unmanaged 那一欄的每個資源都要決定：納入 Terraform 管理、還是維持手動並記錄原因。這篇處理的是「決定要納管」的資源怎麼有系統地 import，而不是一次全部倒進去。</p>
<h2 id="優先序先-import-什麼">優先序：先 import 什麼</h2>
<p>不是所有 unmanaged resource 都值得立刻 import。判斷依據是「這個資源不在 IaC 裡的風險有多高」和「import 的操作複雜度有多低」的交集。</p>
<table>
  <thead>
      <tr>
          <th>優先級</th>
          <th>資源類型</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>Security group、IAM role / policy</td>
          <td>安全邊界資源，手動改動的風險最高，且 import 後 plan 驗證直覺</td>
      </tr>
      <tr>
          <td>2</td>
          <td>VPC、subnet、route table</td>
          <td>網路地基，其他資源依賴它們，import 後上層資源的引用才能從 hardcode 換成引用</td>
      </tr>
      <tr>
          <td>3</td>
          <td>RDS、ElastiCache</td>
          <td>有狀態資源，import 操作本身不改資源，但 plan 不匹配時的修正要謹慎</td>
      </tr>
      <tr>
          <td>4</td>
          <td>S3 bucket、CloudWatch log group</td>
          <td>低風險、低依賴，但數量可能很多，適合最後批次處理</td>
      </tr>
      <tr>
          <td>5</td>
          <td>EC2 instance、Lambda</td>
          <td>變動頻繁、生命週期短，import 的 ROI 低——考慮是否改用 IaC 重建而非 import</td>
      </tr>
  </tbody>
</table>
<p>優先級 1-2 的資源是地基層，import 後能讓後續的 IaC 引用鏈從 hardcode ID 換成資源屬性引用，這是 import 的結構性收益。優先級 5 的資源如果生命週期短（隨部署替換），用 IaC 重新定義再 apply 比逆向 import 划算。</p>
<h2 id="import-block-語法terraform-15">import block 語法（Terraform 1.5+）</h2>
<p>Terraform 1.5 引入了宣告式 import block，取代舊版的 <code>terraform import</code> CLI 指令。宣告式的優勢是 import 本身進版本控制、可 review、可回滾。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">import</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  to</span> <span class="o">=</span> <span class="k">aws_security_group</span><span class="p">.</span><span class="k">api</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  id</span> <span class="o">=</span> <span class="s2">&#34;sg-0abc123def456&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">}
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">import</span> {
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="n">  to</span> <span class="o">=</span> <span class="k">aws_db_instance</span><span class="p">.</span><span class="k">primary</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="n">  id</span> <span class="o">=</span> <span class="s2">&#34;app-prod-primary&#34;</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">}</span></span></code></pre></div><p><code>to</code> 是 Terraform 裡的資源地址（resource type + name），<code>id</code> 是雲端的資源識別碼。每種資源的 id 格式不同：security group 用 <code>sg-xxx</code>、RDS 用 DB identifier、S3 用 bucket name、IAM role 用 role name。格式查 Terraform provider 文件的 Import 段。</p>
<p>多個 import block 可以寫在同一個檔案裡（如 <code>imports.tf</code>），一次 plan/apply 處理整批。apply 完成後這些 import block 可以刪除——它們的作用是觸發 import 動作，import 完成後 state 已經記住了對應關係。</p>
<h2 id="generate-config-out-工作流">generate-config-out 工作流</h2>
<p>import block 只把資源綁進 state，不會自動產生對應的 HCL 定義。Terraform 1.5+ 提供 <code>-generate-config-out</code> flag 自動反推 HCL：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">terraform plan -generate-config-out<span class="o">=</span>generated_resources.tf</span></span></code></pre></div><p>這個指令會：</p>
<ol>
<li>讀取所有 import block</li>
<li>查詢每個資源在雲端的真實屬性</li>
<li>把屬性寫成 HCL 資源定義，輸出到指定檔案</li>
<li>在 plan 輸出中標示每個資源為 <code>import</code>（不是 create/change/destroy）</li>
</ol>
<p>生成的 HCL 是起點，需要人工 review 後才能正式使用。</p>
<h2 id="生成-hcl-的-review-要點">生成 HCL 的 review 要點</h2>
<p>自動生成的 code 有幾個常見問題需要修正：</p>
<h3 id="缺少-lifecycle-設定">缺少 lifecycle 設定</h3>
<p>生成的 code 不會包含 <code>lifecycle</code> block。有狀態資源（RDS、S3）需要手動加上保護：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_db_instance&#34; &#34;primary&#34;</span> {<span class="c1">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">  # ... generated attributes ...
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">lifecycle</span> {
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">    prevent_destroy</span> <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  }
</span></span><span class="line"><span class="ln">7</span><span class="cl">}</span></span></code></pre></div><p>沒加 <code>prevent_destroy</code> 的 stateful 資源，未來某次 plan 如果判定需要 replace，apply 會先刪除再重建——資料跟著消失。</p>
<h3 id="預設值與隱含屬性">預設值與隱含屬性</h3>
<p>雲端資源有些屬性是由平台自動設定的（如 RDS 的 <code>ca_cert_identifier</code>、EC2 的 <code>credit_specification</code>），生成的 code 會把這些都寫出來。下次平台更新預設值時，plan 會顯示 drift。review 時判斷：這個屬性是刻意設定的（保留），還是平台預設的（刪掉、讓 Terraform 接受平台預設）。</p>
<p>判斷方法：如果一個屬性的值跟 provider 文件裡的 default 一致，通常可以刪掉。如果不確定，先保留——保留多餘的屬性只是 code 冗長，刪錯屬性可能在下次 apply 時改變資源行為。</p>
<h3 id="provider-特有的-quirk">provider 特有的 quirk</h3>
<p>不同 provider 有各自的 import 陷阱：</p>
<table>
  <thead>
      <tr>
          <th>資源類型</th>
          <th>常見 quirk</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>aws_security_group</code></td>
          <td>inline <code>ingress</code>/<code>egress</code> block 與獨立的 <code>aws_security_group_rule</code> 衝突，選其一</td>
      </tr>
      <tr>
          <td><code>aws_s3_bucket</code></td>
          <td>Terraform AWS provider 4.x 把 bucket 的子屬性（versioning、encryption）拆成獨立資源</td>
      </tr>
      <tr>
          <td><code>aws_iam_role</code></td>
          <td><code>assume_role_policy</code> 是 JSON 字串，生成的 code 可能把 JSON 格式化方式跟 provider 預期不一致</td>
      </tr>
      <tr>
          <td><code>aws_db_instance</code></td>
          <td><code>password</code> 屬性不會被 import（敏感值），需要手動設定或引用 Secrets Manager</td>
      </tr>
  </tbody>
</table>
<p>security group 的 inline vs 獨立規則問題最常見：如果生成的 code 用 inline <code>ingress</code> block，但環境裡同時有獨立的 <code>aws_security_group_rule</code> 指向同一個 SG，兩者會互相打架。統一選一種寫法——多數情況用獨立 rule 更彈性。</p>
<h2 id="批次策略">批次策略</h2>
<p>一次 import 太多資源會讓 plan 輸出太長、review 不了。按服務類型分批，每批 5-15 個資源：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">批次 1: security groups (所有 SG + 對應的 rules)
</span></span><span class="line"><span class="ln">2</span><span class="cl">批次 2: VPC + subnets + route tables + NAT
</span></span><span class="line"><span class="ln">3</span><span class="cl">批次 3: IAM roles + policies
</span></span><span class="line"><span class="ln">4</span><span class="cl">批次 4: RDS instances + subnet groups + parameter groups
</span></span><span class="line"><span class="ln">5</span><span class="cl">批次 5: S3 buckets + bucket policies
</span></span><span class="line"><span class="ln">6</span><span class="cl">批次 6: ALB + listeners + target groups</span></span></code></pre></div><p>每批的操作流程固定：</p>
<ol>
<li>寫 import block → <code>imports-batch-N.tf</code></li>
<li><code>terraform plan -generate-config-out=generated-batch-N.tf</code> → 檢查 plan 輸出全部是 <code>import</code>、沒有 <code>create</code>/<code>destroy</code></li>
<li>review generated code → 修正 lifecycle、刪除平台預設屬性、處理 provider quirk</li>
<li><code>terraform plan</code> → 確認零非預期變更（import 完後的 plan 應該只有 import 標記、沒有 change）</li>
<li><code>terraform apply</code> → 執行 import</li>
<li><code>terraform plan</code> → 再跑一次確認零 drift（import 後的 state 與雲端一致）</li>
<li>刪除 <code>imports-batch-N.tf</code>（import block 已完成使命）、把 <code>generated-batch-N.tf</code> rename 成正式檔名</li>
</ol>
<p>批次之間要按依賴順序：先 import 被依賴的資源（VPC → subnet → SG），再 import 依賴它們的資源（RDS → EC2）。這樣後面批次的 generated code 可以引用前面批次已經在 state 裡的資源，而非 hardcode ID。</p>
<h2 id="驗證plan-必須是零非預期變更">驗證：plan 必須是零非預期變更</h2>
<p>import 完成的判準是 <code>terraform plan</code> 輸出只有兩種結果之一：</p>
<ul>
<li><strong>完全零變更</strong>（&ldquo;No changes&rdquo;）— 最理想，代表 HCL 和雲端現實完全匹配</li>
<li><strong>只有已知且可接受的差異</strong> — 某些屬性在 HCL 裡省略了（用平台預設）、或 provider 的 plan 行為跟雲端有已知的格式差異（如 JSON 排序不同）</li>
</ul>
<p>出現 <code>change</code>（要修改屬性）代表 HCL 跟雲端有落差，apply 會把雲端改成 HCL 的版本。在確認這個修改是安全的之前，不要 apply。</p>
<p>出現 <code>replace</code>（先刪後建）代表某個屬性的修改會觸發資源重建。對 stateful 資源這等於資料遺失，必須在 apply 之前解決——通常是 HCL 裡漏寫了某個 force-new 屬性。</p>
<h2 id="常見-import-失敗與處理">常見 import 失敗與處理</h2>
<table>
  <thead>
      <tr>
          <th>錯誤訊息</th>
          <th>原因</th>
          <th>處理方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>Resource already managed by Terraform</code></td>
          <td>資源已經在 state 裡</td>
          <td>用 <code>terraform state list</code> 確認、移除重複的 import block</td>
      </tr>
      <tr>
          <td><code>Cannot import non-existent remote object</code></td>
          <td>資源 ID 錯誤或資源已刪除</td>
          <td>確認 ID 格式正確、在 Console 確認資源存在</td>
      </tr>
      <tr>
          <td><code>Error: Unsupported resource type</code></td>
          <td>provider 版本太舊不支援該資源類型</td>
          <td>升級 provider version</td>
      </tr>
      <tr>
          <td><code>AccessDenied</code> / <code>is not authorized to perform</code></td>
          <td>執行 import 的身分權限不足</td>
          <td>import 需要對目標資源的 <code>Describe*</code> 和 <code>Get*</code> 權限</td>
      </tr>
      <tr>
          <td>Plan 顯示意外的 <code>destroy</code></td>
          <td>import block 的 <code>to</code> 地址跟已存在的資源定義衝突</td>
          <td>確認 <code>to</code> 指向的 resource block 不已經管理另一個資源</td>
      </tr>
  </tbody>
</table>
<p>import 操作本身不改變雲端資源——它只修改 state 檔。失敗時的回退方式是 <code>terraform state rm &lt;resource_address&gt;</code>，把 state 裡的對應記錄移除，資源本身不受影響。</p>
<h2 id="時程參考">時程參考</h2>
<table>
  <thead>
      <tr>
          <th>批次規模</th>
          <th>估計時間（含 review）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>5-10 個同類資源</td>
          <td>2-4 小時（含 generated code review）</td>
      </tr>
      <tr>
          <td>10-20 個混合資源</td>
          <td>1-2 天</td>
      </tr>
      <tr>
          <td>50+ 個資源的完整環境</td>
          <td>1-2 週（分 5-8 個批次、每批含驗證）</td>
      </tr>
  </tbody>
</table>
<p>主要時間花在 generated HCL 的 review——生成是秒級的，確認每個屬性正確與否是人工判斷。第一批（security group）通常最慢，因為要建立 review 的肌肉記憶；後面的批次會加速。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/takeover/partial-iac-no-docs/" data-link-title="有半套 IaC 但文件缺失的環境接管" data-link-desc="IaC 覆蓋不完整、部分資源在 state 外、文件缺失的環境怎麼盤點差距、修復 state 健康、收斂 drift 並重建文件">有半套 IaC 但文件缺失的環境接管</a>：import 前的盤點與 state 健康檢查</li>
<li>→ <a href="/blog/infra/takeover/partial-iac-dual-truth-operation/" data-link-title="兩套真相並存的過渡期操作" data-link-desc="部分資源在 IaC、部分在手動時，怎麼安全操作避免比全手動更危險，以及怎麼縮短這個過渡期">兩套真相並存的過渡期操作</a>：import 期間就是 dual-truth 狀態，操作規則見此篇</li>
<li>→ <a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一：IaC 工具選型與 state 地基</a>：state backend 的設定與保護</li>
<li>→ <a href="/blog/infra/05-core-services/stateful-protection-dependency/" data-link-title="Stateful 資源保護與跨服務依賴表達" data-link-desc="stateful 資源的保護策略（multi-AZ、備份、刪除保護）、stateful 與 stateless 的操作差異，以及用 output 與 data source 表達服務間依賴">模組五：Stateful 資源保護</a>：import stateful 資源後的 lifecycle 設定</li>
</ul>
]]></content:encoded></item><item><title>兩套真相並存的過渡期操作</title><link>https://tarrragon.github.io/blog/infra/takeover/partial-iac-dual-truth-operation/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/takeover/partial-iac-dual-truth-operation/</guid><description>&lt;p>部分資源由 Terraform 管理、部分仍在手動操作的環境，比全手動更危險。全手動時每個人都知道要去 Console 操作，行為模式一致；半套 IaC 時同一個環境有兩套操作路徑，每一次操作都要先判斷「這個資源歸哪套管」，判斷錯了的後果是 apply 覆蓋手動設定、或手動改動讓 state 與現實分歧。這篇處理的是怎麼在這個過渡期安全操作，以及怎麼盡快離開這個狀態。&lt;/p>
&lt;h2 id="為什麼半套比全手動更危險">為什麼半套比全手動更危險&lt;/h2>
&lt;p>兩個方向的風險同時存在，而且互相放大。&lt;/p>
&lt;h3 id="apply-可能摧毀未納管的資源">apply 可能摧毀未納管的資源&lt;/h3>
&lt;p>Terraform apply 只知道 state 裡有什麼。一個存在於雲端但不在 state 裡的資源，對 Terraform 來說「不存在」。如果某個 managed resource 引用了一個 unmanaged resource 的 ID（例如一個 security group 引用了一個手動建的 security group 作為 source），apply 不會主動碰那個 unmanaged resource——但如果有人重構了 HCL 並把那個引用移除或改掉，apply 會改動 managed 的那一端，可能讓依賴它的 unmanaged 資源失去連線。&lt;/p>
&lt;p>更直接的風險是 &lt;code>terraform destroy&lt;/code> 或 &lt;code>terraform apply&lt;/code> 配合 &lt;code>count = 0&lt;/code> 這類邏輯刪除：如果有人誤判某個資源已經不用了、但它其實只是不在 state 裡（被前人 &lt;code>state rm&lt;/code> 過），destroy 不會碰它——但如果有人重新 import 它再 destroy，資源就真的被刪了。&lt;/p>
&lt;h3 id="手動改動讓-managed-資源-drift">手動改動讓 managed 資源 drift&lt;/h3>
&lt;p>有人在 Console 手動改了一個已經由 Terraform 管理的資源（例如加了一條 security group 規則），state 不知道這個改動。下一次任何人跑 apply，Terraform 會把手動加的規則判定為「不該存在」並刪除。手動改動的人以為規則已經加好了，直到某次不相關的 apply 把它默默清掉。&lt;/p>
&lt;p>這兩個風險的交叉效應是：團隊對「能不能跑 apply」和「能不能手動改」都缺乏信心，結果是兩邊都不敢動，變更停滯，技術債累積速度比全手動還快。&lt;/p>
&lt;h2 id="過渡期操作規則">過渡期操作規則&lt;/h2>
&lt;p>過渡期的操作紀律核心是一句話：&lt;strong>每個資源在任何時刻都只有一個合法的變更路徑&lt;/strong>。managed 資源走 IaC，unmanaged 資源走 Console + 變更日誌。混用就是 drift 的來源。&lt;/p>
&lt;h3 id="規則一apply-前必讀-plan">規則一：apply 前必讀 plan&lt;/h3>
&lt;p>過渡期的每一次 &lt;code>terraform apply&lt;/code> 之前，都要完整讀 &lt;code>terraform plan&lt;/code> 的輸出，逐行確認每一項變更是預期內的。特別警惕以下訊號：&lt;/p>
&lt;ul>
&lt;li>&lt;code>will be destroyed&lt;/code>：確認這個資源是否有其他依賴（即使它在 state 裡）&lt;/li>
&lt;li>&lt;code>will be updated in-place&lt;/code> 且變更的屬性不是這次修改的：代表有人手動改了這個屬性，apply 會覆蓋回去&lt;/li>
&lt;li>&lt;code>must be replaced&lt;/code>：資源會被先刪後建，stateful 資源（RDS、EBS）在這裡要暫停確認&lt;/li>
&lt;/ul>
&lt;p>過渡期禁止 &lt;code>terraform apply -auto-approve&lt;/code>。即使 CI pipeline 也要把 apply 設為手動觸發（GitHub Actions 的 environment protection rule），確保有人看過 plan。&lt;/p>
&lt;h3 id="規則二不手動改-managed-資源">規則二：不手動改 managed 資源&lt;/h3>
&lt;p>一個資源一旦進了 Terraform state，所有對它的變更都走 HCL → plan → apply。在 Console 改它會製造 drift，而 drift 在過渡期特別危險——因為下一次 apply 可能已經隔了好幾天，中間的手動改動已經忘了。&lt;/p>
&lt;p>如果遇到緊急情況必須手動改 managed 資源（例如安全事件需要立即封鎖某個 port），操作流程是：&lt;/p>
&lt;ol>
&lt;li>在 Console 做緊急變更&lt;/li>
&lt;li>立刻在變更日誌記錄：時間、資源、改了什麼、為什麼&lt;/li>
&lt;li>在 HCL 裡同步這個變更，提 PR&lt;/li>
&lt;li>PR 裡的 plan 應該顯示零變更（因為 HCL 已經對齊了手動改動）&lt;/li>
&lt;li>合併 PR，state 透過下一次 apply 或 refresh 更新&lt;/li>
&lt;/ol>
&lt;h3 id="規則三記錄哪些資源歸誰管">規則三：記錄哪些資源歸誰管&lt;/h3>
&lt;p>維護一份「管理歸屬清單」——哪些資源在 Terraform state 裡、哪些還在手動管理。格式可以是 repo 裡的一個 markdown 表格：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-markdown" data-lang="markdown">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="gu">## 資源管理歸屬
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="gu">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">| 資源類型 | 資源名稱/ID | 管理方式 | 備註 |
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">| -------------- | -------------------- | ---------- | ---------------- |
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">| VPC | vpc-0abc123 | Terraform | |
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">| Subnet (×4) | subnet-0def... | Terraform | |
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">| RDS | app-prod-primary | Terraform | stateful、謹慎操作 |
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">| SG web | sg-0web456 | Terraform | |
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">| SG legacy-api | sg-0legacy789 | 手動 | 待 import |
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">| EC2 worker | i-0worker123 | 手動 | 待 import |
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">| Lambda cron | cleanup-job | 手動 | 待評估是否納管 |&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這份清單的維護者是跑 apply 的人——每次 import 一個新資源後更新清單。清單同時是 team communication 的基礎：team member 要改某個資源前，先查清單確認管理方式。&lt;/p></description><content:encoded><![CDATA[<p>部分資源由 Terraform 管理、部分仍在手動操作的環境，比全手動更危險。全手動時每個人都知道要去 Console 操作，行為模式一致；半套 IaC 時同一個環境有兩套操作路徑，每一次操作都要先判斷「這個資源歸哪套管」，判斷錯了的後果是 apply 覆蓋手動設定、或手動改動讓 state 與現實分歧。這篇處理的是怎麼在這個過渡期安全操作，以及怎麼盡快離開這個狀態。</p>
<h2 id="為什麼半套比全手動更危險">為什麼半套比全手動更危險</h2>
<p>兩個方向的風險同時存在，而且互相放大。</p>
<h3 id="apply-可能摧毀未納管的資源">apply 可能摧毀未納管的資源</h3>
<p>Terraform apply 只知道 state 裡有什麼。一個存在於雲端但不在 state 裡的資源，對 Terraform 來說「不存在」。如果某個 managed resource 引用了一個 unmanaged resource 的 ID（例如一個 security group 引用了一個手動建的 security group 作為 source），apply 不會主動碰那個 unmanaged resource——但如果有人重構了 HCL 並把那個引用移除或改掉，apply 會改動 managed 的那一端，可能讓依賴它的 unmanaged 資源失去連線。</p>
<p>更直接的風險是 <code>terraform destroy</code> 或 <code>terraform apply</code> 配合 <code>count = 0</code> 這類邏輯刪除：如果有人誤判某個資源已經不用了、但它其實只是不在 state 裡（被前人 <code>state rm</code> 過），destroy 不會碰它——但如果有人重新 import 它再 destroy，資源就真的被刪了。</p>
<h3 id="手動改動讓-managed-資源-drift">手動改動讓 managed 資源 drift</h3>
<p>有人在 Console 手動改了一個已經由 Terraform 管理的資源（例如加了一條 security group 規則），state 不知道這個改動。下一次任何人跑 apply，Terraform 會把手動加的規則判定為「不該存在」並刪除。手動改動的人以為規則已經加好了，直到某次不相關的 apply 把它默默清掉。</p>
<p>這兩個風險的交叉效應是：團隊對「能不能跑 apply」和「能不能手動改」都缺乏信心，結果是兩邊都不敢動，變更停滯，技術債累積速度比全手動還快。</p>
<h2 id="過渡期操作規則">過渡期操作規則</h2>
<p>過渡期的操作紀律核心是一句話：<strong>每個資源在任何時刻都只有一個合法的變更路徑</strong>。managed 資源走 IaC，unmanaged 資源走 Console + 變更日誌。混用就是 drift 的來源。</p>
<h3 id="規則一apply-前必讀-plan">規則一：apply 前必讀 plan</h3>
<p>過渡期的每一次 <code>terraform apply</code> 之前，都要完整讀 <code>terraform plan</code> 的輸出，逐行確認每一項變更是預期內的。特別警惕以下訊號：</p>
<ul>
<li><code>will be destroyed</code>：確認這個資源是否有其他依賴（即使它在 state 裡）</li>
<li><code>will be updated in-place</code> 且變更的屬性不是這次修改的：代表有人手動改了這個屬性，apply 會覆蓋回去</li>
<li><code>must be replaced</code>：資源會被先刪後建，stateful 資源（RDS、EBS）在這裡要暫停確認</li>
</ul>
<p>過渡期禁止 <code>terraform apply -auto-approve</code>。即使 CI pipeline 也要把 apply 設為手動觸發（GitHub Actions 的 environment protection rule），確保有人看過 plan。</p>
<h3 id="規則二不手動改-managed-資源">規則二：不手動改 managed 資源</h3>
<p>一個資源一旦進了 Terraform state，所有對它的變更都走 HCL → plan → apply。在 Console 改它會製造 drift，而 drift 在過渡期特別危險——因為下一次 apply 可能已經隔了好幾天，中間的手動改動已經忘了。</p>
<p>如果遇到緊急情況必須手動改 managed 資源（例如安全事件需要立即封鎖某個 port），操作流程是：</p>
<ol>
<li>在 Console 做緊急變更</li>
<li>立刻在變更日誌記錄：時間、資源、改了什麼、為什麼</li>
<li>在 HCL 裡同步這個變更，提 PR</li>
<li>PR 裡的 plan 應該顯示零變更（因為 HCL 已經對齊了手動改動）</li>
<li>合併 PR，state 透過下一次 apply 或 refresh 更新</li>
</ol>
<h3 id="規則三記錄哪些資源歸誰管">規則三：記錄哪些資源歸誰管</h3>
<p>維護一份「管理歸屬清單」——哪些資源在 Terraform state 裡、哪些還在手動管理。格式可以是 repo 裡的一個 markdown 表格：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="gu">## 資源管理歸屬
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">| 資源類型       | 資源名稱/ID         | 管理方式   | 備註             |
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">| -------------- | -------------------- | ---------- | ---------------- |
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">| VPC            | vpc-0abc123          | Terraform  |                  |
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">| Subnet (×4)   | subnet-0def...       | Terraform  |                  |
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">| RDS            | app-prod-primary     | Terraform  | stateful、謹慎操作 |
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">| SG web         | sg-0web456           | Terraform  |                  |
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">| SG legacy-api  | sg-0legacy789        | 手動       | 待 import        |
</span></span><span class="line"><span class="ln">10</span><span class="cl">| EC2 worker     | i-0worker123         | 手動       | 待 import        |
</span></span><span class="line"><span class="ln">11</span><span class="cl">| Lambda cron    | cleanup-job          | 手動       | 待評估是否納管   |</span></span></code></pre></div><p>這份清單的維護者是跑 apply 的人——每次 import 一個新資源後更新清單。清單同時是 team communication 的基礎：team member 要改某個資源前，先查清單確認管理方式。</p>
<h2 id="團隊溝通">團隊溝通</h2>
<p>過渡期最重要的溝通是讓所有會碰 Console 的人知道哪些資源「不能手動改」。溝通的形式是直接的操作指令：</p>
<p>在 team channel 發一則釘選訊息：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">[Infra 過渡期操作規則]
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">以下資源已由 Terraform 管理，變更請走 PR：
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">- VPC 和所有 subnet
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">- Security group: sg-0web456, sg-0app789
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">- RDS: app-prod-primary
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">- ALB: app-prod-alb
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">以下資源仍為手動管理，變更請在 Console 操作後寫 changelog：
</span></span><span class="line"><span class="ln">10</span><span class="cl">- EC2: i-0worker123
</span></span><span class="line"><span class="ln">11</span><span class="cl">- Lambda: cleanup-job
</span></span><span class="line"><span class="ln">12</span><span class="cl">- SG: sg-0legacy789
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">不確定的資源：先問再動。</span></span></code></pre></div><p>隨著 import 進展更新這則訊息。如果團隊用的是 Slack，可以把這則訊息設成 channel bookmark。</p>
<h2 id="縮短過渡期">縮短過渡期</h2>
<p>過渡期越長、兩套真相並存越久、操作事故的機率越高。縮短的方式是用 import sprint 集中處理。</p>
<h3 id="import-sprint-的排程">Import sprint 的排程</h3>
<p>一個 import sprint 是 1-2 天的集中工作，目標是把一批相關的 unmanaged 資源納入 Terraform。按風險從低到高排序：</p>
<table>
  <thead>
      <tr>
          <th>批次</th>
          <th>資源類型</th>
          <th>理由</th>
          <th>預估時間</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>SG、IAM role/policy</td>
          <td>高頻變更、drift 風險最高</td>
          <td>半天到一天</td>
      </tr>
      <tr>
          <td>2</td>
          <td>S3 bucket、CloudWatch</td>
          <td>stateless、import 風險低</td>
          <td>半天</td>
      </tr>
      <tr>
          <td>3</td>
          <td>EC2 instance、ECS</td>
          <td>中風險、需確認 user data 和 AMI</td>
          <td>一天</td>
      </tr>
      <tr>
          <td>4</td>
          <td>RDS、EBS</td>
          <td>stateful、import 失敗代價最高、最後做</td>
          <td>一天（含驗證）</td>
      </tr>
  </tbody>
</table>
<p>每批的操作流程：</p>
<ol>
<li>用 <code>import</code> block + <code>terraform plan -generate-config-out</code> 產生 HCL</li>
<li>審查生成的 HCL，修正屬性差異</li>
<li><code>plan</code> 確認零變更</li>
<li>合併 PR</li>
<li>更新管理歸屬清單</li>
</ol>
<h3 id="縮短期間不要追求完美">縮短期間不要追求完美</h3>
<p>import sprint 的目標是「納管」，不是「重構」。一個手動建的資源 import 進來後，它的 HCL 可能很醜（自動生成的 code 有大量冗餘屬性），但只要 plan 顯示零變更，它就已經是 managed 的了。重構 HCL 是 import 完成之後的事。</p>
<p>同樣，import sprint 期間不要同時做 module 化或環境分離。先把所有資源納管到同一份 state，之後再拆——拆的前提是所有資源都在 state 裡。</p>
<h2 id="過渡期結束的判準">過渡期結束的判準</h2>
<p>過渡期結束的定義是兩個條件同時滿足：</p>
<ol>
<li><strong><code>terraform plan</code> 在無 code 變更時顯示零差異</strong>：代表 state 與雲端現實一致，沒有 drift</li>
<li><strong>管理歸屬清單上的「手動」欄位清空</strong>：所有生產資源都進了 Terraform state</li>
</ol>
<p>第一個條件用定期排程驗證（每天跑一次 plan，非零就告警）。第二個條件用資源盤點比對——雲端的 resource inventory 減去 <code>terraform state list</code> 的輸出，差集為空就完成。</p>
<p>過渡期結束後，操作規則簡化為：所有變更走 IaC + PR，Console 只用來觀察和排查。這就是<a href="/blog/infra/01-minimal-iac/console-readonly-minimal-viable/" data-link-title="Console 唯讀鐵律與最小可行資源集合" data-link-desc="Console 只用來看不用來改的操作紀律、drift 的延遲浮現與偵測，以及能跑出第一個完整 apply 迴路的最小資源集合">模組一的 Console 唯讀鐵律</a>。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/takeover/partial-iac-no-docs/" data-link-title="有半套 IaC 但文件缺失的環境接管" data-link-desc="IaC 覆蓋不完整、部分資源在 state 外、文件缺失的環境怎麼盤點差距、修復 state 健康、收斂 drift 並重建文件">有半套 IaC 但文件缺失的環境接管</a>：本篇的前置操作（盤點、state 健康檢查、drift 收斂）</li>
<li>→ <a href="/blog/infra/takeover/partial-iac-state-repair/" data-link-title="State 修復與清理" data-link-desc="接手的 Terraform state 損壞、有 orphaned entry、或需要搬遷時，怎麼診斷問題、安全操作、以及從錯誤中回復">State 修復與清理</a>：過渡期出問題時可能需要 state surgery</li>
<li>→ <a href="/blog/infra/01-minimal-iac/console-readonly-minimal-viable/" data-link-title="Console 唯讀鐵律與最小可行資源集合" data-link-desc="Console 只用來看不用來改的操作紀律、drift 的延遲浮現與偵測，以及能跑出第一個完整 apply 迴路的最小資源集合">模組一：Console 唯讀鐵律</a>：過渡期結束後的操作紀律</li>
<li>→ <a href="/blog/infra/04-environment-separation/single-to-multi-env-retrofit/" data-link-title="單環境到多環境的 Retrofit 操作手冊" data-link-desc="把已經跑在單一環境的 Terraform 設定拆成 module &#43; per-env 目錄結構的完整操作步驟，含 moved block、zero-change plan 驗證與常見陷阱">模組四：環境分離 retrofit</a>：所有資源納管後的下一步</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">模組七：infra 走 PR 流程</a>：過渡期結束後的完整 PR 護欄</li>
</ul>
]]></content:encoded></item></channel></rss>