<?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 on Tarragon</title><link>https://tarrragon.github.io/blog/tags/iac/</link><description>Recent content in Iac on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 29 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/iac/index.xml" rel="self" type="application/rss+xml"/><item><title>模組負一：還沒有 infra 的環境怎麼盡量做好</title><link>https://tarrragon.github.io/blog/infra/before-infra/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/before-infra/</guid><description>&lt;p>理想的 infra 治理是每一個資源都由版本控制描述、每一次變更都走 review、環境之間靠程式碼複製。多數正在運行的服務離這個畫面很遠：資源是有人在 Console 一個一個點出來的，security group 規則靠記憶維護，誰改了什麼只存在當事人腦裡。這一章承接的就是這個落差 — 你現在就在手動環境、還沒有能力或資源導入 IaC，目標是把這個階段做成「可控的手動」、而不是假裝已經納管，把代價最高的傷害先擋住，並為日後納管鋪好輸入。&lt;/p>
&lt;h2 id="把手動環境做成可控的手動">把手動環境做成「可控的手動」&lt;/h2>
&lt;p>可控的手動指的是一種中間狀態：資源還是手點的，但關鍵變更有痕跡、高風險操作有護欄、現實長什麼樣有紀錄。它的責任是降低兩種成本 — 當下出事的成本，以及未來把資源 import 進 IaC 的成本。手動起家是絕大多數服務的常態起點，從一個人驗證想法到小團隊接手都會經過這一階，把它當成需要管理的階段、而不是需要修正的錯誤。&lt;/p>
&lt;p>判讀自己是否「可控」的訊號很具體：能不能在五分鐘內說出 production 有哪些對外開放的 port、上週誰動過資料庫參數、刪掉某台機器會不會連帶弄壞別的東西。任何一題答不出來，代表這個手動環境的不可見區域正在擴大，下面幾節就是把這些區域逐一收斂。&lt;/p>
&lt;h2 id="先守住代價最高的底線">先守住代價最高的底線&lt;/h2>
&lt;p>護欄要先上在「一次失誤就難以挽回」的操作上，因為手動環境沒有 IaC 的 plan / diff 當預檢，人為操作直接生效。優先級看的是失誤的回退代價、不是操作頻率。&lt;/p>
&lt;p>長期憑證外洩是回退代價最高的一類。手動環境常見的反模式是把長期 access key 寫進腳本、CI 變數或開發者筆電，一旦外流，攻擊者拿到的是不會過期的權限。在還沒有完整 IAM 設計之前，最低成本的護欄是：對人改用會過期的登入工作階段（如 AWS IAM Identity Center 的臨時憑證），對自動化盡量改用平台原生的角色綁定，把還在用的長期 key 列一張清單、設定定期輪替。身分與憑證的完整地基在「模組二：身分與憑證地基」展開，這裡先擋住最容易致命的那一個。&lt;/p>
&lt;p>刪除 production 資源是第二類。手動操作沒有「先看會影響什麼」的步驟，刪一個 security group 或 volume 可能瞬間讓服務失聯。對承載狀態的資源（資料庫、儲存桶、有持久資料的磁碟）開啟平台的刪除保護（如 termination protection、deletion protection），讓誤點多一道阻力。網路規則的大改是第三類 — 調整 VPC 路由、subnet 或對外規則時，先確認回退方式存在再動手，網路地基的系統性設計在「模組三：網路地基」。&lt;/p>
&lt;p>這三類的共同點是：護欄成本低、失誤代價高，所以即使還沒有 IaC，CP 值也足以先做。&lt;/p>
&lt;h2 id="讓變更留下痕跡">讓變更留下痕跡&lt;/h2>
&lt;p>變更留痕的責任是讓「誰、在什麼時候、改了什麼、為什麼」事後可追溯，這是手動階段最接近版本控制的替代品。IaC 的 git history 天然提供這件事，手動環境得靠人為紀律補上。&lt;/p>
&lt;p>最低限度是一份變更日誌，可以只是 repo 裡的一個 &lt;code>CHANGELOG&lt;/code> 或團隊共用文件，每次動 production 就追加一行：時間、操作者、改了哪個資源、原因。它不需要漂亮，需要的是每次都寫。和它互補的是平台的稽核日誌（如 AWS CloudTrail），稽核日誌記錄 API 層級「發生了什麼」，人寫的日誌補上「為什麼」— 前者你查得到某個 security group 在幾點被改，後者你才知道那次改動是為了什麼需求。兩者一起，事故排查時才能從「哪裡變了」一路追到「能不能安全回退」。&lt;/p>
&lt;p>常見陷阱是只在「大改動」時才記錄，結果真正出事的往往是某次以為無關緊要的小調整。判準簡化成一句：只要這個操作別人事後可能需要知道，就記。&lt;/p>
&lt;h2 id="命名與-tagging-從手動階段就開始">命名與 tagging 從手動階段就開始&lt;/h2>
&lt;p>命名規範與資源標籤是降低未來 import 成本的最低成本投資，它的責任是讓每個資源自帶「我是誰、屬於哪個服務、誰負責、哪個環境」的身分資訊。手動點出來的資源若名稱是 &lt;code>test-2&lt;/code>、&lt;code>new-db-final&lt;/code>，日後納管時得靠人逐一辨認哪個還在用、屬於哪條業務線，這個考古成本遠高於當初多打幾個字。&lt;/p>
&lt;p>從手動階段就固定一套規則：資源名稱帶上服務與環境（如 &lt;code>payments-api-prod&lt;/code>），標籤至少包含 &lt;code>service&lt;/code>、&lt;code>env&lt;/code>、&lt;code>owner&lt;/code> 三個維度。這套規則在還沒 IaC 時靠人手動填，等到導入 IaC，這些標籤直接成為 Terraform 把現有資源對應到程式碼的依據，也是模組八治理習慣裡成本歸因與批次操作的基礎（見「模組八：治理好習慣」的 tagging 段）。先建立規範的價值在於：早一天統一，需要回頭重命名的資源就少一批。&lt;/p>
&lt;h2 id="盤點現有資源作為納管輸入">盤點現有資源作為納管輸入&lt;/h2>
&lt;p>資源盤點的責任是把「現實長什麼樣」寫成一份清單，它是日後納管的直接輸入 — 不知道有什麼，就無法決定先 import 什麼。手動環境最危險的是沒人記得還開著的資源。&lt;/p>
&lt;p>盤點不必一次到位，先用平台工具把現況拉出來，存成可比對的形式：&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"># 列出某區域所有 EC2 instance 與其關鍵標籤&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">aws ec2 describe-instances &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> --query &lt;span class="s1">&amp;#39;Reservations[].Instances[].[InstanceId,Tags,State.Name]&amp;#39;&lt;/span> &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> --output table
&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"># 列出所有 security group 與開放規則，找出對外開放的 port&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">aws ec2 describe-security-groups &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --query &lt;span class="s1">&amp;#39;SecurityGroups[].[GroupId,GroupName,IpPermissions]&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --output json&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>把輸出存進 repo，定期重跑比對差異，就能看出環境在背景悄悄長出了什麼。這份清單同時服務三件事：當下的安全盤查（有沒有不該開的對外 port）、未來 IaC import 的範圍界定、以及成熟度評估時「全手動到底有多少資源」的事實基礎（成熟度階梯的定位見「模組零：infra 是什麼」）。&lt;/p>
&lt;h2 id="資源與信任不足下的高槓桿取捨">資源與信任不足下的高槓桿取捨&lt;/h2>
&lt;p>當時間、人力或上層信任都不足，無法一次把上面每件事做齊時，取捨原則是先做「失誤代價高且護欄成本低」的少數幾件。在這個情境下，最划算的通常是兩件：先擋長期憑證外洩，因為一次外洩可能拖垮整個帳號；再開啟有狀態資源的刪除保護，因為資料一旦刪除多半無法復原。&lt;/p>
&lt;p>變更日誌與資源盤點屬於累積型投資 — 越早開始，未來省的考古成本越多，但晚一週開始不會立刻出事，所以在資源極度受限時可以排在護欄之後。命名與 tagging 的取捨點在於：新建資源時順手套規則幾乎零成本，回頭重整存量資源才貴，所以策略是「新的一律照規範、舊的等有餘力再補」，而不是停下來先整理全部存量。資源不足時怎麼跟上層談這些工作的優先級，在「模組九：怎麼把 infra 推動起來」展開。&lt;/p>
&lt;h2 id="該開始導入-iac-的訊號">該開始導入 IaC 的訊號&lt;/h2>
&lt;p>手動環境到了某些訊號出現時，繼續手動的邊際成本會超過導入 IaC 的一次性成本，這就是該往模組一跨進去的時機。訊號是規模與協作的函數，不是時間的函數。&lt;/p>
&lt;p>第一個訊號是環境數量變多：當你需要 dev、staging、production 三套幾乎一樣的環境，手動複製會在環境之間留下難以察覺的差異，而 IaC 的價值正是用同一份程式碼複製環境。第二個是多人同時動資源：一個人手動操作還能靠記憶維護，兩三個人並行時，沒有 plan / review 的手動變更會互相覆蓋、互相破壞。第三個是環境爆炸頻率上升：如果「改一個設定結果弄壞別的東西」這類事故開始每月發生，代表手動環境的隱性依賴已經超過人腦能追蹤的上限。&lt;/p>
&lt;p>任一訊號穩定出現，就是把第一個資源納入 IaC 的起點 — 前面做的命名、tagging、資源盤點此時直接成為 import 的輸入，第一步怎麼跨進去在「模組一：最小可行 IaC」。在訊號出現前過早導入 IaC 也有代價：單人、單環境、低變更頻率時，IaC 的學習與維護成本可能高於它省下的手動工，所以這裡的判準是等訊號、不是趕進度。&lt;/p>
&lt;h2 id="章節文章">章節文章&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>文章&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/before-infra/manual-environment-baseline/" data-link-title="手動環境的可控底線與納管準備" data-link-desc="還沒有 IaC 的環境怎麼守住底線、讓變更可追溯、降低未來納管成本，以及辨識何時該開始導入 IaC">手動環境的可控底線與納管準備&lt;/a>&lt;/td>
 &lt;td>還沒有 IaC 的環境怎麼守住底線、讓變更可追溯、降低未來納管成本，以及辨識何時該開始導入 IaC&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">模組零：infra 是什麼&lt;/a>：成熟度階梯上「全手動」這一階的定位&lt;/li>
&lt;li>→ &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 只能看不能改」鐵律">模組一：最小可行 IaC&lt;/a>：訊號出現後，第一步怎麼跨進 IaC&lt;/li>
&lt;li>→ &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>：長期憑證護欄的系統性設計&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基&lt;/a>：手動階段網路大改的回退考量、之後的系統性設計&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣&lt;/a>：tagging 在成本歸因與批次操作的後續價值&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/infra/09-driving-adoption/" data-link-title="模組九：怎麼把 infra 推動起來" data-link-desc="技術正確不等於推得動 — 信任赤字、期望值對齊、知識共享，infra 落地的組織課題">模組九：怎麼把 infra 推動起來&lt;/a>：資源不足時怎麼跟上層談優先級&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/infra/takeover/" data-link-title="接手維運：別人建的環境怎麼接管" data-link-desc="接手前人的專案時，怎麼在不搞壞東西的前提下盤點現況、建立維運能力、逐步正規化 — 從無 SSH 的 FTP 環境到有半套 IaC 的雲端環境都適用">接手維運：別人建的環境怎麼接管&lt;/a>：接手前人的專案時的盤點與接管流程&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>理想的 infra 治理是每一個資源都由版本控制描述、每一次變更都走 review、環境之間靠程式碼複製。多數正在運行的服務離這個畫面很遠：資源是有人在 Console 一個一個點出來的，security group 規則靠記憶維護，誰改了什麼只存在當事人腦裡。這一章承接的就是這個落差 — 你現在就在手動環境、還沒有能力或資源導入 IaC，目標是把這個階段做成「可控的手動」、而不是假裝已經納管，把代價最高的傷害先擋住，並為日後納管鋪好輸入。</p>
<h2 id="把手動環境做成可控的手動">把手動環境做成「可控的手動」</h2>
<p>可控的手動指的是一種中間狀態：資源還是手點的，但關鍵變更有痕跡、高風險操作有護欄、現實長什麼樣有紀錄。它的責任是降低兩種成本 — 當下出事的成本，以及未來把資源 import 進 IaC 的成本。手動起家是絕大多數服務的常態起點，從一個人驗證想法到小團隊接手都會經過這一階，把它當成需要管理的階段、而不是需要修正的錯誤。</p>
<p>判讀自己是否「可控」的訊號很具體：能不能在五分鐘內說出 production 有哪些對外開放的 port、上週誰動過資料庫參數、刪掉某台機器會不會連帶弄壞別的東西。任何一題答不出來，代表這個手動環境的不可見區域正在擴大，下面幾節就是把這些區域逐一收斂。</p>
<h2 id="先守住代價最高的底線">先守住代價最高的底線</h2>
<p>護欄要先上在「一次失誤就難以挽回」的操作上，因為手動環境沒有 IaC 的 plan / diff 當預檢，人為操作直接生效。優先級看的是失誤的回退代價、不是操作頻率。</p>
<p>長期憑證外洩是回退代價最高的一類。手動環境常見的反模式是把長期 access key 寫進腳本、CI 變數或開發者筆電，一旦外流，攻擊者拿到的是不會過期的權限。在還沒有完整 IAM 設計之前，最低成本的護欄是：對人改用會過期的登入工作階段（如 AWS IAM Identity Center 的臨時憑證），對自動化盡量改用平台原生的角色綁定，把還在用的長期 key 列一張清單、設定定期輪替。身分與憑證的完整地基在「模組二：身分與憑證地基」展開，這裡先擋住最容易致命的那一個。</p>
<p>刪除 production 資源是第二類。手動操作沒有「先看會影響什麼」的步驟，刪一個 security group 或 volume 可能瞬間讓服務失聯。對承載狀態的資源（資料庫、儲存桶、有持久資料的磁碟）開啟平台的刪除保護（如 termination protection、deletion protection），讓誤點多一道阻力。網路規則的大改是第三類 — 調整 VPC 路由、subnet 或對外規則時，先確認回退方式存在再動手，網路地基的系統性設計在「模組三：網路地基」。</p>
<p>這三類的共同點是：護欄成本低、失誤代價高，所以即使還沒有 IaC，CP 值也足以先做。</p>
<h2 id="讓變更留下痕跡">讓變更留下痕跡</h2>
<p>變更留痕的責任是讓「誰、在什麼時候、改了什麼、為什麼」事後可追溯，這是手動階段最接近版本控制的替代品。IaC 的 git history 天然提供這件事，手動環境得靠人為紀律補上。</p>
<p>最低限度是一份變更日誌，可以只是 repo 裡的一個 <code>CHANGELOG</code> 或團隊共用文件，每次動 production 就追加一行：時間、操作者、改了哪個資源、原因。它不需要漂亮，需要的是每次都寫。和它互補的是平台的稽核日誌（如 AWS CloudTrail），稽核日誌記錄 API 層級「發生了什麼」，人寫的日誌補上「為什麼」— 前者你查得到某個 security group 在幾點被改，後者你才知道那次改動是為了什麼需求。兩者一起，事故排查時才能從「哪裡變了」一路追到「能不能安全回退」。</p>
<p>常見陷阱是只在「大改動」時才記錄，結果真正出事的往往是某次以為無關緊要的小調整。判準簡化成一句：只要這個操作別人事後可能需要知道，就記。</p>
<h2 id="命名與-tagging-從手動階段就開始">命名與 tagging 從手動階段就開始</h2>
<p>命名規範與資源標籤是降低未來 import 成本的最低成本投資，它的責任是讓每個資源自帶「我是誰、屬於哪個服務、誰負責、哪個環境」的身分資訊。手動點出來的資源若名稱是 <code>test-2</code>、<code>new-db-final</code>，日後納管時得靠人逐一辨認哪個還在用、屬於哪條業務線，這個考古成本遠高於當初多打幾個字。</p>
<p>從手動階段就固定一套規則：資源名稱帶上服務與環境（如 <code>payments-api-prod</code>），標籤至少包含 <code>service</code>、<code>env</code>、<code>owner</code> 三個維度。這套規則在還沒 IaC 時靠人手動填，等到導入 IaC，這些標籤直接成為 Terraform 把現有資源對應到程式碼的依據，也是模組八治理習慣裡成本歸因與批次操作的基礎（見「模組八：治理好習慣」的 tagging 段）。先建立規範的價值在於：早一天統一，需要回頭重命名的資源就少一批。</p>
<h2 id="盤點現有資源作為納管輸入">盤點現有資源作為納管輸入</h2>
<p>資源盤點的責任是把「現實長什麼樣」寫成一份清單，它是日後納管的直接輸入 — 不知道有什麼，就無法決定先 import 什麼。手動環境最危險的是沒人記得還開著的資源。</p>
<p>盤點不必一次到位，先用平台工具把現況拉出來，存成可比對的形式：</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"># 列出某區域所有 EC2 instance 與其關鍵標籤</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws ec2 describe-instances <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;Reservations[].Instances[].[InstanceId,Tags,State.Name]&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --output table
</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"># 列出所有 security group 與開放規則，找出對外開放的 port</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">aws ec2 describe-security-groups <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;SecurityGroups[].[GroupId,GroupName,IpPermissions]&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="se"></span>  --output json</span></span></code></pre></div><p>把輸出存進 repo，定期重跑比對差異，就能看出環境在背景悄悄長出了什麼。這份清單同時服務三件事：當下的安全盤查（有沒有不該開的對外 port）、未來 IaC import 的範圍界定、以及成熟度評估時「全手動到底有多少資源」的事實基礎（成熟度階梯的定位見「模組零：infra 是什麼」）。</p>
<h2 id="資源與信任不足下的高槓桿取捨">資源與信任不足下的高槓桿取捨</h2>
<p>當時間、人力或上層信任都不足，無法一次把上面每件事做齊時，取捨原則是先做「失誤代價高且護欄成本低」的少數幾件。在這個情境下，最划算的通常是兩件：先擋長期憑證外洩，因為一次外洩可能拖垮整個帳號；再開啟有狀態資源的刪除保護，因為資料一旦刪除多半無法復原。</p>
<p>變更日誌與資源盤點屬於累積型投資 — 越早開始，未來省的考古成本越多，但晚一週開始不會立刻出事，所以在資源極度受限時可以排在護欄之後。命名與 tagging 的取捨點在於：新建資源時順手套規則幾乎零成本，回頭重整存量資源才貴，所以策略是「新的一律照規範、舊的等有餘力再補」，而不是停下來先整理全部存量。資源不足時怎麼跟上層談這些工作的優先級，在「模組九：怎麼把 infra 推動起來」展開。</p>
<h2 id="該開始導入-iac-的訊號">該開始導入 IaC 的訊號</h2>
<p>手動環境到了某些訊號出現時，繼續手動的邊際成本會超過導入 IaC 的一次性成本，這就是該往模組一跨進去的時機。訊號是規模與協作的函數，不是時間的函數。</p>
<p>第一個訊號是環境數量變多：當你需要 dev、staging、production 三套幾乎一樣的環境，手動複製會在環境之間留下難以察覺的差異，而 IaC 的價值正是用同一份程式碼複製環境。第二個是多人同時動資源：一個人手動操作還能靠記憶維護，兩三個人並行時，沒有 plan / review 的手動變更會互相覆蓋、互相破壞。第三個是環境爆炸頻率上升：如果「改一個設定結果弄壞別的東西」這類事故開始每月發生，代表手動環境的隱性依賴已經超過人腦能追蹤的上限。</p>
<p>任一訊號穩定出現，就是把第一個資源納入 IaC 的起點 — 前面做的命名、tagging、資源盤點此時直接成為 import 的輸入，第一步怎麼跨進去在「模組一：最小可行 IaC」。在訊號出現前過早導入 IaC 也有代價：單人、單環境、低變更頻率時，IaC 的學習與維護成本可能高於它省下的手動工，所以這裡的判準是等訊號、不是趕進度。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/infra/before-infra/manual-environment-baseline/" data-link-title="手動環境的可控底線與納管準備" data-link-desc="還沒有 IaC 的環境怎麼守住底線、讓變更可追溯、降低未來納管成本，以及辨識何時該開始導入 IaC">手動環境的可控底線與納管準備</a></td>
          <td>還沒有 IaC 的環境怎麼守住底線、讓變更可追溯、降低未來納管成本，以及辨識何時該開始導入 IaC</td>
      </tr>
  </tbody>
</table>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">模組零：infra 是什麼</a>：成熟度階梯上「全手動」這一階的定位</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</li>
<li>→ <a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>：長期憑證護欄的系統性設計</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>：手動階段網路大改的回退考量、之後的系統性設計</li>
<li>→ <a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>：tagging 在成本歸因與批次操作的後續價值</li>
<li>→ <a href="/blog/infra/09-driving-adoption/" data-link-title="模組九：怎麼把 infra 推動起來" data-link-desc="技術正確不等於推得動 — 信任赤字、期望值對齊、知識共享，infra 落地的組織課題">模組九：怎麼把 infra 推動起來</a>：資源不足時怎麼跟上層談優先級</li>
<li>→ <a href="/blog/infra/takeover/" data-link-title="接手維運：別人建的環境怎麼接管" data-link-desc="接手前人的專案時，怎麼在不搞壞東西的前提下盤點現況、建立維運能力、逐步正規化 — 從無 SSH 的 FTP 環境到有半套 IaC 的雲端環境都適用">接手維運：別人建的環境怎麼接管</a>：接手前人的專案時的盤點與接管流程</li>
</ul>
]]></content:encoded></item><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>Infrastructure as Code (IaC)</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/iac/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/iac/</guid><description>&lt;p>Infrastructure as Code（IaC）的核心概念是用版本控制的程式碼描述基礎設施應該長什麼樣，再由工具負責比對「程式碼描述的目標狀態」與「雲端上的實際狀態」，算出差異並收斂。這個機制把基礎設施從「某個人在 Console 手動點出來的東西」變成「可版本控制、可 review、可重建的描述」。&lt;/p>
&lt;p>IaC 工具分兩條路線：宣告式 DSL（Terraform / OpenTofu，用 HCL 描述資源）與程式語言（AWS CDK / Pulumi，用 TypeScript / Python / Go 生成資源）。兩者都能達成「用程式碼描述、由工具收斂」的目標，差別在閱讀門檻與抽象能力。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>IaC 是 infra 系列的根概念，貫穿所有模組。&lt;a href="https://tarrragon.github.io/blog/infra/00-infra-mindset/infra-responsibility-maturity/" data-link-title="infra 的責任邊界、成熟度階梯與 day 1 鐵律" data-link-desc="基礎設施承擔五個面向的責任，每一面都有獨立的失效模式；成熟度階梯用來對齊現況而非追求滿分，day 1 鐵律則劃出早期團隊該優先鋪的地基">成熟度階梯&lt;/a>的第二階（宣告式 IaC）是 IaC 正式生效的起點，第三階（環境分離）和第四階（PR 流程治理）都建立在 IaC 之上。沒有 IaC，後續所有模組的能力都無法落地。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>需要 IaC 的訊號是規模與協作的函數：環境數量超過一套、多人同時改資源、環境事故頻率上升、外部稽核要求變更紀錄。詳見&lt;a href="https://tarrragon.github.io/blog/infra/before-infra/manual-environment-baseline/" data-link-title="手動環境的可控底線與納管準備" data-link-desc="還沒有 IaC 的環境怎麼守住底線、讓變更可追溯、降低未來納管成本，以及辨識何時該開始導入 IaC">模組負一：該開始導入 IaC 的訊號&lt;/a>。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>採用 IaC 時要決定的核心問題：&lt;/p>
&lt;ul>
&lt;li>工具選型：宣告式 DSL vs 程式語言，取捨在審查透明度 vs 抽象複用能力&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">State&lt;/a> 的存放：remote backend 的選擇與保護&lt;/li>
&lt;li>Console 唯讀紀律：所有寫入操作回到程式碼，Console 只作觀察&lt;/li>
&lt;li>納管範圍：哪些資源先進 IaC、哪些暫時留在手動&lt;/li>
&lt;/ul>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">State&lt;/a> — 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;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/environment-separation/" data-link-title="環境分離" data-link-desc="把同一套基礎設施定義複製成多份隔離的執行實例，各有獨立 state 與故障半徑">環境分離&lt;/a> — 同一份 IaC 描述套用到多環境&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Infrastructure as Code（IaC）的核心概念是用版本控制的程式碼描述基礎設施應該長什麼樣，再由工具負責比對「程式碼描述的目標狀態」與「雲端上的實際狀態」，算出差異並收斂。這個機制把基礎設施從「某個人在 Console 手動點出來的東西」變成「可版本控制、可 review、可重建的描述」。</p>
<p>IaC 工具分兩條路線：宣告式 DSL（Terraform / OpenTofu，用 HCL 描述資源）與程式語言（AWS CDK / Pulumi，用 TypeScript / Python / Go 生成資源）。兩者都能達成「用程式碼描述、由工具收斂」的目標，差別在閱讀門檻與抽象能力。</p>
<h2 id="概念位置">概念位置</h2>
<p>IaC 是 infra 系列的根概念，貫穿所有模組。<a href="/blog/infra/00-infra-mindset/infra-responsibility-maturity/" data-link-title="infra 的責任邊界、成熟度階梯與 day 1 鐵律" data-link-desc="基礎設施承擔五個面向的責任，每一面都有獨立的失效模式；成熟度階梯用來對齊現況而非追求滿分，day 1 鐵律則劃出早期團隊該優先鋪的地基">成熟度階梯</a>的第二階（宣告式 IaC）是 IaC 正式生效的起點，第三階（環境分離）和第四階（PR 流程治理）都建立在 IaC 之上。沒有 IaC，後續所有模組的能力都無法落地。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>需要 IaC 的訊號是規模與協作的函數：環境數量超過一套、多人同時改資源、環境事故頻率上升、外部稽核要求變更紀錄。詳見<a href="/blog/infra/before-infra/manual-environment-baseline/" data-link-title="手動環境的可控底線與納管準備" data-link-desc="還沒有 IaC 的環境怎麼守住底線、讓變更可追溯、降低未來納管成本，以及辨識何時該開始導入 IaC">模組負一：該開始導入 IaC 的訊號</a>。</p>
<h2 id="設計責任">設計責任</h2>
<p>採用 IaC 時要決定的核心問題：</p>
<ul>
<li>工具選型：宣告式 DSL vs 程式語言，取捨在審查透明度 vs 抽象複用能力</li>
<li><a href="/blog/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">State</a> 的存放：remote backend 的選擇與保護</li>
<li>Console 唯讀紀律：所有寫入操作回到程式碼，Console 只作觀察</li>
<li>納管範圍：哪些資源先進 IaC、哪些暫時留在手動</li>
</ul>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">State</a> — 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>
<li><a href="/blog/infra/knowledge-cards/environment-separation/" data-link-title="環境分離" data-link-desc="把同一套基礎設施定義複製成多份隔離的執行實例，各有獨立 state 與故障半徑">環境分離</a> — 同一份 IaC 描述套用到多環境</li>
</ul>
]]></content:encoded></item><item><title>手動環境的可控底線與納管準備</title><link>https://tarrragon.github.io/blog/infra/before-infra/manual-environment-baseline/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/before-infra/manual-environment-baseline/</guid><description>&lt;p>手動起家是絕大多數服務的常態起點。從一個人在 Console 點出第一台 EC2 驗證想法，到小團隊接手開始長出更多資源，環境會經歷一段「全部靠手動、沒有任何程式碼描述」的階段。這個階段在&lt;a href="https://tarrragon.github.io/blog/infra/00-infra-mindset/#%e6%88%90%e7%86%9f%e5%ba%a6%e9%9a%8e%e6%a2%af" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">成熟度階梯&lt;/a>（從全手動到全程式碼治理的五階分級）上屬於第零階，它的責任是把自己管理成「可控的手動」，而不是假裝已經納管。可控意味著三件事：高風險操作有護欄、關鍵變更有痕跡、現實長什麼樣有紀錄。做好這三件事，當下出事的成本降低，未來把資源 import 進 IaC 的成本也跟著降低。&lt;/p>
&lt;h2 id="判讀自己是否可控">判讀自己是否可控&lt;/h2>
&lt;p>可控的手動環境能在五分鐘內回答以下問題：&lt;/p>
&lt;ol>
&lt;li>production 有哪些對外開放的 port？&lt;/li>
&lt;li>上週誰動過資料庫參數，動了什麼？&lt;/li>
&lt;li>刪掉某台機器會不會連帶弄壞別的東西？&lt;/li>
&lt;li>現在用了幾把長期 access key，每把用在哪裡？&lt;/li>
&lt;li>有沒有一份清單能對照 Console 上的資源，確認沒有漏掉的？&lt;/li>
&lt;/ol>
&lt;p>五題都能答的團隊不多，目標也不是一次全通。辨識出哪些區域不可見，按傷害代價從高到低逐一收斂，就是這一章的路線。&lt;/p>
&lt;h2 id="護欄先上在回退代價最高的操作">護欄先上在回退代價最高的操作&lt;/h2>
&lt;p>手動環境沒有 IaC 的 &lt;code>plan&lt;/code> / &lt;code>diff&lt;/code> 當預檢，人為操作直接生效。護欄的優先級看的是失誤的回退代價，不是操作頻率。回退代價最高的三類操作各自有最低成本的防線。&lt;/p>
&lt;h3 id="長期憑證外洩">長期憑證外洩&lt;/h3>
&lt;p>長期 access key 一旦外流，攻擊者拿到的是不會過期的權限。回退代價高的原因不只是撤銷這把 key 本身，而是要找出所有使用它的地方同步更換 — 而「所有使用它的地方」在手動環境裡幾乎沒有完整清單。一把用了半年的 key 可能已經被複製到 CI 環境變數、某個同事的測試腳本、一個早已被遺忘但還在跑的 cron job 裡。&lt;/p>
&lt;p>最低成本的護欄分三步。第一步是盤點：列出帳號裡所有長期 access key，記下建立時間、上次使用時間與對應用途。&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 iam generate-credential-report
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">aws iam get-credential-report --output text --query Content &lt;span class="p">|&lt;/span> base64 -d&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>第二步是替換路徑。對人類操作者，改用會過期的登入工作階段（如 AWS IAM Identity Center 的臨時憑證，幾小時後自動失效）。對跑在雲上的自動化（EC2 上的腳本、ECS task），改用平台原生的角色綁定 — instance profile 或 task role 會自動輪替短期憑證，程式碼不需要存任何 key。對跑在雲外的 CI/CD（如 GitHub Actions），改用 OIDC 聯合（見&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>）。&lt;/p>
&lt;p>第三步是輪替紀律。把還在用的長期 key 設定定期輪替提醒（60 天或 90 天，對齊 AWS IAM credential report 的建議週期），每次輪替時問自己：這把 key 能不能這次就換成臨時憑證，讓它成為最後一次輪替？&lt;/p>
&lt;h3 id="刪除-production-資源">刪除 production 資源&lt;/h3>
&lt;p>在 Console 選中一個 security group 按刪除，平台可能只問「確定嗎？」就直接執行，不會告訴你有三個 EC2 instance 正在引用這個 group。EBS volume 被刪除後，上面的資料就不存在了 — 除非之前有做 snapshot，而手動環境裡有沒有做 snapshot 通常取決於某個人的記憶。&lt;/p>
&lt;p>對承載狀態的資源，最低成本的護欄是開啟平台的刪除保護：&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 rds modify-db-instance &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> --db-instance-identifier payments-prod &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> --deletion-protection &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> --apply-immediately
&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">aws ec2 modify-instance-attribute &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --instance-id i-0abc123 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --disable-api-termination&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>RDS 有 &lt;code>deletion_protection&lt;/code>，EC2 有 &lt;code>termination_protection&lt;/code>，S3 bucket 可以開 MFA delete。這些機制把「一鍵刪除」變成「先關保護再刪除」兩步操作，擋不住蓄意刪除，但能擋住手滑跟批次操作的誤傷。&lt;/p>
&lt;p>刪除保護之外，備份是另一道防線。手動環境裡至少確認 RDS 的自動備份是開著的（預設保留 7 天），以及 S3 bucket 的 versioning 是開著的。S3 bucket 的 versioning 預設是關的，一個沒開 versioning 的 bucket，覆寫或刪除物件後就回不去了。&lt;/p>
&lt;h3 id="網路規則的大改">網路規則的大改&lt;/h3>
&lt;p>手動調整 VPC 路由、subnet 關聯的 route table、或 security group 的入站規則，影響範圍跨越多個服務，而且在手動環境裡沒有版本控制可以 diff 改了什麼。一條路由改錯，某些 private subnet 的服務可能瞬間失去出站能力。&lt;/p>
&lt;p>最低成本的護欄是「改之前先把現況存下來」：&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 ec2 describe-security-groups &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> --group-ids 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> --output json &amp;gt; sg-backup-&lt;span class="k">$(&lt;/span>date +%Y%m%d&lt;span class="k">)&lt;/span>.json&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>用 CLI 把當前的 security group 規則、route table 設定匯出一份 JSON。改完後如果出問題，這份 JSON 就是回退的依據。這不是自動回退 — 手動環境沒有那個能力 — 但至少讓「改回去」有個明確的目標狀態。網路地基的系統性設計在&lt;a href="https://tarrragon.github.io/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基&lt;/a>展開。&lt;/p></description><content:encoded><![CDATA[<p>手動起家是絕大多數服務的常態起點。從一個人在 Console 點出第一台 EC2 驗證想法，到小團隊接手開始長出更多資源，環境會經歷一段「全部靠手動、沒有任何程式碼描述」的階段。這個階段在<a href="/blog/infra/00-infra-mindset/#%e6%88%90%e7%86%9f%e5%ba%a6%e9%9a%8e%e6%a2%af" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">成熟度階梯</a>（從全手動到全程式碼治理的五階分級）上屬於第零階，它的責任是把自己管理成「可控的手動」，而不是假裝已經納管。可控意味著三件事：高風險操作有護欄、關鍵變更有痕跡、現實長什麼樣有紀錄。做好這三件事，當下出事的成本降低，未來把資源 import 進 IaC 的成本也跟著降低。</p>
<h2 id="判讀自己是否可控">判讀自己是否可控</h2>
<p>可控的手動環境能在五分鐘內回答以下問題：</p>
<ol>
<li>production 有哪些對外開放的 port？</li>
<li>上週誰動過資料庫參數，動了什麼？</li>
<li>刪掉某台機器會不會連帶弄壞別的東西？</li>
<li>現在用了幾把長期 access key，每把用在哪裡？</li>
<li>有沒有一份清單能對照 Console 上的資源，確認沒有漏掉的？</li>
</ol>
<p>五題都能答的團隊不多，目標也不是一次全通。辨識出哪些區域不可見，按傷害代價從高到低逐一收斂，就是這一章的路線。</p>
<h2 id="護欄先上在回退代價最高的操作">護欄先上在回退代價最高的操作</h2>
<p>手動環境沒有 IaC 的 <code>plan</code> / <code>diff</code> 當預檢，人為操作直接生效。護欄的優先級看的是失誤的回退代價，不是操作頻率。回退代價最高的三類操作各自有最低成本的防線。</p>
<h3 id="長期憑證外洩">長期憑證外洩</h3>
<p>長期 access key 一旦外流，攻擊者拿到的是不會過期的權限。回退代價高的原因不只是撤銷這把 key 本身，而是要找出所有使用它的地方同步更換 — 而「所有使用它的地方」在手動環境裡幾乎沒有完整清單。一把用了半年的 key 可能已經被複製到 CI 環境變數、某個同事的測試腳本、一個早已被遺忘但還在跑的 cron job 裡。</p>
<p>最低成本的護欄分三步。第一步是盤點：列出帳號裡所有長期 access key，記下建立時間、上次使用時間與對應用途。</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 iam generate-credential-report
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws iam get-credential-report --output text --query Content <span class="p">|</span> base64 -d</span></span></code></pre></div><p>第二步是替換路徑。對人類操作者，改用會過期的登入工作階段（如 AWS IAM Identity Center 的臨時憑證，幾小時後自動失效）。對跑在雲上的自動化（EC2 上的腳本、ECS task），改用平台原生的角色綁定 — instance profile 或 task role 會自動輪替短期憑證，程式碼不需要存任何 key。對跑在雲外的 CI/CD（如 GitHub Actions），改用 OIDC 聯合（見<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>）。</p>
<p>第三步是輪替紀律。把還在用的長期 key 設定定期輪替提醒（60 天或 90 天，對齊 AWS IAM credential report 的建議週期），每次輪替時問自己：這把 key 能不能這次就換成臨時憑證，讓它成為最後一次輪替？</p>
<h3 id="刪除-production-資源">刪除 production 資源</h3>
<p>在 Console 選中一個 security group 按刪除，平台可能只問「確定嗎？」就直接執行，不會告訴你有三個 EC2 instance 正在引用這個 group。EBS volume 被刪除後，上面的資料就不存在了 — 除非之前有做 snapshot，而手動環境裡有沒有做 snapshot 通常取決於某個人的記憶。</p>
<p>對承載狀態的資源，最低成本的護欄是開啟平台的刪除保護：</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 rds modify-db-instance <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --db-instance-identifier payments-prod <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --deletion-protection <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --apply-immediately
</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">aws ec2 modify-instance-attribute <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --instance-id i-0abc123 <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  --disable-api-termination</span></span></code></pre></div><p>RDS 有 <code>deletion_protection</code>，EC2 有 <code>termination_protection</code>，S3 bucket 可以開 MFA delete。這些機制把「一鍵刪除」變成「先關保護再刪除」兩步操作，擋不住蓄意刪除，但能擋住手滑跟批次操作的誤傷。</p>
<p>刪除保護之外，備份是另一道防線。手動環境裡至少確認 RDS 的自動備份是開著的（預設保留 7 天），以及 S3 bucket 的 versioning 是開著的。S3 bucket 的 versioning 預設是關的，一個沒開 versioning 的 bucket，覆寫或刪除物件後就回不去了。</p>
<h3 id="網路規則的大改">網路規則的大改</h3>
<p>手動調整 VPC 路由、subnet 關聯的 route table、或 security group 的入站規則，影響範圍跨越多個服務，而且在手動環境裡沒有版本控制可以 diff 改了什麼。一條路由改錯，某些 private subnet 的服務可能瞬間失去出站能力。</p>
<p>最低成本的護欄是「改之前先把現況存下來」：</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 ec2 describe-security-groups <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --group-ids sg-0abc123 <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --output json &gt; sg-backup-<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>.json</span></span></code></pre></div><p>用 CLI 把當前的 security group 規則、route table 設定匯出一份 JSON。改完後如果出問題，這份 JSON 就是回退的依據。這不是自動回退 — 手動環境沒有那個能力 — 但至少讓「改回去」有個明確的目標狀態。網路地基的系統性設計在<a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>展開。</p>
<h3 id="該先做什麼">該先做什麼</h3>
<p>這三類護欄的共同判準是：護欄成本低（幾條 CLI 指令或 Console 設定）、失誤代價高（憑證外洩、資料遺失、服務中斷）。判讀某個資源該不該現在就加護欄，問自己一個問題：「這個資源出事的回退時間是分鐘級、小時級、還是不可回退？」不可回退的（資料刪除、key 外洩）優先加；分鐘級可回退的（重啟一個 stateless service）可以排後面。</p>
<h2 id="讓變更留下痕跡">讓變更留下痕跡</h2>
<p>變更留痕的責任是讓「誰、在什麼時候、改了什麼、為什麼」事後可追溯。IaC 的 git history 天然提供這件事，手動環境得靠人為紀律補上。</p>
<h3 id="人工變更日誌">人工變更日誌</h3>
<p>最低限度是一份變更日誌，可以只是 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">## 2026-06-20
</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"><span class="k">-</span> **操作者**：alice
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">-</span> **資源**：sg-0abc123 (payments-api-prod)
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">-</span> **變更**：新增 ingress rule, port 8080 from 10.0.0.0/16
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">-</span> **原因**：內部監控服務需要存取 health check endpoint
</span></span><span class="line"><span class="ln">7</span><span class="cl">- <span class="gs">**回退方式**</span>：刪除該 ingress rule</span></span></code></pre></div><p>格式不需要精美，需要的是「每次都寫」。常見陷阱是只在「大改動」時才記錄，結果真正出事的往往是某次以為無關緊要的小調整 — 改了一個 parameter group 的值、調了一條路由的目標、把某個 instance 的 security group 換了一個。判準簡化成一句：只要這個操作別人事後可能需要知道，就記。</p>
<h3 id="平台稽核日誌">平台稽核日誌</h3>
<p>和人工日誌互補的是平台的稽核日誌（如 AWS CloudTrail、GCP Audit Log）。稽核日誌自動記錄 API 層級「發生了什麼」— 某個 IAM user 在某個時間對某個資源呼叫了哪個 API — 不依賴人為紀律、也不會漏。但它只記錄事實，不記錄意圖。它告訴你 security group 在幾點被改，卻不告訴你改的原因。人寫的變更日誌補上的正是「為什麼」這一段。</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 describe-trails <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;trailList[].{Name:Name,S3Bucket:S3BucketName}&#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">aws cloudtrail lookup-events <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --lookup-attributes <span class="nv">AttributeKey</span><span class="o">=</span>EventName,AttributeValue<span class="o">=</span>AuthorizeSecurityGroupIngress <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --max-items <span class="m">10</span></span></span></code></pre></div><p>CloudTrail 在 AWS 帳號裡預設開啟 management event 的 90 天查閱。手動環境裡至少確認 management event 的 trail 存在且在寫入 — 這是事後回推「到底誰動了什麼」的最後防線。兩者一起，事故排查時才能從「哪裡變了」一路追到「為什麼改、能不能安全回退」。</p>
<h2 id="命名與-tagging-從手動階段就開始">命名與 tagging 從手動階段就開始</h2>
<p>命名規範與資源標籤讓每個資源自帶「我是誰、屬於哪個服務、誰負責、哪個環境」的身分資訊。手動點出來的資源若名稱是 <code>test-2</code>、<code>new-db-final</code>、<code>temp-sg</code>，日後納管時得靠人逐一辨認哪個還在用、屬於哪條業務線，考古成本遠高於當初多打幾個字。</p>
<h3 id="命名規範">命名規範</h3>
<p>從手動階段就固定一套命名規則，讓名稱本身攜帶足夠的上下文。一個實用的格式是 <code>{service}-{component}-{env}</code>：</p>
<table>
  <thead>
      <tr>
          <th>資源類型</th>
          <th>命名範例</th>
          <th>攜帶的資訊</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>EC2 instance</td>
          <td><code>payments-api-prod</code></td>
          <td>服務 + 角色 + 環境</td>
      </tr>
      <tr>
          <td>Security group</td>
          <td><code>payments-api-prod-sg</code></td>
          <td>同上 + 資源類型</td>
      </tr>
      <tr>
          <td>RDS instance</td>
          <td><code>payments-db-prod</code></td>
          <td>服務 + 資源類型 + 環境</td>
      </tr>
      <tr>
          <td>S3 bucket</td>
          <td><code>acme-payments-assets-dev</code></td>
          <td>組織 + 服務 + 用途 + 環境</td>
      </tr>
  </tbody>
</table>
<p>命名不需要完美或涵蓋所有維度，需要的是一致。同類資源都用同一套格式，人眼掃一頁 Console 就能分辨「這個屬於 payments 的 prod」跟「這個屬於 auth 的 dev」。不一致的命名（有些用底線、有些用連字號、有些帶 env 有些不帶）會在日後盤點時讓每個資源都變成需要考古的謎題。</p>
<h3 id="最小-tag-集合">最小 tag 集合</h3>
<p>標籤至少包含三個維度：</p>
<table>
  <thead>
      <tr>
          <th>Tag</th>
          <th>問的問題</th>
          <th>典型值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>service</code></td>
          <td>這屬於誰</td>
          <td><code>payments-api</code> / <code>auth</code></td>
      </tr>
      <tr>
          <td><code>env</code></td>
          <td>哪個環境</td>
          <td><code>prod</code> / <code>staging</code> / <code>dev</code></td>
      </tr>
      <tr>
          <td><code>owner</code></td>
          <td>出事找誰</td>
          <td><code>team-payments</code> / <code>platform</code></td>
      </tr>
  </tbody>
</table>
<p>手動階段的 tag 靠人工填。在 Console 建資源時順手加 tag 幾乎零成本 — 多打三行字而已。但如果沒有約定「哪些 tag 是必填」，多數人會跳過。最低限度的紀律是：在團隊文件裡寫下「建任何資源前先填這三個 tag」，並在每次盤點時檢查有沒有漏標的資源。</p>
<p>這套規則在導入 IaC 後直接升級成 Terraform 的 <code>default_tags</code> — 自動套用、不靠人記（見<a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>）。先在手動階段建立習慣，導入 IaC 時只是換一個強制機制，而不是從零學起一套分類法。</p>
<h2 id="盤點現有資源作為納管輸入">盤點現有資源作為納管輸入</h2>
<p>資源盤點把「現實長什麼樣」寫成一份清單，它是日後納管的直接輸入。接手別人建的環境時，盤點的範圍和方法更完整的版本見<a href="/blog/infra/takeover/" data-link-title="接手維運：別人建的環境怎麼接管" data-link-desc="接手前人的專案時，怎麼在不搞壞東西的前提下盤點現況、建立維運能力、逐步正規化 — 從無 SSH 的 FTP 環境到有半套 IaC 的雲端環境都適用">接手維運模組</a>。手動環境裡最難管理的是未標記的閒置資源 — 測試用的 EC2、實驗用的 RDS — 持續計費但沒有標籤，無法用查詢系統性找出，也無法確認是否仍有服務依賴。</p>
<h3 id="盤點方法">盤點方法</h3>
<p>按資源類型分批拉，每批存一份 JSON 或 CSV 進 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">aws ec2 describe-instances <span class="se">\
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;Reservations[].Instances[].[InstanceId,InstanceType,State.Name,Tags[?Key==`Name`].Value|[0],Tags[?Key==`env`].Value|[0]]&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --output table
</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">aws rds describe-db-instances <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;DBInstances[].[DBInstanceIdentifier,Engine,DBInstanceClass,MultiAZ,DeletionProtection]&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --output table
</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">aws ec2 describe-security-groups <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;SecurityGroups[].[GroupId,GroupName,IpPermissions]&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --output json &gt; security-groups-<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>.json
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">aws s3api list-buckets --query <span class="s1">&#39;Buckets[].Name&#39;</span></span></span></code></pre></div><h3 id="盤點後的三件事">盤點後的三件事</h3>
<p>這份清單同時服務三個目的。</p>
<p><strong>當下的安全盤查</strong>：security group 清單裡有沒有不該開的對外 port？有沒有 EC2 直接掛著公網 IP 卻不是 load balancer？用 <code>0.0.0.0/0</code> 搜一遍 security group 的輸出，命中的每一條都要能說出「這個全開是故意的、理由是什麼」。</p>
<p><strong>未來 IaC import 的範圍界定</strong>：哪些資源該先 import。判準是「改動頻率」與「改錯代價」的乘積 — 頻繁改動且改錯代價高的（security group、IAM role）先排進來，很少動的（一個已經穩定的 S3 bucket）可以排後面。</p>
<p><strong>成熟度評估的事實基礎</strong>：成熟度階梯的定位（見<a href="/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">模組零：infra 是什麼</a>）需要知道「全手動到底有多少資源、分布在幾個帳號、跨幾個 region」，這份清單就是評估的輸入。</p>
<h3 id="盤點的節奏">盤點的節奏</h3>
<p>第一次盤點最花時間，因為很多資源的用途需要考古。之後每月或每季重跑一次比對差異 — 重點是看「上次到這次之間長出了什麼新資源」。如果每次比對都發現大量未標記的新資源，這本身就是一個訊號：手動操作的可見性不足，該考慮導入 IaC 了。</p>
<h2 id="資源與信任不足下的高槓桿取捨">資源與信任不足下的高槓桿取捨</h2>
<p>當時間、人力或上層信任都不足，無法一次把上面每件事做齊時，取捨原則是先做「失誤代價高且護欄成本低」的少數幾件：</p>
<table>
  <thead>
      <tr>
          <th>護欄</th>
          <th>實施成本</th>
          <th>失誤代價</th>
          <th>優先級</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>長期 key 盤點</td>
          <td>低</td>
          <td>極高</td>
          <td>立刻做</td>
      </tr>
      <tr>
          <td>刪除保護</td>
          <td>低</td>
          <td>極高</td>
          <td>立刻做</td>
      </tr>
      <tr>
          <td>變更日誌</td>
          <td>低</td>
          <td>中</td>
          <td>第二順位</td>
      </tr>
      <tr>
          <td>命名規範</td>
          <td>近零</td>
          <td>累積</td>
          <td>新資源立刻套用</td>
      </tr>
      <tr>
          <td>資源盤點</td>
          <td>中</td>
          <td>累積</td>
          <td>有空就做</td>
      </tr>
      <tr>
          <td>存量重命名</td>
          <td>高</td>
          <td>累積</td>
          <td>等有餘力</td>
      </tr>
  </tbody>
</table>
<p>長期憑證盤點與刪除保護兩者加起來的實施時間可能不到一小時。命名與 tagging 的策略是「新的一律照規範、舊的等有餘力再補」，而不是停下來先整理全部存量。資源不足時怎麼跟上層談這些工作的優先級，在<a href="/blog/infra/09-driving-adoption/" data-link-title="模組九：怎麼把 infra 推動起來" data-link-desc="技術正確不等於推得動 — 信任赤字、期望值對齊、知識共享，infra 落地的組織課題">模組九：怎麼把 infra 推動起來</a>展開。</p>
<h2 id="該開始導入-iac-的訊號">該開始導入 IaC 的訊號</h2>
<p>手動環境到了某些訊號出現時，繼續手動的邊際成本會超過導入 IaC 的一次性成本。訊號是規模與協作的函數，不是時間的函數 — 一個人運維一個簡單服務，手動可能撐很久；三個人同時動一個稍微複雜的環境，幾週內就會踩到手動的極限。</p>
<p><strong>環境數量變多</strong>：當需要 dev、staging、production 三套幾乎一樣的環境，手動複製會在環境之間留下難以察覺的差異。某個人在 staging 加了一條 security group 規則，忘了在 prod 也加，結果 staging 測通了、prod 部署後服務連不上。IaC 用同一份程式碼複製環境，環境差異只存在於參數值。</p>
<p><strong>多人同時動資源</strong>：一個人手動操作還能靠記憶維護，兩三個人並行時，沒有 plan / review 的手動變更會互相覆蓋。A 改了一個設定解了自己的問題，B 幾天後改了另一個設定把 A 的修正覆蓋掉，事故原因得靠翻 CloudTrail 才查得到。</p>
<p><strong>環境爆炸頻率上升</strong>：如果「改一個設定結果弄壞別的東西」這類事故開始每月發生，代表手動環境的隱性依賴已經超過人腦能追蹤的上限。一個典型的隱性依賴：security group A 被 instance X 和 instance Y 同時引用，改 A 時只想著 X 的需求、忘了 Y 也依賴它，改完 Y 就斷了。</p>
<p><strong>合規或稽核要求</strong>：外部稽核（SOC 2、ISO 27001）開始要求「列出所有對外暴露的服務」「提供存取權限的變更紀錄」「證明 production 環境的變更有經過審查」。手動環境回答這些問題時，每次都是一場考古工程。IaC 加上 PR 流程後，答案就在 repo 裡。</p>
<p>任一訊號穩定出現，就是把第一個資源納入 IaC 的起點 — 前面做的命名、tagging、資源盤點此時直接成為 import 的輸入。第一步怎麼跨進去在<a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一：最小可行 IaC</a>。</p>
<p>在訊號出現前過早導入 IaC 也有代價：單人、單環境、低變更頻率時，IaC 的學習與維護成本可能高於它省下的手動工 — 寫一份 HCL、配一個 state backend、設一條 pipeline 的固定成本，在只有三個資源的環境裡不一定划得來。這裡的判準是等訊號、不是趕進度。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/takeover/" data-link-title="接手維運：別人建的環境怎麼接管" data-link-desc="接手前人的專案時，怎麼在不搞壞東西的前提下盤點現況、建立維運能力、逐步正規化 — 從無 SSH 的 FTP 環境到有半套 IaC 的雲端環境都適用">接手維運</a>：如果這個手動環境是接手來的，先走接手維運的盤點流程</li>
<li>→ <a href="/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">模組零：infra 是什麼</a>：成熟度階梯上「全手動」這一階的定位</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</li>
<li>→ <a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>：長期憑證護欄的系統性設計</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>：手動階段網路大改的回退考量、之後的系統性設計</li>
<li>→ <a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>：tagging 在成本歸因與批次操作的後續價值</li>
<li>→ <a href="/blog/infra/09-driving-adoption/" data-link-title="模組九：怎麼把 infra 推動起來" data-link-desc="技術正確不等於推得動 — 信任赤字、期望值對齊、知識共享，infra 落地的組織課題">模組九：怎麼把 infra 推動起來</a>：資源不足時怎麼跟上層談優先級</li>
</ul>
]]></content:encoded></item><item><title>從單一環境到環境分離：infra 需求的浮現過程</title><link>https://tarrragon.github.io/blog/infra/00-infra-mindset/one-machine-to-environments/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/00-infra-mindset/one-machine-to-environments/</guid><description>&lt;p>多數服務的起點是一台運算實例加一台資料庫，部署方式是 SSH 進去拉 code 再重啟。這個結構在單人、單環境、低變更頻率的條件下運作正常，但它的隱性前提是：所有設定只有一份，且只有一個人在操作。機器的配置存在操作者的記憶裡，資料庫參數存在 Console 頁面上，security group 規則是建立時隨手設的。這些設定沒有被記錄在任何能回溯或重建的地方。&lt;/p>
&lt;p>這個結構的操作極限會在兩個時間點浮現：第一次需要在正式環境以外的地方驗證變更時，以及第二個人開始操作同一組資源時。以下依序說明每個階段的操作現實與對應的 infra 需求。&lt;/p>
&lt;h2 id="資料庫變更需要驗證環境">資料庫變更需要驗證環境&lt;/h2>
&lt;p>應用新增功能時經常需要改資料庫的表結構 — 加欄位、改索引、拆表。這類操作（database migration）如果語法有誤或邏輯有缺，可能導致服務中斷或資料不一致。正常做法是先在非正式環境驗證通過，再推到 production 執行。&lt;/p>
&lt;p>單一環境的情況下沒有驗證的場所。三種應對方式各有不同的風險邊界：&lt;/p>
&lt;p>&lt;strong>直接在 production 執行&lt;/strong>。成本最低，風險最高。migration 腳本跑下去的那一刻，正在使用服務的使用者直接承受後果 — 一個鎖住整張大表的 &lt;code>ALTER TABLE&lt;/code> 會讓所有查詢卡住，一個 &lt;code>DROP COLUMN&lt;/code> 刪錯欄位會造成不可逆的資料遺失。服務規模小、使用者少時代價尚可承受；一旦服務開始承載營收或外部依賴，這個做法的風險代價就超過了它省下的時間。&lt;/p>
&lt;p>&lt;strong>手動複製一套環境&lt;/strong>。到 Console 上照 production 的設定重新建一台 EC2、開一台 RDS、配一組 security group，得到一套「看起來一樣」的 staging。migration 先在 staging 驗證再推 production。這解決了驗證場所的問題，但引入了漂移問題 — 下一節說明。&lt;/p>
&lt;p>&lt;strong>用程式碼描述環境，讓工具複製&lt;/strong>。把 production 的設定寫成描述檔，用 Terraform 或 OpenTofu 根據同一份描述建出 staging。初始成本比手動複製高（要學工具、寫描述檔），但它保證了手動複製保證不了的一件事：staging 和 production 的結構來自同一份描述，差異只存在於刻意不同的參數（機器規格、備份天數）。這就是 Infrastructure as Code（IaC）的起點。&lt;/p>
&lt;h2 id="手動複製的環境會漂移">手動複製的環境會漂移&lt;/h2>
&lt;p>手動複製的 staging 在建立當天跟 production 一致。一個月後通常不再一致。&lt;/p>
&lt;p>漂移的來源是日常操作中的局部調整：staging 的 security group 多了一條規則（某次除錯時加的，事後忘了刪）、production 的 RDS 參數被調過（線上出現慢查詢，DBA 改了 &lt;code>work_mem&lt;/code> 但沒同步 staging）、staging 的 IAM role 多了一條 policy（測試新功能時加的，測完沒拿掉）。每一筆差異都很小，小到不值得專門同步，但它們會累積。&lt;/p>
&lt;p>漂移引爆的時機跟產生的時機通常隔很遠。一個 migration 在 staging 通過、推到 production 失敗，排查半天後發現是一個月前的參數調整造成的 — staging 的 &lt;code>work_mem&lt;/code> 跟 production 不同，剛好影響了這次 migration 的執行計畫。這種因果關係跨越時間的錯誤，排查成本遠高於錯誤本身。&lt;/p>
&lt;p>漂移的根源是「兩套環境各自獨立維護」。只要兩份設定各自存在，同步就完全依賴操作者的記憶與紀律，而記憶會衰退、紀律會在壓力下鬆懈。結構性的解法是讓兩套環境共用同一份設定，差異只存在於刻意控制的參數。&lt;/p>
&lt;h2 id="同一份描述不同的參數">同一份描述、不同的參數&lt;/h2>
&lt;p>IaC 工具消除漂移的方式，是把環境的結構寫成一份 module，用不同的參數值建出不同環境。程式碼只有一份，結構保證相同；差異全部收斂在參數裡，每一處「故意不同」都是明確且可審查的。&lt;/p>
&lt;p>一個描述資料庫的 module：&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">variable&lt;/span> &lt;span class="s2">&amp;#34;instance_class&amp;#34;&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"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">string&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="k">variable&lt;/span> &lt;span class="s2">&amp;#34;backup_retention_days&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"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">number&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"> default&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">7&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;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_db_instance&amp;#34; &amp;#34;main&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="n"> engine&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;postgres&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="n"> instance_class&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">instance_class&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="n"> backup_retention_period&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">backup_retention_days&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Production 傳入大機器和長備份，staging 傳入小機器和短備份：&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"># production
&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">module&lt;/span> &lt;span class="s2">&amp;#34;database&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"> source&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;./modules/database&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"> instance_class&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;db.r6g.large&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"> backup_retention_days&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">14&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>&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">&lt;span class="c1"># staging
&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">&lt;/span>&lt;span class="k">module&lt;/span> &lt;span class="s2">&amp;#34;database&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&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;./modules/database&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="n"> instance_class&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;db.t3.small&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="n"> backup_retention_days&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">3&lt;/span>
&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;p>兩個環境跑的是同一段 module 程式碼。引擎版本、連線方式、安全設定完全相同（寫在 module 裡、不是參數），差異只有機器規格和備份天數（刻意透過參數控制）。改動 module 一次、兩個環境同時生效，漂移的空間被結構性消除。&lt;/p>
&lt;p>IaC 工具會維護一份 state 記錄，追蹤每個環境裡實際建了哪些資源和它們的屬性。改了程式碼後跑 &lt;code>terraform plan&lt;/code>，工具會比對新的程式碼和 state 的差異，列出「會新增 / 修改 / 刪除什麼」。確認差異符合預期後才執行 &lt;code>apply&lt;/code>。state 的角色與安全存放方式在&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 只能看不能改」鐵律">模組一：最小可行 IaC&lt;/a> 展開，環境的目錄結構與 module 設計在&lt;a href="https://tarrragon.github.io/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化&lt;/a> 展開。&lt;/p></description><content:encoded><![CDATA[<p>多數服務的起點是一台運算實例加一台資料庫，部署方式是 SSH 進去拉 code 再重啟。這個結構在單人、單環境、低變更頻率的條件下運作正常，但它的隱性前提是：所有設定只有一份，且只有一個人在操作。機器的配置存在操作者的記憶裡，資料庫參數存在 Console 頁面上，security group 規則是建立時隨手設的。這些設定沒有被記錄在任何能回溯或重建的地方。</p>
<p>這個結構的操作極限會在兩個時間點浮現：第一次需要在正式環境以外的地方驗證變更時，以及第二個人開始操作同一組資源時。以下依序說明每個階段的操作現實與對應的 infra 需求。</p>
<h2 id="資料庫變更需要驗證環境">資料庫變更需要驗證環境</h2>
<p>應用新增功能時經常需要改資料庫的表結構 — 加欄位、改索引、拆表。這類操作（database migration）如果語法有誤或邏輯有缺，可能導致服務中斷或資料不一致。正常做法是先在非正式環境驗證通過，再推到 production 執行。</p>
<p>單一環境的情況下沒有驗證的場所。三種應對方式各有不同的風險邊界：</p>
<p><strong>直接在 production 執行</strong>。成本最低，風險最高。migration 腳本跑下去的那一刻，正在使用服務的使用者直接承受後果 — 一個鎖住整張大表的 <code>ALTER TABLE</code> 會讓所有查詢卡住，一個 <code>DROP COLUMN</code> 刪錯欄位會造成不可逆的資料遺失。服務規模小、使用者少時代價尚可承受；一旦服務開始承載營收或外部依賴，這個做法的風險代價就超過了它省下的時間。</p>
<p><strong>手動複製一套環境</strong>。到 Console 上照 production 的設定重新建一台 EC2、開一台 RDS、配一組 security group，得到一套「看起來一樣」的 staging。migration 先在 staging 驗證再推 production。這解決了驗證場所的問題，但引入了漂移問題 — 下一節說明。</p>
<p><strong>用程式碼描述環境，讓工具複製</strong>。把 production 的設定寫成描述檔，用 Terraform 或 OpenTofu 根據同一份描述建出 staging。初始成本比手動複製高（要學工具、寫描述檔），但它保證了手動複製保證不了的一件事：staging 和 production 的結構來自同一份描述，差異只存在於刻意不同的參數（機器規格、備份天數）。這就是 Infrastructure as Code（IaC）的起點。</p>
<h2 id="手動複製的環境會漂移">手動複製的環境會漂移</h2>
<p>手動複製的 staging 在建立當天跟 production 一致。一個月後通常不再一致。</p>
<p>漂移的來源是日常操作中的局部調整：staging 的 security group 多了一條規則（某次除錯時加的，事後忘了刪）、production 的 RDS 參數被調過（線上出現慢查詢，DBA 改了 <code>work_mem</code> 但沒同步 staging）、staging 的 IAM role 多了一條 policy（測試新功能時加的，測完沒拿掉）。每一筆差異都很小，小到不值得專門同步，但它們會累積。</p>
<p>漂移引爆的時機跟產生的時機通常隔很遠。一個 migration 在 staging 通過、推到 production 失敗，排查半天後發現是一個月前的參數調整造成的 — staging 的 <code>work_mem</code> 跟 production 不同，剛好影響了這次 migration 的執行計畫。這種因果關係跨越時間的錯誤，排查成本遠高於錯誤本身。</p>
<p>漂移的根源是「兩套環境各自獨立維護」。只要兩份設定各自存在，同步就完全依賴操作者的記憶與紀律，而記憶會衰退、紀律會在壓力下鬆懈。結構性的解法是讓兩套環境共用同一份設定，差異只存在於刻意控制的參數。</p>
<h2 id="同一份描述不同的參數">同一份描述、不同的參數</h2>
<p>IaC 工具消除漂移的方式，是把環境的結構寫成一份 module，用不同的參數值建出不同環境。程式碼只有一份，結構保證相同；差異全部收斂在參數裡，每一處「故意不同」都是明確且可審查的。</p>
<p>一個描述資料庫的 module：</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">variable</span> <span class="s2">&#34;instance_class&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  type</span> <span class="o">=</span> <span class="k">string</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">variable</span> <span class="s2">&#34;backup_retention_days&#34;</span> {
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  type</span>    <span class="o">=</span> <span class="k">number</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">  default</span> <span class="o">=</span> <span class="m">7</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="k">resource</span> <span class="s2">&#34;aws_db_instance&#34; &#34;main&#34;</span> {
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">  engine</span>                  <span class="o">=</span> <span class="s2">&#34;postgres&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">  instance_class</span>          <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">instance_class</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">  backup_retention_period</span> <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">backup_retention_days</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">}</span></span></code></pre></div><p>Production 傳入大機器和長備份，staging 傳入小機器和短備份：</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"># production
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">module</span> <span class="s2">&#34;database&#34;</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  source</span>                <span class="o">=</span> <span class="s2">&#34;./modules/database&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  instance_class</span>        <span class="o">=</span> <span class="s2">&#34;db.r6g.large&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">  backup_retention_days</span> <span class="o">=</span> <span class="m">14</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">}<span class="c1">
</span></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"><span class="c1"># staging
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="k">module</span> <span class="s2">&#34;database&#34;</span> {
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">  source</span>                <span class="o">=</span> <span class="s2">&#34;./modules/database&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">  instance_class</span>        <span class="o">=</span> <span class="s2">&#34;db.t3.small&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">  backup_retention_days</span> <span class="o">=</span> <span class="m">3</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">}</span></span></code></pre></div><p>兩個環境跑的是同一段 module 程式碼。引擎版本、連線方式、安全設定完全相同（寫在 module 裡、不是參數），差異只有機器規格和備份天數（刻意透過參數控制）。改動 module 一次、兩個環境同時生效，漂移的空間被結構性消除。</p>
<p>IaC 工具會維護一份 state 記錄，追蹤每個環境裡實際建了哪些資源和它們的屬性。改了程式碼後跑 <code>terraform plan</code>，工具會比對新的程式碼和 state 的差異，列出「會新增 / 修改 / 刪除什麼」。確認差異符合預期後才執行 <code>apply</code>。state 的角色與安全存放方式在<a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一：最小可行 IaC</a> 展開，環境的目錄結構與 module 設計在<a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a> 展開。</p>
<h2 id="環境分離牽出的後續關注點">環境分離牽出的後續關注點</h2>
<p>環境分離解決了「在哪裡驗證」和「為什麼 staging 跟 production 不同」的問題。但多環境運行後，一組後續的操作需求會依序浮現，每一個對應 infra 的一個能力層：</p>
<p><strong>身分與權限隔離</strong>。三個環境代表三組資源。如果所有人對所有環境都有完整操作權限，一次誤操作就可能改壞 production。production 的修改權限應該比 staging 嚴格、操作身分應該分開。這是<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>的範圍。</p>
<p><strong>變更審查流程</strong>。多人同時操作 infra 時，沒有經過 review 的變更會互相覆蓋。把 infra 變更接上跟應用程式碼相同的 PR 流程 — 開分支、自動跑 plan、review 通過才 apply — 讓每一次改動都有提案、審查和歷史。這是<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>的範圍。</p>
<p><strong>機密值管理</strong>。資料庫密碼、API key 這些機密值在有版本控制之前可能直接寫在 <code>.env</code> 或 CI 變數裡。一旦有了 IaC 和 git，這些值如果跟著程式碼進了版本歷史，就會隨著每一次 clone 擴散。機密值要存在專用的密鑰管理服務裡，程式碼只持有指向它的參照。這是<a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>的範圍。</p>
<p><strong>可觀測性</strong>。三個環境各自需要 log、metric 和告警，這些監控要跟環境本身一起建立，而非等服務中斷後才發現沒有可查的資料。這是<a href="/blog/infra/06-observability-logging/" data-link-title="模組六：可觀測性與 log 一併寫進 code" data-link-desc="log group、metric、alarm 跟基礎設施同生命週期管理，出事時追得到查得到">模組六：可觀測性與 log</a> 的範圍。</p>
<p><strong>網路邊界</strong>。三個環境如果共用同一個網段和防火牆規則，staging 的某個被入侵的服務可能橫向觸及 production 的資料庫。每個環境需要有自己的網路邊界。這是<a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>的範圍。</p>
<p>這些關注點的共同根源是同一件事：當服務從單人單環境長成多人多環境，原本藏在記憶和手動操作裡的決策，必須變成可描述、可審查、可重建的規則。整套教材的地圖在<a href="/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">模組零：infra 是什麼</a>，每個模組各自處理一個能力層。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">模組零：infra 是什麼</a>：責任邊界與成熟度階梯（從全手動到全程式碼治理的五階分級）的完整定義</li>
<li>→ <a href="/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一：還沒有 infra 的環境</a>：導入 IaC 之前的低成本護欄</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 與 IaC 工具的選型與起步</li>
<li>→ <a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>：目錄結構、module、參數化的完整設計</li>
</ul>
]]></content:encoded></item><item><title>部署順序與資料庫上 IaC</title><link>https://tarrragon.github.io/blog/infra/05-core-services/deployment-order-database/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/05-core-services/deployment-order-database/</guid><description>&lt;p>地基就緒後，依「地基 → 上層」的順序把實際承載業務的服務寫進 IaC。&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">身分（IAM）&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">網路（VPC / subnet）&lt;/a>與&lt;a href="https://tarrragon.github.io/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">環境分離&lt;/a>構成底層平面，這一層在它們之上描述資料庫、運算、儲存與入口 — 業務流量真正落地的地方。順序與依賴的表達方式決定了這層能不能被乾淨地重建、拆除與演進。共通原則是：描述服務的「身分與接線」，而非把每個執行期參數都塞進程式碼。&lt;/p>
&lt;p>本篇先確立依賴圖怎麼驅動部署順序，再展開核心服務裡最需要謹慎描述的一類 — 資料庫。資料庫持有無法重建的狀態，它的 IaC 描述比其他 stateless 資源多出保護策略、連線管理與讀寫分流三個維度。&lt;/p>
&lt;h2 id="核心服務的部署順序">核心服務的部署順序&lt;/h2>
&lt;p>核心服務的部署順序由依賴方向決定：被依賴的先建，依賴別人的後建。網路與身分是幾乎所有上層服務的共同前置 — 資料庫要放進私有 subnet、運算要套用 IAM role 才能讀 S3、load balancer 要掛在公開 subnet 並引用 security group。這些底層平面若還沒成形，上層資源會在 apply 時因為找不到 subnet ID 或 role ARN 而失敗，或更糟，建在預設 VPC 裡繞過了所有隔離設計。&lt;/p>
&lt;p>把順序交給 IaC 工具的依賴圖自動推導，比人工排序可靠。當運算資源的定義引用了 subnet 與 security group 的資源屬性，Terraform 會解析出「subnet 先於運算」的邊，apply 時自動排程。人工維護一份「先做 A 再做 B」的清單會隨資源增加而失準，依賴圖則隨程式碼本身演進。&lt;/p>
&lt;h3 id="四層依賴結構">四層依賴結構&lt;/h3>
&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>1&lt;/td>
 &lt;td>VPC、subnet、security group、IAM role&lt;/td>
 &lt;td>無（地基層，由模組二到四建立）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>RDS、ElastiCache、S3 bucket&lt;/td>
 &lt;td>引用 subnet group、security group&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>ECS service / EKS workload、RDS Proxy&lt;/td>
 &lt;td>引用 subnet、IAM role、DB 端點&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>4&lt;/td>
 &lt;td>ALB、listener、target group、ACM 憑證&lt;/td>
 &lt;td>引用 public subnet、security group、ECS&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這四層不需要手動編排。只要程式碼裡的引用關係正確，Terraform 就會自動按這個順序 apply。當 plan 輸出的順序看起來不合直覺 — 例如 ALB 先於 ECS — 通常代表某個引用斷了、兩者之間沒有依賴邊。&lt;/p>
&lt;h3 id="順序失控的徵兆">順序失控的徵兆&lt;/h3>
&lt;p>順序失控的早期徵兆是：某個上層資源的定義裡寫了一串 hardcode 的 subnet ID 或 VPC ID。&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"># 硬編碼 ID — 依賴圖斷裂，底層重建時上層不會跟上
&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_db_subnet_group&amp;#34; &amp;#34;private&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"> subnet_ids&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;subnet-0abc123&amp;#34;, &amp;#34;subnet-0def456&amp;#34;&lt;/span>&lt;span class="p">]&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>這段 code 跟底層的 subnet 資源沒有引用關係。底層一旦重建、ID 改變，上層不會自動跟上，state 與雲端現實之間的不一致（即 drift）就此產生。修法是把硬編碼的 ID 換成對底層資源屬性的引用：&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"># 引用資源屬性 — 依賴圖自動推導，底層重建時上層自動取得新 ID
&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_db_subnet_group&amp;#34; &amp;#34;private&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"> subnet_ids&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="k">for&lt;/span> &lt;span class="k">s&lt;/span> &lt;span class="k">in&lt;/span> &lt;span class="k">aws_subnet&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">private&lt;/span> &lt;span class="err">:&lt;/span> &lt;span class="k">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>&lt;span class="p">]&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>跨 state 的情境（網路地基與核心服務分屬不同 state）則用 data source 取代直接引用 — 這個取捨在&lt;a href="https://tarrragon.github.io/blog/infra/05-core-services/stateful-protection-dependency/" data-link-title="Stateful 資源保護與跨服務依賴表達" data-link-desc="stateful 資源的保護策略（multi-AZ、備份、刪除保護）、stateful 與 stateless 的操作差異，以及用 output 與 data source 表達服務間依賴">服務依賴與跨 state 引用&lt;/a>展開。&lt;/p></description><content:encoded><![CDATA[<p>地基就緒後，依「地基 → 上層」的順序把實際承載業務的服務寫進 IaC。<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">身分（IAM）</a>、<a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">網路（VPC / subnet）</a>與<a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">環境分離</a>構成底層平面，這一層在它們之上描述資料庫、運算、儲存與入口 — 業務流量真正落地的地方。順序與依賴的表達方式決定了這層能不能被乾淨地重建、拆除與演進。共通原則是：描述服務的「身分與接線」，而非把每個執行期參數都塞進程式碼。</p>
<p>本篇先確立依賴圖怎麼驅動部署順序，再展開核心服務裡最需要謹慎描述的一類 — 資料庫。資料庫持有無法重建的狀態，它的 IaC 描述比其他 stateless 資源多出保護策略、連線管理與讀寫分流三個維度。</p>
<h2 id="核心服務的部署順序">核心服務的部署順序</h2>
<p>核心服務的部署順序由依賴方向決定：被依賴的先建，依賴別人的後建。網路與身分是幾乎所有上層服務的共同前置 — 資料庫要放進私有 subnet、運算要套用 IAM role 才能讀 S3、load balancer 要掛在公開 subnet 並引用 security group。這些底層平面若還沒成形，上層資源會在 apply 時因為找不到 subnet ID 或 role ARN 而失敗，或更糟，建在預設 VPC 裡繞過了所有隔離設計。</p>
<p>把順序交給 IaC 工具的依賴圖自動推導，比人工排序可靠。當運算資源的定義引用了 subnet 與 security group 的資源屬性，Terraform 會解析出「subnet 先於運算」的邊，apply 時自動排程。人工維護一份「先做 A 再做 B」的清單會隨資源增加而失準，依賴圖則隨程式碼本身演進。</p>
<h3 id="四層依賴結構">四層依賴結構</h3>
<p>依賴圖的典型展開順序呈現四層結構：</p>
<table>
  <thead>
      <tr>
          <th>層次</th>
          <th>資源</th>
          <th>依賴來源</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>VPC、subnet、security group、IAM role</td>
          <td>無（地基層，由模組二到四建立）</td>
      </tr>
      <tr>
          <td>2</td>
          <td>RDS、ElastiCache、S3 bucket</td>
          <td>引用 subnet group、security group</td>
      </tr>
      <tr>
          <td>3</td>
          <td>ECS service / EKS workload、RDS Proxy</td>
          <td>引用 subnet、IAM role、DB 端點</td>
      </tr>
      <tr>
          <td>4</td>
          <td>ALB、listener、target group、ACM 憑證</td>
          <td>引用 public subnet、security group、ECS</td>
      </tr>
  </tbody>
</table>
<p>這四層不需要手動編排。只要程式碼裡的引用關係正確，Terraform 就會自動按這個順序 apply。當 plan 輸出的順序看起來不合直覺 — 例如 ALB 先於 ECS — 通常代表某個引用斷了、兩者之間沒有依賴邊。</p>
<h3 id="順序失控的徵兆">順序失控的徵兆</h3>
<p>順序失控的早期徵兆是：某個上層資源的定義裡寫了一串 hardcode 的 subnet ID 或 VPC 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="c1"># 硬編碼 ID — 依賴圖斷裂，底層重建時上層不會跟上
</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_db_subnet_group&#34; &#34;private&#34;</span> {
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  subnet_ids</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;subnet-0abc123&#34;, &#34;subnet-0def456&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">}</span></span></code></pre></div><p>這段 code 跟底層的 subnet 資源沒有引用關係。底層一旦重建、ID 改變，上層不會自動跟上，state 與雲端現實之間的不一致（即 drift）就此產生。修法是把硬編碼的 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="c1"># 引用資源屬性 — 依賴圖自動推導，底層重建時上層自動取得新 ID
</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_db_subnet_group&#34; &#34;private&#34;</span> {
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  subnet_ids</span> <span class="o">=</span> <span class="p">[</span><span class="k">for</span> <span class="k">s</span> <span class="k">in</span> <span class="k">aws_subnet</span><span class="p">.</span><span class="k">private</span> <span class="err">:</span> <span class="k">s</span><span class="p">.</span><span class="k">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">}</span></span></code></pre></div><p>跨 state 的情境（網路地基與核心服務分屬不同 state）則用 data source 取代直接引用 — 這個取捨在<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 表達服務間依賴">服務依賴與跨 state 引用</a>展開。</p>
<h3 id="隱性依賴與-depends_on">隱性依賴與 depends_on</h3>
<p>自動推導涵蓋的是「引用屬性時產生的邊」。少數情況下兩個資源之間有依賴卻沒有屬性引用 — 例如一個 IAM policy attachment 必須在某個 role 被 ECS task 使用之前完成，但 task 引用的是 role ARN 而非 attachment 的輸出。這時用 <code>depends_on</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_ecs_service&#34; &#34;api&#34;</span> {<span class="c1">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">  # ...
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="n">  depends_on</span> <span class="o">=</span> <span class="p">[</span><span class="k">aws_iam_role_policy_attachment</span><span class="p">.</span><span class="k">ecs_task_s3</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">}</span></span></code></pre></div><p><code>depends_on</code> 應該只出現在自動推導覆蓋不了的場景。如果一個 module 裡到處都是 <code>depends_on</code>，通常代表引用關係寫得不夠明確，該把隱性依賴改成屬性引用。</p>
<h2 id="資料庫rds">資料庫（RDS）</h2>
<p>資料庫是核心服務裡最需要謹慎描述的資源，因為它持有無法重建的狀態。IaC 定義它的 instance class、引擎版本、所在的 subnet group（決定它落在哪些私有 subnet）、套用的 parameter group 與 security group。連線端點不要硬編碼，改用資源 output 暴露給上層運算引用，這樣端點隨主庫 failover 或重建而改變時，上層引用自動更新。</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></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  identifier</span>             <span class="o">=</span> <span class="s2">&#34;app-${var.env}-primary&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  engine</span>                 <span class="o">=</span> <span class="s2">&#34;postgres&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  engine_version</span>         <span class="o">=</span> <span class="s2">&#34;16.3&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">  instance_class</span>         <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">db_instance_class</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  allocated_storage</span>      <span class="o">=</span> <span class="m">100</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">  storage_encrypted</span>      <span class="o">=</span> <span class="kt">true</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 class="n">  db_subnet_group_name</span>   <span class="o">=</span> <span class="k">aws_db_subnet_group</span><span class="p">.</span><span class="k">private</span><span class="p">.</span><span class="k">name</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">  vpc_security_group_ids</span> <span class="o">=</span> <span class="p">[</span><span class="k">aws_security_group</span><span class="p">.</span><span class="k">db</span><span class="p">.</span><span class="k">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">  multi_az</span>                  <span class="o">=</span><span class="n"> var.env</span> <span class="o">==</span> <span class="s2">&#34;prod&#34;</span> <span class="err">?</span> <span class="kt">true</span> <span class="err">:</span> <span class="kt">false</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">  backup_retention_period</span>   <span class="o">=</span><span class="n"> var.env</span> <span class="o">==</span> <span class="s2">&#34;prod&#34;</span> <span class="err">?</span> <span class="m">14</span> <span class="err">:</span> <span class="m">1</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">  backup_window</span>             <span class="o">=</span> <span class="s2">&#34;03:00-04:00&#34;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">  deletion_protection</span>       <span class="o">=</span><span class="n"> var.env</span> <span class="o">==</span> <span class="s2">&#34;prod&#34;</span> <span class="err">?</span> <span class="kt">true</span> <span class="err">:</span> <span class="kt">false</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="n">  skip_final_snapshot</span>       <span class="o">=</span><span class="n"> var.env</span> <span class="o">==</span> <span class="s2">&#34;prod&#34;</span> <span class="err">?</span> <span class="kt">false</span> <span class="err">:</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="n">  final_snapshot_identifier</span> <span class="o">=</span><span class="n"> var.env</span> <span class="o">==</span> <span class="s2">&#34;prod&#34; ? &#34;app-prod-final-${formatdate(&#34;YYYYMMDD&#34;, timestamp())}&#34;</span> <span class="err">:</span> <span class="k">null</span>
</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="n">  tags</span> <span class="o">=</span><span class="n"> { service</span> <span class="o">=</span> <span class="s2">&#34;payments&#34;</span> }
</span></span><span class="line"><span class="ln">20</span><span class="cl">}
</span></span><span class="line"><span class="ln">21</span><span class="cl">
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="k">output</span> <span class="s2">&#34;db_endpoint&#34;</span> {
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="n">  value</span> <span class="o">=</span> <span class="k">aws_db_instance</span><span class="p">.</span><span class="k">primary</span><span class="p">.</span><span class="k">endpoint</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">}</span></span></code></pre></div><h3 id="加密的不可逆性">加密的不可逆性</h3>
<p><code>storage_encrypted = true</code> 確保磁碟層級的加密在資源建立時就生效。RDS 不支援事後對既有 instance 開加密 — 漏了只能重建。補救路徑是匯出快照、用加密 KMS key 複製快照成加密版本、再用加密快照還原成新 instance。這個過程需要停機或切換端點，對已經承載流量的 production 資料庫代價很高。prod 的 RDS 若 <code>storage_encrypted</code> 為 false，這筆技術債越早處理越便宜。</p>
<h3 id="parameter-group-的角色">parameter group 的角色</h3>
<p>parameter group 定義資料庫引擎層級的行為參數（如 <code>max_connections</code>、<code>work_mem</code>、<code>log_min_duration_statement</code>），是 RDS instance 的設定骨架。IaC 描述 parameter group 的好處是讓這些參數進版本控制 — 有人改了 <code>max_connections</code> 會出現在 PR diff 裡，而不是某天在 Console 改了沒人知道。</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_parameter_group&#34; &#34;postgres16&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  family</span> <span class="o">=</span> <span class="s2">&#34;postgres16&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  name</span>   <span class="o">=</span> <span class="s2">&#34;app-${var.env}-pg16&#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 class="k">parameter</span> {
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">    name</span>  <span class="o">=</span> <span class="s2">&#34;log_min_duration_statement&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">    value</span> <span class="o">=</span> <span class="s2">&#34;1000&#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="k">parameter</span> {
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">    name</span>  <span class="o">=</span> <span class="s2">&#34;shared_preload_libraries&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">    value</span> <span class="o">=</span> <span class="s2">&#34;pg_stat_statements&#34;</span>
</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>修改 parameter group 的某些參數需要重啟 RDS instance（稱為 <code>apply_method = &quot;pending-reboot&quot;</code>），修改前要先確認這個參數屬於「立即生效」還是「要重啟」。在 Terraform plan 裡不會明確標示重啟，要靠 AWS 文件交叉比對。</p>
<h3 id="連線管理">連線管理</h3>
<p>運算到資料庫之間有一段常被略過的接線：連線管理。無狀態運算水平擴張時，每個實例各自開連線，容易把資料庫的連線數打滿。一個 ECS service 從 5 個 task 擴到 50 個、每個 task 開 10 條連線，就從 50 條跳到 500 條 — 而一台 <code>db.r6g.large</code> 的 <code>max_connections</code> 預設約在 1600 左右，500 條已經吃掉三分之一。</p>
<p>出現「擴運算反而拖垮 DB」的訊號時，解法是引入連線池或受管的連線代理。RDS Proxy 是 AWS 的受管方案：它在運算與 RDS 之間當一層連線池，把下游的數百條短連線收斂成對 RDS 的少量長連線。在 IaC 裡一併定義，輸出 proxy 端點給運算引用：</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_proxy&#34; &#34;app&#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;app-${var.env}-proxy&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  engine_family</span>          <span class="o">=</span> <span class="s2">&#34;POSTGRESQL&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  role_arn</span>               <span class="o">=</span> <span class="k">aws_iam_role</span><span class="p">.</span><span class="k">rds_proxy</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">  vpc_subnet_ids</span>         <span class="o">=</span> <span class="p">[</span><span class="k">for</span> <span class="k">s</span> <span class="k">in</span> <span class="k">aws_subnet</span><span class="p">.</span><span class="k">private</span> <span class="err">:</span> <span class="k">s</span><span class="p">.</span><span class="k">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  vpc_security_group_ids</span> <span class="o">=</span> <span class="p">[</span><span class="k">aws_security_group</span><span class="p">.</span><span class="k">db</span><span class="p">.</span><span class="k">id</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 class="k">auth</span> {
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">    auth_scheme</span> <span class="o">=</span> <span class="s2">&#34;SECRETS&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">    secret_arn</span>  <span class="o">=</span> <span class="k">aws_secretsmanager_secret</span><span class="p">.</span><span class="k">db_password</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  }
</span></span><span class="line"><span class="ln">12</span><span class="cl">}
</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 class="k">output</span> <span class="s2">&#34;db_proxy_endpoint&#34;</span> {
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">  value</span> <span class="o">=</span> <span class="k">aws_db_proxy</span><span class="p">.</span><span class="k">app</span><span class="p">.</span><span class="k">endpoint</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">}</span></span></code></pre></div><p>運算端引用 <code>db_proxy_endpoint</code> 而非 <code>db_endpoint</code>，連線管理就從各 task 自己處理轉成由 proxy 統一收斂。RDS Proxy 同時提供 failover 的連線保持 — 主庫切換到 standby 時，proxy 維護的連線不會全部斷開重建，應用端感受到的是短暫延遲而非連線錯誤。</p>
<p>判讀是否需要 RDS Proxy 的訊號是連線數成長曲線：如果運算的擴縮範圍固定且連線數上限遠低於 <code>max_connections</code>，直連即可；如果運算會頻繁擴縮或連線數可能逼近上限，proxy 值得引入。proxy 本身有額外成本（按 vCPU 計費），不是所有環境都划算 — dev 環境通常直連就夠。</p>
<h3 id="read-replica">read replica</h3>
<p>當讀流量遠大於寫、且能容忍副本的複寫延遲（通常是毫秒到秒級）時，read replica 是把讀請求導離主庫的下一步。replica 在 IaC 裡用獨立資源描述，引用主庫的 identifier：</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;read_replica&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  identifier</span>             <span class="o">=</span> <span class="s2">&#34;app-${var.env}-replica&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  replicate_source_db</span>    <span class="o">=</span> <span class="k">aws_db_instance</span><span class="p">.</span><span class="k">primary</span><span class="p">.</span><span class="k">identifier</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  instance_class</span>         <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">db_replica_class</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">  vpc_security_group_ids</span> <span class="o">=</span> <span class="p">[</span><span class="k">aws_security_group</span><span class="p">.</span><span class="k">db</span><span class="p">.</span><span class="k">id</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></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">output</span> <span class="s2">&#34;db_replica_endpoint&#34;</span> {
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  value</span> <span class="o">=</span> <span class="k">aws_db_instance</span><span class="p">.</span><span class="k">read_replica</span><span class="p">.</span><span class="k">endpoint</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">}</span></span></code></pre></div><p>運算端依讀寫分流引用不同端點 — 寫走 <code>db_endpoint</code>（或 <code>db_proxy_endpoint</code>），讀走 <code>db_replica_endpoint</code>。這個分流邏輯屬於應用層的責任，infra 只負責把端點暴露出來。</p>
<p>read replica 的邊界要講清楚：它緩解讀流量對主庫的壓力，但它不是備份。replica 會同步複製主庫的所有變更 — 包括誤刪的資料。需要還原到某個時間點的保護由 backup retention 與 PITR（point-in-time recovery）提供，這兩者的 IaC 描述在 <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>。</p>
<h3 id="引擎版本升級的取捨">引擎版本升級的取捨</h3>
<p>RDS 引擎版本（<code>engine_version</code>）寫進 IaC 後，版本升級就成為一個需要 PR review 的變更。升級分 minor 和 major：minor 升級（16.2 → 16.3）通常向後相容、可在維護視窗自動套用；major 升級（15 → 16）可能有 breaking change，需要先在 dev 環境驗證、備份、排維護窗口。</p>
<p>在 IaC 裡把 <code>engine_version</code> 寫死是刻意的選擇 — 它阻止 AWS 在背景自動升級 major 版本，讓版本變更必須走 PR。代價是需要定期檢查是否有 EOL 版本還在用。如果 <code>engine_version</code> 指向的版本已經超過 AWS 的支援期限，Terraform apply 會在某天失敗（AWS 會強制升級），這比主動升級更不可控。</p>
<p>資料庫在規模放大後的治理維度也會改變。<a href="/blog/backend/09-performance-capacity/cases/netflix-aurora-consolidation/" data-link-title="9.C23 Netflix：把關聯式 DB 統一到 Aurora、效能 &#43;75%、成本 -28%" data-link-desc="Netflix 把多套關聯式 DB 統一到 Aurora、效能提升 75%、成本下降 28%、串流數十億小時">Netflix 把分散的 Aurora 叢集整併</a>後成本降了 28%——多個團隊各自開的 RDS instance 加起來的閒置容量遠超一個整併後的叢集。infra 層的教訓是 RDS 的 IaC 描述不只管單一 instance 的設定，長期還要管叢集的分布與合併策略。另一個維度是合規需求驅動的資料落地：<a href="/blog/backend/09-performance-capacity/cases/hard-rock-digital-cockroachdb-sports-betting/" data-link-title="9.C41 Hard Rock Digital：CockroachDB on AWS Outposts、Wire Act 合規 &#43; 跨州單一邏輯 DB" data-link-desc="Hard Rock Digital 用 CockroachDB 跨 AWS Outposts &#43; US-East-1、Wire Act 強制資料留州、單一邏輯 DB 解多州 sportsbook、100 node 32 vCPU 撐 Super Bowl">Hard Rock Digital 因為 Wire Act 法規要求資料留在特定州</a>，用 AWS Outposts 在地端跑運算——這類情境下 infra 的 region 與可用區選擇由法規約束驅動，而非純技術決策。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<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>：資料庫的 subnet group 引用 private subnet</li>
<li>→ <a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>：RDS Proxy 的 IAM role 與 secret 存取</li>
<li>→ <a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>：prod / dev 用同一個 module、不同參數值</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 保護與跨 state 引用</a>：backup retention、deletion protection、multi-AZ 的完整討論</li>
<li>→ <a href="/blog/infra/05-core-services/compute-ecs-eks/" data-link-title="運算平台上 IaC — ECS 與 EKS" data-link-desc="容器運算平台的 IaC 描述：ECS 與 EKS 選型、task definition 與映像版本解耦、IAM task role 分離、auto-scaling 策略">運算上 IaC</a>：運算端怎麼引用資料庫端點</li>
<li>→ <a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">backend 模組一：資料庫</a>：schema 設計、migration、query 層面的服務端討論</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>環境分離與模組化 — 目錄結構、module 參數化與 retrofit 路徑</title><link>https://tarrragon.github.io/blog/infra/04-environment-separation/directory-module-parameterization/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/04-environment-separation/directory-module-parameterization/</guid><description>&lt;p>環境分離的核心責任是讓 dev 的實驗、staging 的驗證、prod 的真實流量彼此不可見也不可達。從目錄結構就定好環境邊界的專案，dev 跟 prod 是兩棵獨立的 state 樹、改錯一邊不會波及另一邊；等資源都長出來、流量都上線了才回頭切的專案，每一次 retrofit 都在帶電作業，動到的是正在服務客戶的網路與身分。同樣一套 module、同樣的工程師，差別只在「環境邊界是設計出來的、還是事後補的」，而這個差別在第一天幾乎零成本、在第一百天可能是一個季度的遷移專案。&lt;/p>
&lt;h2 id="環境分離從第一天的目錄結構就定好">環境分離從第一天的目錄結構就定好&lt;/h2>
&lt;p>環境分離的本質是把「同一套基礎設施定義」複製成多份隔離的執行實例，每份有自己的 state、自己的雲端資源、自己的故障半徑。它承擔的責任是讓 dev 的實驗、staging 的驗證、prod 的真實流量彼此不可見也不可達 — 在 dev 跑壞一個資料庫、套錯一條 security group 規則，prod 完全無感。&lt;/p>
&lt;p>這個邊界要在第一天就用目錄結構表達出來，原因是 state 一旦混在一起就難以無痛拆開。Terraform 這類工具用 state 檔記錄「哪個資源由哪段 code 管理」，如果 dev 跟 prod 的資源都登記在同一份 state，後續想把 prod 移出去，等於要對正在服務的資源做 &lt;code>state mv&lt;/code> 或 import/remove 操作 — 任何一步算錯，工具可能判定資源該銷毀重建，而那是 prod 的資料庫。第一天就分目錄，dev 與 prod 從來不曾共用 state，這個風險根本不存在。&lt;/p>
&lt;p>檢查自己的 repo：如果現在只有一份 &lt;code>main.tf&lt;/code>、裡面同時宣告了 &lt;code>dev-db&lt;/code> 跟 &lt;code>prod-db&lt;/code>，或者 &lt;code>terraform.tfstate&lt;/code> 裡同時記錄了兩個環境的資源，這個專案已經欠下環境分離的債，債齡每天都在增加。下一步路由是先確立目錄骨架，再決定差異怎麼參數化。&lt;/p>
&lt;h2 id="目錄分離-vs-terraform-workspace-的取捨">目錄分離 vs Terraform workspace 的取捨&lt;/h2>
&lt;p>切分環境有兩條主流路徑：每個環境一個獨立目錄（各自持有 backend 與 state），或共用一份 code 用 Terraform workspace 切換不同 state。兩者都能讓 state 隔離，差別在「環境差異藏在哪裡」以及「誤操作的故障半徑多大」。&lt;/p>
&lt;h3 id="隔離強度光譜">隔離強度光譜&lt;/h3>
&lt;p>在挑這兩條路之前，先把它們放回完整的分離強度光譜。環境分離橫跨一條從帳號到 workspace、隔離由粗到細的階梯：&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>帳號級隔離&lt;/td>
 &lt;td>各環境獨立雲端帳號&lt;/td>
 &lt;td>prod 需法規等級的權限與計費分離&lt;/td>
 &lt;td>高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>獨立 repo&lt;/td>
 &lt;td>各環境獨立程式碼庫與 CI pipeline&lt;/td>
 &lt;td>各環境由不同團隊維護或受不同合規約束&lt;/td>
 &lt;td>中高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>目錄分離&lt;/td>
 &lt;td>同 repo 內各環境有獨立目錄與 state&lt;/td>
 &lt;td>多數早期團隊的平衡點&lt;/td>
 &lt;td>低&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Workspace&lt;/td>
 &lt;td>同份 code、執行期切換 state&lt;/td>
 &lt;td>環境高度同構、數量多&lt;/td>
 &lt;td>最低&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>光譜越靠粗的一端，隔離越強、跨環境共用越少、初始與維運成本越高；越靠細的一端，重複越少、邊界越隱性。多數早期團隊在目錄分離這一格落腳，因為它在顯式邊界與維運成本之間平衡得宜。當隔離需求升高（例如 prod 要法規等級的帳務與權限隔離），再沿光譜往帳號級或獨立 repo 移。帳號級隔離的權限邊界設計見&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>。&lt;/p>
&lt;h3 id="目錄分離的結構">目錄分離的結構&lt;/h3>
&lt;p>目錄分離把每個環境寫成可獨立進入的工作目錄，差異透過各自的 &lt;code>terraform.tfvars&lt;/code> 表達，prod 的 backend 設定、變數值、甚至 provider 版本都各自鎖定。&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">infra/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">├── modules/ # 可重用模組、不含任何環境專屬值
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">│ ├── network/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">│ ├── database/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">│ └── service/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">└── environments/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> ├── dev/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> │ ├── main.tf # 呼叫 modules、傳 dev 參數
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> │ ├── backend.tf # state 指向 dev 專屬位址
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> │ └── terraform.tfvars # dev 的差異值
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> ├── staging/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> │ └── ...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> └── prod/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> ├── main.tf
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> ├── backend.tf # state 指向 prod 專屬位址
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> └── terraform.tfvars # prod 的差異值&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>它的代價是目錄之間有重複的 boilerplate（&lt;code>main.tf&lt;/code> 裡的 module 呼叫在每個環境幾乎一樣），好處是邊界顯式 — &lt;code>cd&lt;/code> 進哪個目錄、apply 就只會動那個環境，prod 的 state 位址寫死在 prod 目錄的 backend 設定裡，不會因為忘記切換而打錯環境。&lt;/p></description><content:encoded><![CDATA[<p>環境分離的核心責任是讓 dev 的實驗、staging 的驗證、prod 的真實流量彼此不可見也不可達。從目錄結構就定好環境邊界的專案，dev 跟 prod 是兩棵獨立的 state 樹、改錯一邊不會波及另一邊；等資源都長出來、流量都上線了才回頭切的專案，每一次 retrofit 都在帶電作業，動到的是正在服務客戶的網路與身分。同樣一套 module、同樣的工程師，差別只在「環境邊界是設計出來的、還是事後補的」，而這個差別在第一天幾乎零成本、在第一百天可能是一個季度的遷移專案。</p>
<h2 id="環境分離從第一天的目錄結構就定好">環境分離從第一天的目錄結構就定好</h2>
<p>環境分離的本質是把「同一套基礎設施定義」複製成多份隔離的執行實例，每份有自己的 state、自己的雲端資源、自己的故障半徑。它承擔的責任是讓 dev 的實驗、staging 的驗證、prod 的真實流量彼此不可見也不可達 — 在 dev 跑壞一個資料庫、套錯一條 security group 規則，prod 完全無感。</p>
<p>這個邊界要在第一天就用目錄結構表達出來，原因是 state 一旦混在一起就難以無痛拆開。Terraform 這類工具用 state 檔記錄「哪個資源由哪段 code 管理」，如果 dev 跟 prod 的資源都登記在同一份 state，後續想把 prod 移出去，等於要對正在服務的資源做 <code>state mv</code> 或 import/remove 操作 — 任何一步算錯，工具可能判定資源該銷毀重建，而那是 prod 的資料庫。第一天就分目錄，dev 與 prod 從來不曾共用 state，這個風險根本不存在。</p>
<p>檢查自己的 repo：如果現在只有一份 <code>main.tf</code>、裡面同時宣告了 <code>dev-db</code> 跟 <code>prod-db</code>，或者 <code>terraform.tfstate</code> 裡同時記錄了兩個環境的資源，這個專案已經欠下環境分離的債，債齡每天都在增加。下一步路由是先確立目錄骨架，再決定差異怎麼參數化。</p>
<h2 id="目錄分離-vs-terraform-workspace-的取捨">目錄分離 vs Terraform workspace 的取捨</h2>
<p>切分環境有兩條主流路徑：每個環境一個獨立目錄（各自持有 backend 與 state），或共用一份 code 用 Terraform workspace 切換不同 state。兩者都能讓 state 隔離，差別在「環境差異藏在哪裡」以及「誤操作的故障半徑多大」。</p>
<h3 id="隔離強度光譜">隔離強度光譜</h3>
<p>在挑這兩條路之前，先把它們放回完整的分離強度光譜。環境分離橫跨一條從帳號到 workspace、隔離由粗到細的階梯：</p>
<table>
  <thead>
      <tr>
          <th>隔離層級</th>
          <th>邊界機制</th>
          <th>適用情境</th>
          <th>初始成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>帳號級隔離</td>
          <td>各環境獨立雲端帳號</td>
          <td>prod 需法規等級的權限與計費分離</td>
          <td>高</td>
      </tr>
      <tr>
          <td>獨立 repo</td>
          <td>各環境獨立程式碼庫與 CI pipeline</td>
          <td>各環境由不同團隊維護或受不同合規約束</td>
          <td>中高</td>
      </tr>
      <tr>
          <td>目錄分離</td>
          <td>同 repo 內各環境有獨立目錄與 state</td>
          <td>多數早期團隊的平衡點</td>
          <td>低</td>
      </tr>
      <tr>
          <td>Workspace</td>
          <td>同份 code、執行期切換 state</td>
          <td>環境高度同構、數量多</td>
          <td>最低</td>
      </tr>
  </tbody>
</table>
<p>光譜越靠粗的一端，隔離越強、跨環境共用越少、初始與維運成本越高；越靠細的一端，重複越少、邊界越隱性。多數早期團隊在目錄分離這一格落腳，因為它在顯式邊界與維運成本之間平衡得宜。當隔離需求升高（例如 prod 要法規等級的帳務與權限隔離），再沿光譜往帳號級或獨立 repo 移。帳號級隔離的權限邊界設計見<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>。</p>
<h3 id="目錄分離的結構">目錄分離的結構</h3>
<p>目錄分離把每個環境寫成可獨立進入的工作目錄，差異透過各自的 <code>terraform.tfvars</code> 表達，prod 的 backend 設定、變數值、甚至 provider 版本都各自鎖定。</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">├── modules/                  # 可重用模組、不含任何環境專屬值
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">│   ├── network/
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">│   ├── database/
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">│   └── service/
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">└── environments/
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    ├── dev/
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    │   ├── main.tf           # 呼叫 modules、傳 dev 參數
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    │   ├── backend.tf        # state 指向 dev 專屬位址
</span></span><span class="line"><span class="ln">10</span><span class="cl">    │   └── terraform.tfvars  # dev 的差異值
</span></span><span class="line"><span class="ln">11</span><span class="cl">    ├── staging/
</span></span><span class="line"><span class="ln">12</span><span class="cl">    │   └── ...
</span></span><span class="line"><span class="ln">13</span><span class="cl">    └── prod/
</span></span><span class="line"><span class="ln">14</span><span class="cl">        ├── main.tf
</span></span><span class="line"><span class="ln">15</span><span class="cl">        ├── backend.tf        # state 指向 prod 專屬位址
</span></span><span class="line"><span class="ln">16</span><span class="cl">        └── terraform.tfvars  # prod 的差異值</span></span></code></pre></div><p>它的代價是目錄之間有重複的 boilerplate（<code>main.tf</code> 裡的 module 呼叫在每個環境幾乎一樣），好處是邊界顯式 — <code>cd</code> 進哪個目錄、apply 就只會動那個環境，prod 的 state 位址寫死在 prod 目錄的 backend 設定裡，不會因為忘記切換而打錯環境。</p>
<p>每個環境目錄的 <code>backend.tf</code> 各自指向獨立的 state 路徑，這是邊界的物理保證：</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"># environments/prod/backend.tf
</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/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-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># environments/dev/backend.tf
</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;dev/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><h3 id="terragrunt-收斂-boilerplate">Terragrunt 收斂 boilerplate</h3>
<p>目錄分離的 boilerplate 重複可以用 Terragrunt 收斂。Terragrunt 的存在理由正是把跨環境目錄共通的 backend、provider、module 呼叫抽成一份範本，各環境目錄只留差異值，等於在保留目錄顯式邊界的前提下補上一層 DRY。它值得引入的情境是環境數量多（超過三個）、共通 boilerplate 開始拖慢維護時；環境只有兩三個時，直接維護幾份目錄的成本通常還低於多引入一個工具與它的學習曲線。</p>
<h3 id="workspace-的邊界">Workspace 的邊界</h3>
<p>Workspace 共用同一份 code、用 <code>terraform workspace select prod</code> 在執行期切換 state。它的好處是零重複，所有環境的 code 保證同步；代價是環境差異只能靠 <code>terraform.workspace</code> 在 code 裡寫條件判斷，而當前選中哪個 workspace 是 shell 的隱性狀態。</p>
<p>這個隱性狀態正是早期最該避免的失誤來源。在 dev workspace 以為自己在改 dev、其實上一個指令切到了 prod，apply 下去才發現故障半徑是 prod。沒有任何檔案層級的信號能防止這件事 — workspace 的當前狀態存在本地的 <code>.terraform/</code> 目錄裡，git diff 看不到、code review 也看不到。</p>
<p>早期推薦目錄分離，理由是故障半徑與認知負荷的取捨在小團隊明顯偏向「顯式邊界」這一側：團隊還沒有成熟的 CI gate 攔截誤 apply，顯式目錄是最便宜的防呆。Workspace 較划算的情境是環境數量多且高度同構（例如每個客戶一個隔離環境、差異只有名稱與配額），重複目錄的維護成本開始超過 workspace 隱性狀態的風險時，再切過去。每個環境的 state 要怎麼各自隔離、backend 怎麼設定，見<a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一：最小可行 IaC</a>。</p>
<h2 id="module-化同一套-code不同參數">module 化：同一套 code、不同參數</h2>
<p>Module 是把一組會被多環境重複使用的資源封裝成有輸入參數的單元，承擔的責任是讓 dev 與 prod 共享同一份邏輯定義、只在參數上分歧。沒有 module 時，dev 與 prod 各自維護一份 copy-paste 的資源宣告，兩份會隨時間漂移 — 有人只在 prod 補了一條 security group 規則、忘了同步 dev，於是「dev 能跑、prod 卻爆掉」或更糟的「dev 測過了、prod 行為不同」。</p>
<p>避免漂移的關鍵是讓環境之間唯一合法的差異來源是傳進 module 的參數，而不是 module 內部的 code 分支。Module 內部不寫 <code>if env == &quot;prod&quot;</code> 這類判斷，所有環境相關的值都從 <code>variable</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="c1"># modules/database/variables.tf — module 只宣告它需要什麼參數
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">variable</span> <span class="s2">&#34;instance_class&#34;</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  type</span>        <span class="o">=</span> <span class="k">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  description</span> <span class="o">=</span> <span class="s2">&#34;RDS instance type&#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 class="k">variable</span> <span class="s2">&#34;multi_az&#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="k">bool</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  default</span> <span class="o">=</span> <span class="kt">false</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></span><span class="line"><span class="ln">12</span><span class="cl"><span class="k">variable</span> <span class="s2">&#34;backup_retention_days&#34;</span> {
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">  type</span>    <span class="o">=</span> <span class="k">number</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">  default</span> <span class="o">=</span> <span class="m">7</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></span><span class="line"><span class="ln">17</span><span class="cl"><span class="k">variable</span> <span class="s2">&#34;deletion_protection&#34;</span> {
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="n">  type</span>    <span class="o">=</span> <span class="k">bool</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="n">  default</span> <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">}</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"># modules/database/main.tf — module 用參數建資源，不含環境判斷
</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_db_instance&#34; &#34;primary&#34;</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  identifier</span>              <span class="o">=</span> <span class="s2">&#34;${var.service_name}-${var.env}&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  engine</span>                  <span class="o">=</span> <span class="s2">&#34;postgres&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">  engine_version</span>          <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">engine_version</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  instance_class</span>          <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">instance_class</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">  multi_az</span>                <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">multi_az</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">  backup_retention_period</span> <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">backup_retention_days</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  deletion_protection</span>     <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">deletion_protection</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">  db_subnet_group_name</span>    <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">subnet_group_name</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">  vpc_security_group_ids</span>  <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">security_group_ids</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">}</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"># environments/prod/main.tf — prod 傳自己的值
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">module</span> <span class="s2">&#34;database&#34;</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  source</span>                <span class="o">=</span> <span class="s2">&#34;../../modules/database&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  service_name</span>          <span class="o">=</span> <span class="s2">&#34;payments&#34;</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;prod&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  instance_class</span>        <span class="o">=</span> <span class="s2">&#34;db.r6g.xlarge&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">  engine_version</span>        <span class="o">=</span> <span class="s2">&#34;16.3&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">  multi_az</span>              <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  backup_retention_days</span> <span class="o">=</span> <span class="m">30</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">  deletion_protection</span>   <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">  subnet_group_name</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">private_subnet_group</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">  security_group_ids</span>    <span class="o">=</span> <span class="p">[</span><span class="k">module</span><span class="p">.</span><span class="k">network</span><span class="p">.</span><span class="k">db_security_group_id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">}</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"># environments/dev/main.tf — dev 傳縮小版的值
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">module</span> <span class="s2">&#34;database&#34;</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  source</span>                <span class="o">=</span> <span class="s2">&#34;../../modules/database&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  service_name</span>          <span class="o">=</span> <span class="s2">&#34;payments&#34;</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;dev&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  instance_class</span>        <span class="o">=</span> <span class="s2">&#34;db.t3.micro&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">  engine_version</span>        <span class="o">=</span> <span class="s2">&#34;16.3&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">  multi_az</span>              <span class="o">=</span> <span class="kt">false</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  backup_retention_days</span> <span class="o">=</span> <span class="m">1</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">  deletion_protection</span>   <span class="o">=</span> <span class="kt">false</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">  subnet_group_name</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">private_subnet_group</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">  security_group_ids</span>    <span class="o">=</span> <span class="p">[</span><span class="k">module</span><span class="p">.</span><span class="k">network</span><span class="p">.</span><span class="k">db_security_group_id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">}</span></span></code></pre></div><p>這樣 dev 與 prod 跑的是位元層級相同的 module code，差異全部收斂在 <code>main.tf</code> 的呼叫參數裡、一眼可審。Review 時只要 diff 各環境的參數區塊就能看完所有環境差異。如果發現有人為了某環境的特例去改 module 內部，那是漂移正在發生的徵兆——該把特例改寫成新的參數，而非在 module 裡加 <code>if env == &quot;prod&quot;</code> 分支。核心服務怎麼用 module 跨環境重用，見<a href="/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a>。</p>
<h2 id="環境差異參數化prod-放大dev-縮小">環境差異參數化：prod 放大、dev 縮小</h2>
<p>環境之間真正該不同的是規模與冗餘等級，而這些差異全部表達成參數值、不表達成不同的 code。Prod 承擔真實流量與可用性承諾，所以跨多個可用區（multi-AZ）部署、機器規格放大、備份保留更久、開啟刪除保護；dev 承擔的是迭代速度與成本控制，所以單 AZ、最小機型、短備份甚至無備份，壞了重建即可。</p>
<p>把這些差異參數化的好處是「環境拓樸的形狀一致、只有刻度不同」。Dev 與 prod 都經過同一段 module 邏輯，prod 不會出現一段 dev 從未執行過的 code path — 真正上線的設定，在 dev 已經以縮小版驗證過邏輯正確性。</p>
<table>
  <thead>
      <tr>
          <th>參數</th>
          <th>prod</th>
          <th>staging</th>
          <th>dev</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>instance_class</td>
          <td><code>db.r6g.xlarge</code></td>
          <td><code>db.r6g.large</code></td>
          <td><code>db.t3.micro</code></td>
      </tr>
      <tr>
          <td>multi_az</td>
          <td><code>true</code></td>
          <td><code>true</code></td>
          <td><code>false</code></td>
      </tr>
      <tr>
          <td>backup_retention</td>
          <td><code>30</code></td>
          <td><code>14</code></td>
          <td><code>1</code></td>
      </tr>
      <tr>
          <td>deletion_protection</td>
          <td><code>true</code></td>
          <td><code>true</code></td>
          <td><code>false</code></td>
      </tr>
      <tr>
          <td>desired_count</td>
          <td><code>6</code></td>
          <td><code>2</code></td>
          <td><code>1</code></td>
      </tr>
  </tbody>
</table>
<p>常見陷阱是把成本差異做成「dev 直接砍掉某個元件」：例如 dev 為了省錢不建負載平衡器、prod 才建，結果 prod 的 LB 相關設定從來沒在 dev 測過。較合理的做法是 dev 也建同型元件、只把規格與數量縮到最小，讓拓樸保持同構、只縮放刻度。</p>
<p>邊界在於少數差異無法只靠刻度表達。Prod 需要合規要求的稽核 log、dev 不需要；prod 要開 WAF、dev 不需要。這類差異用 <code>count</code> 或 <code>for_each</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_cloudwatch_log_group&#34; &#34;audit&#34;</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  count</span>             <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">enable_audit_log</span> <span class="err">?</span> <span class="m">1</span> <span class="err">:</span> <span class="m">0</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  name</span>              <span class="o">=</span> <span class="s2">&#34;/app/${var.env}/audit&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  retention_in_days</span> <span class="o">=</span> <span class="m">365</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">}</span></span></code></pre></div><p>仍然走參數化、不分叉 code — 分叉 code 是漂移的起點。跨可用區與冗餘的網路面怎麼鋪，見<a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>。</p>
<h2 id="retrofit-路徑把單環境拆成-per-env-module">retrofit 路徑：把單環境拆成 per-env module</h2>
<p>很多專案是先在單一環境把 IAM、VPC、核心資源都建起來、跑通了，才意識到需要環境分離 — 這是常見且合理的演進順序，尤其是先救火上線、之後才回頭納管的情況。Retrofit 的目標是在不破壞正在服務的資源前提下，把這份「隱含為 prod」的單環境，重構成「modules + per-env 呼叫」的結構，並讓現有資源平移成 prod 環境。</p>
<p>安全的步驟順序是先重構 code、再動資源歸屬，且每一步都用 <code>terraform plan</code> 確認「零變更」。</p>
<h3 id="第一步抽-module宣告搬遷">第一步：抽 module、宣告搬遷</h3>
<p>把現有資源宣告抽成 module：把 <code>main.tf</code> 裡的資源搬進 <code>modules/</code>，原地用 module 呼叫取代，所有值先寫死成現況。資源在 state 裡的位址會從 <code>aws_db_instance.primary</code> 變成 <code>module.database.aws_db_instance.primary</code>，用 <code>moved {}</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">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_db_instance</span><span class="p">.</span><span class="k">primary</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">database</span><span class="p">.</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">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">moved</span> {
</span></span><span class="line"><span class="ln">7</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">db</span>
</span></span><span class="line"><span class="ln">8</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">database</span><span class="p">.</span><span class="k">aws_security_group</span><span class="p">.</span><span class="k">db</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">}</span></span></code></pre></div><p>此時 <code>plan</code> 必須顯示無任何新增或銷毀 — 只是重新組織 code。如果 plan 出現任何 <code>destroy</code> 或 <code>forces replacement</code>，在 prod 路徑上停下來查 <code>moved</code> 區塊哪裡寫錯。</p>
<h3 id="第二步參數化">第二步：參數化</h3>
<p>把寫死的值換成 prod 的參數：把現況值搬進 <code>environments/prod/terraform.tfvars</code>，module 改吃參數。<code>plan</code> 仍須零變更，因為參數值就等於現況值。這一步的驗證方式很機械：每個從字面值改成變數引用的欄位，都能在 tfvars 裡找到完全相同的值。</p>
<h3 id="第三步新增其他環境">第三步：新增其他環境</h3>
<p>複製 prod 的呼叫結構成 <code>environments/dev/</code>，給它自己的 backend（獨立 state）與縮小的參數值。這一步是純新增、不碰 prod。先在 dev <code>apply</code> 出一套完整的縮小版環境、確認 module 在新環境也能 plan/apply 乾淨，再回頭確信 prod 的重構沒有副作用。</p>
<h3 id="風險控制">風險控制</h3>
<p>最大的風險集中在前兩步：現有資源是活的，任何讓工具判定「需要替換」的改動，對 IAM 角色可能是短暫權限真空、對 VPC 可能是子網重建導致服務中斷。防護措施有三個層級：</p>
<p>第一，把每一次 <code>plan</code> 的輸出當成必須為零的驗收條件。非零就停下來查 <code>moved</code> 區塊或參數值哪裡跟現況不符。狀態危險的訊號是 <code>plan</code> 出現任何 <code>destroy</code> 或 <code>forces replacement</code>，在 prod 路徑上這幾乎都該暫停。</p>
<p>第二，在 retrofit 開始前備份 state 檔。S3 backend 有 versioning 可以回捲，但多一份本地備份增加保險層：</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"># retrofit 前備份 state</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws s3 cp s3://acme-tf-state/prod/terraform.tfstate <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  ./state-backup-<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>.tfstate</span></span></code></pre></div><p>第三，<code>moved</code> 區塊優先用宣告式（可 review、可回滾），手動 <code>state mv</code> 留給 <code>moved</code> 表達不了的跨 module 搬遷。整個 retrofit 走 PR 流程、讓 plan 輸出在 review 時可見，見<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>。</p>
<p>時程參考：10-20 個資源的典型環境，從單環境拆成 module + per-env 結構約需 1-2 週（含每步 plan 驗證與跨環境推送）。50 個以上資源的環境需要 3-4 週分階段執行，每階段以一組功能相關的資源為單位。這些時程的主要變數是 stateful 資源的數量——每個 stateful 資源的 moved/import 操作都需要額外的 plan 驗證與備份保險。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<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 怎麼隔開</li>
<li>→ <a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>：帳號級隔離的權限邊界</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>：跨可用區冗餘的網路面</li>
<li>→ <a href="/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a>：核心服務怎麼用 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>：retrofit 的 plan 輸出怎麼進 review</li>
<li>→ <a href="/blog/infra/02-identity-credentials/multi-account-strategy/" data-link-title="跨帳號策略 — Organizations、SCP 與帳號工廠" data-link-desc="用 AWS Organizations 把環境拆成獨立帳號、用 SCP 設定連管理員都越不過的護欄、用帳號工廠讓每個新帳號自帶安全基線">跨帳號策略</a>：帳號級隔離是環境分離光譜最粗的一端</li>
</ul>
]]></content:encoded></item><item><title>IaC plan、apply、drift 與 recovery 流程</title><link>https://tarrragon.github.io/blog/ci/iac-platform-deploy/plan-apply-drift-recovery-flow/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ci/iac-platform-deploy/plan-apply-drift-recovery-flow/</guid><description>&lt;p>IaC 發布流程的核心責任是把基礎設施變更變成可審查、可套用、可追溯的狀態轉移。Terraform、Pulumi、Helm 或平台自動化會改變網路、權限、資料庫、節點、DNS 與部署平台，因此 CI/CD 要把 plan、review、apply、&lt;a href="https://tarrragon.github.io/blog/ci/knowledge-cards/infrastructure-drift/" data-link-title="Infrastructure Drift" data-link-desc="說明真實基礎設施狀態與 IaC 宣告分叉時的偵測、判讀與修復責任">Infrastructure Drift&lt;/a> 與 recovery 分成明確 gate。&lt;/p>
&lt;h2 id="流程定位">流程定位&lt;/h2>
&lt;p>IaC 的風險集中在共享狀態與不可逆資源。應用部署失敗常可回退 artifact；基礎設施變更可能刪除資料、替換節點、改掉 IAM 權限或讓 state 與真實環境分叉。發布流程應讓 reviewer 在 apply 前看到「將要改什麼」，並讓 apply 後能確認「環境是否真的符合宣告」。&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>Plan&lt;/td>
 &lt;td>預覽資源差異與風險&lt;/td>
 &lt;td>create / update / replace / destroy&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Review&lt;/td>
 &lt;td>審核變更意圖、權限與影響面&lt;/td>
 &lt;td>高風險資源、跨環境、資料資源&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Apply&lt;/td>
 &lt;td>在鎖定狀態下套用變更&lt;/td>
 &lt;td>state lock、timeout、partial apply&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Verify&lt;/td>
 &lt;td>確認環境符合預期&lt;/td>
 &lt;td>health、policy、smoke、connectivity&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ci/knowledge-cards/infrastructure-drift/" data-link-title="Infrastructure Drift" data-link-desc="說明真實基礎設施狀態與 IaC 宣告分叉時的偵測、判讀與修復責任">Infrastructure Drift&lt;/a>&lt;/td>
 &lt;td>偵測真實環境與宣告分叉&lt;/td>
 &lt;td>手動 hotfix、console edit、外部系統&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Recovery&lt;/td>
 &lt;td>回退、補正或 state repair&lt;/td>
 &lt;td>是否能安全恢復服務與 state&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Plan 階段負責產生可審查差異。Plan 是 reviewer 判斷資源替換、權限擴大、資料刪除與網路暴露的主要材料。CI 應保留 plan artifact，讓 apply 使用同一份輸入與版本。&lt;/p>
&lt;p>Review 階段負責把風險放到正確 owner。平台、資安、資料庫或服務 owner 應依資源類型參與審核；高風險變更需要額外 gate，例如 maintenance window、人工 approval 或雙人審核。&lt;/p>
&lt;p>Apply 階段負責把宣告狀態寫入環境。&lt;a href="https://tarrragon.github.io/blog/ci/knowledge-cards/state-lock/" data-link-title="State Lock" data-link-desc="說明 IaC apply 如何用狀態鎖避免併發變更覆寫基礎設施狀態">State Lock&lt;/a>、credential、workspace 與環境變數都要固定；partial apply 或 timeout 後，要先判斷 state 與真實資源是否一致，再決定下一步。&lt;/p>
&lt;p>Verify 階段負責確認平台可用。Apply 成功只代表 provider API 接受變更；仍需要 connectivity test、policy check、service smoke test、DNS / certificate check 或 cluster health，確認服務真的能跑。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/ci/knowledge-cards/infrastructure-drift/" data-link-title="Infrastructure Drift" data-link-desc="說明真實基礎設施狀態與 IaC 宣告分叉時的偵測、判讀與修復責任">Infrastructure Drift&lt;/a> 階段負責發現宣告與現況分叉。手動 hotfix、雲端 console 調整、外部 controller 或 provider 預設值都可能造成 drift；drift detection 要定期執行，並把修復責任導回宣告檔。&lt;/p>
&lt;p>Recovery 階段負責處理失敗套用。IaC 回復不一定是 &lt;code>git revert&lt;/code> 後 apply；可能需要 import、state mv、taint / untaint、手動修復資料資源或 forward fix。流程要先保護資料與服務，再修正宣告與 state。&lt;/p>
&lt;h2 id="plan-review-判讀">Plan review 判讀&lt;/h2>
&lt;p>Plan review 的責任是讓變更影響在 apply 前被看見。Reviewer 應依資源語意判斷，讓 diff 行數退居輔助訊號。&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>destroy&lt;/code>&lt;/td>
 &lt;td>資源將被刪除&lt;/td>
 &lt;td>確認資料、依賴與備份&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>replace&lt;/code>&lt;/td>
 &lt;td>先刪後建或重建資源&lt;/td>
 &lt;td>檢查 downtime、IP、DNS、資料&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>IAM 權限擴大&lt;/td>
 &lt;td>blast radius 增加&lt;/td>
 &lt;td>資安或平台 owner 審核&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Network 開放&lt;/td>
 &lt;td>暴露面增加&lt;/td>
 &lt;td>檢查 security group / firewall&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>State 大量漂移&lt;/td>
 &lt;td>宣告與現況長期分叉&lt;/td>
 &lt;td>先處理 drift，再進 feature change&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表讓 review 從「有人按 approve」變成風險判讀。IaC review 的價值在於提前看見不可逆或高代價變更。&lt;/p></description><content:encoded><![CDATA[<p>IaC 發布流程的核心責任是把基礎設施變更變成可審查、可套用、可追溯的狀態轉移。Terraform、Pulumi、Helm 或平台自動化會改變網路、權限、資料庫、節點、DNS 與部署平台，因此 CI/CD 要把 plan、review、apply、<a href="/blog/ci/knowledge-cards/infrastructure-drift/" data-link-title="Infrastructure Drift" data-link-desc="說明真實基礎設施狀態與 IaC 宣告分叉時的偵測、判讀與修復責任">Infrastructure Drift</a> 與 recovery 分成明確 gate。</p>
<h2 id="流程定位">流程定位</h2>
<p>IaC 的風險集中在共享狀態與不可逆資源。應用部署失敗常可回退 artifact；基礎設施變更可能刪除資料、替換節點、改掉 IAM 權限或讓 state 與真實環境分叉。發布流程應讓 reviewer 在 apply 前看到「將要改什麼」，並讓 apply 後能確認「環境是否真的符合宣告」。</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>責任</th>
          <th>判讀訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Plan</td>
          <td>預覽資源差異與風險</td>
          <td>create / update / replace / destroy</td>
      </tr>
      <tr>
          <td>Review</td>
          <td>審核變更意圖、權限與影響面</td>
          <td>高風險資源、跨環境、資料資源</td>
      </tr>
      <tr>
          <td>Apply</td>
          <td>在鎖定狀態下套用變更</td>
          <td>state lock、timeout、partial apply</td>
      </tr>
      <tr>
          <td>Verify</td>
          <td>確認環境符合預期</td>
          <td>health、policy、smoke、connectivity</td>
      </tr>
      <tr>
          <td><a href="/blog/ci/knowledge-cards/infrastructure-drift/" data-link-title="Infrastructure Drift" data-link-desc="說明真實基礎設施狀態與 IaC 宣告分叉時的偵測、判讀與修復責任">Infrastructure Drift</a></td>
          <td>偵測真實環境與宣告分叉</td>
          <td>手動 hotfix、console edit、外部系統</td>
      </tr>
      <tr>
          <td>Recovery</td>
          <td>回退、補正或 state repair</td>
          <td>是否能安全恢復服務與 state</td>
      </tr>
  </tbody>
</table>
<p>Plan 階段負責產生可審查差異。Plan 是 reviewer 判斷資源替換、權限擴大、資料刪除與網路暴露的主要材料。CI 應保留 plan artifact，讓 apply 使用同一份輸入與版本。</p>
<p>Review 階段負責把風險放到正確 owner。平台、資安、資料庫或服務 owner 應依資源類型參與審核；高風險變更需要額外 gate，例如 maintenance window、人工 approval 或雙人審核。</p>
<p>Apply 階段負責把宣告狀態寫入環境。<a href="/blog/ci/knowledge-cards/state-lock/" data-link-title="State Lock" data-link-desc="說明 IaC apply 如何用狀態鎖避免併發變更覆寫基礎設施狀態">State Lock</a>、credential、workspace 與環境變數都要固定；partial apply 或 timeout 後，要先判斷 state 與真實資源是否一致，再決定下一步。</p>
<p>Verify 階段負責確認平台可用。Apply 成功只代表 provider API 接受變更；仍需要 connectivity test、policy check、service smoke test、DNS / certificate check 或 cluster health，確認服務真的能跑。</p>
<p><a href="/blog/ci/knowledge-cards/infrastructure-drift/" data-link-title="Infrastructure Drift" data-link-desc="說明真實基礎設施狀態與 IaC 宣告分叉時的偵測、判讀與修復責任">Infrastructure Drift</a> 階段負責發現宣告與現況分叉。手動 hotfix、雲端 console 調整、外部 controller 或 provider 預設值都可能造成 drift；drift detection 要定期執行，並把修復責任導回宣告檔。</p>
<p>Recovery 階段負責處理失敗套用。IaC 回復不一定是 <code>git revert</code> 後 apply；可能需要 import、state mv、taint / untaint、手動修復資料資源或 forward fix。流程要先保護資料與服務，再修正宣告與 state。</p>
<h2 id="plan-review-判讀">Plan review 判讀</h2>
<p>Plan review 的責任是讓變更影響在 apply 前被看見。Reviewer 應依資源語意判斷，讓 diff 行數退居輔助訊號。</p>
<table>
  <thead>
      <tr>
          <th>Plan 訊號</th>
          <th>判讀</th>
          <th>下一步</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>destroy</code></td>
          <td>資源將被刪除</td>
          <td>確認資料、依賴與備份</td>
      </tr>
      <tr>
          <td><code>replace</code></td>
          <td>先刪後建或重建資源</td>
          <td>檢查 downtime、IP、DNS、資料</td>
      </tr>
      <tr>
          <td>IAM 權限擴大</td>
          <td>blast radius 增加</td>
          <td>資安或平台 owner 審核</td>
      </tr>
      <tr>
          <td>Network 開放</td>
          <td>暴露面增加</td>
          <td>檢查 security group / firewall</td>
      </tr>
      <tr>
          <td>State 大量漂移</td>
          <td>宣告與現況長期分叉</td>
          <td>先處理 drift，再進 feature change</td>
      </tr>
  </tbody>
</table>
<p>這張表讓 review 從「有人按 approve」變成風險判讀。IaC review 的價值在於提前看見不可逆或高代價變更。</p>
<h2 id="drift-處理路由">Drift 處理路由</h2>
<p>Drift 處理的責任是把現況重新帶回可管理狀態。Drift 發現後不應直接 apply 覆蓋，因為 drift 可能是事故 hotfix、外部系統自動調整或宣告檔過期。</p>
<ol>
<li>確認 drift 來源：人工 hotfix、provider 預設、外部 controller 或宣告過期。</li>
<li>判斷 drift 是否仍需要保留：若是真實修復，應回寫到 IaC。</li>
<li>判斷 apply 是否會破壞服務：特別看 replacement、destroy、權限與 network。</li>
<li>修正宣告或 state：必要時使用 import、state mv 或 provider-specific repair。</li>
<li>重新 plan，確認差異收斂到預期。</li>
</ol>
<p>這個路由讓 drift 修復具備審查性。直接在 console 裡補到看起來正常，會讓下一次 CI apply 把修復覆蓋掉。</p>
<h2 id="常見反模式">常見反模式</h2>
<p>反模式的共同問題是把 IaC 降成指令自動化，忽略它承擔的狀態治理責任。</p>
<table>
  <thead>
      <tr>
          <th>反模式</th>
          <th>風險</th>
          <th>替代做法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>plan 與 apply 使用不同輸入</td>
          <td>review 內容與實際套用內容分叉</td>
          <td>保存 plan artifact 或鎖定版本</td>
      </tr>
      <tr>
          <td>沒有 <a href="/blog/ci/knowledge-cards/state-lock/" data-link-title="State Lock" data-link-desc="說明 IaC apply 如何用狀態鎖避免併發變更覆寫基礎設施狀態">State Lock</a></td>
          <td>併發 apply 覆寫狀態</td>
          <td>使用 remote backend 與 locking</td>
      </tr>
      <tr>
          <td>drift 長期忽略</td>
          <td>宣告失去可信度</td>
          <td>定期 drift detection 與 owner 路由</td>
      </tr>
      <tr>
          <td>高風險資源無額外 gate</td>
          <td>資料或網路變更直接進環境</td>
          <td>environment protection / approval</td>
      </tr>
  </tbody>
</table>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>IaC 部署總覽：回 <a href="../">IaC / Platform 部署 CI/CD</a>。</li>
<li>環境保護：讀 <a href="/blog/ci/knowledge-cards/environment-protection/" data-link-title="Environment Protection" data-link-desc="說明目標環境的審核、權限與放行條件如何保護發布">Environment Protection</a>。</li>
<li>Gate 原理：讀 <a href="../../ci-gate-workflow-boundary/">CI gate 與 workflow 邊界</a>。</li>
</ul>
]]></content:encoded></item><item><title>Dotfile 跟 Infra IaC 的平行關係</title><link>https://tarrragon.github.io/blog/linux/dotfile/00-dotfile-mindset/dotfile-iac-parallel/</link><pubDate>Mon, 29 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/linux/dotfile/00-dotfile-mindset/dotfile-iac-parallel/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/infra/" data-link-title="Infra 基礎設施建置指南" data-link-desc="從零循序漸進把雲端基礎設施做起來 — IaC、身分憑證、網路地基、環境分離、核心服務、可觀測性、自動化 review 與治理習慣，含怎麼在組織內推動">Infra 基礎設施建置指南&lt;/a>教的是用 Terraform 或 OpenTofu 把雲端資源（VPC、IAM role、EC2 instance）寫成代碼，讓基礎設施可重現、可 review、可回滾。Dotfile 做的事在概念上完全平行：把個人工作環境（shell、editor、terminal、window manager）寫成代碼，達成同樣的可重現性。&lt;/p>
&lt;h2 id="共用的核心原則">共用的核心原則&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>宣告式&lt;/strong>：描述「環境應該長什麼樣」，而非「操作了哪些步驟」。Terraform 宣告「要有一個 VPC、CIDR 是 10.0.0.0/16」；dotfile 宣告「zsh 的 prompt 格式是這樣、alias ll 對應 ls -la」。&lt;/li>
&lt;li>&lt;strong>版控下的變更歷史&lt;/strong>：誰改了什麼、什麼時候改的、為什麼改，都在 Git log 裡。環境出問題時可以回溯到「上一次正常的狀態」是哪個 commit。&lt;/li>
&lt;li>&lt;strong>可 review&lt;/strong>：改了一個 shell function，diff 清楚可讀。跟在 terminal 裡直接 export 一個變數、下次重開就忘了相比，版控下的改動有跡可循。&lt;/li>
&lt;/ul>
&lt;h2 id="差異">差異&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>Infra IaC&lt;/th>
 &lt;th>Dotfile&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>管理對象&lt;/td>
 &lt;td>組織的雲端資源&lt;/td>
 &lt;td>個人的工作桌面&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>State 管理&lt;/td>
 &lt;td>Remote backend + lock 機制（防並行衝突）&lt;/td>
 &lt;td>通常只用 Git，沒有額外 state file&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>生效方式&lt;/td>
 &lt;td>&lt;code>terraform plan&lt;/code> → &lt;code>terraform apply&lt;/code> 兩步&lt;/td>
 &lt;td>多數改完 source 即生效，或重開 terminal 生效&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>影響範圍&lt;/td>
 &lt;td>改錯可能影響 production 服務&lt;/td>
 &lt;td>改錯最多影響自己的工作環境&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>協作需求&lt;/td>
 &lt;td>團隊共用、需要 PR review&lt;/td>
 &lt;td>通常個人維護，PR review 是可選的&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這個平行不只是比喻。&lt;a href="https://tarrragon.github.io/blog/linux/dotfile/09-team-environment/" data-link-title="模組九：從個人到團隊" data-link-desc="個人 dotfile 管理的思想要延伸到團隊開發環境標準化時回來讀 — devcontainer、nix、商業環境配置管理">從個人到團隊&lt;/a>會教怎麼把 dotfile 的思想正式擴展到團隊環境——devcontainer 把「開發環境應該長什麼樣」寫成宣告式配置，讓新人 clone repo 就能拿到一致的開發環境，這正是 IaC 思想從組織 infra 往個人工作桌面延伸的具體產物。&lt;/p>
&lt;h2 id="dotfile-是重建指令不是備份">Dotfile 是重建指令，不是備份&lt;/h2>
&lt;p>這是最重要的心智模型區分。Dotfile repo 的目標不是「把舊電腦的所有檔案搬到新電腦」（那是備份工具的工作），而是「一份能在空白機器上重建工作環境的指令集」。&lt;/p>
&lt;p>這個思維跟 Docker 的哲學一致：Docker image 透過 Dockerfile「描述如何重建」環境，而不是「對一台跑著的伺服器拍快照」。Dotfile repo 也是——它記錄的是「你的環境應該長什麼樣」的宣告，不是「你的機器上現在有什麼」的快照。&lt;/p>
&lt;p>這個區分決定了 repo 裡該放什麼：&lt;/p>
&lt;ul>
&lt;li>放進去的：&lt;strong>宣告式的配置檔&lt;/strong>（shell config、editor config、WM config）、&lt;strong>套件清單&lt;/strong>（Brewfile、pacman list）、&lt;strong>安裝腳本&lt;/strong>（&lt;code>install.sh&lt;/code>，用來在新機器上自動化部署流程）。&lt;/li>
&lt;li>不放的：&lt;strong>暫存狀態&lt;/strong>（shell history、undo file、session file）、&lt;strong>generated 產物&lt;/strong>（plugin 的 compiled cache）、&lt;strong>大型二進位檔&lt;/strong>（字型檔案可以用套件管理器裝，不用放 repo）。&lt;/li>
&lt;/ul>
&lt;p>維持「重建指令」的純度，repo 才能保持輕量、diff 可讀、跨機器部署不會帶進不必要的狀態。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/infra/" data-link-title="Infra 基礎設施建置指南" data-link-desc="從零循序漸進把雲端基礎設施做起來 — IaC、身分憑證、網路地基、環境分離、核心服務、可觀測性、自動化 review 與治理習慣，含怎麼在組織內推動">Infra 基礎設施建置指南</a>教的是用 Terraform 或 OpenTofu 把雲端資源（VPC、IAM role、EC2 instance）寫成代碼，讓基礎設施可重現、可 review、可回滾。Dotfile 做的事在概念上完全平行：把個人工作環境（shell、editor、terminal、window manager）寫成代碼，達成同樣的可重現性。</p>
<h2 id="共用的核心原則">共用的核心原則</h2>
<ul>
<li><strong>宣告式</strong>：描述「環境應該長什麼樣」，而非「操作了哪些步驟」。Terraform 宣告「要有一個 VPC、CIDR 是 10.0.0.0/16」；dotfile 宣告「zsh 的 prompt 格式是這樣、alias ll 對應 ls -la」。</li>
<li><strong>版控下的變更歷史</strong>：誰改了什麼、什麼時候改的、為什麼改，都在 Git log 裡。環境出問題時可以回溯到「上一次正常的狀態」是哪個 commit。</li>
<li><strong>可 review</strong>：改了一個 shell function，diff 清楚可讀。跟在 terminal 裡直接 export 一個變數、下次重開就忘了相比，版控下的改動有跡可循。</li>
</ul>
<h2 id="差異">差異</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Infra IaC</th>
          <th>Dotfile</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>管理對象</td>
          <td>組織的雲端資源</td>
          <td>個人的工作桌面</td>
      </tr>
      <tr>
          <td>State 管理</td>
          <td>Remote backend + lock 機制（防並行衝突）</td>
          <td>通常只用 Git，沒有額外 state file</td>
      </tr>
      <tr>
          <td>生效方式</td>
          <td><code>terraform plan</code> → <code>terraform apply</code> 兩步</td>
          <td>多數改完 source 即生效，或重開 terminal 生效</td>
      </tr>
      <tr>
          <td>影響範圍</td>
          <td>改錯可能影響 production 服務</td>
          <td>改錯最多影響自己的工作環境</td>
      </tr>
      <tr>
          <td>協作需求</td>
          <td>團隊共用、需要 PR review</td>
          <td>通常個人維護，PR review 是可選的</td>
      </tr>
  </tbody>
</table>
<p>這個平行不只是比喻。<a href="/blog/linux/dotfile/09-team-environment/" data-link-title="模組九：從個人到團隊" data-link-desc="個人 dotfile 管理的思想要延伸到團隊開發環境標準化時回來讀 — devcontainer、nix、商業環境配置管理">從個人到團隊</a>會教怎麼把 dotfile 的思想正式擴展到團隊環境——devcontainer 把「開發環境應該長什麼樣」寫成宣告式配置，讓新人 clone repo 就能拿到一致的開發環境，這正是 IaC 思想從組織 infra 往個人工作桌面延伸的具體產物。</p>
<h2 id="dotfile-是重建指令不是備份">Dotfile 是重建指令，不是備份</h2>
<p>這是最重要的心智模型區分。Dotfile repo 的目標不是「把舊電腦的所有檔案搬到新電腦」（那是備份工具的工作），而是「一份能在空白機器上重建工作環境的指令集」。</p>
<p>這個思維跟 Docker 的哲學一致：Docker image 透過 Dockerfile「描述如何重建」環境，而不是「對一台跑著的伺服器拍快照」。Dotfile repo 也是——它記錄的是「你的環境應該長什麼樣」的宣告，不是「你的機器上現在有什麼」的快照。</p>
<p>這個區分決定了 repo 裡該放什麼：</p>
<ul>
<li>放進去的：<strong>宣告式的配置檔</strong>（shell config、editor config、WM config）、<strong>套件清單</strong>（Brewfile、pacman list）、<strong>安裝腳本</strong>（<code>install.sh</code>，用來在新機器上自動化部署流程）。</li>
<li>不放的：<strong>暫存狀態</strong>（shell history、undo file、session file）、<strong>generated 產物</strong>（plugin 的 compiled cache）、<strong>大型二進位檔</strong>（字型檔案可以用套件管理器裝，不用放 repo）。</li>
</ul>
<p>維持「重建指令」的純度，repo 才能保持輕量、diff 可讀、跨機器部署不會帶進不必要的狀態。</p>
]]></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>infra 的責任邊界、成熟度階梯與 day 1 鐵律</title><link>https://tarrragon.github.io/blog/infra/00-infra-mindset/infra-responsibility-maturity/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/00-infra-mindset/infra-responsibility-maturity/</guid><description>&lt;p>基礎設施（infrastructure，簡稱 infra）是承載應用程式的那層資源與規則：運算、網路、身分、儲存、可觀測性，以及定義它們如何被建立、變更、回收的治理機制。它的責任是讓應用程式有一個可被信任、可被重建、可被審計的執行環境。本篇建立的責任邊界、成熟度階梯與 day 1 鐵律，是後續所有 infra 模組共用的心智模型，其他章節會直接引用這裡定義的詞彙。&lt;/p>
&lt;h2 id="infra-的責任邊界">infra 的責任邊界&lt;/h2>
&lt;p>infra 承擔的是「應用程式之下、作業系統之上」那層共享資源的供應與治理。把責任拆成五個面向比較好對齊：每一面都有自己的失效模式，混在一起談會讓判斷失焦。&lt;/p>
&lt;h3 id="運算compute">運算（compute）&lt;/h3>
&lt;p>運算負責「程式跑在哪、用多少資源、怎麼擴縮」。它的衡量點是容量與彈性：流量尖峰時能不能長出更多實例、閒置時能不能縮回去省錢。一台手動開的 VM 也是運算資源，差別只在它是否被納入可重建的描述。&lt;/p>
&lt;p>運算涵蓋的光譜從 VM（EC2 instance）到容器（ECS task、Kubernetes pod）到 serverless function（Lambda）。抽象層級越高，infra 需要直接管理的細節越少——VM 要管 OS 更新與磁碟擴容，容器只需管映像與編排，serverless 幾乎只管程式碼與觸發條件。但抽象層級不改變運算的基本問題：它跑在什麼網路裡、用什麼身分存取其他資源、出了問題怎麼查。這些「接線」正是 infra 其他四個面向的職責。&lt;/p>
&lt;p>運算層常見的失效模式有兩類。第一類是容量不足：流量上來了但 auto-scaling 沒設或設錯，新實例來不及啟動就超時，表現為使用者端的 502 或延遲飆高。這類事故的排查路徑是先看 scaling policy 的觸發條件與 cooldown 是否跟真實流量匹配，再看運算節點的啟動時間是否在可接受的範圍內。第二類是殭屍資源：跑完的測試機器沒關，停掉的開發環境仍掛著 EBS volume，閒置著燒錢卻沒人發現。殭屍資源的判讀訊號是 CPU 使用率長期趨近於零且沒有對外連線——靠定期盤點加上 tag 過濾最能系統性地收斂，詳見&lt;a href="https://tarrragon.github.io/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣&lt;/a>。&lt;/p>
&lt;h3 id="網路network">網路（network）&lt;/h3>
&lt;p>誰能連到誰、流量走哪條路？這兩個問題的答案在網路層。VPC 切分、子網路、route table、security group 把可達性變成明確規則，而非預設全通。邊界沒畫清楚時，一個被入侵的服務就能橫向打穿整個環境。&lt;/p>
&lt;p>網路的失效模式分兩極。過度開放的代價是安全事故：一條 security group 入站規則寫成 &lt;code>0.0.0.0/0&lt;/code> 允許任何來源連到資料庫埠（5432、3306），等於把密碼驗證當作唯一防線，而暴力嘗試的掃描流量在公網上是持續的。意外隔離的代價是服務中斷：有人改了一條 route table 的預設路由，導致 private subnet 的服務失去出站能力——拉不到外部套件、連不上第三方 API，服務看起來在跑但功能全部退化。兩者在平時都不被注意，事故發生時才現形。排查網路問題的第一步通常是「這個封包走的那條路上，每一層有沒有放行」——route table → NACL → security group，逐層確認。網路地基的系統性設計在&lt;a href="https://tarrragon.github.io/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基&lt;/a>展開。&lt;/p>
&lt;h3 id="身分與憑證identity">身分與憑證（identity）&lt;/h3>
&lt;p>即使網路邊界畫得完美，一把權限過大的 access key 外洩了，攻擊者可以用 API 繞過所有網路規則直接操作資源——身分與憑證是五個面向中失守代價最高的一層。它的職責是讓人、服務、CI pipeline 各拿剛好夠用的權限（最小權限），並確保憑證有明確的生命週期。&lt;/p>
&lt;p>身分層的失效模式有兩類常見形態。權限擴散指的是一個 role 隨時間累積了遠超本職所需的權限——每次需求都加一條新的 action，卻從來沒人收斂已經用不到的舊權限。典型場景是一個 CI role 一開始只需要讀 S3、後來加了建 ECR image、再後來加了改 RDS parameter group，半年後這個 role 的 policy 有三十幾行 action，其中只有不到一半還在使用。憑證散落則指同一把 access key 被複製到越來越多地方——CI 環境變數、開發者筆電的 &lt;code>~/.aws/credentials&lt;/code>、某段部署腳本裡的 hardcode。每多一個副本就多一個外洩點，而外洩後的回退要找出所有副本同步輪替，這在手動環境裡幾乎做不到。這兩者的完整處理在&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>。&lt;/p>
&lt;h3 id="儲存storage">儲存（storage）&lt;/h3>
&lt;p>運算可以隨時重建，資料一旦遺失通常無法重來——這條分界線劃出了儲存層的職責。備份策略、版本保留、刪除保護構成儲存的三道防線，每一道都要在出事前就驗證過，而非事後才發現沒開。&lt;/p>
&lt;p>儲存涵蓋從物件儲存（S3）到區塊儲存（EBS）到受管資料庫（RDS）的底層磁碟。這些資源的共同特性是它們承載狀態，而狀態的失效模式跟運算不同——運算節點掛了重開一台就好，資料刪了就是刪了。具體的失效場景包括：一台 RDS 沒開刪除保護（deletion protection），有人清理開發資源時誤刪了 production 的資料庫；一個 S3 bucket 沒開 versioning，一段錯誤的腳本把整批物件覆寫成空內容，回不去了；一份 EBS snapshot 只保留了 3 天，周五出事、周一上班才發現，快照已經被自動清除。把刪除保護、備份保留天數、版本控制這些防線寫進 IaC，讓保護策略本身成為可審查、可追蹤的程式碼，是&lt;a href="https://tarrragon.github.io/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC&lt;/a> 的重點之一。&lt;/p>
&lt;h3 id="可觀測性observability">可觀測性（observability）&lt;/h3>
&lt;p>可觀測性負責「系統現在發生什麼、出事後查得到嗎」。它把 log、metric、trace 變成可查詢的事實來源。這層常被當成事後再補的附加品，但它和被它觀測的服務應該同生命週期一起建立。&lt;/p>
&lt;p>後補的可觀測性有一個結構性缺陷：出事之前沒有監控，代表出事當下最關鍵的那段資料不存在——知道服務「現在壞了」，但看不到「壞之前發生了什麼」。CPU 從什麼時候開始上升、錯誤率從哪個部署開始出現、某個 API 的延遲從什麼時候劣化——這些問題的答案需要連續的歷史資料，而歷史資料只能在事前就開始收集。另一個常見失效是 alarm 設了但通知沒有接到人：alarm 綁到一個 SNS topic，topic 的 subscription 是某個已停用的 email，值班工程師從頭到尾沒收到通知，直到使用者自己回報。可觀測性的 IaC 描述在&lt;a href="https://tarrragon.github.io/blog/infra/06-observability-logging/" data-link-title="模組六：可觀測性與 log 一併寫進 code" data-link-desc="log group、metric、alarm 跟基礎設施同生命週期管理，出事時追得到查得到">模組六：可觀測性與 log&lt;/a>。&lt;/p>
&lt;h3 id="五面的共同根源">五面的共同根源&lt;/h3>
&lt;p>這五面的共同點是：它們都不是應用功能，使用者看不到，但任何一面崩了，上面的功能全部跟著崩。這正是地基隱形的根源——它的價值只在缺席時被感知。&lt;/p>
&lt;h2 id="地基為什麼隱形">地基為什麼隱形&lt;/h2>
&lt;p>infra 的特性是「運作正常時完全不被感知，失效時才一次現形」。地基鋪得好的環境，工程師每天部署、擴縮、改設定，卻幾乎不會意識到底下有一層在支撐，因為它安靜地做對了每件事。這種隱形讓 infra 在資源排序上長期吃虧：看得見的功能有人催，看不見的地基沒人提。&lt;/p>
&lt;p>現形的時刻通常是環境失效的時刻，而且會在不同規模的團隊裡反覆出現——差別只在影響範圍。&lt;/p>
&lt;p>沒有描述檔的資源在需要重建時，必須從 Console 逐頁反推它的設定——屬於哪個 VPC、掛了哪些 security group、用了什麼 IAM role。這些資訊散落在不同頁面，拼湊一個資源的完整設定要半天，而且每個找到的設定都帶著「不確定是不是還有漏掉的」疑慮。&lt;/p></description><content:encoded><![CDATA[<p>基礎設施（infrastructure，簡稱 infra）是承載應用程式的那層資源與規則：運算、網路、身分、儲存、可觀測性，以及定義它們如何被建立、變更、回收的治理機制。它的責任是讓應用程式有一個可被信任、可被重建、可被審計的執行環境。本篇建立的責任邊界、成熟度階梯與 day 1 鐵律，是後續所有 infra 模組共用的心智模型，其他章節會直接引用這裡定義的詞彙。</p>
<h2 id="infra-的責任邊界">infra 的責任邊界</h2>
<p>infra 承擔的是「應用程式之下、作業系統之上」那層共享資源的供應與治理。把責任拆成五個面向比較好對齊：每一面都有自己的失效模式，混在一起談會讓判斷失焦。</p>
<h3 id="運算compute">運算（compute）</h3>
<p>運算負責「程式跑在哪、用多少資源、怎麼擴縮」。它的衡量點是容量與彈性：流量尖峰時能不能長出更多實例、閒置時能不能縮回去省錢。一台手動開的 VM 也是運算資源，差別只在它是否被納入可重建的描述。</p>
<p>運算涵蓋的光譜從 VM（EC2 instance）到容器（ECS task、Kubernetes pod）到 serverless function（Lambda）。抽象層級越高，infra 需要直接管理的細節越少——VM 要管 OS 更新與磁碟擴容，容器只需管映像與編排，serverless 幾乎只管程式碼與觸發條件。但抽象層級不改變運算的基本問題：它跑在什麼網路裡、用什麼身分存取其他資源、出了問題怎麼查。這些「接線」正是 infra 其他四個面向的職責。</p>
<p>運算層常見的失效模式有兩類。第一類是容量不足：流量上來了但 auto-scaling 沒設或設錯，新實例來不及啟動就超時，表現為使用者端的 502 或延遲飆高。這類事故的排查路徑是先看 scaling policy 的觸發條件與 cooldown 是否跟真實流量匹配，再看運算節點的啟動時間是否在可接受的範圍內。第二類是殭屍資源：跑完的測試機器沒關，停掉的開發環境仍掛著 EBS volume，閒置著燒錢卻沒人發現。殭屍資源的判讀訊號是 CPU 使用率長期趨近於零且沒有對外連線——靠定期盤點加上 tag 過濾最能系統性地收斂，詳見<a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>。</p>
<h3 id="網路network">網路（network）</h3>
<p>誰能連到誰、流量走哪條路？這兩個問題的答案在網路層。VPC 切分、子網路、route table、security group 把可達性變成明確規則，而非預設全通。邊界沒畫清楚時，一個被入侵的服務就能橫向打穿整個環境。</p>
<p>網路的失效模式分兩極。過度開放的代價是安全事故：一條 security group 入站規則寫成 <code>0.0.0.0/0</code> 允許任何來源連到資料庫埠（5432、3306），等於把密碼驗證當作唯一防線，而暴力嘗試的掃描流量在公網上是持續的。意外隔離的代價是服務中斷：有人改了一條 route table 的預設路由，導致 private subnet 的服務失去出站能力——拉不到外部套件、連不上第三方 API，服務看起來在跑但功能全部退化。兩者在平時都不被注意，事故發生時才現形。排查網路問題的第一步通常是「這個封包走的那條路上，每一層有沒有放行」——route table → NACL → security group，逐層確認。網路地基的系統性設計在<a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>展開。</p>
<h3 id="身分與憑證identity">身分與憑證（identity）</h3>
<p>即使網路邊界畫得完美，一把權限過大的 access key 外洩了，攻擊者可以用 API 繞過所有網路規則直接操作資源——身分與憑證是五個面向中失守代價最高的一層。它的職責是讓人、服務、CI pipeline 各拿剛好夠用的權限（最小權限），並確保憑證有明確的生命週期。</p>
<p>身分層的失效模式有兩類常見形態。權限擴散指的是一個 role 隨時間累積了遠超本職所需的權限——每次需求都加一條新的 action，卻從來沒人收斂已經用不到的舊權限。典型場景是一個 CI role 一開始只需要讀 S3、後來加了建 ECR image、再後來加了改 RDS parameter group，半年後這個 role 的 policy 有三十幾行 action，其中只有不到一半還在使用。憑證散落則指同一把 access key 被複製到越來越多地方——CI 環境變數、開發者筆電的 <code>~/.aws/credentials</code>、某段部署腳本裡的 hardcode。每多一個副本就多一個外洩點，而外洩後的回退要找出所有副本同步輪替，這在手動環境裡幾乎做不到。這兩者的完整處理在<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>。</p>
<h3 id="儲存storage">儲存（storage）</h3>
<p>運算可以隨時重建，資料一旦遺失通常無法重來——這條分界線劃出了儲存層的職責。備份策略、版本保留、刪除保護構成儲存的三道防線，每一道都要在出事前就驗證過，而非事後才發現沒開。</p>
<p>儲存涵蓋從物件儲存（S3）到區塊儲存（EBS）到受管資料庫（RDS）的底層磁碟。這些資源的共同特性是它們承載狀態，而狀態的失效模式跟運算不同——運算節點掛了重開一台就好，資料刪了就是刪了。具體的失效場景包括：一台 RDS 沒開刪除保護（deletion protection），有人清理開發資源時誤刪了 production 的資料庫；一個 S3 bucket 沒開 versioning，一段錯誤的腳本把整批物件覆寫成空內容，回不去了；一份 EBS snapshot 只保留了 3 天，周五出事、周一上班才發現，快照已經被自動清除。把刪除保護、備份保留天數、版本控制這些防線寫進 IaC，讓保護策略本身成為可審查、可追蹤的程式碼，是<a href="/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a> 的重點之一。</p>
<h3 id="可觀測性observability">可觀測性（observability）</h3>
<p>可觀測性負責「系統現在發生什麼、出事後查得到嗎」。它把 log、metric、trace 變成可查詢的事實來源。這層常被當成事後再補的附加品，但它和被它觀測的服務應該同生命週期一起建立。</p>
<p>後補的可觀測性有一個結構性缺陷：出事之前沒有監控，代表出事當下最關鍵的那段資料不存在——知道服務「現在壞了」，但看不到「壞之前發生了什麼」。CPU 從什麼時候開始上升、錯誤率從哪個部署開始出現、某個 API 的延遲從什麼時候劣化——這些問題的答案需要連續的歷史資料，而歷史資料只能在事前就開始收集。另一個常見失效是 alarm 設了但通知沒有接到人：alarm 綁到一個 SNS topic，topic 的 subscription 是某個已停用的 email，值班工程師從頭到尾沒收到通知，直到使用者自己回報。可觀測性的 IaC 描述在<a href="/blog/infra/06-observability-logging/" data-link-title="模組六：可觀測性與 log 一併寫進 code" data-link-desc="log group、metric、alarm 跟基礎設施同生命週期管理，出事時追得到查得到">模組六：可觀測性與 log</a>。</p>
<h3 id="五面的共同根源">五面的共同根源</h3>
<p>這五面的共同點是：它們都不是應用功能，使用者看不到，但任何一面崩了，上面的功能全部跟著崩。這正是地基隱形的根源——它的價值只在缺席時被感知。</p>
<h2 id="地基為什麼隱形">地基為什麼隱形</h2>
<p>infra 的特性是「運作正常時完全不被感知，失效時才一次現形」。地基鋪得好的環境，工程師每天部署、擴縮、改設定，卻幾乎不會意識到底下有一層在支撐，因為它安靜地做對了每件事。這種隱形讓 infra 在資源排序上長期吃虧：看得見的功能有人催，看不見的地基沒人提。</p>
<p>現形的時刻通常是環境失效的時刻，而且會在不同規模的團隊裡反覆出現——差別只在影響範圍。</p>
<p>沒有描述檔的資源在需要重建時，必須從 Console 逐頁反推它的設定——屬於哪個 VPC、掛了哪些 security group、用了什麼 IAM role。這些資訊散落在不同頁面，拼湊一個資源的完整設定要半天，而且每個找到的設定都帶著「不確定是不是還有漏掉的」疑慮。</p>
<p>一次安全稽核要求列出所有對外開放的連接埠，才發現 security group 散落在三個帳號、沒人說得清哪條規則還有用。有些規則是兩年前為了某個已經下線的服務開的，但沒人敢刪——萬一那條規則還被某個看不到的服務依賴呢？稽核結果是「我們列出了 37 條規則，其中 12 條無法確認是否仍在使用」。</p>
<p>一台資料庫磁碟滿了要擴容，才發現它從來沒進過任何納管流程。改它的 instance class 或磁碟大小，在 Console 上操作意味著可能觸發重啟，而這台資料庫是 production 唯一的寫入端點。操作時無法預測影響範圍，因為沒有可對照的描述檔；不操作則等著服務因為磁碟寫不進去而停擺。</p>
<p>這些場景有一個共同的累積模式：每一次「這次先手動救」的決定本身是合理的——救火當下沒有時間走流程。問題在於這些決定的殘留會堆疊。手動改了一條 security group 但沒記錄，下一個月又手動改了另一條，半年後沒人說得清哪些規則是原始設計、哪些是臨時補丁。每一次救火都在增加下一次排查的成本，而這個成本在平時完全隱形，只在下一次事故裡一次性浮現。</p>
<p>隱形債務的徵兆很直接：當團隊開始用這些語言描述某項資源，債就已經在累積——「不敢動那台機器」代表依賴關係不可見；「只有某某知道怎麼改」代表知識沒有沉澱在程式碼裡；「上次碰它好像出過事」代表變更缺乏 review 與回退機制；「那個先別管，能跑就好」代表技術債被刻意延後、沒有 tripwire。</p>
<p>地基的價值無法在平順時被看見，只能在它缺席的代價裡被回推，所以它需要一條和功能不同的論證路徑——這條路徑怎麼用商業語言講給上層聽，是<a href="/blog/infra/09-driving-adoption/" data-link-title="模組九：怎麼把 infra 推動起來" data-link-desc="技術正確不等於推得動 — 信任赤字、期望值對齊、知識共享，infra 落地的組織課題">模組九：怎麼把 infra 推動起來</a>的主題。</p>
<h2 id="day-1-鋪地基與事後補的成本差">day 1 鋪地基與事後補的成本差</h2>
<p>在資源剛開始長出來時就用程式碼描述它，和等環境長大後再回頭納管，兩者的成本差距是非線性的。早期鋪地基的成本接近固定：寫一份描述檔、建一個 state、設一條 pipeline，環境只有三五個資源時這些都很輕。事後補的成本則隨資源數量、相互依賴與「不確定能不能動」的恐懼一起放大。</p>
<p>事後納管的痛具體長這樣：一個手動建出來的資源要納入 IaC，得先把它當前的真實狀態完整反推成程式碼（import）。這個過程要逐欄比對 Console 上的設定——一個 RDS instance 的 parameter group、backup retention、storage type、multi-AZ 設定，Console 上看到什麼 HCL 裡就得寫什麼，漏一個欄位下次 apply 就可能把線上設定改掉。資源彼此有依賴時，納管順序也得排——一個 security group 引用另一個 security group 作為 source，兩個都還沒進 IaC 時，要決定哪個先 import、程式碼怎麼暫時處理另一個的引用。當這些手動資源還是線上服務正在用的，整個納管過程等於在開著的引擎上換零件。</p>
<p>import 之後的第一次 <code>plan</code> 是真正的考驗。如果 HCL 跟雲端現實有任何落差——哪怕只是一個 tag 的大小寫不同、或某個欄位在 Console 上有預設值但 HCL 裡沒寫——plan 會把那些落差列為需要修改的變更。在 stateless 資源上這只是小修正，在 production 的 RDS 上如果 plan 判定需要 replace（先刪後建），那就是一個會造成資料遺失的操作，必須在 apply 之前被攔截。手動環境累積的資源越多，這類 plan 裡的「驚喜」越多，整理每一個驚喜都要時間和注意力。這就是事後補的成本隨時間複利的具體機制。</p>
<p>務實的判準不是「day 1 就把所有東西寫成完美的 IaC」，而是「day 1 就讓新長出來的資源預設走可重建的路徑」。多數早期環境合理的選擇是讓地基類資源（網路、身分、state 本身）從一開始就在程式碼裡，而把還在高速試錯的應用層資源留一點手動彈性，等形狀穩定再納管。</p>
<p>哪些資源屬於「地基類」的判斷依據是回頭改的代價。VPC 的 CIDR 一旦確定、裡面的 subnet 都分配出去了，要改地址範圍幾乎等於重建整個網路。IAM 的 role 和 policy 一旦被多個服務引用，改動任一條的影響範圍是整個授權模型。state 後端的 bucket 和 lock table 如果第一天沒設好、用了本地 state，後續要搬到 remote backend 要處理 state migration——而 state 搬遷失敗可能讓工具失去對所有資源的記憶。這類地基的回頭成本是階梯式的（一旦長歪就很貴）。應用層資源的回頭成本是線性到多項式的（越晚越貴但不至於一步跳崖）。差別在於：前者的回頭成本固定，後者隨時間複利。<a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一：最小可行 IaC</a> 會示範這條最小路徑怎麼落地。</p>
<h2 id="成熟度階梯">成熟度階梯</h2>
<p>infra 的成熟度可以排成一條從「全手動」到「全程式碼治理」的階梯，每一階用「資源怎麼被建立與變更」來定義。這條階梯是全系列共用的座標：後續模組描述某個能力時，會說它對應到哪一階，所以這裡先把刻度釘清楚。</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>名稱</th>
          <th>資源怎麼被建立</th>
          <th>真實狀態的來源</th>
          <th>對應模組</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>0</td>
          <td>Console 手動</td>
          <td>在網頁介面點選建立</td>
          <td>只存在於雲端，無描述</td>
          <td><a href="/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一</a></td>
      </tr>
      <tr>
          <td>1</td>
          <td>腳本化</td>
          <td>用 CLI 或腳本建立</td>
          <td>腳本，但無狀態追蹤</td>
          <td>—</td>
      </tr>
      <tr>
          <td>2</td>
          <td>宣告式 IaC</td>
          <td>寫描述檔、由工具 apply</td>
          <td>state 檔記錄已建資源</td>
          <td><a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一</a></td>
      </tr>
      <tr>
          <td>3</td>
          <td>環境分離</td>
          <td>同一份模組套用多環境</td>
          <td>各環境獨立 state</td>
          <td><a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四</a></td>
      </tr>
      <tr>
          <td>4</td>
          <td>PR 流程治理</td>
          <td>變更走 PR、CI 自動 plan</td>
          <td>state + 版控歷史 + 審查紀錄</td>
          <td><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></td>
      </tr>
  </tbody>
</table>
<h3 id="第-0-階console-手動">第 0 階：Console 手動</h3>
<p>所有環境的起點，也是該優先離開的一階。特徵是真實狀態只存在雲端，沒有任何離線描述，所以無法 review、無法重建、無法回答「這個環境長什麼樣」。它不是錯誤的起點，是還沒鋪地基的起點。</p>
<p>問自己兩個問題：「我們的 VPC 長什麼樣」能不能不打開 Console 就回答？「上一次 security group 什麼時候改過」能不能不翻 CloudTrail 就查到？兩題都要靠手動查，就還在第零階。停在這一階的環境怎麼盡量做好，見<a href="/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一：還沒有 infra 的手動環境</a>。</p>
<h3 id="第-1-階腳本化">第 1 階：腳本化</h3>
<p>把建立動作寫成 CLI 或 shell 腳本，比手動可重複，但腳本只描述「怎麼建」，不追蹤「現在有什麼」。重跑同一支腳本可能重複建立或報錯，因為它不知道資源已經存在。</p>
<p>這一階的常見陷阱是誤以為「有腳本就等於有 IaC」。差別在狀態這塊地基——一份 <code>setup.sh</code> 能把環境從零建起來，但它回答不了「跑完後環境裡有哪些資源」「哪些資源是這個腳本建的、哪些是之前手動建的」「如果腳本裡的設定改了，下次重跑會不會把現有資源改壞」。這些都是 state 要解的問題。辨認自己在哪一階的方式是試一次：刪掉某個資源後重跑腳本，能自動把它補回來而不影響其他資源，那就已經在接近第 2 階的行為；重跑會報「already exists」錯誤或重複建立，就還在第 1 階。</p>
<h3 id="第-2-階宣告式-iac">第 2 階：宣告式 IaC</h3>
<p>地基真正成形的一階：用 Terraform / OpenTofu 這類工具寫下「環境應該長什麼樣」，工具負責比對現況與描述、算出差異再套用。state 檔在這裡誕生，成為「目前納管了哪些資源」的事實來源。</p>
<h3 id="怎麼知道自己在第-2-階">怎麼知道自己在第 2 階</h3>
<p>試回答一個問題：能不能從程式碼把整個環境在另一個帳號重建出來？「可以，apply 一次就好」代表 IaC 覆蓋率足夠。「大部分可以，但有些東西還是要手動補」——那些手動補的部分就是下一批該 import 的資源。另一個觀察角度：跑 <code>terraform plan</code> 時如果出現大量 drift（state 與現實不符），代表有人繞過 IaC 直接在 Console 改東西，Console 唯讀紀律在鬆動。工具選型與 state 管理的具體做法在<a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一：最小可行 IaC</a>。</p>
<h3 id="第-3-階環境分離">第 3 階：環境分離</h3>
<p>把同一份描述模組化，套用到 dev / staging / production 等多個環境，各自獨立 state。它解決的問題是「在 staging 驗證過的變更，能用同一套描述安全地推到 production」。</p>
<p>判讀訊號：dev 和 prod 的設定差異是否全部表達在參數裡、還是散落在不同的 code 分支中。如果 prod 目錄裡有一段 dev 目錄沒有的 code，那段 code 就是從來沒在低環境驗證過的生產設定——這是漂移的起點。另一個訊號：如果部署到 staging 和部署到 production 走的是兩條不同的 pipeline 或手動流程，代表環境分離只做了一半。完整切法在<a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>。</p>
<h3 id="第-4-階pr-流程治理">第 4 階：PR 流程治理</h3>
<p>把 infra 變更接上和應用程式碼相同的協作流程：變更走 pull request，CI 自動跑 plan 把預期差異貼上來，人審查後才 apply。到這一階，infra 的每次變更都有提案、審查、歷史與回退點。</p>
<p>用兩個問題定位：任意一次 infra 變更，能不能在 git log 裡找到對應的 PR、看到 plan 輸出、知道誰 review 的？如果某些變更是直接在 main 上 push 的、或是某人在本地 apply 的，代表流程有漏洞。更進一步：主要負責 infra 的人請假時，其他人能不能只靠讀 repo 就理解現狀並安全地改一個小設定？完整的治理護欄在<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>。</p>
<h3 id="階梯不是單向命令">階梯不是單向命令</h3>
<p>這條階梯是一把對齊現況的尺，用來判斷某項資源該停在哪一階，不是越高越好的單向命令。停在哪一階的依據是務實節奏——一個只有三個人、五個資源的早期團隊，強上第四階的 PR 流程，review 成本可能超過它擋下的風險。反過來，一個已經有二十個人在改 infra 的團隊，停在第二階不走 PR，就是在賭每次 apply 都不會出錯。</p>
<h2 id="早期新創的務實節奏">早期新創的務實節奏</h2>
<p>早期團隊的合理目標是「地基類資源先上到階梯第 2 階，應用層資源容許暫時留在低階」，而不是一步衝到第 4 階。資源有限、需求還在劇烈變動的階段，把全部資源都套上完整治理流程，收益正的機率不高——治理的固定成本會壓到本來就稀缺的開發頻寬。</p>
<p>判斷節奏的依據是「這項資源的形狀穩不穩、動它的代價高不高」：</p>
<table>
  <thead>
      <tr>
          <th>資源類型</th>
          <th>形狀穩定度</th>
          <th>改錯代價</th>
          <th>判準</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>VPC / subnet</td>
          <td>高</td>
          <td>極高</td>
          <td>day 1 進 IaC</td>
      </tr>
      <tr>
          <td>IAM role / policy</td>
          <td>高</td>
          <td>極高</td>
          <td>day 1 進 IaC</td>
      </tr>
      <tr>
          <td>state backend</td>
          <td>高</td>
          <td>極高</td>
          <td>day 1 進 IaC</td>
      </tr>
      <tr>
          <td>RDS（已穩定的）</td>
          <td>中高</td>
          <td>極高</td>
          <td>形狀確定後立刻進</td>
      </tr>
      <tr>
          <td>對外 LB</td>
          <td>中</td>
          <td>高</td>
          <td>開始有流量就進</td>
      </tr>
      <tr>
          <td>應用層 EC2 / ECS</td>
          <td>低到中</td>
          <td>中</td>
          <td>開始被依賴或第二人要改時進</td>
      </tr>
      <tr>
          <td>測試用臨時資源</td>
          <td>低</td>
          <td>低</td>
          <td>可以留在手動，設 tag 方便清理</td>
      </tr>
  </tbody>
</table>
<h3 id="day-1-鐵律">day 1 鐵律</h3>
<p>網路拓撲、身分權限、state 後端這三類地基資源，一旦長歪回頭改的代價極高，值得 day 1 就進 IaC——這是少數接近「該照做」的硬判準，因為它牽涉安全邊界：</p>
<ul>
<li><strong>VPC / subnet</strong>：CIDR 一旦確定、subnet 分配出去，改地址範圍幾乎等於重建整個網路（見<a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三</a>）</li>
<li><strong>IAM role / policy</strong>：權限模型被多個服務引用後，改動任一條的影響範圍是整個授權體系（見<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二</a>）</li>
<li><strong>state backend</strong>：state 的存放位置與鎖機制如果第一天沒設好，後續 state migration 失敗可能讓工具失去對所有資源的記憶（見<a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一</a>）</li>
</ul>
<p>反過來，一個還在每週改三次規格的功能用的運算資源，過早凍進嚴格流程反而拖慢試錯。這時容許它手動，但設一條 tripwire：當它開始被線上流量依賴、或開始有第二個人需要改它時，就是把它納管的時機。</p>
<p>tripwire 的操作方式是在建立資源時就決定「觸發納管的條件」，而非等到某天靈感來了才想到要 import。例如：一台跑開發用途的 EC2，建立時在內部文件標記「當這台開始接 staging 或 production 流量時納管」；一個 S3 bucket 正在測試用，標記「當開始存正式用戶上傳的檔案時納管」。tripwire 讓「什麼時候該進 IaC」變成一個可追蹤的條件，而非一個持續被拖延的意願。</p>
<h3 id="兩個反向誤判">兩個反向誤判</h3>
<p>過度設計和放任手動是這個階段的兩個反向誤判。</p>
<p>過度設計的訊號：環境只有五個資源，卻已經有多層抽象模組和還用不到的多環境結構，維護抽象的時間比省下的時間多。常見的觸發是照搬最佳實踐文章的全部教條——三層 module 嵌套、Terragrunt 全家桶、每個資源都有 <code>for_each</code>——結果團隊裡只有一個人看得懂這套結構。對這類過度設計的自測是：「如果今天不做這個抽象，三個月後補的成本是多少？」如果答案是花一小時就能補，那就三個月後再說。</p>
<p>放任手動的訊號：每次有人問「這個怎麼建的」都只能去翻某個人的記憶，地基債務在無聲累積。放任手動的常見藉口是「我們還在早期、先把功能做出來再說」——這句話在創業前三個月合理，但如果三個月後還在這麼說、而環境已經有二十個資源、三個人在改，債就開始複利了。</p>
<p>務實節奏就是在這兩者之間，讓地基先穩、讓應用層保留試錯彈性，再隨著形狀固定逐項往階梯上推。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一：還沒有 infra 的手動環境</a>：階梯第 0 階的環境怎麼盡量做好</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>：地基資源跨上成熟度階梯第 2 階的最小路徑</li>
<li>→ <a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>：身分層的權限收斂與憑證生命週期</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>：網路層的隔離、路由與 security group 設計</li>
<li>→ <a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>：成熟度階梯第 3 階的切法</li>
<li>→ <a href="/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a>：運算與儲存資源的 IaC 描述</li>
<li>→ <a href="/blog/infra/06-observability-logging/" data-link-title="模組六：可觀測性與 log 一併寫進 code" data-link-desc="log group、metric、alarm 跟基礎設施同生命週期管理，出事時追得到查得到">模組六：可觀測性與 log</a>：可觀測性同生命週期管理</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>：成熟度階梯第 4 階的治理護欄</li>
<li>→ <a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>：殭屍資源盤點與 tagging 規範</li>
<li>→ <a href="/blog/infra/09-driving-adoption/" data-link-title="模組九：怎麼把 infra 推動起來" data-link-desc="技術正確不等於推得動 — 信任赤字、期望值對齊、知識共享，infra 落地的組織課題">模組九：怎麼把 infra 推動起來</a>：地基的價值怎麼用商業語言講給上層聽</li>
</ul>
]]></content:encoded></item><item><title>運算平台上 IaC — ECS 與 EKS</title><link>https://tarrragon.github.io/blog/infra/05-core-services/compute-ecs-eks/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/05-core-services/compute-ecs-eks/</guid><description>&lt;p>運算是業務程式碼的執行載體。infra 這層描述的是「運算容量與接線」— 它跑在哪些 subnet、套用哪個 IAM role、掛到哪個 load balancer 的 target group、以及容量怎麼隨負載擴縮。實際跑什麼版本的程式碼由部署流程決定，這個邊界讓 infra 變更與應用發布各走各的節奏 — infra apply 不會因此改動映像，部署 pipeline 不會因此改動 subnet。&lt;/p>
&lt;p>核心服務的部署順序由依賴方向決定（被依賴的先建），運算在這個&lt;a href="https://tarrragon.github.io/blog/infra/05-core-services/deployment-order-database/" data-link-title="部署順序與資料庫上 IaC" data-link-desc="核心服務的依賴圖決定部署順序，資料庫作為第一批上層服務需要最謹慎的 IaC 描述 — 涵蓋 RDS 接線、連線管理、read replica 與端點暴露">四層依賴結構&lt;/a>裡位於第三層：它引用底層的 subnet、security group 與 IAM role，同時被上層的 load balancer target group 引用。所以運算資源的 IaC 定義裡，subnet ID、security group ID、IAM role ARN 都應該是引用而非硬編碼 — 底層重建時上層才會自動跟上。&lt;/p>
&lt;h2 id="ecs-vs-eks-選型">ECS vs EKS 選型&lt;/h2>
&lt;p>ECS 與 EKS 都能跑容器，差異在控制平面的維運模型與生態適配。選型看的是團隊能力與業務需求，而非功能多寡 — 兩者都能達成「容器跑在私有 subnet、用 IAM role 存取資源、掛到 ALB 接收流量」這個基本目標。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>ECS&lt;/th>
 &lt;th>EKS&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>控制平面維運&lt;/td>
 &lt;td>AWS 完全代管&lt;/td>
 &lt;td>AWS 代管 API server，附加元件自行管理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>學習曲線&lt;/td>
 &lt;td>低（AWS 原生概念）&lt;/td>
 &lt;td>高（Kubernetes 生態）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨雲可攜&lt;/td>
 &lt;td>低（AWS 專屬）&lt;/td>
 &lt;td>高（Kubernetes 標準）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>IaC 工具鏈&lt;/td>
 &lt;td>全部用 Terraform AWS provider&lt;/td>
 &lt;td>Terraform 建 cluster，workload 走 Helm&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>適合場景&lt;/td>
 &lt;td>AWS 單雲、團隊無 K8s 經驗&lt;/td>
 &lt;td>已有 K8s 能力或需要其生態時&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>ECS 的控制平面由 AWS 代管，service、task definition、target group 都是 AWS 原生資源，Terraform 的 provider 直接描述，心智負擔低。它的 Fargate 啟動類型更進一步 — 連 EC2 instance 都不用管，只描述 task 要多少 CPU 和記憶體，AWS 負責排程到底層主機。&lt;/p>
&lt;p>EKS 的控制平面是受管的 Kubernetes，IaC 描述的是 cluster 本身與 node group，workload（Deployment、Service）則走 Kubernetes manifest 或 Helm chart。這代表 infra 工具鏈跨越了 Terraform 與 Kubernetes 兩套系統 — Terraform 負責 cluster 基礎設施，kubectl / Helm 負責工作負載，兩者的 state 與變更流程是分開的。&lt;/p>
&lt;p>團隊已有 Kubernetes 能力或需要其生態（service mesh、自訂排程器、多雲部署、社群的 operator 生態）時，EKS 的複雜度才值得承擔。否則 ECS 的低負擔是預設起點。一個自測方式：團隊選了 EKS 但只用到最基本的 Deployment + Service，沒有碰 service mesh、CRD 或跨雲，那等於承擔了 Kubernetes 的維運成本卻沒用到它的回報——退回 ECS 通常更合理。&lt;/p>
&lt;h3 id="fargate-vs-ec2-launch-type">Fargate vs EC2 launch type&lt;/h3>
&lt;p>ECS 的執行模式再分 EC2 launch type 和 Fargate launch type。EC2 launch type 需要自己管理 EC2 instance 組成的 capacity provider — AMI 更新、instance 擴縮、OS 層安全修補都是團隊的責任。Fargate 由 AWS 代管運算實例，不需要配 capacity provider、不需要管 AMI，進一步降低運維面。&lt;/p></description><content:encoded><![CDATA[<p>運算是業務程式碼的執行載體。infra 這層描述的是「運算容量與接線」— 它跑在哪些 subnet、套用哪個 IAM role、掛到哪個 load balancer 的 target group、以及容量怎麼隨負載擴縮。實際跑什麼版本的程式碼由部署流程決定，這個邊界讓 infra 變更與應用發布各走各的節奏 — infra apply 不會因此改動映像，部署 pipeline 不會因此改動 subnet。</p>
<p>核心服務的部署順序由依賴方向決定（被依賴的先建），運算在這個<a href="/blog/infra/05-core-services/deployment-order-database/" data-link-title="部署順序與資料庫上 IaC" data-link-desc="核心服務的依賴圖決定部署順序，資料庫作為第一批上層服務需要最謹慎的 IaC 描述 — 涵蓋 RDS 接線、連線管理、read replica 與端點暴露">四層依賴結構</a>裡位於第三層：它引用底層的 subnet、security group 與 IAM role，同時被上層的 load balancer target group 引用。所以運算資源的 IaC 定義裡，subnet ID、security group ID、IAM role ARN 都應該是引用而非硬編碼 — 底層重建時上層才會自動跟上。</p>
<h2 id="ecs-vs-eks-選型">ECS vs EKS 選型</h2>
<p>ECS 與 EKS 都能跑容器，差異在控制平面的維運模型與生態適配。選型看的是團隊能力與業務需求，而非功能多寡 — 兩者都能達成「容器跑在私有 subnet、用 IAM role 存取資源、掛到 ALB 接收流量」這個基本目標。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>ECS</th>
          <th>EKS</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>控制平面維運</td>
          <td>AWS 完全代管</td>
          <td>AWS 代管 API server，附加元件自行管理</td>
      </tr>
      <tr>
          <td>學習曲線</td>
          <td>低（AWS 原生概念）</td>
          <td>高（Kubernetes 生態）</td>
      </tr>
      <tr>
          <td>跨雲可攜</td>
          <td>低（AWS 專屬）</td>
          <td>高（Kubernetes 標準）</td>
      </tr>
      <tr>
          <td>IaC 工具鏈</td>
          <td>全部用 Terraform AWS provider</td>
          <td>Terraform 建 cluster，workload 走 Helm</td>
      </tr>
      <tr>
          <td>適合場景</td>
          <td>AWS 單雲、團隊無 K8s 經驗</td>
          <td>已有 K8s 能力或需要其生態時</td>
      </tr>
  </tbody>
</table>
<p>ECS 的控制平面由 AWS 代管，service、task definition、target group 都是 AWS 原生資源，Terraform 的 provider 直接描述，心智負擔低。它的 Fargate 啟動類型更進一步 — 連 EC2 instance 都不用管，只描述 task 要多少 CPU 和記憶體，AWS 負責排程到底層主機。</p>
<p>EKS 的控制平面是受管的 Kubernetes，IaC 描述的是 cluster 本身與 node group，workload（Deployment、Service）則走 Kubernetes manifest 或 Helm chart。這代表 infra 工具鏈跨越了 Terraform 與 Kubernetes 兩套系統 — Terraform 負責 cluster 基礎設施，kubectl / Helm 負責工作負載，兩者的 state 與變更流程是分開的。</p>
<p>團隊已有 Kubernetes 能力或需要其生態（service mesh、自訂排程器、多雲部署、社群的 operator 生態）時，EKS 的複雜度才值得承擔。否則 ECS 的低負擔是預設起點。一個自測方式：團隊選了 EKS 但只用到最基本的 Deployment + Service，沒有碰 service mesh、CRD 或跨雲，那等於承擔了 Kubernetes 的維運成本卻沒用到它的回報——退回 ECS 通常更合理。</p>
<h3 id="fargate-vs-ec2-launch-type">Fargate vs EC2 launch type</h3>
<p>ECS 的執行模式再分 EC2 launch type 和 Fargate launch type。EC2 launch type 需要自己管理 EC2 instance 組成的 capacity provider — AMI 更新、instance 擴縮、OS 層安全修補都是團隊的責任。Fargate 由 AWS 代管運算實例，不需要配 capacity provider、不需要管 AMI，進一步降低運維面。</p>
<p>Fargate 的代價是三個面向：單位成本較高（同規格的 vCPU/記憶體比 EC2 貴約 20-40%）、不支援 GPU workload、啟動延遲稍長（cold start 約 30-60 秒，EC2 已有 instance 時近乎即時）。多數 web API 和非 GPU 的背景工作的初始選擇是 Fargate — 省掉的運維時間通常抵得過溢價。流量穩定且需要成本最佳化時再切回 EC2 launch type，屆時增加的是 capacity provider 的設定與 instance 管理。量級參考：一個持續運行 2 vCPU / 4GB 的 Fargate task 月費約 $70，同規格 EC2 t3.medium 約 $30。月費差距在服務數量少時不顯著，當 task 數量超過 10-20 個且流量穩定時，切回 EC2 launch type 的節省量才值得投入切換工程。</p>
<p>後續 HCL 範例以 ECS Fargate 示意，EKS 的接線骨架（subnet、IAM、target group）相近，差異落在編排層的資源類型。</p>
<h2 id="task-definition描述容器規格與接線">Task definition：描述容器規格與接線</h2>
<p>Task definition 是 ECS 描述「一個工作單元長什麼樣」的宣告：要跑哪個容器映像、給多少 CPU 和記憶體、開哪些 port、用哪個 IAM role、log 送到哪裡。它是運算 IaC 的核心資源。</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_ecs_task_definition&#34; &#34;api&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  family</span>                   <span class="o">=</span> <span class="s2">&#34;api-${var.env}&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  requires_compatibilities</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;FARGATE&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  network_mode</span>             <span class="o">=</span> <span class="s2">&#34;awsvpc&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">  cpu</span>                      <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">task_cpu</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  memory</span>                   <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">task_memory</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">  execution_role_arn</span>       <span class="o">=</span> <span class="k">aws_iam_role</span><span class="p">.</span><span class="k">ecs_execution</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">  task_role_arn</span>            <span class="o">=</span> <span class="k">aws_iam_role</span><span class="p">.</span><span class="k">api_task</span><span class="p">.</span><span class="k">arn</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 class="n">  container_definitions</span> <span class="o">=</span> <span class="k">jsonencode</span><span class="p">([</span>{
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">    name</span>  <span class="o">=</span> <span class="s2">&#34;api&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">    image</span> <span class="o">=</span> <span class="s2">&#34;${var.ecr_repo_url}:${var.image_tag}&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">    portMappings</span> <span class="o">=</span><span class="n"> [{ containerPort</span> <span class="o">=</span><span class="n"> 8080, protocol</span> <span class="o">=</span> <span class="s2">&#34;tcp&#34;</span> }<span class="p">]</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">    logConfiguration</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">      logDriver</span> <span class="o">=</span> <span class="s2">&#34;awslogs&#34;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="n">      options</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="n">        &#34;awslogs-group&#34;</span>         <span class="o">=</span> <span class="k">aws_cloudwatch_log_group</span><span class="p">.</span><span class="k">api</span><span class="p">.</span><span class="k">name</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="n">        &#34;awslogs-region&#34;</span>        <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">region</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="n">        &#34;awslogs-stream-prefix&#34;</span> <span class="o">=</span> <span class="s2">&#34;api&#34;</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">      }
</span></span><span class="line"><span class="ln">21</span><span class="cl">    }
</span></span><span class="line"><span class="ln">22</span><span class="cl">  }<span class="p">])</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">}</span></span></code></pre></div><p>這段定義裡有三個刻意的設計：</p>
<p><strong>映像版本解耦</strong>：<code>var.image_tag</code> 在 infra 的 <code>tfvars</code> 裡給一個穩定的預設值（如 <code>latest</code> 或某個基線版本），部署管線覆寫這個值推新版本。infra apply 不會因此改動映像、部署 pipeline 不會因此改動 subnet — 兩者的變更頻率與審查強度不同，混在一起會讓快的等慢的。如果每次部署新版本都要改 infra 的 Terraform code 並跑 apply，代表映像版本跟 infra 沒有解耦——應該讓部署管線直接用 <code>aws ecs update-service</code> 或修改 task definition 的 image tag，不走 Terraform。</p>
<p><strong>兩個 IAM role 的分工</strong>：<code>execution_role_arn</code> 是 ECS 代理用來拉映像和寫 log 的身分 — 它的權限是 ECS 平台層級的，跟業務邏輯無關。<code>task_role_arn</code> 是容器內的應用程式碼在執行期取得的身分 — 它的權限對應業務需求，例如讀寫某個 S3 bucket 或呼叫某個 SQS queue。兩者混在同一個 role 上，就是把平台權限跟業務權限混在一起，違反最小權限（見<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>）。</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_iam_role&#34; &#34;api_task&#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;api-task-${var.env}&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  assume_role_policy</span> <span class="o">=</span> <span class="k">data</span><span class="p">.</span><span class="k">aws_iam_policy_document</span><span class="p">.</span><span class="k">ecs_assume</span><span class="p">.</span><span class="k">json</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">resource</span> <span class="s2">&#34;aws_iam_role_policy&#34; &#34;api_task&#34;</span> {
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">  role</span>   <span class="o">=</span> <span class="k">aws_iam_role</span><span class="p">.</span><span class="k">api_task</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">  policy</span> <span class="o">=</span> <span class="k">data</span><span class="p">.</span><span class="k">aws_iam_policy_document</span><span class="p">.</span><span class="k">api_permissions</span><span class="p">.</span><span class="k">json</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><span class="line"><span class="ln">11</span><span class="cl"><span class="k">data</span> <span class="s2">&#34;aws_iam_policy_document&#34; &#34;api_permissions&#34;</span> {
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="k">statement</span> {
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">    actions</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;s3:GetObject&#34;, &#34;s3:PutObject&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">    resources</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;${aws_s3_bucket.uploads.arn}/*&#34;</span><span class="p">]</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="k">statement</span> {
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="n">    actions</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;sqs:SendMessage&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="n">    resources</span> <span class="o">=</span> <span class="p">[</span><span class="k">aws_sqs_queue</span><span class="p">.</span><span class="k">notifications</span><span class="p">.</span><span class="k">arn</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">  }
</span></span><span class="line"><span class="ln">20</span><span class="cl">}</span></span></code></pre></div><p><strong>Log 接線</strong>：<code>logConfiguration</code> 把容器的 stdout/stderr 導向 CloudWatch Logs，log group 名稱引用的是同一份 IaC 裡宣告的資源 — 這正是<a href="/blog/infra/06-observability-logging/" data-link-title="模組六：可觀測性與 log 一併寫進 code" data-link-desc="log group、metric、alarm 跟基礎設施同生命週期管理，出事時追得到查得到">模組六：可觀測性與 log</a> 說的「監控跟資源同生命週期」。</p>
<h2 id="ecs-service部署模式與網路接線">ECS service：部署模式與網路接線</h2>
<p>ECS service 控制「要跑幾個 task、怎麼部署新版本、掛到哪個 target group」。它是 task definition 的執行實例管理者。</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_ecs_service&#34; &#34;api&#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;api-${var.env}&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  cluster</span>         <span class="o">=</span> <span class="k">aws_ecs_cluster</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  task_definition</span> <span class="o">=</span> <span class="k">aws_ecs_task_definition</span><span class="p">.</span><span class="k">api</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">  desired_count</span>   <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">api_desired_count</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  launch_type</span>     <span class="o">=</span> <span class="s2">&#34;FARGATE&#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 class="k">network_configuration</span> {
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">    subnets</span>          <span class="o">=</span> <span class="p">[</span><span class="k">for</span> <span class="k">s</span> <span class="k">in</span> <span class="k">aws_subnet</span><span class="p">.</span><span class="k">private</span> <span class="err">:</span> <span class="k">s</span><span class="p">.</span><span class="k">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">    security_groups</span>  <span class="o">=</span> <span class="p">[</span><span class="k">aws_security_group</span><span class="p">.</span><span class="k">api</span><span class="p">.</span><span class="k">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">    assign_public_ip</span> <span class="o">=</span> <span class="kt">false</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  }
</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 class="k">load_balancer</span> {
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">    target_group_arn</span> <span class="o">=</span> <span class="k">aws_lb_target_group</span><span class="p">.</span><span class="k">api</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="n">    container_name</span>   <span class="o">=</span> <span class="s2">&#34;api&#34;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="n">    container_port</span>   <span class="o">=</span> <span class="m">8080</span>
</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><span class="line"><span class="ln">20</span><span class="cl">  <span class="k">deployment_circuit_breaker</span> {
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="n">    enable</span>   <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="n">    rollback</span> <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">  }
</span></span><span class="line"><span class="ln">24</span><span class="cl">}</span></span></code></pre></div><p><code>network_configuration</code> 把 task 放進 private subnet 並套用 security group — 它決定了這些容器在網路拓撲裡的位置（見<a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>）。<code>assign_public_ip = false</code> 讓容器不拿公網 IP，對外流量經由 NAT 出去、入站流量經由 ALB 進來。</p>
<p><code>deployment_circuit_breaker</code> 是 ECS 的內建保護：部署新版本時如果 task 持續啟動失敗（health check 不過、容器 crash），ECS 會自動回滾到上一版。這個行為需要明確開啟、預設是關的 — 關著的話，壞版本的 task 會反覆啟動失敗，新版始終上不來但舊版也不會回來，服務陷入降級狀態。</p>
<h2 id="連線管理運算到資料庫的接線">連線管理：運算到資料庫的接線</h2>
<p>運算到資料庫之間有一段常被略過的接線：連線管理。無狀態運算水平擴張時，每個 task 各自開連線到 RDS，容易把資料庫的連線數打滿。RDS 的連線上限由 instance class 決定（例如 <code>db.r6g.large</code> 約 1000 個連線），而一個跑了 50 個 task 的 ECS service，每個 task 開 20 個連線就到上限了。</p>
<p>出現「擴運算反而拖垮 DB」的訊號時，要引入連線池或受管的連線代理。RDS Proxy 在運算與 RDS 之間代理連線，把運算端的大量短命連線收斂成少量長期連線再進資料庫。它也可以寫進 IaC 並輸出端點給運算引用：</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_proxy&#34; &#34;main&#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;api-proxy-${var.env}&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  engine_family</span>          <span class="o">=</span> <span class="s2">&#34;POSTGRESQL&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  role_arn</span>               <span class="o">=</span> <span class="k">aws_iam_role</span><span class="p">.</span><span class="k">rds_proxy</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">  vpc_subnet_ids</span>         <span class="o">=</span> <span class="p">[</span><span class="k">for</span> <span class="k">s</span> <span class="k">in</span> <span class="k">aws_subnet</span><span class="p">.</span><span class="k">private</span> <span class="err">:</span> <span class="k">s</span><span class="p">.</span><span class="k">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  vpc_security_group_ids</span> <span class="o">=</span> <span class="p">[</span><span class="k">aws_security_group</span><span class="p">.</span><span class="k">rds_proxy</span><span class="p">.</span><span class="k">id</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 class="k">auth</span> {
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">    auth_scheme</span> <span class="o">=</span> <span class="s2">&#34;SECRETS&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">    secret_arn</span>  <span class="o">=</span> <span class="k">aws_secretsmanager_secret</span><span class="p">.</span><span class="k">db_password</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  }
</span></span><span class="line"><span class="ln">12</span><span class="cl">}
</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 class="k">output</span> <span class="s2">&#34;db_endpoint&#34;</span> {
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">  value</span> <span class="o">=</span> <span class="k">aws_db_proxy</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">endpoint</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">}</span></span></code></pre></div><p>運算端的連線字串指向 proxy 端點而非 RDS 端點。proxy 的 security group 允許來自運算 security group 的流量，proxy 到 RDS 的流量則由 proxy 自己的 security group 對 RDS security group 的規則控制 — 安全邊界多了一層但更清晰。</p>
<h2 id="auto-scaling容量隨負載擴縮">Auto-scaling：容量隨負載擴縮</h2>
<p>ECS service 的 <code>desired_count</code> 是靜態的起始容量。要讓容量隨負載動態調整，需要加上 Application Auto Scaling。它的責任是在負載上升時長出更多 task、負載下降時縮回去省錢。</p>
<p>auto-scaling 的核心決策是「用什麼指標觸發擴縮」。常見的指標分兩類：</p>
<table>
  <thead>
      <tr>
          <th>指標類型</th>
          <th>典型指標</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>資源利用率</td>
          <td>CPU utilization、memory utilization</td>
          <td>運算密集型服務，CPU 與負載正相關</td>
      </tr>
      <tr>
          <td>業務吞吐量</td>
          <td>ALB request count per target</td>
          <td>I/O 密集型服務，CPU 低但併發高</td>
      </tr>
  </tbody>
</table>
<p>CPU utilization 是最直覺的指標，但它在 I/O 密集型服務上會失準 — 一個等待外部 API 回應的 task，CPU 很低但已經沒有多餘的能力處理新請求。這時用 ALB 的 request count per target（每個 task 平均處理幾個請求）更能反映真實負載。</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_appautoscaling_target&#34; &#34;api&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  max_capacity</span>       <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">api_max_count</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  min_capacity</span>       <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">api_min_count</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  resource_id</span>        <span class="o">=</span> <span class="s2">&#34;service/${aws_ecs_cluster.main.name}/${aws_ecs_service.api.name}&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">  scalable_dimension</span> <span class="o">=</span> <span class="s2">&#34;ecs:service:DesiredCount&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  service_namespace</span>  <span class="o">=</span> <span class="s2">&#34;ecs&#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="k">resource</span> <span class="s2">&#34;aws_appautoscaling_policy&#34; &#34;api_cpu&#34;</span> {
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">  name</span>               <span class="o">=</span> <span class="s2">&#34;api-cpu-${var.env}&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">  policy_type</span>        <span class="o">=</span> <span class="s2">&#34;TargetTrackingScaling&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">  resource_id</span>        <span class="o">=</span> <span class="k">aws_appautoscaling_target</span><span class="p">.</span><span class="k">api</span><span class="p">.</span><span class="k">resource_id</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">  scalable_dimension</span> <span class="o">=</span> <span class="k">aws_appautoscaling_target</span><span class="p">.</span><span class="k">api</span><span class="p">.</span><span class="k">scalable_dimension</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">  service_namespace</span>  <span class="o">=</span> <span class="k">aws_appautoscaling_target</span><span class="p">.</span><span class="k">api</span><span class="p">.</span><span class="k">service_namespace</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="k">target_tracking_scaling_policy_configuration</span> {
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="n">    target_value</span>       <span class="o">=</span> <span class="m">60</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">predefined_metric_specification</span> {
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="n">      predefined_metric_type</span> <span class="o">=</span> <span class="s2">&#34;ECSServiceAverageCPUUtilization&#34;</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    }
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="n">    scale_in_cooldown</span>  <span class="o">=</span> <span class="m">300</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="n">    scale_out_cooldown</span> <span class="o">=</span> <span class="m">60</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">  }
</span></span><span class="line"><span class="ln">24</span><span class="cl">}</span></span></code></pre></div><p><code>target_value = 60</code> 表示目標 CPU 平均維持在 60% — 留 40% 的餘裕應對突發。<code>scale_out_cooldown</code> 設短（60 秒），讓擴張反應快；<code>scale_in_cooldown</code> 設長（300 秒），避免負載短暫下降就立刻縮容、結果下一波流量來了又要重新擴張。</p>
<p>設了 auto-scaling 後要定期看 scaling activity log 確認它在正確的時機擴縮。從來沒觸發過有兩種可能：<code>min_capacity</code> 已經高於實際需求（資源浪費），或 target value 設太高（來不及擴）。</p>
<p><code>max_capacity</code> 是成本護欄 — 設一個你能接受的上限，避免異常流量（爬蟲、攻擊、上游重試風暴）把 task 數推到遠超預期的帳單。運行期的成本優化在 <a href="/blog/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">devops 模組八：成本管理</a> 展開。</p>
<p>規模放大後，auto-scaling 的行為模式會改變。<a href="/blog/backend/09-performance-capacity/cases/niantic-pokemon-go-fifty-x-surge-gcp/" data-link-title="9.C8 Niantic Pokémon GO：在 GCP 上承載 50 倍突發流量" data-link-desc="Pokémon GO 上線時實際流量達原始預估 50 倍、Google CRE 怎麼即時補容量">Pokémon GO 上線時實際流量達預估的 50 倍</a>，這類突發不是 auto-scaling 能事前規劃的——50 倍的 headroom 會讓平日成本不合理。Niantic 的 infra 層前提是 GKE 把容器啟動時間降到秒級，讓 surge 反應成為可能；同時依賴 Google CRE 即時補 node 容量。<a href="/blog/backend/09-performance-capacity/cases/zoom-covid-surge-dynamodb/" data-link-title="9.C18 Zoom：COVID 期間從 1000 萬到 3 億 DAU 的 30 倍突發" data-link-desc="Zoom 在 2020 年 COVID 爆發時、日活從 1000 萬衝到 3 億、用 DynamoDB 撐住會議後端">Zoom COVID 期間的 30 倍突發</a> 則是結構性成長——日活從 1000 萬升到 3 億後不會回落，容量規劃的 baseline 需要永久重新校準。兩個案例的共同教訓是：auto-scaling 的 <code>max_capacity</code> 設定要預留突發空間，但極端突發的處理靠的是平台能力（容器化的快速啟動）和 vendor 支援（managed service 的彈性），不是 IaC 配置能獨立解決的。</p>
<p>多叢集治理是另一個規模維度。<a href="/blog/backend/09-performance-capacity/cases/riot-games-eks-multi-cluster/" data-link-title="9.C12 Riot Games：246 個 EKS cluster 的多遊戲多地區治理" data-link-desc="Riot Games 從 Mesos 遷移到 EKS、用 246 個 cluster 跨遊戲跨地區治理、年省 1000 萬美金">Riot Games 用 246 個 EKS cluster 跨多遊戲多地區</a>，每個遊戲一個獨立叢集（避免跨遊戲互相影響），搭配 Terraform 做 IaC、Karpenter 做 node lifecycle，年省 1000 萬美金。infra 層的教訓是：當運算叢集數量從個位數長到數十甚至數百，叢集本身變成需要 IaC 治理的資源——叢集的建立、版本升級、安全基線都要標準化。<a href="/blog/backend/05-deployment-platform/cases/conde-nast-platform-modernization-eks/" data-link-title="5.C2 Condé Nast：EKS 平台整併與標準化" data-link-desc="多地區異質 Kubernetes 平台整併為統一控制面的案例。">Condé Nast 的 EKS 平台整併</a>也印證了同樣的模式：多團隊各自維護異質 K8s 叢集會造成安全基線不一致，整併到統一平台後把 kube2iam（有 race condition 風險）換成 IRSA（OIDC federation），消除了 node-level 的 credential 共用。</p>
<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>：execution role 與 task role 的最小權限設計</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>：運算放在 private subnet、security group 接線</li>
<li>→ <a href="/blog/infra/06-observability-logging/" data-link-title="模組六：可觀測性與 log 一併寫進 code" data-link-desc="log group、metric、alarm 跟基礎設施同生命週期管理，出事時追得到查得到">模組六：可觀測性與 log</a>：log group 與 task definition 同生命週期</li>
<li>→ <a href="/blog/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">devops 模組八：成本管理</a>：auto-scaling 的成本護欄與 spot/Fargate Spot 混用</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>Drift（設定漂移）</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/drift/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/drift/</guid><description>&lt;p>Drift 指的是 IaC 的 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">state&lt;/a> 記錄與雲端上的實際資源狀態之間的不一致。最常見的來源是有人繞過 IaC、直接在 Console 手動修改資源設定——state 不知道這次改動發生了，下一次 &lt;code>plan&lt;/code> 時工具會把手動改的設定判定為「不在我的記憶裡、要修正回程式碼的版本」。&lt;/p>
&lt;p>Drift 的代價會延遲浮現。手動改的當下看起來沒問題——設定改了、服務正常。問題出在後續某次不相關的 &lt;code>apply&lt;/code>：工具用過時的 state 去比對，把手動改的設定覆蓋掉，服務因此斷線，而且在 PR 裡看不到這件事發生過。Drift 累積越多，每次 &lt;code>apply&lt;/code> 的不確定性越高，最終團隊會開始害怕跑 &lt;code>apply&lt;/code>，IaC 名存實亡。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Drift 是 Console 唯讀鐵律存在的根本理由。&lt;a href="https://tarrragon.github.io/blog/infra/01-minimal-iac/console-readonly-minimal-viable/" data-link-title="Console 唯讀鐵律與最小可行資源集合" data-link-desc="Console 只用來看不用來改的操作紀律、drift 的延遲浮現與偵測，以及能跑出第一個完整 apply 迴路的最小資源集合">模組一：Console 唯讀鐵律&lt;/a>用權限機制（人類身分唯讀、寫入權限留給自動化身分）讓「在 Console 改不動」成為預設狀態，從源頭消除 drift 的產生。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>Drift 存在的訊號：&lt;code>terraform plan&lt;/code> 在沒人改過程式碼的情況下顯示變更（代表有人在 Console 動了東西）、團隊開始說「跑 plan 前先看看有沒有奇怪的差異」、某次例行 apply 意外改掉了不該改的設定。&lt;/p>
&lt;p>偵測 drift 的主動方式是定期跑 &lt;code>terraform plan&lt;/code> 但不 apply，把 diff 輸出當成 drift 偵測的報告。Terraform Cloud 有內建的 drift detection 功能，定期比對 state 與雲端現實。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>處理 drift 時要決定：&lt;/p>
&lt;ul>
&lt;li>偵測頻率：每次 PR 觸發 plan（被動偵測）vs 定期排程 plan（主動偵測）&lt;/li>
&lt;li>修正方向：把雲端改回程式碼的版本（&lt;code>apply&lt;/code>），還是把程式碼改成雲端的版本（更新 HCL）——取捨在「程式碼是 source of truth」vs「手動改的設定有它的理由」&lt;/li>
&lt;li>預防機制：Console 唯讀權限、CI gate 攔截未經 review 的 apply&lt;/li>
&lt;/ul>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">State&lt;/a> — drift 是 state 與現實的落差&lt;/li>
&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> — drift 破壞 IaC 的 source of truth 地位&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Drift 指的是 IaC 的 <a href="/blog/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">state</a> 記錄與雲端上的實際資源狀態之間的不一致。最常見的來源是有人繞過 IaC、直接在 Console 手動修改資源設定——state 不知道這次改動發生了，下一次 <code>plan</code> 時工具會把手動改的設定判定為「不在我的記憶裡、要修正回程式碼的版本」。</p>
<p>Drift 的代價會延遲浮現。手動改的當下看起來沒問題——設定改了、服務正常。問題出在後續某次不相關的 <code>apply</code>：工具用過時的 state 去比對，把手動改的設定覆蓋掉，服務因此斷線，而且在 PR 裡看不到這件事發生過。Drift 累積越多，每次 <code>apply</code> 的不確定性越高，最終團隊會開始害怕跑 <code>apply</code>，IaC 名存實亡。</p>
<h2 id="概念位置">概念位置</h2>
<p>Drift 是 Console 唯讀鐵律存在的根本理由。<a href="/blog/infra/01-minimal-iac/console-readonly-minimal-viable/" data-link-title="Console 唯讀鐵律與最小可行資源集合" data-link-desc="Console 只用來看不用來改的操作紀律、drift 的延遲浮現與偵測，以及能跑出第一個完整 apply 迴路的最小資源集合">模組一：Console 唯讀鐵律</a>用權限機制（人類身分唯讀、寫入權限留給自動化身分）讓「在 Console 改不動」成為預設狀態，從源頭消除 drift 的產生。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>Drift 存在的訊號：<code>terraform plan</code> 在沒人改過程式碼的情況下顯示變更（代表有人在 Console 動了東西）、團隊開始說「跑 plan 前先看看有沒有奇怪的差異」、某次例行 apply 意外改掉了不該改的設定。</p>
<p>偵測 drift 的主動方式是定期跑 <code>terraform plan</code> 但不 apply，把 diff 輸出當成 drift 偵測的報告。Terraform Cloud 有內建的 drift detection 功能，定期比對 state 與雲端現實。</p>
<h2 id="設計責任">設計責任</h2>
<p>處理 drift 時要決定：</p>
<ul>
<li>偵測頻率：每次 PR 觸發 plan（被動偵測）vs 定期排程 plan（主動偵測）</li>
<li>修正方向：把雲端改回程式碼的版本（<code>apply</code>），還是把程式碼改成雲端的版本（更新 HCL）——取捨在「程式碼是 source of truth」vs「手動改的設定有它的理由」</li>
<li>預防機制：Console 唯讀權限、CI gate 攔截未經 review 的 apply</li>
</ul>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">State</a> — drift 是 state 與現實的落差</li>
<li><a href="/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC</a> — drift 破壞 IaC 的 source of truth 地位</li>
</ul>
]]></content:encoded></item><item><title>儲存上 IaC — S3 bucket 的安全與生命週期</title><link>https://tarrragon.github.io/blog/infra/05-core-services/storage-s3/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/05-core-services/storage-s3/</guid><description>&lt;p>S3 bucket 描述的是物件儲存的存在、命名、加密設定、版本控制與存取政策。bucket 本身沒有重建代價意義上的狀態問題 — 困難在它「裝的東西」。空 bucket 可隨時重建，裝了正式資料的 bucket 與 RDS 一樣不可隨意 destroy。把安全設定與生命週期規則寫進 IaC，讓這些防線成為可版本控制、可審查的程式碼，而非散落在 Console 的隱性設定。&lt;/p>
&lt;h2 id="bucket-的四道安全防線">bucket 的四道安全防線&lt;/h2>
&lt;p>一個 S3 bucket 在 IaC 裡至少要描述四個獨立資源，各自對應一道防線。Terraform 把它們拆成獨立資源是設計選擇 — 每道防線可以單獨 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">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_s3_bucket&amp;#34; &amp;#34;assets&amp;#34;&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"> bucket&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;acme-${var.env}-assets&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>&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 class="n"> { service&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> &amp;#34;cdn-origin&amp;#34;, env&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">env&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_s3_bucket_versioning&amp;#34; &amp;#34;assets&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 class="n"> bucket&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_s3_bucket&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">assets&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="n"> versioning_configuration { status&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Enabled&amp;#34;&lt;/span> }
&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">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_s3_bucket_server_side_encryption_configuration&amp;#34; &amp;#34;assets&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="n"> bucket&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_s3_bucket&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">assets&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="k">rule&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">apply_server_side_encryption_by_default&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="n"> sse_algorithm&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;aws:kms&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_s3_bucket_public_access_block&amp;#34; &amp;#34;assets&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">&lt;span class="n"> bucket&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_s3_bucket&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">assets&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">&lt;span class="n"> block_public_acls&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">24&lt;/span>&lt;span class="cl">&lt;span class="n"> block_public_policy&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">25&lt;/span>&lt;span class="cl">&lt;span class="n"> ignore_public_acls&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">26&lt;/span>&lt;span class="cl">&lt;span class="n"> restrict_public_buckets&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">27&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="versioning">versioning&lt;/h3>
&lt;p>&lt;code>versioning&lt;/code> 讓物件的每次覆寫都保留前一版。誤覆寫時可以從版本歷史回退到前一個正確版本，誤刪時物件只是被標記為 delete marker、前一版仍然存在。這道防線對承載正式資料的 bucket 是必要的 — 沒有 versioning 的 bucket，一次誤操作就是資料永久遺失。&lt;/p>
&lt;p>versioning 開啟後會累積歷史版本的儲存量。搭配生命週期規則設定 &lt;code>noncurrent_version_expiration&lt;/code> 可以控制保留多少天的舊版本，避免儲存成本無限成長。這個天數是「保留能力」跟「儲存成本」的取捨 — 保留 30 天通常足以涵蓋發現問題到回退的時間差，受合規要求的資料則依規定延長。&lt;/p>
&lt;h3 id="server-side-encryption">server-side encryption&lt;/h3>
&lt;p>&lt;code>server_side_encryption&lt;/code> 確保物件在 S3 落地時加密。&lt;code>aws:kms&lt;/code> 使用 KMS 管理的金鑰，加密操作對應用程式透明 — 寫入時自動加密、讀取時自動解密，不需要改應用程式碼。選 &lt;code>aws:kms&lt;/code> 而非 &lt;code>AES256&lt;/code>（SSE-S3）的判斷依據是存取控制粒度：KMS 金鑰可以獨立設定 key policy，讓「誰能解密」這件事跟「誰能讀 bucket」分開管理，適合跨帳號或跨團隊的場景。&lt;/p>
&lt;p>使用 KMS 加密的 bucket 在跨帳號存取時，目標帳號除了要有 bucket 的讀取權限，還需要 KMS key 的 &lt;code>kms:Decrypt&lt;/code> 權限 — 少了這一步會拿到 &lt;code>AccessDenied&lt;/code>，錯誤訊息通常指向 S3 權限而非 KMS，排查時容易走錯方向。&lt;/p>
&lt;h3 id="public-access-block">public access block&lt;/h3>
&lt;p>&lt;code>public_access_block&lt;/code> 的四個布林全設 true，等於從 bucket 層級封死對外公開的可能。即使有人之後誤加了一條公開的 bucket policy 或 ACL，這個 block 也會擋住。它是一道兜底機制 — 擋的是設定錯誤，不是正常操作。&lt;/p>
&lt;p>靜態掃描工具（checkov / tfsec）會標記缺少 public access block 的 bucket。這正是&lt;a href="https://tarrragon.github.io/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 流程&lt;/a>裡自動化護欄的典型攔截對象 — 漏設的 bucket 會在 PR 階段被擋下，而非部署到線上才發現。&lt;/p></description><content:encoded><![CDATA[<p>S3 bucket 描述的是物件儲存的存在、命名、加密設定、版本控制與存取政策。bucket 本身沒有重建代價意義上的狀態問題 — 困難在它「裝的東西」。空 bucket 可隨時重建，裝了正式資料的 bucket 與 RDS 一樣不可隨意 destroy。把安全設定與生命週期規則寫進 IaC，讓這些防線成為可版本控制、可審查的程式碼，而非散落在 Console 的隱性設定。</p>
<h2 id="bucket-的四道安全防線">bucket 的四道安全防線</h2>
<p>一個 S3 bucket 在 IaC 裡至少要描述四個獨立資源，各自對應一道防線。Terraform 把它們拆成獨立資源是設計選擇 — 每道防線可以單獨 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">resource</span> <span class="s2">&#34;aws_s3_bucket&#34; &#34;assets&#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-${var.env}-assets&#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 class="n"> { service</span> <span class="o">=</span><span class="n"> &#34;cdn-origin&#34;, 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"> 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 class="k">resource</span> <span class="s2">&#34;aws_s3_bucket_versioning&#34; &#34;assets&#34;</span> {
</span></span><span class="line"><span class="ln"> 8</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">assets</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  versioning_configuration { status</span> <span class="o">=</span> <span class="s2">&#34;Enabled&#34;</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></span><span class="line"><span class="ln">12</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_s3_bucket_server_side_encryption_configuration&#34; &#34;assets&#34;</span> {
</span></span><span class="line"><span class="ln">13</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">assets</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="k">rule</span> {
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">apply_server_side_encryption_by_default</span> {
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="n">      sse_algorithm</span> <span class="o">=</span> <span class="s2">&#34;aws:kms&#34;</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><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_s3_bucket_public_access_block&#34; &#34;assets&#34;</span> {
</span></span><span class="line"><span class="ln">22</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">assets</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="n">  block_public_acls</span>       <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="n">  block_public_policy</span>     <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="n">  ignore_public_acls</span>      <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="n">  restrict_public_buckets</span> <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">}</span></span></code></pre></div><h3 id="versioning">versioning</h3>
<p><code>versioning</code> 讓物件的每次覆寫都保留前一版。誤覆寫時可以從版本歷史回退到前一個正確版本，誤刪時物件只是被標記為 delete marker、前一版仍然存在。這道防線對承載正式資料的 bucket 是必要的 — 沒有 versioning 的 bucket，一次誤操作就是資料永久遺失。</p>
<p>versioning 開啟後會累積歷史版本的儲存量。搭配生命週期規則設定 <code>noncurrent_version_expiration</code> 可以控制保留多少天的舊版本，避免儲存成本無限成長。這個天數是「保留能力」跟「儲存成本」的取捨 — 保留 30 天通常足以涵蓋發現問題到回退的時間差，受合規要求的資料則依規定延長。</p>
<h3 id="server-side-encryption">server-side encryption</h3>
<p><code>server_side_encryption</code> 確保物件在 S3 落地時加密。<code>aws:kms</code> 使用 KMS 管理的金鑰，加密操作對應用程式透明 — 寫入時自動加密、讀取時自動解密，不需要改應用程式碼。選 <code>aws:kms</code> 而非 <code>AES256</code>（SSE-S3）的判斷依據是存取控制粒度：KMS 金鑰可以獨立設定 key policy，讓「誰能解密」這件事跟「誰能讀 bucket」分開管理，適合跨帳號或跨團隊的場景。</p>
<p>使用 KMS 加密的 bucket 在跨帳號存取時，目標帳號除了要有 bucket 的讀取權限，還需要 KMS key 的 <code>kms:Decrypt</code> 權限 — 少了這一步會拿到 <code>AccessDenied</code>，錯誤訊息通常指向 S3 權限而非 KMS，排查時容易走錯方向。</p>
<h3 id="public-access-block">public access block</h3>
<p><code>public_access_block</code> 的四個布林全設 true，等於從 bucket 層級封死對外公開的可能。即使有人之後誤加了一條公開的 bucket policy 或 ACL，這個 block 也會擋住。它是一道兜底機制 — 擋的是設定錯誤，不是正常操作。</p>
<p>靜態掃描工具（checkov / tfsec）會標記缺少 public access block 的 bucket。這正是<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>裡自動化護欄的典型攔截對象 — 漏設的 bucket 會在 PR 階段被擋下，而非部署到線上才發現。</p>
<p>定期用 CLI 掃一遍帳號內所有 bucket 的公開狀態，命中的每個 bucket 都要能回答「這個公開是故意的、理由是什麼」：</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 s3api list-buckets --query <span class="s1">&#39;Buckets[].Name&#39;</span> --output text <span class="p">|</span> tr <span class="s1">&#39;\t&#39;</span> <span class="s1">&#39;\n&#39;</span> <span class="p">|</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  <span class="k">while</span> <span class="nb">read</span> b<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nv">status</span><span class="o">=</span><span class="k">$(</span>aws s3api get-public-access-block --bucket <span class="s2">&#34;</span><span class="nv">$b</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="p">|</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>      jq -r <span class="s1">&#39;.PublicAccessBlockConfiguration | to_entries[] | select(.value==false) | .key&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$status</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$b</span><span class="s2">: </span><span class="nv">$status</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="k">done</span></span></span></code></pre></div><h2 id="生命週期規則">生命週期規則</h2>
<p>儲存成本隨物件數量與保留時間線性成長。生命週期規則讓 IaC 描述「某類物件多久後搬到更便宜的儲存層、再多久後刪掉」，把成本控制變成可版本控制的設定。</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_lifecycle_configuration&#34; &#34;assets&#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">assets</span><span class="p">.</span><span class="k">id</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="k">rule</span> {
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">    id</span>     <span class="o">=</span> <span class="s2">&#34;archive-old-logs&#34;</span>
</span></span><span class="line"><span class="ln"> 6</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"> 7</span><span class="cl"><span class="n">    filter { prefix</span> <span class="o">=</span> <span class="s2">&#34;logs/&#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 class="k">transition</span> {
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">      days</span>          <span class="o">=</span> <span class="m">30</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">      storage_class</span> <span class="o">=</span> <span class="s2">&#34;GLACIER_IR&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    }
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">    expiration { days</span> <span class="o">=</span> <span class="m">365</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></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="k">rule</span> {
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="n">    id</span>     <span class="o">=</span> <span class="s2">&#34;cleanup-old-versions&#34;</span>
</span></span><span class="line"><span class="ln">18</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">19</span><span class="cl">    <span class="k">filter</span> {}
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="k">noncurrent_version_expiration</span> {
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="n">      noncurrent_days</span> <span class="o">=</span> <span class="m">30</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    }
</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></span></code></pre></div><h3 id="儲存層的取捨">儲存層的取捨</h3>
<p>S3 提供多個儲存層，各自在存取延遲與儲存單價之間取捨：</p>
<table>
  <thead>
      <tr>
          <th>儲存層</th>
          <th>存取延遲</th>
          <th>適用場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Standard</td>
          <td>毫秒級</td>
          <td>頻繁讀取的熱資料</td>
      </tr>
      <tr>
          <td>Standard-IA</td>
          <td>毫秒級</td>
          <td>不常存取但需要時立即讀到</td>
      </tr>
      <tr>
          <td>Glacier Instant</td>
          <td>毫秒級</td>
          <td>每季存取一次的歸檔</td>
      </tr>
      <tr>
          <td>Glacier Flexible</td>
          <td>分鐘到小時級</td>
          <td>稽核留存、年度查閱</td>
      </tr>
      <tr>
          <td>Glacier Deep Archive</td>
          <td>12 小時級</td>
          <td>法規留存、極少存取</td>
      </tr>
  </tbody>
</table>
<p><code>transition</code> 規則的日數設定要回推自業務需求：log 在除錯期間需要即時讀取（Standard），超過 30 天後幾乎只在事故回顧時才翻（Glacier Instant Retrieval 或 Standard-IA），超過一年可以淘汰或移到更深的歸檔層。把這些規則寫進 IaC，「為什麼 logs 只留一年」就是一個能在 PR 上被討論的決定，而非某人在 Console 點了不知道大家知不知道的設定。</p>
<h2 id="bucket-policy-與跨帳號存取">bucket policy 與跨帳號存取</h2>
<p>bucket policy 描述誰能對這個 bucket 做什麼操作，是 bucket 層級的存取控制。它跟 IAM policy 的差別在施力點：IAM policy 貼在身分上、定義「這個身分能做什麼」；bucket policy 貼在資源上、定義「這個 bucket 允許誰來」。兩者同時生效 — 一個請求要同時被身分端和資源端允許才會放行（除非有顯式 deny）。</p>
<p>跨帳號存取是 bucket policy 最常見的使用場景。一個帳號的 S3 bucket 要讓另一個帳號的 IAM role 讀取，需要兩端同時授權：bucket policy 允許那個 role 的 ARN，對方帳號的 IAM policy 也允許對這個 bucket 操作。</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_policy&#34; &#34;cross_account_read&#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">assets</span><span class="p">.</span><span class="k">id</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">  policy</span> <span class="o">=</span> <span class="k">jsonencode</span><span class="p">(</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="s2">&#34;2012-10-17&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">    Statement</span> <span class="o">=</span> <span class="p">[</span>{
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">      Sid</span>       <span class="o">=</span> <span class="s2">&#34;AllowCrossAccountRead&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">      Effect</span>    <span class="o">=</span> <span class="s2">&#34;Allow&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">      Principal</span> <span class="o">=</span><span class="n"> { AWS</span> <span class="o">=</span> <span class="s2">&#34;arn:aws:iam::111222333444:role/data-reader&#34;</span> }
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">      Action</span>    <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;s3:GetObject&#34;, &#34;s3:ListBucket&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">      Resource</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">aws_s3_bucket</span><span class="p">.</span><span class="k">assets</span><span class="p">.</span><span class="k">arn</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="s2">&#34;${aws_s3_bucket.assets.arn}/*&#34;</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></span></code></pre></div><p>bucket policy 的常見陷阱是 <code>Principal: &quot;*&quot;</code> — 允許任何人存取。這跟 security group 的 <code>0.0.0.0/0</code> 是同一類風險。除了做為 CloudFront Origin Access Control（OAC）的配合設定，幾乎沒有合理場景需要把 Principal 設成 wildcard。checkov 的 <code>CKV_AWS_70</code> 規則專門攔這個。</p>
<p>把 bucket policy 寫進 IaC 的好處是每一條授權都有 PR 紀錄 — 誰在什麼時候加了一條跨帳號存取、為什麼加、reviewer 同意了沒有。散落在 Console 的 bucket policy 沒有這些追蹤，某天發現一條不認得的授權時，只能去翻 CloudTrail 猜它是什麼時候加的。</p>
<h2 id="事件通知">事件通知</h2>
<p>S3 事件通知讓 bucket 在物件被建立、刪除或還原時，自動觸發下游處理 — 寫入後自動縮圖、上傳後自動掃毒、刪除後自動通知。這些觸發關係寫進 IaC，讓「這個 bucket 會觸發什麼」成為可查詢的事實，而非散落在 Console 的隱性接線。</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_notification&#34; &#34;assets&#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">assets</span><span class="p">.</span><span class="k">id</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="k">lambda_function</span> {
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">    lambda_function_arn</span> <span class="o">=</span> <span class="k">aws_lambda_function</span><span class="p">.</span><span class="k">thumbnail</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">    events</span>              <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;s3:ObjectCreated:*&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">    filter_prefix</span>       <span class="o">=</span> <span class="s2">&#34;uploads/&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">    filter_suffix</span>       <span class="o">=</span> <span class="s2">&#34;.jpg&#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><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_lambda_permission&#34; &#34;allow_s3&#34;</span> {
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">  statement_id</span>  <span class="o">=</span> <span class="s2">&#34;AllowS3Invoke&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">  action</span>        <span class="o">=</span> <span class="s2">&#34;lambda:InvokeFunction&#34;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">  function_name</span> <span class="o">=</span> <span class="k">aws_lambda_function</span><span class="p">.</span><span class="k">thumbnail</span><span class="p">.</span><span class="k">function_name</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="n">  principal</span>     <span class="o">=</span> <span class="s2">&#34;s3.amazonaws.com&#34;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="n">  source_arn</span>    <span class="o">=</span> <span class="k">aws_s3_bucket</span><span class="p">.</span><span class="k">assets</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">}</span></span></code></pre></div><p>事件通知的兩個配置常被忽略。第一是權限：S3 要觸發 Lambda，Lambda 的 resource-based policy 必須允許 S3 呼叫它（上面的 <code>aws_lambda_permission</code>），少了這段 apply 會成功但事件不會觸發，除錯時不容易發現。第二是 filter：不設 prefix / suffix 的通知會對 bucket 裡每一個物件操作都觸發，包括生命週期搬遷產生的物件變動 — 流量遠超預期。用 filter 把觸發範圍收斂到需要處理的路徑與檔案類型。</p>
<p>事件通知也可以導向 SQS 或 SNS，適合需要非同步佇列處理或 fan-out 到多個消費者的場景。選擇依據是下游的消費模式：Lambda 適合輕量即時處理（毫秒級回應），SQS 適合需要 backpressure 和重試的批次處理，SNS 適合同一事件需要同時通知多個服務。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<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>：checkov / tfsec 攔截缺少 public access block 或加密的 bucket</li>
<li>→ <a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>：bucket 的 tagging 與成本歸因</li>
<li>→ <a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>：bucket policy 與 IAM policy 的權限模型交集</li>
</ul>
]]></content:encoded></item><item><title>入口上 IaC — ALB、TLS 與健康檢查</title><link>https://tarrragon.github.io/blog/infra/05-core-services/loadbalancer-alb/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/05-core-services/loadbalancer-alb/</guid><description>&lt;p>ALB（Application Load Balancer）描述流量進入系統的第一站。它在 IaC 裡的接線責任是把三個層次釘清楚：listener 決定監聽哪些 port 與協定、target group 決定流量導向哪些運算後端、health check 決定後端是否健康到可以接流量。ALB 本身是 stateless 的 — 重建不會遺失資料，但會換掉它的 DNS 名稱，所以對外服務通常在它前面再掛一層穩定的 DNS 記錄（Route 53 alias 或 CNAME），讓使用者看到的網域不隨 ALB 重建而改變。&lt;/p>
&lt;p>ALB 掛在 public subnet、引用專屬的 security group，security group 的入站通常只開 80 和 443 對 &lt;code>0.0.0.0/0&lt;/code>（這是少數合理出現全開的位置，因為 ALB 的工作本來就是接收公開流量）。後端運算節點住在 private subnet，它們的 security group 入站只允許來自 ALB security group 的流量 — 這個 group-to-group 引用讓規則跟著成員身分走，不跟著 IP 走（見&lt;a href="https://tarrragon.github.io/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基&lt;/a>）。&lt;/p>
&lt;h2 id="alb-與-listener-設定">ALB 與 listener 設定&lt;/h2>
&lt;p>ALB 資源本身描述的是它掛在哪些 subnet、用哪個 security group、是對外（&lt;code>internal = false&lt;/code>）還是內部。Listener 則是掛在 ALB 上的監聽端點，每個 listener 綁定一個 port + protocol 的組合。&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">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_lb&amp;#34; &amp;#34;api&amp;#34;&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"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;api-${var.env}&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"> internal&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kt">false&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"> load_balancer_type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;application&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"> security_groups&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="k">aws_security_group&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">alb&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&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 class="n"> subnets&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="k">for&lt;/span> &lt;span class="k">s&lt;/span> &lt;span class="k">in&lt;/span> &lt;span class="k">aws_subnet&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">public&lt;/span> &lt;span class="err">:&lt;/span> &lt;span class="k">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>&lt;span class="p">]&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;/code>&lt;/pre>&lt;/div>&lt;h3 id="http-到-https-的強制跳轉">HTTP 到 HTTPS 的強制跳轉&lt;/h3>
&lt;p>正式服務通常同時建兩個 listener：port 443 接受 HTTPS 流量並轉發到後端，port 80 接收 HTTP 流量後直接回一個 301 redirect 到 HTTPS — 確保使用者即使用 &lt;code>http://&lt;/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">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_lb_listener&amp;#34; &amp;#34;https&amp;#34;&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"> load_balancer_arn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_lb&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">api&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&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"> port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">443&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"> protocol&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;HTTPS&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"> ssl_policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;ELBSecurityPolicy-TLS13-1-2-2021-06&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"> certificate_arn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_acm_certificate&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">api&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&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 class="k">default_action&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;forward&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="n"> target_group_arn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_lb_target_group&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">api&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_lb_listener&amp;#34; &amp;#34;http_redirect&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="n"> load_balancer_arn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_lb&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">api&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="n"> port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">80&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="n"> protocol&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;HTTP&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="k">default_action&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;redirect&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="k">redirect&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">&lt;span class="n"> port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;443&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">&lt;span class="n"> protocol&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;HTTPS&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">&lt;span class="n"> status_code&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;HTTP_301&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>ssl_policy&lt;/code> 決定 ALB 接受哪些 TLS 版本與密碼套件。選擇以安全與相容性為取捨 — &lt;code>ELBSecurityPolicy-TLS13-1-2-2021-06&lt;/code> 只接受 TLS 1.2 和 1.3，能阻擋過時協定的降級攻擊，但會拒絕仍在使用 TLS 1.0/1.1 的極舊用戶端。對面向公眾的 API 或網站，TLS 1.2 以上是合理的底線；如果有明確的舊用戶端需求（例如嵌入式設備），再往下調但要知道代價。&lt;/p></description><content:encoded><![CDATA[<p>ALB（Application Load Balancer）描述流量進入系統的第一站。它在 IaC 裡的接線責任是把三個層次釘清楚：listener 決定監聽哪些 port 與協定、target group 決定流量導向哪些運算後端、health check 決定後端是否健康到可以接流量。ALB 本身是 stateless 的 — 重建不會遺失資料，但會換掉它的 DNS 名稱，所以對外服務通常在它前面再掛一層穩定的 DNS 記錄（Route 53 alias 或 CNAME），讓使用者看到的網域不隨 ALB 重建而改變。</p>
<p>ALB 掛在 public subnet、引用專屬的 security group，security group 的入站通常只開 80 和 443 對 <code>0.0.0.0/0</code>（這是少數合理出現全開的位置，因為 ALB 的工作本來就是接收公開流量）。後端運算節點住在 private subnet，它們的 security group 入站只允許來自 ALB security group 的流量 — 這個 group-to-group 引用讓規則跟著成員身分走，不跟著 IP 走（見<a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>）。</p>
<h2 id="alb-與-listener-設定">ALB 與 listener 設定</h2>
<p>ALB 資源本身描述的是它掛在哪些 subnet、用哪個 security group、是對外（<code>internal = false</code>）還是內部。Listener 則是掛在 ALB 上的監聽端點，每個 listener 綁定一個 port + protocol 的組合。</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_lb&#34; &#34;api&#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;api-${var.env}&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  internal</span>           <span class="o">=</span> <span class="kt">false</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  load_balancer_type</span> <span class="o">=</span> <span class="s2">&#34;application&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">  security_groups</span>    <span class="o">=</span> <span class="p">[</span><span class="k">aws_security_group</span><span class="p">.</span><span class="k">alb</span><span class="p">.</span><span class="k">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">  subnets</span>            <span class="o">=</span> <span class="p">[</span><span class="k">for</span> <span class="k">s</span> <span class="k">in</span> <span class="k">aws_subnet</span><span class="p">.</span><span class="k">public</span> <span class="err">:</span> <span class="k">s</span><span class="p">.</span><span class="k">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">}</span></span></code></pre></div><h3 id="http-到-https-的強制跳轉">HTTP 到 HTTPS 的強制跳轉</h3>
<p>正式服務通常同時建兩個 listener：port 443 接受 HTTPS 流量並轉發到後端，port 80 接收 HTTP 流量後直接回一個 301 redirect 到 HTTPS — 確保使用者即使用 <code>http://</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_lb_listener&#34; &#34;https&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  load_balancer_arn</span> <span class="o">=</span> <span class="k">aws_lb</span><span class="p">.</span><span class="k">api</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  port</span>              <span class="o">=</span> <span class="m">443</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  protocol</span>          <span class="o">=</span> <span class="s2">&#34;HTTPS&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">  ssl_policy</span>        <span class="o">=</span> <span class="s2">&#34;ELBSecurityPolicy-TLS13-1-2-2021-06&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  certificate_arn</span>   <span class="o">=</span> <span class="k">aws_acm_certificate</span><span class="p">.</span><span class="k">api</span><span class="p">.</span><span class="k">arn</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 class="k">default_action</span> {
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">    type</span>             <span class="o">=</span> <span class="s2">&#34;forward&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">    target_group_arn</span> <span class="o">=</span> <span class="k">aws_lb_target_group</span><span class="p">.</span><span class="k">api</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  }
</span></span><span class="line"><span class="ln">12</span><span class="cl">}
</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 class="k">resource</span> <span class="s2">&#34;aws_lb_listener&#34; &#34;http_redirect&#34;</span> {
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">  load_balancer_arn</span> <span class="o">=</span> <span class="k">aws_lb</span><span class="p">.</span><span class="k">api</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="n">  port</span>              <span class="o">=</span> <span class="m">80</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="n">  protocol</span>          <span class="o">=</span> <span class="s2">&#34;HTTP&#34;</span>
</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="k">default_action</span> {
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="n">    type</span> <span class="o">=</span> <span class="s2">&#34;redirect&#34;</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="k">redirect</span> {
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="n">      port</span>        <span class="o">=</span> <span class="s2">&#34;443&#34;</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="n">      protocol</span>    <span class="o">=</span> <span class="s2">&#34;HTTPS&#34;</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="n">      status_code</span> <span class="o">=</span> <span class="s2">&#34;HTTP_301&#34;</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    }
</span></span><span class="line"><span class="ln">26</span><span class="cl">  }
</span></span><span class="line"><span class="ln">27</span><span class="cl">}</span></span></code></pre></div><p><code>ssl_policy</code> 決定 ALB 接受哪些 TLS 版本與密碼套件。選擇以安全與相容性為取捨 — <code>ELBSecurityPolicy-TLS13-1-2-2021-06</code> 只接受 TLS 1.2 和 1.3，能阻擋過時協定的降級攻擊，但會拒絕仍在使用 TLS 1.0/1.1 的極舊用戶端。對面向公眾的 API 或網站，TLS 1.2 以上是合理的底線；如果有明確的舊用戶端需求（例如嵌入式設備），再往下調但要知道代價。</p>
<h3 id="多服務共用-alb">多服務共用 ALB</h3>
<p>一個 ALB 可以掛多個 listener rule，用 host header 或 path 把流量分到不同的 target group。這讓多個微服務共用一個 ALB（省成本），而不需要每個服務各開一個：</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_lb_listener_rule&#34; &#34;auth&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  listener_arn</span> <span class="o">=</span> <span class="k">aws_lb_listener</span><span class="p">.</span><span class="k">https</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  priority</span>     <span class="o">=</span> <span class="m">10</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 class="k">condition</span> {
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">    path_pattern { values</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;/auth/*&#34;</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><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="k">action</span> {
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">    type</span>             <span class="o">=</span> <span class="s2">&#34;forward&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">    target_group_arn</span> <span class="o">=</span> <span class="k">aws_lb_target_group</span><span class="p">.</span><span class="k">auth</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  }
</span></span><span class="line"><span class="ln">13</span><span class="cl">}</span></span></code></pre></div><p>一個常見的收斂機會：如果每個服務都各自開了一個 ALB，但流量都從同一個入口進來、只是路徑不同，可以收斂成一個 ALB 加 listener rule。每個 ALB 有固定的小時費，少開幾個月費就少幾筆。反過來，當不同服務的安全等級或流量特性差異大到需要獨立的 security group 和 WAF 規則時，分開 ALB 才合理。</p>
<h2 id="target-group-與健康檢查">target group 與健康檢查</h2>
<p>Target group 定義一組接收流量的後端（ECS task、EC2 instance 或 IP），以及判斷這些後端是否健康的檢查邏輯。它是 ALB 和實際運算之間的橋樑。</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_lb_target_group&#34; &#34;api&#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;api-${var.env}-tg&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  port</span>        <span class="o">=</span> <span class="m">8080</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  protocol</span>    <span class="o">=</span> <span class="s2">&#34;HTTP&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">  vpc_id</span>      <span class="o">=</span> <span class="k">aws_vpc</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  target_type</span> <span class="o">=</span> <span class="s2">&#34;ip&#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 class="k">health_check</span> {
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">    path</span>                <span class="o">=</span> <span class="s2">&#34;/healthz&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">    interval</span>            <span class="o">=</span> <span class="m">15</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">    healthy_threshold</span>   <span class="o">=</span> <span class="m">2</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">    unhealthy_threshold</span> <span class="o">=</span> <span class="m">3</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">    timeout</span>             <span class="o">=</span> <span class="m">5</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">    matcher</span>             <span class="o">=</span> <span class="s2">&#34;200&#34;</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></span></code></pre></div><h3 id="健康檢查的閾值設計">健康檢查的閾值設計</h3>
<p>健康檢查的路徑與閾值是最常被忽略的判讀點。各參數之間的交互作用決定了兩個時間窗口：新後端多久後開始接流量、壞後端多久後被移出。</p>
<p><code>healthy_threshold = 2</code> 配 <code>interval = 15</code> 代表一個新啟動的後端要等 30 秒（兩次通過）才開始接流量。<code>unhealthy_threshold = 3</code> 代表連續三次失敗（45 秒）才被移出。閾值太寬鬆會把壞掉的後端留在輪替裡，讓部分使用者持續收到錯誤；太嚴格會在部署瞬間 — 新容器啟動、應用還在初始化 — 就判定不健康，反覆移出移入，使用者看到間歇性失敗。</p>
<table>
  <thead>
      <tr>
          <th>參數</th>
          <th>過小的風險</th>
          <th>過大的風險</th>
          <th>起點建議</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>interval</code></td>
          <td>ALB 對後端造成額外負擔</td>
          <td>壞後端被偵測到的延遲增加</td>
          <td>15-30 秒</td>
      </tr>
      <tr>
          <td><code>healthy_threshold</code></td>
          <td>還沒完全就緒就接流量</td>
          <td>部署後等太久才開始分流</td>
          <td>2-3 次</td>
      </tr>
      <tr>
          <td><code>unhealthy_threshold</code></td>
          <td>暫時性波動導致健康的後端被移出</td>
          <td>壞後端繼續收流量太久</td>
          <td>2-3 次</td>
      </tr>
      <tr>
          <td><code>timeout</code></td>
          <td>正常但偏慢的回應被誤判為失敗</td>
          <td>確實掛了卻要等很久才確認</td>
          <td>5 秒</td>
      </tr>
  </tbody>
</table>
<h3 id="健康檢查路徑的選擇">健康檢查路徑的選擇</h3>
<p><code>path</code> 指向的端點應該能反映應用是否確實能服務請求，而不只是 process 還活著。一個只回 200 的空端點（所謂 liveness check）證明 HTTP server 在跑，但不代表它能連到資料庫、能讀到必要的 config。較合理的做法是讓 <code>/healthz</code> 至少檢查核心依賴的連線（例如 ping 一下 DB），失敗時回 503。代價是健康檢查會跟著核心依賴一起報不健康 — 如果 DB 暫時斷了，所有後端都會被判定不健康，ALB 會回 503 給使用者。這是正確的行為：如果應用確實無法服務請求，把它標成不健康比假裝健康好。</p>
<p>判讀方式：部署後觀察 target group 裡的 healthy / unhealthy 轉換次數。如果每次部署都看到新 target 在 healthy 與 unhealthy 之間跳動，代表初始等待不夠 — 應用的啟動時間超出 <code>healthy_threshold * interval</code>，考慮加大 <code>healthy_threshold</code> 或設定 ECS 的 <code>startPeriod</code>（啟動寬限期）讓健康檢查在應用初始化期間暫停。</p>
<h2 id="tls-憑證acm-簽發dns-驗證與自動續期">TLS 憑證：ACM 簽發、DNS 驗證與自動續期</h2>
<p>HTTPS listener 引用的 TLS 憑證也屬於 ALB 的接線。用 ACM（AWS Certificate Manager）簽發的憑證在 IaC 裡完整描述 — 涵蓋網域與 DNS 驗證方式 — 讓「憑證存在、驗證、掛載」整條鏈都進版本控制，而非在 Console 手動上傳一份會過期沒人盯的憑證。</p>
<p>ACM 簽發的憑證使用 DNS 驗證時，ACM 要求在指定的 DNS 記錄上放一段驗證值。Terraform 可以自動建立這段記錄並等待驗證通過：</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_acm_certificate&#34; &#34;api&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  domain_name</span>       <span class="o">=</span> <span class="s2">&#34;api.${var.domain}&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  validation_method</span> <span class="o">=</span> <span class="s2">&#34;DNS&#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 class="n">  lifecycle { create_before_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><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_route53_record&#34; &#34;cert_validation&#34;</span> {
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  for_each</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">    for dvo in aws_acm_certificate.api.domain_validation_options : dvo.domain_name</span> <span class="o">=</span><span class="err">&gt;</span> <span class="k">dvo</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  }
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">  zone_id</span> <span class="o">=</span> <span class="k">data</span><span class="p">.</span><span class="k">aws_route53_zone</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">zone_id</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">  name</span>    <span class="o">=</span> <span class="k">each</span><span class="p">.</span><span class="k">value</span><span class="p">.</span><span class="k">resource_record_name</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">  type</span>    <span class="o">=</span> <span class="k">each</span><span class="p">.</span><span class="k">value</span><span class="p">.</span><span class="k">resource_record_type</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">  records</span> <span class="o">=</span> <span class="p">[</span><span class="k">each</span><span class="p">.</span><span class="k">value</span><span class="p">.</span><span class="k">resource_record_value</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="n">  ttl</span>     <span class="o">=</span> <span class="m">60</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 class="k">resource</span> <span class="s2">&#34;aws_acm_certificate_validation&#34; &#34;api&#34;</span> {
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="n">  certificate_arn</span>         <span class="o">=</span> <span class="k">aws_acm_certificate</span><span class="p">.</span><span class="k">api</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="n">  validation_record_fqdns</span> <span class="o">=</span> <span class="p">[</span><span class="k">for</span> <span class="k">r</span> <span class="k">in</span> <span class="k">aws_route53_record</span><span class="p">.</span><span class="k">cert_validation</span> <span class="err">:</span> <span class="k">r</span><span class="p">.</span><span class="k">fqdn</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">}</span></span></code></pre></div><h3 id="create_before_destroy-的必要性">create_before_destroy 的必要性</h3>
<p><code>create_before_destroy = true</code> 確保憑證更新（例如加 SAN 或續期觸發重建）時先建新的再刪舊的，避免 listener 在交接期間沒有可用憑證。Terraform 預設行為是先刪後建，會造成一個短暫的 HTTPS 中斷窗口 — listener 找不到憑證、所有 HTTPS 連線失敗直到新憑證簽發並驗證完畢。</p>
<p>ACM 簽發的憑證自動續期：只要 DNS 驗證記錄還在（由 Terraform 管理，所以會一直在），ACM 在到期前 60 天自動續期。這是把憑證管理成本降到接近零的做法 — 不需要排程提醒、不需要手動下載上傳。判讀訊號：如果 CloudWatch 出現 <code>DaysToExpiry</code> 降到 30 以下的 alarm，代表自動續期失敗，通常是 DNS 驗證記錄被手動刪了或 Route 53 zone 變了。</p>
<h3 id="多網域憑證san">多網域憑證（SAN）</h3>
<p>一張 ACM 憑證可以涵蓋多個網域（Subject Alternative Names），例如 <code>api.example.com</code> 和 <code>admin.example.com</code> 共用一張。在 IaC 裡用 <code>subject_alternative_names</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_acm_certificate&#34; &#34;multi&#34;</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  domain_name</span>               <span class="o">=</span> <span class="s2">&#34;api.${var.domain}&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  subject_alternative_names</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;admin.${var.domain}&#34;, &#34;*.internal.${var.domain}&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  validation_method</span>         <span class="o">=</span> <span class="s2">&#34;DNS&#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="n">  lifecycle { create_before_destroy</span> <span class="o">=</span> <span class="kt">true</span> }
</span></span><span class="line"><span class="ln">7</span><span class="cl">}</span></span></code></pre></div><p>共用一張還是分開簽取決於生命週期：如果這幾個網域總是一起上下線、一起變更，共用一張省維護；如果各自獨立演進，分開簽讓變更範圍更小。</p>
<h2 id="dns-zone-管理與-alb-的銜接">DNS zone 管理與 ALB 的銜接</h2>
<h3 id="hosted-zonedns-記錄的容器">Hosted zone：DNS 記錄的容器</h3>
<p>Route 53 的 hosted zone 是一個網域下所有 DNS 記錄的容器。public hosted zone 管理對外可見的網域（如 <code>example.com</code>），private hosted zone 管理只在 VPC 內可解析的內部網域（如 <code>internal.example.com</code>），讓服務之間用 DNS 名稱互連而不靠 IP。</p>
<p>多環境的 DNS 管理常用子網域 delegation：production 用 <code>example.com</code>（主 zone），dev 和 staging 各用 <code>dev.example.com</code> 和 <code>staging.example.com</code>（子 zone）。子 zone 可以放在不同帳號、由不同團隊管理，主 zone 只需要一組 NS 記錄指向子 zone。這讓環境之間的 DNS 邊界跟帳號邊界對齊。</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_route53_zone&#34; &#34;main&#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="k">var</span><span class="p">.</span><span class="k">domain</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_route53_zone&#34; &#34;staging&#34;</span> {
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  name</span> <span class="o">=</span> <span class="s2">&#34;staging.${var.domain}&#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="k">resource</span> <span class="s2">&#34;aws_route53_record&#34; &#34;staging_ns&#34;</span> {
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">  zone_id</span> <span class="o">=</span> <span class="k">aws_route53_zone</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">zone_id</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">  name</span>    <span class="o">=</span> <span class="s2">&#34;staging.${var.domain}&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">  type</span>    <span class="o">=</span> <span class="s2">&#34;NS&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">  ttl</span>     <span class="o">=</span> <span class="m">300</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">  records</span> <span class="o">=</span> <span class="k">aws_route53_zone</span><span class="p">.</span><span class="k">staging</span><span class="p">.</span><span class="k">name_servers</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">}</span></span></code></pre></div><p>hosted zone 也是 ACM 憑證 DNS 驗證的依賴 — ACM 簽發憑證時需要在對應的 zone 寫入一條驗證記錄，zone 不存在或不在同帳號就接不上。把 zone 的建立排在 ACM 之前，讓依賴圖自然正確。</p>
<h3 id="alb-的穩定-dns-記錄">ALB 的穩定 DNS 記錄</h3>
<p>ALB 重建後 DNS 名稱會改變。穩定對外的方式是在 Route 53 建一條 alias 記錄指向 ALB，使用者連的是 <code>api.example.com</code>，DNS 自動解析到 ALB 目前的位址：</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_route53_record&#34; &#34;api&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  zone_id</span> <span class="o">=</span> <span class="k">data</span><span class="p">.</span><span class="k">aws_route53_zone</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">zone_id</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  name</span>    <span class="o">=</span> <span class="s2">&#34;api.${var.domain}&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  type</span>    <span class="o">=</span> <span class="s2">&#34;A&#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">alias</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="k">aws_lb</span><span class="p">.</span><span class="k">api</span><span class="p">.</span><span class="k">dns_name</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">    zone_id</span>                <span class="o">=</span> <span class="k">aws_lb</span><span class="p">.</span><span class="k">api</span><span class="p">.</span><span class="k">zone_id</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">    evaluate_target_health</span> <span class="o">=</span> <span class="kt">true</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></span></code></pre></div><p><code>evaluate_target_health = true</code> 讓 Route 53 在 ALB 所有 target 都不健康時把這條記錄標為不健康。如果有多個 region 的 ALB 做了 failover routing，這個設定能讓 DNS 層自動切換到健康的 region — 屬於跨區域容災的地基，在 devops 模組展開。</p>
<h2 id="waf-與下一步">WAF 與下一步</h2>
<p>ALB 支援掛載 AWS WAF（Web Application Firewall），在流量進到應用之前先過一層規則 — 擋已知惡意 IP、防 SQL injection / XSS 的常見模式、限制單一 IP 的請求速率。WAF 的規則也可以寫進 IaC，讓「哪些流量被擋」成為可審查的程式碼而非 Console 上的設定。WAF 的詳細設計屬於安全層的範圍（見 <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 模組七：資安與資料保護</a>），這裡只確認它的掛載點是 ALB。</p>
<p>四類核心服務的 IaC 描述到此完成。下一步是讓這些服務可被觀測——log、metric、alarm 跟資源同生命週期建立，見<a href="/blog/infra/06-observability-logging/" data-link-title="模組六：可觀測性與 log 一併寫進 code" data-link-desc="log group、metric、alarm 跟基礎設施同生命週期管理，出事時追得到查得到">模組六：可觀測性與 log</a>。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<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>：ALB 的 security group 設計，group-to-group 引用</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>：ALB 是 stateless，但它引用的 ACM 憑證和 DNS 記錄有自己的生命週期考量</li>
<li>→ <a href="/blog/devops/01-load-balancing/" data-link-title="模組一：負載平衡與反向代理" data-link-desc="流量進來怎麼分給多個服務實例 — nginx / HAProxy / DNS round-robin 的選型和健康檢查路由設計">devops 模組一：負載平衡</a>：ALB 的運行期調校 — 跨 AZ 流量分配、connection draining、sticky session</li>
<li>→ <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 模組七：資安與資料保護</a>：WAF 規則設計</li>
</ul>
]]></content:encoded></item><item><title>模組四：環境分離與模組化</title><link>https://tarrragon.github.io/blog/infra/04-environment-separation/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/04-environment-separation/</guid><description>&lt;p>從目錄結構就定好環境邊界的專案，dev 跟 prod 是兩棵獨立的 state 樹、改錯一邊不會波及另一邊；等資源都長出來、流量都上線了才回頭切的專案，每一次 retrofit 都在帶電作業，動到的是正在服務客戶的網路與身分。同樣一套 module、同樣的工程師，差別只在「環境邊界是設計出來的、還是事後補的」，而這個差別在第一天幾乎零成本、在第一百天可能是一個季度的遷移專案。這一章談的是怎麼讓 dev 跟 prod 共用同一套 code、卻不互相污染，以及已經單環境建好地基的人怎麼安全地補上這條邊界。&lt;/p>
&lt;h2 id="環境分離從第一天的目錄結構就定好">環境分離從第一天的目錄結構就定好&lt;/h2>
&lt;p>環境分離的本質是把「同一套基礎設施定義」複製成多份隔離的執行實例，每份有自己的 state、自己的雲端資源、自己的故障半徑。它承擔的責任是讓 dev 的實驗、staging 的驗證、prod 的真實流量彼此不可見也不可達 — 在 dev 跑壞一個資料庫、套錯一條 security group 規則，prod 完全無感。&lt;/p>
&lt;p>這個邊界要在第一天就用目錄結構表達出來，原因是 state 一旦混在一起就難以無痛拆開。Terraform 這類工具用 state 檔記錄「哪個資源由哪段 code 管理」，如果 dev 跟 prod 的資源都登記在同一份 state，後續想把 prod 移出去，等於要對正在服務的資源做 &lt;code>state mv&lt;/code> 或 import/remove 操作 — 任何一步算錯，工具可能判定資源該銷毀重建，而那是 prod 的資料庫。第一天就分目錄，dev 與 prod 從來不曾共用 state，這個風險根本不存在。&lt;/p>
&lt;p>判讀訊號很簡單：如果現在只有一份 &lt;code>main.tf&lt;/code>、裡面同時宣告了 &lt;code>dev-db&lt;/code> 跟 &lt;code>prod-db&lt;/code>，這個專案已經欠下環境分離的債，債齡每天都在增加。下一步路由是先確立目錄骨架，再決定差異怎麼參數化。&lt;/p>
&lt;h2 id="目錄分離-vs-terraform-workspace-的取捨">目錄分離 vs Terraform workspace 的取捨&lt;/h2>
&lt;p>切分環境有兩條主流路徑：每個環境一個獨立目錄（各自持有 backend 與 state），或共用一份 code 用 Terraform workspace 切換不同 state。兩者都能讓 state 隔離，差別在「環境差異藏在哪裡」以及「誤操作的故障半徑多大」。&lt;/p>
&lt;p>在挑這兩條路之前，先把它們放回完整的分離強度光譜：環境分離橫跨一條從帳號到 workspace、隔離由粗到細的階梯，目錄與 workspace 只是相鄰的兩格，依隔離需求與維運成本取捨決定落在哪一格。最粗也最強的是帳號級隔離 — dev 與 prod 落在不同雲端帳號，憑證、計費與權限邊界天然分開，帳號邊界讓誤操作止於單一帳號（見&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>）。次強的是每環境一個獨立 repo，把 code、IAM 權限與 CI pipeline 都按環境切開，適合各環境由不同團隊維護或受不同合規等級約束。再往細是本章主要討論的目錄分離 — 同一 repo 內各環境有獨立目錄與 state，邊界仍顯式、但共用一套 code 與一組權限。最細的是 workspace，code 完全共用、只在執行期切換 state。光譜越靠粗的一端，隔離越強、跨環境共用越少、初始與維運成本越高；越靠細的一端，重複越少、邊界越隱性。多數早期團隊在目錄分離這一格落腳，因為它在顯式邊界與維運成本之間平衡得宜；當隔離需求升高（例如 prod 要法規等級的帳務與權限隔離），再沿光譜往帳號級或獨立 repo 移。&lt;/p>
&lt;p>目錄分離把每個環境寫成可獨立進入的工作目錄，差異透過各自的 &lt;code>terraform.tfvars&lt;/code> 表達，prod 的 backend 設定、變數值、甚至 provider 版本都各自鎖定。它的代價是目錄之間有重複的 boilerplate，好處是邊界顯式 — 你 &lt;code>cd&lt;/code> 進哪個目錄、apply 就只會動那個環境，prod 的 state 位址寫死在 prod 目錄的 backend 設定裡，不會因為忘記切換而打錯環境。&lt;/p>
&lt;p>目錄分離的 boilerplate 重複可以用 Terragrunt 這類工具收斂。Terragrunt 的存在理由正是把跨環境目錄共通的 backend、provider、module 呼叫抽成一份範本，各環境目錄只留差異值，等於在保留目錄顯式邊界的前提下補上一層 DRY。它划算的情境是環境數量多、共通 boilerplate 開始拖慢維護時，這層強化值得引入；環境只有兩三個時，直接維護幾份目錄的成本通常還低於多引入一個工具與它的學習曲線。&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">infra/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">├── modules/ # 可重用模組、不含任何環境專屬值
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">│ ├── network/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">│ ├── database/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">│ └── service/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">└── environments/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> ├── dev/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> │ ├── main.tf # 呼叫 modules、傳 dev 參數
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> │ ├── backend.tf # state 指向 dev 專屬位址
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> │ └── terraform.tfvars # dev 的差異值
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> ├── staging/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> │ └── ...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> └── prod/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> ├── main.tf
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> ├── backend.tf # state 指向 prod 專屬位址
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> └── terraform.tfvars # prod 的差異值&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Workspace 共用同一份 code、用 &lt;code>terraform workspace select prod&lt;/code> 在執行期切換 state。它的好處是零重複，所有環境的 code 保證同步；代價是環境差異只能靠 &lt;code>terraform.workspace&lt;/code> 在 code 裡寫條件判斷，而當前選中哪個 workspace 是 shell 的隱性狀態 — 在 dev workspace 以為自己在改 dev、其實上一個指令切到了 prod，apply 下去才發現故障半徑是 prod。這個隱性狀態正是早期最該避免的失誤來源。&lt;/p></description><content:encoded><![CDATA[<p>從目錄結構就定好環境邊界的專案，dev 跟 prod 是兩棵獨立的 state 樹、改錯一邊不會波及另一邊；等資源都長出來、流量都上線了才回頭切的專案，每一次 retrofit 都在帶電作業，動到的是正在服務客戶的網路與身分。同樣一套 module、同樣的工程師，差別只在「環境邊界是設計出來的、還是事後補的」，而這個差別在第一天幾乎零成本、在第一百天可能是一個季度的遷移專案。這一章談的是怎麼讓 dev 跟 prod 共用同一套 code、卻不互相污染，以及已經單環境建好地基的人怎麼安全地補上這條邊界。</p>
<h2 id="環境分離從第一天的目錄結構就定好">環境分離從第一天的目錄結構就定好</h2>
<p>環境分離的本質是把「同一套基礎設施定義」複製成多份隔離的執行實例，每份有自己的 state、自己的雲端資源、自己的故障半徑。它承擔的責任是讓 dev 的實驗、staging 的驗證、prod 的真實流量彼此不可見也不可達 — 在 dev 跑壞一個資料庫、套錯一條 security group 規則，prod 完全無感。</p>
<p>這個邊界要在第一天就用目錄結構表達出來，原因是 state 一旦混在一起就難以無痛拆開。Terraform 這類工具用 state 檔記錄「哪個資源由哪段 code 管理」，如果 dev 跟 prod 的資源都登記在同一份 state，後續想把 prod 移出去，等於要對正在服務的資源做 <code>state mv</code> 或 import/remove 操作 — 任何一步算錯，工具可能判定資源該銷毀重建，而那是 prod 的資料庫。第一天就分目錄，dev 與 prod 從來不曾共用 state，這個風險根本不存在。</p>
<p>判讀訊號很簡單：如果現在只有一份 <code>main.tf</code>、裡面同時宣告了 <code>dev-db</code> 跟 <code>prod-db</code>，這個專案已經欠下環境分離的債，債齡每天都在增加。下一步路由是先確立目錄骨架，再決定差異怎麼參數化。</p>
<h2 id="目錄分離-vs-terraform-workspace-的取捨">目錄分離 vs Terraform workspace 的取捨</h2>
<p>切分環境有兩條主流路徑：每個環境一個獨立目錄（各自持有 backend 與 state），或共用一份 code 用 Terraform workspace 切換不同 state。兩者都能讓 state 隔離，差別在「環境差異藏在哪裡」以及「誤操作的故障半徑多大」。</p>
<p>在挑這兩條路之前，先把它們放回完整的分離強度光譜：環境分離橫跨一條從帳號到 workspace、隔離由粗到細的階梯，目錄與 workspace 只是相鄰的兩格，依隔離需求與維運成本取捨決定落在哪一格。最粗也最強的是帳號級隔離 — dev 與 prod 落在不同雲端帳號，憑證、計費與權限邊界天然分開，帳號邊界讓誤操作止於單一帳號（見<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>）。次強的是每環境一個獨立 repo，把 code、IAM 權限與 CI pipeline 都按環境切開，適合各環境由不同團隊維護或受不同合規等級約束。再往細是本章主要討論的目錄分離 — 同一 repo 內各環境有獨立目錄與 state，邊界仍顯式、但共用一套 code 與一組權限。最細的是 workspace，code 完全共用、只在執行期切換 state。光譜越靠粗的一端，隔離越強、跨環境共用越少、初始與維運成本越高；越靠細的一端，重複越少、邊界越隱性。多數早期團隊在目錄分離這一格落腳，因為它在顯式邊界與維運成本之間平衡得宜；當隔離需求升高（例如 prod 要法規等級的帳務與權限隔離），再沿光譜往帳號級或獨立 repo 移。</p>
<p>目錄分離把每個環境寫成可獨立進入的工作目錄，差異透過各自的 <code>terraform.tfvars</code> 表達，prod 的 backend 設定、變數值、甚至 provider 版本都各自鎖定。它的代價是目錄之間有重複的 boilerplate，好處是邊界顯式 — 你 <code>cd</code> 進哪個目錄、apply 就只會動那個環境，prod 的 state 位址寫死在 prod 目錄的 backend 設定裡，不會因為忘記切換而打錯環境。</p>
<p>目錄分離的 boilerplate 重複可以用 Terragrunt 這類工具收斂。Terragrunt 的存在理由正是把跨環境目錄共通的 backend、provider、module 呼叫抽成一份範本，各環境目錄只留差異值，等於在保留目錄顯式邊界的前提下補上一層 DRY。它划算的情境是環境數量多、共通 boilerplate 開始拖慢維護時，這層強化值得引入；環境只有兩三個時，直接維護幾份目錄的成本通常還低於多引入一個工具與它的學習曲線。</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">├── modules/                  # 可重用模組、不含任何環境專屬值
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">│   ├── network/
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">│   ├── database/
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">│   └── service/
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">└── environments/
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    ├── dev/
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    │   ├── main.tf           # 呼叫 modules、傳 dev 參數
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    │   ├── backend.tf        # state 指向 dev 專屬位址
</span></span><span class="line"><span class="ln">10</span><span class="cl">    │   └── terraform.tfvars  # dev 的差異值
</span></span><span class="line"><span class="ln">11</span><span class="cl">    ├── staging/
</span></span><span class="line"><span class="ln">12</span><span class="cl">    │   └── ...
</span></span><span class="line"><span class="ln">13</span><span class="cl">    └── prod/
</span></span><span class="line"><span class="ln">14</span><span class="cl">        ├── main.tf
</span></span><span class="line"><span class="ln">15</span><span class="cl">        ├── backend.tf        # state 指向 prod 專屬位址
</span></span><span class="line"><span class="ln">16</span><span class="cl">        └── terraform.tfvars  # prod 的差異值</span></span></code></pre></div><p>Workspace 共用同一份 code、用 <code>terraform workspace select prod</code> 在執行期切換 state。它的好處是零重複，所有環境的 code 保證同步；代價是環境差異只能靠 <code>terraform.workspace</code> 在 code 裡寫條件判斷，而當前選中哪個 workspace 是 shell 的隱性狀態 — 在 dev workspace 以為自己在改 dev、其實上一個指令切到了 prod，apply 下去才發現故障半徑是 prod。這個隱性狀態正是早期最該避免的失誤來源。</p>
<p>早期推薦目錄分離，理由是故障半徑與認知負荷的取捨在小團隊明顯偏向「顯式邊界」這一側：團隊還沒有成熟的 CI gate 攔截誤 apply，顯式目錄是最便宜的防呆。Workspace 較划算的情境是環境數量多且高度同構（例如每個客戶一個隔離環境、差異只有名稱與配額），重複目錄的維護成本開始超過 workspace 隱性狀態的風險時，再切過去。每個環境的 state 要怎麼各自隔離、backend 怎麼設定，見<a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一：最小可行 IaC</a>。</p>
<h2 id="module-化同一套-code不同參數">module 化：同一套 code、不同參數</h2>
<p>Module 是把一組會被多環境重複使用的資源封裝成有輸入參數的單元，承擔的責任是讓 dev 與 prod 共享同一份邏輯定義、只在參數上分歧。沒有 module 時，dev 與 prod 各自維護一份 copy-paste 的資源宣告，兩份會隨時間漂移 — 有人只在 prod 補了一條 security group 規則、忘了同步 dev，於是「dev 能跑、prod 卻爆掉」或更糟的「dev 測過了、prod 行為不同」。</p>
<p>避免漂移的關鍵是讓環境之間唯一合法的差異來源是傳進 module 的參數，而不是 module 內部的 code 分支。Module 內部不寫 <code>if env == &quot;prod&quot;</code> 這類判斷，所有環境相關的值都從 <code>variable</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="c1"># modules/database/variables.tf — module 只宣告它需要什麼參數
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">variable</span> <span class="s2">&#34;instance_class&#34;</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  type</span> <span class="o">=</span> <span class="k">string</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">variable</span> <span class="s2">&#34;multi_az&#34;</span> {
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">  type</span>    <span class="o">=</span> <span class="k">bool</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">  default</span> <span class="o">=</span> <span class="kt">false</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><span class="line"><span class="ln">11</span><span class="cl"><span class="k">variable</span> <span class="s2">&#34;backup_retention_days&#34;</span> {
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">  type</span>    <span class="o">=</span> <span class="k">number</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">  default</span> <span class="o">=</span> <span class="m">7</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">}</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"># environments/prod/main.tf — prod 傳自己的值
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">module</span> <span class="s2">&#34;database&#34;</span> {
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  source</span>                <span class="o">=</span> <span class="s2">&#34;../../modules/database&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  instance_class</span>        <span class="o">=</span> <span class="s2">&#34;db.r6g.xlarge&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">  multi_az</span>              <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">  backup_retention_days</span> <span class="o">=</span> <span class="m">30</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">}</span></span></code></pre></div><p>這樣 dev 與 prod 跑的是位元層級相同的 module code，差異全部收斂在 <code>main.tf</code> 的呼叫參數裡、一眼可審。判讀訊號是 review 時只要 diff 各環境的參數區塊就能看完所有環境差異；如果發現有人為了某環境的特例去改 module 內部，那是漂移正在發生的徵兆，該把特例改寫成新的參數。核心服務怎麼用 module 跨環境重用，見<a href="/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a>。</p>
<h2 id="環境差異參數化prod-放大dev-縮小">環境差異參數化：prod 放大、dev 縮小</h2>
<p>環境之間真正該不同的是規模與冗餘等級，而這些差異全部表達成參數值、不表達成不同的 code。Prod 承擔真實流量與可用性承諾，所以跨多個可用區（multi-AZ）部署、機器規格放大、備份保留更久、開啟刪除保護；dev 承擔的是迭代速度與成本控制，所以單 AZ、最小機型、短備份甚至無備份，壞了重建即可。</p>
<p>把這些差異參數化的好處是「環境拓樸的形狀一致、只有刻度不同」。Dev 與 prod 都經過同一段 module 邏輯，prod 不會出現一段 dev 從未執行過的 code path — 真正上線的設定，在 dev 已經以縮小版驗證過邏輯正確性。常見陷阱是把成本差異做成「dev 直接砍掉某個元件」：例如 dev 為了省錢不建負載平衡器、prod 才建，結果 prod 的 LB 相關設定從來沒在 dev 測過。較划算的做法是 dev 也建同型元件、只把規格與數量縮到最小，讓拓樸保持同構、只縮放刻度。</p>
<p>邊界在於少數差異無法只靠刻度表達 — 例如 prod 需要合規要求的稽核 log、dev 不需要。這類用 <code>count</code> 或 <code>for_each</code> 配一個布林參數開關，仍然走參數化、不分叉 code。跨可用區與冗餘的網路面怎麼鋪，見<a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>。</p>
<h2 id="retrofit-路徑把單環境拆成-per-env-module">retrofit 路徑：把單環境拆成 per-env module</h2>
<p>很多專案是先在單一環境把 IAM、VPC、核心資源都建起來、跑通了，才意識到需要環境分離 — 這是常見且合理的演進順序，尤其是先救火上線、之後才回頭納管的情況。Retrofit 的目標是在不破壞正在服務的資源前提下，把這份「隱含為 prod」的單環境，重構成「modules + per-env 呼叫」的結構，並讓現有資源平移成 prod 環境。承接<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>與<a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>先建好的單環境地基，這一段就是把它們納入 per-env 管理的路線。</p>
<p>安全的步驟順序是先重構 code、再動資源歸屬，且每一步都用 <code>terraform plan</code> 確認「零變更」：</p>
<ol>
<li><strong>把現有資源宣告抽成 module</strong>：把 <code>main.tf</code> 裡的資源搬進 <code>modules/</code>、原地用 module 呼叫取代，所有值先寫死成現況。此時 <code>plan</code> 必須顯示無任何新增或銷毀 — 只是重新組織 code，資源在 state 裡的位址若有變，用 <code>moved {}</code> 區塊宣告搬遷、避免工具誤判為「銷毀舊的、建新的」。</li>
<li><strong>把寫死的值換成 prod 的參數</strong>：把現況值搬進 <code>environments/prod/terraform.tfvars</code>，module 改吃參數。<code>plan</code> 仍須零變更，因為參數值就等於現況值。</li>
<li><strong>建立其他環境目錄</strong>：複製 prod 的呼叫結構成 <code>environments/dev/</code>，給它自己的 backend（獨立 state）與縮小的參數值。這一步是純新增、不碰 prod。</li>
<li><strong>逐一驗證</strong>：先在 dev <code>apply</code> 出一套完整的縮小版環境、確認 module 在新環境也能 plan/apply 乾淨，再回頭確信 prod 的重構沒有副作用。</li>
</ol>
<p>最大的風險集中在前兩步：現有資源是活的，任何讓工具判定「需要替換」的改動，對 IAM 角色可能是短暫權限真空、對 VPC 可能是子網重建導致服務中斷。防護是把每一次 <code>plan</code> 的輸出當成必須為零的驗收條件，非零就停下來查 <code>moved</code> 區塊或參數值哪裡跟現況不符。狀態危險的訊號是 <code>plan</code> 出現任何 <code>destroy</code> 或 <code>forces replacement</code>，在 prod 路徑上這幾乎都該先暫停。第二個風險是 state 操作本身 — retrofit 期間務必先備份 state 檔，<code>state mv</code> 與 <code>moved</code> 區塊優先用後者（宣告式、可 review、可回滾），手動 <code>state mv</code> 留給 <code>moved</code> 表達不了的跨 module 搬遷。整個 retrofit 走 PR 流程、讓 plan 輸出在 review 時可見，見<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>。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/infra/04-environment-separation/directory-module-parameterization/" data-link-title="環境分離與模組化 — 目錄結構、module 參數化與 retrofit 路徑" data-link-desc="用目錄結構在第一天就隔開 dev 與 prod 的 state，用 module 讓環境共用同一套邏輯只差參數，以及已經單環境跑起來後怎麼安全拆分">環境分離與模組化 — 目錄結構、module 參數化與 retrofit 路徑</a></td>
          <td>用目錄結構隔開 dev 與 prod 的 state，用 module 讓環境共用同一套邏輯只差參數，以及單環境跑起來後怎麼安全拆分</td>
      </tr>
      <tr>
          <td><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></td>
          <td>moved block 步驟、zero-change plan 驗證、state 備份、forces replacement 風險控制</td>
      </tr>
  </tbody>
</table>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<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 怎麼隔開</li>
<li>→ <a href="/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a>：核心服務怎麼用 module 跨環境重用</li>
</ul>
]]></content:encoded></item><item><title>Stateful 資源保護與跨服務依賴表達</title><link>https://tarrragon.github.io/blog/infra/05-core-services/stateful-protection-dependency/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/05-core-services/stateful-protection-dependency/</guid><description>&lt;p>核心服務寫進 IaC 之後，stateful 資源需要一套與 stateless 截然不同的保護與操作規範。資料庫、裝了正式資料的 S3 bucket、持久化 volume 這類資源的共同特性是：重建代價極高甚至不可逆。運算節點掛了重開一台，資料刪了就是刪了。這個差別會傳導到 IaC 的描述方式、變更的審查強度、以及 drift 的處理策略。&lt;/p>
&lt;p>本篇同時處理服務之間依賴的表達方式 — output 與 data source — 因為依賴表達直接影響 stateful 資源的爆炸半徑：同一份 state 裡的資料庫跟運算綁在一起 apply，還是拆成獨立 state 各自演進，決定了一次 apply 失敗會波及多少資源。&lt;/p>
&lt;h2 id="stateful-資源的保護策略">stateful 資源的保護策略&lt;/h2>
&lt;p>stateful 資源的 IaC 描述要把「保護狀態」當成第一類需求，而非事後補上的選項。保護的三個面向 — 可用性、可還原性、防誤刪 — 各自對應不同的機制，混在一起談會讓判斷失焦。&lt;/p>
&lt;h3 id="multi-az-的職責邊界">multi-AZ 的職責邊界&lt;/h3>
&lt;p>multi-AZ 用一個布林屬性開啟，背後是 RDS 在另一個可用區維護同步副本。它承擔的是可用性：主庫所在的可用區故障時，RDS 自動 failover 到 standby，服務在秒級到一兩分鐘的窗口後恢復。&lt;/p>
&lt;p>multi-AZ 的邊界要明確界定，因為把它當成超出職責的工具會在事故裡踩空：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>standby 是熱備不可讀&lt;/strong>。multi-AZ 的 standby 不接受任何查詢流量，所以它不提供讀取擴展。要分攤讀流量得另開 read replica，這是另一個資源、另一個端點、另一套複寫延遲要管。&lt;/li>
&lt;li>&lt;strong>failover 有切換窗口&lt;/strong>。切換期間應用的資料庫連線會中斷、需要重連。應用層如果沒有處理連線中斷的重試邏輯，failover 就會變成一段可見的服務中斷，而非透明切換。&lt;/li>
&lt;li>&lt;strong>它不防邏輯損壞&lt;/strong>。誤刪一張 table、一筆錯誤的批次 UPDATE、一段有 bug 的 migration script — 這些操作會同步複製到 standby。multi-AZ 防的是硬體與可用區故障，邏輯損壞的防線是備份與時間點還原（PITR）。&lt;/li>
&lt;/ul>
&lt;p>這三條邊界說明 multi-AZ 和 backup 的職責正交：前者解可用性，後者解可還原性。兩者要分別配置、分別驗證。成本參考：multi-AZ RDS 的費用約為 single-AZ 的兩倍（standby instance 按相同規格計費）。這筆費用對應的能力是可用區故障時的分鐘級自動 failover——判斷值不值得時，用主庫所承載的服務停機每小時的商業代價來衡量。&lt;/p>
&lt;h3 id="備份保留與時間點還原">備份保留與時間點還原&lt;/h3>
&lt;p>backup 用保留天數與備份視窗描述。RDS 依此每日自動快照並保留交易日誌，以支援還原到任意時間點（PITR）。自動備份的保留上限是 35 天，更長的留存要靠手動快照或匯出到 S3 自行管理。&lt;/p>
&lt;p>&lt;code>backup_retention_period&lt;/code> 取多少天，以 RPO（Recovery Point Objective）與合規要求反推。RPO 問的是「出事時最多能接受遺失多久的資料」— PITR 能還原到最近 5 分鐘內的時間點，但前提是自動備份有開、交易日誌有保留。保留天數決定的是「能回溯多遠」：14 天是 AWS RDS 自動備份 35 天上限的保守折衷，足以涵蓋多數營運場景下「發現問題到決定還原」的時間差；受監理的服務往 30 天推，以滿足稽核追溯窗口。&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">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_db_instance&amp;#34; &amp;#34;primary&amp;#34;&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"> multi_az&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">3&lt;/span>&lt;span class="cl">&lt;span class="n"> backup_retention_period&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">14&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"> backup_window&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;03:00-04:00&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"> deletion_protection&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">6&lt;/span>&lt;span class="cl">&lt;span class="n"> skip_final_snapshot&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kt">false&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"> final_snapshot_identifier&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;app-prod-final-${formatdate(&amp;#34;YYYYMMDD&amp;#34;, timestamp())}&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;/code>&lt;/pre>&lt;/div>&lt;p>備份視窗選在流量低谷（如 UTC 凌晨），避免快照 IO 跟尖峰流量競爭。手動快照用獨立資源描述，常見用途是重大變更前的保險點 — 大版本升級、schema migration、或任何會改變資料結構的操作。&lt;/p>
&lt;h3 id="刪除保護與-final-snapshot">刪除保護與 final snapshot&lt;/h3>
&lt;p>&lt;code>deletion_protection = true&lt;/code> 讓 &lt;code>terraform destroy&lt;/code> 無法直接刪除這個 instance — 要先用另一次 apply 把保護關掉，這一步本身就會出現在 plan 裡、被 review 攔住。&lt;code>skip_final_snapshot = false&lt;/code> 確保即使確實要刪，也會先拍一份最終快照。兩者搭配是正式資料庫的硬性下限。&lt;/p>
&lt;p>該在 review 攔下的訊號是：正式環境的 stateful 資源若 &lt;code>backup_retention_period&lt;/code> 為 0 或 &lt;code>deletion_protection&lt;/code> 為 false，代表狀態保護沒有寫進程式碼。把這些屬性視為正式資料庫的預設值，而非可調的偏好。&lt;/p>
&lt;p>S3 bucket 的保護同理但機制不同。versioning 讓覆寫或刪除的物件可以回到先前版本；MFA delete 要求刪除前提供第二因素驗證；lifecycle rule 控制舊版本的保留時間 — 這三者分別對應「可還原」「防誤刪」「控成本」三個職責，見&lt;a href="https://tarrragon.github.io/blog/infra/05-core-services/storage-s3/" data-link-title="儲存上 IaC — S3 bucket 的安全與生命週期" data-link-desc="S3 bucket 的加密、版本控制、公開存取封鎖、生命週期規則、bucket policy 與事件通知怎麼寫進 IaC，讓儲存的安全與成本防線可審查可追蹤">儲存（S3）&lt;/a>。&lt;/p>
&lt;h3 id="跨-region-災難復原的邊界">跨 region 災難復原的邊界&lt;/h3>
&lt;p>multi-AZ 解的是可用區級故障 — 單一資料中心出問題時，同 region 的另一個可用區接手。跨 region 的災難復原（cross-region read replica、S3 cross-region replication、Route 53 failover routing）屬於更高級的可用性投資，解的是整個 region 不可用的極端情境。它的成本與複雜度顯著上升：跨 region 複寫有延遲、failover routing 需要健康檢查與 DNS TTL 配合、兩個 region 的 infra 要各自維護。多數服務在單 region 的 multi-AZ + 備份做完之後再評估是否需要跨 region，依據是業務的 RTO（Recovery Time Objective）對 region 級故障的容忍度。&lt;/p></description><content:encoded><![CDATA[<p>核心服務寫進 IaC 之後，stateful 資源需要一套與 stateless 截然不同的保護與操作規範。資料庫、裝了正式資料的 S3 bucket、持久化 volume 這類資源的共同特性是：重建代價極高甚至不可逆。運算節點掛了重開一台，資料刪了就是刪了。這個差別會傳導到 IaC 的描述方式、變更的審查強度、以及 drift 的處理策略。</p>
<p>本篇同時處理服務之間依賴的表達方式 — output 與 data source — 因為依賴表達直接影響 stateful 資源的爆炸半徑：同一份 state 裡的資料庫跟運算綁在一起 apply，還是拆成獨立 state 各自演進，決定了一次 apply 失敗會波及多少資源。</p>
<h2 id="stateful-資源的保護策略">stateful 資源的保護策略</h2>
<p>stateful 資源的 IaC 描述要把「保護狀態」當成第一類需求，而非事後補上的選項。保護的三個面向 — 可用性、可還原性、防誤刪 — 各自對應不同的機制，混在一起談會讓判斷失焦。</p>
<h3 id="multi-az-的職責邊界">multi-AZ 的職責邊界</h3>
<p>multi-AZ 用一個布林屬性開啟，背後是 RDS 在另一個可用區維護同步副本。它承擔的是可用性：主庫所在的可用區故障時，RDS 自動 failover 到 standby，服務在秒級到一兩分鐘的窗口後恢復。</p>
<p>multi-AZ 的邊界要明確界定，因為把它當成超出職責的工具會在事故裡踩空：</p>
<ul>
<li><strong>standby 是熱備不可讀</strong>。multi-AZ 的 standby 不接受任何查詢流量，所以它不提供讀取擴展。要分攤讀流量得另開 read replica，這是另一個資源、另一個端點、另一套複寫延遲要管。</li>
<li><strong>failover 有切換窗口</strong>。切換期間應用的資料庫連線會中斷、需要重連。應用層如果沒有處理連線中斷的重試邏輯，failover 就會變成一段可見的服務中斷，而非透明切換。</li>
<li><strong>它不防邏輯損壞</strong>。誤刪一張 table、一筆錯誤的批次 UPDATE、一段有 bug 的 migration script — 這些操作會同步複製到 standby。multi-AZ 防的是硬體與可用區故障，邏輯損壞的防線是備份與時間點還原（PITR）。</li>
</ul>
<p>這三條邊界說明 multi-AZ 和 backup 的職責正交：前者解可用性，後者解可還原性。兩者要分別配置、分別驗證。成本參考：multi-AZ RDS 的費用約為 single-AZ 的兩倍（standby instance 按相同規格計費）。這筆費用對應的能力是可用區故障時的分鐘級自動 failover——判斷值不值得時，用主庫所承載的服務停機每小時的商業代價來衡量。</p>
<h3 id="備份保留與時間點還原">備份保留與時間點還原</h3>
<p>backup 用保留天數與備份視窗描述。RDS 依此每日自動快照並保留交易日誌，以支援還原到任意時間點（PITR）。自動備份的保留上限是 35 天，更長的留存要靠手動快照或匯出到 S3 自行管理。</p>
<p><code>backup_retention_period</code> 取多少天，以 RPO（Recovery Point Objective）與合規要求反推。RPO 問的是「出事時最多能接受遺失多久的資料」— PITR 能還原到最近 5 分鐘內的時間點，但前提是自動備份有開、交易日誌有保留。保留天數決定的是「能回溯多遠」：14 天是 AWS RDS 自動備份 35 天上限的保守折衷，足以涵蓋多數營運場景下「發現問題到決定還原」的時間差；受監理的服務往 30 天推，以滿足稽核追溯窗口。</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></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  multi_az</span>                  <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  backup_retention_period</span>   <span class="o">=</span> <span class="m">14</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  backup_window</span>             <span class="o">=</span> <span class="s2">&#34;03:00-04:00&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">  deletion_protection</span>       <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">  skip_final_snapshot</span>       <span class="o">=</span> <span class="kt">false</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="n">  final_snapshot_identifier</span> <span class="o">=</span> <span class="s2">&#34;app-prod-final-${formatdate(&#34;YYYYMMDD&#34;, timestamp())}&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">}</span></span></code></pre></div><p>備份視窗選在流量低谷（如 UTC 凌晨），避免快照 IO 跟尖峰流量競爭。手動快照用獨立資源描述，常見用途是重大變更前的保險點 — 大版本升級、schema migration、或任何會改變資料結構的操作。</p>
<h3 id="刪除保護與-final-snapshot">刪除保護與 final snapshot</h3>
<p><code>deletion_protection = true</code> 讓 <code>terraform destroy</code> 無法直接刪除這個 instance — 要先用另一次 apply 把保護關掉，這一步本身就會出現在 plan 裡、被 review 攔住。<code>skip_final_snapshot = false</code> 確保即使確實要刪，也會先拍一份最終快照。兩者搭配是正式資料庫的硬性下限。</p>
<p>該在 review 攔下的訊號是：正式環境的 stateful 資源若 <code>backup_retention_period</code> 為 0 或 <code>deletion_protection</code> 為 false，代表狀態保護沒有寫進程式碼。把這些屬性視為正式資料庫的預設值，而非可調的偏好。</p>
<p>S3 bucket 的保護同理但機制不同。versioning 讓覆寫或刪除的物件可以回到先前版本；MFA delete 要求刪除前提供第二因素驗證；lifecycle rule 控制舊版本的保留時間 — 這三者分別對應「可還原」「防誤刪」「控成本」三個職責，見<a href="/blog/infra/05-core-services/storage-s3/" data-link-title="儲存上 IaC — S3 bucket 的安全與生命週期" data-link-desc="S3 bucket 的加密、版本控制、公開存取封鎖、生命週期規則、bucket policy 與事件通知怎麼寫進 IaC，讓儲存的安全與成本防線可審查可追蹤">儲存（S3）</a>。</p>
<h3 id="跨-region-災難復原的邊界">跨 region 災難復原的邊界</h3>
<p>multi-AZ 解的是可用區級故障 — 單一資料中心出問題時，同 region 的另一個可用區接手。跨 region 的災難復原（cross-region read replica、S3 cross-region replication、Route 53 failover routing）屬於更高級的可用性投資，解的是整個 region 不可用的極端情境。它的成本與複雜度顯著上升：跨 region 複寫有延遲、failover routing 需要健康檢查與 DNS TTL 配合、兩個 region 的 infra 要各自維護。多數服務在單 region 的 multi-AZ + 備份做完之後再評估是否需要跨 region，依據是業務的 RTO（Recovery Time Objective）對 region 級故障的容忍度。</p>
<p>跨 region 的 infra 投資在 B2B SaaS 的合約義務下更容易成立。<a href="/blog/backend/09-performance-capacity/cases/genesys-dynamodb-99999-availability/" data-link-title="9.C24 Genesys：用 DynamoDB 在 15 region 跑出 99.999% 可用性" data-link-desc="Genesys 客服平台用 DynamoDB 為預設資料層、跨 15 主 region &#43; 5 衛星 region、達成 12 個月 99.999% 可用性">Genesys 的客服平台跨 15 個 region 用 DynamoDB 達成 99.999% 可用性</a>——年停機只有 5 分鐘。對 B2B SaaS 來說，客戶服務中斷等於客戶的終端使用者打不通電話，可用性是合約義務而非行銷敘述。infra 層的判斷依據是：multi-AZ 不夠用（業務需要跨 region failover）的情況通常由合約 SLA 驅動，而非技術判斷驅動。</p>
<h2 id="stateful-與-stateless-的操作差異">stateful 與 stateless 的操作差異</h2>
<p>stateful 與 stateless 資源的根本差別在重建代價。這個差別傳導到三個操作後果，每一個都影響日常的 PR review 與 apply 流程。</p>
<h3 id="刪除保護的必要性">刪除保護的必要性</h3>
<p>stateless 資源（ECS service、ALB、無狀態運算）重建只是換一組新實例，幾分鐘內恢復、沒有資料損失，所以它們可以被頻繁地 destroy 與 recreate — 這是 IaC 最擅長的對象。stateful 資源重建意味著資料遺失或漫長的還原，代價可能是數小時的停機與不可逆的損失。開啟 deletion protection 讓「不小心 destroy」需要先顯式關閉保護這一步，多一道人為確認。</p>
<h3 id="drift-容忍度">drift 容忍度</h3>
<p>stateless 資源的 drift 可以靠重建抹平 — apply 一次就回到程式碼的狀態，副作用只是新實例的短暫滾動更新。stateful 資源的 drift 要謹慎處理，因為 IaC 的「修正回程式碼狀態」動作可能觸發重啟甚至重建。</p>
<p>一個常見的情境：某人手動改了 RDS 的 parameter group，Terraform plan 顯示要把它改回程式碼的版本。這個改回動作是 <code>update in-place</code>（改設定、不重建）還是 <code>replace</code>（先刪後建），取決於哪個參數被改了 — 某些 parameter 的修改需要重啟，而某些需要整個 instance 重建。判讀方式是先跑 plan、看 drift 修正的結果，<code>update in-place</code> 通常安全（可能觸發重啟），<code>replace</code> 對資料庫意味著先刪後建，在 prod 上需要額外的確認。</p>
<h3 id="變更審查強度">變更審查強度</h3>
<p>改動 stateful 資源的 plan 輸出要逐行看，特別警惕任何顯示為 <code>replace</code>（<code>-/+</code>）或標記 <code>forces replacement</code> 的項目。某些欄位的改動看似無害但會觸發 replace：</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>預期行為</th>
          <th>實際行為</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>RDS <code>identifier</code> 改名</td>
          <td>改個名字而已</td>
          <td>forces replacement</td>
      </tr>
      <tr>
          <td>RDS <code>engine_version</code> 大版本</td>
          <td>升級引擎版本</td>
          <td>可能 replace 或 in-place</td>
      </tr>
      <tr>
          <td>RDS <code>storage_type</code> 變更</td>
          <td>換儲存類型</td>
          <td>部分組合 forces replacement</td>
      </tr>
      <tr>
          <td>S3 bucket <code>bucket</code> 改名</td>
          <td>改個名字而已</td>
          <td>forces replacement</td>
      </tr>
  </tbody>
</table>
<p>Review 時看到 stateful 資源出現 <code>forces replacement</code>，在 prod 路徑上幾乎都該先暫停、確認回退路徑（手動快照是否已拍）再決定是否繼續。常見做法是把這個差別寫進流程：stateful 資源的變更走更嚴格的 PR review 與分階段套用（先在 dev apply 驗證、確認是 in-place 後再推 prod），自動化護欄在<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>展開。</p>
<h2 id="服務之間的依賴怎麼表達">服務之間的依賴怎麼表達</h2>
<p>服務間依賴用 output 與 data source 表達，讓引用關係成為程式碼裡可追蹤的邊，而非靠人記憶的隱性約定。引用方式的選擇直接影響 state 的大小與爆炸半徑。</p>
<h3 id="同-state-內的引用">同 state 內的引用</h3>
<p>同一個 state 內，直接引用資源屬性即可建立依賴。運算資源引用資料庫的端點，IaC 自動推導出「資料庫先於運算」的邊，也讓端點變更時上層自動取得新值：</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_ecs_task_definition&#34; &#34;api&#34;</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  container_definitions</span> <span class="o">=</span> <span class="k">jsonencode</span><span class="p">([</span>{
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">    environment</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">      { name</span> <span class="o">=</span><span class="n"> &#34;DB_HOST&#34;, value</span> <span class="o">=</span> <span class="k">aws_db_instance</span><span class="p">.</span><span class="k">primary</span><span class="p">.</span><span class="k">endpoint</span> }
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="p">]</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  }<span class="p">])</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">}</span></span></code></pre></div><p>同 state 引用的好處是依賴圖最完整 — apply 一次就把所有引用解析到正確的值。代價是 state 越大、單次 apply 的爆炸半徑越大。一份包含網路、資料庫、運算、LB 的 state，一次 apply 失敗可能讓所有資源處於半完成狀態。</p>
<h3 id="跨-state-的-data-source">跨 state 的 data source</h3>
<p>跨 state（例如網路地基與核心服務分屬不同 Terraform state，呼應<a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>的拆分）時，下游用 data source 唯讀地讀取上游已建立的資源：</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">data</span> <span class="s2">&#34;aws_vpc&#34; &#34;main&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  tags</span> <span class="o">=</span><span class="n"> { Name</span> <span class="o">=</span> <span class="s2">&#34;app-${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></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">data</span> <span class="s2">&#34;aws_subnets&#34; &#34;private&#34;</span> {
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="k">filter</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;vpc-id&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">    values</span> <span class="o">=</span> <span class="p">[</span><span class="k">data</span><span class="p">.</span><span class="k">aws_vpc</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">id</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 class="n">  tags</span> <span class="o">=</span><span class="n"> { tier</span> <span class="o">=</span> <span class="s2">&#34;private&#34;</span> }
</span></span><span class="line"><span class="ln">11</span><span class="cl">}</span></span></code></pre></div><p>下游查詢上游的 VPC 與 subnet，取得 ID 來放置自己的資源，而不複製貼上硬編碼的值。</p>
<h3 id="同-state-vs-跨-state-的取捨">同 state vs 跨 state 的取捨</h3>
<p>兩種方式的取捨在耦合與隔離之間：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>同 state 引用</th>
          <th>跨 state data source</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>依賴圖</td>
          <td>完整、自動推導</td>
          <td>跨 state 邊界，需約定上游先 apply</td>
      </tr>
      <tr>
          <td>爆炸半徑</td>
          <td>state 越大、單次 apply 越大</td>
          <td>各 state 獨立、爆炸半徑小</td>
      </tr>
      <tr>
          <td>適合場景</td>
          <td>少量緊密耦合的資源</td>
          <td>地基層與服務層分離</td>
      </tr>
      <tr>
          <td>drift 風險</td>
          <td>低（引用自動追蹤）</td>
          <td>中（上游重建後 data source 可能查不到）</td>
      </tr>
  </tbody>
</table>
<p>用 grep 搜一遍核心服務的 HCL：如果出現大量寫死的 subnet ID 或 VPC ID，代表該用 data source 而沒用。這些硬編碼是日後上游重建時 drift 與 broken reference 的來源。把它們換成 data source，依賴關係才會在程式碼裡顯性化、可被工具與 review 看見。</p>
<p>data source 查詢的可靠性取決於查詢條件的穩定度。用 <code>tags</code> 查比用 <code>Name</code> 查更穩 — tag 是自己定義的、可控的值，而某些資源的 Name 可能在重建時改變。用 <code>terraform_remote_state</code> data source 直接讀上游的 state output 是最精確的方式，但它把兩份 state 的 backend 設定耦合在一起，上游搬 state 時下游也要跟著改。在團隊規模小、state 拆分不多的階段，<code>terraform_remote_state</code> 的耦合代價通常可接受；團隊變大後，用 tag-based data source 或 SSM Parameter Store 當中間層，能讓上下游各自獨立演進。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<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>：核心服務落在哪些 subnet、security group 怎麼引用</li>
<li>→ <a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>：跨 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>：stateful 變更的自動化護欄</li>
</ul>
]]></content:encoded></item><item><title>模組五：核心服務上 IaC</title><link>https://tarrragon.github.io/blog/infra/05-core-services/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/05-core-services/</guid><description>&lt;p>地基就緒後，依「地基 → 上層」的順序把實際承載業務的服務寫進 IaC。前四個模組建立的身分、網路與環境分離是底層平面，這一層在它們之上描述資料庫、運算、儲存與入口 — 業務流量真正落地的地方。順序與依賴的表達方式決定了這層能不能被乾淨地重建、拆除與演進。&lt;/p>
&lt;h2 id="上核心服務的順序">上核心服務的順序&lt;/h2>
&lt;p>核心服務的部署順序由依賴方向決定：被依賴的先建，依賴別人的後建。網路與身分是幾乎所有上層服務的共同前置 — 資料庫要放進私有 subnet、運算要套用 IAM role 才能讀 S3、load balancer 要掛在公開 subnet 並引用 security group。這些底層平面若還沒成形，上層資源會在 apply 時因為找不到 subnet ID 或 role ARN 而失敗，或更糟，建在預設 VPC 裡繞過了所有隔離設計。&lt;/p>
&lt;p>把順序交給 IaC 工具的依賴圖自動推導，比人工排序可靠。當運算資源的定義引用了 subnet 與 security group 的資源屬性，Terraform 會解析出「subnet 先於運算」的邊，apply 時自動排程。人工維護一份「先做 A 再做 B」的清單會隨資源增加而失準，依賴圖則隨程式碼本身演進。&lt;/p>
&lt;p>順序失控的早期徵兆是：某個上層資源的定義裡寫了一串 hardcode 的 subnet ID 或 VPC ID，代表它沒有透過依賴圖連到底層平面。底層一旦重建、ID 改變，上層不會自動跟上，state 與雲端現實之間的不一致（即 drift）就此產生。把硬編碼的 ID 換成對底層資源屬性或 data source 的引用，順序才會回到工具掌控之內。&lt;/p>
&lt;h2 id="各類服務怎麼描述">各類服務怎麼描述&lt;/h2>
&lt;p>四類核心服務承擔不同責任，IaC 描述它們時關注的屬性也不同。共通原則是：描述服務的「身分與接線」，而非把每個執行期參數都塞進程式碼。&lt;/p>
&lt;p>&lt;strong>資料庫（RDS）&lt;/strong> 是這層裡最需要謹慎描述的資源，因為它持有無法重建的狀態。IaC 定義它的 instance class、引擎版本、所在的 subnet group（決定它落在哪些私有 subnet）、套用的 parameter group 與 security group。連線端點不要硬編碼，改用資源 output 暴露給上層運算引用。&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">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_db_instance&amp;#34; &amp;#34;primary&amp;#34;&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"> identifier&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">3&lt;/span>&lt;span class="cl">&lt;span class="n"> engine&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;postgres&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"> engine_version&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;16.3&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"> instance_class&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;db.r6g.large&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"> db_subnet_group_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_db_subnet_group&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">private&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">name&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"> vpc_security_group_ids&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="k">aws_security_group&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">db&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>&lt;span class="p">]&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>運算（ECS / EKS）&lt;/strong> 描述的是業務程式碼的執行載體。重點屬性是它跑在哪些 subnet、套用哪個 task / pod 的 IAM role、掛到哪個 load balancer 的 target group，以及與容器映像版本解耦 — 映像 tag 通常由 CI/CD 在部署期注入，不寫死在 infra 程式碼裡。這層只描述「運算容量與接線」，實際跑什麼版本由部署流程決定，這個邊界讓 infra 變更與應用發布各走各的節奏。&lt;/p>
&lt;p>ECS 與 EKS 在這裡被併寫，但兩者的維運模型不同、存在實際選型：ECS 是受管的容器編排，控制平面由雲商代管、心智負擔低，接線概念貼近 AWS 原生資源；EKS 是受管的 Kubernetes，換來跨雲可攜的生態與更細的編排控制，代價是要承擔 Kubernetes 自身的運維面（升級、附加元件、RBAC）。團隊已有 Kubernetes 能力或需要其生態時 EKS 的成本才划算，否則 ECS 的低負擔通常是預設起點。IaC 描述的接線骨架相近，差異主要落在編排層的資源類型。&lt;/p>
&lt;p>運算到資料庫之間還有一段常被略過的接線：連線管理。無狀態運算水平擴張時，每個實例各自開連線，容易把資料庫的連線數打滿 — 出現「擴運算反而拖垮 DB」的訊號時，要引入連線池或受管的連線代理（如 RDS Proxy），把連線收斂後再進資料庫，這層也可寫進 IaC 並輸出端點給運算引用。當讀流量遠大於寫、且能容忍副本的複寫延遲時，read replica 是把讀請求導離主庫的下一步，運算端依讀寫分流引用不同端點。&lt;/p>
&lt;p>&lt;strong>儲存（S3）&lt;/strong> 描述的是 bucket 的存在、命名、加密設定、版本控制與存取政策。bucket 本身幾乎沒有重建代價意義上的狀態問題 — 困難在它「裝的東西」。空 bucket 可隨時重建，裝了正式資料的 bucket 與 RDS 一樣不可隨意 destroy。描述時把加密、public access block、生命週期規則寫進去，這些是安全與成本的預設防線。&lt;/p>
&lt;p>&lt;strong>入口（ALB）&lt;/strong> 描述流量進入系統的第一站。它定義 listener（監聽哪些 port 與協定）、target group（流量導向哪些運算後端）、health check 條件與 TLS 憑證。ALB 本身是 stateless 的 — 重建一個 load balancer 不會遺失資料，但會換掉它的 DNS 名稱，所以對外服務通常在它前面再掛一層穩定的 DNS 記錄。健康檢查的路徑與閾值是這裡最常被忽略的判讀點：閾值太寬鬆會把壞掉的後端留在輪替裡，太嚴格會在部署瞬間誤判健康的新實例。HTTPS listener 引用的 TLS 憑證也屬於這層的接線 — 憑證由 ACM 簽發與自動續期，IaC 用憑證資源描述它（涵蓋網域與驗證方式），再把憑證 ARN 接到 listener 上，讓「憑證存在、續期、掛載」整條鏈都進版本控制，而非在 Console 手動上傳一份會過期沒人盯的憑證。&lt;/p></description><content:encoded><![CDATA[<p>地基就緒後，依「地基 → 上層」的順序把實際承載業務的服務寫進 IaC。前四個模組建立的身分、網路與環境分離是底層平面，這一層在它們之上描述資料庫、運算、儲存與入口 — 業務流量真正落地的地方。順序與依賴的表達方式決定了這層能不能被乾淨地重建、拆除與演進。</p>
<h2 id="上核心服務的順序">上核心服務的順序</h2>
<p>核心服務的部署順序由依賴方向決定：被依賴的先建，依賴別人的後建。網路與身分是幾乎所有上層服務的共同前置 — 資料庫要放進私有 subnet、運算要套用 IAM role 才能讀 S3、load balancer 要掛在公開 subnet 並引用 security group。這些底層平面若還沒成形，上層資源會在 apply 時因為找不到 subnet ID 或 role ARN 而失敗，或更糟，建在預設 VPC 裡繞過了所有隔離設計。</p>
<p>把順序交給 IaC 工具的依賴圖自動推導，比人工排序可靠。當運算資源的定義引用了 subnet 與 security group 的資源屬性，Terraform 會解析出「subnet 先於運算」的邊，apply 時自動排程。人工維護一份「先做 A 再做 B」的清單會隨資源增加而失準，依賴圖則隨程式碼本身演進。</p>
<p>順序失控的早期徵兆是：某個上層資源的定義裡寫了一串 hardcode 的 subnet ID 或 VPC ID，代表它沒有透過依賴圖連到底層平面。底層一旦重建、ID 改變，上層不會自動跟上，state 與雲端現實之間的不一致（即 drift）就此產生。把硬編碼的 ID 換成對底層資源屬性或 data source 的引用，順序才會回到工具掌控之內。</p>
<h2 id="各類服務怎麼描述">各類服務怎麼描述</h2>
<p>四類核心服務承擔不同責任，IaC 描述它們時關注的屬性也不同。共通原則是：描述服務的「身分與接線」，而非把每個執行期參數都塞進程式碼。</p>
<p><strong>資料庫（RDS）</strong> 是這層裡最需要謹慎描述的資源，因為它持有無法重建的狀態。IaC 定義它的 instance class、引擎版本、所在的 subnet group（決定它落在哪些私有 subnet）、套用的 parameter group 與 security group。連線端點不要硬編碼，改用資源 output 暴露給上層運算引用。</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></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  identifier</span>             <span class="o">=</span> <span class="s2">&#34;app-prod-primary&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  engine</span>                 <span class="o">=</span> <span class="s2">&#34;postgres&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  engine_version</span>         <span class="o">=</span> <span class="s2">&#34;16.3&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">  instance_class</span>         <span class="o">=</span> <span class="s2">&#34;db.r6g.large&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">  db_subnet_group_name</span>   <span class="o">=</span> <span class="k">aws_db_subnet_group</span><span class="p">.</span><span class="k">private</span><span class="p">.</span><span class="k">name</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="n">  vpc_security_group_ids</span> <span class="o">=</span> <span class="p">[</span><span class="k">aws_security_group</span><span class="p">.</span><span class="k">db</span><span class="p">.</span><span class="k">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">}</span></span></code></pre></div><p><strong>運算（ECS / EKS）</strong> 描述的是業務程式碼的執行載體。重點屬性是它跑在哪些 subnet、套用哪個 task / pod 的 IAM role、掛到哪個 load balancer 的 target group，以及與容器映像版本解耦 — 映像 tag 通常由 CI/CD 在部署期注入，不寫死在 infra 程式碼裡。這層只描述「運算容量與接線」，實際跑什麼版本由部署流程決定，這個邊界讓 infra 變更與應用發布各走各的節奏。</p>
<p>ECS 與 EKS 在這裡被併寫，但兩者的維運模型不同、存在實際選型：ECS 是受管的容器編排，控制平面由雲商代管、心智負擔低，接線概念貼近 AWS 原生資源；EKS 是受管的 Kubernetes，換來跨雲可攜的生態與更細的編排控制，代價是要承擔 Kubernetes 自身的運維面（升級、附加元件、RBAC）。團隊已有 Kubernetes 能力或需要其生態時 EKS 的成本才划算，否則 ECS 的低負擔通常是預設起點。IaC 描述的接線骨架相近，差異主要落在編排層的資源類型。</p>
<p>運算到資料庫之間還有一段常被略過的接線：連線管理。無狀態運算水平擴張時，每個實例各自開連線，容易把資料庫的連線數打滿 — 出現「擴運算反而拖垮 DB」的訊號時，要引入連線池或受管的連線代理（如 RDS Proxy），把連線收斂後再進資料庫，這層也可寫進 IaC 並輸出端點給運算引用。當讀流量遠大於寫、且能容忍副本的複寫延遲時，read replica 是把讀請求導離主庫的下一步，運算端依讀寫分流引用不同端點。</p>
<p><strong>儲存（S3）</strong> 描述的是 bucket 的存在、命名、加密設定、版本控制與存取政策。bucket 本身幾乎沒有重建代價意義上的狀態問題 — 困難在它「裝的東西」。空 bucket 可隨時重建，裝了正式資料的 bucket 與 RDS 一樣不可隨意 destroy。描述時把加密、public access block、生命週期規則寫進去，這些是安全與成本的預設防線。</p>
<p><strong>入口（ALB）</strong> 描述流量進入系統的第一站。它定義 listener（監聽哪些 port 與協定）、target group（流量導向哪些運算後端）、health check 條件與 TLS 憑證。ALB 本身是 stateless 的 — 重建一個 load balancer 不會遺失資料，但會換掉它的 DNS 名稱，所以對外服務通常在它前面再掛一層穩定的 DNS 記錄。健康檢查的路徑與閾值是這裡最常被忽略的判讀點：閾值太寬鬆會把壞掉的後端留在輪替裡，太嚴格會在部署瞬間誤判健康的新實例。HTTPS listener 引用的 TLS 憑證也屬於這層的接線 — 憑證由 ACM 簽發與自動續期，IaC 用憑證資源描述它（涵蓋網域與驗證方式），再把憑證 ARN 接到 listener 上，讓「憑證存在、續期、掛載」整條鏈都進版本控制，而非在 Console 手動上傳一份會過期沒人盯的憑證。</p>
<h2 id="stateful-資源的特殊處理">stateful 資源的特殊處理</h2>
<p>stateful 資源的 IaC 描述要把「保護狀態」當成第一類需求，而非事後補上的選項。RDS 是典型 — 它的高可用、備份與還原能力全都能、也應該用程式碼描述，這樣保護策略本身就進入版本控制與審查流程，而非散落在某人手動點過的 Console 設定裡。</p>
<p>multi-AZ 用一個布林屬性開啟，背後是 RDS 在另一個可用區維護同步副本。它解的是可用性：主庫故障時 failover 到 standby，但這個切換有秒級到一兩分鐘的窗口而非零停機，期間連線會中斷重連。要先界定它的邊界，才不會把它當成超出職責的工具。standby 副本是熱備不可讀，所以 multi-AZ 不提供讀取擴展 — 要分攤讀流量得另開 read replica 或改用 multi-AZ cluster 形態。它也不防邏輯損壞：誤刪一張表或一筆錯誤的批次更新會同步複製到 standby，這類風險由 backup 與時間點還原（PITR）負責，與 multi-AZ 的可用性職責正交，兩者要分別配置。</p>
<p>backup 用保留天數與備份視窗描述，RDS 依此每日自動快照並保留交易日誌以支援還原到任意時間點。自動備份的保留上限是 35 天，更長的留存要靠手動快照或匯出到 S3 自行管理。下方 <code>backup_retention_period</code> 取 14 是以 RPO 與合規要求反推的結果 — 一般營運場景 14 天足以涵蓋「發現問題到決定還原」的時間差，受監理或需要更長追溯窗口的服務則往 30 天甚至接上手動快照保險。手動快照用獨立資源描述，常見於重大變更前的保險點。</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></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  multi_az</span>                   <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  backup_retention_period</span>    <span class="o">=</span> <span class="m">14</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  backup_window</span>              <span class="o">=</span> <span class="s2">&#34;03:00-04:00&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">  deletion_protection</span>        <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">  skip_final_snapshot</span>        <span class="o">=</span> <span class="kt">false</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="n">  final_snapshot_identifier</span>  <span class="o">=</span> <span class="s2">&#34;app-prod-final&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">}</span></span></code></pre></div><p>該在 review 攔下的訊號是：正式環境的 stateful 資源若 <code>backup_retention_period</code> 為 0 或 <code>deletion_protection</code> 為 false，代表狀態保護沒有寫進程式碼。把這些屬性視為正式資料庫的硬性下限，而非可調的偏好。</p>
<h2 id="stateful-與-stateless-的差異怎麼影響操作">stateful 與 stateless 的差異怎麼影響操作</h2>
<p>stateful 與 stateless 資源的根本差別在重建代價，這個差別會傳導到刪除保護與 drift 風險的處理方式。stateless 資源（ECS service、ALB、無狀態運算）重建只是換一組新實例，幾分鐘內恢復、沒有資料損失，所以它們可以被頻繁地 destroy 與 recreate，是 IaC 最擅長的對象。</p>
<p>stateful 資源（RDS、裝了資料的 S3、持久化 volume）重建意味著資料遺失或漫長的還原，代價可能是數小時的停機與不可逆的損失。這個差別帶來三個操作後果。第一，刪除保護是必要的：stateful 資源開啟 deletion protection，讓「不小心 destroy」需要先顯式關閉保護這一步，多一道人為確認。第二，state drift 的容忍度不同：stateless 資源的 drift 可以靠重建抹平，stateful 資源的 drift（例如有人手動改了 parameter group）要謹慎處理，因為 IaC 的「修正回程式碼狀態」動作可能觸發重啟或重建。第三，變更的審查強度不同：改動 stateful 資源的 plan 輸出要逐行看，特別警惕任何顯示為 <code>replace</code>（先刪後建）而非 <code>update in-place</code> 的項目 — 對資料庫而言這通常代表資料會被丟棄。</p>
<p>實務上把這個差別寫進流程：stateful 資源的變更走更嚴格的 PR review 與分階段套用，這部分的自動化護欄在「模組七：infra 走 PR 流程與自動化護欄」展開。</p>
<h2 id="服務之間的依賴怎麼表達">服務之間的依賴怎麼表達</h2>
<p>服務間依賴用 output 與 data source 表達，讓引用關係成為程式碼裡可追蹤的邊，而非靠人記憶的隱性約定。同一個 state 內，直接引用資源屬性即可建立依賴 — 運算資源引用資料庫的端點 output，IaC 自動推導出「資料庫先於運算」，也讓端點變更時上層自動取得新值。</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">output</span> <span class="s2">&#34;db_endpoint&#34;</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  value</span> <span class="o">=</span> <span class="k">aws_db_instance</span><span class="p">.</span><span class="k">primary</span><span class="p">.</span><span class="k">endpoint</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">}</span></span></code></pre></div><p>跨 state（例如網路地基與核心服務分屬不同 Terraform state，呼應「模組四：環境分離與模組化」的拆分）時，下游用 data source 唯讀地讀取上游已建立的資源。下游查詢上游的 VPC 與 subnet，取得 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">data</span> <span class="s2">&#34;aws_vpc&#34; &#34;main&#34;</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  tags</span> <span class="o">=</span><span class="n"> { Name</span> <span class="o">=</span> <span class="s2">&#34;app-prod&#34;</span> }
</span></span><span class="line"><span class="ln">3</span><span class="cl">}</span></span></code></pre></div><p>兩種方式的取捨在耦合與隔離之間。同 state 引用最直接、依賴圖最完整，但 state 越大、單次 apply 的爆炸半徑越大。跨 state 的 data source 把爆炸半徑切小、讓網路地基能獨立演進，代價是依賴關係跨越了 state 邊界、需要約定上游一定先 apply。判讀訊號是：若一份核心服務程式碼裡出現大量寫死的 ID，通常代表該用 data source 而沒用 — 這是日後上游重建時 drift 與 broken reference 的來源。把硬編碼的引用換成 data source，依賴關係才會在程式碼裡顯性化、可被工具與 review 看見。</p>
<p>服務都接上後，下一個關注點是讓它們可被觀測 — log 與 metric 與服務同生命週期建立，這部分在「模組六：可觀測性與 log 同生命週期」展開。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/infra/05-core-services/deployment-order-database/" data-link-title="部署順序與資料庫上 IaC" data-link-desc="核心服務的依賴圖決定部署順序，資料庫作為第一批上層服務需要最謹慎的 IaC 描述 — 涵蓋 RDS 接線、連線管理、read replica 與端點暴露">部署順序與資料庫上 IaC</a></td>
          <td>依賴圖決定部署順序，RDS 接線、連線管理、read replica 與端點暴露</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/05-core-services/compute-ecs-eks/" data-link-title="運算平台上 IaC — ECS 與 EKS" data-link-desc="容器運算平台的 IaC 描述：ECS 與 EKS 選型、task definition 與映像版本解耦、IAM task role 分離、auto-scaling 策略">運算平台上 IaC — ECS 與 EKS</a></td>
          <td>ECS 與 EKS 選型、task definition 與映像版本解耦、IAM task role、auto-scaling</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/05-core-services/storage-s3/" data-link-title="儲存上 IaC — S3 bucket 的安全與生命週期" data-link-desc="S3 bucket 的加密、版本控制、公開存取封鎖、生命週期規則、bucket policy 與事件通知怎麼寫進 IaC，讓儲存的安全與成本防線可審查可追蹤">儲存上 IaC — S3 bucket 的安全與生命週期</a></td>
          <td>加密、版本控制、公開存取封鎖、生命週期規則、bucket policy 與事件通知</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/05-core-services/loadbalancer-alb/" data-link-title="入口上 IaC — ALB、TLS 與健康檢查" data-link-desc="Application Load Balancer 的 listener、target group、健康檢查閾值設計，以及用 ACM 把 TLS 憑證的簽發、驗證與掛載整條鏈寫進版本控制">入口上 IaC — ALB、TLS 與健康檢查</a></td>
          <td>listener、target group、健康檢查閾值設計、ACM 憑證與 DNS 別名</td>
      </tr>
      <tr>
          <td><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></td>
          <td>multi-AZ 邊界、備份保留、刪除保護、stateful vs stateless 操作差異、output 與 data source</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/05-core-services/acm-tls-dns-setup/" data-link-title="ACM 憑證、DNS 與 HTTPS 設定" data-link-desc="從 Route 53 hosted zone 到 ACM 憑證申請、DNS 驗證、ALB HTTPS listener 與 HTTP 重導的完整設定流程">ACM 憑證、DNS 與 HTTPS 設定</a></td>
          <td>hosted zone、DNS 驗證、TLS listener、HTTP redirect、SAN 憑證、續期監控</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/05-core-services/ecs-fargate-cost-optimization/" data-link-title="ECS Fargate 成本分析與優化" data-link-desc="Fargate 的計價模型、與 EC2 launch type 的成本交叉點、Spot 與 Savings Plans 的折扣機制、task 規格的 rightsizing 方法，以及何時該切回 EC2">ECS Fargate 成本分析與優化</a></td>
          <td>Fargate vs EC2 成本比較、Fargate Spot、Savings Plans、task rightsizing</td>
      </tr>
  </tbody>
</table>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">backend 模組五：部署平台</a>：PaaS / container 平台跑在這層之上</li>
<li>→ <a href="/blog/devops/" data-link-title="DevOps 實務指南" data-link-desc="負載平衡、水平擴展、流量管控、服務探活、容量規劃、高可用、突發流量、成本管理 — 服務營運的工程基礎">devops 實務指南</a>：這些服務上線後的運行期維運</li>
</ul>
]]></content:encoded></item><item><title>IaC / Platform 部署 CI/CD</title><link>https://tarrragon.github.io/blog/ci/iac-platform-deploy/</link><pubDate>Wed, 06 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ci/iac-platform-deploy/</guid><description>&lt;p>IaC / Platform 部署 CI/CD 的核心責任是把基礎設施變更轉成可審查、可追溯、可回復的流程。它和應用部署不同，主要風險在 state、權限、&lt;a href="https://tarrragon.github.io/blog/ci/knowledge-cards/infrastructure-drift/" data-link-title="Infrastructure Drift" data-link-desc="說明真實基礎設施狀態與 IaC 宣告分叉時的偵測、判讀與修復責任">Infrastructure Drift&lt;/a> 與不可逆資源變更。&lt;/p>
&lt;h2 id="場域定位">場域定位&lt;/h2>
&lt;p>IaC 流程通常分成 plan、review、apply 三段，並依環境分層推進。部署成功不只代表指令完成，還代表資源狀態符合預期且未引入漂移。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>面向&lt;/th>
 &lt;th>IaC 部署常見責任&lt;/th>
 &lt;th>判讀訊號&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Plan&lt;/td>
 &lt;td>變更差異預覽與風險提示&lt;/td>
 &lt;td>是否包含高風險破壞性變更&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Review&lt;/td>
 &lt;td>審核資源變更與權限範圍&lt;/td>
 &lt;td>是否符合治理規範&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Apply&lt;/td>
 &lt;td>狀態寫入與資源同步&lt;/td>
 &lt;td>state lock / timeout 是否可控&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ci/knowledge-cards/infrastructure-drift/" data-link-title="Infrastructure Drift" data-link-desc="說明真實基礎設施狀態與 IaC 宣告分叉時的偵測、判讀與修復責任">Infrastructure Drift&lt;/a>&lt;/td>
 &lt;td>實際環境與宣告差異檢查&lt;/td>
 &lt;td>是否存在未受控手動變更&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Recovery&lt;/td>
 &lt;td>回退或補正策略&lt;/td>
 &lt;td>失敗時是否有安全回復路徑&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="常見注意事項">常見注意事項&lt;/h2>
&lt;ul>
&lt;li>plan 與 apply 要用同一份輸入與版本，避免結果漂移。&lt;/li>
&lt;li>state backend 要有鎖定與權限隔離，避免併發覆寫。&lt;/li>
&lt;li>高風險資源變更需要額外 gate（人工審核或變更時窗）。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/ci/knowledge-cards/infrastructure-drift/" data-link-title="Infrastructure Drift" data-link-desc="說明真實基礎設施狀態與 IaC 宣告分叉時的偵測、判讀與修復責任">Infrastructure Drift&lt;/a> 偵測要定期執行，並有修復責任人。&lt;/li>
&lt;/ul>
&lt;h2 id="學習路線">學習路線&lt;/h2>
&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;a href="plan-apply-drift-recovery-flow/">IaC plan、apply、drift 與 recovery 流程&lt;/a>&lt;/td>
 &lt;td>Plan, apply, drift and recovery&lt;/td>
 &lt;td>控制基礎設施變更、漂移與回復&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>IaC 發布主流程：讀 &lt;a href="plan-apply-drift-recovery-flow/">IaC plan、apply、drift 與 recovery 流程&lt;/a>。&lt;/li>
&lt;li>環境保護：讀 &lt;a href="https://tarrragon.github.io/blog/ci/knowledge-cards/environment-protection/" data-link-title="Environment Protection" data-link-desc="說明目標環境的審核、權限與放行條件如何保護發布">Environment Protection&lt;/a>。&lt;/li>
&lt;li>部署合約：讀 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deployment-contract/" data-link-title="Deployment Contract" data-link-desc="說明服務與部署平台之間的生命週期約定">Deployment Contract&lt;/a>。&lt;/li>
&lt;li>變更放行：讀 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/release-gate/" data-link-title="Release Gate" data-link-desc="說明變更在正式釋出前如何通過或阻擋">Release Gate&lt;/a>。&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>IaC / Platform 部署 CI/CD 的核心責任是把基礎設施變更轉成可審查、可追溯、可回復的流程。它和應用部署不同，主要風險在 state、權限、<a href="/blog/ci/knowledge-cards/infrastructure-drift/" data-link-title="Infrastructure Drift" data-link-desc="說明真實基礎設施狀態與 IaC 宣告分叉時的偵測、判讀與修復責任">Infrastructure Drift</a> 與不可逆資源變更。</p>
<h2 id="場域定位">場域定位</h2>
<p>IaC 流程通常分成 plan、review、apply 三段，並依環境分層推進。部署成功不只代表指令完成，還代表資源狀態符合預期且未引入漂移。</p>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>IaC 部署常見責任</th>
          <th>判讀訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Plan</td>
          <td>變更差異預覽與風險提示</td>
          <td>是否包含高風險破壞性變更</td>
      </tr>
      <tr>
          <td>Review</td>
          <td>審核資源變更與權限範圍</td>
          <td>是否符合治理規範</td>
      </tr>
      <tr>
          <td>Apply</td>
          <td>狀態寫入與資源同步</td>
          <td>state lock / timeout 是否可控</td>
      </tr>
      <tr>
          <td><a href="/blog/ci/knowledge-cards/infrastructure-drift/" data-link-title="Infrastructure Drift" data-link-desc="說明真實基礎設施狀態與 IaC 宣告分叉時的偵測、判讀與修復責任">Infrastructure Drift</a></td>
          <td>實際環境與宣告差異檢查</td>
          <td>是否存在未受控手動變更</td>
      </tr>
      <tr>
          <td>Recovery</td>
          <td>回退或補正策略</td>
          <td>失敗時是否有安全回復路徑</td>
      </tr>
  </tbody>
</table>
<h2 id="常見注意事項">常見注意事項</h2>
<ul>
<li>plan 與 apply 要用同一份輸入與版本，避免結果漂移。</li>
<li>state backend 要有鎖定與權限隔離，避免併發覆寫。</li>
<li>高風險資源變更需要額外 gate（人工審核或變更時窗）。</li>
<li><a href="/blog/ci/knowledge-cards/infrastructure-drift/" data-link-title="Infrastructure Drift" data-link-desc="說明真實基礎設施狀態與 IaC 宣告分叉時的偵測、判讀與修復責任">Infrastructure Drift</a> 偵測要定期執行，並有修復責任人。</li>
</ul>
<h2 id="學習路線">學習路線</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>核心責任</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="plan-apply-drift-recovery-flow/">IaC plan、apply、drift 與 recovery 流程</a></td>
          <td>Plan, apply, drift and recovery</td>
          <td>控制基礎設施變更、漂移與回復</td>
      </tr>
  </tbody>
</table>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>IaC 發布主流程：讀 <a href="plan-apply-drift-recovery-flow/">IaC plan、apply、drift 與 recovery 流程</a>。</li>
<li>環境保護：讀 <a href="/blog/ci/knowledge-cards/environment-protection/" data-link-title="Environment Protection" data-link-desc="說明目標環境的審核、權限與放行條件如何保護發布">Environment Protection</a>。</li>
<li>部署合約：讀 <a href="/blog/backend/knowledge-cards/deployment-contract/" data-link-title="Deployment Contract" data-link-desc="說明服務與部署平台之間的生命週期約定">Deployment Contract</a>。</li>
<li>變更放行：讀 <a href="/blog/backend/knowledge-cards/release-gate/" data-link-title="Release Gate" data-link-desc="說明變更在正式釋出前如何通過或阻擋">Release Gate</a>。</li>
</ul>
]]></content:encoded></item><item><title>Infrastructure Drift</title><link>https://tarrragon.github.io/blog/ci/knowledge-cards/infrastructure-drift/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ci/knowledge-cards/infrastructure-drift/</guid><description>&lt;p>Infrastructure Drift 的核心概念是「真實環境狀態與宣告檔分叉」。它會削弱 &lt;a href="https://tarrragon.github.io/blog/ci/knowledge-cards/environment-protection/" data-link-title="Environment Protection" data-link-desc="說明目標環境的審核、權限與放行條件如何保護發布">Environment Protection&lt;/a> 與 deployment review 的可信度，並影響下一次 plan / apply 的安全性。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Infrastructure Drift 位在 IaC state、cloud resource、手動 hotfix 與外部 controller 之間，常由 console edit、事故修復、provider 預設值或自動調整造成。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;ul>
&lt;li>plan 顯示大量非預期變更。&lt;/li>
&lt;li>production 資源和 repository 宣告不一致。&lt;/li>
&lt;li>下次 apply 可能覆蓋事故 hotfix。&lt;/li>
&lt;/ul>
&lt;h2 id="接近真實服務的例子">接近真實服務的例子&lt;/h2>
&lt;p>事故中工程師在雲端 console 手動放寬 security group。服務恢復後，IaC plan 顯示 security group 與宣告檔不同；團隊需要判斷這個變更是短期 hotfix 還是應回寫成正式規則。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Infrastructure Drift 要定義偵測頻率、owner、修復路由、state repair 與回寫規則，讓平台狀態重新回到可審查流程。&lt;/p></description><content:encoded><![CDATA[<p>Infrastructure Drift 的核心概念是「真實環境狀態與宣告檔分叉」。它會削弱 <a href="/blog/ci/knowledge-cards/environment-protection/" data-link-title="Environment Protection" data-link-desc="說明目標環境的審核、權限與放行條件如何保護發布">Environment Protection</a> 與 deployment review 的可信度，並影響下一次 plan / apply 的安全性。</p>
<h2 id="概念位置">概念位置</h2>
<p>Infrastructure Drift 位在 IaC state、cloud resource、手動 hotfix 與外部 controller 之間，常由 console edit、事故修復、provider 預設值或自動調整造成。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<ul>
<li>plan 顯示大量非預期變更。</li>
<li>production 資源和 repository 宣告不一致。</li>
<li>下次 apply 可能覆蓋事故 hotfix。</li>
</ul>
<h2 id="接近真實服務的例子">接近真實服務的例子</h2>
<p>事故中工程師在雲端 console 手動放寬 security group。服務恢復後，IaC plan 顯示 security group 與宣告檔不同；團隊需要判斷這個變更是短期 hotfix 還是應回寫成正式規則。</p>
<h2 id="設計責任">設計責任</h2>
<p>Infrastructure Drift 要定義偵測頻率、owner、修復路由、state repair 與回寫規則，讓平台狀態重新回到可審查流程。</p>
]]></content:encoded></item><item><title>State Lock</title><link>https://tarrragon.github.io/blog/ci/knowledge-cards/state-lock/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ci/knowledge-cards/state-lock/</guid><description>&lt;p>State Lock 的核心概念是「讓同一份基礎設施狀態一次只被一個 apply 修改」。它支撐 &lt;a href="https://tarrragon.github.io/blog/ci/knowledge-cards/infrastructure-drift/" data-link-title="Infrastructure Drift" data-link-desc="說明真實基礎設施狀態與 IaC 宣告分叉時的偵測、判讀與修復責任">Infrastructure Drift&lt;/a> 的治理，避免 CI job 或人工操作併發覆寫 state。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>State Lock 位在 IaC state backend、plan / apply workflow 與平台資源之間，常由 Terraform backend、Pulumi state 或平台鎖定機制提供。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;ul>
&lt;li>多個 pipeline 同時 apply 同一個 workspace。&lt;/li>
&lt;li>state file 出現併發覆寫或 partial apply 後不一致。&lt;/li>
&lt;li>apply 長時間卡住需要判斷 lock 是否仍有效。&lt;/li>
&lt;/ul>
&lt;h2 id="接近真實服務的例子">接近真實服務的例子&lt;/h2>
&lt;p>兩個 PR 同時修改 production network。第一個 workflow 取得 state lock 後進入 apply，第二個 workflow 等待或失敗，避免兩次變更同時寫入 state。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>State Lock 要定義 lock backend、timeout、人工解鎖條件、環境隔離與失敗處理，讓 IaC apply 保持序列化。&lt;/p></description><content:encoded><![CDATA[<p>State Lock 的核心概念是「讓同一份基礎設施狀態一次只被一個 apply 修改」。它支撐 <a href="/blog/ci/knowledge-cards/infrastructure-drift/" data-link-title="Infrastructure Drift" data-link-desc="說明真實基礎設施狀態與 IaC 宣告分叉時的偵測、判讀與修復責任">Infrastructure Drift</a> 的治理，避免 CI job 或人工操作併發覆寫 state。</p>
<h2 id="概念位置">概念位置</h2>
<p>State Lock 位在 IaC state backend、plan / apply workflow 與平台資源之間，常由 Terraform backend、Pulumi state 或平台鎖定機制提供。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<ul>
<li>多個 pipeline 同時 apply 同一個 workspace。</li>
<li>state file 出現併發覆寫或 partial apply 後不一致。</li>
<li>apply 長時間卡住需要判斷 lock 是否仍有效。</li>
</ul>
<h2 id="接近真實服務的例子">接近真實服務的例子</h2>
<p>兩個 PR 同時修改 production network。第一個 workflow 取得 state lock 後進入 apply，第二個 workflow 等待或失敗，避免兩次變更同時寫入 state。</p>
<h2 id="設計責任">設計責任</h2>
<p>State Lock 要定義 lock backend、timeout、人工解鎖條件、環境隔離與失敗處理，讓 IaC apply 保持序列化。</p>
]]></content:encoded></item><item><title>模組零：infra 是什麼，為什麼 day 1 就要鋪地基</title><link>https://tarrragon.github.io/blog/infra/00-infra-mindset/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/00-infra-mindset/</guid><description>&lt;p>基礎設施（infrastructure，簡稱 infra）是承載應用程式的那層資源與規則：運算、網路、身分、儲存、可觀測性，以及定義它們如何被建立、變更、回收的治理機制。它的責任是讓應用程式有一個可被信任、可被重建、可被審計的執行環境。本章建立的責任邊界、成熟度階梯與 day 1 鐵律，是後續所有 infra 模組共用的心智模型，其他章節會直接引用這裡定義的詞彙。&lt;/p>
&lt;h2 id="infra-的責任邊界">infra 的責任邊界&lt;/h2>
&lt;p>infra 承擔的是「應用程式之下、作業系統之上」那層共享資源的供應與治理。把責任拆成五個面向比較好對齊：每一面都有自己的失效模式，混在一起談會讓判斷失焦。&lt;/p>
&lt;p>運算（compute）負責「程式跑在哪、用多少資源、怎麼擴縮」。它的衡量點是容量與彈性：流量尖峰時能不能長出更多實例、閒置時能不能縮回去省錢。一台手動開的 VM 也是運算資源，差別只在它是否被納入可重建的描述。&lt;/p>
&lt;p>網路（network）負責「誰能連到誰、流量走哪條路」。它的責任是把可達性變成明確規則，而非預設全通。VPC 切分、子網路、security group 都屬於這層，邊界沒畫清楚時，一個被入侵的服務就能橫向打穿整個環境。&lt;/p>
&lt;p>身分與憑證（identity）負責「誰能對哪些資源做什麼操作」。它承擔最小權限的落地：人、服務、CI pipeline 各拿剛好夠用的權限，憑證有明確的生命週期。這層失守的代價最高，因為它是其他所有資源的閘門。&lt;/p>
&lt;p>儲存（storage）負責「資料放哪、能不能還原」。它的責任是持久性與可回復性：備份策略、版本保留、刪除保護。運算可以隨時重建，資料一旦遺失通常無法重來，所以這層的回退路徑要在出事前就驗證過。&lt;/p>
&lt;p>可觀測性（observability）負責「系統現在發生什麼、出事後查得到嗎」。它把 log、metric、trace 變成可查詢的事實來源。這層常被當成事後再補的附加品，但它和被它觀測的服務應該同生命週期一起建立，補在後面的可觀測性往往缺了出事當下最關鍵的那段資料。&lt;/p>
&lt;p>這五面的共同點是：它們都不是應用功能，使用者看不到，但任何一面崩了，上面的功能全部跟著崩。這正是地基隱形的根源。&lt;/p>
&lt;h2 id="地基為什麼隱形">地基為什麼隱形&lt;/h2>
&lt;p>infra 的特性是「運作正常時完全不被感知，失效時才一次現形」。地基鋪得好的環境，工程師每天部署、擴縮、改設定，卻幾乎不會意識到底下有一層在支撐，因為它安靜地做對了每件事。這種隱形讓 infra 在資源排序上長期吃虧：看得見的功能有人催，看不見的地基沒人提。&lt;/p>
&lt;p>現形的時刻通常是環境爆炸的時刻。一個沒有人記得怎麼建的服務掛了，才發現它是某位早期工程師在 Console 手動點出來的，沒有任何描述檔；一次安全稽核要求列出所有對外開放的連接埠，才發現 security group 散落在三個帳號、沒人說得清哪條規則還有用；一台資料庫磁碟滿了要擴容，才發現它從來沒進過任何納管流程，動它等於拆未爆彈。&lt;/p>
&lt;p>隱形債務的徵兆很直接：當團隊開始用「不敢動那台機器」「只有某某知道怎麼改」來描述某項資源，債就已經在累積。地基的價值無法在平順時被看見，只能在它缺席的代價裡被回推，所以它需要一條和功能不同的論證路徑——這條路徑怎麼用商業語言講給上層聽，是「模組九：怎麼把 infra 推動起來」的主題。&lt;/p>
&lt;h2 id="day-1-鋪地基與事後補的成本差">day 1 鋪地基與事後補的成本差&lt;/h2>
&lt;p>在資源剛開始長出來時就用程式碼描述它，和等環境長大後再回頭納管，兩者的成本差距是非線性的。早期鋪地基的成本接近固定：寫一份描述檔、建一個 state、設一條 pipeline，環境只有三五個資源時這些都很輕。事後補的成本則隨資源數量、相互依賴與「不確定能不能動」的恐懼一起放大。&lt;/p>
&lt;p>事後納管的痛具體長這樣：一個手動建出來的資源要納入 IaC，得先把它當前的真實狀態完整反推成程式碼（import），這個過程要逐欄比對 Console 上的設定，漏一個欄位下次 apply 就可能把線上設定改掉。資源彼此有依賴時，納管順序也得排——先納管的資源引用了還沒納管的資源，描述就接不起來。當這些手動資源還是線上服務正在用的，整個納管過程等於在開著的引擎上換零件。&lt;/p>
&lt;p>務實的判準不是「day 1 就把所有東西寫成完美的 IaC」，而是「day 1 就讓新長出來的資源預設走可重建的路徑」。多數早期環境划得來的選擇，是讓地基類資源（網路、身分、state 本身）從一開始就在程式碼裡，而把還在高速試錯的應用層資源留一點手動彈性，等形狀穩定再納管。差別在於：前者的回頭成本固定，後者隨時間複利。「模組一：最小可行 IaC」會示範這條最小路徑怎麼落地。&lt;/p>
&lt;h2 id="成熟度階梯">成熟度階梯&lt;/h2>
&lt;p>infra 的成熟度可以排成一條從「全手動」到「全程式碼治理」的階梯，每一階用「資源怎麼被建立與變更」來定義。這條階梯是全系列共用的座標：後續模組描述某個能力時，會說它對應到哪一階，所以這裡先把刻度釘清楚。&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>0&lt;/td>
 &lt;td>Console 手動&lt;/td>
 &lt;td>在網頁介面點選建立&lt;/td>
 &lt;td>只存在於雲端，無描述&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>腳本化&lt;/td>
 &lt;td>用 CLI 或腳本建立&lt;/td>
 &lt;td>腳本，但無狀態追蹤&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>宣告式 IaC&lt;/td>
 &lt;td>寫描述檔、由工具 apply&lt;/td>
 &lt;td>state 檔記錄已建資源&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>環境分離&lt;/td>
 &lt;td>同一份模組套用多環境&lt;/td>
 &lt;td>各環境獨立 state&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>4&lt;/td>
 &lt;td>PR 流程治理&lt;/td>
 &lt;td>變更走 PR、CI 自動 plan&lt;/td>
 &lt;td>state + 版控歷史 + 審查紀錄&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第 0 階「Console 手動」是所有環境的起點，也是必須最快離開的一階。它的特徵是真實狀態只存在雲端，沒有任何離線描述，所以無法 review、無法重建、無法回答「這個環境長什麼樣」。它不是錯誤的起點，是還沒鋪地基的起點。&lt;/p>
&lt;p>第 1 階「腳本化」把建立動作寫成 CLI 或 shell 腳本，比手動可重複，但腳本只描述「怎麼建」，不追蹤「現在有什麼」。重跑同一支腳本可能重複建立或報錯，因為它不知道資源已經存在。這一階的常見陷阱是誤以為「有腳本就等於有 IaC」，差的是狀態這塊地基。&lt;/p>
&lt;p>第 2 階「宣告式 IaC」是地基真正成形的一階：用 Terraform / OpenTofu 這類工具寫下「環境應該長什麼樣」，工具負責比對現況與描述、算出差異再套用。state 檔在這裡誕生，成為「目前納管了哪些資源」的事實來源。這一階的判讀訊號是：能不能從程式碼把整個環境在另一個帳號重建出來。&lt;/p>
&lt;p>第 3 階「環境分離」把同一份描述模組化，套用到 dev / staging / production 等多個環境，各自獨立 state。它解決的問題是「在 staging 驗證過的變更，能用同一套描述安全地推到 production」。「模組四：環境分離與模組化」專講這一階的切法。&lt;/p>
&lt;p>第 4 階「PR 流程治理」把 infra 變更接上和應用程式碼相同的協作流程：變更走 pull request，CI 自動跑 plan 把預期差異貼上來，人審查後才 apply。到這一階，infra 的每次變更都有提案、審查、歷史與回退點。「模組七：infra 走 PR 流程」會完整展開這套護欄。&lt;/p>
&lt;p>這條階梯是一把對齊現況的尺，用來判斷某項資源該停在哪一階，不是越高越好的單向命令。停在哪一階的依據，是務實節奏。&lt;/p>
&lt;h2 id="早期新創的務實節奏">早期新創的務實節奏&lt;/h2>
&lt;p>早期團隊的合理目標是「地基類資源先上到階梯第 2 階，應用層資源容許暫時留在低階」，而不是一步衝到第 4 階。資源有限、需求還在劇烈變動的階段，把全部資源都套上完整治理流程，划得來的機率不高——治理的固定成本會壓到本來就稀缺的開發頻寬。&lt;/p>
&lt;p>判斷節奏的依據是「這項資源的形狀穩不穩、動它的代價高不高」。網路拓撲、身分權限、state 後端這類地基，一旦長歪回頭改的代價極高，值得 day 1 就進 IaC，這是少數接近「該照做」的硬判準，因為它牽涉安全邊界。反過來，一個還在每週改三次規格的功能用的運算資源，過早凍進嚴格流程反而拖慢試錯，這時容許它手動、但設一條 tripwire：當它開始被線上流量依賴、或開始有第二個人需要改它時，就是把它納管的時機。&lt;/p>
&lt;p>過度設計和放任手動是這個階段的兩個反向誤判。過度設計的訊號是：環境只有五個資源，卻已經有多層抽象模組和還用不到的多環境結構，維護抽象的時間比省下的時間多。放任手動的訊號是：每次有人問「這個怎麼建的」都只能去翻某個人的記憶，地基債務在無聲累積。務實節奏就是在這兩者之間，讓地基先穩、讓應用層保留試錯彈性，再隨著形狀固定逐項往階梯上推。&lt;/p>
&lt;h2 id="章節文章">章節文章&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>文章&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/00-infra-mindset/personal-project-to-infra/" data-link-title="雲端部署裡已經存在的 infra 元件" data-link-desc="VPC、security group、IAM、儲存 — 這些元件在任何雲端部署裡都已經在運作，差別在於有沒有被有意識地管理">個人專案到團隊服務：infra 在哪裡出現&lt;/a>&lt;/td>
 &lt;td>從 side project 部署到雲端的過程，看見 VPC、security group、IAM 這些元件其實早就在運作&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/00-infra-mindset/one-machine-to-environments/" data-link-title="從單一環境到環境分離：infra 需求的浮現過程" data-link-desc="單一 EC2 &amp;#43; RDS 的結構在需要測試環境、多人協作時會撞到哪些操作極限，以及環境分離怎麼牽出身分、網路、變更流程等後續 infra 關注點">一台機器到三個環境：infra 解決的問題&lt;/a>&lt;/td>
 &lt;td>從一台 EC2 到需要 dev / staging / prod 三個環境的過程中，infra 的每一個關注點怎麼自然浮現&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/00-infra-mindset/infra-responsibility-maturity/" data-link-title="infra 的責任邊界、成熟度階梯與 day 1 鐵律" data-link-desc="基礎設施承擔五個面向的責任，每一面都有獨立的失效模式；成熟度階梯用來對齊現況而非追求滿分，day 1 鐵律則劃出早期團隊該優先鋪的地基">責任邊界、成熟度階梯與 day 1 鐵律&lt;/a>&lt;/td>
 &lt;td>五個責任面向的失效模式、成熟度階梯的五個刻度、day 1 鐵律與早期團隊的務實節奏&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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;/td>
 &lt;td>被指派 infra 工作時的第一小時安全底線、帳號現況判讀、後續學習路線分流&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一：還沒有 infra 的手動環境&lt;/a>：階梯第 0 階的環境怎麼盡量做好&lt;/li>
&lt;li>→ &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 只能看不能改」鐵律">模組一：最小可行 IaC&lt;/a>：地基資源跨上成熟度階梯第 2 階的最小路徑&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化&lt;/a>：成熟度階梯第 3 階的切法&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/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 流程&lt;/a>：成熟度階梯第 4 階的治理護欄&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/infra/09-driving-adoption/" data-link-title="模組九：怎麼把 infra 推動起來" data-link-desc="技術正確不等於推得動 — 信任赤字、期望值對齊、知識共享，infra 落地的組織課題">模組九：怎麼把 infra 推動起來&lt;/a>：地基的價值怎麼用商業語言講給上層聽&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/linux/install/" data-link-title="Linux 安裝與機器初始化" data-link-desc="在 VM 或新機器從零裝好 Linux、判讀安裝程式選項、驗證最小系統、或要從外部連入跑 bootstrap 時回來讀">Linux 安裝與機器初始化&lt;/a>：拿到雲端主機後從 OS 層連入、跑 bootstrap 的前置，跟 infra 的資源管理是上下游；主機連不到 / 起不來時的診斷見 &lt;a href="https://tarrragon.github.io/blog/linux/debug/machine-unreachable/" data-link-title="機器連不到或起不來" data-link-desc="遠端機器突然 SSH 連不上、虛擬機開不了機、或懷疑磁碟滿引發連鎖故障時，從主機側與網路層的權威狀態往下定位是哪一環斷了">機器連不到或起不來&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>基礎設施（infrastructure，簡稱 infra）是承載應用程式的那層資源與規則：運算、網路、身分、儲存、可觀測性，以及定義它們如何被建立、變更、回收的治理機制。它的責任是讓應用程式有一個可被信任、可被重建、可被審計的執行環境。本章建立的責任邊界、成熟度階梯與 day 1 鐵律，是後續所有 infra 模組共用的心智模型，其他章節會直接引用這裡定義的詞彙。</p>
<h2 id="infra-的責任邊界">infra 的責任邊界</h2>
<p>infra 承擔的是「應用程式之下、作業系統之上」那層共享資源的供應與治理。把責任拆成五個面向比較好對齊：每一面都有自己的失效模式，混在一起談會讓判斷失焦。</p>
<p>運算（compute）負責「程式跑在哪、用多少資源、怎麼擴縮」。它的衡量點是容量與彈性：流量尖峰時能不能長出更多實例、閒置時能不能縮回去省錢。一台手動開的 VM 也是運算資源，差別只在它是否被納入可重建的描述。</p>
<p>網路（network）負責「誰能連到誰、流量走哪條路」。它的責任是把可達性變成明確規則，而非預設全通。VPC 切分、子網路、security group 都屬於這層，邊界沒畫清楚時，一個被入侵的服務就能橫向打穿整個環境。</p>
<p>身分與憑證（identity）負責「誰能對哪些資源做什麼操作」。它承擔最小權限的落地：人、服務、CI pipeline 各拿剛好夠用的權限，憑證有明確的生命週期。這層失守的代價最高，因為它是其他所有資源的閘門。</p>
<p>儲存（storage）負責「資料放哪、能不能還原」。它的責任是持久性與可回復性：備份策略、版本保留、刪除保護。運算可以隨時重建，資料一旦遺失通常無法重來，所以這層的回退路徑要在出事前就驗證過。</p>
<p>可觀測性（observability）負責「系統現在發生什麼、出事後查得到嗎」。它把 log、metric、trace 變成可查詢的事實來源。這層常被當成事後再補的附加品，但它和被它觀測的服務應該同生命週期一起建立，補在後面的可觀測性往往缺了出事當下最關鍵的那段資料。</p>
<p>這五面的共同點是：它們都不是應用功能，使用者看不到，但任何一面崩了，上面的功能全部跟著崩。這正是地基隱形的根源。</p>
<h2 id="地基為什麼隱形">地基為什麼隱形</h2>
<p>infra 的特性是「運作正常時完全不被感知，失效時才一次現形」。地基鋪得好的環境，工程師每天部署、擴縮、改設定，卻幾乎不會意識到底下有一層在支撐，因為它安靜地做對了每件事。這種隱形讓 infra 在資源排序上長期吃虧：看得見的功能有人催，看不見的地基沒人提。</p>
<p>現形的時刻通常是環境爆炸的時刻。一個沒有人記得怎麼建的服務掛了，才發現它是某位早期工程師在 Console 手動點出來的，沒有任何描述檔；一次安全稽核要求列出所有對外開放的連接埠，才發現 security group 散落在三個帳號、沒人說得清哪條規則還有用；一台資料庫磁碟滿了要擴容，才發現它從來沒進過任何納管流程，動它等於拆未爆彈。</p>
<p>隱形債務的徵兆很直接：當團隊開始用「不敢動那台機器」「只有某某知道怎麼改」來描述某項資源，債就已經在累積。地基的價值無法在平順時被看見，只能在它缺席的代價裡被回推，所以它需要一條和功能不同的論證路徑——這條路徑怎麼用商業語言講給上層聽，是「模組九：怎麼把 infra 推動起來」的主題。</p>
<h2 id="day-1-鋪地基與事後補的成本差">day 1 鋪地基與事後補的成本差</h2>
<p>在資源剛開始長出來時就用程式碼描述它，和等環境長大後再回頭納管，兩者的成本差距是非線性的。早期鋪地基的成本接近固定：寫一份描述檔、建一個 state、設一條 pipeline，環境只有三五個資源時這些都很輕。事後補的成本則隨資源數量、相互依賴與「不確定能不能動」的恐懼一起放大。</p>
<p>事後納管的痛具體長這樣：一個手動建出來的資源要納入 IaC，得先把它當前的真實狀態完整反推成程式碼（import），這個過程要逐欄比對 Console 上的設定，漏一個欄位下次 apply 就可能把線上設定改掉。資源彼此有依賴時，納管順序也得排——先納管的資源引用了還沒納管的資源，描述就接不起來。當這些手動資源還是線上服務正在用的，整個納管過程等於在開著的引擎上換零件。</p>
<p>務實的判準不是「day 1 就把所有東西寫成完美的 IaC」，而是「day 1 就讓新長出來的資源預設走可重建的路徑」。多數早期環境划得來的選擇，是讓地基類資源（網路、身分、state 本身）從一開始就在程式碼裡，而把還在高速試錯的應用層資源留一點手動彈性，等形狀穩定再納管。差別在於：前者的回頭成本固定，後者隨時間複利。「模組一：最小可行 IaC」會示範這條最小路徑怎麼落地。</p>
<h2 id="成熟度階梯">成熟度階梯</h2>
<p>infra 的成熟度可以排成一條從「全手動」到「全程式碼治理」的階梯，每一階用「資源怎麼被建立與變更」來定義。這條階梯是全系列共用的座標：後續模組描述某個能力時，會說它對應到哪一階，所以這裡先把刻度釘清楚。</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>名稱</th>
          <th>資源怎麼被建立</th>
          <th>真實狀態的來源</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>0</td>
          <td>Console 手動</td>
          <td>在網頁介面點選建立</td>
          <td>只存在於雲端，無描述</td>
      </tr>
      <tr>
          <td>1</td>
          <td>腳本化</td>
          <td>用 CLI 或腳本建立</td>
          <td>腳本，但無狀態追蹤</td>
      </tr>
      <tr>
          <td>2</td>
          <td>宣告式 IaC</td>
          <td>寫描述檔、由工具 apply</td>
          <td>state 檔記錄已建資源</td>
      </tr>
      <tr>
          <td>3</td>
          <td>環境分離</td>
          <td>同一份模組套用多環境</td>
          <td>各環境獨立 state</td>
      </tr>
      <tr>
          <td>4</td>
          <td>PR 流程治理</td>
          <td>變更走 PR、CI 自動 plan</td>
          <td>state + 版控歷史 + 審查紀錄</td>
      </tr>
  </tbody>
</table>
<p>第 0 階「Console 手動」是所有環境的起點，也是必須最快離開的一階。它的特徵是真實狀態只存在雲端，沒有任何離線描述，所以無法 review、無法重建、無法回答「這個環境長什麼樣」。它不是錯誤的起點，是還沒鋪地基的起點。</p>
<p>第 1 階「腳本化」把建立動作寫成 CLI 或 shell 腳本，比手動可重複，但腳本只描述「怎麼建」，不追蹤「現在有什麼」。重跑同一支腳本可能重複建立或報錯，因為它不知道資源已經存在。這一階的常見陷阱是誤以為「有腳本就等於有 IaC」，差的是狀態這塊地基。</p>
<p>第 2 階「宣告式 IaC」是地基真正成形的一階：用 Terraform / OpenTofu 這類工具寫下「環境應該長什麼樣」，工具負責比對現況與描述、算出差異再套用。state 檔在這裡誕生，成為「目前納管了哪些資源」的事實來源。這一階的判讀訊號是：能不能從程式碼把整個環境在另一個帳號重建出來。</p>
<p>第 3 階「環境分離」把同一份描述模組化，套用到 dev / staging / production 等多個環境，各自獨立 state。它解決的問題是「在 staging 驗證過的變更，能用同一套描述安全地推到 production」。「模組四：環境分離與模組化」專講這一階的切法。</p>
<p>第 4 階「PR 流程治理」把 infra 變更接上和應用程式碼相同的協作流程：變更走 pull request，CI 自動跑 plan 把預期差異貼上來，人審查後才 apply。到這一階，infra 的每次變更都有提案、審查、歷史與回退點。「模組七：infra 走 PR 流程」會完整展開這套護欄。</p>
<p>這條階梯是一把對齊現況的尺，用來判斷某項資源該停在哪一階，不是越高越好的單向命令。停在哪一階的依據，是務實節奏。</p>
<h2 id="早期新創的務實節奏">早期新創的務實節奏</h2>
<p>早期團隊的合理目標是「地基類資源先上到階梯第 2 階，應用層資源容許暫時留在低階」，而不是一步衝到第 4 階。資源有限、需求還在劇烈變動的階段，把全部資源都套上完整治理流程，划得來的機率不高——治理的固定成本會壓到本來就稀缺的開發頻寬。</p>
<p>判斷節奏的依據是「這項資源的形狀穩不穩、動它的代價高不高」。網路拓撲、身分權限、state 後端這類地基，一旦長歪回頭改的代價極高，值得 day 1 就進 IaC，這是少數接近「該照做」的硬判準，因為它牽涉安全邊界。反過來，一個還在每週改三次規格的功能用的運算資源，過早凍進嚴格流程反而拖慢試錯，這時容許它手動、但設一條 tripwire：當它開始被線上流量依賴、或開始有第二個人需要改它時，就是把它納管的時機。</p>
<p>過度設計和放任手動是這個階段的兩個反向誤判。過度設計的訊號是：環境只有五個資源，卻已經有多層抽象模組和還用不到的多環境結構，維護抽象的時間比省下的時間多。放任手動的訊號是：每次有人問「這個怎麼建的」都只能去翻某個人的記憶，地基債務在無聲累積。務實節奏就是在這兩者之間，讓地基先穩、讓應用層保留試錯彈性，再隨著形狀固定逐項往階梯上推。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/infra/00-infra-mindset/personal-project-to-infra/" data-link-title="雲端部署裡已經存在的 infra 元件" data-link-desc="VPC、security group、IAM、儲存 — 這些元件在任何雲端部署裡都已經在運作，差別在於有沒有被有意識地管理">個人專案到團隊服務：infra 在哪裡出現</a></td>
          <td>從 side project 部署到雲端的過程，看見 VPC、security group、IAM 這些元件其實早就在運作</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/00-infra-mindset/one-machine-to-environments/" data-link-title="從單一環境到環境分離：infra 需求的浮現過程" data-link-desc="單一 EC2 &#43; RDS 的結構在需要測試環境、多人協作時會撞到哪些操作極限，以及環境分離怎麼牽出身分、網路、變更流程等後續 infra 關注點">一台機器到三個環境：infra 解決的問題</a></td>
          <td>從一台 EC2 到需要 dev / staging / prod 三個環境的過程中，infra 的每一個關注點怎麼自然浮現</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/00-infra-mindset/infra-responsibility-maturity/" data-link-title="infra 的責任邊界、成熟度階梯與 day 1 鐵律" data-link-desc="基礎設施承擔五個面向的責任，每一面都有獨立的失效模式；成熟度階梯用來對齊現況而非追求滿分，day 1 鐵律則劃出早期團隊該優先鋪的地基">責任邊界、成熟度階梯與 day 1 鐵律</a></td>
          <td>五個責任面向的失效模式、成熟度階梯的五個刻度、day 1 鐵律與早期團隊的務實節奏</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/00-infra-mindset/first-day-with-cloud-account/" data-link-title="拿到雲端帳號的第一天" data-link-desc="被指派 infra 工作、拿到 AWS 或 GCP 帳號、不確定該先做什麼時讀 — 第一小時安全底線、帳號現況判讀、後續學習路線分流">拿到雲端帳號的第一天</a></td>
          <td>被指派 infra 工作時的第一小時安全底線、帳號現況判讀、後續學習路線分流</td>
      </tr>
  </tbody>
</table>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一：還沒有 infra 的手動環境</a>：階梯第 0 階的環境怎麼盡量做好</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>：地基資源跨上成熟度階梯第 2 階的最小路徑</li>
<li>→ <a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>：成熟度階梯第 3 階的切法</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>：成熟度階梯第 4 階的治理護欄</li>
<li>→ <a href="/blog/infra/09-driving-adoption/" data-link-title="模組九：怎麼把 infra 推動起來" data-link-desc="技術正確不等於推得動 — 信任赤字、期望值對齊、知識共享，infra 落地的組織課題">模組九：怎麼把 infra 推動起來</a>：地基的價值怎麼用商業語言講給上層聽</li>
<li>→ <a href="/blog/linux/install/" data-link-title="Linux 安裝與機器初始化" data-link-desc="在 VM 或新機器從零裝好 Linux、判讀安裝程式選項、驗證最小系統、或要從外部連入跑 bootstrap 時回來讀">Linux 安裝與機器初始化</a>：拿到雲端主機後從 OS 層連入、跑 bootstrap 的前置，跟 infra 的資源管理是上下游；主機連不到 / 起不來時的診斷見 <a href="/blog/linux/debug/machine-unreachable/" data-link-title="機器連不到或起不來" data-link-desc="遠端機器突然 SSH 連不上、虛擬機開不了機、或懷疑磁碟滿引發連鎖故障時，從主機側與網路層的權威狀態往下定位是哪一環斷了">機器連不到或起不來</a></li>
</ul>
]]></content:encoded></item></channel></rss>