<?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>模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律 on Tarragon</title><link>https://tarrragon.github.io/blog/infra/01-minimal-iac/</link><description>Recent content in 模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律 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/infra/01-minimal-iac/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>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></channel></rss>