<?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>Infra on Tarragon</title><link>https://tarrragon.github.io/blog/tags/infra/</link><description>Recent content in Infra on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 30 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/infra/index.xml" rel="self" type="application/rss+xml"/><item><title>斷網環境的 infra：沒有網路時怎麼做</title><link>https://tarrragon.github.io/blog/infra/air-gapped/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/air-gapped/</guid><description>&lt;p>斷網環境（air-gapped）是跟網際網路完全隔離的執行環境——沒有 &lt;code>apt install&lt;/code>、沒有 &lt;code>terraform init&lt;/code> 自動下載 provider、沒有 Docker Hub 可以 pull image、沒有 GitHub Actions 可以跑 CI。這個約束不改變 infra 的原則（可重建、可追蹤、可審查），但改變了幾乎所有工具的使用方式。&lt;/p>
&lt;p>常見的斷網情境：政府或軍事機密網路（實體隔離）、工控與 OT 環境（工廠、電廠、SCADA）、金融交易系統的高安全隔離區、醫療設備網路、以及地端機房裡刻意不開 internet access 的 private zone。&lt;/p>
&lt;p>這個模組是橫切約束——它影響&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>到&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 自動化，讓基礎設施可審查、可回溯、可交接">模組七（PR 流程）&lt;/a>的每一個操作步驟。每篇文章處理一個被斷網影響的主要面向。&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/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">斷網環境的通用原則&lt;/a>&lt;/td>
 &lt;td>離線套件管理、內容搬運、變更追蹤的共通操作模式&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-iac/" data-link-title="斷網環境的 IaC" data-link-desc="Terraform provider mirror、離線 plugin cache、本地 state backend、沒有雲端時的 plan/apply 流程與內網 CI">斷網環境的 IaC&lt;/a>&lt;/td>
 &lt;td>Terraform provider mirror、離線 state backend、plan/apply 流程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-container/" data-link-title="斷網環境的容器與映像管理" data-link-desc="Private registry 架設、映像搬運（docker save/load、skopeo）、base image 更新週期、離線漏洞掃描">斷網環境的容器與映像管理&lt;/a>&lt;/td>
 &lt;td>Private registry、映像搬運、離線 base image 更新&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-monitoring/" data-link-title="斷網環境的監控與可觀測性" data-link-desc="Self-hosted 監控（Prometheus &amp;#43; Grafana）、離線 log 收集（Loki / ELK）、不能 phone home 的告警、NTP 時間同步">斷網環境的監控與可觀測性&lt;/a>&lt;/td>
 &lt;td>Self-hosted 監控工具、離線告警、log 收集&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-self-hosted-services/" data-link-title="斷網環境要自建的服務清單" data-link-desc="正常環境消費的 SaaS（GitHub、Docker Hub、npm、Datadog）在斷網環境全部要自建 — 服務清單、選型、部署順序、統一管理取捨與維護的隱藏成本">斷網環境要自建的服務清單&lt;/a>&lt;/td>
 &lt;td>10 類服務的選型、部署順序、統一管理 vs 個別部署、維護成本&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-vcs-ci/" data-link-title="斷網環境的版本控制與 CI/CD" data-link-desc="在沒有 GitHub、沒有 Docker Hub 的隔離網路裡，怎麼部署版本控制、設定 CI runner、跨邊界傳輸 commit、以及讓 PR review 流程運作">斷網環境的版控與 CI/CD&lt;/a>&lt;/td>
 &lt;td>GitLab CE / Gitea 離線安裝、CI runner、git bundle 跨邊界傳輸&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-package-registry/" data-link-title="斷網環境的套件與容器映像 Registry" data-link-desc="斷網環境裡每一個 apt install、npm install、docker pull 都需要內部來源 — 用 Nexus Repository 統一管理套件、用 Harbor 管理容器映像、建立定期搬運與安全掃描的更新週期">斷網環境的套件與容器 Registry&lt;/a>&lt;/td>
 &lt;td>Nexus 統一 proxy、Harbor 容器 registry、映像搬運 SOP、Helm 離線&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-infrastructure-services/" data-link-title="斷網環境的基礎服務：DNS、NTP、CA 與 Secret Management" data-link-desc="斷網環境裡其他所有服務的前提——內部 DNS 做名稱解析、NTP 做時間同步、內部 CA 簽發 TLS 憑證、Vault 管理機密值。這四個服務先部署、其他才能跟上。">斷網環境的基礎服務&lt;/a>&lt;/td>
 &lt;td>DNS (CoreDNS) + NTP (chrony) + CA (step-ca) + Vault&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-security-access/" data-link-title="斷網環境的資安與權限控管" data-link-desc="斷網環境的威脅模型從外部攻擊轉向內部人員與供應鏈——實體安全、離線認證、稽核日誌、更新延遲窗口、跨邊界傳輸審查各有專屬的操作方式">斷網環境的資安與權限控管&lt;/a>&lt;/td>
 &lt;td>威脅模型轉變、實體安全、離線認證、稽核日誌、跨邊界安全審查&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/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一：最小可行 IaC&lt;/a>：斷網時 IaC 工具選型和 state backend 的替代做法&lt;/li>
&lt;li>→ &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;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/infra/06-observability-logging/" data-link-title="模組六：可觀測性與 log 一併寫進 code" data-link-desc="log group、metric、alarm 跟基礎設施同生命週期管理，出事時追得到查得到">模組六：可觀測性&lt;/a>：斷網環境的監控不能 phone home&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 自動化，讓基礎設施可審查、可回溯、可交接">模組七：PR 流程&lt;/a>：CI/CD 在內網怎麼跑&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>斷網環境（air-gapped）是跟網際網路完全隔離的執行環境——沒有 <code>apt install</code>、沒有 <code>terraform init</code> 自動下載 provider、沒有 Docker Hub 可以 pull image、沒有 GitHub Actions 可以跑 CI。這個約束不改變 infra 的原則（可重建、可追蹤、可審查），但改變了幾乎所有工具的使用方式。</p>
<p>常見的斷網情境：政府或軍事機密網路（實體隔離）、工控與 OT 環境（工廠、電廠、SCADA）、金融交易系統的高安全隔離區、醫療設備網路、以及地端機房裡刻意不開 internet access 的 private zone。</p>
<p>這個模組是橫切約束——它影響<a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一（IaC 選型）</a>到<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 自動化，讓基礎設施可審查、可回溯、可交接">模組七（PR 流程）</a>的每一個操作步驟。每篇文章處理一個被斷網影響的主要面向。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/infra/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">斷網環境的通用原則</a></td>
          <td>離線套件管理、內容搬運、變更追蹤的共通操作模式</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/air-gapped/air-gapped-iac/" data-link-title="斷網環境的 IaC" data-link-desc="Terraform provider mirror、離線 plugin cache、本地 state backend、沒有雲端時的 plan/apply 流程與內網 CI">斷網環境的 IaC</a></td>
          <td>Terraform provider mirror、離線 state backend、plan/apply 流程</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/air-gapped/air-gapped-container/" data-link-title="斷網環境的容器與映像管理" data-link-desc="Private registry 架設、映像搬運（docker save/load、skopeo）、base image 更新週期、離線漏洞掃描">斷網環境的容器與映像管理</a></td>
          <td>Private registry、映像搬運、離線 base image 更新</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/air-gapped/air-gapped-monitoring/" data-link-title="斷網環境的監控與可觀測性" data-link-desc="Self-hosted 監控（Prometheus &#43; Grafana）、離線 log 收集（Loki / ELK）、不能 phone home 的告警、NTP 時間同步">斷網環境的監控與可觀測性</a></td>
          <td>Self-hosted 監控工具、離線告警、log 收集</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/air-gapped/air-gapped-self-hosted-services/" data-link-title="斷網環境要自建的服務清單" data-link-desc="正常環境消費的 SaaS（GitHub、Docker Hub、npm、Datadog）在斷網環境全部要自建 — 服務清單、選型、部署順序、統一管理取捨與維護的隱藏成本">斷網環境要自建的服務清單</a></td>
          <td>10 類服務的選型、部署順序、統一管理 vs 個別部署、維護成本</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/air-gapped/air-gapped-vcs-ci/" data-link-title="斷網環境的版本控制與 CI/CD" data-link-desc="在沒有 GitHub、沒有 Docker Hub 的隔離網路裡，怎麼部署版本控制、設定 CI runner、跨邊界傳輸 commit、以及讓 PR review 流程運作">斷網環境的版控與 CI/CD</a></td>
          <td>GitLab CE / Gitea 離線安裝、CI runner、git bundle 跨邊界傳輸</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/air-gapped/air-gapped-package-registry/" data-link-title="斷網環境的套件與容器映像 Registry" data-link-desc="斷網環境裡每一個 apt install、npm install、docker pull 都需要內部來源 — 用 Nexus Repository 統一管理套件、用 Harbor 管理容器映像、建立定期搬運與安全掃描的更新週期">斷網環境的套件與容器 Registry</a></td>
          <td>Nexus 統一 proxy、Harbor 容器 registry、映像搬運 SOP、Helm 離線</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/air-gapped/air-gapped-infrastructure-services/" data-link-title="斷網環境的基礎服務：DNS、NTP、CA 與 Secret Management" data-link-desc="斷網環境裡其他所有服務的前提——內部 DNS 做名稱解析、NTP 做時間同步、內部 CA 簽發 TLS 憑證、Vault 管理機密值。這四個服務先部署、其他才能跟上。">斷網環境的基礎服務</a></td>
          <td>DNS (CoreDNS) + NTP (chrony) + CA (step-ca) + Vault</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/air-gapped/air-gapped-security-access/" data-link-title="斷網環境的資安與權限控管" data-link-desc="斷網環境的威脅模型從外部攻擊轉向內部人員與供應鏈——實體安全、離線認證、稽核日誌、更新延遲窗口、跨邊界傳輸審查各有專屬的操作方式">斷網環境的資安與權限控管</a></td>
          <td>威脅模型轉變、實體安全、離線認證、稽核日誌、跨邊界安全審查</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>：斷網時 IaC 工具選型和 state backend 的替代做法</li>
<li>→ <a href="/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a>：容器映像和套件依賴的離線管理</li>
<li>→ <a href="/blog/infra/06-observability-logging/" data-link-title="模組六：可觀測性與 log 一併寫進 code" data-link-desc="log group、metric、alarm 跟基礎設施同生命週期管理，出事時追得到查得到">模組六：可觀測性</a>：斷網環境的監控不能 phone home</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 自動化，讓基礎設施可審查、可回溯、可交接">模組七：PR 流程</a>：CI/CD 在內網怎麼跑</li>
<li>→ <a href="/blog/infra/takeover/" data-link-title="接手維運：別人建的環境怎麼接管" data-link-desc="接手前人的專案時，怎麼在不搞壞東西的前提下盤點現況、建立維運能力、逐步正規化 — 從無 SSH 的 FTP 環境到有半套 IaC 的雲端環境都適用">接手維運</a>：接手斷網環境的額外約束</li>
</ul>
]]></content:encoded></item><item><title>環境與系統升級：帶電施工的遷移操作</title><link>https://tarrragon.github.io/blog/infra/upgrade/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/upgrade/</guid><description>&lt;p>環境與系統升級跟從零建置的差別在於：從零建置時可以先建好再上線，升級時系統已經在服務客戶，每一步操作都要在不中斷（或可控中斷）的前提下完成。這個約束決定了升級的操作模式——不是「拆掉重建」，而是「在旁邊建一個新的、驗證通過後切過去、確認沒問題再拆舊的」。&lt;/p>
&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>平行而非串行——升級可能發生在任何成熟度階段。跟&lt;a href="https://tarrragon.github.io/blog/infra/takeover/" data-link-title="接手維運：別人建的環境怎麼接管" data-link-desc="接手前人的專案時，怎麼在不搞壞東西的前提下盤點現況、建立維運能力、逐步正規化 — 從無 SSH 的 FTP 環境到有半套 IaC 的雲端環境都適用">接手維運&lt;/a>的關係是：接手後的下一步常常就是升級（接手一個 PHP 5.6 的站台，穩定維運後第一個任務就是升 PHP 版本）。&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/upgrade/upgrade-framework/" data-link-title="升級的共通操作框架" data-link-desc="任何環境或系統升級的四階段模型：差異評估、平行環境驗證、分批切換、退役舊環境，以及貫穿全程的升級紀律">升級的共通操作框架&lt;/a>&lt;/td>
 &lt;td>評估差異、建平行環境、分批切換、退役舊環境的四階段模型&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/upgrade/runtime-version-upgrade/" data-link-title="Runtime 版本升級" data-link-desc="PHP / Node.js / Python 大版本升級的相容性評估、本地驗證、分批部署策略與常見陷阱">Runtime 版本升級&lt;/a>&lt;/td>
 &lt;td>PHP / Node / Python 大版本升級的相容性評估、測試策略、分批部署&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/upgrade/platform-migration/" data-link-title="平台遷移" data-link-desc="FTP 面板主機到 VPS、VPS 到雲端、地端到雲端的遷移路徑 — 資料同步策略、DNS 切換、驗證與回退">平台遷移&lt;/a>&lt;/td>
 &lt;td>FTP 面板主機 → VPS → 雲端的遷移路徑、DNS 切換、資料同步&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/upgrade/database-major-upgrade/" data-link-title="資料庫大版本升級" data-link-desc="MySQL 5.7→8.0、PostgreSQL 13→16 等大版本升級的相容性評估、備份保險、平行驗證、切換策略與升級後監控">資料庫大版本升級&lt;/a>&lt;/td>
 &lt;td>MySQL / PostgreSQL 大版本升級的相容性、備份、平行驗證、切換策略&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/upgrade/os-base-software-upgrade/" data-link-title="OS 與基礎軟體更換" data-link-desc="EOL 作業系統的遷移評估、目標 OS 選型、原地升級 vs 平行建置的取捨、應用層遷移清單，以及 Apache → nginx 等基礎軟體切換的操作要點">OS 與基礎軟體更換&lt;/a>&lt;/td>
 &lt;td>EOL OS 的遷移、套件相容性、服務重新部署&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/takeover/" data-link-title="接手維運：別人建的環境怎麼接管" data-link-desc="接手前人的專案時，怎麼在不搞壞東西的前提下盤點現況、建立維運能力、逐步正規化 — 從無 SSH 的 FTP 環境到有半套 IaC 的雲端環境都適用">接手維運&lt;/a>：接手後穩定維運的下一步常是升級&lt;/li>
&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>：升級過程中建立的操作紀律可以對齊這裡&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 的好時機——新環境用 IaC 建、舊環境手動退役&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC&lt;/a>：資料庫和運算平台的升級涉及 stateful 資源的特殊處理&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>環境與系統升級跟從零建置的差別在於：從零建置時可以先建好再上線，升級時系統已經在服務客戶，每一步操作都要在不中斷（或可控中斷）的前提下完成。這個約束決定了升級的操作模式——不是「拆掉重建」，而是「在旁邊建一個新的、驗證通過後切過去、確認沒問題再拆舊的」。</p>
<p>這個模組處理的是升級的操作框架與各類型的專屬風險，跟<a href="/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">成熟度階梯</a>平行而非串行——升級可能發生在任何成熟度階段。跟<a href="/blog/infra/takeover/" data-link-title="接手維運：別人建的環境怎麼接管" data-link-desc="接手前人的專案時，怎麼在不搞壞東西的前提下盤點現況、建立維運能力、逐步正規化 — 從無 SSH 的 FTP 環境到有半套 IaC 的雲端環境都適用">接手維運</a>的關係是：接手後的下一步常常就是升級（接手一個 PHP 5.6 的站台，穩定維運後第一個任務就是升 PHP 版本）。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/infra/upgrade/upgrade-framework/" data-link-title="升級的共通操作框架" data-link-desc="任何環境或系統升級的四階段模型：差異評估、平行環境驗證、分批切換、退役舊環境，以及貫穿全程的升級紀律">升級的共通操作框架</a></td>
          <td>評估差異、建平行環境、分批切換、退役舊環境的四階段模型</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/upgrade/runtime-version-upgrade/" data-link-title="Runtime 版本升級" data-link-desc="PHP / Node.js / Python 大版本升級的相容性評估、本地驗證、分批部署策略與常見陷阱">Runtime 版本升級</a></td>
          <td>PHP / Node / Python 大版本升級的相容性評估、測試策略、分批部署</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/upgrade/platform-migration/" data-link-title="平台遷移" data-link-desc="FTP 面板主機到 VPS、VPS 到雲端、地端到雲端的遷移路徑 — 資料同步策略、DNS 切換、驗證與回退">平台遷移</a></td>
          <td>FTP 面板主機 → VPS → 雲端的遷移路徑、DNS 切換、資料同步</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/upgrade/database-major-upgrade/" data-link-title="資料庫大版本升級" data-link-desc="MySQL 5.7→8.0、PostgreSQL 13→16 等大版本升級的相容性評估、備份保險、平行驗證、切換策略與升級後監控">資料庫大版本升級</a></td>
          <td>MySQL / PostgreSQL 大版本升級的相容性、備份、平行驗證、切換策略</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/upgrade/os-base-software-upgrade/" data-link-title="OS 與基礎軟體更換" data-link-desc="EOL 作業系統的遷移評估、目標 OS 選型、原地升級 vs 平行建置的取捨、應用層遷移清單，以及 Apache → nginx 等基礎軟體切換的操作要點">OS 與基礎軟體更換</a></td>
          <td>EOL OS 的遷移、套件相容性、服務重新部署</td>
      </tr>
  </tbody>
</table>
<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/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一：還沒有 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 的好時機——新環境用 IaC 建、舊環境手動退役</li>
<li>→ <a href="/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a>：資料庫和運算平台的升級涉及 stateful 資源的特殊處理</li>
</ul>
]]></content:encoded></item><item><title>接手維運：別人建的環境怎麼接管</title><link>https://tarrragon.github.io/blog/infra/takeover/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/takeover/</guid><description>&lt;p>接手維運跟從零建置的差別在於：從零建置時每一個資源都是自己點的，知道它存在、知道為什麼存在；接手時面對的是一個不確定哪些東西還在用、不知道動什麼會壞的環境。第一個要解的問題不是「怎麼做 infra」，而是「現在到底有什麼、它還能不能跑、改了會怎樣」。&lt;/p>
&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>平行而非串行 — 接手可能發生在任何成熟度階段：接手一個只有 FTP 存取的 PHP 站、接手一個有 SSH 但沒有 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/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">無 SSH 的 FTP / 面板管理環境接管&lt;/a>&lt;/td>
 &lt;td>沒有 SSH、沒有 CLI、只有 FTP 和 phpMyAdmin 的 legacy 環境怎麼接管（總覽）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/takeover/legacy-database-backup-migration/" data-link-title="無 SSH 環境的資料庫備份與變更管理" data-link-desc="在只有 phpMyAdmin 或有限遠端連線的無 SSH 環境裡，怎麼建立可靠的資料庫備份策略、schema 變更紀律與還原演練流程">無 SSH 環境的資料庫備份與變更管理&lt;/a>&lt;/td>
 &lt;td>phpMyAdmin 的限制與對策、備份策略、migration 紀律、還原演練&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/takeover/legacy-code-versioning-deployment/" data-link-title="程式碼版控與 FTP 部署紀律" data-link-desc="無 SSH 環境的 PHP 專案的程式碼怎麼從 FTP 拉回來建 Git repo、設定檔怎麼分離、FTP 部署怎麼建立可追蹤的流程、以及怎麼用 CI 取代手動上傳">程式碼版控與 FTP 部署紀律&lt;/a>&lt;/td>
 &lt;td>本地 Git 工作流、config 分離、FTP 部署風險控制、CI 化 FTP&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/takeover/legacy-php-security-audit/" data-link-title="Legacy PHP 的安全盤點" data-link-desc="接手 legacy PHP 專案後的系統性安全審查：credential 掃描、PHP 版本風險、常見漏洞模式的 grep 偵測、.htaccess 防線、檔案權限、外部依賴與掃描工具">Legacy PHP 的安全盤點&lt;/a>&lt;/td>
 &lt;td>credential 掃描、PHP 版本風險、SQL injection/XSS 模式、.htaccess 防護&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/takeover/legacy-external-monitoring/" data-link-title="無 SSH 環境的監控與告警" data-link-desc="無 SSH 環境沒辦法裝 agent、沒辦法串 log pipeline，用外部 HTTP check、錯誤追蹤服務與效能基線建立最低成本的監控能力">無 SSH 環境的監控與告警&lt;/a>&lt;/td>
 &lt;td>外部 HTTP check、錯誤追蹤、效能基線、流量異常偵測&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/takeover/cloud-no-iac/" data-link-title="有 SSH 但沒有 IaC 的雲端環境接管" data-link-desc="接手一個全手動建立的雲端環境時，怎麼盤點資源、推導依賴關係、收斂 credential、驗證備份、建立變更紀律，以及什麼時候該開始導入 IaC">有 SSH 但沒有 IaC 的雲端環境接管&lt;/a>&lt;/td>
 &lt;td>有 Console 和 CLI 存取、但資源全是手動建的雲端環境怎麼盤點和接管&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/takeover/partial-iac-no-docs/" data-link-title="有半套 IaC 但文件缺失的環境接管" data-link-desc="IaC 覆蓋不完整、部分資源在 state 外、文件缺失的環境怎麼盤點差距、修復 state 健康、收斂 drift 並重建文件">有半套 IaC 但文件缺失的環境接管&lt;/a>&lt;/td>
 &lt;td>IaC 覆蓋不完整、部分資源在 state 外、文件缺失的環境怎麼收斂（總覽）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/takeover/partial-iac-state-repair/" data-link-title="State 修復與清理" data-link-desc="接手的 Terraform state 損壞、有 orphaned entry、或需要搬遷時，怎麼診斷問題、安全操作、以及從錯誤中回復">State 修復與清理&lt;/a>&lt;/td>
 &lt;td>state 損壞診斷、orphaned entry 清理、state surgery、backend 搬遷&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/takeover/partial-iac-drift-triage/" data-link-title="Drift 分類處理指南" data-link-desc="接手半套 IaC 環境時，怎麼讀 plan 輸出分類 drift、判斷保留還是回退、處理 stateful 資源的高風險漂移，以及批次收斂的工作流">Drift 分類處理指南&lt;/a>&lt;/td>
 &lt;td>plan 輸出分類、adopt vs revert 決策、stateful replacement 風險&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/takeover/partial-iac-bulk-import/" data-link-title="Unmanaged Resource 批次 Import 工作流" data-link-desc="把 Terraform state 外的雲端資源有系統地納入 IaC 管理：優先序判斷、import block 語法、generated HCL 的 review 要點、批次策略與常見失敗處理">Unmanaged Resource 批次 Import&lt;/a>&lt;/td>
 &lt;td>優先序、import block、generated HCL review、批次策略&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/takeover/partial-iac-dual-truth-operation/" data-link-title="兩套真相並存的過渡期操作" data-link-desc="部分資源在 IaC、部分在手動時，怎麼安全操作避免比全手動更危險，以及怎麼縮短這個過渡期">兩套真相並存的過渡期操作&lt;/a>&lt;/td>
 &lt;td>操作規則、ownership 台帳、團隊溝通、import sprint、transition 完成判準&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="跟其他模組的關係">跟其他模組的關係&lt;/h2>
&lt;p>接手維運的終點是把環境帶到&lt;a href="https://tarrragon.github.io/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一&lt;/a>（可控的手動）或&lt;a href="https://tarrragon.github.io/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一&lt;/a>（最小可行 IaC）的狀態。接手流程本身不做 IaC 導入 — 它的責任是讓接手者理解環境、建立維運能力、確認什麼能動什麼不能動。IaC 導入是接手完成之後的下一步。&lt;/p></description><content:encoded><![CDATA[<p>接手維運跟從零建置的差別在於：從零建置時每一個資源都是自己點的，知道它存在、知道為什麼存在；接手時面對的是一個不確定哪些東西還在用、不知道動什麼會壞的環境。第一個要解的問題不是「怎麼做 infra」，而是「現在到底有什麼、它還能不能跑、改了會怎樣」。</p>
<p>這個模組處理的是接管的操作流程，跟<a href="/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">成熟度階梯</a>平行而非串行 — 接手可能發生在任何成熟度階段：接手一個只有 FTP 存取的 PHP 站、接手一個有 SSH 但沒有 IaC 的雲端環境、接手一個有半套 IaC 但文件缺失的專案。每種情境的約束不同，但操作原則相通：先拍現況、再建維運能力、最後逐步正規化。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">無 SSH 的 FTP / 面板管理環境接管</a></td>
          <td>沒有 SSH、沒有 CLI、只有 FTP 和 phpMyAdmin 的 legacy 環境怎麼接管（總覽）</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/takeover/legacy-database-backup-migration/" data-link-title="無 SSH 環境的資料庫備份與變更管理" data-link-desc="在只有 phpMyAdmin 或有限遠端連線的無 SSH 環境裡，怎麼建立可靠的資料庫備份策略、schema 變更紀律與還原演練流程">無 SSH 環境的資料庫備份與變更管理</a></td>
          <td>phpMyAdmin 的限制與對策、備份策略、migration 紀律、還原演練</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/takeover/legacy-code-versioning-deployment/" data-link-title="程式碼版控與 FTP 部署紀律" data-link-desc="無 SSH 環境的 PHP 專案的程式碼怎麼從 FTP 拉回來建 Git repo、設定檔怎麼分離、FTP 部署怎麼建立可追蹤的流程、以及怎麼用 CI 取代手動上傳">程式碼版控與 FTP 部署紀律</a></td>
          <td>本地 Git 工作流、config 分離、FTP 部署風險控制、CI 化 FTP</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/takeover/legacy-php-security-audit/" data-link-title="Legacy PHP 的安全盤點" data-link-desc="接手 legacy PHP 專案後的系統性安全審查：credential 掃描、PHP 版本風險、常見漏洞模式的 grep 偵測、.htaccess 防線、檔案權限、外部依賴與掃描工具">Legacy PHP 的安全盤點</a></td>
          <td>credential 掃描、PHP 版本風險、SQL injection/XSS 模式、.htaccess 防護</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/takeover/legacy-external-monitoring/" data-link-title="無 SSH 環境的監控與告警" data-link-desc="無 SSH 環境沒辦法裝 agent、沒辦法串 log pipeline，用外部 HTTP check、錯誤追蹤服務與效能基線建立最低成本的監控能力">無 SSH 環境的監控與告警</a></td>
          <td>外部 HTTP check、錯誤追蹤、效能基線、流量異常偵測</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/takeover/cloud-no-iac/" data-link-title="有 SSH 但沒有 IaC 的雲端環境接管" data-link-desc="接手一個全手動建立的雲端環境時，怎麼盤點資源、推導依賴關係、收斂 credential、驗證備份、建立變更紀律，以及什麼時候該開始導入 IaC">有 SSH 但沒有 IaC 的雲端環境接管</a></td>
          <td>有 Console 和 CLI 存取、但資源全是手動建的雲端環境怎麼盤點和接管</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/takeover/partial-iac-no-docs/" data-link-title="有半套 IaC 但文件缺失的環境接管" data-link-desc="IaC 覆蓋不完整、部分資源在 state 外、文件缺失的環境怎麼盤點差距、修復 state 健康、收斂 drift 並重建文件">有半套 IaC 但文件缺失的環境接管</a></td>
          <td>IaC 覆蓋不完整、部分資源在 state 外、文件缺失的環境怎麼收斂（總覽）</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/takeover/partial-iac-state-repair/" data-link-title="State 修復與清理" data-link-desc="接手的 Terraform state 損壞、有 orphaned entry、或需要搬遷時，怎麼診斷問題、安全操作、以及從錯誤中回復">State 修復與清理</a></td>
          <td>state 損壞診斷、orphaned entry 清理、state surgery、backend 搬遷</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/takeover/partial-iac-drift-triage/" data-link-title="Drift 分類處理指南" data-link-desc="接手半套 IaC 環境時，怎麼讀 plan 輸出分類 drift、判斷保留還是回退、處理 stateful 資源的高風險漂移，以及批次收斂的工作流">Drift 分類處理指南</a></td>
          <td>plan 輸出分類、adopt vs revert 決策、stateful replacement 風險</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/takeover/partial-iac-bulk-import/" data-link-title="Unmanaged Resource 批次 Import 工作流" data-link-desc="把 Terraform state 外的雲端資源有系統地納入 IaC 管理：優先序判斷、import block 語法、generated HCL 的 review 要點、批次策略與常見失敗處理">Unmanaged Resource 批次 Import</a></td>
          <td>優先序、import block、generated HCL review、批次策略</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/takeover/partial-iac-dual-truth-operation/" data-link-title="兩套真相並存的過渡期操作" data-link-desc="部分資源在 IaC、部分在手動時，怎麼安全操作避免比全手動更危險，以及怎麼縮短這個過渡期">兩套真相並存的過渡期操作</a></td>
          <td>操作規則、ownership 台帳、團隊溝通、import sprint、transition 完成判準</td>
      </tr>
  </tbody>
</table>
<h2 id="跟其他模組的關係">跟其他模組的關係</h2>
<p>接手維運的終點是把環境帶到<a href="/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一</a>（可控的手動）或<a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一</a>（最小可行 IaC）的狀態。接手流程本身不做 IaC 導入 — 它的責任是讓接手者理解環境、建立維運能力、確認什麼能動什麼不能動。IaC 導入是接手完成之後的下一步。</p>
<ul>
<li>→ <a href="/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一：還沒有 infra 的環境</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/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證</a>：接手時的 credential 盤點與輪替</li>
<li>→ <a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>：接手後的 tagging 與 secret 管理</li>
</ul>
]]></content:encoded></item><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>infra 投資的商業論證</title><link>https://tarrragon.github.io/blog/infra/09-driving-adoption/infra-business-justification/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/09-driving-adoption/infra-business-justification/</guid><description>&lt;p>技術正確的論述說服不了商業決策者。「我們需要 Infrastructure as Code 來確保環境可重現」這句話在工程會議裡有重量，在預算會議裡沒有 — 決策者聽到的是一個他不懂的技術詞、一個他看不到的好處、以及一筆他得批的時間預算。infra 推不動，多數時候是因為提案的語言跟決策者的語言對不上，而非 infra 本身不重要。&lt;/p>
&lt;p>這篇文章提供三條可以直接拿去用的論述線 — 成本、風險、速度 — 以及一套簡報骨架和常見反對意見的回應。目標是讓讀完的人能在下一次預算會議上，用決策者聽得懂的語言講出 infra 投資的必要性。&lt;/p>
&lt;h2 id="成本論述不做-infra-的隱藏成本">成本論述：不做 infra 的隱藏成本&lt;/h2>
&lt;p>infra 投資的成本是可見的（工程師時間），不做的成本是隱藏的（散落在不同科目、由不同人承擔、在不同時間點浮現）。商業論證的第一步是把隱藏成本攤開來算，讓「不做」也有一個價格標籤。&lt;/p>
&lt;h3 id="事故恢復時間">事故恢復時間&lt;/h3>
&lt;p>沒有環境藍圖（程式碼描述）的系統，出事後的恢復時間取決於「有沒有人記得它當初怎麼建的」。一個手動點出來的環境，主要維護者離開座位的那一刻就進入「沒有藍圖」的狀態 — 重建它需要翻 Console、翻 CloudTrail、翻 Slack 對話、猜測各項設定的用意，這個過程以天計。有環境藍圖的系統，重建是一條指令加上等待資源啟動的時間，以分鐘計。&lt;/p>
&lt;p>把這個差距換算成商業數字：停機每小時的營收損失乘以恢復時間的差距，就是「沒有藍圖」在一次事故中的價格。一個日營收 100 萬的服務停機 8 小時和停機 30 分鐘，差距是 750 萬。這個數字不需要精確 — 量級對了就足以讓決策者重新評估「不做」的代價。&lt;/p>
&lt;h3 id="人員依賴成本">人員依賴成本&lt;/h3>
&lt;p>當只有一個人懂整套環境怎麼運作，這個人的離職成本不只是招聘與交接 — 還包括新人摸索期間的生產力損失、期間無法安全改動環境的機會成本、以及「找不到一樣有經驗的人」的風險。把環境的建立方式寫成程式碼，新人讀程式碼就能理解環境結構，交接從「口耳相傳」變成「讀文件」。&lt;/p>
&lt;p>量化方式：目前負責 infra 的人如果下週離職，預估團隊需要多少時間才能重新掌握環境？乘以團隊的平均日薪，就是人員依賴的隱含成本。這個成本隨著環境複雜度增長而加速 — 環境越大、手動設定越多，交接缺口越寬。&lt;/p>
&lt;h3 id="殭屍資源成本">殭屍資源成本&lt;/h3>
&lt;p>沒有資源盤點與標籤的環境，會持續累積「沒有人記得還開著」的資源 — 測試用的機器跑完沒關、舊版服務下線但底層資源沒清、某個實驗用的資料庫一直在計費。這些殭屍資源的月費不大，但它們會無聲地長期累積。&lt;/p>
&lt;p>量化方式：請雲端帳號管理者拉出過去三個月的帳單，找出「沒有標籤」或「標籤顯示是非正式環境」的資源，加總它們的費用。多數團隊第一次做這件事時，會發現 10-30% 的月費花在沒有人認領的資源上。這個數字本身就是論證素材。&lt;/p>
&lt;h3 id="合規與稽核風險">合規與稽核風險&lt;/h3>
&lt;p>當外部稽核（SOC 2、ISO 27001、金融監管、客戶的安全問卷）要求「列出所有對外暴露的服務」「提供存取權限的變更紀錄」「證明 production 環境的變更有經過審查」，手動環境的回應方式是花一到兩週人工考古。有 infra 藍圖的環境，這些問題的答案在程式碼倉庫裡，幾分鐘就能產出。&lt;/p>
&lt;p>合規的商業代價不是抽象的 — 稽核不過可能導致客戶合約無法續簽、保險費率上調、或直接的監管罰款。把「每次稽核的準備時間」和「稽核不過的潛在損失」列成數字，比講「我們需要更好的治理」有效得多。&lt;/p>
&lt;h2 id="風險論述一張表說明影響範圍">風險論述：一張表說明影響範圍&lt;/h2>
&lt;p>成本論述算的是持續性的隱藏支出，風險論述算的是一次性失效的最壞情況。兩者的語言不同：成本用月費和工時講，風險用客戶影響和法律後果講。&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>全部客戶&lt;/td>
 &lt;td>數天&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>沒有存取權限管控&lt;/td>
 &lt;td>一把外洩的密鑰被用來存取所有資源&lt;/td>
 &lt;td>資料外洩通知 + 法律&lt;/td>
 &lt;td>數週到數月&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>測試環境與正式環境共用&lt;/td>
 &lt;td>測試操作直接影響正式客戶&lt;/td>
 &lt;td>全部客戶&lt;/td>
 &lt;td>數小時&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>沒有變更紀錄&lt;/td>
 &lt;td>事故排查找不到「誰改了什麼」&lt;/td>
 &lt;td>排查人力 + 停機延長&lt;/td>
 &lt;td>數小時&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>沒有資源標籤&lt;/td>
 &lt;td>清理資源時誤刪正式服務&lt;/td>
 &lt;td>受影響的服務客戶&lt;/td>
 &lt;td>數小時到天&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>密碼寫在程式碼裡&lt;/td>
 &lt;td>程式碼被複製或公開後密碼外洩&lt;/td>
 &lt;td>資料外洩 + 全面換密碼&lt;/td>
 &lt;td>數天&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表的用法是：請決策者指出「哪些情境我們現在有可能發生」，命中的每一行都是一個尚未兌現的風險。風險論述的價值在於它把抽象的技術缺口換算成具體的商業後果 — 不是「我們缺乏環境分離」，而是「一次測試操作可以直接打到正式客戶」。&lt;/p>
&lt;p>使用這張表時要誠實分級。把每一行都講成即將發生的災難，幾次之後決策者會把所有警告當成危言聳聽。把真正的地基級風險（密鑰外洩、沒有藍圖）跟營運效率級的問題（缺標籤、缺變更紀錄）分開講，前者用「最壞情況」爭取優先級，後者用「累積成本」來排序。&lt;/p>
&lt;h2 id="速度論述infra-是加速器">速度論述：infra 是加速器&lt;/h2>
&lt;p>成本和風險是防守型論述（不做會怎樣），速度是進攻型論述（做了會快多少）。多數決策者對「變快」的興趣高於對「防災」的興趣，因為速度直接對應到他們在意的指標 — 交付頻率、上市時間、團隊效率。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>場景&lt;/th>
 &lt;th>沒有 infra 藍圖&lt;/th>
 &lt;th>有 infra 藍圖&lt;/th>
 &lt;th>加速倍數&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>開一個新環境&lt;/td>
 &lt;td>3-5 天（逐一比對 Console 設定並手動複製）&lt;/td>
 &lt;td>30 分鐘（套用同一份程式碼）&lt;/td>
 &lt;td>10-50 倍&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>新人理解環境&lt;/td>
 &lt;td>1-2 週（口耳相傳）&lt;/td>
 &lt;td>1-2 天（讀程式碼 + PR 歷史）&lt;/td>
 &lt;td>5-10 倍&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>事故排查&lt;/td>
 &lt;td>數小時（翻 Console）&lt;/td>
 &lt;td>分鐘（查變更紀錄 + log）&lt;/td>
 &lt;td>10-30 倍&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>安全稽核準備&lt;/td>
 &lt;td>1-2 週（人工考古）&lt;/td>
 &lt;td>幾小時（從程式碼產出報告）&lt;/td>
 &lt;td>10-20 倍&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>環境一致性驗證&lt;/td>
 &lt;td>無法確認&lt;/td>
 &lt;td>程式碼 diff 一眼可見&lt;/td>
 &lt;td>從不可能到可能&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>速度論述的關鍵是把「infra 投入」框架成「一次性投入換取持續性加速」，而不是「持續性的額外負擔」。前 2-4 週是投入期（建立藍圖、設定自動化），之後每一次新環境、每一次排查、每一次稽核都在收割回報。投入是固定的，回報是累積的。&lt;/p>
&lt;h2 id="一頁簡報的邏輯">一頁簡報的邏輯&lt;/h2>
&lt;p>把前面三條論述線收斂成四頁簡報，這是可以直接拿進會議室的骨架：&lt;/p>
&lt;h3 id="第一頁現況盤點">第一頁：現況盤點&lt;/h3>
&lt;p>列出具體數字 — 我們有多少個雲端資源、其中多少百分比沒有程式碼描述、多少百分比沒有標籤、有幾把超過 90 天沒輪替的密鑰。這些數字讓決策者看到「我們目前的狀態」而非聽到一個抽象的技術判斷。數字來源是資源盤點（見&lt;a href="https://tarrragon.github.io/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一&lt;/a>）和雲端帳單。&lt;/p>
&lt;h3 id="第二頁風險與成本">第二頁：風險與成本&lt;/h3>
&lt;p>從上面的風險表挑出「我們現在確實有可能發生」的 2-3 個情境，附上最壞情況的商業影響估算。加上殭屍資源的月費和稽核準備的人工成本。這一頁的任務是讓「不做」有一個數字。&lt;/p>
&lt;h3 id="第三頁投入規劃">第三頁：投入規劃&lt;/h3>
&lt;p>把 infra 工作拆成階段，每階段標明工程師時間和里程碑。階段拆法對應&lt;a href="https://tarrragon.github.io/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">成熟度階梯&lt;/a>：第一階段（2-3 週）建立藍圖與版本控制、第二階段（2-3 週）環境分離與權限收斂、第三階段（持續）自動化護欄與治理。每個階段都能獨立交付價值 — 不是一次性的大工程，是分批兌現的投資。&lt;/p>
&lt;h3 id="第四頁回報預期">第四頁：回報預期&lt;/h3>
&lt;p>用速度論述的表格呈現：投入完成後，新環境時間、排查時間、稽核準備時間各縮短多少。加一條「人員依賴風險」的改善 — 從「只有一個人懂」到「任何人讀程式碼都能理解」。&lt;/p></description><content:encoded><![CDATA[<p>技術正確的論述說服不了商業決策者。「我們需要 Infrastructure as Code 來確保環境可重現」這句話在工程會議裡有重量，在預算會議裡沒有 — 決策者聽到的是一個他不懂的技術詞、一個他看不到的好處、以及一筆他得批的時間預算。infra 推不動，多數時候是因為提案的語言跟決策者的語言對不上，而非 infra 本身不重要。</p>
<p>這篇文章提供三條可以直接拿去用的論述線 — 成本、風險、速度 — 以及一套簡報骨架和常見反對意見的回應。目標是讓讀完的人能在下一次預算會議上，用決策者聽得懂的語言講出 infra 投資的必要性。</p>
<h2 id="成本論述不做-infra-的隱藏成本">成本論述：不做 infra 的隱藏成本</h2>
<p>infra 投資的成本是可見的（工程師時間），不做的成本是隱藏的（散落在不同科目、由不同人承擔、在不同時間點浮現）。商業論證的第一步是把隱藏成本攤開來算，讓「不做」也有一個價格標籤。</p>
<h3 id="事故恢復時間">事故恢復時間</h3>
<p>沒有環境藍圖（程式碼描述）的系統，出事後的恢復時間取決於「有沒有人記得它當初怎麼建的」。一個手動點出來的環境，主要維護者離開座位的那一刻就進入「沒有藍圖」的狀態 — 重建它需要翻 Console、翻 CloudTrail、翻 Slack 對話、猜測各項設定的用意，這個過程以天計。有環境藍圖的系統，重建是一條指令加上等待資源啟動的時間，以分鐘計。</p>
<p>把這個差距換算成商業數字：停機每小時的營收損失乘以恢復時間的差距，就是「沒有藍圖」在一次事故中的價格。一個日營收 100 萬的服務停機 8 小時和停機 30 分鐘，差距是 750 萬。這個數字不需要精確 — 量級對了就足以讓決策者重新評估「不做」的代價。</p>
<h3 id="人員依賴成本">人員依賴成本</h3>
<p>當只有一個人懂整套環境怎麼運作，這個人的離職成本不只是招聘與交接 — 還包括新人摸索期間的生產力損失、期間無法安全改動環境的機會成本、以及「找不到一樣有經驗的人」的風險。把環境的建立方式寫成程式碼，新人讀程式碼就能理解環境結構，交接從「口耳相傳」變成「讀文件」。</p>
<p>量化方式：目前負責 infra 的人如果下週離職，預估團隊需要多少時間才能重新掌握環境？乘以團隊的平均日薪，就是人員依賴的隱含成本。這個成本隨著環境複雜度增長而加速 — 環境越大、手動設定越多，交接缺口越寬。</p>
<h3 id="殭屍資源成本">殭屍資源成本</h3>
<p>沒有資源盤點與標籤的環境，會持續累積「沒有人記得還開著」的資源 — 測試用的機器跑完沒關、舊版服務下線但底層資源沒清、某個實驗用的資料庫一直在計費。這些殭屍資源的月費不大，但它們會無聲地長期累積。</p>
<p>量化方式：請雲端帳號管理者拉出過去三個月的帳單，找出「沒有標籤」或「標籤顯示是非正式環境」的資源，加總它們的費用。多數團隊第一次做這件事時，會發現 10-30% 的月費花在沒有人認領的資源上。這個數字本身就是論證素材。</p>
<h3 id="合規與稽核風險">合規與稽核風險</h3>
<p>當外部稽核（SOC 2、ISO 27001、金融監管、客戶的安全問卷）要求「列出所有對外暴露的服務」「提供存取權限的變更紀錄」「證明 production 環境的變更有經過審查」，手動環境的回應方式是花一到兩週人工考古。有 infra 藍圖的環境，這些問題的答案在程式碼倉庫裡，幾分鐘就能產出。</p>
<p>合規的商業代價不是抽象的 — 稽核不過可能導致客戶合約無法續簽、保險費率上調、或直接的監管罰款。把「每次稽核的準備時間」和「稽核不過的潛在損失」列成數字，比講「我們需要更好的治理」有效得多。</p>
<h2 id="風險論述一張表說明影響範圍">風險論述：一張表說明影響範圍</h2>
<p>成本論述算的是持續性的隱藏支出，風險論述算的是一次性失效的最壞情況。兩者的語言不同：成本用月費和工時講，風險用客戶影響和法律後果講。</p>
<table>
  <thead>
      <tr>
          <th>缺口</th>
          <th>最壞情況</th>
          <th>影響範圍</th>
          <th>恢復時間</th>
      </tr>
  </thead>
  <tbody>
      <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>
      <tr>
          <td>密碼寫在程式碼裡</td>
          <td>程式碼被複製或公開後密碼外洩</td>
          <td>資料外洩 + 全面換密碼</td>
          <td>數天</td>
      </tr>
  </tbody>
</table>
<p>這張表的用法是：請決策者指出「哪些情境我們現在有可能發生」，命中的每一行都是一個尚未兌現的風險。風險論述的價值在於它把抽象的技術缺口換算成具體的商業後果 — 不是「我們缺乏環境分離」，而是「一次測試操作可以直接打到正式客戶」。</p>
<p>使用這張表時要誠實分級。把每一行都講成即將發生的災難，幾次之後決策者會把所有警告當成危言聳聽。把真正的地基級風險（密鑰外洩、沒有藍圖）跟營運效率級的問題（缺標籤、缺變更紀錄）分開講，前者用「最壞情況」爭取優先級，後者用「累積成本」來排序。</p>
<h2 id="速度論述infra-是加速器">速度論述：infra 是加速器</h2>
<p>成本和風險是防守型論述（不做會怎樣），速度是進攻型論述（做了會快多少）。多數決策者對「變快」的興趣高於對「防災」的興趣，因為速度直接對應到他們在意的指標 — 交付頻率、上市時間、團隊效率。</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>沒有 infra 藍圖</th>
          <th>有 infra 藍圖</th>
          <th>加速倍數</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>開一個新環境</td>
          <td>3-5 天（逐一比對 Console 設定並手動複製）</td>
          <td>30 分鐘（套用同一份程式碼）</td>
          <td>10-50 倍</td>
      </tr>
      <tr>
          <td>新人理解環境</td>
          <td>1-2 週（口耳相傳）</td>
          <td>1-2 天（讀程式碼 + PR 歷史）</td>
          <td>5-10 倍</td>
      </tr>
      <tr>
          <td>事故排查</td>
          <td>數小時（翻 Console）</td>
          <td>分鐘（查變更紀錄 + log）</td>
          <td>10-30 倍</td>
      </tr>
      <tr>
          <td>安全稽核準備</td>
          <td>1-2 週（人工考古）</td>
          <td>幾小時（從程式碼產出報告）</td>
          <td>10-20 倍</td>
      </tr>
      <tr>
          <td>環境一致性驗證</td>
          <td>無法確認</td>
          <td>程式碼 diff 一眼可見</td>
          <td>從不可能到可能</td>
      </tr>
  </tbody>
</table>
<p>速度論述的關鍵是把「infra 投入」框架成「一次性投入換取持續性加速」，而不是「持續性的額外負擔」。前 2-4 週是投入期（建立藍圖、設定自動化），之後每一次新環境、每一次排查、每一次稽核都在收割回報。投入是固定的，回報是累積的。</p>
<h2 id="一頁簡報的邏輯">一頁簡報的邏輯</h2>
<p>把前面三條論述線收斂成四頁簡報，這是可以直接拿進會議室的骨架：</p>
<h3 id="第一頁現況盤點">第一頁：現況盤點</h3>
<p>列出具體數字 — 我們有多少個雲端資源、其中多少百分比沒有程式碼描述、多少百分比沒有標籤、有幾把超過 90 天沒輪替的密鑰。這些數字讓決策者看到「我們目前的狀態」而非聽到一個抽象的技術判斷。數字來源是資源盤點（見<a href="/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一</a>）和雲端帳單。</p>
<h3 id="第二頁風險與成本">第二頁：風險與成本</h3>
<p>從上面的風險表挑出「我們現在確實有可能發生」的 2-3 個情境，附上最壞情況的商業影響估算。加上殭屍資源的月費和稽核準備的人工成本。這一頁的任務是讓「不做」有一個數字。</p>
<h3 id="第三頁投入規劃">第三頁：投入規劃</h3>
<p>把 infra 工作拆成階段，每階段標明工程師時間和里程碑。階段拆法對應<a href="/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">成熟度階梯</a>：第一階段（2-3 週）建立藍圖與版本控制、第二階段（2-3 週）環境分離與權限收斂、第三階段（持續）自動化護欄與治理。每個階段都能獨立交付價值 — 不是一次性的大工程，是分批兌現的投資。</p>
<h3 id="第四頁回報預期">第四頁：回報預期</h3>
<p>用速度論述的表格呈現：投入完成後，新環境時間、排查時間、稽核準備時間各縮短多少。加一條「人員依賴風險」的改善 — 從「只有一個人懂」到「任何人讀程式碼都能理解」。</p>
<h2 id="常見反對意見的回應">常見反對意見的回應</h2>
<h3 id="我們還小不需要">「我們還小，不需要」</h3>
<p>地基類的設定（環境藍圖、權限管控、密鑰管理）的補救成本隨時間複利。5 個資源的環境花半天就能建好藍圖；50 個資源的環境要花兩週逐一考古、逐一對照。問題不是「現在需不需要」，而是「現在做和半年後做，成本差多少」。多數情況下，越早做越便宜 — 這跟技術規模無關，跟補救成本的增長曲線有關。</p>
<p>判斷該不該現在做的方式是看<a href="/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">成熟度階梯</a>上三個 day 1 鐵律：環境藍圖、密鑰不進程式碼、有狀態資源的刪除保護。這三項的補救成本最陡，即使「還小」也值得先立。其他的治理機制（自動化護欄、細緻的成本分攤）確實可以等規模到了再做。</p>
<h3 id="太貴了">「太貴了」</h3>
<p>infra 工具本身免費或接近免費（Terraform / OpenTofu 開源、雲端 state 儲存成本極低）。真正的成本是工程師時間 — 但這個時間要跟「不做」的隱藏成本比。如果團隊每個月花 2 天處理手動環境的事故排查、花 1 天回答稽核問題、每季花 1 週準備合規報告，加起來的時間比一次性投入 2-4 週建好基礎更貴，而且是每個月都在付。</p>
<p>另一個角度是問：團隊裡最懂環境的那個人，他每週花多少時間回答「這個怎麼設的」「那個能不能改」這類問題？這些時間乘以他的時薪，就是「沒有程式碼描述」的持續性成本。</p>
<h3 id="會拖慢開發">「會拖慢開發」</h3>
<p>短期會 — 前 2-4 週的投入期確實在做不產出功能的工作。但這跟蓋辦公室一樣：搬進去之前要先裝修，裝修期間不能辦公，但裝修完之後每天都在受益。</p>
<p>具體的加速數字見上面的速度表。比較有效的框架是：這 2-4 週的投入，換到的是之後每次新環境省 3 天、每次排查省幾小時、每次稽核省一週。投入三次之後就回本，之後都是淨賺。如果決策者對時間投入有疑慮，可以提議從最高 ROI 的項目開始（通常是環境藍圖 + 密鑰管理），先用 1 週交付一個可見的改善，再爭取後續階段。</p>
<h3 id="現在能跑就好">「現在能跑就好」</h3>
<p>這個反對意見的翻譯是「我看不到壞掉的風險」。回應的方式是問一個具體問題：「如果我們的主要服務現在掛了，我們能在多久內重建起來？」如果答案超過一小時、或者答案是「不確定」，這本身就是論證 — 決策者通常能理解「不知道能不能救回來」的商業代價。</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 的真實起點">模組負一：手動環境</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 走 PR 流程與自動化護欄</title><link>https://tarrragon.github.io/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/</guid><description>&lt;p>infra 變更要走跟 application code 一樣的流程：開分支、提 PR、跑檢查、review diff、合併、發布。這條原則把基礎設施變更從「某個人在自己終端機 apply」轉成「團隊可審查的紀錄」，是 IaC 真正兌現價值的地方，也是解開「只有我懂 infra」這個單點依賴的關鍵。基礎設施跟程式碼一樣會出錯、會需要回溯、會交接給別人，所以它需要同一套保護機制。&lt;/p>
&lt;h2 id="infra-變更走-code-流程">infra 變更走 code 流程&lt;/h2>
&lt;p>infra 變更的標準路徑是 PR → plan → review diff → 合併 → apply。這個順序的核心責任是把「執行前先看清楚要改什麼」變成強制步驟，而不是 apply 之後才從事故裡發現改錯了。每個環節各自承擔一段審查責任，少掉任一段，infra 就退回到不可審查的狀態。&lt;/p>
&lt;h3 id="plan-是整條鏈最關鍵的一環">plan 是整條鏈最關鍵的一環&lt;/h3>
&lt;p>&lt;code>terraform plan&lt;/code> 把當前 state、雲端實際資源、與目標設定三方比對，產出一份「會新增 / 修改 / 刪除哪些資源」的 diff。這份 diff 是 review 的對象：reviewer 直接看 plan 算出來的實際變更，而非讀 HCL 自行想像結果。&lt;/p>
&lt;p>plan 輸出裡最關鍵的判讀訊號是操作類型。&lt;code>+&lt;/code> 是新增，&lt;code>~&lt;/code> 是就地更新，&lt;code>-&lt;/code> 是銷毀，&lt;code>-/+&lt;/code> 是先銷毀再重建。前兩者多數情境是安全的，後兩者需要逐行細看。改一個看似無害的欄位可能觸發整個資源重建（&lt;code>-/+&lt;/code>），例如某些雲資源的 &lt;code>name&lt;/code> 或 &lt;code>identifier&lt;/code> 是 immutable 屬性，改它的唯一方式就是銷毀再建。對有狀態的服務（RDS、帶資料的 EBS volume），&lt;code>-/+&lt;/code> 代表資料遺失或停機。Review 階段抓到這個 &lt;code>-/+&lt;/code>，比 apply 到一半才發現便宜太多。&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"># plan 輸出中要特別警惕的標記
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"># forces replacement — 某個 immutable 屬性被修改，將觸發銷毀重建
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"># must be replaced — 跟上面同義，Terraform 新版的表達方式
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"># will be destroyed — 資源將被刪除
&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_db_instance.primary must be replaced
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> -/+ resource &amp;#34;aws_db_instance&amp;#34; &amp;#34;primary&amp;#34; {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> ~ identifier = &amp;#34;app-prod&amp;#34; -&amp;gt; &amp;#34;app-production&amp;#34; # forces replacement
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> ...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> }&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="把-plan-結果貼回-pr">把 plan 結果貼回 PR&lt;/h3>
&lt;p>把 plan 結果貼回 PR 是讓 review 真正生效的做法。流程上，PR 觸發 CI 跑 plan，plan 輸出回貼成 PR comment，reviewer 連同程式碼 diff 一起看；approve 後才允許合併，合併才觸發 apply。&lt;/p>
&lt;p>這裡有個取捨：plan 與 apply 之間若隔了很久，雲端實際狀態可能已經漂移（有人手動改了、或別的 PR 先 apply 了），導致 apply 時的 plan 跟 review 時看到的不一致。應對方式分保守與務實兩種。保守做法是 apply 前重跑一次 plan 並比對結果 — 一致才繼續，不一致就中斷。務實做法是在合併觸發 apply 時自動跑 plan 並只在無 destroy / replace 時自動執行，有 destroy / replace 就停下來要人確認。多數團隊從務實做法開始，到遇過一次 plan-apply 不一致的事故後才升級到保守做法。&lt;/p>
&lt;h3 id="apply-失敗的回退邊界">apply 失敗的回退邊界&lt;/h3>
&lt;p>infra apply 不像程式碼部署可以直接 rollback 到上一版 image — 中途失敗時部分資源已經建立、state 可能處於半完成狀態。例如 apply 建了一個新 subnet 但在建 route table 時 timeout，此時 subnet 存在於雲端和 state 裡，route table 只在雲端不在 state 裡（或反過來），下一次 plan 的計算基礎就不精準。&lt;/p></description><content:encoded><![CDATA[<p>infra 變更要走跟 application code 一樣的流程：開分支、提 PR、跑檢查、review diff、合併、發布。這條原則把基礎設施變更從「某個人在自己終端機 apply」轉成「團隊可審查的紀錄」，是 IaC 真正兌現價值的地方，也是解開「只有我懂 infra」這個單點依賴的關鍵。基礎設施跟程式碼一樣會出錯、會需要回溯、會交接給別人，所以它需要同一套保護機制。</p>
<h2 id="infra-變更走-code-流程">infra 變更走 code 流程</h2>
<p>infra 變更的標準路徑是 PR → plan → review diff → 合併 → apply。這個順序的核心責任是把「執行前先看清楚要改什麼」變成強制步驟，而不是 apply 之後才從事故裡發現改錯了。每個環節各自承擔一段審查責任，少掉任一段，infra 就退回到不可審查的狀態。</p>
<h3 id="plan-是整條鏈最關鍵的一環">plan 是整條鏈最關鍵的一環</h3>
<p><code>terraform plan</code> 把當前 state、雲端實際資源、與目標設定三方比對，產出一份「會新增 / 修改 / 刪除哪些資源」的 diff。這份 diff 是 review 的對象：reviewer 直接看 plan 算出來的實際變更，而非讀 HCL 自行想像結果。</p>
<p>plan 輸出裡最關鍵的判讀訊號是操作類型。<code>+</code> 是新增，<code>~</code> 是就地更新，<code>-</code> 是銷毀，<code>-/+</code> 是先銷毀再重建。前兩者多數情境是安全的，後兩者需要逐行細看。改一個看似無害的欄位可能觸發整個資源重建（<code>-/+</code>），例如某些雲資源的 <code>name</code> 或 <code>identifier</code> 是 immutable 屬性，改它的唯一方式就是銷毀再建。對有狀態的服務（RDS、帶資料的 EBS volume），<code>-/+</code> 代表資料遺失或停機。Review 階段抓到這個 <code>-/+</code>，比 apply 到一半才發現便宜太多。</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"># plan 輸出中要特別警惕的標記
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"># forces replacement  — 某個 immutable 屬性被修改，將觸發銷毀重建
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"># must be replaced    — 跟上面同義，Terraform 新版的表達方式
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"># will be destroyed   — 資源將被刪除
</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_db_instance.primary must be replaced
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  -/+ resource &#34;aws_db_instance&#34; &#34;primary&#34; {
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">      ~ identifier = &#34;app-prod&#34; -&gt; &#34;app-production&#34;  # forces replacement
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        ...
</span></span><span class="line"><span class="ln">10</span><span class="cl">    }</span></span></code></pre></div><h3 id="把-plan-結果貼回-pr">把 plan 結果貼回 PR</h3>
<p>把 plan 結果貼回 PR 是讓 review 真正生效的做法。流程上，PR 觸發 CI 跑 plan，plan 輸出回貼成 PR comment，reviewer 連同程式碼 diff 一起看；approve 後才允許合併，合併才觸發 apply。</p>
<p>這裡有個取捨：plan 與 apply 之間若隔了很久，雲端實際狀態可能已經漂移（有人手動改了、或別的 PR 先 apply 了），導致 apply 時的 plan 跟 review 時看到的不一致。應對方式分保守與務實兩種。保守做法是 apply 前重跑一次 plan 並比對結果 — 一致才繼續，不一致就中斷。務實做法是在合併觸發 apply 時自動跑 plan 並只在無 destroy / replace 時自動執行，有 destroy / replace 就停下來要人確認。多數團隊從務實做法開始，到遇過一次 plan-apply 不一致的事故後才升級到保守做法。</p>
<h3 id="apply-失敗的回退邊界">apply 失敗的回退邊界</h3>
<p>infra apply 不像程式碼部署可以直接 rollback 到上一版 image — 中途失敗時部分資源已經建立、state 可能處於半完成狀態。例如 apply 建了一個新 subnet 但在建 route table 時 timeout，此時 subnet 存在於雲端和 state 裡，route table 只在雲端不在 state 裡（或反過來），下一次 plan 的計算基礎就不精準。</p>
<p>應對的紀律是：apply 失敗後，先跑一次 <code>terraform plan</code> 確認 state 與現實的差距，再決定是修正 code 重新 apply 還是手動清理殘留資源後 <code>terraform state rm</code>。在清理之前不要再改 code、不要連發第二次 apply — 第二次 apply 在不確定的 state 上跑，可能把問題擴大。</p>
<p>PR 流程的價值在這裡不只是事前審查，也是事後可追溯：每次變更都對應一個 commit 與一個 PR，要回溯時知道是哪次改的、為什麼改、誰 review 的。</p>
<h2 id="fmt-與-validate最便宜的第一道檢查">fmt 與 validate：最便宜的第一道檢查</h2>
<p><code>fmt</code> 與 <code>validate</code> 是進到任何安全掃描之前的基礎檢查，責任是擋掉格式不一致與語法 / 型別錯誤這類不需要動腦判斷的問題。它們跑得快（通常不到五秒）、沒有誤判空間，適合放在 CI 最前面當作快速 fail 的關卡。</p>
<p><code>terraform fmt -check</code> 驗證程式碼是否符合標準排版。它本身不影響基礎設施行為，價值在於消除 diff 噪音：當每個人的編輯器縮排習慣不同，PR diff 會混入大量純排版變動，把真正的邏輯變更淹沒，reviewer 更容易看漏。統一格式後，diff 裡剩下的就是語意變更。在本地開發階段配合 editor plugin 或 pre-commit hook 在存檔時自動 fmt，讓 CI 的 fmt check 幾乎不會再 fail — 它存在的意義是攔住那些沒裝 plugin 的人。</p>
<p><code>validate</code> 則檢查設定在語法與內部一致性上是否成立 — reference 到不存在的變數、型別不匹配、必填參數缺漏、module 呼叫的 source 解析不了，這些在 validate 階段就會報錯，不必等到 plan 連線雲端才發現。validate 需要先跑 <code>terraform init</code>，但可以用 <code>-backend=false</code> 跳過連線 state backend，這樣在 CI 裡不需要雲端憑證就能跑完。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># .github/workflows/terraform.yml — plan 前的基礎檢查</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">validate</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">hashicorp/setup-terraform@v3</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform fmt -check -recursive</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform init -backend=false</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform validate</span></span></span></code></pre></div><p>判讀上，fmt 與 validate 失敗代表的是「這份 code 還沒準備好被認真 review」，屬於作者自己該先修掉的問題，不該佔用 reviewer 注意力。把它們設成 CI 必過的 gate，作者在本地就會先跑、先修，PR 送出時已經是乾淨的。</p>
<h2 id="tflint--checkov--tfsec抓壞寫法與安全漏洞">tflint / checkov / tfsec：抓壞寫法與安全漏洞</h2>
<p>fmt 與 validate 確認 code「語法正確」，但語法正確的設定仍然可能是危險的設定。tflint、checkov、tfsec 這類靜態掃描工具承擔的是「語意正確」這層：在不實際建立資源的前提下，從 HCL 裡比對已知的壞寫法與安全反模式，把問題擋在 plan 之前。它們補的是 reviewer 肉眼容易漏掉的盲區 — 人會看漏一個 <code>0.0.0.0/0</code>，規則不會。</p>
<h3 id="三者的側重">三者的側重</h3>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>側重領域</th>
          <th>典型命中</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>tflint</td>
          <td>provider 層正確性與慣例</td>
          <td>棄用參數、region 不存在的 instance type、命名違規</td>
      </tr>
      <tr>
          <td>checkov</td>
          <td>安全與合規（CIS benchmark 導向）</td>
          <td>S3 公開、未加密、缺少 log、IAM 過寬</td>
      </tr>
      <tr>
          <td>tfsec</td>
          <td>安全反模式（HCL 結構導向）</td>
          <td>敏感埠全開、未加密、hardcode secret</td>
      </tr>
  </tbody>
</table>
<p>checkov 與 tfsec 的覆蓋範圍有重疊（都會掃 S3 公開與 SG 全開），差別在規則來源與報告格式。checkov 的規則對標 CIS benchmark 和多雲合規框架（AWS、Azure、GCP、Kubernetes），tfsec 更專注在 Terraform HCL 結構。兩者跑在一起時，重複的命中可以用其中一個的 skip 標記豁免。</p>
<h3 id="兩個最常攔下的反模式">兩個最常攔下的反模式</h3>
<p><strong>S3 bucket 對外公開</strong>。一個漏設 <code>block_public_access</code> 或 ACL 寫成 <code>public-read</code> 的 bucket，會讓裡面的物件對整個網際網路可讀。這類設定在 HCL 裡只是一兩行，肉眼 review 時很容易因為「看起來像樣板」而放過，但後果是資料外洩。checkov 規則 <code>CKV_AWS_19</code>（S3 bucket 未啟用 server-side encryption）和 <code>CKV_AWS_53</code>（block public access 未全開）會標記這類漏洞：</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"># checkov 會攔下的寫法 — 缺少 block_public_access
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">resource</span> <span class="s2">&#34;aws_s3_bucket&#34; &#34;data&#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-customer-data&#34;</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 class="c1">
</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 class="k">resource</span> <span class="s2">&#34;aws_s3_bucket_public_access_block&#34; &#34;data&#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">data</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">  block_public_acls</span>       <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">10</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">11</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">12</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">13</span><span class="cl">}</span></span></code></pre></div><p><strong>Security group 對全世界開放</strong>。一條 ingress 寫成 <code>cidr_blocks = [&quot;0.0.0.0/0&quot;]</code> 加上 port 22 或 3306，等於把 SSH 或資料庫埠暴露給全網掃描器。tfsec 與 checkov 都會標記這種「敏感埠 + 全開 CIDR」的組合。這條規則跟<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 收斂原則是同一件事的兩端 — 模組三教怎麼把規則寫對，本章用靜態掃描確保寫錯時擋得下來。</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"># 三道掃描串在一起，任一 fail 就中斷</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">tflint --recursive
</span></span><span class="line"><span class="ln">3</span><span class="cl">checkov -d . --quiet --compact
</span></span><span class="line"><span class="ln">4</span><span class="cl">tfsec . --soft-fail<span class="o">=</span>false</span></span></code></pre></div><h3 id="命中是候選不是判決">命中是候選不是判決</h3>
<p>判讀這些工具的命中時，要區分「真漏洞」與「情境合理的例外」。並非每個 <code>0.0.0.0/0</code> 都是錯 — 一個對外的 HTTPS load balancer 在 port 443 開全網是設計本意。所以掃描的命中是候選不是判決。</p>
<p>多數工具支援用行內註解標記豁免。checkov 用 <code>#checkov:skip=CKV_AWS_260:ALB 443 對外是設計本意</code>，tfsec 用 <code>#tfsec:ignore:aws-elb-alb-not-public</code>。豁免的紀律是：每個 skip 都要寫理由、要在 PR 裡可見。沒有理由的 skip 跟關掉整條規則沒有差別 — review 時看到無理由的 skip 應該當成跟看到裸 <code>0.0.0.0/0</code> 一樣的警報。</p>
<p>把例外顯式化、留下為什麼豁免的紀錄，比關掉整條規則安全。隨時間累積的 skip 也要定期盤點：某個當初合理的例外，在架構演進後可能已經不再合理。</p>
<h2 id="atlantis-與-github-actions自動化-plan-與-apply">Atlantis 與 GitHub Actions：自動化 plan 與 apply</h2>
<p>把上述流程自動化，需要一個能監聽 PR 事件、在對的時機跑 plan 與 apply 的執行層。兩種常見做法是直接用 CI 平台（如 GitHub Actions）寫 workflow，或用 Atlantis 這類專為 Terraform PR 流程設計的工具。</p>
<h3 id="atlantis">Atlantis</h3>
<p>Atlantis 是一個常駐服務，掛在 git 平台的 webhook 上。PR 開啟時它自動跑 <code>plan</code> 並把結果貼回 PR comment，reviewer approve 後在 PR 留言 <code>atlantis apply</code>，它才執行 apply 並回報結果。它的價值在於把「誰能 apply、apply 前要不要 approve、plan 結果在哪看」這些規則收斂成一致的、可設定的流程。</p>
<p>Atlantis 內建的 state lock 語意在多 PR 並行時特別有用：當兩個 PR 都改到同一個 Terraform project，第二個 PR 的 plan 會被 lock 擋住，直到第一個 apply 完成或 PR 關閉。這避免了兩個 PR 各自拿到的 plan 基於不同的 state 快照、apply 時互相覆蓋的問題。用 GitHub Actions 要自己實作這個 lock 邏輯（通常靠 Terraform 自己的 state lock + workflow concurrency group），複雜度高得多。</p>
<p>Atlantis 的代價是它本身是一個要部署、要升級、要保護的常駐服務 — 它持有對雲端的寫入權限，所以它的部署環境必須嚴格控制存取。</p>
<h3 id="github-actions">GitHub Actions</h3>
<p>GitHub Actions workflow 的優點是不必額外維運服務、跟既有 CI 共用同一套 runner。缺點是 apply 的 gating 邏輯要自己用 workflow 條件拼出來。一個完整的 workflow 通常分成兩個 job：PR 觸發 plan job（跑 fmt / validate / scan / plan、把結果貼回 PR），合併到 main 才觸發 apply job。</p>
<p>無論哪種執行層，自動化的 apply 都需要對雲端的寫入權限，而這個權限怎麼來是整條管線的安全根基。這裡正是<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>鋪設的 OIDC 兌現的地方 — 管線不該存放長期的 access key，而是在 runner 執行時用 OIDC 向雲端換取短期 token。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># 合併到主幹後，用 OIDC 換短期憑證再 apply（呼應模組二）</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">apply</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">github.ref == &#39;refs/heads/main&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write  </span><span class="w"> </span><span class="c"># 允許 runner 取得 OIDC token</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">aws-actions/configure-aws-credentials@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">          </span><span class="nt">role-to-assume</span><span class="p">:</span><span class="w"> </span><span class="l">arn:aws:iam::123456789012:role/infra-apply</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">          </span><span class="nt">aws-region</span><span class="p">:</span><span class="w"> </span><span class="l">ap-northeast-1</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">hashicorp/setup-terraform@v3</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform init</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform apply -auto-approve</span></span></span></code></pre></div><h3 id="選型判準">選型判準</h3>
<table>
  <thead>
      <tr>
          <th>考量</th>
          <th>GitHub Actions</th>
          <th>Atlantis</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>維運成本</td>
          <td>無額外服務</td>
          <td>需部署 + 升級常駐服務</td>
      </tr>
      <tr>
          <td>state lock</td>
          <td>靠 Terraform 自身 + concurrency</td>
          <td>內建 project lock、跨 PR 互斥</td>
      </tr>
      <tr>
          <td>apply gating</td>
          <td>自己用 environment rule 拼</td>
          <td>內建 approve + <code>atlantis apply</code> 語意</td>
      </tr>
      <tr>
          <td>跨 repo 一致</td>
          <td>每 repo 各自寫 workflow</td>
          <td>一套 server config 管所有 repo</td>
      </tr>
      <tr>
          <td>適合規模</td>
          <td>少量 repo、簡單流程</td>
          <td>多 repo、需統一 apply 治理</td>
      </tr>
  </tbody>
</table>
<p>判讀自動 apply 的邊界：對會觸發資源重建或刪除的高風險 plan，多數團隊會保留人工 apply 的關卡（Atlantis 的手動 <code>atlantis apply</code>、或 workflow 加 environment protection rule 要人按確認），不讓這類變更在合併瞬間無人看管地執行。自動化的目的是消除重複勞動與人為遺漏，不是把判斷也一起省掉。</p>
<h2 id="知識留在-code而不是留在個人腦中">知識留在 code，而不是留在個人腦中</h2>
<p>走完整套 PR 流程後，infra 的真正收穫是知識從個人的記憶移到了 repo 裡。每一次「為什麼這個 security group 開這個埠」「為什麼這台機器選這個 instance type」的決策，都以 code + PR 描述 + review 討論的形式留下，新人讀 repo 就能還原當初的判斷，不必去問那個「只有他懂 infra」的人。基礎設施可被閱讀，等於它可被交接。PR 流程上線後，管理層可以從 repo 的 PR merge 歷史與 plan comment 確認所有 infra 變更都經過提案與審查——這本身就是稽核要求的變更紀錄證據，不需要額外產出。</p>
<h3 id="git-revert-的能力與邊界">git revert 的能力與邊界</h3>
<p>可 revert 是 PR 流程最直接的兌現。當某次變更引發問題，回退手段是 <code>git revert</code> 那個 commit 再走一次 PR 流程，讓基礎設施回到變更前的設定 — 跟回退一段壞掉的程式碼是同一個動作。對照手動操作的舊狀態：回退靠的是當事人記得自己改了什麼、手動在 Console 改回去，記錯或人不在就無從回退。把變更歷史留在 git，回退就從「依賴某人的記憶」變成「依賴版本紀錄」。</p>
<p>這份 revert 能力的邊界要講清楚。revert code 救得回的是「設定」，救不回已經被銷毀的狀態與資料：</p>
<ul>
<li>revert 掉一個刪除 RDS 的 commit，只是讓設定回到「該資源應該存在」。apply 時 Terraform 會試圖建一個新的空資料庫 — 但被刪掉的資料庫裡的資料不會跟著回來。</li>
<li>rename 或 replace 類的變更 revert 後，可能再觸發一次資源重建 — 因為 <code>identifier</code> 又改回去了，而 identifier 是 immutable 屬性。</li>
<li>apply 到一半失敗的 state 不能直接 revert code 修復，得先處理 state 與雲端現實的不一致。</li>
</ul>
<p>stateful 變更的真正回退仍然靠備份與快照，這正是<a href="/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a> stateful 處理與<a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a> secret / state 保護要顧的事。把 git revert 當「設定層回退」就誠實，把它當「資料層回退」就會在事故裡踩空。</p>
<h3 id="知識共享的判讀訊號">知識共享的判讀訊號</h3>
<p>判讀一個團隊是否確實把知識留在 code 的訊號：當主要負責 infra 的人請假，其他人能不能只靠讀 repo 就理解現狀並安全地改一個小設定。如果答案是「得等他回來」，那不論工具鏈多完整，知識還在個人腦中，PR 流程只是形式。這個訊號比任何工具設定都更能反映 infra 的成熟度。</p>
<p>讓知識真正從個人腦中搬進 repo 的方式，除了 PR 流程本身，還需要組織層的配合 — 刻意的 review 輪替、on-call 輪值、配對操作。這條路線在<a href="/blog/infra/09-driving-adoption/" data-link-title="模組九：怎麼把 infra 推動起來" data-link-desc="技術正確不等於推得動 — 信任赤字、期望值對齊、知識共享，infra 落地的組織課題">模組九：怎麼把 infra 推動起來</a>展開到組織層。本章解決的是技術機制 — code 留得住知識；模組九解決的是怎麼讓團隊實際願意走這套流程、把知識交出來。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/ci/" data-link-title="CI/CD 教學" data-link-desc="整理 CI/CD 的驗證、建置、發布 gate 與不同部署場域的流程差異，讓每次變更都能被穩定驗證與交付">CI/CD 教學</a>：infra 管線用的就是這套驗證 / 發布 gate，plan / apply 對應 build / deploy 階段</li>
<li>→ <a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>：管線用 OIDC 取得 apply 權限，本章是該章 OIDC 設計的回報兌現處</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 收斂原則，本章用 tfsec / checkov 在 CI 攔下寫錯的全開規則</li>
<li>→ <a href="/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a>：stateful 資源的保護策略，git revert 救不回資料層</li>
<li>→ <a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>：secret / state 保護</li>
<li>→ <a href="/blog/infra/09-driving-adoption/" data-link-title="模組九：怎麼把 infra 推動起來" data-link-desc="技術正確不等於推得動 — 信任赤字、期望值對齊、知識共享，infra 落地的組織課題">模組九：怎麼把 infra 推動起來</a>：本章把知識留在 code 的技術機制，在該章展開成組織層的採用與知識共享</li>
<li>→ <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 模組七：資安與資料保護</a>：S3 公開、敏感埠全開這類掃描攔截的反模式，對應的資料保護原則</li>
<li>→ <a href="/blog/infra/02-identity-credentials/team-access-management/" data-link-title="團隊權限分級與存取管理" data-link-desc="用 admin / operator / viewer 三級劃分團隊成員的雲端操作權限，設計臨時提權流程、定期 access review 節奏，以及 contractor 與外部 vendor 的存取邊界">團隊權限分級</a>：權限變更走 PR 流程，讓 policy 調整有審查紀錄</li>
<li>→ <a href="/blog/infra/08-governance-habits/handover-design/" data-link-title="職務交接與存取撤銷設計" data-link-desc="人員異動時的存取撤銷順序、credential rotation、最小交接清單，以及讓交接成本結構性降低的 infra 設計原則">職務交接設計</a>：PR 歷史是交接時的知識載體</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/terraform-ci-pipeline-setup/" data-link-title="Terraform CI Pipeline 設定指南" data-link-desc="用 GitHub Actions 建立完整的 Terraform CI pipeline：fmt → validate → tflint → plan → PR comment → apply，含 OIDC credential 與環境保護規則">Terraform CI Pipeline 設定指南</a>：GitHub Actions 完整 workflow</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/checkov-tfsec-rule-customization/" data-link-title="checkov 與 tfsec 規則配置" data-link-desc="靜態掃描工具的規則選擇策略、自訂規則、豁免管理、false positive 處理與 CI 整合，讓掃描從噪音來源變成可信的品質關卡">checkov 與 tfsec 規則配置</a>：規則選擇、豁免管理、CI 整合</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>Tagging 規範與 Secrets 不進 code</title><link>https://tarrragon.github.io/blog/infra/08-governance-habits/tagging-secrets/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/08-governance-habits/tagging-secrets/</guid><description>&lt;p>每一個治理習慣單獨看都很小：在資源上多打三個 tag、把一段連線字串挪去別的地方。但少了這些習慣，半年後的代價是另一個量級 — 翻著一頁兩百筆沒有歸屬的資源猜哪個能砍、為了輪替一把外洩的密鑰回頭 grep 整個 repo。Tagging 與 secret 管理是治理習慣裡補救成本最陡的兩項：tag 一旦缺席就得回頭考古幾百個資源，密鑰一旦進了 git 歷史就無法清除。它們共同的特性是 day-1 建立的成本接近零，事後補的代價隨資源數量與時間複利。&lt;/p>
&lt;h2 id="tagging-規範查帳與清資源的依據">Tagging 規範：查帳與清資源的依據&lt;/h2>
&lt;p>Tag 是貼在每個資源上的結構化標籤，承擔「讓資源能被機器查詢與分群」的責任。沒有 tag 的資源在 console 裡只剩一個隨機後綴的名字，人能勉強認得幾個，但一旦數量過百，任何「列出所有 staging 的資源」「算出 team-a 這個月花多少」的問題都無法用查詢回答，只能逐筆翻。Tag 把這些問題從人工考古變成一行 filter。&lt;/p>
&lt;h3 id="最小-tag-集合">最小 tag 集合&lt;/h3>
&lt;p>值得從第一天就強制的最小 tag 集合是三個維度，各自回答一個治理問題：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Tag&lt;/th>
 &lt;th>回答的問題&lt;/th>
 &lt;th>典型值&lt;/th>
 &lt;th>缺了會怎樣&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>env&lt;/code>&lt;/td>
 &lt;td>這是哪個環境&lt;/td>
 &lt;td>&lt;code>prod&lt;/code> / &lt;code>staging&lt;/code> / &lt;code>dev&lt;/code>&lt;/td>
 &lt;td>清資源時不敢動、怕誤刪生產&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>owner&lt;/code>&lt;/td>
 &lt;td>出事找誰&lt;/td>
 &lt;td>&lt;code>team-payments&lt;/code> / &lt;code>platform&lt;/code>&lt;/td>
 &lt;td>資源孤兒化、沒人認領也沒人敢回收&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>cost-center&lt;/code>&lt;/td>
 &lt;td>這筆錢算誰的&lt;/td>
 &lt;td>&lt;code>cc-1024&lt;/code> / &lt;code>growth&lt;/code>&lt;/td>
 &lt;td>帳單無法拆分、成本變成一筆沒人負責的公共支出&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>env&lt;/code> 是清資源時的安全護欄。回收動作最大的恐懼是誤刪生產資源。當每個資源都標了 &lt;code>env&lt;/code>，「列出所有 &lt;code>env=dev&lt;/code> 且 30 天無流量的資源」就是一條可以放心執行的清理查詢，而 &lt;code>env=prod&lt;/code> 的資源自動被排除在批次刪除之外。沒有這個 tag，任何自動化清理都因為怕誤傷而不敢落地，最後退回人工逐筆確認，於是根本沒人去清。&lt;/p>
&lt;p>&lt;code>owner&lt;/code> 解決資源孤兒化。服務出狀況、或是看到一個用途不明的資源時，第一個問題是「這誰的」。標了 owner，告警可以自動路由、清理前可以自動通知認領；沒標，這個資源就停在「沒人敢動、因為不知道砍了會不會弄壞什麼」的狀態，永久占用配額與費用。團隊命名比個人名好 — 人會離職，團隊邊界相對穩定。&lt;/p>
&lt;p>&lt;code>cost-center&lt;/code> 是成本歸屬的地基，把帳單從「一筆公共支出」拆成「每個團隊各自負責的花費」。這個維度的後續應用在&lt;a href="https://tarrragon.github.io/blog/infra/08-governance-habits/cost-visibility-rhythm/" data-link-title="成本可見性與最小可行治理節奏" data-link-desc="用 tag 驅動的成本分攤讓帳單有人負責，以及判斷什麼治理該 day-1 就立、什麼等規模逼出來再加">成本可見性與最小可行治理節奏&lt;/a>展開。&lt;/p>
&lt;h3 id="附加-tag-的合理時機">附加 tag 的合理時機&lt;/h3>
&lt;p>三個必填之外，隨著團隊規模增長，幾個常見的附加維度會自然浮現：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Tag&lt;/th>
 &lt;th>用途&lt;/th>
 &lt;th>加入時機&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>managed-by&lt;/code>&lt;/td>
 &lt;td>區分 IaC 管理 vs 手動建立&lt;/td>
 &lt;td>導入 IaC 第一天就加&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>project&lt;/code>&lt;/td>
 &lt;td>區分同一團隊下不同產品線&lt;/td>
 &lt;td>團隊負責超過一個產品時&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>ttl&lt;/code>&lt;/td>
 &lt;td>資源預定存活時間（如 &lt;code>7d&lt;/code>）&lt;/td>
 &lt;td>開始有大量開發 / 測試用臨時資源時&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>compliance&lt;/code>&lt;/td>
 &lt;td>標記受法規約束的資源（如 &lt;code>pci&lt;/code> / &lt;code>hipaa&lt;/code>）&lt;/td>
 &lt;td>開始有合規稽核需求時&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>managed-by = terraform&lt;/code> 搭配 &lt;code>env&lt;/code>，可以快速找出「不在 IaC 管理下的生產資源」 — 這些就是 Console 唯讀紀律（&lt;a href="https://tarrragon.github.io/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一&lt;/a>）鬆動的痕跡。附加 tag 不需要一次規劃完，但一旦加入就要跟必填 tag 一起走自動護欄。&lt;/p>
&lt;h3 id="用-iac-自動標記">用 IaC 自動標記&lt;/h3>
&lt;p>Tag 必須在資源建立時就由 IaC 寫進去，而不是事後補。Terraform 的 &lt;code>default_tags&lt;/code> 讓一個 provider 區塊內的所有資源自動繼承一組 tag，避免逐個資源手動標、也避免漏標：&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">provider&lt;/span> &lt;span class="s2">&amp;#34;aws&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"> 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"> 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="k">default_tags&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"> tags&lt;/span> &lt;span class="o">=&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"> 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"> 7&lt;/span>&lt;span class="cl">&lt;span class="n"> owner&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">team&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"> cost-center&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">cost_center&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"> managed-by&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;terraform&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>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>用 &lt;code>var&lt;/code> 取代寫死的值，讓同一套 provider 設定跨環境複用 — 每個環境的 &lt;code>terraform.tfvars&lt;/code> 填入自己的值。這和&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>每一個治理習慣單獨看都很小：在資源上多打三個 tag、把一段連線字串挪去別的地方。但少了這些習慣，半年後的代價是另一個量級 — 翻著一頁兩百筆沒有歸屬的資源猜哪個能砍、為了輪替一把外洩的密鑰回頭 grep 整個 repo。Tagging 與 secret 管理是治理習慣裡補救成本最陡的兩項：tag 一旦缺席就得回頭考古幾百個資源，密鑰一旦進了 git 歷史就無法清除。它們共同的特性是 day-1 建立的成本接近零，事後補的代價隨資源數量與時間複利。</p>
<h2 id="tagging-規範查帳與清資源的依據">Tagging 規範：查帳與清資源的依據</h2>
<p>Tag 是貼在每個資源上的結構化標籤，承擔「讓資源能被機器查詢與分群」的責任。沒有 tag 的資源在 console 裡只剩一個隨機後綴的名字，人能勉強認得幾個，但一旦數量過百，任何「列出所有 staging 的資源」「算出 team-a 這個月花多少」的問題都無法用查詢回答，只能逐筆翻。Tag 把這些問題從人工考古變成一行 filter。</p>
<h3 id="最小-tag-集合">最小 tag 集合</h3>
<p>值得從第一天就強制的最小 tag 集合是三個維度，各自回答一個治理問題：</p>
<table>
  <thead>
      <tr>
          <th>Tag</th>
          <th>回答的問題</th>
          <th>典型值</th>
          <th>缺了會怎樣</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>env</code></td>
          <td>這是哪個環境</td>
          <td><code>prod</code> / <code>staging</code> / <code>dev</code></td>
          <td>清資源時不敢動、怕誤刪生產</td>
      </tr>
      <tr>
          <td><code>owner</code></td>
          <td>出事找誰</td>
          <td><code>team-payments</code> / <code>platform</code></td>
          <td>資源孤兒化、沒人認領也沒人敢回收</td>
      </tr>
      <tr>
          <td><code>cost-center</code></td>
          <td>這筆錢算誰的</td>
          <td><code>cc-1024</code> / <code>growth</code></td>
          <td>帳單無法拆分、成本變成一筆沒人負責的公共支出</td>
      </tr>
  </tbody>
</table>
<p><code>env</code> 是清資源時的安全護欄。回收動作最大的恐懼是誤刪生產資源。當每個資源都標了 <code>env</code>，「列出所有 <code>env=dev</code> 且 30 天無流量的資源」就是一條可以放心執行的清理查詢，而 <code>env=prod</code> 的資源自動被排除在批次刪除之外。沒有這個 tag，任何自動化清理都因為怕誤傷而不敢落地，最後退回人工逐筆確認，於是根本沒人去清。</p>
<p><code>owner</code> 解決資源孤兒化。服務出狀況、或是看到一個用途不明的資源時，第一個問題是「這誰的」。標了 owner，告警可以自動路由、清理前可以自動通知認領；沒標，這個資源就停在「沒人敢動、因為不知道砍了會不會弄壞什麼」的狀態，永久占用配額與費用。團隊命名比個人名好 — 人會離職，團隊邊界相對穩定。</p>
<p><code>cost-center</code> 是成本歸屬的地基，把帳單從「一筆公共支出」拆成「每個團隊各自負責的花費」。這個維度的後續應用在<a href="/blog/infra/08-governance-habits/cost-visibility-rhythm/" data-link-title="成本可見性與最小可行治理節奏" data-link-desc="用 tag 驅動的成本分攤讓帳單有人負責，以及判斷什麼治理該 day-1 就立、什麼等規模逼出來再加">成本可見性與最小可行治理節奏</a>展開。</p>
<h3 id="附加-tag-的合理時機">附加 tag 的合理時機</h3>
<p>三個必填之外，隨著團隊規模增長，幾個常見的附加維度會自然浮現：</p>
<table>
  <thead>
      <tr>
          <th>Tag</th>
          <th>用途</th>
          <th>加入時機</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>managed-by</code></td>
          <td>區分 IaC 管理 vs 手動建立</td>
          <td>導入 IaC 第一天就加</td>
      </tr>
      <tr>
          <td><code>project</code></td>
          <td>區分同一團隊下不同產品線</td>
          <td>團隊負責超過一個產品時</td>
      </tr>
      <tr>
          <td><code>ttl</code></td>
          <td>資源預定存活時間（如 <code>7d</code>）</td>
          <td>開始有大量開發 / 測試用臨時資源時</td>
      </tr>
      <tr>
          <td><code>compliance</code></td>
          <td>標記受法規約束的資源（如 <code>pci</code> / <code>hipaa</code>）</td>
          <td>開始有合規稽核需求時</td>
      </tr>
  </tbody>
</table>
<p><code>managed-by = terraform</code> 搭配 <code>env</code>，可以快速找出「不在 IaC 管理下的生產資源」 — 這些就是 Console 唯讀紀律（<a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一</a>）鬆動的痕跡。附加 tag 不需要一次規劃完，但一旦加入就要跟必填 tag 一起走自動護欄。</p>
<h3 id="用-iac-自動標記">用 IaC 自動標記</h3>
<p>Tag 必須在資源建立時就由 IaC 寫進去，而不是事後補。Terraform 的 <code>default_tags</code> 讓一個 provider 區塊內的所有資源自動繼承一組 tag，避免逐個資源手動標、也避免漏標：</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">provider</span> <span class="s2">&#34;aws&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</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"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="k">default_tags</span> {
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">    tags</span> <span class="o">=</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="k">var</span><span class="p">.</span><span class="k">team</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">      cost-center</span> <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">cost_center</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">      managed-by</span>  <span class="o">=</span> <span class="s2">&#34;terraform&#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></span></code></pre></div><p>用 <code>var</code> 取代寫死的值，讓同一套 provider 設定跨環境複用 — 每個環境的 <code>terraform.tfvars</code> 填入自己的值。這和<a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>的參數化設計一致。</p>
<p>個別資源若需要額外 tag（例如 <code>ttl</code>），在資源自身的 <code>tags</code> block 裡寫，它會跟 <code>default_tags</code> 合併，不需要重複寫環境層的三個必填。兩者有相同 key 時資源層優先，所以某個特殊資源要覆蓋 owner 也行。</p>
<p>事後補 tag 是個會無限拖延的工作，因為它不影響任何功能、沒有 deadline、永遠排在 backlog 最後。</p>
<h3 id="tag-合規護欄">Tag 合規護欄</h3>
<p>判讀訊號很簡單：定期跑一條「列出缺少必填 tag 的資源」的查詢，數字若持續成長，代表有人繞過 IaC 手動開資源 — 這既是 tag 問題，也是模組一「Console 唯讀」紀律鬆動的徵兆。</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"># 列出沒有 env tag 的 EC2 instance</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws resourcegroupstaggingapi get-resources <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --resource-type-filters ec2:instance <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --tag-filters <span class="nv">Key</span><span class="o">=</span>env,Values<span class="o">=</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;ResourceTagMappingList[].ResourceARN&#39;</span></span></span></code></pre></div><p>手動查詢只是起點。更可靠的做法是用策略引擎在建立期或 PR 階段就擋住不合規的資源：</p>
<ul>
<li><strong>AWS Tag Policy</strong>（Organizations 層級）：定義必填 tag 與允許值的枚舉，不符合就阻止建立。適合整個組織統一推行。</li>
<li><strong>OPA / Sentinel</strong>（CI / PR 層級）：在 <code>terraform plan</code> 之後、<code>apply</code> 之前檢查 plan 輸出，缺 tag 就讓 CI fail。適合跟<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>整合。</li>
<li><strong>checkov / tfsec 自訂規則</strong>：靜態掃描 HCL，在 code push 時就擋。成本最低但只擋得住 IaC 管理的資源。</li>
</ul>
<p>三層護欄互補：靜態掃描擋寫 code 時的遺漏、plan 檢查擋執行前的偏差、tag policy 擋繞過 IaC 的手動操作。早期只做一層也有價值，三層都做時覆蓋最完整。定期跑 tag 覆蓋率報告（缺少必填 tag 的資源數 / 總資源數）可以作為治理進度的量化指標。覆蓋率從 40% 到 90% 的趨勢比單次數字更有意義，適合放進月報讓管理層追蹤。</p>
<p>Tagging 在合規驅動的基礎設施中還有另一層用途：用 tag 標記資料的地理歸屬，讓合規查詢可以機器化。Hard Rock Digital 的運動博彩平台受美國 Wire Act 約束，不同州的投注資料必須留在州內。它們用 CockroachDB 跨 AWS Outposts 部署，每個 Outpost 的資源用地理 tag 標記歸屬州，合規稽核時用 tag 過濾而非逐台盤查。這個案例的 infra 教訓是：tag 的維度設計在受地理或法規約束的服務中，要提前納入合規需求的維度，而非只做成本和歸屬。詳見 <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">9.C41 Hard Rock Digital：Wire Act 合規</a>。</p>
<h2 id="secrets-不進-code機密值的儲存與引用">Secrets 不進 code：機密值的儲存與引用</h2>
<p>機密值 — 資料庫密碼、第三方 API key、簽章用的私鑰 — 要存在專用的密鑰管理服務裡，而 code 與 IaC 只持有指向它的參照，不持有值本身。這條規則承擔的責任是把「機密外洩的爆炸半徑」與「程式碼的散布範圍」脫鉤：一旦密碼寫進 repo，它就跟著每一次 clone、每一份 CI 快取、每一個 fork 擴散，輪替時無法保證所有副本都更新，git 歷史更是會把它永久留存，即使後來刪掉那一行。</p>
<h3 id="密鑰管理服務的選型">密鑰管理服務的選型</h3>
<p>密鑰管理服務提供的是一個有存取控制、有審計紀錄、可輪替的集中儲存。值放在這裡，誰讀過、什麼時候讀的都有 log，輪替時只改一個地方，所有引用方下次讀取就拿到新值。</p>
<table>
  <thead>
      <tr>
          <th>服務</th>
          <th>定位</th>
          <th>適合的情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>AWS Secrets Manager</td>
          <td>受管 secret、支援自動輪替</td>
          <td>資料庫密碼、需要自動輪替的 key</td>
      </tr>
      <tr>
          <td>AWS SSM Parameter Store</td>
          <td>輕量級 key-value、有免費額度</td>
          <td>設定值、不需要自動輪替的 secret</td>
      </tr>
      <tr>
          <td>HashiCorp Vault</td>
          <td>自管 / 託管、跨雲、動態 secret</td>
          <td>多雲或需要動態產生短期憑證的團隊</td>
      </tr>
      <tr>
          <td>GCP Secret Manager</td>
          <td>GCP 原生受管 secret</td>
          <td>GCP 生態</td>
      </tr>
  </tbody>
</table>
<p>選型看的是團隊已有的生態與輪替需求。對已在 AWS 上的團隊，Secrets Manager 適合需要自動輪替的資料庫密碼，SSM Parameter Store 適合其餘設定值（免費額度通常夠用）。跨雲或對動態 secret 有需求的團隊會走 Vault。</p>
<h3 id="iac-怎麼引用-secret">IaC 怎麼引用 secret</h3>
<p>IaC 應該存的是密鑰的 ARN（或等價的資源識別碼）與「在執行期去讀它」的指令，而不是密鑰的明文：</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_secretsmanager_secret_version&#34; &#34;db&#34;</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  secret_id</span> <span class="o">=</span> <span class="s2">&#34;prod/payments/db-password&#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_db_instance&#34; &#34;payments&#34;</span> {
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">  password</span> <span class="o">=</span> <span class="k">data</span><span class="p">.</span><span class="k">aws_secretsmanager_secret_version</span><span class="p">.</span><span class="k">db</span><span class="p">.</span><span class="k">secret_string</span><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"></span>}</span></span></code></pre></div><p>另一種做法是讓 IaC 只建立 secret 的「容器」（空的 Secrets Manager entry），值由人工或自動化流程在 IaC 之外寫入。這樣 state 裡只有 secret 的 metadata（ARN、名稱、版本 ID），完全不碰明文。適合密碼由安全團隊管理、IaC 只負責「確保 secret 存在且有正確的存取策略」的分工模式。</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_secretsmanager_secret&#34; &#34;db&#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;${var.env}/payments/db-password&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">}<span class="c1">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 值不由 Terraform 管理 — 在 Console 或 CLI 手動設定
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="err">#</span> <span class="k">secret</span> <span class="k">version</span> <span class="k">生命週期在</span> <span class="k">IaC</span> <span class="k">之外</span></span></span></code></pre></div><h3 id="state-裡的機密邊界">state 裡的機密邊界</h3>
<p>Terraform 即使從 Secrets Manager 讀值，那個值仍然會以明文落進 state 檔。這是一個常被忽略的邊界。「不進 code」只是第一道，state 後端的加密與存取控制（<a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一的 state 地基</a>）是同等重要的第二道 — 否則密鑰只是從 repo 搬到了一個沒鎖好的 state bucket。</p>
<p>State 的保護措施是一道複合防線：</p>
<ul>
<li>S3 bucket 開 <code>encrypt = true</code>（AES-256 或 KMS）</li>
<li>Bucket 的 IAM policy 只允許跑 <code>apply</code> 的 role 讀寫</li>
<li>Bucket 開 versioning，誤寫或損壞時可以回捲</li>
<li>DynamoDB lock table 防止並行 apply 覆蓋</li>
</ul>
<p>這些措施在<a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一</a>的 remote state backend 段已經詳述，這裡提醒的是：state 的安全程度決定了 secret 引用策略的上限。state 沒鎖好時，把 secret 值拉進 state 的做法等於把密碼從 repo 搬到了另一個不設防的地方。</p>
<h3 id="secret-掃描">Secret 掃描</h3>
<p>判讀訊號：定期用 secret 掃描工具掃 repo 與 CI log，任何命中都當成需要輪替的外洩事件處理，而不是刪掉那行就算了 — 因為 git 歷史與既有 clone 已經保不住了。</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"># gitleaks：掃描整個 git 歷史</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gitleaks detect --source . --report-format json --report-path gitleaks-report.json
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># trufflehog：掃描 git、filesystem、CI</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">trufflehog git file://. --json</span></span></code></pre></div><p>兩個工具覆蓋面不同（gitleaks 用 regex pattern、trufflehog 用 detector + entropy），搭配用覆蓋更完整。放進 CI pipeline 讓每個 PR 自動掃，比人工定期跑更可靠。命中後的處理流程：先輪替被洩露的 secret，再從 repo 清除（<code>git filter-repo</code>），最後通知所有可能受影響的服務。</p>
<h3 id="secret-命名規範">Secret 命名規範</h3>
<p>機密的命名也值得約定。用 <code>{env}/{service}/{purpose}</code> 這類有結構的路徑（如 <code>prod/payments/db-password</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"># 給 payments service 的 role 只能讀自己的 secret
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">data</span> <span class="s2">&#34;aws_iam_policy_document&#34; &#34;payments_secrets&#34;</span> {
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">statement</span> {
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">    actions</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;secretsmanager:GetSecretValue&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">    resources</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;arn:aws:secretsmanager:*:*:secret:${var.env}/payments/*&#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></span></code></pre></div><p>前綴授權的好處是新增 secret 時不需要改 IAM policy — 只要命名落在同一個前綴下，存取權限自動繼承。跟<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二</a>的最小權限設計一致：service A 的 role 只看得到 <code>payments/*</code>，看不到 <code>auth/*</code>，即使它們存在同一個帳號的 Secrets Manager 裡。</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 後端的加密與存取控制是 secret 引用策略的安全地基</li>
<li>→ <a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>：誰能讀哪些 secret 的 IAM 權限設計</li>
<li>→ <a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>：tag 的環境值與 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>：tag 合規與 secret 掃描整合進 CI pipeline</li>
<li>→ <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 模組七：資安與資料保護</a>：密鑰生命週期、輪替策略與資料保護的完整討論</li>
</ul>
]]></content:encoded></item><item><title>升級的共通操作框架</title><link>https://tarrragon.github.io/blog/infra/upgrade/upgrade-framework/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/upgrade/upgrade-framework/</guid><description>&lt;p>環境與系統升級的核心約束是系統在升級過程中要持續服務客戶。這個約束排除了「關機 → 換版本 → 開機」的簡單路徑，取而代之的操作模式是四個階段：評估新舊版本的差異、在旁邊建一個新環境驗證、把流量分批切過去、確認沒問題後退役舊環境。這四個階段不管升級的對象是 runtime 版本、資料庫引擎、作業系統還是整個平台，框架相同，差異落在每個階段的具體操作與風險點。&lt;/p>
&lt;h2 id="phase-1差異評估">Phase 1：差異評估&lt;/h2>
&lt;p>差異評估的產出是一份 change manifest——列出所有已知的新舊差異、每項的風險等級、以及需要的應對措施。這份清單是後續所有階段的依據：平行環境要驗證清單上的每一項、切換策略要先處理高風險項、退役前要確認清單上的所有相容性問題都已解決。&lt;/p>
&lt;h3 id="差異的三個維度">差異的三個維度&lt;/h3>
&lt;p>第一個維度是目標本身的變化。版本升級要看 changelog、breaking changes list、deprecated features list。平台遷移要看兩個平台的功能差異（共享主機沒有的 cron 彈性、VPS 有的 SSH 存取）。資料庫升級要看 SQL 語法差異、預設行為變更（如 MySQL 8.0 的 &lt;code>caching_sha2_password&lt;/code> 預設認證方式）。&lt;/p>
&lt;p>第二個維度是依賴關係。升級 PHP 版本時，所有 Composer 套件都可能受影響；升級 MySQL 時，ORM 的 SQL 生成可能不相容；遷移平台時，原本靠主機面板設定的 cron job 要改用系統 crontab 或雲端排程。依賴關係沒列完整，平行環境的測試就會漏掉受影響的元件。&lt;/p>
&lt;p>第三個維度是過渡期的雙版本相容性。升級不是瞬間完成的——在切換的過程中，系統的某些部分跑新版本、某些部分跑舊版本。這段期間兩個版本必須能共存：資料庫的 schema 要同時相容新舊版本的程式碼、API 的回應格式要讓新舊版本的客戶端都能處理、session 格式要能跨版本延續。&lt;/p>
&lt;h3 id="風險分級">風險分級&lt;/h3>
&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>向後相容、不需改 code&lt;/td>
 &lt;td>平行環境驗證即可&lt;/td>
 &lt;td>PHP 8.x 的效能改善&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>中&lt;/td>
 &lt;td>需要改 code 但改動明確&lt;/td>
 &lt;td>先改 code、確認新舊版本都能跑&lt;/td>
 &lt;td>deprecated function 替換&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>高&lt;/td>
 &lt;td>行為變更、可能影響商業邏輯&lt;/td>
 &lt;td>需要完整的功能測試 + 人工驗證&lt;/td>
 &lt;td>浮點數精度變更、排序預設值變更&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>阻塞&lt;/td>
 &lt;td>無法在新版本運作、沒有替代方案&lt;/td>
 &lt;td>必須在升級前解決或決定放棄升級&lt;/td>
 &lt;td>依賴的套件不支援新版本&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每一項差異分級後，高風險和阻塞項決定升級的可行性與時程。阻塞項超過團隊能處理的量時，升級可能需要拆成多個階段（先升到中間版本、再升到目標版本）或延後。&lt;/p>
&lt;h3 id="時程與管理層報告">時程與管理層報告&lt;/h3>
&lt;p>差異評估的時程通常佔整個升級的 20-30%——看起來「還沒開始做」但這段時間的產出（change manifest）決定了後面所有階段的範圍。向管理層報告時用 change manifest 的風險分級表：「共 N 項差異，其中 X 項低風險、Y 項中風險、Z 項高風險、W 項阻塞。中高風險項的處理估計 M 天，阻塞項的替代方案評估需要額外 K 天。」&lt;/p>
&lt;h2 id="phase-2平行環境驗證">Phase 2：平行環境驗證&lt;/h2>
&lt;p>平行環境驗證的責任是用事實證明「新版本在跟 production 相同的條件下能正常運作」。它的產出是一份驗證報告——每一項 change manifest 上的差異都標上「已驗證通過 / 有問題待修 / 不影響」。沒有這份報告就切換，等於在賭新版本會正常。&lt;/p>
&lt;h3 id="建立平行環境">建立平行環境&lt;/h3>
&lt;p>平行環境跟 production 越相似，驗證結果越可信。理想狀態是完全複製 production 的架構（同規格、同設定、同網路拓撲），只差目標元件的版本不同。成本限制下的折衷是用縮小版（較小的 instance、較少的資料量），但關鍵設定（PHP 模組、MySQL 參數、安全設定）必須跟 production 一致。&lt;/p>
&lt;p>資料的處理要特別注意。用 production 的資料副本驗證最可靠（能觸發真實的邊界狀況），但如果資料含 PII，需要先脫敏處理。另一個選項是用 staging 環境的資料，但要確認 staging 的 schema 跟 production 一致——schema drift 會讓驗證結果失真。&lt;/p>
&lt;h3 id="驗證清單">驗證清單&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>驗證項目&lt;/th>
 &lt;th>方法&lt;/th>
 &lt;th>通過標準&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>應用程式啟動&lt;/td>
 &lt;td>部署到新環境、觀察 log&lt;/td>
 &lt;td>無 fatal error、所有服務啟動成功&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>自動化測試&lt;/td>
 &lt;td>跑完整測試套件&lt;/td>
 &lt;td>通過率跟舊環境一致&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>關鍵業務流程&lt;/td>
 &lt;td>人工操作核心流程（登入、下單、金流）&lt;/td>
 &lt;td>每個步驟的結果正確&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>效能比對&lt;/td>
 &lt;td>同樣的 workload 打新舊環境&lt;/td>
 &lt;td>回應時間差異 &amp;lt; 10%（或可解釋）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>相容性問題&lt;/td>
 &lt;td>逐一驗證 change manifest 的中高風險項&lt;/td>
 &lt;td>每項有「通過」或「已修」的紀錄&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>外部整合&lt;/td>
 &lt;td>第三方 API callback、webhook、email&lt;/td>
 &lt;td>外部服務能正常與新環境互動&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="平行期的時間長度">平行期的時間長度&lt;/h3>
&lt;p>平行環境跑多久才能切換？取決於業務週期。如果系統有月結、季結的批次處理，平行環境至少要跑過一次完整週期。電商系統要跑過至少一個促銷活動。沒有明顯週期的系統，一到兩週的平行驗證通常足夠發現主要問題。&lt;/p>
&lt;h2 id="phase-3分批切換">Phase 3：分批切換&lt;/h2>
&lt;p>分批切換的核心原則是不一次切 100%——先把最低風險的流量導到新環境，觀察一段時間確認正常，再逐步增加比例。&lt;/p>
&lt;h3 id="切換策略">切換策略&lt;/h3>
&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>DNS 權重切換&lt;/td>
 &lt;td>有多組 server 的環境&lt;/td>
 &lt;td>Route 53 weighted routing 或類似機制，逐步調整新舊比例&lt;/td>
 &lt;td>分鐘級（改 DNS 權重）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Blue-green&lt;/td>
 &lt;td>有 load balancer 的環境&lt;/td>
 &lt;td>新舊環境各掛在不同 target group，LB 切換指向&lt;/td>
 &lt;td>秒級（切 target group）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Canary&lt;/td>
 &lt;td>容器化或 serverless 環境&lt;/td>
 &lt;td>新版本只接 5% → 20% → 50% → 100% 流量&lt;/td>
 &lt;td>秒級（調整 weight）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>維護窗口&lt;/td>
 &lt;td>共享主機（無 LB）&lt;/td>
 &lt;td>公告停機時間、切換、驗證、恢復服務&lt;/td>
 &lt;td>分鐘級（FTP 上傳舊版）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>共享主機通常只能用維護窗口策略——沒有 load balancer 做流量分配、沒有 DNS 權重可調。維護窗口的關鍵是時間規劃：備份（15 分鐘）→ 切換（30 分鐘）→ 驗證（30 分鐘）→ 恢復或回退（15 分鐘），在窗口內必須完成全部步驟，超時就回退。&lt;/p></description><content:encoded><![CDATA[<p>環境與系統升級的核心約束是系統在升級過程中要持續服務客戶。這個約束排除了「關機 → 換版本 → 開機」的簡單路徑，取而代之的操作模式是四個階段：評估新舊版本的差異、在旁邊建一個新環境驗證、把流量分批切過去、確認沒問題後退役舊環境。這四個階段不管升級的對象是 runtime 版本、資料庫引擎、作業系統還是整個平台，框架相同，差異落在每個階段的具體操作與風險點。</p>
<h2 id="phase-1差異評估">Phase 1：差異評估</h2>
<p>差異評估的產出是一份 change manifest——列出所有已知的新舊差異、每項的風險等級、以及需要的應對措施。這份清單是後續所有階段的依據：平行環境要驗證清單上的每一項、切換策略要先處理高風險項、退役前要確認清單上的所有相容性問題都已解決。</p>
<h3 id="差異的三個維度">差異的三個維度</h3>
<p>第一個維度是目標本身的變化。版本升級要看 changelog、breaking changes list、deprecated features list。平台遷移要看兩個平台的功能差異（共享主機沒有的 cron 彈性、VPS 有的 SSH 存取）。資料庫升級要看 SQL 語法差異、預設行為變更（如 MySQL 8.0 的 <code>caching_sha2_password</code> 預設認證方式）。</p>
<p>第二個維度是依賴關係。升級 PHP 版本時，所有 Composer 套件都可能受影響；升級 MySQL 時，ORM 的 SQL 生成可能不相容；遷移平台時，原本靠主機面板設定的 cron job 要改用系統 crontab 或雲端排程。依賴關係沒列完整，平行環境的測試就會漏掉受影響的元件。</p>
<p>第三個維度是過渡期的雙版本相容性。升級不是瞬間完成的——在切換的過程中，系統的某些部分跑新版本、某些部分跑舊版本。這段期間兩個版本必須能共存：資料庫的 schema 要同時相容新舊版本的程式碼、API 的回應格式要讓新舊版本的客戶端都能處理、session 格式要能跨版本延續。</p>
<h3 id="風險分級">風險分級</h3>
<table>
  <thead>
      <tr>
          <th>風險等級</th>
          <th>定義</th>
          <th>應對方式</th>
          <th>範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>低</td>
          <td>向後相容、不需改 code</td>
          <td>平行環境驗證即可</td>
          <td>PHP 8.x 的效能改善</td>
      </tr>
      <tr>
          <td>中</td>
          <td>需要改 code 但改動明確</td>
          <td>先改 code、確認新舊版本都能跑</td>
          <td>deprecated function 替換</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>每一項差異分級後，高風險和阻塞項決定升級的可行性與時程。阻塞項超過團隊能處理的量時，升級可能需要拆成多個階段（先升到中間版本、再升到目標版本）或延後。</p>
<h3 id="時程與管理層報告">時程與管理層報告</h3>
<p>差異評估的時程通常佔整個升級的 20-30%——看起來「還沒開始做」但這段時間的產出（change manifest）決定了後面所有階段的範圍。向管理層報告時用 change manifest 的風險分級表：「共 N 項差異，其中 X 項低風險、Y 項中風險、Z 項高風險、W 項阻塞。中高風險項的處理估計 M 天，阻塞項的替代方案評估需要額外 K 天。」</p>
<h2 id="phase-2平行環境驗證">Phase 2：平行環境驗證</h2>
<p>平行環境驗證的責任是用事實證明「新版本在跟 production 相同的條件下能正常運作」。它的產出是一份驗證報告——每一項 change manifest 上的差異都標上「已驗證通過 / 有問題待修 / 不影響」。沒有這份報告就切換，等於在賭新版本會正常。</p>
<h3 id="建立平行環境">建立平行環境</h3>
<p>平行環境跟 production 越相似，驗證結果越可信。理想狀態是完全複製 production 的架構（同規格、同設定、同網路拓撲），只差目標元件的版本不同。成本限制下的折衷是用縮小版（較小的 instance、較少的資料量），但關鍵設定（PHP 模組、MySQL 參數、安全設定）必須跟 production 一致。</p>
<p>資料的處理要特別注意。用 production 的資料副本驗證最可靠（能觸發真實的邊界狀況），但如果資料含 PII，需要先脫敏處理。另一個選項是用 staging 環境的資料，但要確認 staging 的 schema 跟 production 一致——schema drift 會讓驗證結果失真。</p>
<h3 id="驗證清單">驗證清單</h3>
<table>
  <thead>
      <tr>
          <th>驗證項目</th>
          <th>方法</th>
          <th>通過標準</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>應用程式啟動</td>
          <td>部署到新環境、觀察 log</td>
          <td>無 fatal error、所有服務啟動成功</td>
      </tr>
      <tr>
          <td>自動化測試</td>
          <td>跑完整測試套件</td>
          <td>通過率跟舊環境一致</td>
      </tr>
      <tr>
          <td>關鍵業務流程</td>
          <td>人工操作核心流程（登入、下單、金流）</td>
          <td>每個步驟的結果正確</td>
      </tr>
      <tr>
          <td>效能比對</td>
          <td>同樣的 workload 打新舊環境</td>
          <td>回應時間差異 &lt; 10%（或可解釋）</td>
      </tr>
      <tr>
          <td>相容性問題</td>
          <td>逐一驗證 change manifest 的中高風險項</td>
          <td>每項有「通過」或「已修」的紀錄</td>
      </tr>
      <tr>
          <td>外部整合</td>
          <td>第三方 API callback、webhook、email</td>
          <td>外部服務能正常與新環境互動</td>
      </tr>
  </tbody>
</table>
<h3 id="平行期的時間長度">平行期的時間長度</h3>
<p>平行環境跑多久才能切換？取決於業務週期。如果系統有月結、季結的批次處理，平行環境至少要跑過一次完整週期。電商系統要跑過至少一個促銷活動。沒有明顯週期的系統，一到兩週的平行驗證通常足夠發現主要問題。</p>
<h2 id="phase-3分批切換">Phase 3：分批切換</h2>
<p>分批切換的核心原則是不一次切 100%——先把最低風險的流量導到新環境，觀察一段時間確認正常，再逐步增加比例。</p>
<h3 id="切換策略">切換策略</h3>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>適用環境</th>
          <th>操作方式</th>
          <th>回退速度</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DNS 權重切換</td>
          <td>有多組 server 的環境</td>
          <td>Route 53 weighted routing 或類似機制，逐步調整新舊比例</td>
          <td>分鐘級（改 DNS 權重）</td>
      </tr>
      <tr>
          <td>Blue-green</td>
          <td>有 load balancer 的環境</td>
          <td>新舊環境各掛在不同 target group，LB 切換指向</td>
          <td>秒級（切 target group）</td>
      </tr>
      <tr>
          <td>Canary</td>
          <td>容器化或 serverless 環境</td>
          <td>新版本只接 5% → 20% → 50% → 100% 流量</td>
          <td>秒級（調整 weight）</td>
      </tr>
      <tr>
          <td>維護窗口</td>
          <td>共享主機（無 LB）</td>
          <td>公告停機時間、切換、驗證、恢復服務</td>
          <td>分鐘級（FTP 上傳舊版）</td>
      </tr>
  </tbody>
</table>
<p>共享主機通常只能用維護窗口策略——沒有 load balancer 做流量分配、沒有 DNS 權重可調。維護窗口的關鍵是時間規劃：備份（15 分鐘）→ 切換（30 分鐘）→ 驗證（30 分鐘）→ 恢復或回退（15 分鐘），在窗口內必須完成全部步驟，超時就回退。</p>
<h3 id="切換期間的監控">切換期間的監控</h3>
<p>切換開始後要密切觀察的指標：</p>
<ul>
<li><strong>錯誤率</strong>：5xx / 4xx 比例相對於切換前的基線</li>
<li><strong>回應時間</strong>：p50 和 p99 相對於基線</li>
<li><strong>業務指標</strong>：轉換率、訂單數、付款成功率（如果適用）</li>
<li><strong>外部整合</strong>：第三方 callback 是否正常</li>
</ul>
<h3 id="回退觸發條件">回退觸發條件</h3>
<p>在切換前就定義好回退條件，避免事故發生時還要開會決定要不要退：</p>
<ul>
<li>錯誤率超過基線的 2 倍持續 5 分鐘 → 回退</li>
<li>核心業務流程失敗（登入、結帳、金流） → 立刻回退</li>
<li>回應時間超過基線的 3 倍持續 10 分鐘 → 回退</li>
</ul>
<p>回退不是失敗——它是風險控制機制的正常運作。回退後排查問題、修正、重新走 Phase 2 驗證、再嘗試切換。</p>
<h3 id="切換的通知">切換的通知</h3>
<table>
  <thead>
      <tr>
          <th>對象</th>
          <th>通知時機</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>內部團隊</td>
          <td>切換前 24 小時 + 切換開始時</td>
          <td>切換時間、影響範圍、回退計畫</td>
      </tr>
      <tr>
          <td>客戶（如有 SLA）</td>
          <td>切換前 1 週</td>
          <td>預計維護窗口、預期影響</td>
      </tr>
      <tr>
          <td>外部 vendor</td>
          <td>切換前 1 週</td>
          <td>endpoint 變更（如有）、IP 變更（如有）</td>
      </tr>
  </tbody>
</table>
<h2 id="phase-4退役舊環境">Phase 4：退役舊環境</h2>
<p>切換完成後不要立刻刪掉舊環境——保留 1-2 週的冷備。這段時間處理長尾問題：DNS 快取還沒更新的客戶端、排程任務還指向舊 endpoint 的外部系統、舊環境上可能還有未遷移的資料。</p>
<h3 id="退役前的檢查">退役前的檢查</h3>
<ul>
<li>舊環境的存取 log 是否歸零？（有流量代表還有東西指向它）</li>
<li>所有 cron job 是否都已在新環境運行？</li>
<li>外部系統的 webhook / callback URL 是否都已更新？</li>
<li>舊環境上有沒有需要歸檔的資料？（log、上傳檔案、備份快照）</li>
</ul>
<h3 id="退役步驟">退役步驟</h3>
<ol>
<li>停止舊環境的應用服務（但不刪除）</li>
<li>觀察 1 週——如果有問題可以快速重啟</li>
<li>匯出需要保留的資料（log、uploaded files）</li>
<li>刪除舊環境的運算資源（VM、容器）</li>
<li>保留舊環境的最後一份備份 30 天，作為最後的保險</li>
<li>清理舊環境的 DNS 記錄、SSL 憑證、IAM 角色</li>
</ol>
<h2 id="貫穿全程的升級紀律">貫穿全程的升級紀律</h2>
<h3 id="一次只升一個東西">一次只升一個東西</h3>
<p>同時升級 PHP 版本 + 遷移到新主機 + 重構資料庫 schema，出問題時無法判斷是哪個變更造成的。每次升級只改一個主要元件，穩定後再升下一個。如果業務壓力要求一次完成，至少在 Phase 2 的驗證環境裡逐一引入、逐一確認。</p>
<h3 id="每個階段轉換前備份">每個階段轉換前備份</h3>
<p>Phase 1 結束前備份 production 現況、Phase 3 切換前備份、Phase 4 退役前備份。三份備份各自獨立、各自有還原驗證。備份不只是「做了」——要實際測試過還原，確認備份的完整性。</p>
<h3 id="記錄每一步">記錄每一步</h3>
<p>每個升級操作記錄在 repo 的 changelog 裡：什麼時間、誰做的、改了什麼、觀察到什麼結果。升級出問題時，changelog 是回溯「上一步做了什麼」的唯一依據。</p>
<h3 id="在平行階段就練習回退">在平行階段就練習回退</h3>
<p>不要等到 Phase 3 切換時才第一次嘗試回退。在 Phase 2 的平行環境裡，刻意從新版本切回舊版本一次，確認回退路徑能走通、回退後服務能正常恢復。回退的演練跟升級的驗證同等重要。</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/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a>：stateful 資源（RDS、S3）的升級涉及特殊的備份與切換策略</li>
<li>→ <a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</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>：升級涉及的 IaC 變更走 PR review</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>可觀測性與 log 同生命週期管理</title><link>https://tarrragon.github.io/blog/infra/06-observability-logging/log-metric-alarm-lifecycle/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/06-observability-logging/log-metric-alarm-lifecycle/</guid><description>&lt;p>可觀測性要跟它監控的資源同生命週期：log group、metric 與 alarm 寫進建立資源的同一套 IaC，資源開出來的那一刻監控就在線，而非等出事才補。這條規則的責任是讓基礎設施在出事時可被追查、在日常時可被量化，而它的建立與銷毀和被監控的資源綁在一起，則保證監控的覆蓋率不會隨時間衰退。&lt;/p>
&lt;p>沒有同生命週期管理時，新服務上線後的監控覆蓋率取決於有沒有人記得手動建立 log group 和 alarm，而這個記憶在服務數量增長後會衰退。監控缺口在平時不被注意，在事故排查時才浮現 — 需要回溯「什麼時候開始劣化」時，可能發現劣化期間根本沒有對應的 metric 資料。&lt;/p>
&lt;h2 id="同生命週期的落地方式">同生命週期的落地方式&lt;/h2>
&lt;p>可觀測性是基礎設施的一部分，它的建立、變更與銷毀要跟被監控的資源綁在同一個 apply 單位裡。一個 RDS 實例被 IaC 建立時，它的 log group、它的關鍵 metric alarm 應該在同一份 &lt;code>terraform plan&lt;/code> 裡一起出現；這個資源被 destroy 時，對應的 alarm 也一起收掉。&lt;/p>
&lt;p>落地方式是把監控宣告收進服務的 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>談的模組化在這裡延伸成「每個服務模組自帶它的 observability 宣告」。一個 database module 內部除了 &lt;code>aws_db_instance&lt;/code>，還包含它的 log group、CPU alarm、連線數 alarm：&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"># modules/database/monitoring.tf — 跟 database 資源同一個 module
&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_cloudwatch_log_group&amp;#34; &amp;#34;db_slow_query&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"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;/rds/${var.env}/${var.db_identifier}/slowquery&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"> retention_in_days&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">log_retention_days&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"> kms_key_id&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">log_kms_key_arn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_cloudwatch_metric_alarm&amp;#34; &amp;#34;db_cpu&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="n"> alarm_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.env}-${var.db_identifier}-cpu-high&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"> comparison_operator&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;GreaterThanThreshold&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"> evaluation_periods&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">12&lt;/span>&lt;span class="cl">&lt;span class="n"> metric_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;CPUUtilization&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"> namespace&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;AWS/RDS&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="n"> period&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">300&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"> statistic&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Average&amp;#34;&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"> threshold&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"> alarm_actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">oncall_sns_arn&lt;/span>&lt;span class="p">]&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="n"> dimensions&lt;/span> &lt;span class="o">=&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"> DBInstanceIdentifier&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_db_instance&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">primary&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">identifier&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這樣 &lt;code>terraform apply&lt;/code> 建資料庫的同一刻，監控就存在；&lt;code>terraform destroy&lt;/code> 砍資料庫時，孤兒 alarm 也一起清掉。新環境套用同一個 module 時，監控覆蓋率自動跟著資源走，不需要額外的人工記憶。&lt;/p>
&lt;h2 id="監控脫鉤造成的兩類漂移">監控脫鉤造成的兩類漂移&lt;/h2>
&lt;p>把監控外掛在資源之外（用另一份 IaC、另一個 repo、或手動在 console 設定）會製造兩種方向相反的漂移，兩者的共同根因都是監控跟資源不在同一個 apply 單位裡。&lt;/p>
&lt;h3 id="漂移一新資源沒有監控">漂移一：新資源沒有監控&lt;/h3>
&lt;p>service 透過 PR 加上去了，但 alarm 的建立依賴某人事後手動進 console 設定，或等另一個 repo 的 PR 跟上。於是有些 service 有 alarm、有些沒有，覆蓋率取決於「誰記得」。沒有 alarm 的 service 出事時，事故發現路徑從「告警 → 排查」退化成「客訴 → 排查」，反應時間從分鐘級退化到小時級。&lt;/p>
&lt;p>用一條查詢就能看出這個漂移有多嚴重：列出所有 RDS instance，比對各自有沒有對應的 CloudWatch alarm。沒有 alarm 的 instance 就是漂移的活證據。&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"># 列出所有 RDS instance，比對有沒有對應的 CloudWatch alarm&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">aws rds describe-db-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;DBInstances[].DBInstanceIdentifier&amp;#39;&lt;/span> --output text &lt;span class="p">|&lt;/span> tr &lt;span class="s1">&amp;#39;\t&amp;#39;&lt;/span> &lt;span class="s1">&amp;#39;\n&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span> &lt;span class="k">while&lt;/span> &lt;span class="nb">read&lt;/span> db&lt;span class="p">;&lt;/span> &lt;span class="k">do&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nv">count&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="k">$(&lt;/span>aws cloudwatch describe-alarms &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --alarm-name-prefix &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nv">db&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> --query &lt;span class="s1">&amp;#39;MetricAlarms | length(@)&amp;#39;&lt;/span>&lt;span class="k">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nv">db&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">: &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nv">count&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> alarms&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="k">done&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="漂移二死資源留下殘響">漂移二：死資源留下殘響&lt;/h3>
&lt;p>資源砍了但 alarm 還在，orphan alarm 對不存在的 target 持續報 &lt;code>INSUFFICIENT_DATA&lt;/code>，跟有效 alarm 混在同一個通知頻道裡，降低告警的訊噪比。訊噪比低到一定程度後，有效的 &lt;code>INSUFFICIENT_DATA&lt;/code>（某個服務停止送 metric）也被一起略過 — 告警疲勞讓 alarm 從保護機制退化成背景噪音。&lt;/p></description><content:encoded><![CDATA[<p>可觀測性要跟它監控的資源同生命週期：log group、metric 與 alarm 寫進建立資源的同一套 IaC，資源開出來的那一刻監控就在線，而非等出事才補。這條規則的責任是讓基礎設施在出事時可被追查、在日常時可被量化，而它的建立與銷毀和被監控的資源綁在一起，則保證監控的覆蓋率不會隨時間衰退。</p>
<p>沒有同生命週期管理時，新服務上線後的監控覆蓋率取決於有沒有人記得手動建立 log group 和 alarm，而這個記憶在服務數量增長後會衰退。監控缺口在平時不被注意，在事故排查時才浮現 — 需要回溯「什麼時候開始劣化」時，可能發現劣化期間根本沒有對應的 metric 資料。</p>
<h2 id="同生命週期的落地方式">同生命週期的落地方式</h2>
<p>可觀測性是基礎設施的一部分，它的建立、變更與銷毀要跟被監控的資源綁在同一個 apply 單位裡。一個 RDS 實例被 IaC 建立時，它的 log group、它的關鍵 metric alarm 應該在同一份 <code>terraform plan</code> 裡一起出現；這個資源被 destroy 時，對應的 alarm 也一起收掉。</p>
<p>落地方式是把監控宣告收進服務的 module。<a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四（環境分離與模組化）</a>談的模組化在這裡延伸成「每個服務模組自帶它的 observability 宣告」。一個 database module 內部除了 <code>aws_db_instance</code>，還包含它的 log group、CPU alarm、連線數 alarm：</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/monitoring.tf — 跟 database 資源同一個 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_cloudwatch_log_group&#34; &#34;db_slow_query&#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;/rds/${var.env}/${var.db_identifier}/slowquery&#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="k">var</span><span class="p">.</span><span class="k">log_retention_days</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">  kms_key_id</span>        <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">log_kms_key_arn</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_cloudwatch_metric_alarm&#34; &#34;db_cpu&#34;</span> {
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  alarm_name</span>          <span class="o">=</span> <span class="s2">&#34;${var.env}-${var.db_identifier}-cpu-high&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">  comparison_operator</span> <span class="o">=</span> <span class="s2">&#34;GreaterThanThreshold&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">  evaluation_periods</span>  <span class="o">=</span> <span class="m">3</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">  metric_name</span>         <span class="o">=</span> <span class="s2">&#34;CPUUtilization&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">  namespace</span>           <span class="o">=</span> <span class="s2">&#34;AWS/RDS&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">  period</span>              <span class="o">=</span> <span class="m">300</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">  statistic</span>           <span class="o">=</span> <span class="s2">&#34;Average&#34;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="n">  threshold</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">  alarm_actions</span>       <span class="o">=</span> <span class="p">[</span><span class="k">var</span><span class="p">.</span><span class="k">oncall_sns_arn</span><span class="p">]</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">  dimensions</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="n">    DBInstanceIdentifier</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">21</span><span class="cl">  }
</span></span><span class="line"><span class="ln">22</span><span class="cl">}</span></span></code></pre></div><p>這樣 <code>terraform apply</code> 建資料庫的同一刻，監控就存在；<code>terraform destroy</code> 砍資料庫時，孤兒 alarm 也一起清掉。新環境套用同一個 module 時，監控覆蓋率自動跟著資源走，不需要額外的人工記憶。</p>
<h2 id="監控脫鉤造成的兩類漂移">監控脫鉤造成的兩類漂移</h2>
<p>把監控外掛在資源之外（用另一份 IaC、另一個 repo、或手動在 console 設定）會製造兩種方向相反的漂移，兩者的共同根因都是監控跟資源不在同一個 apply 單位裡。</p>
<h3 id="漂移一新資源沒有監控">漂移一：新資源沒有監控</h3>
<p>service 透過 PR 加上去了，但 alarm 的建立依賴某人事後手動進 console 設定，或等另一個 repo 的 PR 跟上。於是有些 service 有 alarm、有些沒有，覆蓋率取決於「誰記得」。沒有 alarm 的 service 出事時，事故發現路徑從「告警 → 排查」退化成「客訴 → 排查」，反應時間從分鐘級退化到小時級。</p>
<p>用一條查詢就能看出這個漂移有多嚴重：列出所有 RDS instance，比對各自有沒有對應的 CloudWatch alarm。沒有 alarm 的 instance 就是漂移的活證據。</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"># 列出所有 RDS instance，比對有沒有對應的 CloudWatch alarm</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws rds describe-db-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;DBInstances[].DBInstanceIdentifier&#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="k">while</span> <span class="nb">read</span> db<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nv">count</span><span class="o">=</span><span class="k">$(</span>aws cloudwatch describe-alarms <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>    --alarm-name-prefix <span class="s2">&#34;</span><span class="si">${</span><span class="nv">db</span><span class="si">}</span><span class="s2">&#34;</span> --query <span class="s1">&#39;MetricAlarms | length(@)&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">db</span><span class="si">}</span><span class="s2">: </span><span class="si">${</span><span class="nv">count</span><span class="si">}</span><span class="s2"> alarms&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="k">done</span></span></span></code></pre></div><h3 id="漂移二死資源留下殘響">漂移二：死資源留下殘響</h3>
<p>資源砍了但 alarm 還在，orphan alarm 對不存在的 target 持續報 <code>INSUFFICIENT_DATA</code>，跟有效 alarm 混在同一個通知頻道裡，降低告警的訊噪比。訊噪比低到一定程度後，有效的 <code>INSUFFICIENT_DATA</code>（某個服務停止送 metric）也被一起略過 — 告警疲勞讓 alarm 從保護機制退化成背景噪音。</p>
<p>漂移二的成本不只是注意力。殘留的 alarm 會佔用 CloudWatch alarm 的配額（每個帳號有配額上限），大量孤兒 alarm 累積後，新服務要加 alarm 可能需要先清理舊的 — 這在事故當下是最不該花時間的事。</p>
<p>修法是把 alarm 的生命週期綁進 module：資源 destroy 時 alarm 跟著 destroy，不需要另一個流程去「記得清理」。如果因為歷史原因已經有大量孤兒 alarm，可以用 alarm 的 <code>StateValue</code> 為 <code>INSUFFICIENT_DATA</code> 且持續超過 7 天作為清理候選的篩選條件。</p>
<h2 id="log-group-設計">log group 設計</h2>
<p>Log group 是日誌的歸屬與保存單位，它要回答兩個治理問題：留多久（retention）、誰能讀（access control）。這兩個問題寫進 IaC 才能稽核，而非依賴 vendor 的隱性預設。</p>
<h3 id="retention三方取捨">Retention：三方取捨</h3>
<p>許多雲端服務在沒有明確宣告 log group 時會自動建一個、套上「永久保留」的預設值。永久保留的問題不是技術性的 — CloudWatch Logs 可以存到無限久 — 而是治理性的：日誌無限堆積、帳單緩慢長大，而沒有人做過「這條 log 該留多久」的顯式決定。</p>
<p>Retention 是成本、合規與除錯需求的三方取捨：</p>
<table>
  <thead>
      <tr>
          <th>日誌類型</th>
          <th>除錯需求</th>
          <th>合規需求</th>
          <th>建議 retention</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>應用 log（request、error）</td>
          <td>近 2-4 週</td>
          <td>通常無特殊要求</td>
          <td>14-30 天</td>
      </tr>
      <tr>
          <td>資料庫 slow query log</td>
          <td>近 1-2 週</td>
          <td>通常無特殊要求</td>
          <td>14 天</td>
      </tr>
      <tr>
          <td>存取稽核 log（CloudTrail）</td>
          <td>偶爾回溯</td>
          <td>1-7 年</td>
          <td>90-365 天 + 歸檔 S3</td>
      </tr>
      <tr>
          <td>金流 / 交易 log</td>
          <td>對帳用、偶爾</td>
          <td>依法規 3-7 年</td>
          <td>短期保留 + 長期歸檔</td>
      </tr>
  </tbody>
</table>
<p>較合理的做法是按日誌類型分層：高頻、除錯用的 application log 設短 retention，稽核相關的 access log 按合規要求設長期保留，必要時再把冷資料用 subscription filter 歸檔到更便宜的物件儲存（S3 + Glacier）。把這些值寫進 IaC，讓「為什麼這條 log 留 90 天」是一個能在 PR 上被討論的決定，而非某人半年前在 console 點的一個數字。成本參考：CloudWatch Logs 的儲存費用約 $0.03/GB/月。一個每天產生 10GB log 的服務，30 天 retention 的月費約 $9，7 天約 $2。retention 天數的選擇是合規需求（留多久才合規）與儲存成本的直接取捨，可以按 log 類型分層設定。</p>
<p>觀測平台的帳單在規模化後容易超線性成長，而缺乏 per-team cost attribution 的環境只能靠全域砍 retention 或降 sampling 來控制成本，兩者都會傷害觀測品質。把 log retention 跟 cardinality budget 的決定從全域級拆到團隊級（用 tag 歸因），才能做到「該省的省、該留的留」。這個取捨在 <a href="/blog/backend/04-observability/cases/observability-cost-governance-at-scale/" data-link-title="4.C14 觀測平台成本治理：從帳單驚嚇到可預測成本" data-link-desc="觀測帳單持續超線性成長時，用 cost attribution、cardinality budget、log tiering 跟 adaptive sampling 建立可預測成本模型。">4.C14 觀測平台成本治理</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_cloudwatch_log_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;/app/${var.env}/api&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  retention_in_days</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">30</span> <span class="err">:</span> <span class="m">7</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  kms_key_id</span>        <span class="o">=</span> <span class="k">aws_kms_key</span><span class="p">.</span><span class="k">logs</span><span class="p">.</span><span class="k">arn</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_cloudwatch_log_group&#34; &#34;audit&#34;</span> {
</span></span><span class="line"><span class="ln"> 8</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"> 9</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">10</span><span class="cl"><span class="n">  kms_key_id</span>        <span class="o">=</span> <span class="k">aws_kms_key</span><span class="p">.</span><span class="k">logs</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></code></pre></div><p>Dev 環境的 retention 可以大幅縮短（7 天甚至 3 天），因為它不承擔合規責任，存取量也低，帳單節省直接對應這個差值。</p>
<h3 id="存取控制與加密">存取控制與加密</h3>
<p>「誰能讀」是 retention 之外的另一半。Log 經常夾帶 PII（使用者信箱、IP）、token 或內部結構，讀取權限要跟<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二（身分與憑證地基）</a>建立的 IAM 角色一起管。</p>
<p>常見陷阱是 log 在傳輸與儲存都加密了（<code>kms_key_id</code> 有設），卻對整個團隊開放讀取。加密保護的是靜態資料不被未授權存取，但如果整個開發團隊都有 <code>logs:GetLogEvents</code> 權限，加密形同虛設 — read 權限應該縮到值班與稽核需要的最小集合。</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"># 只允許 oncall role 讀取 prod log
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">data</span> <span class="s2">&#34;aws_iam_policy_document&#34; &#34;log_read&#34;</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">statement</span> {
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">    actions</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;logs:GetLogEvents&#34;, &#34;logs:FilterLogEvents&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">    resources</span> <span class="o">=</span> <span class="p">[</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">arn</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></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_iam_role_policy&#34; &#34;oncall_log_read&#34;</span> {
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">  role</span>   <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">oncall_role_name</span>
</span></span><span class="line"><span class="ln">11</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">log_read</span><span class="p">.</span><span class="k">json</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">}</span></span></code></pre></div><p>應用層該怎麼決定哪些欄位根本不該進 log（例如在 logger 層做 PII masking），屬於資料保護的範圍，見 <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 模組七：資安與資料保護</a>。</p>
<h2 id="metric-與-alarm-設計">metric 與 alarm 設計</h2>
<p>Metric 與 alarm 寫進 IaC，目的是讓「資源被建立的同時就帶著它的健康判準」。Alarm 是一份成文約定：哪條 metric、跨多長的評估窗口、超過什麼值要通知誰。把這份約定寫進 code，它就能被 review、被版本控制、被跨環境複用。</p>
<h3 id="症狀型-vs-成因型告警">症狀型 vs 成因型告警</h3>
<p>閾值設計是訊號與雜訊的取捨。告警可以分成兩類：症狀型（symptom-based）對應的是「使用者已經受影響」的指標 — 5xx 錯誤率、p99 延遲、佇列積壓。成因型（cause-based）對應的是「某個元件在劣化但使用者可能還沒感知」的指標 — CPU 使用率、記憶體使用率、磁碟 IOPS。</p>
<p>收益最高的起點是：症狀型設 alarm 並綁通知，成因型留在 dashboard 上作為診斷線索。理由是成因和症狀之間不一定有直接關係 — CPU 在 80% 不代表使用者受影響（可能 auto-scaling 正在長新節點），而 CPU 在 30% 也不代表安全（可能是某個 goroutine 卡住了，CPU 反而閒下來）。如果每個成因指標都獨立設 alarm，告警數量會與資源數量等比增長，訊噪比下降後症狀型告警容易被成因型告警淹沒。</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"># 症狀型 alarm：5xx 超過閾值代表使用者已受影響
</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_cloudwatch_metric_alarm&#34; &#34;api_5xx&#34;</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  alarm_name</span>          <span class="o">=</span> <span class="s2">&#34;${var.env}-api-5xx-rate&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  comparison_operator</span> <span class="o">=</span> <span class="s2">&#34;GreaterThanThreshold&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">  evaluation_periods</span>  <span class="o">=</span> <span class="m">3</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  metric_name</span>         <span class="o">=</span> <span class="s2">&#34;5XXError&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">  namespace</span>           <span class="o">=</span> <span class="s2">&#34;AWS/ApiGateway&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">  period</span>              <span class="o">=</span> <span class="m">60</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  statistic</span>           <span class="o">=</span> <span class="s2">&#34;Sum&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">  threshold</span>           <span class="o">=</span> <span class="m">10</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">  treat_missing_data</span>  <span class="o">=</span> <span class="s2">&#34;notBreaching&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">  alarm_actions</span>       <span class="o">=</span> <span class="p">[</span><span class="k">var</span><span class="p">.</span><span class="k">oncall_sns_arn</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">}<span class="c1">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"># 成因型指標：CPU 放 dashboard、不設 alarm
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1"></span><span class="err">#</span> <span class="k">除非確認</span><span class="err">「</span><span class="k">CPU</span> <span class="k">到</span> <span class="k">X</span><span class="err">%</span> <span class="k">一定代表服務即將不可用</span><span class="err">」</span><span class="k">這個因果關係</span></span></span></code></pre></div><p>當成因和症狀之間有明確的因果閾值（例如 RDS 磁碟用量到 90% 就會開始拒絕寫入），那條成因也值得設 alarm — 關鍵是因果關係要確認過、而非假設。</p>
<h3 id="insufficient_data-的處理">INSUFFICIENT_DATA 的處理</h3>
<p><code>treat_missing_data</code> 決定了「沒收到 metric 資料點」時 alarm 怎麼判定。這個設定常被忽略，但它在兩個情境下會造成顯著差異：</p>
<p><strong>持續有資料的 metric</strong>（如 API request count）：資料突然消失通常代表服務掛了或 metric 管線斷了，應該設 <code>treat_missing_data = &quot;breaching&quot;</code> — 沒資料本身就是異常訊號。</p>
<p><strong>間歇性的 metric</strong>（如錯誤 count、某個低頻 Lambda 的 invocation）：平常就沒有資料點，沒資料代表正常運作，應該設 <code>treat_missing_data = &quot;notBreaching&quot;</code> — 避免每次低谷時段都觸發假告警。</p>
<p>判讀方式是問自己：「這條 metric 如果 10 分鐘沒有任何資料，代表好事還是壞事？」好事用 <code>notBreaching</code>，壞事用 <code>breaching</code>，不確定用 <code>ignore</code>（不改變 alarm 狀態，等下一個有資料的評估週期再判定）。</p>
<h3 id="告警必須連到動作">告警必須連到動作</h3>
<p>一條有用的 alarm 至少要綁定通知去向。<code>alarm_actions</code> 為空的 alarm 只會在 CloudWatch console 裡變色，而事故發生時沒有人會盯著 console 看 — alarm 的價值在於它主動推送到值班的人手上。</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_sns_topic&#34; &#34;oncall&#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;${var.env}-oncall-alerts&#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_sns_topic_subscription&#34; &#34;pagerduty&#34;</span> {
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">  topic_arn</span> <span class="o">=</span> <span class="k">aws_sns_topic</span><span class="p">.</span><span class="k">oncall</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln">7</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">8</span><span class="cl"><span class="n">  endpoint</span>  <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">pagerduty_integration_url</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">}</span></span></code></pre></div><p>通知去向也該寫進 IaC — SNS topic、subscription、整合端點都是基礎設施的一部分。手動建的 SNS subscription 跟手動建的 alarm 有同樣的問題：沒人記得、沒人維護、出事才發現斷了。</p>
<h3 id="把基礎告警做成-module-預設">把基礎告警做成 module 預設</h3>
<p>如果每次新服務上線都要有人「記得」去加 alarm，代表 alarm 還沒進 module 模板。把基礎告警（錯誤率、延遲、健康檢查失敗）做成服務模組的預設輸出，新服務 apply 時 alarm 跟著一起生出來：</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/service/variables.tf
</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;alarm_5xx_threshold&#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">number</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  default</span> <span class="o">=</span> <span class="m">10</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;alarm_latency_p99_ms&#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">number</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="m">3000</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">}</span></span></code></pre></div><p>開新服務時 alarm 跟著資源一起生出來，調整閾值才是該服務 owner 的選配。預設值的選擇依據是「保守但不擾民」— 初始閾值設寬一點，上線穩定後再根據實際基線收斂。</p>
<p>觀測訊號的設計有一個容易忽略的盲區：aggregated metric 會遮蔽局部惡化。Discord 在三代儲存架構的遷移過程中反覆遇到同一個問題——整體 p95 延遲正常，但少數 hot partition 或大型群組的延遲已經飆升，直到使用者回報才發現。教訓是 alarm 的維度要跟業務的 fan-out 結構對齊，而非只看全域聚合。詳見 <a href="/blog/backend/04-observability/cases/discord-storage-growth-observability-gap/" data-link-title="4.C13 Discord：從儲存問題回推觀測缺口" data-link-desc="每次儲存遷移都暴露觀測盲區，把儲存成長問題重新框架為訊號設計問題。">4.C13 Discord：從儲存問題回推觀測缺口</a>。規模化後叢集的動態擴縮也會改變觀測模型——擴縮事件本身要成為觀測對象，見 <a href="/blog/backend/04-observability/cases/airbnb-observability-k8s-scale-signals/" data-link-title="4.C8 Airbnb：Kubernetes 規模化下的觀測訊號治理" data-link-desc="叢集擴縮與工作負載變動如何回寫觀測模型。">4.C8 Airbnb：K8s 規模化觀測訊號治理</a>。</p>
<h2 id="基礎設施訊號-vs-客戶端行為訊號">基礎設施訊號 vs 客戶端行為訊號</h2>
<p>本模組的可觀測性處理基礎設施訊號，<a href="/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">Monitoring 監控體系</a>處理客戶端與業務行為訊號。兩者觀測的對象不同、生命週期也不同，因此分屬不同的 code 與不同的部署管道。</p>
<p>基礎設施訊號是資源層的健康狀態：log group retention、CPU、佇列深度、5xx 比例、實例存活。它們跟著資源被 IaC 建立與銷毀，回答的問題是「這個系統還活著嗎、哪裡壞了」。</p>
<p>客戶端行為訊號則是 SDK、Collector、業務埋點那一層：使用者點了什麼、轉換漏斗在哪裡流失、前端 JS 錯誤率、自訂業務事件。它們跟著產品功能演進、不跟著基礎設施資源同生共滅。</p>
<p>判讀分界的問法是：這個訊號是「資源建立時就該存在」還是「功能開發時才埋」。前者進本模組的 IaC，後者進 monitoring 那層的應用程式碼。</p>
<p>兩者在事故排查時會合流 — 基礎設施 alarm 告訴值班「RDS CPU 飆到 95%」，客戶端訊號告訴產品團隊「結帳頁面的失敗率從 0.1% 跳到 12%」。把兩條訊號交叉比對才能判斷影響範圍。但它們的擁有者、變更節奏與部署管道不同 — 基礎設施 alarm 跟著 infra PR 走，前端埋點跟著產品 sprint 走。混在同一份 code 裡會讓「誰負責這條訊號的閾值」變模糊，也讓 infra PR 的 review 範圍擴大到不相干的業務邏輯。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">monitoring 監控體系</a>：客戶端 SDK / Collector 那層的監控</li>
<li>→ <a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>：module 化在這裡延伸成「每個模組自帶 observability 宣告」</li>
<li>→ <a href="/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a>：每個核心服務帶自己的 log 與 alarm</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>：observability 變更也走 PR 與自動化護欄</li>
<li>→ <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 模組七：資安與資料保護</a>：哪些欄位不該進 log、PII 處理</li>
</ul>
]]></content:encoded></item><item><title>身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計</title><link>https://tarrragon.github.io/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/</guid><description>&lt;p>權限一旦散落，後面每一層都建在沙上。網路收斂得再好，只要一把權限過大的長期憑證流出，攻擊者就能繞過所有邊界直接動到核心資源；環境分得再乾淨，只要 production 跟 staging 共用同一組身分，一次誤操作就跨環境炸開。身分與憑證是地基層最先該收斂的能力，因為它決定了「誰能動什麼」這個問題有沒有可信的答案。&lt;/p>
&lt;h2 id="iam-的心智模型">IAM 的心智模型&lt;/h2>
&lt;p>IAM（Identity and Access Management）是雲端平台用來回答「某個身分能不能對某個資源做某件事」的授權系統。它把授權拆成三個獨立的零件：identity（身分，發起動作的主體）、policy（政策，描述允許或拒絕的規則）、role（角色，一組可以被臨時取得的權限集合）。理解這三者的分工，是後面所有憑證決策的前提。&lt;/p>
&lt;h3 id="identity長期主體-vs-臨時假扮">identity：長期主體 vs 臨時假扮&lt;/h3>
&lt;p>identity 分兩類，這個區分在後面設計權限邊界時會反覆用到。一類是 user，代表一個長期存在的主體，通常對應到一個真人或一個固定的服務帳號，本身可以持有長期憑證（密碼或 access key）。另一類是 role，代表一組權限的暫時授予 — 沒有自己的長期密碼，而是讓某個被信任的身分「假扮（assume）」成它、換取一段有時效的臨時憑證。&lt;/p>
&lt;p>把 identity 想成「護照」和「通行證」的差別：user 是護照，長期有效、全程攜帶；role 是通行證，到了管制區域臨時換發、離開就失效。多數安全事故源自於把通行證當護照用 — 某個 role 被長期假扮且從未被撤回，或某個 user 持有永不輪替的 access key。&lt;/p>
&lt;h3 id="policy描述允許對什麼做什麼">policy：描述「允許對什麼做什麼」&lt;/h3>
&lt;p>policy 是貼在 user 或 role 上的規則文件，列出 &lt;code>Action&lt;/code>（能做什麼，如 &lt;code>s3:GetObject&lt;/code>）、&lt;code>Resource&lt;/code>（對哪個資源，如特定 bucket 的 ARN）、&lt;code>Effect&lt;/code>（Allow 或 Deny）。一條 policy 可以包含多個 statement，每條 statement 描述一組操作許可。&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"># 最小權限範例：CI 只能讀寫特定 bucket，不給整個 S3
&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">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;ci_artifacts&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="k">statement&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"> effect&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Allow&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"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;s3:GetObject&amp;#34;, &amp;#34;s3:PutObject&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 class="n"> resources&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;arn:aws:s3:::myapp-artifacts/*&amp;#34;&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;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>這段 policy 只允許對 &lt;code>myapp-artifacts&lt;/code> 這一個 bucket 做讀寫。如果寫成 &lt;code>resources = [&amp;quot;*&amp;quot;]&lt;/code>，同一把身分被攻破時，攻擊者就能讀寫帳號內所有 bucket — 差別不在語法，在 &lt;code>Resource&lt;/code> 欄位收到多緊。&lt;/p>
&lt;h3 id="role臨時身分的載體">role：臨時身分的載體&lt;/h3>
&lt;p>role 本身不持有長期密碼。它靠 trust policy（信任政策）定義「誰能假扮我」，靠 permissions policy 定義「假扮後能做什麼」。trust policy 和 permissions policy 是兩份獨立的文件，分別回答「誰進得來」與「進來後能做什麼」。&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"># trust policy：只允許 ECS 服務假扮此 role
&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">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;ecs_trust&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="k">statement&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"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sts:AssumeRole&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">principals&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="s2">&amp;#34;Service&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="n"> identifiers&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ecs-tasks.amazonaws.com&amp;#34;&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;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&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_iam_role&amp;#34; &amp;#34;api_task&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"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;api-task-prod&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="n"> assume_role_policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">ecs_trust&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>trust policy 裡的 &lt;code>principals&lt;/code> 決定能進門的身分。上面這段把進門權限限給 ECS 服務本身，意味著只有跑在 ECS 上的 task 才能取得這個 role 的臨時憑證 — 一個在本地筆電跑的程式呼叫 &lt;code>AssumeRole&lt;/code> 會被拒絕。&lt;/p>
&lt;h2 id="最小權限持續收斂而非一次設定">最小權限：持續收斂而非一次設定&lt;/h2>
&lt;p>最小權限（least privilege）是貫穿整套系統的設計原則：一個身分只應該拿到完成它本職工作所需的最小權限集合。多一個 action 是多一條攻擊面，多一個 resource 是多一個爆炸半徑。&lt;/p>
&lt;p>最小權限是持續收斂的過程，而非一次設定就結束的靜態狀態。服務初期常為了快速上線給寬鬆權限 — 一個新的 ECS task role 掛上 &lt;code>AmazonS3FullAccess&lt;/code> 讓它能跑起來，半年後這個 role 實際只用了 &lt;code>s3:GetObject&lt;/code> 和 &lt;code>s3:PutObject&lt;/code> 兩個 action、針對一個 bucket，但 policy 裡寫的還是全部 S3 操作對所有 bucket。&lt;/p>
&lt;p>收斂的工具是 access analyzer。AWS IAM Access Analyzer 能分析 CloudTrail 日誌，列出某個 role 在過去 N 天內實際用了哪些 action 與 resource，據此產出一份建議的最小 policy。用它的步驟是：開著寬 policy 跑一段時間 → 用 access analyzer 產出實際使用清單 → 把 policy 收斂到這份清單 → 確認服務仍正常。&lt;/p></description><content:encoded><![CDATA[<p>權限一旦散落，後面每一層都建在沙上。網路收斂得再好，只要一把權限過大的長期憑證流出，攻擊者就能繞過所有邊界直接動到核心資源；環境分得再乾淨，只要 production 跟 staging 共用同一組身分，一次誤操作就跨環境炸開。身分與憑證是地基層最先該收斂的能力，因為它決定了「誰能動什麼」這個問題有沒有可信的答案。</p>
<h2 id="iam-的心智模型">IAM 的心智模型</h2>
<p>IAM（Identity and Access Management）是雲端平台用來回答「某個身分能不能對某個資源做某件事」的授權系統。它把授權拆成三個獨立的零件：identity（身分，發起動作的主體）、policy（政策，描述允許或拒絕的規則）、role（角色，一組可以被臨時取得的權限集合）。理解這三者的分工，是後面所有憑證決策的前提。</p>
<h3 id="identity長期主體-vs-臨時假扮">identity：長期主體 vs 臨時假扮</h3>
<p>identity 分兩類，這個區分在後面設計權限邊界時會反覆用到。一類是 user，代表一個長期存在的主體，通常對應到一個真人或一個固定的服務帳號，本身可以持有長期憑證（密碼或 access key）。另一類是 role，代表一組權限的暫時授予 — 沒有自己的長期密碼，而是讓某個被信任的身分「假扮（assume）」成它、換取一段有時效的臨時憑證。</p>
<p>把 identity 想成「護照」和「通行證」的差別：user 是護照，長期有效、全程攜帶；role 是通行證，到了管制區域臨時換發、離開就失效。多數安全事故源自於把通行證當護照用 — 某個 role 被長期假扮且從未被撤回，或某個 user 持有永不輪替的 access key。</p>
<h3 id="policy描述允許對什麼做什麼">policy：描述「允許對什麼做什麼」</h3>
<p>policy 是貼在 user 或 role 上的規則文件，列出 <code>Action</code>（能做什麼，如 <code>s3:GetObject</code>）、<code>Resource</code>（對哪個資源，如特定 bucket 的 ARN）、<code>Effect</code>（Allow 或 Deny）。一條 policy 可以包含多個 statement，每條 statement 描述一組操作許可。</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"># 最小權限範例：CI 只能讀寫特定 bucket，不給整個 S3
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">data</span> <span class="s2">&#34;aws_iam_policy_document&#34; &#34;ci_artifacts&#34;</span> {
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">statement</span> {
</span></span><span class="line"><span class="ln">4</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">5</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">6</span><span class="cl"><span class="n">    resources</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;arn:aws:s3:::myapp-artifacts/*&#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></code></pre></div><p>這段 policy 只允許對 <code>myapp-artifacts</code> 這一個 bucket 做讀寫。如果寫成 <code>resources = [&quot;*&quot;]</code>，同一把身分被攻破時，攻擊者就能讀寫帳號內所有 bucket — 差別不在語法，在 <code>Resource</code> 欄位收到多緊。</p>
<h3 id="role臨時身分的載體">role：臨時身分的載體</h3>
<p>role 本身不持有長期密碼。它靠 trust policy（信任政策）定義「誰能假扮我」，靠 permissions policy 定義「假扮後能做什麼」。trust policy 和 permissions policy 是兩份獨立的文件，分別回答「誰進得來」與「進來後能做什麼」。</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"># trust policy：只允許 ECS 服務假扮此 role
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">data</span> <span class="s2">&#34;aws_iam_policy_document&#34; &#34;ecs_trust&#34;</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">statement</span> {
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">    actions</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;sts:AssumeRole&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">principals</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="s2">&#34;Service&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">      identifiers</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;ecs-tasks.amazonaws.com&#34;</span><span class="p">]</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></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_iam_role&#34; &#34;api_task&#34;</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="s2">&#34;api-task-prod&#34;</span>
</span></span><span class="line"><span class="ln">14</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_trust</span><span class="p">.</span><span class="k">json</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">}</span></span></code></pre></div><p>trust policy 裡的 <code>principals</code> 決定能進門的身分。上面這段把進門權限限給 ECS 服務本身，意味著只有跑在 ECS 上的 task 才能取得這個 role 的臨時憑證 — 一個在本地筆電跑的程式呼叫 <code>AssumeRole</code> 會被拒絕。</p>
<h2 id="最小權限持續收斂而非一次設定">最小權限：持續收斂而非一次設定</h2>
<p>最小權限（least privilege）是貫穿整套系統的設計原則：一個身分只應該拿到完成它本職工作所需的最小權限集合。多一個 action 是多一條攻擊面，多一個 resource 是多一個爆炸半徑。</p>
<p>最小權限是持續收斂的過程，而非一次設定就結束的靜態狀態。服務初期常為了快速上線給寬鬆權限 — 一個新的 ECS task role 掛上 <code>AmazonS3FullAccess</code> 讓它能跑起來，半年後這個 role 實際只用了 <code>s3:GetObject</code> 和 <code>s3:PutObject</code> 兩個 action、針對一個 bucket，但 policy 裡寫的還是全部 S3 操作對所有 bucket。</p>
<p>收斂的工具是 access analyzer。AWS IAM Access Analyzer 能分析 CloudTrail 日誌，列出某個 role 在過去 N 天內實際用了哪些 action 與 resource，據此產出一份建議的最小 policy。用它的步驟是：開著寬 policy 跑一段時間 → 用 access analyzer 產出實際使用清單 → 把 policy 收斂到這份清單 → 確認服務仍正常。</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"># 產出建議 policy：分析 api-task-prod role 過去 90 天的實際用量</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">aws accessanalyzer generate-policy <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --policy-generation-details <span class="s1">&#39;{
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s1">    &#34;principalArn&#34;: &#34;arn:aws:iam::123456789012:role/api-task-prod&#34;,
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s1">    &#34;cloudTrailDetails&#34;: {
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s1">      &#34;trailArn&#34;: &#34;arn:aws:cloudtrail:ap-northeast-1:123456789012:trail/main&#34;,
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s1">      &#34;startTime&#34;: &#34;2026-03-01T00:00:00Z&#34;,
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s1">      &#34;endTime&#34;: &#34;2026-06-01T00:00:00Z&#34;
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s1">    }
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s1">  }&#39;</span></span></span></code></pre></div><p>一個快速的盤點方式：列出所有掛著 <code>AdministratorAccess</code>、<code>PowerUserAccess</code>、<code>*FullAccess</code> 這類寬鬆 managed policy 的 role，每個命中都問一次「這個 role 確實需要這些權限嗎」。CI role 的 policy 裡出現 <code>*:*</code> 更是明確的收斂目標。</p>
<h2 id="長期-access-key-的風險">長期 access key 的風險</h2>
<p>長期 access key 是一組沒有到期時間的靜態憑證（access key ID + secret），任何持有它的人或程式都能以對應身分的全部權限呼叫 API，直到有人手動撤銷為止。它最大的問題是「沒有時效」這個性質本身，會在三個方向上累積風險，而且風險隨團隊規模與時間單調上升。</p>
<h3 id="散落">散落</h3>
<p>長期 key 為了被程式使用，會被複製進 <code>.env</code> 檔、CI 設定、本機 <code>~/.aws/credentials</code>、Slack 訊息、甚至誤推進 git 歷史。每多一個副本就多一個外洩點。一把 key 在半年內可能被貼到六個地方 — 部署腳本、兩個 CI 平台的環境變數、某台共用跳板機的 profile、一封交接信、一位已離職同事的筆電 — 而這六個副本沒有任何中央清單能列舉。</p>
<h3 id="權限過大">權限過大</h3>
<p>因為輪替麻煩，團隊傾向給一把 key 配足夠寬的權限「一次搞定」。建立時圖方便掛了 AdministratorAccess，打算「等穩定了再收斂」，但那天從來沒有到來。於是一把本來只該讀 artifact 的 key 同時握有刪除 production 資料庫的能力。</p>
<h3 id="難以輪替">難以輪替</h3>
<p>輪替一把長期 key 意味著找出所有副本、同步替換、確認沒有遺漏。這個成本高到讓多數團隊選擇拖延，於是 key 的有效期變成「無限」，外洩後的曝險窗口也跟著變成無限。用一個問題辨認風險：能不能在五分鐘內回答「這把 key 被用在哪些地方、上次輪替是什麼時候」？答不出來，它就已經是技術債。</p>
<p>常見的散落路徑：部署腳本使用的 admin key 留在 CI 環境變數，建立者離職後沒人知道這把 key 的存在與權限範圍。這類情境的風險在於外洩後沒有手段限制影響範圍 — key 的權限有多大，影響範圍就有多大。用 credential report 定期盤點帳號內所有 access key 的建立時間與使用時間，見<a href="/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一：還沒有 infra 的環境</a>。</p>
<p>長期憑證風險的實際規模可以從兩個案例看到。Snowflake 2024 事件中，攻擊者利用外洩的長期憑證登入缺少 MFA 的客戶環境，執行大量資料匯出，造成跨客戶的資料竊取與勒索（見 <a href="/blog/backend/07-security-data-protection/red-team/cases/data-exfiltration/snowflake-2024-credential-abuse/" data-link-title="7.R7.4.2 Snowflake 2024：憑證濫用與資料竊取" data-link-desc="外洩憑證與 MFA 缺口如何在資料平台形成高風險外送事件">Snowflake 2024：憑證濫用與資料竊取</a>）。LastPass 2022 事件則顯示備份路徑的憑證管理缺口會讓影響範圍沿信任鏈擴散——開發環境取得的資訊被用來存取雲端備份，整條路徑的金鑰隔離不足是根因（見 <a href="/blog/backend/07-security-data-protection/red-team/cases/data-exfiltration/lastpass-2022-backup-chain/" data-link-title="7.R7.4.1 LastPass 2022：備份路徑與鏈式入侵" data-link-desc="開發環境資訊外流如何沿著備份路徑擴大成資料風險">LastPass 2022：備份路徑與鏈式入侵</a>）。兩個案例的共同教訓是：長期憑證的風險不止於外洩本身，而在於外洩後缺乏限制影響範圍的機制。</p>
<h2 id="oidc給-cicd-的短期憑證">OIDC：給 CI/CD 的短期憑證</h2>
<p>OIDC（OpenID Connect）聯合讓 CI/CD 平台用一段每次執行才簽發、幾分鐘後就失效的短期憑證取代長期 key，從根本上消掉「靜態密鑰散落」這個問題。它的運作方式是建立信任關係：雲端帳號信任某個外部 identity provider（如 GitHub Actions 的 OIDC issuer），當管線執行時，CI 平台簽發一個帶有可驗證 claim 的 token（描述「這是哪個 repo、哪個 branch、哪個 workflow 在跑」），雲端用這個 token 換出一段臨時憑證。沒有任何長期 secret 需要被儲存在 CI 設定裡。</p>
<h3 id="trust-policy-的收斂">trust policy 的收斂</h3>
<p>關鍵設計在 role 的 trust policy 上 — 它規定「哪個外部身分被允許假扮成這個 role」。trust policy 要用 token 的 claim 把假扮條件收到最緊。</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"># OIDC trust policy：只允許特定 repo 的 main branch 假扮此 role
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">data</span> <span class="s2">&#34;aws_iam_policy_document&#34; &#34;ci_trust&#34;</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">statement</span> {
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">    actions</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;sts:AssumeRoleWithWebIdentity&#34;</span><span class="p">]</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">principals</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="s2">&#34;Federated&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">      identifiers</span> <span class="o">=</span> <span class="p">[</span><span class="k">aws_iam_openid_connect_provider</span><span class="p">.</span><span class="k">github</span><span class="p">.</span><span class="k">arn</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><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">condition</span> {
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">      test</span>     <span class="o">=</span> <span class="s2">&#34;StringEquals&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">      variable</span> <span class="o">=</span> <span class="s2">&#34;token.actions.githubusercontent.com:aud&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">      values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;sts.amazonaws.com&#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></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">condition</span> {
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="n">      test</span>     <span class="o">=</span> <span class="s2">&#34;StringLike&#34;</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="n">      variable</span> <span class="o">=</span> <span class="s2">&#34;token.actions.githubusercontent.com:sub&#34;</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="n">      values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;repo:my-org/my-app:ref:refs/heads/main&#34;</span><span class="p">]</span>
</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></span><span class="line"><span class="ln">23</span><span class="cl">}</span></span></code></pre></div><p>每個 condition 各守一段邊界。<code>aud</code> 的 <code>StringEquals</code> 確認 token 是發給 AWS STS 的（防止用錯 audience 的 token 闖入）。<code>sub</code> 的 <code>StringLike</code> 把假扮限定在特定 repo 的 main branch — 設成 <code>repo:my-org/*</code> 等於讓組織內任何 repo 的任何 branch 都能假扮這個 role，這是常見的設定陷阱。</p>
<p>收斂 trust policy 的判讀問法是：「如果 my-org 底下某個公開 fork 跑了一個惡意 workflow，它能不能假扮這個 role？」如果答案是能，<code>sub</code> 條件就太鬆了。</p>
<h3 id="分離-plan-與-apply-的-role">分離 plan 與 apply 的 role</h3>
<p>進一步的收斂是替 <code>plan</code> 和 <code>apply</code> 分別建立 role。plan 只需要唯讀存取（讀 state、讀雲端現況），apply 需要寫入權限。把兩者分成獨立 role，讓 PR 階段的 CI 用唯讀 role 跑 plan、合併後才用寫入 role 跑 apply。任何拿到 plan role 的 token 無法修改基礎設施。</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"># plan role：只需讀取 state 與雲端現況
</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_iam_role&#34; &#34;ci_plan&#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;infra-ci-plan&#34;</span>
</span></span><span class="line"><span class="ln"> 4</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">ci_trust</span><span class="p">.</span><span class="k">json</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_iam_role_policy_attachment&#34; &#34;ci_plan_read&#34;</span> {
</span></span><span class="line"><span class="ln"> 8</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">ci_plan</span><span class="p">.</span><span class="k">name</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  policy_arn</span> <span class="o">=</span> <span class="s2">&#34;arn:aws:iam::aws:policy/ReadOnlyAccess&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">}<span class="c1">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># apply role：需要寫入權限，trust policy 限定只有 main branch
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span><span class="k">resource</span> <span class="s2">&#34;aws_iam_role&#34; &#34;ci_apply&#34;</span> {
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">  name</span>               <span class="o">=</span> <span class="s2">&#34;infra-ci-apply&#34;</span>
</span></span><span class="line"><span class="ln">15</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">ci_trust_main_only</span><span class="p">.</span><span class="k">json</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">}</span></span></code></pre></div><p>這一章把 role 與 trust policy 設計好，OIDC 的實際回報要到<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>建管線時才兌現 — 屆時管線用這裡定義好的 role 取得短期權限執行 <code>plan</code> 與 <code>apply</code>，CI 環境裡不需要存任何 access key。</p>
<h2 id="權限邊界設計">權限邊界設計</h2>
<p>權限邊界是把不同類型的身分與不同環境之間的權限刻意隔開，讓任何一個身分被攻破時，爆炸半徑都被限制在它本職的範圍內。邊界設計有兩條軸線需要分別處理：人 vs 機器，以及環境之間。</p>
<h3 id="人-vs-機器">人 vs 機器</h3>
<p>兩者的存取模式根本不同，混在同一個身分上會同時喪失兩邊的保護。</p>
<p>人類身分需要互動式登入、應該強制 MFA、權限隨職責變動，且通常透過 SSO 集中管理。機器身分（CI runner、ECS task、Lambda function）需要的是程式化、無人值守的存取，應該用 role 假扮取得短期憑證，永遠不該配長期 key。</p>
<p>機器身分還要再依「跑在哪裡」分兩類。跑在雲上的 workload（EC2 instance、ECS task、Lambda）由平台直接把 role 綁在執行環境上 — AWS 用 instance profile 把 role 掛在 EC2、用 task role 掛在 ECS task，workload 從實例 metadata 端點自動取得輪替的短期憑證。跑在雲外的 CI/CD（GitHub Actions、GitLab CI）拿不到實例 metadata，需要前面那套 OIDC 信任關係換憑證。</p>
<p>一個常見陷阱是工程師用自己的個人 key 跑自動化腳本 — 這把人的廣泛權限直接送進了無人值守的執行環境，MFA 保護形同虛設（API 呼叫不需要 MFA challenge），權限範圍比任何 CI role 都大。</p>
<h3 id="環境之間">環境之間</h3>
<p>環境之間的邊界，目的是讓 production 的權限與 staging、dev 完全不交叉。驗證邊界的方式是用 dev 環境的 CI role 嘗試列出或刪除 production 的資源——能做到，就代表邊界沒有建立。</p>
<h4 id="帳號級護欄scp">帳號級護欄：SCP</h4>
<p>Organizations 把環境拆成獨立帳號，再用 SCP（Service Control Policy）對整個帳號或組織單位設定權限天花板，連帳號內的管理員都越不過去。SCP 是 deny-based 的頂層限制 — 它不授予任何權限，只限制「即使有人給了權限也不准做」。</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;Version&#34;</span><span class="p">:</span> <span class="s2">&#34;2012-10-17&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nt">&#34;Statement&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="nt">&#34;Sid&#34;</span><span class="p">:</span> <span class="s2">&#34;DenyLeaveOrg&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">      <span class="nt">&#34;Effect&#34;</span><span class="p">:</span> <span class="s2">&#34;Deny&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="nt">&#34;Action&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;organizations:LeaveOrganization&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">      <span class="nt">&#34;Resource&#34;</span><span class="p">:</span> <span class="s2">&#34;*&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">      <span class="nt">&#34;Sid&#34;</span><span class="p">:</span> <span class="s2">&#34;DenyDisableCloudTrail&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">      <span class="nt">&#34;Effect&#34;</span><span class="p">:</span> <span class="s2">&#34;Deny&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">      <span class="nt">&#34;Action&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="s2">&#34;cloudtrail:StopLogging&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="s2">&#34;cloudtrail:DeleteTrail&#34;</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="nt">&#34;Resource&#34;</span><span class="p">:</span> <span class="s2">&#34;*&#34;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這份 SCP 掛在整個組織底下的所有帳號上，確保任何帳號都不能關閉稽核日誌或退出組織 — 即使該帳號裡有人持有 AdministratorAccess。SCP 的定位是組織層的不可踰越底線。</p>
<h4 id="role-級護欄permissions-boundary">Role 級護欄：Permissions Boundary</h4>
<p>Permissions Boundary 是掛在單一 role 上的權限上限。它跟 SCP 的差別在粒度：SCP 管整個帳號，Permissions Boundary 管單一身分。即使有人後來給一個 role 貼了過寬的 policy，Boundary 也會擋住超出上限的部分。</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"># Permissions Boundary：CI role 最多只能操作特定服務
</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_iam_policy&#34; &#34;ci_boundary&#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;ci-boundary-prod&#34;</span>
</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></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">        Action</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;ecs:*&#34;, &#34;ecr:*&#34;, &#34;s3:*&#34;, &#34;logs:*&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">        Resource</span> <span class="o">=</span> <span class="s2">&#34;*&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">      }<span class="p">,</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">        Effect</span>   <span class="o">=</span> <span class="s2">&#34;Deny&#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="p">[</span><span class="s2">&#34;iam:*&#34;, &#34;organizations:*&#34;, &#34;account:*&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">        Resource</span> <span class="o">=</span> <span class="s2">&#34;*&#34;</span>
</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="p">]</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  }<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><span class="line"><span class="ln">21</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_iam_role&#34; &#34;ci_apply&#34;</span> {
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="n">  name</span>                 <span class="o">=</span> <span class="s2">&#34;infra-ci-apply&#34;</span>
</span></span><span class="line"><span class="ln">23</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">ci_trust</span><span class="p">.</span><span class="k">json</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="n">  permissions_boundary</span> <span class="o">=</span> <span class="k">aws_iam_policy</span><span class="p">.</span><span class="k">ci_boundary</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">}</span></span></code></pre></div><p>SCP 與 Permissions Boundary 疊起來的效果是：SCP 在帳號層鎖住最危險的操作（關日誌、退組織），Boundary 在 role 層限制單一身分最多能做什麼，permissions policy 在這兩層天花板之內授予實際需要的權限。三者各管一層，缺一層就少一道屏障。</p>
<p>身分控制面本身的韌性在兩個案例中被檢驗。Azure AD 2021 事件中，身分服務的控制面故障導致所有依賴身份驗證的服務同時受影響，事故處理需要在身份恢復與服務降級策略之間排優先序（見 <a href="/blog/backend/07-security-data-protection/cases/azure-ad-identity-control-plane-2021/" data-link-title="7.C3 Azure AD：2021 Identity Control-plane 事件" data-link-desc="身分控制面事件如何影響多服務信任鏈與回復優先序。">Azure AD：Identity Control-plane 事件</a>）。Microsoft Storm-0558 事件則顯示簽章金鑰一旦失守，token 驗證的信任鏈會跨租戶失效，修復不只是修補漏洞、而是重建整條 key lifecycle 與 issuer 驗證流程（見 <a href="/blog/backend/07-security-data-protection/cases/microsoft-storm-0558-signing-key-2023/" data-link-title="7.C4 Microsoft：Storm-0558 簽章金鑰事件" data-link-desc="簽章金鑰事件如何回寫 identity 信任邊界與觀測證據鏈。">Microsoft：Storm-0558 簽章金鑰事件</a>）。這兩個案例揭露的是：權限邊界只管「某個身分能做什麼」，但身分系統本身的控制面如果失效，所有建立在它之上的邊界都跟著失效。</p>
<p>環境隔離的更完整實作（帳號結構、模組化參數）會在<a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>展開。</p>
<h2 id="身分層-vs-應用層-secret-的邊界">身分層 vs 應用層 secret 的邊界</h2>
<p>這一章談的是身分與憑證 — 誰是誰、怎麼證明、能動什麼。憑證背後引用的應用層 secret（資料庫密碼、第三方 API key）怎麼安全儲存與注入，屬於<a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>的 secret management 範圍。兩者的交集是：身分層決定「誰能讀到 secret store」，secret 層決定「secret 怎麼存與輪替」。把 IAM role 的 policy 收到只能讀取該服務路徑下的 secret（如 <code>prod/payments/*</code>），是同時落實最小權限與 secret 隔離的結合點。</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>的範圍。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一：還沒有 infra 的環境</a>：長期 key 盤點與護欄</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/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</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>：CI/CD 管線用 OIDC 取得短期權限</li>
<li>→ <a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>：應用層 secret 的儲存與引用</li>
<li>→ <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 模組七：資安與資料保護</a>：Secret Management 與憑證管理交集</li>
<li>→ <a href="/blog/infra/02-identity-credentials/access-key-rotation-playbook/" data-link-title="Access Key 輪替手冊" data-link-desc="從 credential report 盤點散落的長期 access key，到逐把輪替、自動化輪替與 key age 監控的完整操作步驟">Access Key 輪替手冊</a>：key 盤點與輪替的操作步驟</li>
<li>→ <a href="/blog/infra/02-identity-credentials/oidc-trust-policy-setup/" data-link-title="OIDC Trust Policy 設定指南" data-link-desc="GitHub Actions 與 AWS 之間的 OIDC 聯合設定：建立 provider、設計 trust policy 的 claim 收斂、plan 與 apply role 分離、常見錯誤排查">OIDC Trust Policy 設定指南</a>：GitHub Actions OIDC 的 step-by-step 設定</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>無 SSH 的 FTP / 面板管理環境接管</title><link>https://tarrragon.github.io/blog/infra/takeover/legacy-ftp-no-ssh/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/takeover/legacy-ftp-no-ssh/</guid><description>&lt;p>接手一個只有 FTP 和網頁面板（cPanel / Plesk / phpMyAdmin）存取的 PHP 專案時，面對的約束跟有 SSH 的環境不同：沒辦法登入下指令、沒有 CLI 工具可以批次操作、部署靠 FTP 上傳檔案、資料庫操作靠 phpMyAdmin 的網頁介面。這類環境常見於共享主機，但也可能出現在只安裝了面板的獨立主機或 VPS 上。前一位維護者的「文件」是他的記憶，而這份記憶已經隨著人一起離開。第一步是穩定維運，不是現代化改造。&lt;/p>
&lt;p>這篇文章的操作順序按風險排列：先做不碰 prod 的盤點（零風險），再建本地開發環境（只動本機），然後才是碰 prod 的部署與資料庫紀律。&lt;/p>
&lt;h2 id="拍下完整現況不動-prod">拍下完整現況（不動 prod）&lt;/h2>
&lt;p>接手後的第一個工作日只做一件事：把 prod 的完整狀態拍一份下來存到本地。這一步不改 prod 的任何東西，目的是讓自己手上有一份可對照的快照。&lt;/p>
&lt;p>環境不同，拍照的工具和流程不同。先判斷自己的情境：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>有 cPanel / Plesk 完整備份功能&lt;/strong> → &lt;a href="#%e7%94%a8%e4%b8%bb%e6%a9%9f%e9%9d%a2%e6%9d%bf%e4%b8%80%e6%ac%a1%e6%89%93%e5%8c%85">用主機面板一次打包&lt;/a>&lt;/li>
&lt;li>&lt;strong>只有 FTP 存取&lt;/strong> → &lt;a href="#%e7%94%a8-ftp-%e9%80%90%e5%b1%a4%e6%8b%8d%e7%85%a7">用 FTP 逐層拍照&lt;/a>&lt;/li>
&lt;li>&lt;strong>有 SSH 存取&lt;/strong>（部分 VPS 或獨立主機）→ 改讀&lt;a href="https://tarrragon.github.io/blog/infra/takeover/cloud-no-iac/" data-link-title="有 SSH 但沒有 IaC 的雲端環境接管" data-link-desc="接手一個全手動建立的雲端環境時，怎麼盤點資源、推導依賴關係、收斂 credential、驗證備份、建立變更紀律，以及什麼時候該開始導入 IaC">有 SSH 但沒有 IaC 的雲端環境接管&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="用主機面板一次打包">用主機面板一次打包&lt;/h3>
&lt;p>如果主機有 cPanel，「備份精靈（Backup Wizard）」可以一次打包程式碼 + 資料庫 + email 設定 + cron jobs，是最快的完整快照方式。Plesk 的對應功能在「工具與設定 → 備份管理員」。&lt;/p>
&lt;p>面板備份通常包含：網站檔案（含隱藏檔）、所有 MySQL 資料庫、email 帳戶與轉寄規則、cron job 設定、DNS zone 記錄。下載打包檔後解壓到本地、用 Git 初始化（見下方「初始化 Git repo」段）。&lt;/p>
&lt;p>面板備份可能不包含的：SSL 憑證的私鑰（Let&amp;rsquo;s Encrypt 自動續期的通常不需要手動備份）、PHP 版本與模組設定（需要另外記錄，見&lt;a href="#%e7%92%b0%e5%a2%83%e8%a8%ad%e5%ae%9a%e7%9a%84%e6%8b%8d%e7%85%a7">環境設定的拍照&lt;/a>）、&lt;code>.htaccess&lt;/code> 以外的 Apache/LiteSpeed 自訂設定。拿到面板備份後仍然要跑「環境設定的拍照」段，因為面板備份拍的是檔案、不是環境設定。&lt;/p>
&lt;h3 id="用-ftp-逐層拍照">用 FTP 逐層拍照&lt;/h3>
&lt;p>沒有主機面板（或面板不提供完整備份）時，要用 FTP 和 phpMyAdmin 分別拍程式碼和資料庫。&lt;/p>
&lt;p>&lt;strong>程式碼與靜態資源&lt;/strong>：用 FTP client 把整個網站目錄鏡像到本地。FileZilla 的操作路徑：站台管理員連線後，在遠端面板對根目錄按右鍵 → 「下載」，或用「伺服器 → 同步瀏覽」模式讓本地與遠端目錄結構保持對齊。WinSCP 提供「保持更新（Keep Remote Directory up to Date）」功能，但接手階段只需要一次性的完整下載，不需要持續同步。下載前確認 FTP client 的設定有勾選「顯示隱藏檔案」——&lt;code>.htaccess&lt;/code>、&lt;code>.env&lt;/code>、&lt;code>.user.ini&lt;/code> 這類隱藏檔經常包含關鍵設定。&lt;/p>
&lt;p>&lt;strong>資料庫&lt;/strong>：用 phpMyAdmin 的「匯出」功能匯出完整資料庫（詳見下方「資料庫」段）。FTP 只拍程式碼，資料庫要另外匯出。&lt;/p>
&lt;h3 id="初始化-git-repo">初始化 Git repo&lt;/h3>
&lt;p>不論用面板備份還是 FTP 逐層拍，拿到檔案後都初始化成 Git repo：&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 project-takeover &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nb">cd&lt;/span> project-takeover
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1"># FTP 下載完整站台到此目錄後&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">git init
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">git add -A
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">git commit -m &lt;span class="s2">&amp;#34;initial snapshot from production FTP&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 commit 是接手的基準線。之後任何改動都能 diff 回這個起點，知道自己改了什麼。&lt;/p>
&lt;h3 id="資料庫">資料庫&lt;/h3>
&lt;p>用 phpMyAdmin 的「匯出」功能：選「自訂」模式 → 勾選所有資料表 → 格式選 SQL → 勾選「加入 DROP TABLE / VIEW / PROCEDURE / FUNCTION / EVENT / TRIGGER 敘述」（讓匯入時能乾淨覆蓋）→ 壓縮選 gzip（大型資料庫避免瀏覽器逾時）→ 編碼選 UTF-8 → 執行。&lt;/p>
&lt;p>phpMyAdmin 的匯出在資料庫超過幾百 MB 時容易因 PHP &lt;code>max_execution_time&lt;/code> 或記憶體限制中斷。替代方案：如果主機有 cPanel，「phpMyAdmin → 匯出」旁邊通常有「MySQL 資料庫備份」或透過 cPanel API 的 &lt;code>mysqldump&lt;/code> 介面，比 phpMyAdmin 的 PHP 層匯出更可靠。另一個選項是本地安裝 DBeaver（免費、跨平台）或 TablePlus（macOS/Windows），用主機提供的遠端 MySQL 連線（cPanel → 遠端 MySQL → 加入本機 IP 白名單）直接從本機執行 &lt;code>mysqldump&lt;/code>。HeidiSQL（Windows 免費）也支援同樣的遠端連線匯出。&lt;/p></description><content:encoded><![CDATA[<p>接手一個只有 FTP 和網頁面板（cPanel / Plesk / phpMyAdmin）存取的 PHP 專案時，面對的約束跟有 SSH 的環境不同：沒辦法登入下指令、沒有 CLI 工具可以批次操作、部署靠 FTP 上傳檔案、資料庫操作靠 phpMyAdmin 的網頁介面。這類環境常見於共享主機，但也可能出現在只安裝了面板的獨立主機或 VPS 上。前一位維護者的「文件」是他的記憶，而這份記憶已經隨著人一起離開。第一步是穩定維運，不是現代化改造。</p>
<p>這篇文章的操作順序按風險排列：先做不碰 prod 的盤點（零風險），再建本地開發環境（只動本機），然後才是碰 prod 的部署與資料庫紀律。</p>
<h2 id="拍下完整現況不動-prod">拍下完整現況（不動 prod）</h2>
<p>接手後的第一個工作日只做一件事：把 prod 的完整狀態拍一份下來存到本地。這一步不改 prod 的任何東西，目的是讓自己手上有一份可對照的快照。</p>
<p>環境不同，拍照的工具和流程不同。先判斷自己的情境：</p>
<ul>
<li><strong>有 cPanel / Plesk 完整備份功能</strong> → <a href="#%e7%94%a8%e4%b8%bb%e6%a9%9f%e9%9d%a2%e6%9d%bf%e4%b8%80%e6%ac%a1%e6%89%93%e5%8c%85">用主機面板一次打包</a></li>
<li><strong>只有 FTP 存取</strong> → <a href="#%e7%94%a8-ftp-%e9%80%90%e5%b1%a4%e6%8b%8d%e7%85%a7">用 FTP 逐層拍照</a></li>
<li><strong>有 SSH 存取</strong>（部分 VPS 或獨立主機）→ 改讀<a href="/blog/infra/takeover/cloud-no-iac/" data-link-title="有 SSH 但沒有 IaC 的雲端環境接管" data-link-desc="接手一個全手動建立的雲端環境時，怎麼盤點資源、推導依賴關係、收斂 credential、驗證備份、建立變更紀律，以及什麼時候該開始導入 IaC">有 SSH 但沒有 IaC 的雲端環境接管</a></li>
</ul>
<h3 id="用主機面板一次打包">用主機面板一次打包</h3>
<p>如果主機有 cPanel，「備份精靈（Backup Wizard）」可以一次打包程式碼 + 資料庫 + email 設定 + cron jobs，是最快的完整快照方式。Plesk 的對應功能在「工具與設定 → 備份管理員」。</p>
<p>面板備份通常包含：網站檔案（含隱藏檔）、所有 MySQL 資料庫、email 帳戶與轉寄規則、cron job 設定、DNS zone 記錄。下載打包檔後解壓到本地、用 Git 初始化（見下方「初始化 Git repo」段）。</p>
<p>面板備份可能不包含的：SSL 憑證的私鑰（Let&rsquo;s Encrypt 自動續期的通常不需要手動備份）、PHP 版本與模組設定（需要另外記錄，見<a href="#%e7%92%b0%e5%a2%83%e8%a8%ad%e5%ae%9a%e7%9a%84%e6%8b%8d%e7%85%a7">環境設定的拍照</a>）、<code>.htaccess</code> 以外的 Apache/LiteSpeed 自訂設定。拿到面板備份後仍然要跑「環境設定的拍照」段，因為面板備份拍的是檔案、不是環境設定。</p>
<h3 id="用-ftp-逐層拍照">用 FTP 逐層拍照</h3>
<p>沒有主機面板（或面板不提供完整備份）時，要用 FTP 和 phpMyAdmin 分別拍程式碼和資料庫。</p>
<p><strong>程式碼與靜態資源</strong>：用 FTP client 把整個網站目錄鏡像到本地。FileZilla 的操作路徑：站台管理員連線後，在遠端面板對根目錄按右鍵 → 「下載」，或用「伺服器 → 同步瀏覽」模式讓本地與遠端目錄結構保持對齊。WinSCP 提供「保持更新（Keep Remote Directory up to Date）」功能，但接手階段只需要一次性的完整下載，不需要持續同步。下載前確認 FTP client 的設定有勾選「顯示隱藏檔案」——<code>.htaccess</code>、<code>.env</code>、<code>.user.ini</code> 這類隱藏檔經常包含關鍵設定。</p>
<p><strong>資料庫</strong>：用 phpMyAdmin 的「匯出」功能匯出完整資料庫（詳見下方「資料庫」段）。FTP 只拍程式碼，資料庫要另外匯出。</p>
<h3 id="初始化-git-repo">初始化 Git repo</h3>
<p>不論用面板備份還是 FTP 逐層拍，拿到檔案後都初始化成 Git 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">mkdir project-takeover <span class="o">&amp;&amp;</span> <span class="nb">cd</span> project-takeover
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># FTP 下載完整站台到此目錄後</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">git init
</span></span><span class="line"><span class="ln">4</span><span class="cl">git add -A
</span></span><span class="line"><span class="ln">5</span><span class="cl">git commit -m <span class="s2">&#34;initial snapshot from production FTP&#34;</span></span></span></code></pre></div><p>這個 commit 是接手的基準線。之後任何改動都能 diff 回這個起點，知道自己改了什麼。</p>
<h3 id="資料庫">資料庫</h3>
<p>用 phpMyAdmin 的「匯出」功能：選「自訂」模式 → 勾選所有資料表 → 格式選 SQL → 勾選「加入 DROP TABLE / VIEW / PROCEDURE / FUNCTION / EVENT / TRIGGER 敘述」（讓匯入時能乾淨覆蓋）→ 壓縮選 gzip（大型資料庫避免瀏覽器逾時）→ 編碼選 UTF-8 → 執行。</p>
<p>phpMyAdmin 的匯出在資料庫超過幾百 MB 時容易因 PHP <code>max_execution_time</code> 或記憶體限制中斷。替代方案：如果主機有 cPanel，「phpMyAdmin → 匯出」旁邊通常有「MySQL 資料庫備份」或透過 cPanel API 的 <code>mysqldump</code> 介面，比 phpMyAdmin 的 PHP 層匯出更可靠。另一個選項是本地安裝 DBeaver（免費、跨平台）或 TablePlus（macOS/Windows），用主機提供的遠端 MySQL 連線（cPanel → 遠端 MySQL → 加入本機 IP 白名單）直接從本機執行 <code>mysqldump</code>。HeidiSQL（Windows 免費）也支援同樣的遠端連線匯出。</p>
<p>把匯出的 <code>.sql</code> 檔存進 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">mkdir db-snapshots
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 把 phpMyAdmin 匯出的檔案存到這裡</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">mv ~/Downloads/production-dump.sql db-snapshots/<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>-initial.sql
</span></span><span class="line"><span class="ln">4</span><span class="cl">git add db-snapshots/
</span></span><span class="line"><span class="ln">5</span><span class="cl">git commit -m <span class="s2">&#34;initial database snapshot from phpMyAdmin&#34;</span></span></span></code></pre></div><p>如果主機面板有提供 <code>mysqldump</code> 的 web 介面（部分 cPanel 有），用那個比 phpMyAdmin 的匯出更可靠——phpMyAdmin 在大資料庫上容易因為 PHP 記憶體限制而中斷。</p>
<h3 id="環境資訊記錄">環境資訊記錄</h3>
<p>在 repo 根目錄建一份 <code>ENVIRONMENT.md</code>，記錄以下資訊：</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">## Production 環境
</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> **主機商**：[名稱]、方案：[方案名稱]
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="k">-</span> **PHP 版本**：cPanel/Plesk 的 PHP 設定頁直接顯示；沒有控制面板時，FTP 上傳一個 <span class="sb">`phpinfo.php`</span>（內容 <span class="sb">`&lt;?php phpinfo();`</span>）到站台根目錄、瀏覽器開啟後記錄版本、確認後立刻刪除（phpinfo 會暴露伺服器完整設定）
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">-</span> **MySQL 版本**：phpMyAdmin 首頁顯示
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="k">-</span> **Web server**：Apache / LiteSpeed / Nginx（控制面板或 response header）
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">-</span> **域名 / DNS**：誰管的、nameserver 指向哪裡
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">-</span> **SSL**：Let&#39;s Encrypt 自動續期 / 主機商代管 / 手動上傳
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="k">-</span> **Cron jobs**：控制面板 → Cron Jobs 頁面截圖或列表
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="k">-</span> **Email**：有沒有用主機的 email 服務、轉寄規則
</span></span><span class="line"><span class="ln">11</span><span class="cl">- <span class="ge">**</span>.htaccess**：已包含在 FTP 下載中（注意隱藏檔有沒有漏）</span></span></code></pre></div><h3 id="掃描-hardcoded-credential">掃描 hardcoded credential</h3>
<p>PHP 專案常見的做法是把資料庫密碼、API key 直接寫在 <code>config.php</code> 或 <code>wp-config.php</code> 裡。在本地 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">grep -rn <span class="s2">&#34;password\|passwd\|secret\|api_key\|apikey\|api_secret&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --include<span class="o">=</span><span class="s2">&#34;*.php&#34;</span> --include<span class="o">=</span><span class="s2">&#34;*.ini&#34;</span> --include<span class="o">=</span><span class="s2">&#34;*.env&#34;</span> .</span></span></code></pre></div><p>把找到的每一筆記錄下來：哪個檔案、什麼 credential、用在哪裡。這份清單是後續 credential 輪替的輸入。</p>
<h3 id="第三方整合清單">第三方整合清單</h3>
<p>翻 code 找出所有對外部服務的呼叫——金流（綠界、藍新、Stripe）、簡訊（Twilio、三竹）、Email（SendGrid、SMTP）、社群登入（Facebook、Google）、CDN、Analytics。每一個整合都有對應的 API key 或 webhook URL，這些都是接手後需要確認存取權的項目。</p>
<h3 id="環境設定的拍照">環境設定的拍照</h3>
<p>程式碼和資料庫之外，伺服器的執行環境本身也要記錄。非 container 環境沒有 <code>docker commit</code> 可以一次打包整台機器，要逐層拍：</p>
<p><strong>PHP 設定</strong>：在站台根目錄上傳一個 <code>phpinfo.php</code>（內容 <code>&lt;?php phpinfo();</code>），用瀏覽器打開後把完整輸出另存為 HTML 檔。記錄完立刻刪掉這個檔案——phpinfo 會暴露伺服器的完整設定與路徑。需要記錄的關鍵項：PHP 版本、載入的模組（<code>mysqli</code>、<code>curl</code>、<code>mbstring</code>、<code>gd</code>、<code>imagick</code>）、<code>upload_max_filesize</code>、<code>post_max_size</code>、<code>max_execution_time</code>、<code>memory_limit</code>、<code>error_reporting</code>、<code>session.save_handler</code>。這些值直接影響程式碼能不能在本地環境重現相同的行為。</p>
<p><strong>Cron jobs</strong>：cPanel 的 Cron Jobs 頁面或 Plesk 的排程工作清單，截圖或逐條抄到 <code>ENVIRONMENT.md</code>。每一條 cron 記錄三項：排程時間、執行的指令（通常是 <code>/usr/local/bin/php /home/user/public_html/cron.php</code>）、這條 cron 的業務用途（如果能從指令或檔案名推斷）。</p>
<p><strong>SSL 憑證</strong>：記錄域名、簽發者（Let&rsquo;s Encrypt / 自購 / 主機商代管）、到期日。瀏覽器的鎖頭圖示可以查看憑證詳情。從本機也可以用 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="nb">echo</span> <span class="p">|</span> openssl s_client -connect example.com:443 2&gt;/dev/null <span class="p">|</span> openssl x509 -noout -dates -issuer</span></span></code></pre></div><p>如果是 Let&rsquo;s Encrypt 自動續期，要確認續期機制是 cPanel 內建（AutoSSL）還是某個自訂 cron。手動購買的憑證要記錄到期日並設日曆提醒——過期後站台會直接出現瀏覽器安全警告。</p>
<p><strong>.htaccess 規則</strong>：<code>.htaccess</code> 可能散在多個目錄（根目錄、<code>uploads/</code>、<code>wp-admin/</code>、<code>api/</code>）。FTP 下載時已包含在內（前提是 FTP client 有設定顯示隱藏檔案），確認一下這些檔案都在 repo 裡。</p>
<p><strong>外部服務連線</strong>：除了前一節的第三方整合清單，用 grep 掃程式碼找出所有對外 URL。這些連線在未來遷移時要同步處理——搬了伺服器但 callback URL 沒改，金流通知就收不到。</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">grep -rn <span class="s2">&#34;https\?://&#34;</span> --include<span class="o">=</span><span class="s2">&#34;*.php&#34;</span> . <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  <span class="p">|</span> grep -v <span class="s2">&#34;localhost\|127\.0\.0\.1\|example\.com&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  <span class="p">|</span> sort -u &gt; _environment/external-urls.txt</span></span></code></pre></div><p><strong>檔案權限</strong>：FileZilla 的遠端檔案清單有權限欄。記錄 <code>uploads/</code>、<code>cache/</code>、<code>sessions/</code>、config 檔案的權限。777 的目錄是安全風險（任何使用者都能寫入），在多租戶的主機上尤其危險——同台主機的其他帳戶也能存取。</p>
<p>把以上資料存進 repo 的 <code>_environment/</code> 目錄：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">_environment/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── phpinfo-20260626.html      # phpinfo 完整輸出
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── cron-jobs.md               # cron 清單
</span></span><span class="line"><span class="ln">4</span><span class="cl">├── ssl-cert-info.txt          # 憑證資訊
</span></span><span class="line"><span class="ln">5</span><span class="cl">├── external-urls.txt          # 外部連線清單
</span></span><span class="line"><span class="ln">6</span><span class="cl">└── file-permissions.txt       # 目錄權限記錄</span></span></code></pre></div><p><code>_environment/</code> 可加進 <code>.gitignore</code>（phpinfo 含敏感資訊），或只 ignore HTML 檔、其餘進 Git。</p>
<h2 id="建立本地開發環境">建立本地開發環境</h2>
<p>本地能跑起來，才有安全的測試空間。目標是在本機重現 prod 的 PHP + MySQL 版本組合。</p>
<h3 id="選型docker-vs-本地堆疊">選型：Docker vs 本地堆疊</h3>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>平台</th>
          <th>費用</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Docker Compose</td>
          <td>跨平台</td>
          <td>免費</td>
          <td>最精確對齊 prod 版本，特別是 PHP 5.6/7.0 這類舊版本</td>
      </tr>
      <tr>
          <td>MAMP Pro</td>
          <td>macOS</td>
          <td>付費（約 $50/年）</td>
          <td>圖形介面切 PHP 版本，不熟 Docker 時最快上手</td>
      </tr>
      <tr>
          <td>Laragon</td>
          <td>Windows</td>
          <td>免費</td>
          <td>比 XAMPP 現代、內建 PHP 版本切換與虛擬網域</td>
      </tr>
      <tr>
          <td>XAMPP</td>
          <td>Windows / macOS / Linux</td>
          <td>免費</td>
          <td>最老牌、社群資源多，但 PHP 版本切換較麻煩</td>
      </tr>
      <tr>
          <td>Laravel Valet</td>
          <td>macOS</td>
          <td>免費</td>
          <td>輕量 CLI 為主，適合已經熟悉 CLI 的開發者</td>
      </tr>
      <tr>
          <td>ServBay</td>
          <td>macOS</td>
          <td>免費版可用</td>
          <td>較新、支援多 PHP 版本共存、內建資料庫管理</td>
      </tr>
  </tbody>
</table>
<p>選型判準：如果 prod 的 PHP 版本是 5.6 或 7.0 這類已停止維護的舊版，Docker 是唯一能精確對齊的選項——MAMP/XAMPP 通常只提供仍在維護的版本。常見版本（7.4、8.0、8.1、8.2）用 MAMP/Laragon 會比 Docker 更快跑起來。</p>
<h3 id="docker-方式">Docker 方式</h3>
<p>Docker Compose V2（<code>docker compose</code> 指令）不需要 <code>version</code> 欄位。如果使用舊版 <code>docker-compose</code> CLI，在檔案開頭加 <code>version: '3.8'</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># docker-compose.yml</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">web</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">php:8.1-apache</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">      </span>- <span class="l">./:/var/www/html</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span>- <span class="s2">&#34;8080:80&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">mysql:8.0</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">      </span><span class="nt">MYSQL_ROOT_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">localdev</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">      </span><span class="nt">MYSQL_DATABASE</span><span class="p">:</span><span class="w"> </span><span class="l">project</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">      </span>- <span class="l">./db-snapshots/initial.sql:/docker-entrypoint-initdb.d/init.sql</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">      </span>- <span class="s2">&#34;3306:3306&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">  </span><span class="nt">phpmyadmin</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">phpmyadmin/phpmyadmin</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w">      </span><span class="nt">PMA_HOST</span><span class="p">:</span><span class="w"> </span><span class="l">db</span><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="w">      </span>- <span class="s2">&#34;8081:80&#34;</span></span></span></code></pre></div><p>PHP 版本要對齊 prod。如果 prod 是 PHP 7.4，本地用 <code>php:7.4-apache</code>。版本差異會導致函式行為不同（<code>str_contains</code> 在 8.0 才有、<code>mysql_*</code> 系列在 7.0 移除），測試通過但 prod 壞掉。phpmyadmin service 讓本地也有跟 prod 相同的資料庫操作介面，方便驗證 phpMyAdmin 上要執行的操作。</p>
<h3 id="匯入資料庫">匯入資料庫</h3>
<p>Docker 啟動後匯入初始快照：</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">docker <span class="nb">exec</span> -i project-db-1 mysql -uroot -plocaldev project &lt; db-snapshots/20260626-initial.sql</span></span></code></pre></div><p>MAMP/Laragon/XAMPP 的匯入方式：開啟對應的 phpMyAdmin（通常在 <code>localhost/phpmyadmin</code>）→ 選資料庫 → 匯入 → 選 <code>.sql</code> 檔案 → 執行。或用 DBeaver/TablePlus 連本地 MySQL 後執行 SQL 檔。</p>
<h3 id="常見的本地跑不起來原因">常見的「本地跑不起來」原因</h3>
<table>
  <thead>
      <tr>
          <th>症狀</th>
          <th>原因</th>
          <th>修法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>白頁或 500</td>
          <td>config 裡寫了 prod 的絕對路徑</td>
          <td>改成相對路徑或用環境變數</td>
      </tr>
      <tr>
          <td>連不上資料庫</td>
          <td>DB host 寫了 <code>localhost</code> 但 Docker 裡 DB 是另一個容器</td>
          <td>改成 Docker service 名稱（<code>db</code>）</td>
      </tr>
      <tr>
          <td>某些功能壞掉</td>
          <td>prod 有裝特定 PHP extension（gd、mbstring、curl）</td>
          <td>Dockerfile 加 <code>docker-php-ext-install</code></td>
      </tr>
      <tr>
          <td>.htaccess rewrite 不生效</td>
          <td>Apache mod_rewrite 沒啟用</td>
          <td>Dockerfile 加 <code>a2enmod rewrite</code></td>
      </tr>
      <tr>
          <td>圖片上傳失敗</td>
          <td>上傳目錄權限不對</td>
          <td><code>chmod 777 uploads/</code>（僅限本地）</td>
      </tr>
  </tbody>
</table>
<p>本地能完整跑起來之後，這個環境就是所有變更的測試場。任何改動都先在這裡驗證。</p>
<h2 id="資料庫變更紀律">資料庫變更紀律</h2>
<p>phpMyAdmin 讓修改 prod DB 只需要幾次點擊，這正是它危險的原因——沒有 preview、沒有 undo、沒有 review。紀律要靠流程補上。</p>
<h3 id="變更流程">變更流程</h3>
<ol>
<li>在本地 DB 寫好 SQL 並執行，確認結果正確</li>
<li>把 SQL 存進 repo 的 <code>migrations/</code> 目錄，檔名帶日期：</li>
</ol>





<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"># migrations/2026-06-26-add-status-column.sql</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ALTER TABLE orders ADD COLUMN status VARCHAR<span class="o">(</span>20<span class="o">)</span> DEFAULT <span class="s1">&#39;pending&#39;</span><span class="p">;</span></span></span></code></pre></div><ol start="3">
<li>在 phpMyAdmin 上對要改的資料表做匯出（只匯出該表的結構 + 資料），存進 <code>db-snapshots/</code> 作為回退依據</li>
<li>在 phpMyAdmin 的 SQL 頁籤貼上已驗證的 SQL 執行</li>
<li>在 repo 的 <code>CHANGELOG.md</code> 記錄：時間、操作者、改了什麼、為什麼</li>
</ol>
<h3 id="高風險操作的額外防護">高風險操作的額外防護</h3>
<p>修改欄位型別、刪除欄位、刪除資料表、批次更新資料——這些操作在 phpMyAdmin 上執行就生效，沒有乾淨的 undo。額外防護是在執行前先確認：</p>
<ul>
<li>有沒有剛做的該資料表備份（不是上週的，是剛剛做的）</li>
<li>這張表有沒有 foreign key 或觸發器會連帶影響其他表</li>
<li>如果改錯了，回退的具體步驟是什麼（從備份 SQL 重建整張表？還是用 UPDATE 改回來？）</li>
</ul>
<h2 id="部署紀律">部署紀律</h2>
<p>FTP 部署沒有 CI pipeline 的自動化保護，但不代表不能有流程。流程的目標是讓每次部署都可追溯、可回退。</p>
<h3 id="部署步驟">部署步驟</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">1. git diff HEAD~1 --name-only          # 確認這次改了哪些檔案
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. 本地測試通過
</span></span><span class="line"><span class="ln">3</span><span class="cl">3. FTP client 開兩個窗格：左邊本地、右邊 prod
</span></span><span class="line"><span class="ln">4</span><span class="cl">4. 用 FileZilla 的目錄比較功能確認差異
</span></span><span class="line"><span class="ln">5</span><span class="cl">5. 只上傳有變更的檔案（不要整站覆蓋）
</span></span><span class="line"><span class="ln">6</span><span class="cl">6. 上傳完在瀏覽器驗證功能
</span></span><span class="line"><span class="ln">7</span><span class="cl">7. git tag deploy-20260626 &amp;&amp; git push   # 標記這次部署的版本</span></span></code></pre></div><h3 id="備份策略">備份策略</h3>
<p>無 SSH 的主機環境通常不提供自動快照。備份要自己做：</p>
<table>
  <thead>
      <tr>
          <th>備份項目</th>
          <th>頻率</th>
          <th>方式</th>
          <th>保留</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>程式碼</td>
          <td>每次部署前</td>
          <td>Git tag</td>
          <td>永久（在 repo 裡）</td>
      </tr>
      <tr>
          <td>資料庫</td>
          <td>每週 + 每次 schema 變更前</td>
          <td>phpMyAdmin 匯出</td>
          <td>至少保留 4 週</td>
      </tr>
      <tr>
          <td>上傳檔案</td>
          <td>每週</td>
          <td>FTP 下載 uploads/ 目錄</td>
          <td>至少保留 4 週</td>
      </tr>
      <tr>
          <td>主機設定</td>
          <td>每次變更</td>
          <td>控制面板截圖 + ENVIRONMENT.md 更新</td>
          <td>在 repo 裡</td>
      </tr>
  </tbody>
</table>
<p>如果主機面板有自動備份功能（cPanel 的 Backup Wizard），確認它有開並且能還原。但不要把它當唯一備份——主機商的備份可能在主機出問題時一起不見。</p>
<h3 id="備份自動化沒-ssh-也能做">備份自動化（沒 SSH 也能做）</h3>
<p>無 SSH 的環境沒有 cron + CLI 的組合，但可以用本機排程 + FTP client 的 CLI 模式達成自動化備份。</p>
<p>用 lftp（macOS/Linux 可透過 Homebrew 或 apt 安裝）做定期站台鏡像：</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"># backup.sh — 加入本機的 cron 或 launchd 每日執行</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">lftp -e <span class="s2">&#34;mirror --verbose /public_html/ /local/backup/site/; quit&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  -u username,password ftp.example.com</span></span></code></pre></div><p>rclone 是另一個選項，支援 FTP/SFTP 且有更好的增量同步（只傳有變更的檔案）：</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"># 設定 rclone remote（首次）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">rclone config  <span class="c1"># 選 FTP、填入主機資訊</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 同步（之後每次只傳差異）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">rclone sync myhost:/public_html/ /local/backup/site/ --progress</span></span></code></pre></div><p>macOS 用 launchd plist、Windows 用工作排程器（Task Scheduler）排定每日執行這些腳本，讓備份不再依賴人工記得。</p>
<p>資料庫的自動備份較受限——phpMyAdmin 沒有 CLI 介面。如果主機允許遠端 MySQL 連線，可以在本機 cron 裡加一條 <code>mysqldump</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">mysqldump -h mysql.example.com -u dbuser -p<span class="s1">&#39;password&#39;</span> dbname <span class="p">|</span> gzip &gt; /local/backup/db/<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>.sql.gz</span></span></code></pre></div><p>不允許遠端連線時，退而求其次：每週手動從 phpMyAdmin 匯出一次、存進 repo。</p>
<h3 id="回退方式">回退方式</h3>
<p>FTP 部署沒有 rollback 按鈕。回退的方式是：</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 checkout deploy-20260625 -- path/to/changed/file.php
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 把特定檔案回到上一次部署的版本，再 FTP 上傳</span></span></span></code></pre></div><p>整站回退則是 checkout 到上一個 deploy tag，再整批 FTP 上傳。這就是為什麼 deploy tag 重要——沒有 tag 就不知道要回退到哪個版本。</p>
<h2 id="credential-盤點與保護">credential 盤點與保護</h2>
<p>接手後要回答的問題是：有哪些 credential、誰有存取權、哪些需要輪替。</p>
<h3 id="盤點清單">盤點清單</h3>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>常見位置</th>
          <th>輪替難度</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>資料庫密碼</td>
          <td><code>config.php</code>、<code>wp-config.php</code>、<code>.env</code></td>
          <td>低（phpMyAdmin + 改 config）</td>
      </tr>
      <tr>
          <td>主機面板登入</td>
          <td>主機商帳號</td>
          <td>中（可能綁前人的 email）</td>
      </tr>
      <tr>
          <td>金流 API key</td>
          <td><code>payment.php</code> 或 config 檔</td>
          <td>中（需要登入金流後台）</td>
      </tr>
      <tr>
          <td>SMTP 密碼</td>
          <td><code>mail.php</code> 或 config 檔</td>
          <td>低</td>
      </tr>
      <tr>
          <td>域名管理</td>
          <td>DNS 服務商帳號</td>
          <td>高（可能綁前人的帳號）</td>
      </tr>
      <tr>
          <td>SSL 憑證</td>
          <td>主機面板或 Let&rsquo;s Encrypt</td>
          <td>低（自動續期則不用管）</td>
      </tr>
  </tbody>
</table>
<p>最高優先輪替的是前人可能仍持有存取權的 credential：主機面板密碼、資料庫密碼。如果前人的離開不是善意的（被解僱、爭端），這些應該在接手的第一天就改。</p>
<h3 id="從-hardcode-到-config-分離">從 hardcode 到 config 分離</h3>
<p>長期目標是把 credential 從 code 裡搬出來。即使在沒有 SSH 的環境也能做：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 改前：password 直接寫在 code 裡
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nv">$db_password</span> <span class="o">=</span> <span class="s1">&#39;p@ssw0rd123&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">// 改後：從 .env 讀取（用 vlucas/phpdotenv 或手寫 parse）
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="nv">$db_password</span> <span class="o">=</span> <span class="nx">getenv</span><span class="p">(</span><span class="s1">&#39;DB_PASSWORD&#39;</span><span class="p">)</span> <span class="o">?:</span> <span class="nx">parse_ini_file</span><span class="p">(</span><span class="no">__DIR__</span> <span class="o">.</span> <span class="s1">&#39;/.env&#39;</span><span class="p">)[</span><span class="s1">&#39;DB_PASSWORD&#39;</span><span class="p">];</span></span></span></code></pre></div><p><code>.env</code> 放在 webroot 之外（如果主機允許）或在 <code>.htaccess</code> 裡禁止存取：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-apache" data-lang="apache"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">&lt;Files</span> <span class="s">&#34;.env&#34;</span><span class="nt">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nb">Require</span> <span class="k">all</span> denied
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nt">&lt;/Files&gt;</span></span></span></code></pre></div><h2 id="外部監控prod-不用裝東西">外部監控（prod 不用裝東西）</h2>
<p>無 SSH 的環境裝不了監控 agent，但可以用外部 HTTP 檢查服務從外面看。這類服務從多個地理位置定期對網站發送 HTTP request，回應異常時通知。</p>
<p>UptimeRobot 的免費方案提供 50 個 monitor、每 5 分鐘檢查一次，夠用於一個站台的首頁 + 幾個關鍵頁面（登入頁、API endpoint、金流回呼 URL）。Better Stack（原 Better Uptime）提供類似功能並附帶 status page。兩者都只需要填入 URL 和通知方式（email / Slack / webhook），不需要在 server 上裝任何東西。</p>
<p>設定後至少加三個 monitor：首頁（網站是否活著）、登入或後台入口（PHP 是否正常執行）、以及任何有外部依賴的頁面（金流 callback、API endpoint）。這不是完整的可觀測性，但至少讓「網站掛了」這件事從「使用者打電話來」變成「手機收到通知」。</p>
<h2 id="時程參考">時程參考</h2>
<p>完整走完盤點（FTP mirror + DB dump + 環境記錄）約需半天到一天。本地環境建立與驗證約需半天到一天（取決於 PHP 版本對齊的難度）。紀律建立（changelog + 部署流程）是持續的、但框架搭建約需 2-3 小時。CI 化 FTP 部署約需半天。整體從接手到穩定維運約 2-3 個工作天。</p>
<h2 id="升級路徑的切入點">升級路徑的切入點</h2>
<p>接手穩定後，逐步脫離無 SSH 環境的約束。每一步都獨立且可回退。</p>
<h3 id="最低成本的第一步ci-化-ftp-部署">最低成本的第一步：CI 化 FTP 部署</h3>
<p>在 GitHub repo 設定 GitHub Actions，推到 main 時自動跑測試（如果有的話）+ 自動 FTP 部署。FTP credential 存在 GitHub Secrets 裡，不在 code 裡。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># .github/workflows/deploy.yml</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy via FTP</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">  </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">SamKirkland/FTP-Deploy-Action@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">          </span><span class="nt">server</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.FTP_HOST }}</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">          </span><span class="nt">username</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.FTP_USER }}</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">          </span><span class="nt">password</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.FTP_PASS }}</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">          </span><span class="nt">server-dir</span><span class="p">:</span><span class="w"> </span><span class="l">/public_html/</span></span></span></code></pre></div><p>這一步的價值是部署從「開 FileZilla 手動上傳」變成「push to main 自動部署」，人為失誤的空間顯著縮小。Prod 伺服器不需要任何改動。</p>
<h3 id="下一步遷移到有-ssh-的-vps">下一步：遷移到有 SSH 的 VPS</h3>
<p>當以下任一條件出現時，無 SSH 環境的約束會變成瓶頸：</p>
<ul>
<li>需要 SSH 存取（裝 Git、跑 CLI 工具、設排程）</li>
<li>需要自訂 PHP extension 或 PHP 版本</li>
<li>需要更多的運算資源或記憶體</li>
<li>需要環境分離（dev / staging / prod）</li>
</ul>
<p>遷移到 VPS（DigitalOcean、Linode、AWS Lightsail）後，SSH 存取讓所有雲端環境的工具鏈成為可用——Git on server、composer、artisan、mysqldump CLI、cron 的完整控制。這一步之後，接手維運的環境開始對齊<a href="/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一：還沒有 infra 的環境</a>的操作紀律，後續可以按<a href="/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">成熟度階梯</a>逐步往 IaC 推進。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/takeover/cloud-no-iac/" data-link-title="有 SSH 但沒有 IaC 的雲端環境接管" data-link-desc="接手一個全手動建立的雲端環境時，怎麼盤點資源、推導依賴關係、收斂 credential、驗證備份、建立變更紀律，以及什麼時候該開始導入 IaC">有 SSH 但沒有 IaC 的雲端環境接管</a>：搬到 VPS 或雲端後的接管流程</li>
<li>→ <a href="/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一：還沒有 infra 的環境</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/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>：credential 盤點與輪替的系統性設計</li>
<li>→ <a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>：tagging、secret 管理、成本可見性</li>
</ul>
]]></content:encoded></item><item><title>網路地基 — VPC、subnet 分層與 security group 設計</title><link>https://tarrragon.github.io/blog/infra/03-network-foundation/vpc-subnet-security-group/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/03-network-foundation/vpc-subnet-security-group/</guid><description>&lt;p>網路地基要先於核心服務存在。VPC、subnet、route table 與 security group 構成一張「服務能落在哪、誰能跟誰講話」的地圖，資料庫、運算節點與對外入口都得落在這張地圖規劃好的格子裡。先把邊界畫清楚，後面每個核心服務上線時只需要選一塊已經定義好安全等級的位置，而不是邊開服務邊補洞。&lt;/p>
&lt;p>這篇文章建立四層邊界：最外層的 VPC 隔離、中層的 public / private subnet 切分、流量進出的 route table 與 NAT、以及最貼近服務的 security group。每一層解決的問題不同，疊起來才是一個可審計、可收斂的網路。&lt;/p>
&lt;h2 id="vpc網路隔離的最外層邊界">VPC：網路隔離的最外層邊界&lt;/h2>
&lt;p>VPC（Virtual Private Cloud）先圈定整個系統的網路地址空間 — 一塊邏輯隔離的私有網段，是其餘所有網路切分的起點。在 VPC 裡開出來的所有資源預設只看得到同一個 VPC 內的成員，與其他 VPC、與其他帳號的網路天然隔離。它是後面所有切分動作的容器 — 沒有 VPC，subnet 與 security group 無處依附。&lt;/p>
&lt;h3 id="cidr-規劃一次決定事後難改">CIDR 規劃：一次決定、事後難改&lt;/h3>
&lt;p>建立 VPC 時最關鍵的決策是 CIDR 區塊的大小。這個範圍要一次規劃足夠大，因為事後擴張地址空間在多數雲上是麻煩且容易出錯的操作。AWS 雖然允許在 VPC 上追加 secondary CIDR，但追加的網段不能與原有的重疊，也不是所有服務都能自然使用跨 CIDR 的 subnet，routing 的複雜度會因此上升。&lt;/p>
&lt;p>CIDR 規劃要同時考慮三件事。第一是容量：&lt;code>/16&lt;/code> 提供約六萬五千個位址，對多數單一環境的 VPC 足夠寬裕，切成 &lt;code>/24&lt;/code> 的 subnet 也有 256 個可用子網。第二是不重疊：未來若要透過 VPC peering、Transit Gateway 或 VPN 把這個 VPC 接回地端機房或其他環境，重疊的 CIDR 會讓路由無法解析。三個環境各自是 &lt;code>10.0.0.0/16&lt;/code>，在彼此不需要互連時不是問題，但一旦要開 peering 就會撞車 — 這時候改 CIDR 的代價是重建整個 VPC。第三是預留：如果公司同時有多個 VPC（不同環境或不同產品線），用連續但不重疊的大段分配（如 dev &lt;code>10.0.0.0/16&lt;/code>、staging &lt;code>10.1.0.0/16&lt;/code>、prod &lt;code>10.2.0.0/16&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_vpc&amp;#34; &amp;#34;main&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"> cidr_block&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;10.0.0.0/16&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"> enable_dns_support&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"> 4&lt;/span>&lt;span class="cl">&lt;span class="n"> enable_dns_hostnames&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"> 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="n"> tags&lt;/span> &lt;span class="o">=&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"> Name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;platform-prod&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"> Environment&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;production&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 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>enable_dns_support&lt;/code> 和 &lt;code>enable_dns_hostnames&lt;/code> 在多數場景都該開啟。沒開 DNS hostname 時，EC2 instance 不會拿到可解析的 hostname，某些服務依賴 DNS 尋址而非 IP（如 VPC endpoint 的 private DNS），關著會讓它們靜靜失敗而不報錯。&lt;/p>
&lt;p>判讀訊號：規劃 CIDR 時先問「這個環境三年後會有幾個 subnet、跨幾個可用區、要不要跟其他 VPC 或地端互連」。風險集中在地址耗盡與網段衝突 — 兩者都得在開第一個 subnet 之前定案。VPC 只負責隔離與定址，它不決定哪個服務能對外，那是 subnet 與 security group 的工作。環境之間的 VPC 該怎麼分，是&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;h2 id="public-與-private-subnet-的切分原則">public 與 private subnet 的切分原則&lt;/h2>
&lt;p>一塊資源對外暴露到什麼程度，取決於它被放進哪個 subnet。VPC 內部按可用區與暴露程度切出來的子網段，決定資源有沒有一條通往網際網路的路徑。判斷一個資源該放 public 還是 private，問題只有一個：它需不需要被網際網路直接定址。&lt;/p>
&lt;h3 id="兩類-subnet-的定位">兩類 subnet 的定位&lt;/h3>
&lt;p>public subnet 放的是必須接收外部入站流量的元件 — 對外的負載平衡器、NAT Gateway、堡壘主機（bastion）。這些資源透過 route table 連到 Internet Gateway，因此能被外部 IP 直接觸及。private subnet 放的是只該在內網被存取的元件 — 應用伺服器、資料庫、快取、內部佇列。它們沒有通往 Internet Gateway 的路由，外部無法主動連入，需要對外時才透過 NAT 出去。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Subnet 類型&lt;/th>
 &lt;th>典型住戶&lt;/th>
 &lt;th>對外路徑&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>public&lt;/td>
 &lt;td>對外 LB、NAT Gateway、bastion&lt;/td>
 &lt;td>經 Internet Gateway 雙向&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>private&lt;/td>
 &lt;td>應用節點、資料庫、快取、佇列&lt;/td>
 &lt;td>僅經 NAT 單向出站、不可入站&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>public subnet 的真實樣貌是「薄薄一層」：它通常只住負載平衡器與 NAT 這類入口設施，而不是業務邏輯。常見陷阱是為了 SSH 方便把應用伺服器直接開在 public subnet 並配公網 IP，等於把每一台業務主機的管理埠暴露在掃描流量下 — 全球的 bot 會在秒級頻率對公網 IP 的 22 埠嘗試登入。private subnet 的住戶反而是系統的主體 — 資料庫放這裡是因為它一旦能被外網定址，攻擊面就從「打穿入口層」變成「直接連資料庫埠試密碼」。&lt;/p></description><content:encoded><![CDATA[<p>網路地基要先於核心服務存在。VPC、subnet、route table 與 security group 構成一張「服務能落在哪、誰能跟誰講話」的地圖，資料庫、運算節點與對外入口都得落在這張地圖規劃好的格子裡。先把邊界畫清楚，後面每個核心服務上線時只需要選一塊已經定義好安全等級的位置，而不是邊開服務邊補洞。</p>
<p>這篇文章建立四層邊界：最外層的 VPC 隔離、中層的 public / private subnet 切分、流量進出的 route table 與 NAT、以及最貼近服務的 security group。每一層解決的問題不同，疊起來才是一個可審計、可收斂的網路。</p>
<h2 id="vpc網路隔離的最外層邊界">VPC：網路隔離的最外層邊界</h2>
<p>VPC（Virtual Private Cloud）先圈定整個系統的網路地址空間 — 一塊邏輯隔離的私有網段，是其餘所有網路切分的起點。在 VPC 裡開出來的所有資源預設只看得到同一個 VPC 內的成員，與其他 VPC、與其他帳號的網路天然隔離。它是後面所有切分動作的容器 — 沒有 VPC，subnet 與 security group 無處依附。</p>
<h3 id="cidr-規劃一次決定事後難改">CIDR 規劃：一次決定、事後難改</h3>
<p>建立 VPC 時最關鍵的決策是 CIDR 區塊的大小。這個範圍要一次規劃足夠大，因為事後擴張地址空間在多數雲上是麻煩且容易出錯的操作。AWS 雖然允許在 VPC 上追加 secondary CIDR，但追加的網段不能與原有的重疊，也不是所有服務都能自然使用跨 CIDR 的 subnet，routing 的複雜度會因此上升。</p>
<p>CIDR 規劃要同時考慮三件事。第一是容量：<code>/16</code> 提供約六萬五千個位址，對多數單一環境的 VPC 足夠寬裕，切成 <code>/24</code> 的 subnet 也有 256 個可用子網。第二是不重疊：未來若要透過 VPC peering、Transit Gateway 或 VPN 把這個 VPC 接回地端機房或其他環境，重疊的 CIDR 會讓路由無法解析。三個環境各自是 <code>10.0.0.0/16</code>，在彼此不需要互連時不是問題，但一旦要開 peering 就會撞車 — 這時候改 CIDR 的代價是重建整個 VPC。第三是預留：如果公司同時有多個 VPC（不同環境或不同產品線），用連續但不重疊的大段分配（如 dev <code>10.0.0.0/16</code>、staging <code>10.1.0.0/16</code>、prod <code>10.2.0.0/16</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_vpc&#34; &#34;main&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  cidr_block</span>           <span class="o">=</span> <span class="s2">&#34;10.0.0.0/16&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  enable_dns_support</span>   <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  enable_dns_hostnames</span> <span class="o">=</span> <span class="kt">true</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">  tags</span> <span class="o">=</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;platform-prod&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">    Environment</span> <span class="o">=</span> <span class="s2">&#34;production&#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><code>enable_dns_support</code> 和 <code>enable_dns_hostnames</code> 在多數場景都該開啟。沒開 DNS hostname 時，EC2 instance 不會拿到可解析的 hostname，某些服務依賴 DNS 尋址而非 IP（如 VPC endpoint 的 private DNS），關著會讓它們靜靜失敗而不報錯。</p>
<p>判讀訊號：規劃 CIDR 時先問「這個環境三年後會有幾個 subnet、跨幾個可用區、要不要跟其他 VPC 或地端互連」。風險集中在地址耗盡與網段衝突 — 兩者都得在開第一個 subnet 之前定案。VPC 只負責隔離與定址，它不決定哪個服務能對外，那是 subnet 與 security group 的工作。環境之間的 VPC 該怎麼分，是<a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>的主題。</p>
<h2 id="public-與-private-subnet-的切分原則">public 與 private subnet 的切分原則</h2>
<p>一塊資源對外暴露到什麼程度，取決於它被放進哪個 subnet。VPC 內部按可用區與暴露程度切出來的子網段，決定資源有沒有一條通往網際網路的路徑。判斷一個資源該放 public 還是 private，問題只有一個：它需不需要被網際網路直接定址。</p>
<h3 id="兩類-subnet-的定位">兩類 subnet 的定位</h3>
<p>public subnet 放的是必須接收外部入站流量的元件 — 對外的負載平衡器、NAT Gateway、堡壘主機（bastion）。這些資源透過 route table 連到 Internet Gateway，因此能被外部 IP 直接觸及。private subnet 放的是只該在內網被存取的元件 — 應用伺服器、資料庫、快取、內部佇列。它們沒有通往 Internet Gateway 的路由，外部無法主動連入，需要對外時才透過 NAT 出去。</p>
<table>
  <thead>
      <tr>
          <th>Subnet 類型</th>
          <th>典型住戶</th>
          <th>對外路徑</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>public</td>
          <td>對外 LB、NAT Gateway、bastion</td>
          <td>經 Internet Gateway 雙向</td>
      </tr>
      <tr>
          <td>private</td>
          <td>應用節點、資料庫、快取、佇列</td>
          <td>僅經 NAT 單向出站、不可入站</td>
      </tr>
  </tbody>
</table>
<p>public subnet 的真實樣貌是「薄薄一層」：它通常只住負載平衡器與 NAT 這類入口設施，而不是業務邏輯。常見陷阱是為了 SSH 方便把應用伺服器直接開在 public subnet 並配公網 IP，等於把每一台業務主機的管理埠暴露在掃描流量下 — 全球的 bot 會在秒級頻率對公網 IP 的 22 埠嘗試登入。private subnet 的住戶反而是系統的主體 — 資料庫放這裡是因為它一旦能被外網定址，攻擊面就從「打穿入口層」變成「直接連資料庫埠試密碼」。</p>
<h3 id="跨可用區冗餘">跨可用區冗餘</h3>
<p>每個 subnet 綁定單一可用區（Availability Zone）。高可用設計通常是每種角色跨至少兩個可用區各開一個 subnet：兩個 public、兩個 private，讓單一可用區故障時另一區的同類 subnet 還能承接。subnet 的 CIDR 切法要留足空間 — 如果 VPC 是 <code>/16</code>，每個 subnet 用 <code>/20</code>（約四千個位址）可以在三個可用區各開 public + private 共六個 subnet，還有大量空間留給未來擴展。</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">locals</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  azs</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;ap-northeast-1a&#34;, &#34;ap-northeast-1c&#34;, &#34;ap-northeast-1d&#34;</span><span class="p">]</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_subnet&#34; &#34;public&#34;</span> {
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  for_each</span>          <span class="o">=</span> <span class="k">toset</span><span class="p">(</span><span class="k">local</span><span class="p">.</span><span class="k">azs</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</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"> 8</span><span class="cl"><span class="n">  cidr_block</span>        <span class="o">=</span> <span class="k">cidrsubnet</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">cidr_block</span><span class="p">,</span> <span class="m">4</span><span class="p">,</span> <span class="k">index</span><span class="p">(</span><span class="k">local</span><span class="p">.</span><span class="k">azs</span><span class="p">,</span> <span class="k">each</span><span class="p">.</span><span class="k">key</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  availability_zone</span> <span class="o">=</span> <span class="k">each</span><span class="p">.</span><span class="k">key</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="n">  tags</span> <span class="o">=</span><span class="n"> { Name</span> <span class="o">=</span> <span class="s2">&#34;public-${each.key}&#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></span><span class="line"><span class="ln">14</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_subnet&#34; &#34;private&#34;</span> {
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">  for_each</span>          <span class="o">=</span> <span class="k">toset</span><span class="p">(</span><span class="k">local</span><span class="p">.</span><span class="k">azs</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</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">17</span><span class="cl"><span class="n">  cidr_block</span>        <span class="o">=</span> <span class="k">cidrsubnet</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">cidr_block</span><span class="p">,</span> <span class="m">4</span><span class="p">,</span> <span class="k">index</span><span class="p">(</span><span class="k">local</span><span class="p">.</span><span class="k">azs</span><span class="p">,</span> <span class="k">each</span><span class="p">.</span><span class="k">key</span><span class="p">)</span> <span class="err">+</span> <span class="k">length</span><span class="p">(</span><span class="k">local</span><span class="p">.</span><span class="k">azs</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="n">  availability_zone</span> <span class="o">=</span> <span class="k">each</span><span class="p">.</span><span class="k">key</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 class="n">  tags</span> <span class="o">=</span><span class="n"> { Name</span> <span class="o">=</span> <span class="s2">&#34;private-${each.key}&#34;</span> }
</span></span><span class="line"><span class="ln">21</span><span class="cl">}</span></span></code></pre></div><p><code>cidrsubnet</code> 函式自動切分子網段，避免手動計算 CIDR。第二個參數 <code>4</code> 表示在 <code>/16</code> 基礎上加 4 bit 得到 <code>/20</code>，第三個參數是序號。public 與 private 各佔不同序號區間，保證不重疊。</p>
<p>對外入口怎麼把流量分到跨可用區的 private 後端，是 devops 層負載平衡的範圍。這裡只要確保 subnet 的地圖在多 AZ 下對稱。</p>
<h2 id="route-table-與-nat流量的進出路徑">route table 與 NAT：流量的進出路徑</h2>
<p>離開一個 subnet 的封包往哪走，逐條寫在 route table 這組轉送規則裡 — 它掛在 subnet 上，是封包出口方向的依據。一個 subnet 是 public 還是 private，技術上的差別就在它關聯的 route table 裡有沒有一條指向 Internet Gateway 的預設路由。subnet 的對外性質由它關聯的 route table 賦予，而非寫在 subnet 自身的屬性。</p>
<h3 id="public-與-private-的路由差異">public 與 private 的路由差異</h3>
<p>public subnet 的 route table 有一條 <code>0.0.0.0/0 → Internet Gateway</code>，讓未知目的地的流量直接出網、也讓外部可達。private subnet 的 route table 則把 <code>0.0.0.0/0</code> 指向 NAT Gateway。</p>
<p>NAT（Network Address Translation）解決的問題是：private subnet 的資源需要主動對外（拉套件、呼叫第三方 API、抓 OS 更新），但不能因此變得可被外部入站連入。NAT 讓出站流量借用一個公網位址出去、把回應導回原請求者，同時不開放任何外部主動發起的連線。</p>
<h3 id="每-az-一個-nat-vs-共享-nat-的取捨">每 AZ 一個 NAT vs 共享 NAT 的取捨</h3>
<p>NAT Gateway 是綁定單一可用區的資源 — 一個 NAT Gateway 活在某一個 public subnet，也就活在那個可用區裡。這帶來一個架構取捨：</p>
<p><strong>共享 NAT（成本優先）</strong>：全部 private subnet 的 route table 都指向同一個 NAT。用一份 NAT 成本服務整個 VPC，代價是把 NAT 所在的可用區變成出站方向的單點 — 該可用區故障時，所有 private subnet 的對外連線同時中斷，即使其他可用區的節點本身健康。</p>
<p><strong>每 AZ 一個 NAT（可用性優先）</strong>：每個可用區各放一個 NAT Gateway，並讓每一區的 private subnet route table 指向同區的 NAT。出站路徑與 subnet 的跨可用區冗餘對齊，單一 AZ 故障只影響該區。每個 NAT Gateway 的固定月費約 $32 加流量費 $0.045/GB 處理量。三個可用區各一個就是三倍固定費。這筆成本與業務對出站中斷的容忍度對齊——如果單一可用區故障導致全部出站中斷可接受（例如有重試機制），共享 NAT 的成本效益較高。</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_eip&#34; &#34;nat&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  for_each</span> <span class="o">=</span> <span class="k">toset</span><span class="p">(</span><span class="k">local</span><span class="p">.</span><span class="k">azs</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  domain</span>   <span class="o">=</span> <span class="s2">&#34;vpc&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  tags</span>     <span class="o">=</span><span class="n"> { Name</span> <span class="o">=</span> <span class="s2">&#34;nat-${each.key}&#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">resource</span> <span class="s2">&#34;aws_nat_gateway&#34; &#34;per_az&#34;</span> {
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">  for_each</span>      <span class="o">=</span> <span class="k">aws_subnet</span><span class="p">.</span><span class="k">public</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  allocation_id</span> <span class="o">=</span> <span class="k">aws_eip</span><span class="p">.</span><span class="k">nat</span><span class="p">[</span><span class="k">each</span><span class="p">.</span><span class="k">key</span><span class="p">].</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">  subnet_id</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">id</span>
</span></span><span class="line"><span class="ln">11</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;nat-${each.key}&#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></span><span class="line"><span class="ln">14</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_route_table&#34; &#34;private&#34;</span> {
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">  for_each</span> <span class="o">=</span> <span class="k">aws_subnet</span><span class="p">.</span><span class="k">private</span>
</span></span><span class="line"><span class="ln">16</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">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="k">route</span> {
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="n">    cidr_block</span>     <span class="o">=</span> <span class="s2">&#34;0.0.0.0/0&#34;</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="n">    nat_gateway_id</span> <span class="o">=</span> <span class="k">aws_nat_gateway</span><span class="p">.</span><span class="k">per_az</span><span class="p">[</span><span class="k">each</span><span class="p">.</span><span class="k">key</span><span class="p">].</span><span class="k">id</span>
</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></span><span class="line"><span class="ln">23</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;private-rt-${each.key}&#34;</span> }
</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><span class="line"><span class="ln">26</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_route_table_association&#34; &#34;private&#34;</span> {
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="n">  for_each</span>       <span class="o">=</span> <span class="k">aws_subnet</span><span class="p">.</span><span class="k">private</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="n">  subnet_id</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">id</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="n">  route_table_id</span> <span class="o">=</span> <span class="k">aws_route_table</span><span class="p">.</span><span class="k">private</span><span class="p">[</span><span class="k">each</span><span class="p">.</span><span class="k">key</span><span class="p">].</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">}</span></span></code></pre></div><p>判讀訊號：private subnet 的服務拉不到外部套件、或第三方 API 全部逾時，先查它關聯的 route table 有沒有指向健康的 NAT；若只有某一個可用區的節點受影響，多半是那一區的 NAT 或其所在 subnet 出狀況。</p>
<h3 id="nat-的成本邊界">NAT 的成本邊界</h3>
<p>NAT Gateway 按處理流量計費（每 GB 一個費率），把大量出站流量長期走 NAT 會讓帳單可觀。常見的高流量場景包括：備份上傳到 S3、跨區資料同步、大量 API 呼叫。對於走向 AWS 自家服務的流量，成本效益較好的做法是用 VPC Endpoint（Gateway 型或 Interface 型）讓流量直連服務、繞過 NAT。S3 與 DynamoDB 的 Gateway Endpoint 是免費的，光是把 S3 備份流量從 NAT 改走 Gateway Endpoint 就能在流量大的環境省下可觀的費用。</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_vpc_endpoint&#34; &#34;s3&#34;</span> {
</span></span><span class="line"><span class="ln">2</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">3</span><span class="cl"><span class="n">  service_name</span> <span class="o">=</span> <span class="s2">&#34;com.amazonaws.ap-northeast-1.s3&#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">  route_table_ids</span> <span class="o">=</span> <span class="p">[</span><span class="k">for</span> <span class="k">rt</span> <span class="k">in</span> <span class="k">aws_route_table</span><span class="p">.</span><span class="k">private</span> <span class="err">:</span> <span class="k">rt</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 class="n">  tags</span> <span class="o">=</span><span class="n"> { Name</span> <span class="o">=</span> <span class="s2">&#34;s3-gateway-endpoint&#34;</span> }
</span></span><span class="line"><span class="ln">8</span><span class="cl">}</span></span></code></pre></div><p>NAT 的數量取捨與出站成本的更完整討論在 <a href="/blog/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">devops 模組八：成本管理</a>。route table 與 NAT 只管「能不能出去、走哪條路」，至於某個埠允不允許連，是 security group 的職責。</p>
<h2 id="security-group-設計最小開放">security group 設計：最小開放</h2>
<p>一條連線究竟能不能打到某個埠，由 security group 逐埠拍板 — 它是掛在資源網卡（ENI）層級的有狀態防火牆，規則描述的是哪些來源連得進這個資源。它是貼著服務的最後一道網路邊界 — 即使封包順著 route table 抵達了 private subnet，security group 仍能逐埠決定放不放行。「有狀態」的意思是：放行一條入站連線後，對應的回應出站自動允許，規則只需描述入站方向想開放什麼。</p>
<h3 id="用-group-引用取代-ip-範圍">用 group 引用取代 IP 範圍</h3>
<p>設計原則是最小開放：每條規則只開「這個服務確實需要被誰連的那個埠」。資料庫的 security group 入站只允許來自應用層 security group 的資料庫埠，而不是某個 IP 範圍。用 security group 互相引用、而非寫死網段，是因為應用節點會隨擴縮而換 IP — 引用來源 group 讓規則跟著成員身分走、不跟著位址走。</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_security_group&#34; &#34;app&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  name_prefix</span> <span class="o">=</span> <span class="s2">&#34;app-&#34;</span>
</span></span><span class="line"><span class="ln"> 3</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"> 4</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-sg&#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">resource</span> <span class="s2">&#34;aws_security_group&#34; &#34;database&#34;</span> {
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">  name_prefix</span> <span class="o">=</span> <span class="s2">&#34;db-&#34;</span>
</span></span><span class="line"><span class="ln"> 9</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">10</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;db-sg&#34;</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 class="k">resource</span> <span class="s2">&#34;aws_security_group_rule&#34; &#34;db_from_app&#34;</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="s2">&#34;ingress&#34;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">  from_port</span>                <span class="o">=</span> <span class="m">5432</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="n">  to_port</span>                  <span class="o">=</span> <span class="m">5432</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;tcp&#34;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="n">  security_group_id</span>        <span class="o">=</span> <span class="k">aws_security_group</span><span class="p">.</span><span class="k">database</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="n">  source_security_group_id</span> <span class="o">=</span> <span class="k">aws_security_group</span><span class="p">.</span><span class="k">app</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">}</span></span></code></pre></div><p>這條規則表達的語意是「資料庫只接受來自 app group 成員的 5432 連線」。app 的 instance 數量從 2 台增長到 20 台時，規則本身不需要改 — 新 instance 只要也掛了 <code>app</code> 的 security group 就自動被允許。</p>
<h3 id="00000-的盤點紀律">0.0.0.0/0 的盤點紀律</h3>
<p>把入站來源設成 <code>0.0.0.0/0</code> 等於允許整個網際網路連這個埠。對資料庫埠（5432、3306、6379）或管理埠（22、3389）這麼做，會讓服務暴露在持續性的自動掃描與暴力嘗試下。</p>
<p>合理出現 <code>0.0.0.0/0</code> 的位置只有對外負載平衡器的 80 / 443 入站 — 因為它的工作本來就是接收公開流量。其餘所有 <code>0.0.0.0/0</code> 都該被質疑。</p>
<p>盤點的做法：列出所有 security group，過濾 source 是 <code>0.0.0.0/0</code> 的 ingress rule，逐條問「這個埠確實需要全世界都連得到嗎」。在 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">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>  --query <span class="s1">&#39;SecurityGroups[].{
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s1">    ID:GroupId,
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s1">    Name:GroupName,
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s1">    OpenPorts:IpPermissions[?IpRanges[?CidrIp==`0.0.0.0/0`]].[FromPort,ToPort]
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s1">  }&#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></code></pre></div><p>資料庫埠、SSH、內部 API 出現在這份清單上就是該收斂的目標。管理埠的存取更安全的替代方案是 SSM Session Manager — 它讓你透過 IAM 權限建立 shell session，完全不需要開 22 埠，連線經由 Systems Manager 的控制通道走、不走公網，同時自動留下 session log。誰能透過 IAM 改動這些規則，銜接<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>。</p>
<p>在 CI 層面，<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>用 tfsec / checkov 做靜態掃描，自動攔截「敏感埠 + 全開 CIDR」的組合，把 security group 的盤點從人工定期做變成每次 PR 自動做。</p>
<p>邊界設備漏洞帶來的教訓同樣適用於 security group 設計。<a href="/blog/backend/07-security-data-protection/red-team/cases/edge-exposure/check-point-cve-2024-24919-vpn-info-disclosure/" data-link-title="7.R7.3.12 Check Point 2024：VPN 資訊外洩與會話風險" data-link-desc="邊界設備資訊外洩漏洞可快速轉為憑證與會話濫用風險">Check Point CVE-2024-24919</a> 事件顯示 VPN 邊界設備的資訊外洩漏洞可以直接轉為憑證與會話濫用，攻擊路徑是「邊界入口 → 會話竊取 → 內部橫向擴散」。<a href="/blog/backend/07-security-data-protection/red-team/cases/edge-exposure/citrix-bleed-2023-session-hijack/" data-link-title="7.R7.3.3 Citrix Bleed 2023：會話被劫持與重放風險" data-link-desc="邊界設備會話資料外洩後，如何演變成帳號與服務風險">Citrix Bleed 2023</a> 則是邊界設備的會話資料外洩導致重放攻擊。這兩個案例的 infra 層教訓是：邊界設備（VPN concentrator、ADC、bastion）的 security group 只開必要的管理埠，且事件後需要全域 session/token 失效流程。</p>
<p>網路控制面的自動化也有風險。<a href="/blog/backend/07-security-data-protection/cases/cloudflare-route-leak-2026/" data-link-title="7.C1 Cloudflare：2026 Route Leak 事件" data-link-desc="BGP 路由政策自動化失誤如何回寫控制面治理。">Cloudflare 2026 Route Leak</a> 事件中，自動化路由政策配置的錯誤導致流量擁塞。infra 層的教訓是：路由與 security group 規則的自動化變更需要 pre-check 與影響範圍評估，且要有快速撤回機制——這正是 <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>的 plan → review → apply 流程要擋的。</p>
<h2 id="nacl-與-security-group-的分工">NACL 與 security group 的分工</h2>
<p>subnet 這一層還有另一道防火牆 — network ACL（NACL），它與 security group 分工在兩個層級。</p>
<table>
  <thead>
      <tr>
          <th>屬性</th>
          <th>Security Group</th>
          <th>NACL</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>掛在哪裡</td>
          <td>資源網卡（ENI）</td>
          <td>Subnet</td>
      </tr>
      <tr>
          <td>狀態</td>
          <td>有狀態（回程自動放行）</td>
          <td>無狀態（回程要另寫規則）</td>
      </tr>
      <tr>
          <td>規則方向</td>
          <td>只寫入站</td>
          <td>入站與出站各寫</td>
      </tr>
      <tr>
          <td>能否 deny</td>
          <td>只能列允許清單</td>
          <td>支援顯式 deny</td>
      </tr>
      <tr>
          <td>評估順序</td>
          <td>所有規則一起評估</td>
          <td>按規則編號順序，命中即停</td>
      </tr>
  </tbody>
</table>
<p>NACL 的特點是無狀態與支援顯式 deny。無狀態意味著放行了入站不代表回應的出站自動放行，回程封包得自己對得上另一條出站規則 — 這讓 NACL 的維護比 security group 複雜。支援顯式 deny 則是它獨有的能力：security group 只能說「誰可以進」，NACL 能說「誰一定不能進」，這在需要 subnet 邊界封鎖特定已知惡意網段時有用。</p>
<p>多數設計的主力是 security group：它貼著服務、用 group 互相引用就能表達「誰能連誰」，已經涵蓋大部分最小開放需求。NACL 留給少數情境 — 需要在 subnet 邊界擋掉一整段已知惡意網段、或要對某類流量做顯式 deny 時才展開。多數環境讓 NACL 維持預設全通、把存取控制集中在 security group，是可以接受的選擇。重點是知道這一層存在、在需要 subnet 層粗篩時記得它。</p>
<h2 id="為什麼網路要先於核心服務鋪好">為什麼網路要先於核心服務鋪好</h2>
<p>網路地基先行，是因為核心服務的安全位置由網路拓樸決定，而不是反過來。資料庫該落在哪個 private subnet、它的 security group 只接受哪個來源、它的出站走不走 NAT — 這些都是服務「出生時」就該確定的屬性。</p>
<p>先有規劃好的 subnet 與 security group，新服務上線只是挑一塊已定義安全等級的位置放進去。網路還沒鋪就先開服務，則往往落在預設 VPC 與寬鬆規則上。預設 VPC 是所有人共享的、CIDR 不可控的、security group 預設全通的 — 把正式服務放在這裡，等於跳過了所有隔離設計。事後再回頭收斂，要在服務已經有流量、有依賴的情況下改網段與防火牆，風險和協調成本都高得多。</p>
<p>這也呼應<a href="/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">模組零：infra 是什麼</a>的 day-1 鐵律：邊界與隔離屬於一開始就該存在的地基，不是長出問題後才補的修補。網路規劃好之後，照「從零建置」路線下一步先進<a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>確定環境怎麼切，再讓核心服務落進這些 subnet（見<a href="/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a>）。</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>：誰有權改動 security group 與路由表</li>
<li>→ <a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>：環境之間的 VPC 怎麼分</li>
<li>→ <a href="/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a>：核心服務怎麼落進規劃好的 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>：tfsec / checkov 自動攔截 security group 全開</li>
<li>→ <a href="/blog/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">devops 模組八：成本管理</a>：NAT 與出站流量的成本取捨</li>
<li>→ <a href="/blog/infra/03-network-foundation/security-group-audit-cleanup/" data-link-title="Security Group 稽核與清理" data-link-desc="盤點所有 security group 規則、找出 0.0.0.0/0 全開與未使用的 SG、依賴檢查後安全刪除、自動化治理">Security Group 稽核與清理</a>：SG 規則盤點、未使用 SG 識別、清理工作流</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>斷網環境的通用原則</title><link>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-principles/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-principles/</guid><description>&lt;p>斷網環境的 infra 原則跟連網環境相同——可重建、可追蹤、可審查。差別在於連網環境用網路解決的事情（下載套件、推送 code、拉取映像、發送告警），斷網環境要用替代路徑解決。這些替代路徑有一個共通模式：把內容在有網路的環境準備好，經過安全審查後搬進隔離網路。本篇建立這個共通模式的操作框架，後續的 IaC、容器、監控各篇在這個框架上展開各自的細節。&lt;/p>
&lt;h2 id="內容搬運模式content-ferry">內容搬運模式（Content Ferry）&lt;/h2>
&lt;p>斷網環境裡的所有外部依賴（套件、映像、工具、更新）都要經過一條可控的搬運路徑進入。這條路徑的設計決定了環境的安全性和維護效率。&lt;/p>
&lt;h3 id="搬運路徑的三種形態">搬運路徑的三種形態&lt;/h3>
&lt;p>&lt;strong>離線媒介搬運&lt;/strong>：用 USB 隨身碟、外接硬碟或光碟把檔案從有網路的工作站搬進隔離網路。適合高安全環境（軍事、政府機密網路），搬運頻率通常是週或月級。每次搬運的內容要經過掃毒和完整性驗證。&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 ferry/&lt;span class="k">$(&lt;/span>date +%Y%m%d&lt;span class="k">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1"># 把需要的套件、映像、工具複製進去&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">cp -r packages/ images/ tools/ ferry/&lt;span class="k">$(&lt;/span>date +%Y%m%d&lt;span class="k">)&lt;/span>/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1"># 產生 checksum&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">find ferry/&lt;span class="k">$(&lt;/span>date +%Y%m%d&lt;span class="k">)&lt;/span> -type f -exec sha256sum &lt;span class="o">{}&lt;/span> &lt;span class="se">\;&lt;/span> &amp;gt; ferry/&lt;span class="k">$(&lt;/span>date +%Y%m%d&lt;span class="k">)&lt;/span>/manifest.sha256&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 隔離網路內：驗證搬運包完整性&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /mnt/usb/ferry/20260626
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">sha256sum -c manifest.sha256&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>跨網段閘道搬運&lt;/strong>：在隔離網路的邊界放一台 staging gateway（跳板機），它有兩張網卡——一張連外部網路（或 DMZ）、一張連內部隔離網路。外部的內容先傳到閘道、經過掃描和審查後再推進內部。適合金融和工控環境，搬運頻率可以是日級。&lt;/p>
&lt;p>閘道的安全約束：只允許特定的檔案類型通過、所有傳入的檔案經過掃毒、傳輸記錄要保留 audit log、閘道本身定期更新安全軟體。&lt;/p>
&lt;p>&lt;strong>單向資料二極體（Data Diode）&lt;/strong>：硬體層面只允許資料單向流動（外 → 內），物理上無法從內部網路傳資料出去。用在最高安全等級的環境。搬運頻率和內容由二極體的設定決定。&lt;/p>
&lt;h3 id="搬運的操作紀律">搬運的操作紀律&lt;/h3>
&lt;p>每次搬運都要記錄：日期、搬運者、搬運內容清單（檔名 + 版本 + checksum）、搬運理由。這份紀錄存在內部網路的版本控制裡，讓「這個套件是誰、什麼時候、為什麼帶進來的」事後可追溯。&lt;/p>
&lt;p>搬運內容的安全審查至少包含：掃毒（ClamAV 或商業掃毒）、checksum 驗證（確認搬運過程沒有被竄改）、版本確認（確認搬進來的版本跟預期的一致、不是被降級的舊版）。&lt;/p>
&lt;p>時程參考：建立搬運流程（含閘道設定、掃描工具安裝、紀錄模板）約需 2-3 天。之後每次搬運操作約 1-2 小時（含準備、掃描、驗證、紀錄）。&lt;/p>
&lt;h2 id="離線套件管理">離線套件管理&lt;/h2>
&lt;p>連網環境的 &lt;code>apt install&lt;/code>、&lt;code>yum install&lt;/code>、&lt;code>npm install&lt;/code> 背後都在連線到公開的套件倉庫。斷網環境需要在內部建立這些倉庫的離線鏡像。&lt;/p>
&lt;h3 id="作業系統套件">作業系統套件&lt;/h3>
&lt;p>&lt;strong>Debian/Ubuntu&lt;/strong>：用 &lt;code>apt-mirror&lt;/code> 或 &lt;code>aptly&lt;/code> 在有網路的環境建立 mirror，把整個 mirror 搬進內部網路，內部機器的 &lt;code>/etc/apt/sources.list&lt;/code> 指向內部 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"># 外部：建立 mirror（首次約 50-200GB，後續增量）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">apt-mirror /etc/apt/mirror.list
&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"># 內部：設定 sources.list 指向內部 mirror&lt;/span>
&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="s2">&amp;#34;deb http://internal-mirror.local/ubuntu jammy main restricted&amp;#34;&lt;/span> &amp;gt; /etc/apt/sources.list
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">apt update&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>RHEL/CentOS&lt;/strong>：用 &lt;code>reposync&lt;/code> 把 yum repo 同步到本地，搬進內部後用 &lt;code>createrepo&lt;/code> 建立 repo metadata。&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"># 外部：同步 repo&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">reposync --repoid&lt;span class="o">=&lt;/span>baseos --download-metadata -p /path/to/mirror/
&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"># 內部：建立 repo 並設定&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">createrepo /path/to/mirror/baseos&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="應用層套件">應用層套件&lt;/h3>
&lt;p>&lt;strong>Node.js（npm）&lt;/strong>：&lt;code>npm pack&lt;/code> 把每個依賴打包成 .tgz，搬進內部後用 &lt;code>npm install --offline&lt;/code> 或建立 Verdaccio private registry。&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">npm pack --pack-destination ./offline-packages/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1"># 或用 npm-offline-mirror&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">npm install --prefer-offline --cache ./npm-cache&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Python（pip）&lt;/strong>：&lt;code>pip download&lt;/code> 把依賴下載成 wheel 或 tarball，搬進內部後 &lt;code>pip install --no-index --find-links=./packages/&lt;/code>。&lt;/p>
&lt;p>&lt;strong>PHP（Composer）&lt;/strong>：&lt;code>composer install&lt;/code> 後整個 &lt;code>vendor/&lt;/code> 目錄打包搬進去。或建立 Satis 作為 private Packagist mirror。&lt;/p>
&lt;h3 id="套件鏡像的維護節奏">套件鏡像的維護節奏&lt;/h3>
&lt;p>離線 mirror 需要定期更新——安全補丁、版本升級都要透過搬運流程進入。更新頻率取決於安全需求：高安全環境至少月更（安全補丁）、一般環境季更可接受。每次更新都是一次搬運操作，要走完整的審查流程。&lt;/p>
&lt;h3 id="多格式統一nexus-repository">多格式統一：Nexus Repository&lt;/h3>
&lt;p>上面的做法是每個套件生態各自建 mirror（apt-mirror + Verdaccio + Satis + pip local index）。Nexus Repository 是多格式統一的 artifact proxy，同時支援 apt / yum / npm / Maven / PyPI / Docker / Helm——在企業級斷網環境裡，用一個 Nexus 實例取代多個獨立的離線 repo mirror，維護成本較低。代價是 Nexus 本身的安裝和維運（Java 應用、需要磁碟空間和記憶體），小團隊各自建 mirror 可能反而更簡單。&lt;/p></description><content:encoded><![CDATA[<p>斷網環境的 infra 原則跟連網環境相同——可重建、可追蹤、可審查。差別在於連網環境用網路解決的事情（下載套件、推送 code、拉取映像、發送告警），斷網環境要用替代路徑解決。這些替代路徑有一個共通模式：把內容在有網路的環境準備好，經過安全審查後搬進隔離網路。本篇建立這個共通模式的操作框架，後續的 IaC、容器、監控各篇在這個框架上展開各自的細節。</p>
<h2 id="內容搬運模式content-ferry">內容搬運模式（Content Ferry）</h2>
<p>斷網環境裡的所有外部依賴（套件、映像、工具、更新）都要經過一條可控的搬運路徑進入。這條路徑的設計決定了環境的安全性和維護效率。</p>
<h3 id="搬運路徑的三種形態">搬運路徑的三種形態</h3>
<p><strong>離線媒介搬運</strong>：用 USB 隨身碟、外接硬碟或光碟把檔案從有網路的工作站搬進隔離網路。適合高安全環境（軍事、政府機密網路），搬運頻率通常是週或月級。每次搬運的內容要經過掃毒和完整性驗證。</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 ferry/<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 把需要的套件、映像、工具複製進去</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">cp -r packages/ images/ tools/ ferry/<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>/
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 產生 checksum</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">find ferry/<span class="k">$(</span>date +%Y%m%d<span class="k">)</span> -type f -exec sha256sum <span class="o">{}</span> <span class="se">\;</span> &gt; ferry/<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>/manifest.sha256</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 隔離網路內：驗證搬運包完整性</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">cd</span> /mnt/usb/ferry/20260626
</span></span><span class="line"><span class="ln">3</span><span class="cl">sha256sum -c manifest.sha256</span></span></code></pre></div><p><strong>跨網段閘道搬運</strong>：在隔離網路的邊界放一台 staging gateway（跳板機），它有兩張網卡——一張連外部網路（或 DMZ）、一張連內部隔離網路。外部的內容先傳到閘道、經過掃描和審查後再推進內部。適合金融和工控環境，搬運頻率可以是日級。</p>
<p>閘道的安全約束：只允許特定的檔案類型通過、所有傳入的檔案經過掃毒、傳輸記錄要保留 audit log、閘道本身定期更新安全軟體。</p>
<p><strong>單向資料二極體（Data Diode）</strong>：硬體層面只允許資料單向流動（外 → 內），物理上無法從內部網路傳資料出去。用在最高安全等級的環境。搬運頻率和內容由二極體的設定決定。</p>
<h3 id="搬運的操作紀律">搬運的操作紀律</h3>
<p>每次搬運都要記錄：日期、搬運者、搬運內容清單（檔名 + 版本 + checksum）、搬運理由。這份紀錄存在內部網路的版本控制裡，讓「這個套件是誰、什麼時候、為什麼帶進來的」事後可追溯。</p>
<p>搬運內容的安全審查至少包含：掃毒（ClamAV 或商業掃毒）、checksum 驗證（確認搬運過程沒有被竄改）、版本確認（確認搬進來的版本跟預期的一致、不是被降級的舊版）。</p>
<p>時程參考：建立搬運流程（含閘道設定、掃描工具安裝、紀錄模板）約需 2-3 天。之後每次搬運操作約 1-2 小時（含準備、掃描、驗證、紀錄）。</p>
<h2 id="離線套件管理">離線套件管理</h2>
<p>連網環境的 <code>apt install</code>、<code>yum install</code>、<code>npm install</code> 背後都在連線到公開的套件倉庫。斷網環境需要在內部建立這些倉庫的離線鏡像。</p>
<h3 id="作業系統套件">作業系統套件</h3>
<p><strong>Debian/Ubuntu</strong>：用 <code>apt-mirror</code> 或 <code>aptly</code> 在有網路的環境建立 mirror，把整個 mirror 搬進內部網路，內部機器的 <code>/etc/apt/sources.list</code> 指向內部 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"># 外部：建立 mirror（首次約 50-200GB，後續增量）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">apt-mirror /etc/apt/mirror.list
</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"># 內部：設定 sources.list 指向內部 mirror</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;deb http://internal-mirror.local/ubuntu jammy main restricted&#34;</span> &gt; /etc/apt/sources.list
</span></span><span class="line"><span class="ln">6</span><span class="cl">apt update</span></span></code></pre></div><p><strong>RHEL/CentOS</strong>：用 <code>reposync</code> 把 yum repo 同步到本地，搬進內部後用 <code>createrepo</code> 建立 repo metadata。</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"># 外部：同步 repo</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">reposync --repoid<span class="o">=</span>baseos --download-metadata -p /path/to/mirror/
</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"># 內部：建立 repo 並設定</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">createrepo /path/to/mirror/baseos</span></span></code></pre></div><h3 id="應用層套件">應用層套件</h3>
<p><strong>Node.js（npm）</strong>：<code>npm pack</code> 把每個依賴打包成 .tgz，搬進內部後用 <code>npm install --offline</code> 或建立 Verdaccio private registry。</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">npm pack --pack-destination ./offline-packages/
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 或用 npm-offline-mirror</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">npm install --prefer-offline --cache ./npm-cache</span></span></code></pre></div><p><strong>Python（pip）</strong>：<code>pip download</code> 把依賴下載成 wheel 或 tarball，搬進內部後 <code>pip install --no-index --find-links=./packages/</code>。</p>
<p><strong>PHP（Composer）</strong>：<code>composer install</code> 後整個 <code>vendor/</code> 目錄打包搬進去。或建立 Satis 作為 private Packagist mirror。</p>
<h3 id="套件鏡像的維護節奏">套件鏡像的維護節奏</h3>
<p>離線 mirror 需要定期更新——安全補丁、版本升級都要透過搬運流程進入。更新頻率取決於安全需求：高安全環境至少月更（安全補丁）、一般環境季更可接受。每次更新都是一次搬運操作，要走完整的審查流程。</p>
<h3 id="多格式統一nexus-repository">多格式統一：Nexus Repository</h3>
<p>上面的做法是每個套件生態各自建 mirror（apt-mirror + Verdaccio + Satis + pip local index）。Nexus Repository 是多格式統一的 artifact proxy，同時支援 apt / yum / npm / Maven / PyPI / Docker / Helm——在企業級斷網環境裡，用一個 Nexus 實例取代多個獨立的離線 repo mirror，維護成本較低。代價是 Nexus 本身的安裝和維運（Java 應用、需要磁碟空間和記憶體），小團隊各自建 mirror 可能反而更簡單。</p>
<h3 id="離線-configuration-managementansible">離線 Configuration Management：Ansible</h3>
<p>斷網環境的 OS 設定、套件安裝、服務啟動等 configuration management 需求，Ansible 是運作良好的工具——它不需要在目標機器安裝 agent、透過 SSH 推送 playbook 執行，playbook 本身是 YAML 可版本控制。在沒有雲端 IaC（Terraform 管的是雲端資源 API）的地端斷網環境裡，Ansible 負責 configuration management 層。Ansible 自身的安裝只需要 Python，控制端安裝後即可透過 SSH 管理內部所有機器。</p>
<h2 id="變更追蹤沒有-github-怎麼辦">變更追蹤：沒有 GitHub 怎麼辦</h2>
<p>斷網環境不能 push 到 GitHub、不能開 PR、不能用 GitHub Actions。但 git 本身是離線工具——git 的所有操作（commit、branch、merge、log、diff）都不需要網路。</p>
<h3 id="內部-git-server">內部 Git Server</h3>
<p>在隔離網路內架設 git server：Gitea（輕量、單一二進位、適合小團隊）、GitLab CE（功能完整、含 CI/CD runner、適合中大團隊）、或最簡單的 bare repo on NFS。</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"># 最簡單的方式：bare repo on 共用檔案系統</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git init --bare /shared/repos/infra.git
</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"># 開發者 clone</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git clone /shared/repos/infra.git</span></span></code></pre></div><h3 id="git-bundle-跨網段傳遞">Git Bundle 跨網段傳遞</h3>
<p>如果需要在有網路的環境開發、完成後搬進隔離網路，用 <code>git bundle</code> 把 commit 打包成單一檔案：</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"># 外部：把 main branch 的所有 commit 打包</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git bundle create infra-<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>.bundle main
</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"># 搬運後，在內部 clone 或 pull</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git clone infra-20260626.bundle infra-repo
</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">git pull infra-20260626.bundle main</span></span></code></pre></div><p>bundle 檔案可以用 <code>git bundle verify</code> 驗證完整性。增量 bundle（只包含某個 tag 之後的 commit）可以減少搬運的資料量：</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 bundle create incremental.bundle last-imported-tag..main</span></span></code></pre></div><h3 id="code-review-的替代方案">Code Review 的替代方案</h3>
<p>沒有 GitHub PR，code review 可以用：</p>
<ul>
<li>GitLab CE / Gitea 的內建 merge request（如果架了內部 git server）</li>
<li><code>git format-patch</code> 產出 patch 檔 + email review（傳統做法、不需要 web UI）</li>
<li><code>git diff main..feature | less</code> 直接在終端機 review（最簡陋但可行）</li>
</ul>
<h2 id="staging-gateway-的設計">Staging Gateway 的設計</h2>
<p>staging gateway 是搬運路徑的關鍵節點——它決定了什麼能進、什麼不能進。設計要點：</p>
<p><strong>最小安裝</strong>：閘道上只裝搬運需要的工具（scp、rsync、掃毒軟體、checksum 工具），不裝開發工具、不跑應用服務。攻擊面越小越好。</p>
<p><strong>雙網卡隔離</strong>：一張網卡連外部（或 DMZ）、一張連內部。兩張網卡之間沒有自動路由——檔案必須經過人工或腳本從外部目錄搬到內部目錄，中間經過掃描。</p>
<p><strong>審計紀錄</strong>：閘道上的所有檔案操作（建立、複製、刪除）都要記錄。<code>auditd</code> 或等價工具提供核心層級的操作追蹤。</p>
<p><strong>定期輪替</strong>：閘道本身的 OS 和掃毒軟體需要更新。這是一個遞迴問題（用什麼搬運閘道的更新？）——通常用離線媒介搬運閘道自身的更新，或用另一台更上游的閘道。</p>
<p>時程參考：閘道的初次設定（含 OS 安裝、雙網卡配置、掃描工具、審計設定）約需 1-2 天。搬運流程文件化約需半天。</p>
<h2 id="安全審查什麼能跨越隔離邊界">安全審查：什麼能跨越隔離邊界</h2>
<p>每一筆跨越隔離邊界的內容都是潛在的攻擊向量。審查的原則是：預設拒絕，逐項允許。</p>
<p>審查清單：</p>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>檢查方式</th>
          <th>通過條件</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>掃毒</td>
          <td>ClamAV / 商業掃毒</td>
          <td>0 偵測</td>
      </tr>
      <tr>
          <td>完整性</td>
          <td>sha256sum 比對</td>
          <td>checksum 與外部記錄一致</td>
      </tr>
      <tr>
          <td>版本</td>
          <td>比對預期版本號</td>
          <td>跟申請單的版本一致</td>
      </tr>
      <tr>
          <td>來源</td>
          <td>驗證下載來源</td>
          <td>來自官方 repo 或已知 mirror</td>
      </tr>
      <tr>
          <td>必要性</td>
          <td>申請理由審查</td>
          <td>有明確的使用場景</td>
      </tr>
  </tbody>
</table>
<p>對決策者的重點：斷網環境的安全不是「隔離就安全」——搬運路徑是唯一的攻擊面，這條路徑的安全審查品質決定了整個隔離環境的安全水位。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-iac/" data-link-title="斷網環境的 IaC" data-link-desc="Terraform provider mirror、離線 plugin cache、本地 state backend、沒有雲端時的 plan/apply 流程與內網 CI">斷網環境的 IaC</a>：Terraform provider 和 module 的離線管理</li>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-container/" data-link-title="斷網環境的容器與映像管理" data-link-desc="Private registry 架設、映像搬運（docker save/load、skopeo）、base image 更新週期、離線漏洞掃描">斷網環境的容器管理</a>：映像搬運用的是本篇的 content ferry 模式</li>
<li>→ <a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>：斷網環境的搬運紀錄是治理的一部分</li>
</ul>
]]></content:encoded></item><item><title>Console 唯讀鐵律與最小可行資源集合</title><link>https://tarrragon.github.io/blog/infra/01-minimal-iac/console-readonly-minimal-viable/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/01-minimal-iac/console-readonly-minimal-viable/</guid><description>&lt;p>state 管好之後，下一件要釘死的事是保證 state 與現實不會分歧。&lt;a href="https://tarrragon.github.io/blog/infra/01-minimal-iac/iac-tool-state-backend/" data-link-title="IaC 工具選型與 state 地基" data-link-desc="Terraform / OpenTofu / CDK / Pulumi 的選型判準，state 作為 IaC 工具對現實的唯一記憶，以及 remote state backend 的自管與託管路線">IaC 工具選型與 state 地基&lt;/a>建立了 state 作為工具記憶的角色，這篇處理的是怎麼讓這份記憶不被背後偷改 — Console 唯讀鐵律，以及怎麼用最小資源集合驗證整條 IaC 鏈路端到端可運作。&lt;/p>
&lt;h2 id="console-唯讀鐵律把-console-當儀表板不當方向盤">Console 唯讀鐵律：把 Console 當儀表板，不當方向盤&lt;/h2>
&lt;p>Console 唯讀鐵律是一條操作紀律：雲端 Console 只用來觀察與排查，所有會改變資源的動作都回到程式碼走 apply。這條紀律維護的是 state 與現實的一致 — IaC 工具能正確運作的前提，是它的 state 反映得了真實世界，而每一次在 Console 點按鈕改設定，都是在 state 不知情的情況下動了現實。&lt;/p>
&lt;h3 id="drift-的延遲浮現">drift 的延遲浮現&lt;/h3>
&lt;p>state 與現實的分歧叫 drift。drift 的後果在後續某次 apply 時才浮現——工具用過時的 state 比對雲端現況、把手動設定判定為「不該存在」並覆蓋掉，手動改的當下一切正常。手動改的當下一切正常，後果要等到下一次不相關的 apply 才出現。&lt;/p>
&lt;p>常見的 drift 路徑：在 Console 手動加了一條 security group 規則（例如讓外部監控系統連進來），state 不知道這條規則存在。後續某次 apply 時，工具比對 state 和雲端現況、把這條規則判定為「不在記憶裡」而刪除。同樣的機制也發生在手動調整的 RDS parameter group（例如增加 &lt;code>max_connections&lt;/code>）— 後續 apply 會把參數重設回程式碼裡的值。&lt;/p>
&lt;p>Console 改得越多、與程式碼分歧越久，某次例行 apply 就越可能掃掉一批沒人記得的手動設定。drift 的累積是單調遞增的 — 每一次手動改動都加一筆，沒有任何自然機制會讓它減少。&lt;/p>
&lt;h3 id="drift-偵測">drift 偵測&lt;/h3>
&lt;p>主動偵測 drift 的方式是定期跑 &lt;code>terraform plan&lt;/code> 而不做 apply — plan 的輸出會列出「code 描述的狀態」與「雲端現況」之間的差異。如果 plan 在沒有 code 變更的情況下顯示非零差異，代表有人在背後動了資源。&lt;/p>





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





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_s3_bucket&#34; &#34;smoke_test&#34;</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  bucket</span> <span class="o">=</span> <span class="s2">&#34;acme-smoke-test-${var.env}&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  tags</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">    purpose</span> <span class="o">=</span> <span class="s2">&#34;validate-iac-pipeline&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">    env</span>     <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">env</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="n">    owner</span>   <span class="o">=</span> <span class="s2">&#34;platform&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  }
</span></span><span class="line"><span class="ln">9</span><span class="cl">}</span></span></code></pre></div><h3 id="刻意不放進來的東西">刻意不放進來的東西</h3>
<p>正式的應用服務、資料庫、跨環境的複製、複雜的模組抽象，全部留到地基驗證通過之後。在 state 與 Console 唯讀都還沒站穩前就堆服務，等於把房子蓋在還沒灌漿的地基上。</p>
<p>常見的過早引入包括：在最小集合裡就加 RDS（state 操作出問題時資料庫可能被影響）、在還沒有環境分離前就建多層 module 嵌套（驗證地基的複雜度不應該來自抽象層）、在一個人開發時就配好 Atlantis 或 Terraform Cloud 的完整 PR 流程（固定成本太高、且需要<a href="/blog/infra/07-infra-as-pr/" data-link-title="模組七：infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">模組七</a>的完整護欄才能發揮價值）。</p>
<p>網路骨架怎麼長、身分怎麼切，分別由<a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>與<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>接手深入；這一階只需要它們各自最薄的一層，湊出一個能 apply、能 destroy、能交接的閉環。</p>
<h3 id="驗證閉環">驗證閉環</h3>
<p>最小集合就位後的驗證步驟：</p>
<ol>
<li><code>terraform init</code> — 確認 backend 設定正確、provider 能下載</li>
<li><code>terraform plan</code> — 確認 plan 輸出符合預期、沒有意外的 destroy 或 replace</li>
<li><code>terraform apply</code> — 確認資源在雲端確實出現</li>
<li><code>terraform plan</code>（再跑一次）— 確認輸出是零差異，代表 state 與現實一致</li>
<li><code>terraform destroy</code> — 確認資源能被乾淨拆除（smoke test 資源）</li>
</ol>
<p>第四步「再跑一次 plan」是容易被跳過卻最關鍵的一步。如果第一次 apply 之後立刻 plan 就出現差異，代表 provider 的行為和 HCL 描述之間有落差（例如某些屬性是雲端自動設的、HCL 沒寫），這類落差要在最小集合階段就修掉，等到正式服務上線後再修，成本會高很多。</p>
<p>最小可行 IaC 跑通後，下一步是收斂身分與憑證——把 Console 唯讀鐵律從紀律升級成權限限制，見<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/01-minimal-iac/iac-tool-state-backend/" data-link-title="IaC 工具選型與 state 地基" data-link-desc="Terraform / OpenTofu / CDK / Pulumi 的選型判準，state 作為 IaC 工具對現實的唯一記憶，以及 remote state backend 的自管與託管路線">IaC 工具選型與 state 地基</a>：state 怎麼管、backend 怎麼選</li>
<li>→ <a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>：Console 唯讀鐵律靠權限落地，人類唯讀、自動化身分持有寫入權</li>
<li>→ <a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>：最小集合裡的 VPC 與 subnet 怎麼設計</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/" data-link-title="模組七：infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">模組七：infra 走 PR 流程</a>：state 變更與 apply 怎麼納入 review</li>
</ul>
]]></content:encoded></item><item><title>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>Runtime 版本升級</title><link>https://tarrragon.github.io/blog/infra/upgrade/runtime-version-upgrade/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/upgrade/runtime-version-upgrade/</guid><description>&lt;p>Runtime 版本升級改變的是既有程式碼的執行環境。程式碼是針對某個版本的行為寫的——函式存不存在、預設值是什麼、型別檢查嚴不嚴格——新版本可能移除函式、改變預設行為、引入更嚴格的型別系統。升級的工作量不在「切換版本」這個動作本身（多數環境只需要改一個設定），而在「讓既有程式碼在新版本下行為正確」的驗證與修正。&lt;/p>
&lt;p>本篇以 PHP 為主要範例（legacy 升級最常見的情境），Node.js 和 Python 的對應工具在各段併列。&lt;/p>
&lt;h2 id="相容性評估">相容性評估&lt;/h2>
&lt;p>升級前要先知道「現有程式碼跟新版本有多少不相容」。不相容的類型分四種：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>類型&lt;/th>
 &lt;th>範例（PHP 7→8）&lt;/th>
 &lt;th>影響&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>移除的函式&lt;/td>
 &lt;td>&lt;code>each()&lt;/code>、&lt;code>create_function()&lt;/code>、&lt;code>mysql_*&lt;/code> 系列&lt;/td>
 &lt;td>呼叫直接 fatal error&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>改變的預設行為&lt;/td>
 &lt;td>&lt;code>error_reporting&lt;/code> 預設含 &lt;code>E_DEPRECATED&lt;/code>、字串比較更嚴格&lt;/td>
 &lt;td>行為靜默改變、不一定報錯&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>更嚴格的型別&lt;/td>
 &lt;td>內部函式的參數型別檢查從警告升級為 TypeError&lt;/td>
 &lt;td>之前能跑的呼叫現在拋例外&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>擴充模組可用性&lt;/td>
 &lt;td>&lt;code>json&lt;/code> 從可選變內建、&lt;code>mcrypt&lt;/code> 已移除&lt;/td>
 &lt;td>部分功能無法使用&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="php-相容性掃描">PHP 相容性掃描&lt;/h3>
&lt;p>PHPCompatibility 是 PHP_CodeSniffer 的規則集，可以自動掃描程式碼裡哪些寫法在目標版本不相容：&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">composer global require phpcompatibility/php-compatibility
&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"># 掃描：目標版本 8.0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">phpcs --standard&lt;span class="o">=&lt;/span>PHPCompatibility &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --runtime-set testVersion 8.0 &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> --extensions&lt;span class="o">=&lt;/span>php &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> -p &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> src/&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>掃描結果會列出每一處不相容的位置、原因和嚴重度。常見的命中包括：&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">FILE: src/legacy/Database.php
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">----------------------------------------------------------------------
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">FOUND 3 ERRORS:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> 42 | ERROR | Function mysql_connect() is removed since PHP 7.0
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> 89 | ERROR | Function each() is removed since PHP 8.0
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">156 | ERROR | Curly brace access syntax is deprecated since PHP 7.4
&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;p>&lt;code>php -l&lt;/code> 可以做基本的語法檢查，但它只抓語法錯誤、抓不到 deprecated 函式和行為變更。PHPCompatibility 掃描的覆蓋面更廣。&lt;/p>
&lt;h3 id="php-升級的高頻修改項">PHP 升級的高頻修改項&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>項目&lt;/th>
 &lt;th>PHP 5.6→7.x&lt;/th>
 &lt;th>PHP 7.x→8.x&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>資料庫連線&lt;/td>
 &lt;td>&lt;code>mysql_*&lt;/code> → &lt;code>mysqli_*&lt;/code> 或 PDO&lt;/td>
 &lt;td>—&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>陣列遍歷&lt;/td>
 &lt;td>—&lt;/td>
 &lt;td>&lt;code>each()&lt;/code> → &lt;code>foreach&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>字串存取&lt;/td>
 &lt;td>—&lt;/td>
 &lt;td>&lt;code>$str{0}&lt;/code> → &lt;code>$str[0]&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>錯誤處理&lt;/td>
 &lt;td>&lt;code>set_error_handler&lt;/code> 行為變更&lt;/td>
 &lt;td>內部函式 TypeError 取代 warning&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>建構函式&lt;/td>
 &lt;td>同名建構函式 deprecated&lt;/td>
 &lt;td>同名建構函式 removed&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>正則表達式&lt;/td>
 &lt;td>&lt;code>ereg_*&lt;/code> → &lt;code>preg_*&lt;/code>&lt;/td>
 &lt;td>—&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>加密&lt;/td>
 &lt;td>&lt;code>mcrypt_*&lt;/code> → &lt;code>openssl_*&lt;/code> 或 sodium&lt;/td>
 &lt;td>—&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="nodejs-相容性掃描">Node.js 相容性掃描&lt;/h3>





&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"># 用 nvm 切換版本後跑測試&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">nvm install &lt;span class="m">20&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">nvm use &lt;span class="m">20&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">npm &lt;span class="nb">test&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1"># 檢查 package.json 的 engines 欄位&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">cat package.json &lt;span class="p">|&lt;/span> jq &lt;span class="s1">&amp;#39;.engines&amp;#39;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Node.js 的 breaking change 集中在 V8 引擎行為（&lt;code>Buffer&lt;/code> 建構式、&lt;code>fs&lt;/code> 的 callback 簽章）和原生模組的 ABI 相容性。如果專案用了原生模組（&lt;code>node-gyp&lt;/code> 編譯的），版本升級後要重新 &lt;code>npm rebuild&lt;/code>。&lt;/p>
&lt;h3 id="python-相容性掃描">Python 相容性掃描&lt;/h3>





&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"># Python 2→3：用 2to3 掃描&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">2to3 --no-diffs -w src/
&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"># Python 3.x 小版本：用 pyupgrade&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">pip install pyupgrade
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">pyupgrade --py310-plus src/**/*.py&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Python 2→3 的修改量通常很大（print 語法、unicode 處理、dict 方法），是接近重寫等級的升級。Python 3.x 之間的升級相對溫和，主要是 deprecation 移除和 typing 語法的演進。&lt;/p></description><content:encoded><![CDATA[<p>Runtime 版本升級改變的是既有程式碼的執行環境。程式碼是針對某個版本的行為寫的——函式存不存在、預設值是什麼、型別檢查嚴不嚴格——新版本可能移除函式、改變預設行為、引入更嚴格的型別系統。升級的工作量不在「切換版本」這個動作本身（多數環境只需要改一個設定），而在「讓既有程式碼在新版本下行為正確」的驗證與修正。</p>
<p>本篇以 PHP 為主要範例（legacy 升級最常見的情境），Node.js 和 Python 的對應工具在各段併列。</p>
<h2 id="相容性評估">相容性評估</h2>
<p>升級前要先知道「現有程式碼跟新版本有多少不相容」。不相容的類型分四種：</p>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>範例（PHP 7→8）</th>
          <th>影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>移除的函式</td>
          <td><code>each()</code>、<code>create_function()</code>、<code>mysql_*</code> 系列</td>
          <td>呼叫直接 fatal error</td>
      </tr>
      <tr>
          <td>改變的預設行為</td>
          <td><code>error_reporting</code> 預設含 <code>E_DEPRECATED</code>、字串比較更嚴格</td>
          <td>行為靜默改變、不一定報錯</td>
      </tr>
      <tr>
          <td>更嚴格的型別</td>
          <td>內部函式的參數型別檢查從警告升級為 TypeError</td>
          <td>之前能跑的呼叫現在拋例外</td>
      </tr>
      <tr>
          <td>擴充模組可用性</td>
          <td><code>json</code> 從可選變內建、<code>mcrypt</code> 已移除</td>
          <td>部分功能無法使用</td>
      </tr>
  </tbody>
</table>
<h3 id="php-相容性掃描">PHP 相容性掃描</h3>
<p>PHPCompatibility 是 PHP_CodeSniffer 的規則集，可以自動掃描程式碼裡哪些寫法在目標版本不相容：</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">composer global require phpcompatibility/php-compatibility
</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"># 掃描：目標版本 8.0</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">phpcs --standard<span class="o">=</span>PHPCompatibility <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --runtime-set testVersion 8.0 <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --extensions<span class="o">=</span>php <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  -p <span class="se">\
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="se"></span>  src/</span></span></code></pre></div><p>掃描結果會列出每一處不相容的位置、原因和嚴重度。常見的命中包括：</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">FILE: src/legacy/Database.php
</span></span><span class="line"><span class="ln">2</span><span class="cl">----------------------------------------------------------------------
</span></span><span class="line"><span class="ln">3</span><span class="cl">FOUND 3 ERRORS:
</span></span><span class="line"><span class="ln">4</span><span class="cl"> 42 | ERROR | Function mysql_connect() is removed since PHP 7.0
</span></span><span class="line"><span class="ln">5</span><span class="cl"> 89 | ERROR | Function each() is removed since PHP 8.0
</span></span><span class="line"><span class="ln">6</span><span class="cl">156 | ERROR | Curly brace access syntax is deprecated since PHP 7.4
</span></span><span class="line"><span class="ln">7</span><span class="cl">----------------------------------------------------------------------</span></span></code></pre></div><p><code>php -l</code> 可以做基本的語法檢查，但它只抓語法錯誤、抓不到 deprecated 函式和行為變更。PHPCompatibility 掃描的覆蓋面更廣。</p>
<h3 id="php-升級的高頻修改項">PHP 升級的高頻修改項</h3>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>PHP 5.6→7.x</th>
          <th>PHP 7.x→8.x</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>資料庫連線</td>
          <td><code>mysql_*</code> → <code>mysqli_*</code> 或 PDO</td>
          <td>—</td>
      </tr>
      <tr>
          <td>陣列遍歷</td>
          <td>—</td>
          <td><code>each()</code> → <code>foreach</code></td>
      </tr>
      <tr>
          <td>字串存取</td>
          <td>—</td>
          <td><code>$str{0}</code> → <code>$str[0]</code></td>
      </tr>
      <tr>
          <td>錯誤處理</td>
          <td><code>set_error_handler</code> 行為變更</td>
          <td>內部函式 TypeError 取代 warning</td>
      </tr>
      <tr>
          <td>建構函式</td>
          <td>同名建構函式 deprecated</td>
          <td>同名建構函式 removed</td>
      </tr>
      <tr>
          <td>正則表達式</td>
          <td><code>ereg_*</code> → <code>preg_*</code></td>
          <td>—</td>
      </tr>
      <tr>
          <td>加密</td>
          <td><code>mcrypt_*</code> → <code>openssl_*</code> 或 sodium</td>
          <td>—</td>
      </tr>
  </tbody>
</table>
<h3 id="nodejs-相容性掃描">Node.js 相容性掃描</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 用 nvm 切換版本後跑測試</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">nvm install <span class="m">20</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">nvm use <span class="m">20</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">npm <span class="nb">test</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># 檢查 package.json 的 engines 欄位</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">cat package.json <span class="p">|</span> jq <span class="s1">&#39;.engines&#39;</span></span></span></code></pre></div><p>Node.js 的 breaking change 集中在 V8 引擎行為（<code>Buffer</code> 建構式、<code>fs</code> 的 callback 簽章）和原生模組的 ABI 相容性。如果專案用了原生模組（<code>node-gyp</code> 編譯的），版本升級後要重新 <code>npm rebuild</code>。</p>
<h3 id="python-相容性掃描">Python 相容性掃描</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Python 2→3：用 2to3 掃描</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">2to3 --no-diffs -w src/
</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"># Python 3.x 小版本：用 pyupgrade</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">pip install pyupgrade
</span></span><span class="line"><span class="ln">6</span><span class="cl">pyupgrade --py310-plus src/**/*.py</span></span></code></pre></div><p>Python 2→3 的修改量通常很大（print 語法、unicode 處理、dict 方法），是接近重寫等級的升級。Python 3.x 之間的升級相對溫和，主要是 deprecation 移除和 typing 語法的演進。</p>
<h2 id="本地驗證">本地驗證</h2>
<p>相容性掃描找出的是靜態分析能偵測的不相容。執行期的行為變更（如字串比較規則改變、排序穩定性改變）只有跑起來才看得到。</p>
<h3 id="建立目標版本的本地環境">建立目標版本的本地環境</h3>
<p>用 Docker 建一個精確匹配目標版本的環境：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">php:8.2-apache</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">      </span>- <span class="l">./src:/var/www/html</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span>- <span class="s2">&#34;8080:80&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">  </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">mysql:8.0</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span><span class="nt">MYSQL_ROOT_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">localdev</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">      </span><span class="nt">MYSQL_DATABASE</span><span class="p">:</span><span class="w"> </span><span class="l">app</span></span></span></code></pre></div><p>如果不用 Docker，MAMP Pro 或 Laragon 可以切換 PHP 版本。關鍵是本地環境的 runtime 版本要跟升級目標完全一致——PHP 8.0 跟 8.2 之間也有差異。</p>
<h3 id="驗證策略">驗證策略</h3>
<p>有測試套件的專案跑測試套件。沒有測試套件的專案（legacy 專案的常態）按照這個優先序手動驗證：</p>
<ol>
<li><strong>首頁能載入</strong>：最基本的 smoke test，確認 PHP 不 fatal error</li>
<li><strong>登入流程</strong>：session 處理是版本升級最常出問題的區域</li>
<li><strong>資料庫操作</strong>：CRUD 的每一種至少各跑一次</li>
<li><strong>金流 / 第三方 API</strong>：callback URL 和 API 呼叫是否正常</li>
<li><strong>表單提交</strong>：file upload、驗證邏輯</li>
</ol>
<p>PHP 升級時把 <code>error_reporting</code> 開到最大：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 開發環境設定（不要在 prod 開）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nx">error_reporting</span><span class="p">(</span><span class="k">E_ALL</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">ini_set</span><span class="p">(</span><span class="s1">&#39;display_errors&#39;</span><span class="p">,</span> <span class="s1">&#39;1&#39;</span><span class="p">);</span></span></span></code></pre></div><p>所有 notice、warning、deprecation 都要修——它們在下一個版本可能升級為 error。</p>
<h3 id="第三方依賴相容性">第三方依賴相容性</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Composer：檢查哪些套件需要更新</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">composer outdated
</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"># 檢查各套件是否支援目標 PHP 版本</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">composer why-not php 8.2</span></span></code></pre></div><p><code>composer why-not</code> 會列出哪些套件的 <code>require.php</code> 限制不允許目標版本。這些套件要先升級到支援新版本的版號，才能升 PHP。</p>
<p>如果某個套件已經不再維護且不支援新 PHP 版本，要評估替代方案或 fork 修改。這個評估的工作量可能佔整個升級的大部分時間。</p>
<h2 id="分批部署策略">分批部署策略</h2>
<h3 id="有獨立環境控制的情境vps--雲端">有獨立環境控制的情境（VPS / 雲端）</h3>
<p>最安全的策略是建一套平行環境跑新版本：</p>
<ol>
<li>用新 PHP 版本建一台新的 VM 或容器</li>
<li>部署相同的程式碼</li>
<li>匯入 prod 資料庫的副本</li>
<li>在新環境跑完整驗證</li>
<li>DNS 或 load balancer 切換流量到新環境</li>
<li>舊環境保留一段時間作為 rollback 目標</li>
</ol>
<p>rollback 是把流量切回舊環境。舊環境在確認新環境穩定之前不要關——保留期至少一週。</p>
<h3 id="面板管理主機無-ssh的情境">面板管理主機（無 SSH）的情境</h3>
<p>面板管理主機（cPanel / Plesk）的 PHP 版本切換通常是 per-domain 的設定：</p>
<ul>
<li><strong>cPanel</strong>：MultiPHP Manager，選域名 → 選 PHP 版本 → Apply</li>
<li><strong>Plesk</strong>：PHP Settings → PHP version 下拉選單</li>
</ul>
<p>切換是即時生效的，rollback 也是即時的（選回舊版本）。但沒有「平行環境驗證」的能力——除非主機商提供 staging subdomain 可以先測。</p>
<p>面板管理主機的升級策略：</p>
<ol>
<li>如果有 staging subdomain：先在 staging 切換版本、驗證、再切 prod</li>
<li>如果沒有：選流量最低的時段切換（如凌晨），切換後立刻驗證關鍵流程，出問題立刻切回</li>
<li>切換前備份（FTP mirror + DB dump），確認 rollback 路徑存在</li>
</ol>
<h3 id="wordpress--框架的版本矩陣">WordPress / 框架的版本矩陣</h3>
<p>WordPress 和主流框架有明確的 PHP 版本支援矩陣。升級 PHP 前要先確認框架版本是否支援目標 PHP 版本：</p>
<table>
  <thead>
      <tr>
          <th>框架</th>
          <th>查詢方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>WordPress</td>
          <td><a href="https://wordpress.org/about/requirements/">官方需求頁</a></td>
      </tr>
      <tr>
          <td>Laravel</td>
          <td>各版本 <code>composer.json</code> 的 <code>require.php</code></td>
      </tr>
      <tr>
          <td>Symfony</td>
          <td><a href="https://symfony.com/releases">Release and support</a> 頁面</td>
      </tr>
  </tbody>
</table>
<p>如果框架不支援目標 PHP 版本，要先升級框架。框架升級和 PHP 升級不要同時做——先升框架、驗證穩定、再升 PHP，每一步都有獨立的 rollback 點。</p>
<h2 id="常見的升級陷阱">常見的升級陷阱</h2>
<h3 id="session-序列化格式">Session 序列化格式</h3>
<p>PHP 的 session 序列化格式在某些版本之間有變更。版本切換後舊 session 檔案可能無法反序列化，使用者會被強制登出。處理方式：</p>
<ul>
<li>在維護窗口切換版本（使用者預期重新登入）</li>
<li>或在切換前清除所有 session 檔案</li>
</ul>
<h3 id="opcache-快取">opcache 快取</h3>
<p>PHP 的 opcache 會快取編譯後的 bytecode。版本切換後如果 opcache 沒清，可能用舊版本編譯的 bytecode 跑在新版本上。切換後的第一件事：</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"># CLI 方式清除（如果有 SSH）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">php -r <span class="s2">&#34;opcache_reset();&#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="c1"># 或重啟 PHP-FPM / Apache</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">systemctl restart php8.2-fpm</span></span></code></pre></div><h3 id="composer-的-php-版本鎖定">Composer 的 PHP 版本鎖定</h3>
<p><code>composer.lock</code> 裡的套件版本是根據當時的 PHP 版本解析的。PHP 版本變了之後，要重新 <code>composer update</code> 讓 Composer 用新版本重新解析依賴。但 <code>composer update</code> 可能升級其他套件——較安全的做法是 <code>composer update --lock</code> 只更新 lock file 的 metadata、不升級套件版本。</p>
<h3 id="隱性的行為變更">隱性的行為變更</h3>
<p>PHP 8.0 起，字串跟數字的比較規則改了（<code>0 == &quot;foo&quot;</code> 從 <code>true</code> 變 <code>false</code>）。這類變更不會報錯、不會拋例外，程式碼照跑但行為不同。靜態分析抓不到，只有業務邏輯測試能覆蓋。</p>
<p>如果沒有測試套件，至少在切換後的一週內密切監控錯誤日誌和業務指標（訂單數、登入數、API 錯誤率），用業務指標的異常作為行為變更的偵測手段。</p>
<h2 id="時程與管理層溝通">時程與管理層溝通</h2>
<table>
  <thead>
      <tr>
          <th>升級類型</th>
          <th>典型時程</th>
          <th>主要成本來源</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>PHP 小版本（8.0→8.2）</td>
          <td>2-5 天</td>
          <td>依賴更新 + 測試</td>
      </tr>
      <tr>
          <td>PHP 跨大版本（7.4→8.x）</td>
          <td>1-2 週</td>
          <td>函式替換 + 行為驗證</td>
      </tr>
      <tr>
          <td>PHP 跳代（5.6→8.x）</td>
          <td>4-8 週</td>
          <td>大量程式碼修改 + 框架升級</td>
      </tr>
      <tr>
          <td>Node.js 大版本</td>
          <td>3-5 天</td>
          <td>原生模組重編 + API 變更</td>
      </tr>
      <tr>
          <td>Python 2→3</td>
          <td>8-16 週</td>
          <td>接近重寫等級</td>
      </tr>
  </tbody>
</table>
<p>向管理層溝通時要說明：「升級 runtime 版本不只是在伺服器改一個設定。程式碼裡用到的函式和行為在新版本有不同的定義，需要逐一修改和驗證。時程取決於程式碼用了多少舊版本的專屬功能。」</p>
<p>成本參考：PHP 版本升級本身的工具和環境不花錢（PHPCompatibility 開源、Docker 免費、cPanel 版本切換內建）。成本全在工程師時間。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/upgrade/upgrade-framework/" data-link-title="升級的共通操作框架" data-link-desc="任何環境或系統升級的四階段模型：差異評估、平行環境驗證、分批切換、退役舊環境，以及貫穿全程的升級紀律">升級的共通操作框架</a>：四階段模型（評估 → 平行環境 → 切換 → 退役）</li>
<li>→ <a href="/blog/infra/takeover/legacy-php-security-audit/" data-link-title="Legacy PHP 的安全盤點" data-link-desc="接手 legacy PHP 專案後的系統性安全審查：credential 掃描、PHP 版本風險、常見漏洞模式的 grep 偵測、.htaccess 防線、檔案權限、外部依賴與掃描工具">Legacy PHP 的安全盤點</a>：PHP 版本風險評估與漏洞掃描</li>
<li>→ <a href="/blog/infra/takeover/legacy-code-versioning-deployment/" data-link-title="程式碼版控與 FTP 部署紀律" data-link-desc="無 SSH 環境的 PHP 專案的程式碼怎麼從 FTP 拉回來建 Git repo、設定檔怎麼分離、FTP 部署怎麼建立可追蹤的流程、以及怎麼用 CI 取代手動上傳">程式碼版控與 FTP 部署紀律</a>：升級前的 Git 基準線與 rollback 策略</li>
</ul>
]]></content:encoded></item><item><title>Security Group 稽核與清理</title><link>https://tarrragon.github.io/blog/infra/03-network-foundation/security-group-audit-cleanup/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/03-network-foundation/security-group-audit-cleanup/</guid><description>&lt;p>Security group 的規則會隨時間累積：某次救火加了一條 0.0.0.0/0、某個已下線的服務留下沒人認領的 SG、某條規則的用途只存在建立者的記憶裡。稽核的目標是把這些累積的規則攤開來，逐條回答「這條規則還有在用嗎、來源該這麼寬嗎」，然後安全地清理不需要的部分。&lt;/p>
&lt;h2 id="匯出所有-security-group-與規則">匯出所有 security group 與規則&lt;/h2>
&lt;p>稽核的第一步是把當前所有 SG 和它們的規則拉出來存成可查詢的 JSON。這份 JSON 是後續所有分析的輸入，也是「稽核那天環境長什麼樣」的快照。&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> --query &lt;span class="s1">&amp;#39;SecurityGroups[].{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="s1"> GroupId:GroupId,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="s1"> GroupName:GroupName,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="s1"> VpcId:VpcId,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="s1"> Description:Description,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="s1"> IngressRules:IpPermissions,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="s1"> EgressRules:IpPermissionsEgress,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="s1"> Tags:Tags
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="s1"> }&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --output json &amp;gt; sg-inventory-&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>這份檔案通常幾百 KB 到幾 MB，存進 repo 的 &lt;code>inventory/&lt;/code> 目錄，方便日後比對變化。如果帳號有多個 region，每個 region 各跑一次並標明 region。&lt;/p>
&lt;p>用 jq 快速看有多少 SG 和總規則數：&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">jq &lt;span class="s1">&amp;#39;length&amp;#39;&lt;/span> sg-inventory-*.json
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">jq &lt;span class="s1">&amp;#39;[.[].IngressRules | length] | add&amp;#39;&lt;/span> sg-inventory-*.json&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="找出-00000-全開的入站規則">找出 0.0.0.0/0 全開的入站規則&lt;/h2>
&lt;p>0.0.0.0/0 入站代表允許整個網際網路連到這個埠。對外 ALB 的 80/443 開 0.0.0.0/0 是設計意圖，但資料庫埠（5432、3306、6379）、SSH（22）或管理埠開 0.0.0.0/0 是需要收斂的目標。&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">jq -r &lt;span class="s1">&amp;#39;.[] | select(.IngressRules[]?.IpRanges[]?.CidrIp == &amp;#34;0.0.0.0/0&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="s1"> {GroupId, GroupName, OpenPorts: [.IngressRules[] |
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="s1"> select(.IpRanges[]?.CidrIp == &amp;#34;0.0.0.0/0&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="s1"> &amp;#34;\(.FromPort // &amp;#34;all&amp;#34;)-\(.ToPort // &amp;#34;all&amp;#34;)/\(.IpProtocol)&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="s1"> ]}&amp;#39;&lt;/span> sg-inventory-*.json&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>輸出會列出每個有全開規則的 SG 和對應的 port 範圍。對每一條命中，判斷：&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>ALB 的 80/443&lt;/td>
 &lt;td>合規 — 負載平衡器的職責就是接收公開流量&lt;/td>
 &lt;td>保留，標記為已審查&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>SSH (22) 或 RDP (3389)&lt;/td>
 &lt;td>需收斂 — 管理埠暴露在持續的暴力掃描下&lt;/td>
 &lt;td>改用 SSM Session Manager 或限縮到辦公室 IP&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>資料庫埠 (5432/3306/6379)&lt;/td>
 &lt;td>需收斂 — 資料庫不應從公網可達&lt;/td>
 &lt;td>改為只允許應用層 SG 來源&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>全埠 (0-65535 / -1)&lt;/td>
 &lt;td>需收斂 — 等於沒有防火牆&lt;/td>
 &lt;td>拆成具體需要的埠和來源&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>IPv6 的 &lt;code>::/0&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">jq -r &lt;span class="s1">&amp;#39;.[] | select(.IngressRules[]?.Ipv6Ranges[]?.CidrIpv6 == &amp;#34;::/0&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="s1"> .GroupId&amp;#39;&lt;/span> sg-inventory-*.json&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="找出未使用的-security-group">找出未使用的 security group&lt;/h2>
&lt;p>未使用的 SG 是沒有任何網路介面（ENI）掛載的 SG。它不影響任何正在運行的資源，但佔用 SG 配額（每個 VPC 預設上限 2500 個），而且它的規則會讓稽核清單更長、更難判讀。&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-network-interfaces &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> --query &lt;span class="s1">&amp;#39;NetworkInterfaces[].Groups[].GroupId&amp;#39;&lt;/span> &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 text &lt;span class="p">|&lt;/span> tr &lt;span class="s1">&amp;#39;\t&amp;#39;&lt;/span> &lt;span class="s1">&amp;#39;\n&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span> sort -u &amp;gt; sg-in-use.txt
&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">jq -r &lt;span class="s1">&amp;#39;.[].GroupId&amp;#39;&lt;/span> sg-inventory-*.json &lt;span class="p">|&lt;/span> sort -u &amp;gt; sg-all.txt
&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">comm -23 sg-all.txt sg-in-use.txt &amp;gt; sg-unused.txt
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">cat sg-unused.txt&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>sg-unused.txt&lt;/code> 裡列出的就是當前沒有任何 ENI 引用的 SG。注意幾個例外：&lt;/p></description><content:encoded><![CDATA[<p>Security group 的規則會隨時間累積：某次救火加了一條 0.0.0.0/0、某個已下線的服務留下沒人認領的 SG、某條規則的用途只存在建立者的記憶裡。稽核的目標是把這些累積的規則攤開來，逐條回答「這條規則還有在用嗎、來源該這麼寬嗎」，然後安全地清理不需要的部分。</p>
<h2 id="匯出所有-security-group-與規則">匯出所有 security group 與規則</h2>
<p>稽核的第一步是把當前所有 SG 和它們的規則拉出來存成可查詢的 JSON。這份 JSON 是後續所有分析的輸入，也是「稽核那天環境長什麼樣」的快照。</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>  --query <span class="s1">&#39;SecurityGroups[].{
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s1">    GroupId:GroupId,
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s1">    GroupName:GroupName,
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s1">    VpcId:VpcId,
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s1">    Description:Description,
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s1">    IngressRules:IpPermissions,
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s1">    EgressRules:IpPermissionsEgress,
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s1">    Tags:Tags
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s1">  }&#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; sg-inventory-<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>.json</span></span></code></pre></div><p>這份檔案通常幾百 KB 到幾 MB，存進 repo 的 <code>inventory/</code> 目錄，方便日後比對變化。如果帳號有多個 region，每個 region 各跑一次並標明 region。</p>
<p>用 jq 快速看有多少 SG 和總規則數：</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">jq <span class="s1">&#39;length&#39;</span> sg-inventory-*.json
</span></span><span class="line"><span class="ln">2</span><span class="cl">jq <span class="s1">&#39;[.[].IngressRules | length] | add&#39;</span> sg-inventory-*.json</span></span></code></pre></div><h2 id="找出-00000-全開的入站規則">找出 0.0.0.0/0 全開的入站規則</h2>
<p>0.0.0.0/0 入站代表允許整個網際網路連到這個埠。對外 ALB 的 80/443 開 0.0.0.0/0 是設計意圖，但資料庫埠（5432、3306、6379）、SSH（22）或管理埠開 0.0.0.0/0 是需要收斂的目標。</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">jq -r <span class="s1">&#39;.[] | select(.IngressRules[]?.IpRanges[]?.CidrIp == &#34;0.0.0.0/0&#34;) |
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s1">  {GroupId, GroupName, OpenPorts: [.IngressRules[] |
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s1">    select(.IpRanges[]?.CidrIp == &#34;0.0.0.0/0&#34;) |
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s1">    &#34;\(.FromPort // &#34;all&#34;)-\(.ToPort // &#34;all&#34;)/\(.IpProtocol)&#34;
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s1">  ]}&#39;</span> sg-inventory-*.json</span></span></code></pre></div><p>輸出會列出每個有全開規則的 SG 和對應的 port 範圍。對每一條命中，判斷：</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>全開是否合規</th>
          <th>處理方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>ALB 的 80/443</td>
          <td>合規 — 負載平衡器的職責就是接收公開流量</td>
          <td>保留，標記為已審查</td>
      </tr>
      <tr>
          <td>SSH (22) 或 RDP (3389)</td>
          <td>需收斂 — 管理埠暴露在持續的暴力掃描下</td>
          <td>改用 SSM Session Manager 或限縮到辦公室 IP</td>
      </tr>
      <tr>
          <td>資料庫埠 (5432/3306/6379)</td>
          <td>需收斂 — 資料庫不應從公網可達</td>
          <td>改為只允許應用層 SG 來源</td>
      </tr>
      <tr>
          <td>全埠 (0-65535 / -1)</td>
          <td>需收斂 — 等於沒有防火牆</td>
          <td>拆成具體需要的埠和來源</td>
      </tr>
  </tbody>
</table>
<p>IPv6 的 <code>::/0</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">jq -r <span class="s1">&#39;.[] | select(.IngressRules[]?.Ipv6Ranges[]?.CidrIpv6 == &#34;::/0&#34;) |
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s1">  .GroupId&#39;</span> sg-inventory-*.json</span></span></code></pre></div><h2 id="找出未使用的-security-group">找出未使用的 security group</h2>
<p>未使用的 SG 是沒有任何網路介面（ENI）掛載的 SG。它不影響任何正在運行的資源，但佔用 SG 配額（每個 VPC 預設上限 2500 個），而且它的規則會讓稽核清單更長、更難判讀。</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-network-interfaces <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;NetworkInterfaces[].Groups[].GroupId&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></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> sort -u &gt; sg-in-use.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">jq -r <span class="s1">&#39;.[].GroupId&#39;</span> sg-inventory-*.json <span class="p">|</span> sort -u &gt; sg-all.txt
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl">comm -23 sg-all.txt sg-in-use.txt &gt; sg-unused.txt
</span></span><span class="line"><span class="ln">8</span><span class="cl">cat sg-unused.txt</span></span></code></pre></div><p><code>sg-unused.txt</code> 裡列出的就是當前沒有任何 ENI 引用的 SG。注意幾個例外：</p>
<ul>
<li><strong>default SG</strong>：每個 VPC 都有一個 default SG，即使未使用也無法刪除，可以跳過</li>
<li><strong>被其他 SG 引用</strong>：某個 SG 雖然沒有掛在任何 ENI 上，但被另一個 SG 的入站規則引用為 source — 刪除它會讓引用方的規則失效</li>
<li><strong>被 launch template 或 auto-scaling group 引用</strong>：新啟動的實例會套用這個 SG，刪了之後新實例啟動會失敗</li>
</ul>
<h2 id="依賴檢查刪除前確認沒有間接引用">依賴檢查：刪除前確認沒有間接引用</h2>
<p>直接刪一個 SG 之前，確認沒有其他資源引用它。AWS 在 SG 被引用時會擋住刪除（報 DependencyViolation），但提前知道引用方可以避免白跑一趟。</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="nv">SG_ID</span><span class="o">=</span><span class="s2">&#34;sg-0abc123&#34;</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"># 哪些 SG 的入站規則引用了這個 SG 作為來源</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">jq -r --arg sg <span class="s2">&#34;</span><span class="nv">$SG_ID</span><span class="s2">&#34;</span> <span class="s1">&#39;.[] |
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s1">  select(.IngressRules[]?.UserIdGroupPairs[]?.GroupId == $sg) |
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s1">  &#34;\(.GroupId) (\(.GroupName)) 的入站規則引用了 \($sg)&#34;&#39;</span> sg-inventory-*.json
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 哪些 ENI 掛了這個 SG</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">aws ec2 describe-network-interfaces <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --filters <span class="nv">Name</span><span class="o">=</span>group-id,Values<span class="o">=</span><span class="nv">$SG_ID</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;NetworkInterfaces[].{Id:NetworkInterfaceId,Desc:Description,Status:Status}&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="se"></span>  --output table
</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="c1"># 哪些 RDS instance 使用這個 SG</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">aws rds describe-db-instances <span class="se">\
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="se"></span>  --query <span class="s2">&#34;DBInstances[?VpcSecurityGroups[?VpcSecurityGroupId==&#39;</span><span class="nv">$SG_ID</span><span class="s2">&#39;]].[DBInstanceIdentifier]&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="se"></span>  --output text
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"># 哪些 ELB 使用這個 SG</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">aws elbv2 describe-load-balancers <span class="se">\
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="se"></span>  --query <span class="s2">&#34;LoadBalancers[?SecurityGroups[?contains(@,&#39;</span><span class="nv">$SG_ID</span><span class="s2">&#39;)]].[LoadBalancerName]&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="se"></span>  --output text</span></span></code></pre></div><p>如果所有查詢都回傳空，這個 SG 可以安全刪除。</p>
<h2 id="清理流程標記--通知--等待--刪除">清理流程：標記 → 通知 → 等待 → 刪除</h2>
<p>批量清理不是一次 <code>delete-security-group</code> 的事。安全的流程有四步：</p>
<h3 id="標記候選">標記候選</h3>
<p>對每個要清理的 SG 加一個 tag 標明狀態和預定刪除日期：</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 create-tags <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --resources sg-0abc123 sg-0def456 <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --tags <span class="nv">Key</span><span class="o">=</span>cleanup-status,Value<span class="o">=</span>pending-deletion <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>         <span class="nv">Key</span><span class="o">=</span>cleanup-date,Value<span class="o">=</span>2026-07-10 <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>         <span class="nv">Key</span><span class="o">=</span>cleanup-reason,Value<span class="o">=</span><span class="s2">&#34;unused-no-eni-no-reference&#34;</span></span></span></code></pre></div><h3 id="通知">通知</h3>
<p>如果 SG 有 <code>owner</code> tag，通知該 owner：「這個 SG 預計在 cleanup-date 刪除，如果仍在使用請回報」。如果沒有 owner tag（多數需要清理的 SG 都沒有），在團隊頻道公告清理清單。</p>
<h3 id="等待">等待</h3>
<p>給 7-14 天的寬限期。期間如果有人回報某個 SG 仍在使用，把 cleanup-status 改成 <code>retained</code> 並補上正確的 owner tag。</p>
<h3 id="刪除">刪除</h3>
<p>寬限期過後，對仍是 <code>pending-deletion</code> 的 SG 執行刪除：</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="k">for</span> sg in <span class="k">$(</span>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>  --filters <span class="nv">Name</span><span class="o">=</span>tag:cleanup-status,Values<span class="o">=</span>pending-deletion <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;SecurityGroups[].GroupId&#39;</span> --output text<span class="k">)</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;Deleting </span><span class="nv">$sg</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  aws ec2 delete-security-group --group-id <span class="nv">$sg</span> 2&gt;<span class="p">&amp;</span><span class="m">1</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">done</span></span></span></code></pre></div><p>DependencyViolation 代表有遺漏的引用，跳過該 SG 並重新調查。</p>
<h2 id="自動化持續治理">自動化持續治理</h2>
<p>手動稽核適合第一次清理，持續治理靠自動化：</p>
<h3 id="aws-config-規則">AWS Config 規則</h3>
<p><code>restricted-ssh</code> 和 <code>restricted-common-ports</code> 是 AWS Config 的 managed rule，啟用後會持續監控 SG 規則，新增的 0.0.0.0/0 規則會在幾分鐘內被標記為 non-compliant。</p>
<h3 id="prowler-定期掃描">Prowler 定期掃描</h3>
<p>在 CI 排程中定期跑 Prowler，掃描結果存進 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">prowler aws --services ec2 --checks ec2_securitygroup_allow_ingress_from_internet_to_any_port <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  -M json-ocsf -o inventory/prowler/</span></span></code></pre></div><h3 id="pr-流程攔截">PR 流程攔截</h3>
<p><a href="/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">模組七的 checkov/tfsec 護欄</a>在 PR 階段攔截新增的 0.0.0.0/0 規則。這是把治理從「事後稽核」推到「事前攔截」的關鍵一步：稽核能發現已存在的問題，PR 護欄能阻止新問題被引入。</p>
<p>AWS Security Hub 啟用 Foundational Security Best Practices 標準後，會自動聚合 SG 相關的合規 finding 並提供統一 dashboard，適合作為管理層報告的來源。Security Hub 整合了 Config rules 和 Prowler 各自能發現的問題，提供單一窗口追蹤合規趨勢。</p>
<h2 id="稽核節奏">稽核節奏</h2>
<p>第一次稽核最花時間（半天到一天，取決於 SG 數量）。之後的節奏取決於環境變動速度：</p>
<table>
  <thead>
      <tr>
          <th>環境類型</th>
          <th>建議節奏</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>有 PR 流程 + checkov 的環境</td>
          <td>每季</td>
          <td>新規則已被 PR 攔截，稽核主要看 drift</td>
      </tr>
      <tr>
          <td>有 IaC 但沒有 PR 護欄</td>
          <td>每月</td>
          <td>手動 apply 可能繞過審查</td>
      </tr>
      <tr>
          <td>全手動環境</td>
          <td>每月或每次事故後</td>
          <td>沒有任何自動攔截機制</td>
      </tr>
  </tbody>
</table>
<p>稽核產出一份報告：SG 總數、0.0.0.0/0 規則數、未使用 SG 數、上次稽核以來的變化。這份報告可以作為治理進度的量化指標，納入月報。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/03-network-foundation/vpc-subnet-security-group/" data-link-title="網路地基 — VPC、subnet 分層與 security group 設計" data-link-desc="VPC CIDR 規劃、public / private subnet 切分、route table 與 NAT 的可用性成本取捨、security group 最小開放設計，以及 NACL 的定位">網路地基 — security group 設計</a>：SG 的設計原則（最小開放、group 互相引用）</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">infra 走 PR 流程</a>：checkov/tfsec 在 PR 階段攔截 0.0.0.0/0</li>
<li>→ <a href="/blog/infra/08-governance-habits/tagging-secrets/" data-link-title="Tagging 規範與 Secrets 不進 code" data-link-desc="tag 讓資源可盤點、可清理、可歸屬；密鑰存在專用服務裡而非 code 或 state，兩者都屬於 day-1 就該立的治理地基">治理好習慣 — tagging</a>：tag 是識別 SG owner 和清理候選的依據</li>
</ul>
]]></content:encoded></item><item><title>State（IaC 狀態檔）</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/state/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/state/</guid><description>&lt;p>State 是 IaC 工具用來記錄「上一次 apply 之後，每個資源在雲端長什麼樣」的快照。它的作用是讓工具能算出「程式碼描述的目標」與「雲端上的現況」之間的最小差異。沒有 state，工具每次都得把所有資源重新查一遍才知道該不該動，而且無法分辨「這個資源是我建的、該由我管」還是「別人手動建的、不歸我管」。&lt;/p>
&lt;p>State 裡通常含有資源的真實 ID、相依關係，以及部分敏感屬性（例如資料庫的初始密碼、private key 的輸出值）。這帶來兩條硬邊界：state 不能進 git（含敏感值，推進版控等於把密碼寫進每個 clone 的歷史）、state 不能只放本地（本地 state 的失敗模式是記憶綁在一台筆電上，多人並行 apply 會互相覆蓋）。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>State 是 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC&lt;/a> 的記憶機制。&lt;a href="https://tarrragon.github.io/blog/infra/01-minimal-iac/iac-tool-state-backend/" data-link-title="IaC 工具選型與 state 地基" data-link-desc="Terraform / OpenTofu / CDK / Pulumi 的選型判準，state 作為 IaC 工具對現實的唯一記憶，以及 remote state backend 的自管與託管路線">模組一：最小可行 IaC&lt;/a> 的核心主題就是怎麼把 state 管好——remote backend、加密、鎖機制。State 管不好，後續所有 IaC 操作都建立在不可靠的記憶上。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>State 出問題的訊號包括：&lt;code>terraform plan&lt;/code> 顯示大量非預期的變更（state 與現實不一致）、兩個人同時 apply 後環境出現矛盾狀態、&lt;code>state list&lt;/code> 的資源數與 Console 上看到的不一致。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>管理 state 時要決定：&lt;/p>
&lt;ul>
&lt;li>存放位置：S3 + DynamoDB（自管）vs Terraform Cloud（託管），取捨在維運負擔 vs 控制權&lt;/li>
&lt;li>加密：state 含敏感值，落地加密（S3 SSE）是底線&lt;/li>
&lt;li>版本保留：bucket versioning 讓 state 損壞時能回捲到上一個正確版本&lt;/li>
&lt;li>鎖機制：防止兩個人同時 apply 互相覆蓋&lt;/li>
&lt;li>分割策略：一個大 state vs 多個小 state，取捨在引用便利性 vs 影響範圍控制&lt;/li>
&lt;/ul>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC&lt;/a> — state 是 IaC 工具的核心依賴&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/drift/" data-link-title="Drift（設定漂移）" data-link-desc="IaC 的 state 與雲端實際狀態之間的不一致，通常因為有人繞過 IaC 直接在 Console 改設定">Drift&lt;/a> — state 與現實的落差&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>State 是 IaC 工具用來記錄「上一次 apply 之後，每個資源在雲端長什麼樣」的快照。它的作用是讓工具能算出「程式碼描述的目標」與「雲端上的現況」之間的最小差異。沒有 state，工具每次都得把所有資源重新查一遍才知道該不該動，而且無法分辨「這個資源是我建的、該由我管」還是「別人手動建的、不歸我管」。</p>
<p>State 裡通常含有資源的真實 ID、相依關係，以及部分敏感屬性（例如資料庫的初始密碼、private key 的輸出值）。這帶來兩條硬邊界：state 不能進 git（含敏感值，推進版控等於把密碼寫進每個 clone 的歷史）、state 不能只放本地（本地 state 的失敗模式是記憶綁在一台筆電上，多人並行 apply 會互相覆蓋）。</p>
<h2 id="概念位置">概念位置</h2>
<p>State 是 <a href="/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC</a> 的記憶機制。<a href="/blog/infra/01-minimal-iac/iac-tool-state-backend/" data-link-title="IaC 工具選型與 state 地基" data-link-desc="Terraform / OpenTofu / CDK / Pulumi 的選型判準，state 作為 IaC 工具對現實的唯一記憶，以及 remote state backend 的自管與託管路線">模組一：最小可行 IaC</a> 的核心主題就是怎麼把 state 管好——remote backend、加密、鎖機制。State 管不好，後續所有 IaC 操作都建立在不可靠的記憶上。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>State 出問題的訊號包括：<code>terraform plan</code> 顯示大量非預期的變更（state 與現實不一致）、兩個人同時 apply 後環境出現矛盾狀態、<code>state list</code> 的資源數與 Console 上看到的不一致。</p>
<h2 id="設計責任">設計責任</h2>
<p>管理 state 時要決定：</p>
<ul>
<li>存放位置：S3 + DynamoDB（自管）vs Terraform Cloud（託管），取捨在維運負擔 vs 控制權</li>
<li>加密：state 含敏感值，落地加密（S3 SSE）是底線</li>
<li>版本保留：bucket versioning 讓 state 損壞時能回捲到上一個正確版本</li>
<li>鎖機制：防止兩個人同時 apply 互相覆蓋</li>
<li>分割策略：一個大 state vs 多個小 state，取捨在引用便利性 vs 影響範圍控制</li>
</ul>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC</a> — state 是 IaC 工具的核心依賴</li>
<li><a href="/blog/infra/knowledge-cards/drift/" data-link-title="Drift（設定漂移）" data-link-desc="IaC 的 state 與雲端實際狀態之間的不一致，通常因為有人繞過 IaC 直接在 Console 改設定">Drift</a> — state 與現實的落差</li>
</ul>
]]></content:encoded></item><item><title>Terraform CI Pipeline 設定指南</title><link>https://tarrragon.github.io/blog/infra/07-infra-as-pr/terraform-ci-pipeline-setup/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/07-infra-as-pr/terraform-ci-pipeline-setup/</guid><description>&lt;p>Terraform 的 PR 流程要發揮價值，plan 和 apply 需要在 CI 裡自動執行，而非在工程師的本機跑。本篇用 GitHub Actions 建立一條完整的 pipeline：PR 開啟時跑檢查和 plan、plan 結果貼回 PR comment 讓 reviewer 看、合併到主幹後才 apply。整條管線的 credential 用 OIDC 取得短期 token（見 &lt;a href="https://tarrragon.github.io/blog/infra/02-identity-credentials/oidc-trust-policy-setup/" data-link-title="OIDC Trust Policy 設定指南" data-link-desc="GitHub Actions 與 AWS 之間的 OIDC 聯合設定：建立 provider、設計 trust policy 的 claim 收斂、plan 與 apply role 分離、常見錯誤排查">OIDC Trust Policy 設定&lt;/a>），不存任何長期 key。&lt;/p>
&lt;h2 id="pipeline-的兩個階段">Pipeline 的兩個階段&lt;/h2>
&lt;p>整條 pipeline 分成兩個觸發時機，各自承擔不同責任：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>階段&lt;/th>
 &lt;th>觸發條件&lt;/th>
 &lt;th>責任&lt;/th>
 &lt;th>失敗時&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Plan&lt;/td>
 &lt;td>PR 開啟或更新&lt;/td>
 &lt;td>檢查格式、驗證語法、靜態掃描、產出 plan diff&lt;/td>
 &lt;td>PR 無法合併&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Apply&lt;/td>
 &lt;td>合併到 main&lt;/td>
 &lt;td>把 plan 過的變更套用到雲端&lt;/td>
 &lt;td>需要人工介入&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩個階段用不同的 IAM role：plan role 只有唯讀權限（能跑 &lt;code>terraform plan&lt;/code> 但不能改任何資源），apply role 有寫入權限。這個分離確保 PR 階段的任何 code 都沒辦法偷偷改動雲端資源。&lt;/p>
&lt;h2 id="plan-階段的完整-workflow">Plan 階段的完整 workflow&lt;/h2>





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Terraform Apply</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">paths</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">      </span>- <span class="s1">&#39;infra/**&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">  </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">  </span><span class="nt">apply</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w"> </span><span class="l">production</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">    </span><span class="nt">defaults</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">      </span><span class="nt">run</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">        </span><span class="nt">working-directory</span><span class="p">:</span><span class="w"> </span><span class="l">infra/environments/prod</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">aws-actions/configure-aws-credentials@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="w">          </span><span class="nt">role-to-assume</span><span class="p">:</span><span class="w"> </span><span class="l">arn:aws:iam::123456789012:role/infra-apply</span><span class="w">
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="w">          </span><span class="nt">aws-region</span><span class="p">:</span><span class="w"> </span><span class="l">ap-northeast-1</span><span class="w">
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">hashicorp/setup-terraform@v3</span><span class="w">
</span></span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="w">          </span><span class="nt">terraform_version</span><span class="p">:</span><span class="w"> </span><span class="m">1.9.0</span><span class="w">
</span></span></span><span class="line"><span class="ln">31</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">32</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Init</span><span class="w">
</span></span></span><span class="line"><span class="ln">33</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform init -input=false</span><span class="w">
</span></span></span><span class="line"><span class="ln">34</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">35</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Plan (verify)</span><span class="w">
</span></span></span><span class="line"><span class="ln">36</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform plan -no-color -input=false -detailed-exitcode</span><span class="w">
</span></span></span><span class="line"><span class="ln">37</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">38</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Apply</span><span class="w">
</span></span></span><span class="line"><span class="ln">39</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform apply -auto-approve -input=false</span></span></span></code></pre></div><h3 id="environment-protection-rule">environment protection rule</h3>
<p><code>environment: production</code> 這一行啟用 GitHub 的環境保護功能。在 repo 的 Settings → Environments → production 設定：</p>
<ul>
<li><strong>Required reviewers</strong>：指定至少一個人 approve 才能執行 apply job</li>
<li><strong>Wait timer</strong>：合併後等 N 分鐘才開始 apply（給人反應時間）</li>
<li><strong>Deployment branches</strong>：限定只有 main branch 能觸發</li>
</ul>
<p>這層保護讓高風險的變更（plan 顯示 destroy 或 replace）在 apply 前多一道人工確認。日常低風險變更（加一個 tag、調一個參數）可以直接通過。取捨點是：每次 apply 都要人按確認會拖慢頻繁的小變更，可以用 deployment rule 的條件只攔 production 環境。</p>
<h3 id="apply-階段重跑-plan-的理由">Apply 階段重跑 plan 的理由</h3>
<p>apply 之前重跑一次 plan，是為了驗證合併後的現實跟 PR review 時看到的一致。PR 從開啟到合併可能隔了幾小時或幾天，期間有人可能手動改了雲端資源（drift）或別的 PR 先 apply 了。重跑 plan 確認差異跟預期一致，不一致就停下來而非盲目 apply。</p>
<p>如果使用了 plan 階段的 <code>-out=tfplan</code> 保存 plan 檔，apply 可以改為 <code>terraform apply tfplan</code> 直接執行已 review 過的 plan。代價是 plan 檔需要跨 job 傳遞（GitHub Actions 的 artifact），且 plan 檔有時效——state 在 plan 之後被修改，apply 會拒絕執行。</p>
<h2 id="多環境的-pipeline-設計">多環境的 pipeline 設計</h2>
<p>管理 dev / staging / prod 三個環境時，pipeline 有兩種常見結構：</p>
<p><strong>單 workflow 加 matrix</strong>：一份 YAML 用 <code>strategy.matrix</code> 跑三個環境，每個環境有自己的 working directory 和 IAM role。好處是維護一份 YAML；代價是三個環境的 plan 都在同一次 PR run 裡，reviewer 要看三份 plan 輸出。</p>
<p><strong>每環境獨立 workflow</strong>：三份 YAML 各自觸發在對應環境目錄的變更上（<code>paths: ['infra/environments/dev/**']</code>）。好處是只有改到的環境才跑、PR comment 乾淨；代價是三份 YAML 有重複。</p>
<p>多數團隊起步時用單 workflow + matrix，環境數量超過三個或各環境的 apply 策略不同（dev 自動、prod 要 approval）時切到獨立 workflow。</p>
<h2 id="安全邊界">安全邊界</h2>
<p>CI pipeline 是 infra 變更的自動化執行者，它的安全性等同於 apply role 的權限。幾個邊界要守住：</p>
<p><strong>OIDC claim 收斂</strong>：apply role 的 trust policy 只允許特定 repo 的 main branch 假扮（見 <a href="/blog/infra/02-identity-credentials/oidc-trust-policy-setup/" data-link-title="OIDC Trust Policy 設定指南" data-link-desc="GitHub Actions 與 AWS 之間的 OIDC 聯合設定：建立 provider、設計 trust policy 的 claim 收斂、plan 與 apply role 分離、常見錯誤排查">OIDC Trust Policy 設定</a>）。如果 claim 只驗 repo 不驗 branch，任何人在 feature branch 推一個修改過的 workflow 就能觸發 apply。</p>
<p><strong>Workflow 修改的 review</strong>：<code>.github/workflows/</code> 底下的 YAML 變更應該跟 infra code 一樣走 PR review。修改 workflow 等於修改 pipeline 的行為——加一個 <code>terraform destroy</code> step 就能在合併時清掉整個環境。GitHub 的 CODEOWNERS 功能可以強制特定人 review workflow 變更。</p>
<p><strong>Secret 與 environment variable</strong>：OIDC 取代了存在 repo secrets 裡的 access key，但 workflow 可能還用到其他 secret（Terraform Cloud token、Slack webhook URL）。這些 secret 要限定在特定 environment 才能存取，不開放給所有 branch。</p>
<p>本篇聚焦 GitHub Actions。如果團隊選擇 Atlantis（常駐服務、內建 state lock 與 apply 語意），見<a href="/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">主文章的 Atlantis 段</a>的選型討論。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/02-identity-credentials/oidc-trust-policy-setup/" data-link-title="OIDC Trust Policy 設定指南" data-link-desc="GitHub Actions 與 AWS 之間的 OIDC 聯合設定：建立 provider、設計 trust policy 的 claim 收斂、plan 與 apply role 分離、常見錯誤排查">OIDC Trust Policy 設定</a>：pipeline 的 credential 來源</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/checkov-tfsec-rule-customization/" data-link-title="checkov 與 tfsec 規則配置" data-link-desc="靜態掃描工具的規則選擇策略、自訂規則、豁免管理、false positive 處理與 CI 整合，讓掃描從噪音來源變成可信的品質關卡">checkov / tfsec 規則配置</a>：pipeline 裡的靜態安全掃描怎麼配</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">infra 走 PR 流程與自動化護欄</a>：pipeline 背後的審查原則</li>
<li>→ <a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>：多環境的目錄結構決定 pipeline 的 working directory</li>
</ul>
]]></content:encoded></item><item><title>成本可見性與最小可行治理節奏</title><link>https://tarrragon.github.io/blog/infra/08-governance-habits/cost-visibility-rhythm/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/08-governance-habits/cost-visibility-rhythm/</guid><description>&lt;p>治理習慣的責任是讓基礎設施在規模長大後仍然可被盤點、可被追責、可被回收。資源歸屬靠 tagging、密鑰安全靠 secret 管理（見 &lt;a href="https://tarrragon.github.io/blog/infra/08-governance-habits/tagging-secrets/" data-link-title="Tagging 規範與 Secrets 不進 code" data-link-desc="tag 讓資源可盤點、可清理、可歸屬；密鑰存在專用服務裡而非 code 或 state，兩者都屬於 day-1 就該立的治理地基">tagging 與 secrets&lt;/a>），本篇處理兩個後續問題：成本怎麼拆解到擁有者，以及治理規範的節奏怎麼拿捏 — 什麼該第一天就立、什麼等到痛點出現再加。&lt;/p>
&lt;p>先界定邊界。成本這一塊分兩層：把資源歸屬到擁有者與用途的地基（tagging、chargeback 的依據）在這裡，運行期怎麼用 reserved instance、spot、rightsizing 去壓低帳單，是 &lt;a href="https://tarrragon.github.io/blog/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">devops 模組八：成本管理&lt;/a> 的範圍。&lt;/p>
&lt;h2 id="成本可見性每筆花費都對得到擁有者與用途">成本可見性：每筆花費都對得到擁有者與用途&lt;/h2>
&lt;p>成本可見性的目標是讓帳單上的每一筆花費都能回答「這是誰的、為了什麼」。雲帳單預設是一筆按服務類型加總的數字 — EC2 多少、RDS 多少 — 這個視角能告訴你花在哪類資源，卻答不出花在哪個團隊、哪個產品線、哪個功能。當這個問題答不出來，成本就變成一筆沒人負責的公共支出，沒有人有動機去優化自己看不到的帳。&lt;/p>
&lt;h3 id="tag-驅動的成本分攤">Tag 驅動的成本分攤&lt;/h3>
&lt;p>把成本拆解到擁有者的地基，正是 tagging。雲廠商的成本分攤工具（AWS Cost Explorer、Cost Allocation Tags、GCP 的 billing label）能用 tag 當分群維度，前提是那些 tag 要先在 billing 後台啟用為「成本分攤標籤（Cost Allocation Tag）」。啟用是一次性設定，之後新建的資源只要帶了這個 tag，費用就會自動歸入對應維度。&lt;/p>
&lt;p>啟用後，&lt;code>cost-center&lt;/code> 和 &lt;code>owner&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="c1"># 用 AWS CLI 查某個 cost-center 的月費用&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">aws ce get-cost-and-usage &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> --time-period &lt;span class="nv">Start&lt;/span>&lt;span class="o">=&lt;/span>2026-06-01,End&lt;span class="o">=&lt;/span>2026-06-30 &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> --granularity MONTHLY &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --filter &lt;span class="s1">&amp;#39;{&amp;#34;Tags&amp;#34;:{&amp;#34;Key&amp;#34;:&amp;#34;cost-center&amp;#34;,&amp;#34;Values&amp;#34;:[&amp;#34;cc-1024&amp;#34;]}}&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --metrics BlendedCost &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> --group-by &lt;span class="nv">Type&lt;/span>&lt;span class="o">=&lt;/span>TAG,Key&lt;span class="o">=&lt;/span>owner&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>「team-payments 這個月花多少」「staging 環境占總成本幾成」變成一張報表而不是一場會議。&lt;/p>
&lt;h3 id="成本異常告警">成本異常告警&lt;/h3>
&lt;p>可見性先於優化，這個順序不能反。看不見的成本無法被歸屬，無法歸屬就無法問責，沒有問責就沒有人去做優化。在可見性建立之後，下一步是設一條成本異常告警：&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_ce_anomaly_monitor&amp;#34; &amp;#34;cost&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;daily-cost-anomaly&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"> monitor_type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;DIMENSIONAL&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"> monitor_dimension&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;SERVICE&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&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_ce_anomaly_subscription&amp;#34; &amp;#34;alert&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"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;cost-anomaly-alert&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="n"> frequency&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;DAILY&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 class="n"> monitor_arn_list&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="k">aws_ce_anomaly_monitor&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">cost&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>&lt;span class="p">]&lt;/span>
&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 class="k">subscriber&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&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;SNS&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"> address&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_sns_topic&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">cost_alerts&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>&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 class="k">threshold_expression&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="k">dimension&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"> key&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;ANOMALY_TOTAL_IMPACT_ABSOLUTE&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="n"> values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;100&amp;#34;&lt;/span>&lt;span class="p">]&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"> match_options&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;GREATER_THAN_OR_EQUAL&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>當告警觸發時，因為有 tag，可以立刻定位是哪個團隊的哪類資源在漲，而不是面對一個無法拆解到具體團隊或資源類型的總數。常見的成本異常來源：開發者開了一組大型 instance 測試後忘了關、某個 auto-scaling group 的最大值設太高在流量尖峰長出了大量機器、NAT Gateway 被大量出站流量灌到帳單翻倍。這些情境只要 tag 到位，都能在異常告警觸發後幾分鐘內找到根因。&lt;/p>
&lt;p>到了「知道誰花多少、接下來怎麼省」這一步 — reserved instance 的承諾折扣、spot 的可中斷算力、閒置資源的 rightsizing 與排程關機 — 就進入 &lt;a href="https://tarrragon.github.io/blog/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">devops 模組八：成本管理&lt;/a> 的運行期優化範圍。這一章負責的是讓那些優化「有帳可查、有人可問」。&lt;/p>
&lt;p>成本治理在不同規模下的操作形態差異很大。Netflix 把多套關聯式資料庫統一到 Aurora 後成本下降 28%，核心操作是「把資源種類收斂、讓成本歸因的維度減少」——這在 tagging 已經到位的前提下才做得到，見 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/netflix-aurora-consolidation/" data-link-title="9.C23 Netflix：把關聯式 DB 統一到 Aurora、效能 &amp;#43;75%、成本 -28%" data-link-desc="Netflix 把多套關聯式 DB 統一到 Aurora、效能提升 75%、成本下降 28%、串流數十億小時">9.C23 Netflix：Aurora 整併&lt;/a>。另一個極端是 Arcjet 用 Redis Streams 取代 managed Kafka，年費從六位數美金降到約 $1k，代價是自行維護 retention 與 consumer group 監控——這個取捨的前提是團隊有能力承擔額外的運維面，見 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/cases/redis-streams-arcjet-replace-kafka/" data-link-title="3.C43 Arcjet：Redis Streams 取代 Kafka 省 6 位數 $" data-link-desc="Arcjet security 平台、Kafka managed 6 位數 $/yr、用 Redis Streams 約 $1k/yr、自寫 Janitor 監控 retention。">3.C43 Arcjet：Redis Streams 取代 Kafka&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>治理習慣的責任是讓基礎設施在規模長大後仍然可被盤點、可被追責、可被回收。資源歸屬靠 tagging、密鑰安全靠 secret 管理（見 <a href="/blog/infra/08-governance-habits/tagging-secrets/" data-link-title="Tagging 規範與 Secrets 不進 code" data-link-desc="tag 讓資源可盤點、可清理、可歸屬；密鑰存在專用服務裡而非 code 或 state，兩者都屬於 day-1 就該立的治理地基">tagging 與 secrets</a>），本篇處理兩個後續問題：成本怎麼拆解到擁有者，以及治理規範的節奏怎麼拿捏 — 什麼該第一天就立、什麼等到痛點出現再加。</p>
<p>先界定邊界。成本這一塊分兩層：把資源歸屬到擁有者與用途的地基（tagging、chargeback 的依據）在這裡，運行期怎麼用 reserved instance、spot、rightsizing 去壓低帳單，是 <a href="/blog/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">devops 模組八：成本管理</a> 的範圍。</p>
<h2 id="成本可見性每筆花費都對得到擁有者與用途">成本可見性：每筆花費都對得到擁有者與用途</h2>
<p>成本可見性的目標是讓帳單上的每一筆花費都能回答「這是誰的、為了什麼」。雲帳單預設是一筆按服務類型加總的數字 — EC2 多少、RDS 多少 — 這個視角能告訴你花在哪類資源，卻答不出花在哪個團隊、哪個產品線、哪個功能。當這個問題答不出來，成本就變成一筆沒人負責的公共支出，沒有人有動機去優化自己看不到的帳。</p>
<h3 id="tag-驅動的成本分攤">Tag 驅動的成本分攤</h3>
<p>把成本拆解到擁有者的地基，正是 tagging。雲廠商的成本分攤工具（AWS Cost Explorer、Cost Allocation Tags、GCP 的 billing label）能用 tag 當分群維度，前提是那些 tag 要先在 billing 後台啟用為「成本分攤標籤（Cost Allocation Tag）」。啟用是一次性設定，之後新建的資源只要帶了這個 tag，費用就會自動歸入對應維度。</p>
<p>啟用後，<code>cost-center</code> 和 <code>owner</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"># 用 AWS CLI 查某個 cost-center 的月費用</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws ce get-cost-and-usage <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --time-period <span class="nv">Start</span><span class="o">=</span>2026-06-01,End<span class="o">=</span>2026-06-30 <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --granularity MONTHLY <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --filter <span class="s1">&#39;{&#34;Tags&#34;:{&#34;Key&#34;:&#34;cost-center&#34;,&#34;Values&#34;:[&#34;cc-1024&#34;]}}&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --metrics BlendedCost <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --group-by <span class="nv">Type</span><span class="o">=</span>TAG,Key<span class="o">=</span>owner</span></span></code></pre></div><p>「team-payments 這個月花多少」「staging 環境占總成本幾成」變成一張報表而不是一場會議。</p>
<h3 id="成本異常告警">成本異常告警</h3>
<p>可見性先於優化，這個順序不能反。看不見的成本無法被歸屬，無法歸屬就無法問責，沒有問責就沒有人去做優化。在可見性建立之後，下一步是設一條成本異常告警：</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_ce_anomaly_monitor&#34; &#34;cost&#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;daily-cost-anomaly&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  monitor_type</span>      <span class="o">=</span> <span class="s2">&#34;DIMENSIONAL&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  monitor_dimension</span> <span class="o">=</span> <span class="s2">&#34;SERVICE&#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">resource</span> <span class="s2">&#34;aws_ce_anomaly_subscription&#34; &#34;alert&#34;</span> {
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">  name</span>      <span class="o">=</span> <span class="s2">&#34;cost-anomaly-alert&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  frequency</span> <span class="o">=</span> <span class="s2">&#34;DAILY&#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 class="n">  monitor_arn_list</span> <span class="o">=</span> <span class="p">[</span><span class="k">aws_ce_anomaly_monitor</span><span class="p">.</span><span class="k">cost</span><span class="p">.</span><span class="k">arn</span><span class="p">]</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="k">subscriber</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="s2">&#34;SNS&#34;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">    address</span> <span class="o">=</span> <span class="k">aws_sns_topic</span><span class="p">.</span><span class="k">cost_alerts</span><span class="p">.</span><span class="k">arn</span>
</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></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="k">threshold_expression</span> {
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">dimension</span> {
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="n">      key</span>           <span class="o">=</span> <span class="s2">&#34;ANOMALY_TOTAL_IMPACT_ABSOLUTE&#34;</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="n">      values</span>        <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;100&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="n">      match_options</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;GREATER_THAN_OR_EQUAL&#34;</span><span class="p">]</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><p>當告警觸發時，因為有 tag，可以立刻定位是哪個團隊的哪類資源在漲，而不是面對一個無法拆解到具體團隊或資源類型的總數。常見的成本異常來源：開發者開了一組大型 instance 測試後忘了關、某個 auto-scaling group 的最大值設太高在流量尖峰長出了大量機器、NAT Gateway 被大量出站流量灌到帳單翻倍。這些情境只要 tag 到位，都能在異常告警觸發後幾分鐘內找到根因。</p>
<p>到了「知道誰花多少、接下來怎麼省」這一步 — reserved instance 的承諾折扣、spot 的可中斷算力、閒置資源的 rightsizing 與排程關機 — 就進入 <a href="/blog/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">devops 模組八：成本管理</a> 的運行期優化範圍。這一章負責的是讓那些優化「有帳可查、有人可問」。</p>
<p>成本治理在不同規模下的操作形態差異很大。Netflix 把多套關聯式資料庫統一到 Aurora 後成本下降 28%，核心操作是「把資源種類收斂、讓成本歸因的維度減少」——這在 tagging 已經到位的前提下才做得到，見 <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%、串流數十億小時">9.C23 Netflix：Aurora 整併</a>。另一個極端是 Arcjet 用 Redis Streams 取代 managed Kafka，年費從六位數美金降到約 $1k，代價是自行維護 retention 與 consumer group 監控——這個取捨的前提是團隊有能力承擔額外的運維面，見 <a href="/blog/backend/03-message-queue/cases/redis-streams-arcjet-replace-kafka/" data-link-title="3.C43 Arcjet：Redis Streams 取代 Kafka 省 6 位數 $" data-link-desc="Arcjet security 平台、Kafka managed 6 位數 $/yr、用 Redis Streams 約 $1k/yr、自寫 Janitor 監控 retention。">3.C43 Arcjet：Redis Streams 取代 Kafka</a>。</p>
<h2 id="最小可行節奏先把地基跑起來再逐步加">最小可行節奏：先把地基跑起來，再逐步加</h2>
<p>治理的最小可行節奏，是早期只立「拔掉就會痛、補起來很貴」的那幾條規範，其餘留到規模逼出需求時再加。治理機制本身有維護成本 — 每一條策略規則、每一個審批關卡、每一套標籤分類法都要有人維護、有人解釋、有人在它擋錯東西時來救。在團隊還小、資源還少時堆滿企業級治理框架，付出的是當下的速度，換來的是一套還用不到的複雜度。</p>
<h3 id="補救成本曲線">補救成本曲線</h3>
<p>判斷一條治理規範該不該現在就立，看它的「補救成本曲線」— 越晚導入、事後補救的代價越高的規範，越應該提前立：</p>
<table>
  <thead>
      <tr>
          <th>規範</th>
          <th>補救成本曲線</th>
          <th>day-1 該立</th>
          <th>說明</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Tagging</td>
          <td>陡峭</td>
          <td>是</td>
          <td>幾百個沒 tag 的資源要回頭考古，建立時順手標只要幾秒</td>
      </tr>
      <tr>
          <td>Secrets 不進 code</td>
          <td>幾乎垂直</td>
          <td>是</td>
          <td>密鑰一旦進了 git 歷史就無法清除，只能輪替</td>
      </tr>
      <tr>
          <td>成本分攤維度</td>
          <td>中等</td>
          <td>是（輕量）</td>
          <td>依賴 tagging，tag 立了它就近乎免費啟用</td>
      </tr>
      <tr>
          <td>Secret 自動輪替</td>
          <td>平緩</td>
          <td>等</td>
          <td>手動輪替在早期可接受，自動化在 secret 數量增多後再投入</td>
      </tr>
      <tr>
          <td>細緻的審批流程</td>
          <td>平坦</td>
          <td>等</td>
          <td>補救成本低、可以隨時加，早期硬上反而拖慢交付</td>
      </tr>
      <tr>
          <td>多層級策略引擎（OPA / Sentinel）</td>
          <td>平坦</td>
          <td>等</td>
          <td>等到 tag policy 擋不住的邊界案例出現再引入</td>
      </tr>
  </tbody>
</table>
<p>這個曲線給出的節奏是：補救成本陡的從第一天就用 IaC 強制，補救成本平的等到痛點確實出現 — 開始有人手滑誤刪、開始有跨團隊的權限爭議 — 再有針對性地加。那時你也才知道該往哪個方向加。</p>
<h3 id="過度治理的訊號">過度治理的訊號</h3>
<p>過度治理跟過度設計是同一類問題，訊號很類似：</p>
<ul>
<li>建一個測試用的小資源需要走三層審批流程</li>
<li>團隊花在解釋為什麼某個護欄擋錯的時間，比護欄實際擋住的風險還多</li>
<li>策略規則的 exception 清單比規則本身還長</li>
<li>新人第一週的大部分時間花在理解治理框架而非理解業務</li>
</ul>
<p>這些訊號出現時，該回頭簡化 — 砍掉沒帶來價值的規則、把誤判率高的規則降級為 warning 而非 blocking。治理框架跟程式碼一樣需要重構。</p>
<h3 id="和其他模組的節奏對齊">和其他模組的節奏對齊</h3>
<p>這個節奏跟<a href="/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">模組零</a>的成熟度階梯是同一套思路：基礎設施的治理跟基礎設施本身一樣，是逐級長出來的，不是一次到位設計完的。把規範變成自動護欄的工程（PR 階段擋缺 tag、CI 掃 secret）值得早投入，因為自動化的護欄維護成本低、且越早接管越省人力 — 這部分怎麼落地在<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>
<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/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>：tag 合規與 secret 掃描整合進 CI pipeline</li>
<li>→ <a href="/blog/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">devops 模組八：成本管理</a>：運行期的成本控制與優化手段</li>
</ul>
]]></content:encoded></item><item><title>有 SSH 但沒有 IaC 的雲端環境接管</title><link>https://tarrragon.github.io/blog/infra/takeover/cloud-no-iac/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/takeover/cloud-no-iac/</guid><description>&lt;p>雲端資源存在且正在服務 production 流量，但沒有人能回答「我們有什麼、為什麼這樣設定、改了會影響什麼」。Console 裡有幾十個資源，有些名稱是 &lt;code>test-final-v2&lt;/code>，有些沒有名稱，security group 規則不知道哪條還在用，IAM user 清單裡有幾個已離職的人。這是接手全手動雲端環境的典型起點。&lt;/p>
&lt;p>接管的操作順序是：先拍下現況（盤點）、再理解結構（依賴）、再收斂風險（credential、備份）、再建立紀律（變更紀錄）、最後才考慮 IaC 導入。每一步都在不改動 production 的前提下進行。&lt;/p>
&lt;h2 id="資源盤點拍下雲端現況">資源盤點：拍下雲端現況&lt;/h2>
&lt;p>盤點的目標是把「雲端上有什麼」轉成一份可版本控制的清單。這份清單是後續所有操作的事實基礎 — 沒有清單就無法判斷哪些資源重要、哪些可以回收、哪些的設定有風險。&lt;/p>
&lt;p>盤點的工具依環境類型不同：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>VM 為主（EC2 / GCE）&lt;/strong> → 先跑 &lt;a href="#vm-%e5%b1%a4%e7%b4%9a%e7%9a%84%e5%bf%ab%e7%85%a7">VM 快照與系統清單&lt;/a>，再跑 &lt;a href="#%e7%94%a8-cli-%e6%8b%89%e6%b8%85%e5%96%ae">CLI 資源盤點&lt;/a>&lt;/li>
&lt;li>&lt;strong>Managed service 為主（RDS / Lambda / S3）&lt;/strong> → 直接跑 &lt;a href="#%e7%94%a8-cli-%e6%8b%89%e6%b8%85%e5%96%ae">CLI 資源盤點&lt;/a>&lt;/li>
&lt;li>&lt;strong>混合（VM + managed）&lt;/strong> → 兩個都跑：先 VM 快照（拍下機器狀態），再 CLI 盤點（拍下所有雲端資源）&lt;/li>
&lt;/ul>
&lt;h3 id="用-cli-拉清單">用 CLI 拉清單&lt;/h3>
&lt;p>盤點有三層工具可用，從粗到細：&lt;/p>
&lt;p>&lt;strong>全貌掃描&lt;/strong>：先用跨服務工具拿到「到底有多少資源」的量級感。AWS Resource Explorer 在 Console 開啟後可以用搜尋語法跨 region、跨 service 查資源（例如搜 &lt;code>resourcetype:ec2:instance&lt;/code> 列出所有 EC2）。Steampipe 是開源的 SQL 介面雲端查詢工具，用 &lt;code>select * from aws_ec2_instance&lt;/code> 這類語法查詢，對習慣 SQL 的人比 CLI flag 直覺。兩者都能在幾分鐘內拿到環境的全貌。&lt;/p>
&lt;p>&lt;strong>Tag 層掃描&lt;/strong>：AWS Resource Groups Tagging API 能跨服務撈出所有被標記的資源，但會漏掉沒有 tag 的 — 而接手環境裡沒 tag 的資源往往是風險最高的（沒人認領、不敢動）。&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 resourcegroupstaggingapi get-resources &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> --output json &amp;gt; inventory/tagged-resources.json&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Per-service 細節&lt;/strong>：全貌掃描只告訴你資源存在，細節（備份設定、SG 規則、IAM policy）要用 per-service describe 拉。以下是接手時最該優先盤點的四類：&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：哪些機器在跑、什麼規格、在哪個 subnet&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,InstanceType,State.Name,SubnetId,SecurityGroups[].GroupId,Tags]&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 json &amp;gt; inventory/ec2.json
&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"># RDS：資料庫的備份設定、刪除保護、Multi-AZ&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">aws rds describe-db-instances &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;DBInstances[].[DBInstanceIdentifier,Engine,DBInstanceClass,MultiAZ,BackupRetentionPeriod,DeletionProtection]&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 &amp;gt; inventory/rds.json
&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 class="c1"># Security Group：哪些規則對外開放&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&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">13&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">14&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --output json &amp;gt; inventory/security-groups.json
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="c1"># S3：哪些 bucket、versioning 是否開啟&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="k">for&lt;/span> bucket in &lt;span class="k">$(&lt;/span>aws s3api list-buckets --query &lt;span class="s1">&amp;#39;Buckets[].Name&amp;#39;&lt;/span> --output text&lt;span class="k">)&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="k">do&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$bucket&lt;/span>&lt;span class="s2">: &lt;/span>&lt;span class="k">$(&lt;/span>aws s3api get-bucket-versioning --bucket &lt;span class="nv">$bucket&lt;/span> --query &lt;span class="s1">&amp;#39;Status&amp;#39;&lt;/span> --output text&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="k">done&lt;/span> &amp;gt; inventory/s3-versioning.txt&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>把所有輸出存進一個 Git repo 的 &lt;code>inventory/&lt;/code> 目錄。這份快照的價值在於：一週後再跑一次比對差異，就能看出環境在背景長出了什麼新資源。&lt;/p>
&lt;h3 id="優先查三件事">優先查三件事&lt;/h3>
&lt;p>盤點不需要一次做完所有服務，但三件事要第一天就查：&lt;/p>
&lt;p>&lt;strong>對外暴露面&lt;/strong>：security group 裡有沒有 &lt;code>0.0.0.0/0&lt;/code> 入站規則指向非 HTTP/HTTPS 的 port（22、3306、5432、6379）。手動逐條查很慢 — 用安全掃描工具一次跑完更可靠。Prowler 是開源的 AWS 安全掃描工具，一次執行就能產出「哪些 SG 對外開放、哪些 S3 public、哪些 IAM 過寬」的分類報告：&lt;/p></description><content:encoded><![CDATA[<p>雲端資源存在且正在服務 production 流量，但沒有人能回答「我們有什麼、為什麼這樣設定、改了會影響什麼」。Console 裡有幾十個資源，有些名稱是 <code>test-final-v2</code>，有些沒有名稱，security group 規則不知道哪條還在用，IAM user 清單裡有幾個已離職的人。這是接手全手動雲端環境的典型起點。</p>
<p>接管的操作順序是：先拍下現況（盤點）、再理解結構（依賴）、再收斂風險（credential、備份）、再建立紀律（變更紀錄）、最後才考慮 IaC 導入。每一步都在不改動 production 的前提下進行。</p>
<h2 id="資源盤點拍下雲端現況">資源盤點：拍下雲端現況</h2>
<p>盤點的目標是把「雲端上有什麼」轉成一份可版本控制的清單。這份清單是後續所有操作的事實基礎 — 沒有清單就無法判斷哪些資源重要、哪些可以回收、哪些的設定有風險。</p>
<p>盤點的工具依環境類型不同：</p>
<ul>
<li><strong>VM 為主（EC2 / GCE）</strong> → 先跑 <a href="#vm-%e5%b1%a4%e7%b4%9a%e7%9a%84%e5%bf%ab%e7%85%a7">VM 快照與系統清單</a>，再跑 <a href="#%e7%94%a8-cli-%e6%8b%89%e6%b8%85%e5%96%ae">CLI 資源盤點</a></li>
<li><strong>Managed service 為主（RDS / Lambda / S3）</strong> → 直接跑 <a href="#%e7%94%a8-cli-%e6%8b%89%e6%b8%85%e5%96%ae">CLI 資源盤點</a></li>
<li><strong>混合（VM + managed）</strong> → 兩個都跑：先 VM 快照（拍下機器狀態），再 CLI 盤點（拍下所有雲端資源）</li>
</ul>
<h3 id="用-cli-拉清單">用 CLI 拉清單</h3>
<p>盤點有三層工具可用，從粗到細：</p>
<p><strong>全貌掃描</strong>：先用跨服務工具拿到「到底有多少資源」的量級感。AWS Resource Explorer 在 Console 開啟後可以用搜尋語法跨 region、跨 service 查資源（例如搜 <code>resourcetype:ec2:instance</code> 列出所有 EC2）。Steampipe 是開源的 SQL 介面雲端查詢工具，用 <code>select * from aws_ec2_instance</code> 這類語法查詢，對習慣 SQL 的人比 CLI flag 直覺。兩者都能在幾分鐘內拿到環境的全貌。</p>
<p><strong>Tag 層掃描</strong>：AWS Resource Groups Tagging API 能跨服務撈出所有被標記的資源，但會漏掉沒有 tag 的 — 而接手環境裡沒 tag 的資源往往是風險最高的（沒人認領、不敢動）。</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 resourcegroupstaggingapi get-resources <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --output json &gt; inventory/tagged-resources.json</span></span></code></pre></div><p><strong>Per-service 細節</strong>：全貌掃描只告訴你資源存在，細節（備份設定、SG 規則、IAM policy）要用 per-service describe 拉。以下是接手時最該優先盤點的四類：</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：哪些機器在跑、什麼規格、在哪個 subnet</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,InstanceType,State.Name,SubnetId,SecurityGroups[].GroupId,Tags]&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --output json &gt; inventory/ec2.json
</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"># RDS：資料庫的備份設定、刪除保護、Multi-AZ</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">aws rds describe-db-instances <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;DBInstances[].[DBInstanceIdentifier,Engine,DBInstanceClass,MultiAZ,BackupRetentionPeriod,DeletionProtection]&#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 &gt; inventory/rds.json
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># Security Group：哪些規則對外開放</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">aws ec2 describe-security-groups <span class="se">\
</span></span></span><span class="line"><span class="ln">13</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">14</span><span class="cl"><span class="se"></span>  --output json &gt; inventory/security-groups.json
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1"># S3：哪些 bucket、versioning 是否開啟</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="k">for</span> bucket in <span class="k">$(</span>aws s3api list-buckets --query <span class="s1">&#39;Buckets[].Name&#39;</span> --output text<span class="k">)</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$bucket</span><span class="s2">: </span><span class="k">$(</span>aws s3api get-bucket-versioning --bucket <span class="nv">$bucket</span> --query <span class="s1">&#39;Status&#39;</span> --output text<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="k">done</span> &gt; inventory/s3-versioning.txt</span></span></code></pre></div><p>把所有輸出存進一個 Git repo 的 <code>inventory/</code> 目錄。這份快照的價值在於：一週後再跑一次比對差異，就能看出環境在背景長出了什麼新資源。</p>
<h3 id="優先查三件事">優先查三件事</h3>
<p>盤點不需要一次做完所有服務，但三件事要第一天就查：</p>
<p><strong>對外暴露面</strong>：security group 裡有沒有 <code>0.0.0.0/0</code> 入站規則指向非 HTTP/HTTPS 的 port（22、3306、5432、6379）。手動逐條查很慢 — 用安全掃描工具一次跑完更可靠。Prowler 是開源的 AWS 安全掃描工具，一次執行就能產出「哪些 SG 對外開放、哪些 S3 public、哪些 IAM 過寬」的分類報告：</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">prowler aws --services ec2 iam s3 rds -M json-ocsf -o inventory/
</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"># 如果只想快速查 SG 暴露面，用 CLI：</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">aws ec2 describe-security-groups <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;SecurityGroups[].IpPermissions[?contains(IpRanges[].CidrIp, `0.0.0.0/0`)]&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --output json <span class="p">|</span> jq <span class="s1">&#39;[.[][] | select(.FromPort != 80 and .FromPort != 443)]&#39;</span></span></span></code></pre></div><p>ScoutSuite 是類似工具、支援多雲（AWS / GCP / Azure）。AWS Trusted Advisor 的免費 tier 也有基本安全檢查（S3 public access、SG 開放埠），但覆蓋面比 Prowler 窄。接手時三者選一跑一次，比手動翻 Console 快且不會漏。</p>
<p><strong>備份狀態</strong>：RDS 的 <code>BackupRetentionPeriod</code> 是不是 0（代表沒有自動備份）。S3 的 versioning 是不是關的。如果是，這是接手後第一個要改的設定 — 改備份設定不影響服務運作，但沒有備份時任何資料操作失誤都不可逆。</p>
<p><strong>誰最近在動環境</strong>：CloudTrail 記錄了所有 API 呼叫。查最近 30 天的變更事件，能看出哪些資源被頻繁修改、被誰修改。這比逐一問前團隊成員可靠——CloudTrail 不會漏記。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws cloudtrail lookup-events <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --lookup-attributes <span class="nv">AttributeKey</span><span class="o">=</span>ReadOnly,AttributeValue<span class="o">=</span><span class="nb">false</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --start-time <span class="k">$(</span>date -v-30d +%Y-%m-%dT%H:%M:%S<span class="k">)</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --max-items <span class="m">50</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;Events[].[EventTime,Username,EventName,Resources[0].ResourceName]&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --output table</span></span></code></pre></div><h3 id="vm-層級的快照">VM 層級的快照</h3>
<p>如果接手的環境包含 EC2 或 GCE 等 VM，在做任何改動之前先對每台 VM 建一個 AMI（AWS）或 machine image（GCP）。這是最粗粒度但最完整的「拍照」——整台機器的 OS、安裝的軟體、設定檔、磁碟內容全部打包成一個可重建的映像。</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"># AWS: 對 EC2 建 AMI（--no-reboot 避免服務中斷）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">aws ec2 create-image <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --instance-id i-0abc123 <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --name <span class="s2">&#34;takeover-baseline-</span><span class="k">$(</span>date +%Y%m%d<span class="k">)</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --no-reboot
</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"># 確認 AMI 建立完成</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">aws ec2 describe-images <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --owners self <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --filters <span class="s2">&#34;Name=name,Values=takeover-baseline-*&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;Images[].[ImageId,Name,State]&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="se"></span>  --output table</span></span></code></pre></div><p><code>--no-reboot</code> 讓快照過程中服務不中斷，代價是檔案系統快照的一致性不如有 reboot 的版本（記憶體中的寫入可能還沒 flush 到磁碟），但對接手基線已經足夠。AMI 的費用是底層 EBS 快照的儲存費用（按 GB 計費、差異儲存），作為接手保險措施這筆成本值得。</p>
<p>除了 VM 快照，有 SSH 存取時也要拍 VM 內部的軟體環境——AMI 可以還原整台機器，但看不到「裡面裝了什麼、跑了什麼」的摘要：</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">cat /etc/os-release
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># 已安裝的套件清單</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">dpkg -l &gt; ~/takeover/packages-<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>.txt   <span class="c1"># Debian/Ubuntu</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">rpm -qa &gt; ~/takeover/packages-<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>.txt    <span class="c1"># RHEL/CentOS/Amazon Linux</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="c1"># 執行中的服務</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">systemctl list-units --type<span class="o">=</span>service --state<span class="o">=</span>running &gt; ~/takeover/services.txt
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># 所有使用者的 cron jobs</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="k">for</span> user in <span class="k">$(</span>cut -f1 -d: /etc/passwd<span class="k">)</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;=== </span><span class="nv">$user</span><span class="s2"> ===&#34;</span> &gt;&gt; ~/takeover/crontabs.txt
</span></span><span class="line"><span class="ln">14</span><span class="cl">  crontab -u <span class="s2">&#34;</span><span class="nv">$user</span><span class="s2">&#34;</span> -l 2&gt;/dev/null &gt;&gt; ~/takeover/crontabs.txt
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="k">done</span>
</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="c1"># 網路監聽的 port（哪個 process 在聽哪個 port）</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">ss -tlnp &gt; ~/takeover/listening-ports.txt</span></span></code></pre></div><p>把這些輸出存進盤點 repo，跟 CLI 資源盤點（describe 指令的輸出）放在一起。<code>listening-ports.txt</code> 跟 security group 規則對照，可以看出「哪些 port 有服務在聽但 SG 沒開」（可能是內部服務）和「哪些 port SG 開了但沒有服務在聽」（可能是殘留規則）。</p>
<h2 id="依賴關係推導">依賴關係推導</h2>
<p>盤點回答「有什麼」，依賴推導回答「改一個會連帶影響什麼」。手動環境沒有 Terraform 的依賴圖可以看，需要從資源的引用關係反推。</p>
<h3 id="從-security-group-開始">從 security group 開始</h3>
<p>Security group 是依賴推導的最佳起點，因為它的引用關係最密集 — 幾乎每個資源都掛著至少一個 SG，而 SG 之間可以互相引用（app SG 的入站來源是 LB SG、DB SG 的入站來源是 app SG）。</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"># 列出每個 SG 被哪些 ENI（網卡）使用</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws ec2 describe-network-interfaces <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;NetworkInterfaces[].[NetworkInterfaceId,Description,Groups[].GroupId]&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --output json &gt; inventory/sg-usage.json</span></span></code></pre></div><p>AWS Console 的 VPC 頁面有 Resource Map 功能，可以視覺化 subnet → instance → SG 的對應關係，接手時第一次瀏覽依賴用它比 CLI 直覺。要產出可存檔的依賴圖，draw.io（有 AWS icon set）或 Lucidchart 都能畫，重點是圖要存進 repo、不是畫完就丟。</p>
<p>如果後續打算導入 Terraform，Former2 可以掃描現有 AWS 資源、自動產出 Terraform / CloudFormation / CDK 程式碼。產出的程式碼不會完美（屬性常漏、命名要改），但作為反推依賴關係的起點比從零寫快。Inframap 則是從 Terraform state 產出依賴關係圖（在 import 階段才用得到）。</p>
<p>從 SG 的引用鏈可以畫出一張粗略的依賴圖：</p>
<table>
  <thead>
      <tr>
          <th>層次</th>
          <th>資源</th>
          <th>入站來自</th>
          <th>出站到</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>入口</td>
          <td>ALB</td>
          <td>0.0.0.0/0:443</td>
          <td>app SG</td>
      </tr>
      <tr>
          <td>應用</td>
          <td>EC2 / ECS</td>
          <td>ALB SG</td>
          <td>DB SG、外部 API</td>
      </tr>
      <tr>
          <td>資料</td>
          <td>RDS</td>
          <td>app SG:5432</td>
          <td>—</td>
      </tr>
  </tbody>
</table>
<p>這張圖不需要精確到每個 port — 它的用途是在改動任何資源前，快速判斷影響範圍。例如要改 app SG 的規則時，先查它被哪些 EC2 和 ECS 引用、它的入站來源 ALB SG 是否受影響。</p>
<h3 id="其他依賴面向">其他依賴面向</h3>
<p>除了 SG，以下幾個引用關係也要記錄：</p>
<ul>
<li><strong>EC2 → IAM role</strong>：instance profile 決定這台機器能存取什麼（S3 bucket、Secrets Manager、其他 AWS 服務）</li>
<li><strong>RDS → subnet group</strong>：決定資料庫在哪些 subnet 裡，改 VPC 或 subnet 時會受影響</li>
<li><strong>ALB → target group → EC2/ECS</strong>：流量路徑，改 target group 的 health check 或移除成員會影響服務可用性</li>
<li><strong>Lambda → VPC 設定</strong>：如果 Lambda 被放進 VPC，它的出站走 NAT，改 NAT 或 route table 會影響它</li>
<li><strong>Route 53 → ALB/EC2</strong>：DNS 指向哪個資源，改資源 IP 或 ALB 時要同步更新</li>
</ul>
<h2 id="credential-盤點與收斂">credential 盤點與收斂</h2>
<p>接手環境時，credential 是風險最高的一類 — 前團隊建立的 IAM user 和 access key 可能還在活躍狀態，而那些人已經不在團隊裡了。</p>
<p>接手後第一件事是用 aws-vault 管理自己的 credential。aws-vault 把 AWS access key 存在 OS keychain（macOS Keychain / Windows Credential Manager），而非明文放在 <code>~/.aws/credentials</code>。執行 AWS 指令時由 aws-vault 注入臨時 session，本地磁碟上不留長期 key 的明文。不要沿用前人留下的 AWS CLI profile — 那些 profile 的權限範圍和用途都不確定。</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"># 安裝後設定新的 profile</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws-vault add takeover-admin
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 用臨時 session 執行指令</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">aws-vault <span class="nb">exec</span> takeover-admin -- aws sts get-caller-identity</span></span></code></pre></div><h3 id="產出-credential-報告">產出 credential 報告</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws iam generate-credential-report
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws iam get-credential-report <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;Content&#39;</span> --output text <span class="p">|</span> base64 -d &gt; inventory/credential-report.csv</span></span></code></pre></div><p>這份 CSV 列出所有 IAM user、每把 access key 的建立時間、上次使用時間、MFA 是否啟用。從中篩出三類需要處理的：</p>
<table>
  <thead>
      <tr>
          <th>類別</th>
          <th>判斷方式</th>
          <th>處理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>已離職人員的 key</td>
          <td>user 名稱對照離職清單</td>
          <td>停用 key → 觀察 7 天無異常 → 刪除 user</td>
      </tr>
      <tr>
          <td>超過 90 天未使用的 key</td>
          <td><code>access_key_last_used</code> 超過 90 天</td>
          <td>停用 → 觀察是否有服務中斷 → 確認無影響後刪除</td>
      </tr>
      <tr>
          <td>有 admin 權限的 key</td>
          <td>policy 含 <code>AdministratorAccess</code> 或 <code>*:*</code></td>
          <td>降權到實際需要的最小權限</td>
      </tr>
  </tbody>
</table>
<p>停用（deactivate）而非直接刪除是關鍵 — 停用後如果某個自動化腳本依賴這把 key 會立刻報錯，這時候可以快速重新啟用；直接刪除就回不去了。觀察期設 7 天，涵蓋一個完整的業務週期（含週末的 cron job）。</p>
<h3 id="檢查-key-散落的位置">檢查 key 散落的位置</h3>
<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"><span class="c1"># EC2 user data 裡是否有 hardcode 的 key</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">aws ec2 describe-instance-attribute <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --instance-id i-xxx --attribute userData <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;UserData.Value&#39;</span> --output text <span class="p">|</span> base64 -d <span class="p">|</span> grep -i <span class="s2">&#34;aws_access_key\|aws_secret&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># Lambda 環境變數</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">aws lambda list-functions --query <span class="s1">&#39;Functions[].FunctionName&#39;</span> --output text <span class="p">|</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  xargs -I<span class="o">{}</span> aws lambda get-function-configuration --function-name <span class="o">{}</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;Environment.Variables&#39;</span> --output json <span class="p">|</span> grep -i <span class="s2">&#34;key\|secret\|password&#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 class="c1"># SSM Parameter Store</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">aws ssm describe-parameters --query <span class="s1">&#39;Parameters[].Name&#39;</span> --output text</span></span></code></pre></div><p>找到 hardcode 的 key 後，替換路徑是改用 IAM role（EC2 用 instance profile、Lambda 用 execution role）。替換前先確認 role 的 policy 涵蓋這把 key 原本在做的操作。</p>
<h2 id="備份驗證">備份驗證</h2>
<p>盤點出的每個 stateful 資源（RDS、S3、EBS）都要確認備份狀態。接手環境時不能假設「前團隊應該有設定備份」— 要親自驗證。</p>
<h3 id="rds-備份">RDS 備份</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 檢查每個 RDS instance 的備份設定</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws rds describe-db-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;DBInstances[].[DBInstanceIdentifier,BackupRetentionPeriod,LatestRestorableTime,DeletionProtection]&#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></code></pre></div><p><code>BackupRetentionPeriod</code> 為 0 代表沒有自動備份 — 立刻改成至少 7 天。<code>DeletionProtection</code> 為 false 代表一個誤操作就能刪掉資料庫 — 立刻開啟。這兩項設定的修改不需要重啟、不影響服務。</p>
<p>備份存在不等於備份可用。接手後的第一週內，從最近的 snapshot 還原一台測試 RDS、連進去確認資料完整。這個步驟的成本是一台 RDS 跑幾小時的費用，換到的是「備份確定能用」的確認 — 等到要用備份的時候才發現不能還原，代價是另一個量級。</p>
<h3 id="s3-versioning">S3 versioning</h3>
<p>沒有開 versioning 的 bucket，物件被覆寫或刪除後不可回復。對承載業務資料的 bucket（上傳的檔案、匯出的報表、設定檔），開啟 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">aws s3api put-bucket-versioning <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --bucket my-business-data <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --versioning-configuration <span class="nv">Status</span><span class="o">=</span>Enabled</span></span></code></pre></div><p>開啟 versioning 不影響既有物件，但會讓後續的每次覆寫都保留舊版本。儲存成本會因為保留歷史版本而增加 — 配一條 lifecycle rule 設定 noncurrent version 的過期天數來控制。</p>
<h2 id="建立變更紀律">建立變更紀律</h2>
<p>盤點、依賴推導、credential 收斂做完後，環境的現況已經有一份可查的記錄。下一步是確保從現在開始的每一次變更都留下痕跡。</p>
<h3 id="變更日誌">變更日誌</h3>
<p>在 inventory repo 裡建一份 <code>CHANGELOG.md</code>，每次改動 production 就追加一筆：</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-26
</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> **資源**：rds/payments-prod
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">-</span> **變更**：BackupRetentionPeriod 0 → 14, DeletionProtection false → true
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">-</span> **原因**：接手盤點發現備份未開啟
</span></span><span class="line"><span class="ln">7</span><span class="cl">- <span class="gs">**回退方式**</span>：BackupRetentionPeriod 改回 0（不建議）</span></span></code></pre></div><h3 id="cloudtrail-確認">CloudTrail 確認</h3>
<p>確認 CloudTrail 正在記錄 management events。如果沒有 trail 存在，建一個指向 S3 bucket 的 trail — 這是事後追溯「誰動了什麼」的最後防線。</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 --query <span class="s1">&#39;trailList[].{Name:Name,S3:S3BucketName,IsLogging:IsLogging}&#39;</span></span></span></code></pre></div><h3 id="開始標-tag">開始標 tag</h3>
<p>盤點過程中辨識出的每個資源，標上 <code>env</code>、<code>owner</code>、<code>service</code> 三個 tag。接手階段的 <code>owner</code> 通常標「待確認」或新接手的團隊名稱。tag 的價值在於讓後續的盤點和清理可以用查詢系統性地進行 — 沒有 tag 的資源無法被 filter 找到。</p>
<h2 id="往-iac-的銜接">往 IaC 的銜接</h2>
<p>盤點和紀律建立完成後，環境已經從「不知道有什麼」推進到「知道有什麼、知道誰在動、改了有紀錄」。這個狀態對應<a href="/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">成熟度階梯</a>的第零階到第一階之間。</p>
<h3 id="成本現況">成本現況</h3>
<p>接手環境通常伴隨「這個月帳單多少」的問題。AWS Cost Explorer（免費）能看過去幾個月的花費分布，按服務類型、帳號、tag 維度拆。接手時先拉一次 Cost Explorer 的月度趨勢，看有沒有異常成長或不預期的高額服務。後續導入 IaC 後，Infracost 可以在 <code>terraform plan</code> 階段預估變更的成本影響（例如「升 RDS 規格會多花多少」），讓成本決策在 apply 之前就被看見。</p>
<p>往 IaC 的銜接不需要一次做完。按穩定度和改動風險排序：</p>
<table>
  <thead>
      <tr>
          <th>優先級</th>
          <th>資源類型</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>先做</td>
          <td>VPC、subnet、route table</td>
          <td>形狀穩定、幾乎不會改、import 風險低</td>
      </tr>
      <tr>
          <td>次做</td>
          <td>security group</td>
          <td>規則明確、import 後 plan 容易驗證</td>
      </tr>
      <tr>
          <td>後做</td>
          <td>RDS、EC2、ALB</td>
          <td>stateful 或與部署耦合、import 風險較高</td>
      </tr>
      <tr>
          <td>最後</td>
          <td>Lambda、API Gateway</td>
          <td>通常跟應用程式碼耦合、import 後維護邊界需要釐清</td>
      </tr>
  </tbody>
</table>
<p>每批 import 的操作流程是：<code>terraform import</code> → <code>terraform plan</code> 確認零變更 → 寫 HCL 補齊差異 → 再跑 <code>plan</code> 直到零變更。具體的 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>時程參考：10-20 個資源的環境，完成盤點 + credential 收斂 + 備份驗證約需 3-5 天；往 IaC 的 import 約需 1-2 週。兩者可以平行進行但建議先完成盤點 — 沒有完整的資源清單就開始 import，容易漏掉關鍵的依賴關係。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/takeover/partial-iac-no-docs/" data-link-title="有半套 IaC 但文件缺失的環境接管" data-link-desc="IaC 覆蓋不完整、部分資源在 state 外、文件缺失的環境怎麼盤點差距、修復 state 健康、收斂 drift 並重建文件">有半套 IaC 但文件缺失的環境接管</a>：如果盤點過程中發現環境裡已有部分 Terraform code</li>
<li>→ <a href="/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一：還沒有 infra 的環境</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>：credential 收斂的完整設計</li>
<li>→ <a href="/blog/infra/02-identity-credentials/team-access-management/" data-link-title="團隊權限分級與存取管理" data-link-desc="用 admin / operator / viewer 三級劃分團隊成員的雲端操作權限，設計臨時提權流程、定期 access review 節奏，以及 contractor 與外部 vendor 的存取邊界">團隊權限分級與存取管理</a>：接手後重新建立權限分級</li>
</ul>
]]></content:encoded></item><item><title>怎麼把 infra 推動起來 — 信任赤字、期望值對齊與知識共享</title><link>https://tarrragon.github.io/blog/infra/09-driving-adoption/trust-alignment-knowledge-sharing/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/09-driving-adoption/trust-alignment-knowledge-sharing/</guid><description>&lt;p>一套技術上正確的 infra 推不動，後果會往回退、不只是停在原地。state 上了版控但團隊照樣手改 Console、PR 護欄建好了卻被繞過、tagging 規範寫進文件但沒人填，這些都會讓 infra 從「資產」變成「擺設」。更糟的情況是推到一半就停：一部分環境上了 IaC、一部分還是手動，兩套真相並存，排查問題時不知道該信哪邊，infra 反而成了扣分項。本系列的技術模組（從&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> 到 &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 自動化，讓基礎設施可審查、可回溯、可交接">PR 流程治理&lt;/a>）講的是怎麼做對，這一章講技術做對之後、怎麼跨過商業優先級與組織信任這兩道更難的關卡。&lt;/p>
&lt;h2 id="為什麼-infra-常推不動">為什麼 infra 常推不動&lt;/h2>
&lt;p>infra 是一種看不到立即回報的投入，這是它在商業優先級裡天然吃虧的根本原因。產品功能上線當天就能看到使用者數字、營收曲線、客訴下降；infra 投入當天看到的只有「花了時間，但畫面上什麼都沒變」。把 state 搬上遠端後端、把 IAM 從長期 access key 換成 OIDC、把環境拆成獨立帳號 — 這些工作的價值要等到某次事故、某次稽核、某次擴張才會兌現。在價值兌現之前，它在排程會議上跟一個能立刻帶來轉換率的功能競爭，幾乎必輸。&lt;/p>
&lt;h3 id="回報曲線的錯位">回報曲線的錯位&lt;/h3>
&lt;p>徵兆很直接：當 infra 工作總是被排進「有空再做」的待辦、季度結束時總是第一個被砍，根源在於它的回報曲線跟決策者的時間視窗對不上，而不是團隊不重視。決策者看的是這一季的可交付，infra 的回報落在下一次危機，兩者中間隔著一段沒有反饋的真空期。&lt;/p>
&lt;p>這個落差在三種組織場景裡特別明顯，各自有不同的困局與突破口。&lt;/p>
&lt;p>第一種是早期新創：每個人都在趕功能，infra 被當成「等有規模再說」的奢侈品。創辦人或技術負責人在 Console 手動把環境點起來，跑得動就不再碰。結果等到規模來的時候 — 第一個客戶進來了、需要 staging 環境了、第二個工程師要動資源了 — 手動環境的債已經高到要花整個季度去還。這個場景的突破口通常是某次事故：誤刪了 production 的資源、或者安全掃描發現長期 key 外洩，這個事件才會把 infra 從「有空再做」推進「下一個 sprint」。&lt;/p>
&lt;p>第二種是成長期的公司：已經有幾十個手動資源了，每次出事都靠一兩個人熟手救火，管理層看到的是「反正每次都救回來了」，結論是「所以現在不急」。這個結論會一直成立、直到那個熟手離職的那天。更隱蔽的版本是熟手沒離職但開始成為瓶頸 — 所有 infra 變更都排隊等他、他無法去做其他事、團隊的開發速度被他一個人的頻寬卡住。&lt;/p>
&lt;p>第三種是大組織裡的平台團隊：infra 是跨團隊的公共投入，每個產品團隊都想用但沒人想出資源，因為投入算自己的 headcount、收益算大家的。這個場景的常見僵局是平台團隊建了一套 IaC 模組，但產品團隊不願意學、不願意遷移、也不願意從自己的 sprint 裡撥時間，因為遷移的收益算在平台團隊的 OKR 裡而非自己的。&lt;/p>
&lt;h3 id="歸因的陷阱">歸因的陷阱&lt;/h3>
&lt;p>理解這個落差，就不會把推不動歸因成「同事不懂技術」。把它當成溝通態度問題去硬碰，結果是工程端越說越委屈、業務端越聽越像本位主義。也別矯枉過正 — infra 確實有一部分屬於可以延後的優化，不是每一項都該現在做。&lt;/p>
&lt;p>常見的歸因錯誤有兩種方向。第一種是工程端把所有 infra 需求都當成「技術上正確所以該做」，忽略優先級與時機 — 在產品還沒找到 PMF 的階段要求花三週做完整的多環境 IaC，即使技術上正確，對組織也是錯誤的資源配置。第二種是管理端把所有 infra 請求都歸入「工程師的潔癖」，因為上次某個 infra 改造確實沒帶來可見的業務效果 — 但那次可能是一個優化級的工作，跟這次的地基級需求（例如長期 key 散落）風險等級完全不同。兩種歸因都把 infra 當成一個不分層的整體，而拆層正是解開這個僵局的關鍵。&lt;/p>
&lt;p>真正該做的是把「哪些 infra 屬於不能延後的地基」跟「哪些屬於可排程的優化」分開談。這條線在&lt;a href="https://tarrragon.github.io/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">模組零：infra 是什麼&lt;/a>的成熟度階梯與 day 1 鐵律裡有完整討論 — 地基類資源（網路、身分、state）回頭改的代價極高，是接近「該照做」的硬判準；應用層資源可以容許暫時手動，等形狀穩定再納管。把這條線講清楚，決策者才有辦法區分「這真的急」跟「這只是工程師想整理」，而不是把所有 infra 工作當成同一類。&lt;/p>
&lt;h2 id="信任赤字下的兩難">信任赤字下的兩難&lt;/h2>
&lt;p>信任赤字指的是團隊對「動 infra 會不會把東西弄壞」的預設懷疑。當一個服務運作正常，任何對它底層的改動在旁人眼裡都是多此一舉，一旦改出問題，責任全記在發起改動的人頭上。這種不對稱讓人傾向不動，於是技術債持續累積，而累積本身又讓下一次改動更危險，形成越不敢動就越不能動的循環。&lt;/p>
&lt;h3 id="兩難的具體形狀">兩難的具體形狀&lt;/h3>
&lt;p>大改動風險高、需要的信任額度也高，但信任正是現在缺的；小改動安全，卻又解不了結構性的問題。更尷尬的中間態是改到一半 — 把一半服務遷上 IaC、另一半留在手動。這時系統同時揹著舊流程的隨意性跟新流程的約束，兩邊的缺點都拿到、好處都沒拿滿。排查問題的人要先猜這個資源歸哪套管，認知成本比改造前還高。&lt;/p>
&lt;p>一個常見的情境是：平台工程師花了兩週把網路地基寫進 Terraform，PR review 通過、plan 乾淨、apply 成功。但因為只做了網路、還沒做 IAM 和核心服務，團隊日常操作還是在 Console 手動改 security group。某次手動改動造成 drift，下一次 Terraform apply 把手動改的規則覆蓋掉了，服務斷線。這個事故的結論是「半套管的中間態比全手動更危險」— 這正是信任赤字的來源：團隊看到的是 infra 造成的新風險，而非 infra 的價值。&lt;/p>
&lt;h3 id="用可回退性換取授權">用可回退性換取授權&lt;/h3>
&lt;p>可操作的判準是用改動的「可回退性」換取授權，而不是用「保證不出錯」去爭取。把一次大遷移切成多個獨立可回退的 PR，每個 PR 都能單獨 review、單獨 apply、單獨 revert，這樣每一步的風險都是有界的，團隊願意給的信任額度也跟著提高。&lt;/p>
&lt;p>切片的原則有兩個邊界。第一，每個切片都要讓系統落在一個自洽的狀態 — 不能切到一半的 security group 在 IaC 裡、另一半在手動，因為這個中間態正是信任消耗最大的狀態。一個常見的錯誤切法是「先 import VPC 但不 import 它底下的 subnet」，結果 Terraform 看到 VPC 歸自己管但 subnet 不歸，下次有人改 VPC 的某個屬性做 apply，plan 裡不會顯示 subnet 的相關影響，而實際上那些手動管的 subnet 可能依賴 VPC 的那個屬性。功能相關的資源要整批進、整批出。&lt;/p></description><content:encoded><![CDATA[<p>一套技術上正確的 infra 推不動，後果會往回退、不只是停在原地。state 上了版控但團隊照樣手改 Console、PR 護欄建好了卻被繞過、tagging 規範寫進文件但沒人填，這些都會讓 infra 從「資產」變成「擺設」。更糟的情況是推到一半就停：一部分環境上了 IaC、一部分還是手動，兩套真相並存，排查問題時不知道該信哪邊，infra 反而成了扣分項。本系列的技術模組（從<a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">最小可行 IaC</a> 到 <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 自動化，讓基礎設施可審查、可回溯、可交接">PR 流程治理</a>）講的是怎麼做對，這一章講技術做對之後、怎麼跨過商業優先級與組織信任這兩道更難的關卡。</p>
<h2 id="為什麼-infra-常推不動">為什麼 infra 常推不動</h2>
<p>infra 是一種看不到立即回報的投入，這是它在商業優先級裡天然吃虧的根本原因。產品功能上線當天就能看到使用者數字、營收曲線、客訴下降；infra 投入當天看到的只有「花了時間，但畫面上什麼都沒變」。把 state 搬上遠端後端、把 IAM 從長期 access key 換成 OIDC、把環境拆成獨立帳號 — 這些工作的價值要等到某次事故、某次稽核、某次擴張才會兌現。在價值兌現之前，它在排程會議上跟一個能立刻帶來轉換率的功能競爭，幾乎必輸。</p>
<h3 id="回報曲線的錯位">回報曲線的錯位</h3>
<p>徵兆很直接：當 infra 工作總是被排進「有空再做」的待辦、季度結束時總是第一個被砍，根源在於它的回報曲線跟決策者的時間視窗對不上，而不是團隊不重視。決策者看的是這一季的可交付，infra 的回報落在下一次危機，兩者中間隔著一段沒有反饋的真空期。</p>
<p>這個落差在三種組織場景裡特別明顯，各自有不同的困局與突破口。</p>
<p>第一種是早期新創：每個人都在趕功能，infra 被當成「等有規模再說」的奢侈品。創辦人或技術負責人在 Console 手動把環境點起來，跑得動就不再碰。結果等到規模來的時候 — 第一個客戶進來了、需要 staging 環境了、第二個工程師要動資源了 — 手動環境的債已經高到要花整個季度去還。這個場景的突破口通常是某次事故：誤刪了 production 的資源、或者安全掃描發現長期 key 外洩，這個事件才會把 infra 從「有空再做」推進「下一個 sprint」。</p>
<p>第二種是成長期的公司：已經有幾十個手動資源了，每次出事都靠一兩個人熟手救火，管理層看到的是「反正每次都救回來了」，結論是「所以現在不急」。這個結論會一直成立、直到那個熟手離職的那天。更隱蔽的版本是熟手沒離職但開始成為瓶頸 — 所有 infra 變更都排隊等他、他無法去做其他事、團隊的開發速度被他一個人的頻寬卡住。</p>
<p>第三種是大組織裡的平台團隊：infra 是跨團隊的公共投入，每個產品團隊都想用但沒人想出資源，因為投入算自己的 headcount、收益算大家的。這個場景的常見僵局是平台團隊建了一套 IaC 模組，但產品團隊不願意學、不願意遷移、也不願意從自己的 sprint 裡撥時間，因為遷移的收益算在平台團隊的 OKR 裡而非自己的。</p>
<h3 id="歸因的陷阱">歸因的陷阱</h3>
<p>理解這個落差，就不會把推不動歸因成「同事不懂技術」。把它當成溝通態度問題去硬碰，結果是工程端越說越委屈、業務端越聽越像本位主義。也別矯枉過正 — infra 確實有一部分屬於可以延後的優化，不是每一項都該現在做。</p>
<p>常見的歸因錯誤有兩種方向。第一種是工程端把所有 infra 需求都當成「技術上正確所以該做」，忽略優先級與時機 — 在產品還沒找到 PMF 的階段要求花三週做完整的多環境 IaC，即使技術上正確，對組織也是錯誤的資源配置。第二種是管理端把所有 infra 請求都歸入「工程師的潔癖」，因為上次某個 infra 改造確實沒帶來可見的業務效果 — 但那次可能是一個優化級的工作，跟這次的地基級需求（例如長期 key 散落）風險等級完全不同。兩種歸因都把 infra 當成一個不分層的整體，而拆層正是解開這個僵局的關鍵。</p>
<p>真正該做的是把「哪些 infra 屬於不能延後的地基」跟「哪些屬於可排程的優化」分開談。這條線在<a href="/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">模組零：infra 是什麼</a>的成熟度階梯與 day 1 鐵律裡有完整討論 — 地基類資源（網路、身分、state）回頭改的代價極高，是接近「該照做」的硬判準；應用層資源可以容許暫時手動，等形狀穩定再納管。把這條線講清楚，決策者才有辦法區分「這真的急」跟「這只是工程師想整理」，而不是把所有 infra 工作當成同一類。</p>
<h2 id="信任赤字下的兩難">信任赤字下的兩難</h2>
<p>信任赤字指的是團隊對「動 infra 會不會把東西弄壞」的預設懷疑。當一個服務運作正常，任何對它底層的改動在旁人眼裡都是多此一舉，一旦改出問題，責任全記在發起改動的人頭上。這種不對稱讓人傾向不動，於是技術債持續累積，而累積本身又讓下一次改動更危險，形成越不敢動就越不能動的循環。</p>
<h3 id="兩難的具體形狀">兩難的具體形狀</h3>
<p>大改動風險高、需要的信任額度也高，但信任正是現在缺的；小改動安全，卻又解不了結構性的問題。更尷尬的中間態是改到一半 — 把一半服務遷上 IaC、另一半留在手動。這時系統同時揹著舊流程的隨意性跟新流程的約束，兩邊的缺點都拿到、好處都沒拿滿。排查問題的人要先猜這個資源歸哪套管，認知成本比改造前還高。</p>
<p>一個常見的情境是：平台工程師花了兩週把網路地基寫進 Terraform，PR review 通過、plan 乾淨、apply 成功。但因為只做了網路、還沒做 IAM 和核心服務，團隊日常操作還是在 Console 手動改 security group。某次手動改動造成 drift，下一次 Terraform apply 把手動改的規則覆蓋掉了，服務斷線。這個事故的結論是「半套管的中間態比全手動更危險」— 這正是信任赤字的來源：團隊看到的是 infra 造成的新風險，而非 infra 的價值。</p>
<h3 id="用可回退性換取授權">用可回退性換取授權</h3>
<p>可操作的判準是用改動的「可回退性」換取授權，而不是用「保證不出錯」去爭取。把一次大遷移切成多個獨立可回退的 PR，每個 PR 都能單獨 review、單獨 apply、單獨 revert，這樣每一步的風險都是有界的，團隊願意給的信任額度也跟著提高。</p>
<p>切片的原則有兩個邊界。第一，每個切片都要讓系統落在一個自洽的狀態 — 不能切到一半的 security group 在 IaC 裡、另一半在手動，因為這個中間態正是信任消耗最大的狀態。一個常見的錯誤切法是「先 import VPC 但不 import 它底下的 subnet」，結果 Terraform 看到 VPC 歸自己管但 subnet 不歸，下次有人改 VPC 的某個屬性做 apply，plan 裡不會顯示 subnet 的相關影響，而實際上那些手動管的 subnet 可能依賴 VPC 的那個屬性。功能相關的資源要整批進、整批出。</p>
<p>第二，切片不能切到讓中間態長期懸著 — 如果第一個切片是「import 網路」，但第二個切片（import IAM）排在三個月後，這三個月裡網路由 Terraform 管、IAM 還是手動，drift 風險每天都在。比較安全的節奏是把緊鄰的兩三個切片排在同一個 sprint 或同一個月裡，讓中間態存在的時間越短越好。</p>
<p>一個實際可行的切片順序：先用 <code>terraform import</code> 把一組功能相關的資源（例如一個服務的 VPC + subnet + security group）整批納管，同一個 PR 裡完成。這批資源 import 完後跑 <code>plan</code> 確認零變更，就算一個完整的切片。這個切片的回退方式是 <code>terraform state rm</code> 把資源從 state 移除（資源本身不受影響），系統回到手動狀態。每完成一個切片且沒出事，下一步能拿到的授權就多一點，原本越不敢動就越不能動的循環才會倒過來轉。</p>
<p>切片的排序有一條實務經驗可以參考：先納管唯讀性質的地基（VPC、subnet、route table），再納管 security group 與 IAM role，最後才碰 stateful 資源（RDS、S3）。原因是地基層的 import 風險最低 — 即使 plan 出現非零差異，VPC 或 subnet 的 update-in-place 不會中斷服務。security group 的風險稍高但仍可控。RDS 是風險最高的，因為任何觸發 replace 的欄位差異都意味著資料庫重建 — 這類資源留到信任累積足夠之後再處理，屆時團隊已經對 import 流程有經驗、對 plan 輸出的判讀有信心。</p>
<p>把改動綁進 PR 流程取得 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>
<p>期望值對齊指的是在動工之前，先跟相關角色講好 infra 工作的價值、時程、以及它「慢」的原因，讓慢成為事前的共識而不是事後的指責。infra 的改造之所以慢，是因為它要動的是正在承載流量的地基 — 每一步都得確認沒有破壞既有服務、得保留回退路徑、得跨環境驗證。這種慢是風險控制的成本，不是效率問題。但如果沒有事先說明，旁人看到的只有「一個簡單的事情做了兩週」。</p>
<h3 id="對齊三件事">對齊三件事</h3>
<p><strong>第一：價值翻成對方語言</strong>。對 PM 講的是「這個改動讓未來新環境從三天縮到三十分鐘」，不是「我們把 state 上了遠端後端」。對財務講的是「這批 tag 上完後，下個月的雲帳單能拆到各產品線」，不是「我們需要統一 tagging 規範」。對 CTO 講的是「這讓下一次安全稽核只需要跑一條指令就能列出所有對外開放的端口」，不是「我們要把 security group 從手動改成 IaC」。翻譯的技巧是找到對方在意的度量 — 時間、錢、風險 — 然後用那個度量描述 infra 的效果。</p>
<p><strong>第二：時程給範圍而非單點</strong>。infra 工作有很多步驟是不可壓縮的驗證：每一次 import 都要跑 plan 確認零變更、每一個環境都要各自 apply 再驗收、高風險的 stateful 資源要額外的 review 和手動確認。這些步驟佔了大部分時間但產出不可見。給時程時把「估計 2-3 週」拆成「1 週 import + 驗證、1 週跨環境推送、0.5-1 週 buffer 處理 drift」，讓每一段都有對應的產出。比起一個「3 週」的黑盒，分段時程讓進度可被追蹤、延遲可被歸因。</p>
<p>分段時程的另一個好處是讓「卡住了」的原因可被理解。infra 工作常被卡在非技術因素上：等某個人 review PR（那個人在趕自己的 deadline）、等 staging 環境空出來跑驗證（另一個團隊正在用）、等安全團隊確認 IAM 變更符合政策。如果時程只有一個總數，這些等待全部會被歸因為「infra 太慢」。分段後，卡在哪個環節、等的是誰，一目了然 — 這讓延遲的責任回歸到真正的阻塞點，而非無差別地歸到 infra 團隊身上。</p>
<p><strong>第三：把「慢」的來源攤開</strong>。告訴對方哪幾步是在跨環境驗證（dev 跑通了才推 staging、staging 跑通了才推 prod）、哪幾步是在等 plan review（PR 送出到有人 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>裡用 plan 預覽變更、讓改動在 apply 前就被看見是同一個邏輯，只是把對象從程式碼擴大到人。</p>
<h3 id="對齊的自測">對齊的自測</h3>
<p>一個具體的自測：如果每次進度同步都要重新解釋「為什麼還沒好」，代表期望值沒對齊在前面。最常見的失手是把對齊做成單向報告 — 工程師把計畫寫好丟出去就算對齊了。真正的對齊需要對方有機會在動工前提出他的時間壓力，雙方各退一步排出優先序。有些 infra 工作可以拆成「先做不中斷服務的前半段（import + 驗證），高風險的後半段（切換 apply 流程）排到下一個季度」，這種拆法同時回應了業務的時間壓力跟 infra 的安全需求。</p>
<p>對齊也不等於承諾零風險。反而要在這個階段就把可能的失敗模式講清楚：「import 過程中如果發現某個資源的 Console 設定跟我們以為的不一樣，這個步驟會卡住，需要人工確認現況後才能繼續」。事先講比事後解釋便宜得多。</p>
<p>一個被低估的對齊技巧是拆半交付。有些 infra 工作可以拆成「先做不中斷服務的前半段（import + 驗證），高風險的後半段（切換 apply 流程）排到下一個季度」。前半段的產出是一份跟現況一致的 IaC 程式碼，它本身就有價值 — 新人讀 code 就能理解環境、稽核時有可查的描述。後半段才是讓後續變更走 PR 流程。這種拆法同時回應了業務的時間壓力跟 infra 的安全需求：前半段拿到的價值足以讓決策者看到回報，後半段就有信任基礎去爭取。</p>
<h2 id="知識共享優於個人英雄主義">知識共享優於個人英雄主義</h2>
<p>infra 知識要分散在團隊裡、並盡量沉澱進可執行的程式碼，這樣組織才不會把營運連續性押在單一個人身上。當只有一個人懂整套 infra 怎麼運作，這個人請假、轉組、離職的那一刻，組織就失去了安全改動地基的能力 — 剩下的人不敢動，因為沒人知道動了會牽連到什麼。這是一種典型的單點故障，只是故障點是人不是機器。</p>
<h3 id="英雄主義的代價">英雄主義的代價</h3>
<p>個人英雄主義在短期看起來很有效率：一個熟手能繞過所有流程、直接在 Console 把問題解掉。但這種效率有三個隱性成本。第一，它不會留下痕跡 — 下一個人遇到同樣狀況時得從零重來，或者更常見的是直接去問那個熟手，而那個熟手變成了所有人的瓶頸。第二，它會阻礙流程建立 — 當「找某人手動修」比「走 PR 流程」快，團隊就沒有動力採用流程，於是流程永遠停在「有但沒人用」的狀態。第三，它對個人也是負擔 — 組織越依賴他，他越難抽身去做別的事、越難請長假、越難轉組。</p>
<p>判讀知識集中度的訊號是問一個問題：如果最懂 infra 的人下週離職，團隊還敢動 production 的網路設定嗎？如果答案是「得等他回來」或「只能凍結變更等新人到」，那不論工具鏈多完整，知識還在個人腦中，PR 流程只是形式。</p>
<p>可以用更細緻的分級來評估集中度：能不能看懂 plan 輸出（讀的能力）、能不能寫一個新的小資源（寫的能力）、能不能處理一次 import（操作的能力）、能不能在 apply 出問題時判斷該回退還是繼續（決策的能力）。這四級能力分布在幾個人身上，比所有能力集中在一個人身上，組織韌性高得多。</p>
<h3 id="兩條互補的分散路徑">兩條互補的分散路徑</h3>
<p>把知識搬出個人腦袋有兩條路徑，互補使用。</p>
<p>第一條是把運作邏輯寫進程式碼與流程。當環境的建立方式是一份 IaC、變更方式是一個 PR，知識就內建在可執行的物件裡，新人讀 code 跟 PR 歷史就能重建脈絡。PR 的描述不只是「改了什麼」，還要寫「為什麼這樣改」— 三個月後有人翻 git log，看到「把 NAT 從單一改成 per-AZ，因為上週 ap-northeast-1a 故障時全部 private subnet 出站斷了」，這個決策脈絡就永久保留了。這正是<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>第二條是刻意的輪替與配對。讓不同人輪流負責 infra 的 review 與 apply，用實際操作累積分散的熟悉度。具體做法包括：</p>
<ul>
<li><strong>第二 reviewer 制度</strong>：每次 infra PR 指定一個「非平常負責 infra 的人」做第二 reviewer。這個人不需要能獨立寫 HCL，但需要能讀懂 plan 輸出、問出「這個 replace 是故意的嗎」這類問題。review 本身就是學習。</li>
<li><strong>輪值部署</strong>：每季度讓不同人負責一次環境部署或擴容。第一次由熟手配對帶著做，第二次獨立執行、熟手待命。兩次之後這個人就能獨立處理同類操作。</li>
<li><strong>on-call 不自動轉派</strong>：on-call 輪值時 infra 問題不自動轉給專家，先讓當值的人用 code 和文件嘗試處理，15 分鐘內搞不定再 escalate。這 15 分鐘裡他會學到的比任何文件都多 — 而且會發現哪些 runbook 缺了、哪些步驟寫得不清楚，這些回饋又改善了文件品質。</li>
<li><strong>infra 變更的 runbook</strong>：把常見操作（加一條 security group rule、擴容 RDS、加一個新環境）寫成 step-by-step 的操作文件，包含「跑這條指令」「確認這個輸出」「看到這個就停」。Runbook 降低的是「開始做」的門檻 — 有 runbook 的操作，非專家也敢接手。</li>
</ul>
<p>這些做法的共同點是刻意把操作機會分散出去，讓知識透過做而非透過講來傳遞。</p>
<p>共享不必走到人人都是專家。只要關鍵操作有第二個人能接手、關鍵決策的脈絡留得下來，瓶頸就不再卡在單一個人身上。</p>
<h2 id="把-infra-重要性翻成商業語言">把 infra 重要性翻成商業語言</h2>
<p>infra 的重要性要翻譯成商業後果才能進入決策者的優先級，因為決策者用的是成本與風險的語言，不是技術術語的語言。「我們缺乏環境分離」對 PM 沒有重量，但「測試環境的一次誤操作可以直接打到正式資料庫、波及全部客戶」有重量，因為後者描述的是一個可以標價的損失。翻譯的本質是把抽象的技術缺口換算成一個具體的、會痛的場景。</p>
<h3 id="缺口兌現時的商業後果">缺口兌現時的商業後果</h3>
<p>把地基失效時會發生什麼攤開來算。每一項 infra 缺口都有對應的失效情境：</p>
<table>
  <thead>
      <tr>
          <th>infra 缺口</th>
          <th>失效情境</th>
          <th>商業後果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>沒有 state 版控</td>
          <td>兩人併發 apply，環境記錄錯亂</td>
          <td>重建要數天，期間服務不可用</td>
      </tr>
      <tr>
          <td>沒有身分隔離</td>
          <td>一把外洩的長期 key 橫向存取所有資源</td>
          <td>資料外洩，客戶通知，可能的法律責任</td>
      </tr>
      <tr>
          <td>沒有環境分離</td>
          <td>本該打在 staging 的變更直接改了 production</td>
          <td>生產服務中斷，影響所有客戶</td>
      </tr>
      <tr>
          <td>沒有 Console 唯讀鐵律</td>
          <td>手動改動造成 drift，下一次 apply 覆蓋手動設定</td>
          <td>不可預期的服務中斷</td>
      </tr>
      <tr>
          <td>沒有 tagging</td>
          <td>清理資源時無法區分 prod 與 dev，不敢動</td>
          <td>殭屍資源永久燒錢，配額被佔滿</td>
      </tr>
      <tr>
          <td>沒有 secret 管理</td>
          <td>資料庫密碼存在 git 歷史裡，某次 fork 外洩</td>
          <td>全面輪替 + 潛在資料外洩</td>
      </tr>
  </tbody>
</table>
<p>這些場景的共同點是平時完全看不見、失效時一次性兌現巨大成本，這也正是<a href="/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">模組零：infra 是什麼</a>裡地基隱形、出事才現形的論證。把這條論證從技術語境搬到商業語境，就是這一章要做的翻譯。</p>
<p>準備這份表格時，數字不需要精確到小數點，但需要有依據。「重建要數天」可以改成「上次類似事故花了兩天半」；「影響所有客戶」可以改成「影響約 N 個帳號」。有具體數字的描述比泛泛的「可能很嚴重」有說服力得多 — 決策者每天處理的都是模糊的風險，一個有量級的損失估計才會從背景噪音裡跳出來。如果團隊沒發生過類似事故、沒有歷史數字可引用，可以用行業公開的事故報告作為參照（例如某知名服務因為 S3 bucket 公開導致的資料外洩事件），說明同類事故在別的組織造成的代價。</p>
<h3 id="誠實分級">誠實分級</h3>
<p>可操作的做法是替每一項想推動的 infra 工作，準備一句「不做的話，最壞情況是什麼、影響多少客戶、要救多久」。這句話本身就是一道篩子：講不出對應商業後果的工作，可能確實優先級不高、可以排到後面；講得出而且後果嚴重的，這句話就是排程的籌碼。</p>
<p>要小心的陷阱是把每件事都講成最嚴重的情況。幾次之後狼來了效應會讓所有警告失效 — 決策者開始把所有 infra 請求當成「工程師又在危言聳聽」。翻譯要誠實分級：</p>
<table>
  <thead>
      <tr>
          <th>嚴重度</th>
          <th>特徵</th>
          <th>適用的 infra 工作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>地基級</td>
          <td>出事不可逆或回退代價極高</td>
          <td>身分隔離、secret 不進 code、刪除保護</td>
      </tr>
      <tr>
          <td>營運效率級</td>
          <td>出事可恢復但耗時且反覆發生</td>
          <td>環境分離、PR 流程、tagging</td>
      </tr>
      <tr>
          <td>優化級</td>
          <td>不做也不會出事，做了省時間或省錢</td>
          <td>自動化護欄、進階成本分攤、Terragrunt</td>
      </tr>
  </tbody>
</table>
<p>三種嚴重度對應三種論證語言：</p>
<p>地基級的工作用「最壞情況」爭取優先級 — 「如果這把外洩的 admin key 被拿去開一百台礦機，我們的帳號會在幾小時內燒掉整個季度的雲端預算，而且清理過程中所有服務都得暫停」。營運效率級的用「過去 N 次事故的累積成本」來論證 — 「過去半年因為 dev/prod 共用環境，已經發生了三次誤操作影響到正式客戶，每次修復花了半天到一天，加上客戶溝通的時間，累計約六個工作天」。優化級的用「投入 X 天、之後每次省 Y 小時」的 ROI 來排序 — 「導入 Terragrunt 需要三天，之後每次加新環境從兩小時縮到十分鐘」。</p>
<p>三種語言混著用、各自對應到正確嚴重度的工作，才能讓決策者建立「這個人的優先級判斷值得信任」的印象，而不是「這個人不分輕重」。</p>
<p>商業語言是用來爭取優先級、不是用來嚇人；爭取到之後，怎麼安全地做仍然回到本系列技術模組的判準。把成本量化的延伸方法，可參考 <a href="/blog/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">devops 模組八：成本管理</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>：地基隱形、爆炸時才現形的論證，成熟度階梯與 day 1 鐵律</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>：用流程把 infra 知識從個人腦裡搬進 code，PR 作為知識載體</li>
<li>→ <a href="/blog/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">devops 模組八：成本管理</a>：把 infra 缺口換算成可標價成本的拆解視角</li>
<li>→ <a href="/blog/infra/02-identity-credentials/team-access-management/" data-link-title="團隊權限分級與存取管理" data-link-desc="用 admin / operator / viewer 三級劃分團隊成員的雲端操作權限，設計臨時提權流程、定期 access review 節奏，以及 contractor 與外部 vendor 的存取邊界">團隊權限分級</a>：權限分級讓知識不集中在 admin 一個人身上</li>
<li>→ <a href="/blog/infra/08-governance-habits/handover-design/" data-link-title="職務交接與存取撤銷設計" data-link-desc="人員異動時的存取撤銷順序、credential rotation、最小交接清單，以及讓交接成本結構性降低的 infra 設計原則">職務交接設計</a>：交接的操作清單與結構性降低交接成本的設計</li>
</ul>
]]></content:encoded></item><item><title>單環境到多環境的 Retrofit 操作手冊</title><link>https://tarrragon.github.io/blog/infra/04-environment-separation/single-to-multi-env-retrofit/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/04-environment-separation/single-to-multi-env-retrofit/</guid><description>&lt;p>單環境的 Terraform 設定在資源數量少、只有一個人操作時運作順暢。當需要第二個環境（dev 或 staging）、或第二個人開始改 infra 時，單環境的限制會開始浮現：沒有地方安全地測試變更、apply 一次就是對 production 動手。Retrofit 的目標是把這份單環境設定拆成「module + per-env 目錄」的結構，讓 dev 與 prod 各持獨立 state、共用同一套邏輯，而且在整個過程中 production 的資源不受任何影響。&lt;/p>
&lt;h2 id="retrofit-前的準備">Retrofit 前的準備&lt;/h2>
&lt;p>Retrofit 操作的是正在服務的 production 資源，每一步都要確認「plan 顯示零變更」才能往下走。準備工作的目的是降低操作過程中的風險。&lt;/p>
&lt;h3 id="state-備份">State 備份&lt;/h3>
&lt;p>開始之前把 state 拉一份完整備份到本地：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">terraform state pull &amp;gt; state-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>這份備份是最後的回退手段。如果 retrofit 過程中 state 被弄壞（例如 moved block 指向錯誤的位置），可以用 &lt;code>terraform state push state-backup.json&lt;/code> 回到起點重來。state push 會覆蓋遠端 state，屬於危險操作——只在回退時使用。&lt;/p>
&lt;h3 id="識別-stateful-資源">識別 stateful 資源&lt;/h3>
&lt;p>列出所有 state 裡的資源，標記哪些是 stateful（RDS、S3 含資料、EBS volume）：&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 state list &lt;span class="p">|&lt;/span> sort&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Stateful 資源在 retrofit 過程中的風險最高：如果 moved block 寫錯導致 Terraform 判定需要 replace（先刪後建），stateful 資源的 replace 代表資料遺失。後面每一步的 plan 輸出都要特別檢查 stateful 資源有沒有出現 &lt;code>must be replaced&lt;/code> 或 &lt;code>forces replacement&lt;/code>。&lt;/p>
&lt;h3 id="確認-plan-baseline">確認 plan baseline&lt;/h3>
&lt;p>在還沒改任何 code 之前先跑一次 plan，確認起點是乾淨的：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">terraform plan -detailed-exitcode&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Exit code 0 代表 state 與現實一致、沒有 drift。如果此時就有 drift（exit code 2），先解決 drift 再做 retrofit——在已經有 drift 的基礎上做結構重構，plan 的差異訊號會被 drift 淹沒，無法區分「drift 造成的差異」和「retrofit 造成的差異」。&lt;/p>
&lt;h2 id="步驟一把資源宣告抽成-module">步驟一：把資源宣告抽成 module&lt;/h2>
&lt;p>第一步純粹是程式碼重組——把 &lt;code>main.tf&lt;/code> 裡的資源宣告搬進 &lt;code>modules/&lt;/code> 目錄，原地改成 module 呼叫。這一步不改任何資源屬性、不改 backend、不改 provider，所有值先寫死成當前的值。&lt;/p>
&lt;h3 id="目標目錄結構">目標目錄結構&lt;/h3>





&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">│ │ ├── main.tf # VPC、subnet、SG 從根目錄搬過來
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">│ │ ├── variables.tf # 先把所有值寫死在 default 裡
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">│ │ └── outputs.tf # 暴露 VPC ID、subnet IDs 等
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">│ └── database/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">│ ├── main.tf # RDS 從根目錄搬過來
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">│ ├── variables.tf
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">│ └── outputs.tf
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">├── main.tf # 改成 module 呼叫
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">├── backend.tf # 不動
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">└── terraform.tfvars # 這一步還不存在&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="用-moved-block-告訴-terraform-搬家">用 moved block 告訴 Terraform 搬家&lt;/h3>
&lt;p>資源從根目錄搬進 module 後，Terraform 的內部位址從 &lt;code>aws_vpc.main&lt;/code> 變成 &lt;code>module.network.aws_vpc.main&lt;/code>。如果不告訴 Terraform 這個對應關係，它會判定舊位址的資源「要刪」、新位址的資源「要建」——對 VPC 或 RDS 來說這代表服務中斷。&lt;/p></description><content:encoded><![CDATA[<p>單環境的 Terraform 設定在資源數量少、只有一個人操作時運作順暢。當需要第二個環境（dev 或 staging）、或第二個人開始改 infra 時，單環境的限制會開始浮現：沒有地方安全地測試變更、apply 一次就是對 production 動手。Retrofit 的目標是把這份單環境設定拆成「module + per-env 目錄」的結構，讓 dev 與 prod 各持獨立 state、共用同一套邏輯，而且在整個過程中 production 的資源不受任何影響。</p>
<h2 id="retrofit-前的準備">Retrofit 前的準備</h2>
<p>Retrofit 操作的是正在服務的 production 資源，每一步都要確認「plan 顯示零變更」才能往下走。準備工作的目的是降低操作過程中的風險。</p>
<h3 id="state-備份">State 備份</h3>
<p>開始之前把 state 拉一份完整備份到本地：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">terraform state pull &gt; state-backup-<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>.json</span></span></code></pre></div><p>這份備份是最後的回退手段。如果 retrofit 過程中 state 被弄壞（例如 moved block 指向錯誤的位置），可以用 <code>terraform state push state-backup.json</code> 回到起點重來。state push 會覆蓋遠端 state，屬於危險操作——只在回退時使用。</p>
<h3 id="識別-stateful-資源">識別 stateful 資源</h3>
<p>列出所有 state 裡的資源，標記哪些是 stateful（RDS、S3 含資料、EBS volume）：</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 state list <span class="p">|</span> sort</span></span></code></pre></div><p>Stateful 資源在 retrofit 過程中的風險最高：如果 moved block 寫錯導致 Terraform 判定需要 replace（先刪後建），stateful 資源的 replace 代表資料遺失。後面每一步的 plan 輸出都要特別檢查 stateful 資源有沒有出現 <code>must be replaced</code> 或 <code>forces replacement</code>。</p>
<h3 id="確認-plan-baseline">確認 plan baseline</h3>
<p>在還沒改任何 code 之前先跑一次 plan，確認起點是乾淨的：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">terraform plan -detailed-exitcode</span></span></code></pre></div><p>Exit code 0 代表 state 與現實一致、沒有 drift。如果此時就有 drift（exit code 2），先解決 drift 再做 retrofit——在已經有 drift 的基礎上做結構重構，plan 的差異訊號會被 drift 淹沒，無法區分「drift 造成的差異」和「retrofit 造成的差異」。</p>
<h2 id="步驟一把資源宣告抽成-module">步驟一：把資源宣告抽成 module</h2>
<p>第一步純粹是程式碼重組——把 <code>main.tf</code> 裡的資源宣告搬進 <code>modules/</code> 目錄，原地改成 module 呼叫。這一步不改任何資源屬性、不改 backend、不改 provider，所有值先寫死成當前的值。</p>
<h3 id="目標目錄結構">目標目錄結構</h3>





<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">│   │   ├── main.tf        # VPC、subnet、SG 從根目錄搬過來
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">│   │   ├── variables.tf   # 先把所有值寫死在 default 裡
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">│   │   └── outputs.tf     # 暴露 VPC ID、subnet IDs 等
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">│   └── database/
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">│       ├── main.tf        # RDS 從根目錄搬過來
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">│       ├── variables.tf
</span></span><span class="line"><span class="ln">10</span><span class="cl">│       └── outputs.tf
</span></span><span class="line"><span class="ln">11</span><span class="cl">├── main.tf                # 改成 module 呼叫
</span></span><span class="line"><span class="ln">12</span><span class="cl">├── backend.tf             # 不動
</span></span><span class="line"><span class="ln">13</span><span class="cl">└── terraform.tfvars       # 這一步還不存在</span></span></code></pre></div><h3 id="用-moved-block-告訴-terraform-搬家">用 moved block 告訴 Terraform 搬家</h3>
<p>資源從根目錄搬進 module 後，Terraform 的內部位址從 <code>aws_vpc.main</code> 變成 <code>module.network.aws_vpc.main</code>。如果不告訴 Terraform 這個對應關係，它會判定舊位址的資源「要刪」、新位址的資源「要建」——對 VPC 或 RDS 來說這代表服務中斷。</p>
<p><code>moved</code> block 宣告式地描述搬遷：</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_vpc</span><span class="p">.</span><span class="k">main</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  to</span>   <span class="o">=</span> <span class="k">module</span><span class="p">.</span><span class="k">network</span><span class="p">.</span><span class="k">aws_vpc</span><span class="p">.</span><span class="k">main</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_subnet</span><span class="p">.</span><span class="k">public</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">network</span><span class="p">.</span><span class="k">aws_subnet</span><span class="p">.</span><span class="k">public</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">moved</span> {
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">  from</span> <span class="o">=</span> <span class="k">aws_subnet</span><span class="p">.</span><span class="k">private</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">  to</span>   <span class="o">=</span> <span class="k">module</span><span class="p">.</span><span class="k">network</span><span class="p">.</span><span class="k">aws_subnet</span><span class="p">.</span><span class="k">private</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">moved</span> {
</span></span><span class="line"><span class="ln">17</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">18</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">19</span><span class="cl">}</span></span></code></pre></div><p>每個搬進 module 的資源都需要一條 moved block。遺漏任何一條，plan 就會顯示該資源要 destroy + create。</p>
<h3 id="zero-change-plan-驗證">Zero-change plan 驗證</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">terraform plan</span></span></code></pre></div><p>這一步的 plan 輸出必須是：</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">Plan: 0 to add, 0 to change, 0 to destroy.</span></span></code></pre></div><p>如果 plan 顯示任何 add、change 或 destroy，先停下來檢查：</p>
<ul>
<li><code>destroy + create</code>：moved block 遺漏或位址寫錯</li>
<li><code>change</code>：module 內的 resource 屬性跟搬進來之前不一致（漏了某個 attribute、default 值不同）</li>
<li><code>add</code>：新的 module output 或 data source 被 Terraform 當成新資源</li>
</ul>
<p>修到 plan 顯示零變更才能 apply。apply 之後 state 裡的資源位址從 <code>aws_vpc.main</code> 更新成 <code>module.network.aws_vpc.main</code>，雲端資源本身不受影響。</p>
<p>安全暫停點：本步完成後 code 已重組、state 位址已更新、雲端資源未變，環境處於自洽狀態，可隔日繼續。</p>
<h2 id="步驟二把寫死的值換成參數">步驟二：把寫死的值換成參數</h2>
<p>Module 內部的寫死值搬到 <code>variables.tf</code>，module 呼叫端從 <code>terraform.tfvars</code> 讀入。這一步的 plan 仍然必須是零變更——因為參數的值就等於原本寫死的值。</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
</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"># main.tf — module 呼叫端
</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="k">var</span><span class="p">.</span><span class="k">db_instance_class</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="k">var</span><span class="p">.</span><span class="k">db_multi_az</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="k">var</span><span class="p">.</span><span class="k">db_backup_retention_days</span>
</span></span><span class="line"><span class="ln">7</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"># terraform.tfvars — prod 的值
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="n">db_instance_class</span>        <span class="o">=</span> <span class="s2">&#34;db.r6g.large&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">db_multi_az</span>              <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">db_backup_retention_days</span> <span class="o">=</span> <span class="m">30</span></span></span></code></pre></div><p>再跑一次 plan 確認零變更。值從寫死改成參數傳入，但傳入的值跟原來一樣，所以 Terraform 算出的差異是零。</p>
<p>安全暫停點：本步完成後 module 已參數化、prod 行為不變，可隔日繼續。</p>
<h2 id="步驟三建立新環境目錄">步驟三：建立新環境目錄</h2>
<p>prod 確認穩定後，建 dev 環境的獨立目錄。這一步是純新增——不碰 prod 的任何檔案。</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">├── environments/
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">│   ├── prod/
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">│   │   ├── main.tf          # 原本根目錄的 module 呼叫搬過來
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">│   │   ├── backend.tf       # prod 的 state 位址
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">│   │   └── terraform.tfvars # prod 的值
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">│   └── dev/
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">│       ├── main.tf          # 複製 prod 的 module 呼叫
</span></span><span class="line"><span class="ln">10</span><span class="cl">│       ├── backend.tf       # dev 的獨立 state 位址
</span></span><span class="line"><span class="ln">11</span><span class="cl">│       └── terraform.tfvars # dev 的縮小值</span></span></code></pre></div><p>dev 的 <code>terraform.tfvars</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"># environments/dev/terraform.tfvars
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="n">db_instance_class</span>        <span class="o">=</span> <span class="s2">&#34;db.t3.micro&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">db_multi_az</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">db_backup_retention_days</span> <span class="o">=</span> <span class="m">1</span></span></span></code></pre></div><p>dev 的 <code>backend.tf</code> 指向獨立的 state 路徑——dev 和 prod 的 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="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;dev/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>如果原本的 prod 是在根目錄操作（不是在 <code>environments/prod/</code> 目錄），這一步還需要把 prod 的操作也搬進 <code>environments/prod/</code>。這個搬遷本身又是一次 moved block + zero-change plan 驗證的循環。</p>
<p>安全暫停點：本步是純新增（建目錄和檔案），不影響 prod 的 state 或資源，可隔日繼續。</p>
<h2 id="步驟四先在-dev-apply-驗證">步驟四：先在 dev apply 驗證</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">cd</span> environments/dev
</span></span><span class="line"><span class="ln">2</span><span class="cl">terraform init
</span></span><span class="line"><span class="ln">3</span><span class="cl">terraform plan
</span></span><span class="line"><span class="ln">4</span><span class="cl">terraform apply</span></span></code></pre></div><p>dev 是全新環境、全新 state，apply 會建出一整套資源。這一步驗證的是 module 在「從零建立」的情境下能否正常運作。如果 dev apply 成功且環境可用，代表 module 的邏輯正確。</p>
<p>dev 環境 apply 後跑一次 plan 確認零 drift：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">terraform plan -detailed-exitcode
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 預期 exit code 0</span></span></span></code></pre></div><p>安全暫停點：dev 環境已驗證、prod 未受影響，可隔日繼續最後的 prod 驗證。</p>
<h2 id="步驟五驗證-prod-未受影響">步驟五：驗證 prod 未受影響</h2>
<p>回到 prod 目錄，跑 plan 確認 prod 的資源沒有任何變化：</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">cd</span> environments/prod
</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</span></span></span></code></pre></div><p>如果此時 prod plan 顯示差異，可能的原因：</p>
<ul>
<li>prod 的 module 呼叫路徑變了（<code>source = &quot;./modules/...&quot;</code> → <code>source = &quot;../../modules/...&quot;</code>）但 moved block 沒跟著更新</li>
<li><code>terraform.tfvars</code> 的某個值跟原本寫死的不一致</li>
<li>provider 版本在 init 時升級了</li>
</ul>
<p>修到零變更。這一步結束後 retrofit 完成——prod 和 dev 各持獨立 state、共用同一套 module、環境差異全部收斂在 tfvars 裡。</p>
<h2 id="常見陷阱">常見陷阱</h2>
<h3 id="moved-block-vs-terraform-state-mv">moved block vs terraform state mv</h3>
<p>兩者都能告訴 Terraform 資源搬了家。<code>moved</code> block 是宣告式的——寫在 HCL 裡、可以 review、可以 revert（刪掉 moved block 就回去）。<code>terraform state mv</code> 是命令式的——直接改 state，沒有 review 機制、改完沒有 undo。</p>
<p>優先用 moved block。<code>state mv</code> 留給 moved block 表達不了的情境：跨 state 搬遷（把資源從一份 state 移到另一份）、或 Terraform 版本太舊不支援 moved block（0.13 以下）。</p>
<h3 id="forces-replacement-觸發">forces replacement 觸發</h3>
<p>某些 resource 的某些 attribute 是「改了就要重建」的（immutable attribute）。常見的觸發：</p>
<table>
  <thead>
      <tr>
          <th>Resource</th>
          <th>Attribute</th>
          <th>改了會怎樣</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>aws_db_instance</code></td>
          <td><code>identifier</code></td>
          <td>forces replacement（資料遺失）</td>
      </tr>
      <tr>
          <td><code>aws_db_instance</code></td>
          <td><code>engine</code></td>
          <td>forces replacement</td>
      </tr>
      <tr>
          <td><code>aws_instance</code></td>
          <td><code>ami</code></td>
          <td>forces replacement</td>
      </tr>
      <tr>
          <td><code>aws_s3_bucket</code></td>
          <td><code>bucket</code></td>
          <td>forces replacement（bucket 名稱不可改）</td>
      </tr>
      <tr>
          <td><code>aws_vpc</code></td>
          <td><code>cidr_block</code></td>
          <td>forces replacement</td>
      </tr>
  </tbody>
</table>
<p>Retrofit 過程中如果不小心改了這些 attribute（例如把 <code>identifier = &quot;mydb&quot;</code> 參數化時打錯了值），plan 會顯示 <code>must be replaced</code>。stateful 資源的 replacement 代表先刪後建——對 RDS 來說就是資料遺失。所以每一步 plan 都要特別檢查有沒有 <code>forces replacement</code> 的輸出。</p>
<h3 id="state-locking-與並行操作">State locking 與並行操作</h3>
<p>Retrofit 期間如果有其他人同時 apply（CI pipeline 被觸發、同事在操作），兩邊的 state 操作會衝突。DynamoDB lock table 會擋下並行的 apply，但 init 和 plan 不一定會被擋。</p>
<p>操作建議：retrofit 開始前在團隊頻道通知「infra 暫停操作」，retrofit 完成後再解除。如果用 Atlantis，可以暫時鎖定 apply 權限。時程參考：10-20 個資源的環境，步驟一到五約需半天到一天。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/04-environment-separation/directory-module-parameterization/" data-link-title="環境分離與模組化 — 目錄結構、module 參數化與 retrofit 路徑" data-link-desc="用目錄結構在第一天就隔開 dev 與 prod 的 state，用 module 讓環境共用同一套邏輯只差參數，以及已經單環境跑起來後怎麼安全拆分">環境分離與模組化</a>：retrofit 的目標結構與設計原則</li>
<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 的設定與 lock 機制</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>：stateful 資源的 replacement 風險</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">infra 走 PR 流程</a>：retrofit 的每一步走 PR 讓 plan 可被 review</li>
</ul>
]]></content:encoded></item><item><title>跨帳號策略 — Organizations、SCP 與帳號工廠</title><link>https://tarrragon.github.io/blog/infra/02-identity-credentials/multi-account-strategy/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/02-identity-credentials/multi-account-strategy/</guid><description>&lt;p>單一帳號走到某個規模後，帳號本身會變成隔離的瓶頸。IAM policy 能控制「誰能做什麼」，但同一個帳號裡的所有資源共用同一組 service quota、同一份 CloudTrail、同一張帳單，一個團隊的操作失誤或資源耗盡會波及整個帳號。把環境拆成獨立帳號，讓每個帳號只承載一個職責，是 IAM 之上的第二層隔離 — &lt;a href="https://tarrragon.github.io/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">模組二的身分與憑證地基&lt;/a>控制的是「誰能做什麼」，帳號邊界控制的是「做錯了波及多遠」。&lt;/p>
&lt;h2 id="單帳號-vs-多帳號什麼時候該切">單帳號 vs 多帳號：什麼時候該切&lt;/h2>
&lt;p>單帳號在早期是合理的起點 — 資源少、人少、管理成本低。帳號邊界帶來的隔離收益要跟它的管理成本比較：每多一個帳號就多一份 CloudTrail、多一組 IAM 基線、多一個需要管理的 state backend。&lt;/p>
&lt;p>三個訊號出現時，單帳號的邊際風險開始超過多帳號的管理成本：&lt;/p>
&lt;p>第一，production 和 dev 的資源開始互相影響。一個 dev 環境的壓力測試把帳號的 EC2 instance quota 吃滿，production 的 auto-scaling 因為拿不到新 instance 而失敗 — 這個故障跟程式碼品質無關，純粹是兩個環境共用同一組配額。帳號分開後，dev 吃滿自己的 quota 不會碰到 production。&lt;/p>
&lt;p>第二，權限邊界用 IAM 已經管不住。一個工程師的 IAM policy 限制他只能操作 &lt;code>env=dev&lt;/code> 的資源，但他手滑用了一個沒有 tag 條件的 policy、或者某個 IAM role 的 trust policy 太寬，他就能碰到 production 資源。帳號邊界是比 IAM policy 更硬的護欄 — 即使 IAM 設定出錯，帳號邊界本身就是物理隔離。&lt;/p>
&lt;p>第三，合規或稽核要求明確區分環境。SOC 2 或金融監管可能要求 production 環境有獨立的存取紀錄和變更審計，與開發環境完全分離。同帳號裡做這件事要靠大量的 IAM 條件和 CloudTrail filter，跨帳號則天然滿足。&lt;/p>
&lt;h2 id="ou-結構帳號怎麼分群">OU 結構：帳號怎麼分群&lt;/h2>
&lt;p>AWS Organizations 用 Organizational Unit（OU）把帳號分群，OU 是 SCP 的掛載點 — 一條 SCP 掛在 OU 上，底下所有帳號都受約束。OU 的設計決定了護欄的作用範圍。&lt;/p>
&lt;p>常見的 OU 拓撲有四層：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>OU&lt;/th>
 &lt;th>底下的帳號&lt;/th>
 &lt;th>職責&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Security&lt;/td>
 &lt;td>Log Archive、Security Tooling&lt;/td>
 &lt;td>集中存放 CloudTrail / Config 日誌、安全工具帳號&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Workload / Prod&lt;/td>
 &lt;td>每個產品線或服務的 production 帳號&lt;/td>
 &lt;td>承載正式流量，SCP 最嚴格&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Workload / NonProd&lt;/td>
 &lt;td>dev、staging 帳號&lt;/td>
 &lt;td>承載開發與驗證，SCP 較寬鬆但仍有底線&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sandbox&lt;/td>
 &lt;td>個人實驗帳號&lt;/td>
 &lt;td>可隨時重建，SCP 限制預算上限和禁止的服務&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>環境怎麼對應到帳號，跟&lt;a href="https://tarrragon.github.io/blog/infra/04-environment-separation/directory-module-parameterization/" data-link-title="環境分離與模組化 — 目錄結構、module 參數化與 retrofit 路徑" data-link-desc="用目錄結構在第一天就隔開 dev 與 prod 的 state，用 module 讓環境共用同一套邏輯只差參數，以及已經單環境跑起來後怎麼安全拆分">模組四的環境分離&lt;/a>是同一個問題的不同層次 — 模組四用目錄和 state 分離環境的 IaC，這裡用帳號分離環境的雲端資源。兩者可以疊加：每個帳號裡的 IaC 仍然用獨立目錄和 state 管理。&lt;/p>
&lt;p>OU 結構的設計原則是「按信任等級分群、按職責隔離」。Prod 跟 NonProd 分開是因為信任等級不同（prod 的 SCP 更嚴格）。Security 獨立是因為它的職責是「監控其他所有帳號」— 如果 security 帳號被攻破，攻擊者能修改稽核日誌來掩蓋行蹤，所以它的存取權限要收到最小。&lt;/p>
&lt;p>一個常見的錯誤是把 OU 當成組織架構的映射（按部門分 OU）。OU 的分群依據是安全邊界和 SCP 策略，不是彙報線。兩個部門如果需要相同的 SCP，它們的帳號應該在同一個 OU 底下；一個部門如果有 prod 和 dev 環境，它們應該在不同 OU 底下。&lt;/p></description><content:encoded><![CDATA[<p>單一帳號走到某個規模後，帳號本身會變成隔離的瓶頸。IAM policy 能控制「誰能做什麼」，但同一個帳號裡的所有資源共用同一組 service quota、同一份 CloudTrail、同一張帳單，一個團隊的操作失誤或資源耗盡會波及整個帳號。把環境拆成獨立帳號，讓每個帳號只承載一個職責，是 IAM 之上的第二層隔離 — <a href="/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">模組二的身分與憑證地基</a>控制的是「誰能做什麼」，帳號邊界控制的是「做錯了波及多遠」。</p>
<h2 id="單帳號-vs-多帳號什麼時候該切">單帳號 vs 多帳號：什麼時候該切</h2>
<p>單帳號在早期是合理的起點 — 資源少、人少、管理成本低。帳號邊界帶來的隔離收益要跟它的管理成本比較：每多一個帳號就多一份 CloudTrail、多一組 IAM 基線、多一個需要管理的 state backend。</p>
<p>三個訊號出現時，單帳號的邊際風險開始超過多帳號的管理成本：</p>
<p>第一，production 和 dev 的資源開始互相影響。一個 dev 環境的壓力測試把帳號的 EC2 instance quota 吃滿，production 的 auto-scaling 因為拿不到新 instance 而失敗 — 這個故障跟程式碼品質無關，純粹是兩個環境共用同一組配額。帳號分開後，dev 吃滿自己的 quota 不會碰到 production。</p>
<p>第二，權限邊界用 IAM 已經管不住。一個工程師的 IAM policy 限制他只能操作 <code>env=dev</code> 的資源，但他手滑用了一個沒有 tag 條件的 policy、或者某個 IAM role 的 trust policy 太寬，他就能碰到 production 資源。帳號邊界是比 IAM policy 更硬的護欄 — 即使 IAM 設定出錯，帳號邊界本身就是物理隔離。</p>
<p>第三，合規或稽核要求明確區分環境。SOC 2 或金融監管可能要求 production 環境有獨立的存取紀錄和變更審計，與開發環境完全分離。同帳號裡做這件事要靠大量的 IAM 條件和 CloudTrail filter，跨帳號則天然滿足。</p>
<h2 id="ou-結構帳號怎麼分群">OU 結構：帳號怎麼分群</h2>
<p>AWS Organizations 用 Organizational Unit（OU）把帳號分群，OU 是 SCP 的掛載點 — 一條 SCP 掛在 OU 上，底下所有帳號都受約束。OU 的設計決定了護欄的作用範圍。</p>
<p>常見的 OU 拓撲有四層：</p>
<table>
  <thead>
      <tr>
          <th>OU</th>
          <th>底下的帳號</th>
          <th>職責</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Security</td>
          <td>Log Archive、Security Tooling</td>
          <td>集中存放 CloudTrail / Config 日誌、安全工具帳號</td>
      </tr>
      <tr>
          <td>Workload / Prod</td>
          <td>每個產品線或服務的 production 帳號</td>
          <td>承載正式流量，SCP 最嚴格</td>
      </tr>
      <tr>
          <td>Workload / NonProd</td>
          <td>dev、staging 帳號</td>
          <td>承載開發與驗證，SCP 較寬鬆但仍有底線</td>
      </tr>
      <tr>
          <td>Sandbox</td>
          <td>個人實驗帳號</td>
          <td>可隨時重建，SCP 限制預算上限和禁止的服務</td>
      </tr>
  </tbody>
</table>
<p>環境怎麼對應到帳號，跟<a href="/blog/infra/04-environment-separation/directory-module-parameterization/" data-link-title="環境分離與模組化 — 目錄結構、module 參數化與 retrofit 路徑" data-link-desc="用目錄結構在第一天就隔開 dev 與 prod 的 state，用 module 讓環境共用同一套邏輯只差參數，以及已經單環境跑起來後怎麼安全拆分">模組四的環境分離</a>是同一個問題的不同層次 — 模組四用目錄和 state 分離環境的 IaC，這裡用帳號分離環境的雲端資源。兩者可以疊加：每個帳號裡的 IaC 仍然用獨立目錄和 state 管理。</p>
<p>OU 結構的設計原則是「按信任等級分群、按職責隔離」。Prod 跟 NonProd 分開是因為信任等級不同（prod 的 SCP 更嚴格）。Security 獨立是因為它的職責是「監控其他所有帳號」— 如果 security 帳號被攻破，攻擊者能修改稽核日誌來掩蓋行蹤，所以它的存取權限要收到最小。</p>
<p>一個常見的錯誤是把 OU 當成組織架構的映射（按部門分 OU）。OU 的分群依據是安全邊界和 SCP 策略，不是彙報線。兩個部門如果需要相同的 SCP，它們的帳號應該在同一個 OU 底下；一個部門如果有 prod 和 dev 環境，它們應該在不同 OU 底下。</p>
<h2 id="scp連管理員都越不過的護欄">SCP：連管理員都越不過的護欄</h2>
<p>Service Control Policy（SCP）是掛在 OU 或帳號上的權限天花板。它跟 IAM policy 的差別是層級：IAM policy 控制「這個身分能做什麼」，SCP 控制「這個帳號裡的任何身分最多能做什麼」。即使帳號內的 root user 或 AdministratorAccess role，也受 SCP 約束。</p>
<p>SCP 的設計策略以 deny-list 為主 — 預設允許所有動作，用 SCP 明確禁止少數高風險操作。相比 allow-list（預設禁止、逐一開放），deny-list 的管理成本低得多，因為 AWS 的 service 和 action 數量龐大，逐一列舉允許清單容易漏、也容易在新服務上線時擋住正常使用。</p>
<p>三條適合從第一天就掛上去的 SCP：</p>
<h3 id="禁止關閉-cloudtrail">禁止關閉 CloudTrail</h3>





<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;Version&#34;</span><span class="p">:</span> <span class="s2">&#34;2012-10-17&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nt">&#34;Statement&#34;</span><span class="p">:</span> <span class="p">[{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nt">&#34;Sid&#34;</span><span class="p">:</span> <span class="s2">&#34;DenyCloudTrailDisable&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nt">&#34;Effect&#34;</span><span class="p">:</span> <span class="s2">&#34;Deny&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nt">&#34;Action&#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="s2">&#34;cloudtrail:StopLogging&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">      <span class="s2">&#34;cloudtrail:DeleteTrail&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">      <span class="s2">&#34;cloudtrail:UpdateTrail&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">],</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nt">&#34;Resource&#34;</span><span class="p">:</span> <span class="s2">&#34;*&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="p">}]</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>CloudTrail 是事後追溯「誰做了什麼」的唯一來源。攻擊者入侵帳號後的第一步往往是關掉稽核日誌來掩蓋行蹤，用 SCP 禁止這個動作，讓日誌在帳號層級不可關閉。</p>
<h3 id="禁止離開指定-region">禁止離開指定 region</h3>





<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;Version&#34;</span><span class="p">:</span> <span class="s2">&#34;2012-10-17&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nt">&#34;Statement&#34;</span><span class="p">:</span> <span class="p">[{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nt">&#34;Sid&#34;</span><span class="p">:</span> <span class="s2">&#34;DenyOutsideRegion&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nt">&#34;Effect&#34;</span><span class="p">:</span> <span class="s2">&#34;Deny&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nt">&#34;NotAction&#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="s2">&#34;iam:*&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">      <span class="s2">&#34;sts:*&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">      <span class="s2">&#34;organizations:*&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">      <span class="s2">&#34;support:*&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">],</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nt">&#34;Resource&#34;</span><span class="p">:</span> <span class="s2">&#34;*&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nt">&#34;Condition&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">      <span class="nt">&#34;StringNotEquals&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nt">&#34;aws:RequestedRegion&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;ap-northeast-1&#34;</span><span class="p">,</span> <span class="s2">&#34;us-east-1&#34;</span><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><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>限制資源只能建在指定 region，避免有人在沒人注意的 region（如 <code>af-south-1</code>）開資源 — 不管是誤操作還是攻擊者利用。<code>NotAction</code> 裡排除 IAM 和 STS 等全域服務，因為它們不分 region。<code>us-east-1</code> 通常要保留，因為 CloudFront、ACM（global cert）等服務的 API 端點在 us-east-1。</p>
<h3 id="禁止刪除-vpc-flow-logs">禁止刪除 VPC Flow Logs</h3>





<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;Version&#34;</span><span class="p">:</span> <span class="s2">&#34;2012-10-17&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;Statement&#34;</span><span class="p">:</span> <span class="p">[{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nt">&#34;Sid&#34;</span><span class="p">:</span> <span class="s2">&#34;DenyDeleteFlowLogs&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nt">&#34;Effect&#34;</span><span class="p">:</span> <span class="s2">&#34;Deny&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nt">&#34;Action&#34;</span><span class="p">:</span> <span class="s2">&#34;ec2:DeleteFlowLogs&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nt">&#34;Resource&#34;</span><span class="p">:</span> <span class="s2">&#34;*&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="p">}]</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>VPC Flow Logs 記錄網路層的流量軌跡，是安全事件排查的關鍵資料。跟 CloudTrail 的邏輯一樣 — 稽核資料不允許被帳號內的操作者刪除。</p>
<h3 id="scp-的繼承模型">SCP 的繼承模型</h3>
<p>SCP 沿著 OU 樹向下繼承：掛在 Root OU 的 SCP 對所有帳號生效，掛在子 OU 的 SCP 只對該 OU 底下的帳號生效。多層 SCP 的效果是交集 — 父 OU 禁止的動作，子 OU 無法用 SCP 重新允許。這個交集模型讓安全團隊能在頂層設「絕對底線」，各子 OU 只能在底線之內進一步收斂、不能放寬。</p>
<p>把 SCP 用 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_organizations_policy&#34; &#34;deny_cloudtrail_disable&#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;deny-cloudtrail-disable&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  description</span> <span class="o">=</span> <span class="s2">&#34;Prevent anyone from stopping or deleting CloudTrail&#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;SERVICE_CONTROL_POLICY&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">  content</span>     <span class="o">=</span> <span class="k">file</span><span class="p">(</span><span class="s2">&#34;policies/deny-cloudtrail-disable.json&#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></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_organizations_policy_attachment&#34; &#34;root_deny_cloudtrail&#34;</span> {
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  policy_id</span> <span class="o">=</span> <span class="k">aws_organizations_policy</span><span class="p">.</span><span class="k">deny_cloudtrail_disable</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">  target_id</span> <span class="o">=</span> <span class="k">aws_organizations_organization</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">roots</span><span class="p">[</span><span class="m">0</span><span class="p">].</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">}</span></span></code></pre></div><p>SCP 的 JSON 存在 repo 的 <code>policies/</code> 目錄，變更走 PR review，讓護欄本身也在版本控制與審查流程裡。</p>
<p>控制面 token 的治理是 SCP 護欄之外需要同步處理的議題。Cloudflare 2023 事件中，控制面 token 的生命週期與最小權限沒有對齊，機器憑證形成跨服務的高權限風險（見 <a href="/blog/backend/07-security-data-protection/cases/cloudflare-control-plane-token-2023/" data-link-title="7.C2 Cloudflare：2023 Control-plane Token 事件" data-link-desc="控制面 token 事件如何回寫 secrets 與機器憑證治理。">Cloudflare：Control-plane Token 事件</a>）。Okta 2023 事件則顯示身份治理若只覆蓋生產系統而忽略支援工具鏈，支援系統的 session 和 token 會成為跨租戶的風險放大點（見 <a href="/blog/backend/07-security-data-protection/cases/okta-support-system-incident-2023/" data-link-title="7.C5 Okta：2023 Support System 事件" data-link-desc="支援系統憑證風險如何擴散到客戶租戶的案例。">Okta：Support System 事件</a>）。兩個案例的共同教訓是：SCP 管的是 AWS API 層的動作上限，但 token / session 這類應用層的機器憑證需要獨立的 lifecycle 治理。</p>
<h2 id="帳號工廠每個新帳號自帶安全基線">帳號工廠：每個新帳號自帶安全基線</h2>
<p>跨帳號策略（帳號數量、OU 結構、SCP 規則）屬於影響全組織的架構決策，建議在實施前取得技術主管或 CTO 的對齊。SCP 一旦套用到 OU，該 OU 下所有帳號立即受影響，回退需要修改 SCP 或移動帳號到不同 OU。</p>
<p>手動建帳號的問題跟手動建資源一樣 — 每次都靠人記得「開完帳號後要開 CloudTrail、要刪預設 VPC、要設基線 IAM role」。帳號工廠（Account Factory）把這些步驟自動化成一個可重複的流程：建一個帳號、自動套用安全基線、自動加進正確的 OU。</p>
<p>AWS Control Tower 是 AWS 提供的帳號工廠實作，它包裝了 Organizations、SCP、Config Rules 和 CloudFormation StackSet，提供一個「建帳號 → 自動配置」的流水線。它的好處是一鍵啟用、內建一組 AWS 建議的護欄；代價是它對 OU 結構和 SCP 有自己的意見，跟團隊已有的設計可能衝突，而且它用 CloudFormation StackSet 做基線配置，跟 Terraform 管理的資源需要劃清邊界。</p>
<p>不用 Control Tower 時，帳號工廠可以用 Terraform + 腳本自建。核心是一個 module 接受帳號名稱和 OU 作為參數，產出：帳號建立、CloudTrail trail、預設 VPC 刪除、基線 IAM role（讓管理帳號能 assume 進來做維護）、Config recorder 啟用。</p>
<p>每個新帳號該自帶的安全基線至少包含：</p>
<ul>
<li>CloudTrail 開啟並寫到集中的 Log Archive 帳號</li>
<li>預設 VPC 刪除（預設 VPC 的 security group 全通、CIDR 固定且跨帳號重複，留著是隱患）</li>
<li>基線 IAM role 讓管理帳號能 assume 進來</li>
<li>Config recorder 啟用（記錄資源設定變更歷史）</li>
<li>掛上所屬 OU 的 SCP</li>
</ul>
<p>導入時程參考：初次設定 Organizations + OU 結構 + day-1 SCP 約需 2-3 天；之後每開一個新帳號（含基線配置）約需 2-4 小時。</p>
<h2 id="跨帳號存取role-assumption">跨帳號存取：role assumption</h2>
<p>多帳號架構裡，人或自動化需要在不同帳號之間切換操作。跨帳號存取用 IAM role 的 trust policy 實現 — 目標帳號建一個 role，trust policy 允許來源帳號的特定身分 assume 這個 role。</p>
<p>AWS Organizations 在建子帳號時會自動建一個 <code>OrganizationAccountAccessRole</code>，讓管理帳號的 admin 能 assume 進去。這個 role 的權限是 AdministratorAccess — 它的用途是初始設定和緊急存取，日常操作不該用它。日常的跨帳號存取應該建立職責專用的 role：部署用的 role 只有部署相關權限、唯讀稽核用的 role 只有 read 權限。</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;deploy_from_cicd&#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;deploy-from-cicd-account&#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">  assume_role_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">      Effect</span>    <span class="o">=</span> <span class="s2">&#34;Allow&#34;</span>
</span></span><span class="line"><span class="ln"> 8</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::111111111111:role/cicd-runner&#34;</span> }
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">      Action</span>    <span class="o">=</span> <span class="s2">&#34;sts:AssumeRole&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">      Condition</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">        StringEquals</span> <span class="o">=</span><span class="n"> { &#34;sts:ExternalId&#34;</span> <span class="o">=</span> <span class="s2">&#34;deploy-prod-2026&#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="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></span></code></pre></div><p><code>ExternalId</code> 是防止 confused deputy 攻擊的機制 — 如果 trust policy 只用帳號 ID 驗證，任何能在來源帳號建 role 的人都能 assume 目標 role。加上 ExternalId 讓 assumption 多一個只有雙方知道的驗證值。</p>
<p>跨帳號存取的設計與<a href="/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">模組二的 OIDC 短期憑證</a>互補 — OIDC 解決「雲外到雲內」的身分聯合（CI/CD → AWS），role assumption 解決「雲內帳號之間」的身分切換。</p>
<h2 id="帳單整合">帳單整合</h2>
<p>Organizations 的附帶收益是合併帳單（Consolidated Billing）。所有子帳號的用量合併到管理帳號的帳單裡，一方面簡化付款流程（一張帳單而非多張），另一方面可以享受跨帳號的用量折扣 — 例如 S3 的定價階梯是看總用量，三個帳號各用 1TB 分開計費跟合併成 3TB 計費，後者的單位價格更低。</p>
<p>合併帳單跟成本歸屬的 tagging 互補。合併帳單讓所有費用匯到一張帳單，tagging 讓這張帳單能拆到各團隊和用途 — 這兩件事在<a href="/blog/infra/08-governance-habits/cost-visibility-rhythm/" data-link-title="成本可見性與最小可行治理節奏" data-link-desc="用 tag 驅動的成本分攤讓帳單有人負責，以及判斷什麼治理該 day-1 就立、什麼等規模逼出來再加">模組八的成本可見性</a>展開。帳號邊界本身也是一層成本隔離：每個帳號的用量可以獨立查看，讓「這個帳號這個月花了多少」變成自動可查、不需要依賴 tag。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">身分與憑證地基</a>：IAM role / policy / OIDC 是帳號內的身分控制，本篇是帳號間的隔離</li>
<li>→ <a href="/blog/infra/04-environment-separation/directory-module-parameterization/" data-link-title="環境分離與模組化 — 目錄結構、module 參數化與 retrofit 路徑" data-link-desc="用目錄結構在第一天就隔開 dev 與 prod 的 state，用 module 讓環境共用同一套邏輯只差參數，以及已經單環境跑起來後怎麼安全拆分">環境分離與模組化</a>：目錄與 state 分離環境的 IaC，帳號分離是雲端資源層的對應</li>
<li>→ <a href="/blog/infra/08-governance-habits/cost-visibility-rhythm/" data-link-title="成本可見性與最小可行治理節奏" data-link-desc="用 tag 驅動的成本分攤讓帳單有人負責，以及判斷什麼治理該 day-1 就立、什麼等規模逼出來再加">成本可見性</a>：合併帳單 + tagging 的成本歸屬</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">infra 走 PR 流程</a>：SCP 的 JSON 存 repo、變更走 PR review</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>模組二：身分與憑證地基 — IAM 與 OIDC</title><link>https://tarrragon.github.io/blog/infra/02-identity-credentials/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/02-identity-credentials/</guid><description>&lt;p>權限一旦散落，後面每一層都建在沙上。網路收斂得再好，只要一把權限過大的長期憑證流出，攻擊者就能繞過所有邊界直接動到核心資源；環境分得再乾淨，只要 production 跟 staging 共用同一組身分，一次誤操作就跨環境炸開。身分與憑證是地基層最先該收斂的能力，因為它決定了「誰能動什麼」這個問題有沒有可信的答案。這一章把這個地基設計好，讓後面的網路、環境分離、服務上線都有一個明確的權限模型可以掛靠。&lt;/p>
&lt;h2 id="iam-的心智模型">IAM 的心智模型&lt;/h2>
&lt;p>IAM（Identity and Access Management）是雲端平台用來回答「某個身分能不能對某個資源做某件事」的授權系統。它把授權拆成三個獨立的零件：identity（身分，發起動作的主體）、policy（政策，描述「允許/拒絕對哪些資源做哪些動作」的規則）、role（角色，一組可以被臨時取得的權限集合）。理解這三者的分工，是後面所有憑證決策的前提。&lt;/p>
&lt;p>identity 分兩類，這個區分在後面設計權限邊界時會反覆用到。一類是 user，代表一個長期存在的主體，通常對應到一個真人或一個固定的服務帳號，本身可以持有長期憑證。另一類是 role，代表一組權限的暫時授予 — 沒有自己的長期密碼，而是讓某個被信任的身分「假扮（assume）」成它、換取一段有時效的臨時憑證。policy 則是貼在 user 或 role 上的規則文件，列出 &lt;code>Action&lt;/code>（能做什麼，如 &lt;code>s3:GetObject&lt;/code>）、&lt;code>Resource&lt;/code>（對哪個資源）、&lt;code>Effect&lt;/code>（允許或拒絕）。&lt;/p>
&lt;p>最小權限（least privilege）是貫穿這套系統的設計原則：一個身分只應該拿到完成它本職工作所需的最小權限集合，多一個 action、多一個 resource 都是攻擊面。最小權限是持續收斂的過程，而非一次設定就結束的靜態狀態 — 服務初期常為了快速上線給寬鬆權限，之後要靠 access analyzer 這類工具觀察「實際用到哪些 action」，再把沒用到的權限收掉。判讀訊號很直接：如果一個 CI role 的 policy 裡有 &lt;code>*:*&lt;/code> 或 &lt;code>AdministratorAccess&lt;/code>，它就是下一個 incident 的入口。&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"># 最小權限：CI 只能讀寫特定 bucket、不給整個 S3
&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">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;ci_artifacts&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="k">statement&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"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;s3:GetObject&amp;#34;, &amp;#34;s3:PutObject&amp;#34;&lt;/span>&lt;span class="p">]&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"> resources&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;arn:aws:s3:::myapp-artifacts/*&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>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="長期-access-key-的風險">長期 access key 的風險&lt;/h2>
&lt;p>長期 access key 是一組沒有到期時間的靜態憑證（access key ID + secret），任何持有它的人或程式都能以對應身分的全部權限呼叫 API，直到有人手動撤銷為止。它最大的問題是「沒有時效」這個性質本身，會在三個方向上累積風險，而且風險隨團隊規模與時間單調上升。&lt;/p>
&lt;p>第一是散落。長期 key 為了被程式使用，會被複製進 &lt;code>.env&lt;/code> 檔、CI 設定、本機 &lt;code>~/.aws/credentials&lt;/code>、Slack 訊息、甚至誤推進 git 歷史。每多一個副本就多一個外洩點，而你很難盤點清楚一把 key 到底被貼進了多少地方。第二是權限過大。因為輪替麻煩，團隊傾向給一把 key 配足夠寬的權限「一次搞定」，於是一把本來只該讀 artifact 的 key 同時握有刪除 production 資料庫的能力。第三是難以輪替。輪替一把長期 key 意味著找出所有副本、同步替換、確認沒有遺漏，這個成本高到讓多數團隊選擇拖延，於是 key 的有效期變成「無限」，外洩後的曝險窗口也跟著變成無限。&lt;/p>
&lt;p>判讀訊號是：如果你無法在五分鐘內回答「這把 key 被用在哪些地方、上次輪替是什麼時候」，它就已經是技術債。早期新創特別容易踩這個坑 — 一個工程師為了讓部署腳本跑起來，在筆電上建了一把 admin key，半年後這把 key 還在 CI 環境變數裡，建立它的人已經離職。這類事故的代價不在於「key 外洩」這個事件本身，而在於外洩之後你沒有任何手段限制爆炸半徑。&lt;/p>
&lt;h2 id="oidc給-cicd-的短期憑證">OIDC：給 CI/CD 的短期憑證&lt;/h2>
&lt;p>OIDC（OpenID Connect）聯合讓 CI/CD 平台用一段每次執行才簽發、幾分鐘後就失效的短期憑證取代長期 key，從根本上消掉「靜態密鑰散落」這個問題。它的運作方式是建立信任關係：雲端帳號信任某個外部 identity provider（如 GitHub Actions、GitLab CI 的 OIDC issuer），當管線執行時，CI 平台簽發一個帶有可驗證 claim 的 token（描述「這是哪個 repo、哪個 branch、哪個 workflow 在跑」），雲端用這個 token 換出一段臨時憑證。沒有任何長期 secret 需要被儲存在 CI 設定裡。&lt;/p>
&lt;p>關鍵設計在 role 的 trust policy（信任政策）上 — 它規定「哪個外部身分被允許假扮成這個 role」。trust policy 要用 token 的 claim 把假扮條件收到最緊：限定 issuer、限定 audience、限定特定 repo 與 branch。收得太鬆（例如只驗 issuer、不驗 repo）等於任何掛在同一個 CI 平台的專案都能假扮你的 role，這是常見的設定陷阱。&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"># OIDC trust policy：只允許特定 repo 的 main branch 假扮此 role
&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">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;ci_trust&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="k">statement&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"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sts:AssumeRoleWithWebIdentity&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">principals&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="s2">&amp;#34;Federated&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="n"> identifiers&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="k">aws_iam_openid_connect_provider&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">github&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&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;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">condition&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"> test&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;StringEquals&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"> variable&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;token.actions.githubusercontent.com:aud&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"> values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sts.amazonaws.com&amp;#34;&lt;/span>&lt;span class="p">]&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;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="k">condition&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"> test&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;StringLike&amp;#34;&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"> variable&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;token.actions.githubusercontent.com:sub&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 class="n"> values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;repo:my-org/my-app:ref:refs/heads/main&amp;#34;&lt;/span>&lt;span class="p">]&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>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這一章只把 role 與 trust policy 設計好，OIDC 的實際回報要到&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>建管線時才兌現 — 屆時管線用這裡定義好的 role 取得短期權限執行 &lt;code>plan&lt;/code> 與 &lt;code>apply&lt;/code>，CI 環境裡不需要存任何 access key。下一步路由很明確：role 與最小權限的 policy 屬於這裡的地基，管線怎麼觸發、怎麼卡 review 屬於模組七。&lt;/p></description><content:encoded><![CDATA[<p>權限一旦散落，後面每一層都建在沙上。網路收斂得再好，只要一把權限過大的長期憑證流出，攻擊者就能繞過所有邊界直接動到核心資源；環境分得再乾淨，只要 production 跟 staging 共用同一組身分，一次誤操作就跨環境炸開。身分與憑證是地基層最先該收斂的能力，因為它決定了「誰能動什麼」這個問題有沒有可信的答案。這一章把這個地基設計好，讓後面的網路、環境分離、服務上線都有一個明確的權限模型可以掛靠。</p>
<h2 id="iam-的心智模型">IAM 的心智模型</h2>
<p>IAM（Identity and Access Management）是雲端平台用來回答「某個身分能不能對某個資源做某件事」的授權系統。它把授權拆成三個獨立的零件：identity（身分，發起動作的主體）、policy（政策，描述「允許/拒絕對哪些資源做哪些動作」的規則）、role（角色，一組可以被臨時取得的權限集合）。理解這三者的分工，是後面所有憑證決策的前提。</p>
<p>identity 分兩類，這個區分在後面設計權限邊界時會反覆用到。一類是 user，代表一個長期存在的主體，通常對應到一個真人或一個固定的服務帳號，本身可以持有長期憑證。另一類是 role，代表一組權限的暫時授予 — 沒有自己的長期密碼，而是讓某個被信任的身分「假扮（assume）」成它、換取一段有時效的臨時憑證。policy 則是貼在 user 或 role 上的規則文件，列出 <code>Action</code>（能做什麼，如 <code>s3:GetObject</code>）、<code>Resource</code>（對哪個資源）、<code>Effect</code>（允許或拒絕）。</p>
<p>最小權限（least privilege）是貫穿這套系統的設計原則：一個身分只應該拿到完成它本職工作所需的最小權限集合，多一個 action、多一個 resource 都是攻擊面。最小權限是持續收斂的過程，而非一次設定就結束的靜態狀態 — 服務初期常為了快速上線給寬鬆權限，之後要靠 access analyzer 這類工具觀察「實際用到哪些 action」，再把沒用到的權限收掉。判讀訊號很直接：如果一個 CI role 的 policy 裡有 <code>*:*</code> 或 <code>AdministratorAccess</code>，它就是下一個 incident 的入口。</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"># 最小權限：CI 只能讀寫特定 bucket、不給整個 S3
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">data</span> <span class="s2">&#34;aws_iam_policy_document&#34; &#34;ci_artifacts&#34;</span> {
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">statement</span> {
</span></span><span class="line"><span class="ln">4</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">5</span><span class="cl"><span class="n">    resources</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;arn:aws:s3:::myapp-artifacts/*&#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></span></code></pre></div><h2 id="長期-access-key-的風險">長期 access key 的風險</h2>
<p>長期 access key 是一組沒有到期時間的靜態憑證（access key ID + secret），任何持有它的人或程式都能以對應身分的全部權限呼叫 API，直到有人手動撤銷為止。它最大的問題是「沒有時效」這個性質本身，會在三個方向上累積風險，而且風險隨團隊規模與時間單調上升。</p>
<p>第一是散落。長期 key 為了被程式使用，會被複製進 <code>.env</code> 檔、CI 設定、本機 <code>~/.aws/credentials</code>、Slack 訊息、甚至誤推進 git 歷史。每多一個副本就多一個外洩點，而你很難盤點清楚一把 key 到底被貼進了多少地方。第二是權限過大。因為輪替麻煩，團隊傾向給一把 key 配足夠寬的權限「一次搞定」，於是一把本來只該讀 artifact 的 key 同時握有刪除 production 資料庫的能力。第三是難以輪替。輪替一把長期 key 意味著找出所有副本、同步替換、確認沒有遺漏，這個成本高到讓多數團隊選擇拖延，於是 key 的有效期變成「無限」，外洩後的曝險窗口也跟著變成無限。</p>
<p>判讀訊號是：如果你無法在五分鐘內回答「這把 key 被用在哪些地方、上次輪替是什麼時候」，它就已經是技術債。早期新創特別容易踩這個坑 — 一個工程師為了讓部署腳本跑起來，在筆電上建了一把 admin key，半年後這把 key 還在 CI 環境變數裡，建立它的人已經離職。這類事故的代價不在於「key 外洩」這個事件本身，而在於外洩之後你沒有任何手段限制爆炸半徑。</p>
<h2 id="oidc給-cicd-的短期憑證">OIDC：給 CI/CD 的短期憑證</h2>
<p>OIDC（OpenID Connect）聯合讓 CI/CD 平台用一段每次執行才簽發、幾分鐘後就失效的短期憑證取代長期 key，從根本上消掉「靜態密鑰散落」這個問題。它的運作方式是建立信任關係：雲端帳號信任某個外部 identity provider（如 GitHub Actions、GitLab CI 的 OIDC issuer），當管線執行時，CI 平台簽發一個帶有可驗證 claim 的 token（描述「這是哪個 repo、哪個 branch、哪個 workflow 在跑」），雲端用這個 token 換出一段臨時憑證。沒有任何長期 secret 需要被儲存在 CI 設定裡。</p>
<p>關鍵設計在 role 的 trust policy（信任政策）上 — 它規定「哪個外部身分被允許假扮成這個 role」。trust policy 要用 token 的 claim 把假扮條件收到最緊：限定 issuer、限定 audience、限定特定 repo 與 branch。收得太鬆（例如只驗 issuer、不驗 repo）等於任何掛在同一個 CI 平台的專案都能假扮你的 role，這是常見的設定陷阱。</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"># OIDC trust policy：只允許特定 repo 的 main branch 假扮此 role
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">data</span> <span class="s2">&#34;aws_iam_policy_document&#34; &#34;ci_trust&#34;</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">statement</span> {
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">    actions</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;sts:AssumeRoleWithWebIdentity&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">principals</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="s2">&#34;Federated&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">      identifiers</span> <span class="o">=</span> <span class="p">[</span><span class="k">aws_iam_openid_connect_provider</span><span class="p">.</span><span class="k">github</span><span class="p">.</span><span class="k">arn</span><span class="p">]</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">condition</span> {
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">      test</span>     <span class="o">=</span> <span class="s2">&#34;StringEquals&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">      variable</span> <span class="o">=</span> <span class="s2">&#34;token.actions.githubusercontent.com:aud&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">      values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;sts.amazonaws.com&#34;</span><span class="p">]</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 class="k">condition</span> {
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">      test</span>     <span class="o">=</span> <span class="s2">&#34;StringLike&#34;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="n">      variable</span> <span class="o">=</span> <span class="s2">&#34;token.actions.githubusercontent.com:sub&#34;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="n">      values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;repo:my-org/my-app:ref:refs/heads/main&#34;</span><span class="p">]</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></span></code></pre></div><p>這一章只把 role 與 trust policy 設計好，OIDC 的實際回報要到<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>建管線時才兌現 — 屆時管線用這裡定義好的 role 取得短期權限執行 <code>plan</code> 與 <code>apply</code>，CI 環境裡不需要存任何 access key。下一步路由很明確：role 與最小權限的 policy 屬於這裡的地基，管線怎麼觸發、怎麼卡 review 屬於模組七。</p>
<h2 id="權限邊界設計">權限邊界設計</h2>
<p>權限邊界是把不同類型的身分與不同環境之間的權限刻意隔開，讓任何一個身分被攻破時，爆炸半徑都被限制在它本職的範圍內。邊界設計有兩條軸線需要分別處理：人 vs 機器，以及環境之間。</p>
<p>人 vs 機器的邊界，源自兩者的存取模式根本不同。人類身分需要互動式登入、應該強制 MFA、權限隨職責變動，且通常透過 SSO 集中管理而非各自持有 key。機器身分（CI、跑在運算資源上的服務）需要的是程式化、無人值守的存取，應該用 role 假扮取得短期憑證，永遠不該配長期 key。機器身分還要再分跑在哪裡：跑在雲上的 workload（運算實例、容器任務）由平台直接把 role 綁在執行環境上 — AWS 用 instance profile 把 role 掛在 EC2 instance、用 ECS task role 把 role 掛在容器任務，workload 從實例 metadata 自動取得輪替的短期憑證，這是早於 OIDC 就存在的標準解；只有跑在雲外的 CI/CD（如 GitHub Actions）拿不到實例 metadata，才需要前面那套 OIDC 信任關係換憑證。把這兩類混在同一個身分上，會讓你既無法對人強制 MFA，也無法對機器收斂權限。一個常見陷阱是工程師用自己的個人 key 跑自動化腳本 — 這把人的廣泛權限直接送進了無人值守的執行環境。</p>
<p>環境之間的邊界，目的是讓 production 的權限與 staging、dev 完全不交叉，避免一次誤操作或一個被攻破的低敏感環境波及到核心資產。實作上常見的做法是每個環境用獨立的帳號（account）或獨立的 role，部署到 production 的身分拿不到 staging 的資源、反之亦然。這條邊界在 AWS 上有兩層具體機制可以落地：帳號級的護欄用 Organizations 把環境拆成獨立帳號，再用 SCP（Service Control Policy）對整個帳號或組織單位設定權限天花板，連帳號內的管理員都越不過去；role 級的護欄用 Permissions Boundary 這個 IAM 字面功能，給單一 role 設一個權限上限，限制它「最多能拿到什麼」，即使有人後來給它貼了過寬的 policy 也會被天花板擋住。前者收的是帳號與組織的整體範圍，後者收的是單一身分的上限，兩者疊起來才讓「權限邊界」從概念變成擋得住誤設的具體工具。判讀訊號是：如果一個 dev 環境的 CI role 能列出或刪除 production 的資源，邊界就沒有真正建立。環境隔離的更完整實作（帳號結構、模組化參數）會在<a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>展開，這裡先確保身分層的權限不跨環境。</p>
<p>這一章談的是身分與憑證 — 誰是誰、怎麼證明、能動什麼。憑證背後引用的應用層 secret（資料庫密碼、第三方 API key）怎麼安全儲存與注入，屬於<a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>的 secret management 範圍，不在這裡處理。兩者的交集是：身分層決定「誰能讀到 secret store」，secret 層決定「secret 怎麼存與輪替」。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計</a></td>
          <td>IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/02-identity-credentials/multi-account-strategy/" data-link-title="跨帳號策略 — Organizations、SCP 與帳號工廠" data-link-desc="用 AWS Organizations 把環境拆成獨立帳號、用 SCP 設定連管理員都越不過的護欄、用帳號工廠讓每個新帳號自帶安全基線">跨帳號策略 — Organizations、SCP 與帳號工廠</a></td>
          <td>用 Organizations 把環境拆成獨立帳號、用 SCP 設定帳號級護欄、用帳號工廠自動化新帳號的建立流程</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/02-identity-credentials/team-access-management/" data-link-title="團隊權限分級與存取管理" data-link-desc="用 admin / operator / viewer 三級劃分團隊成員的雲端操作權限，設計臨時提權流程、定期 access review 節奏，以及 contractor 與外部 vendor 的存取邊界">團隊權限分級與存取管理</a></td>
          <td>三級權限模型（admin / operator / viewer）、臨時提權、定期 access review、contractor 存取</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/02-identity-credentials/access-key-rotation-playbook/" data-link-title="Access Key 輪替手冊" data-link-desc="從 credential report 盤點散落的長期 access key，到逐把輪替、自動化輪替與 key age 監控的完整操作步驟">Access Key 輪替手冊</a></td>
          <td>access key 盤點、輪替步驟、Secrets Manager 自動化輪替、key age 監控</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/02-identity-credentials/oidc-trust-policy-setup/" data-link-title="OIDC Trust Policy 設定指南" data-link-desc="GitHub Actions 與 AWS 之間的 OIDC 聯合設定：建立 provider、設計 trust policy 的 claim 收斂、plan 與 apply role 分離、常見錯誤排查">OIDC Trust Policy 設定指南</a></td>
          <td>GitHub Actions OIDC provider 設定、trust policy claim 收斂、plan/apply role 分離、常見錯誤排查</td>
      </tr>
  </tbody>
</table>
<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>：身分備妥後，劃清服務之間的網路邊界</li>
<li>→ <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 模組七：資安與資料保護</a>：Secret Management 與這裡的憑證管理交集</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/CD 用 OIDC 取得短期權限</li>
<li>→ <a href="/blog/infra/takeover/" data-link-title="接手維運：別人建的環境怎麼接管" data-link-desc="接手前人的專案時，怎麼在不搞壞東西的前提下盤點現況、建立維運能力、逐步正規化 — 從無 SSH 的 FTP 環境到有半套 IaC 的雲端環境都適用">接手維運</a>：接手時的 credential 盤點與輪替</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>checkov 與 tfsec 規則配置</title><link>https://tarrragon.github.io/blog/infra/07-infra-as-pr/checkov-tfsec-rule-customization/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/07-infra-as-pr/checkov-tfsec-rule-customization/</guid><description>&lt;p>checkov 和 tfsec 安裝後直接跑，通常會產出幾十到幾百條命中。全部修完不切實際、全部忽略又失去價值。這篇處理的是怎麼從「裝了工具」走到「工具的產出可信且可操作」——規則選擇、嚴重度過濾、豁免管理、自訂規則、CI 整合，以及 false positive 的處理流程。&lt;/p>
&lt;h2 id="規則選擇策略">規則選擇策略&lt;/h2>
&lt;p>兩個工具的內建規則集都超過數百條，涵蓋從加密設定到命名慣例。全開跑會讓命中清單長到沒人看。規則選擇的判準是「這條規則命中後，團隊會不會真的去修」——答案是不會的規則，開著只是製造噪音。&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;th>啟用時機&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>地基層&lt;/td>
 &lt;td>資料外洩與權限失控&lt;/td>
 &lt;td>S3 public access、SG 0.0.0.0/0、IAM wildcard&lt;/td>
 &lt;td>day 1&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>營運層&lt;/td>
 &lt;td>加密與備份&lt;/td>
 &lt;td>RDS encryption、EBS encryption、backup retention&lt;/td>
 &lt;td>IaC 覆蓋率 &amp;gt;50%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>規範層&lt;/td>
 &lt;td>命名、tagging、logging&lt;/td>
 &lt;td>缺 tag、缺 log group、resource naming&lt;/td>
 &lt;td>治理成熟後&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>地基層是即使其他規則都關掉也要開的——S3 bucket 對外公開（&lt;code>CKV_AWS_19&lt;/code>、&lt;code>CKV_AWS_53&lt;/code>）和 security group 全開（&lt;code>CKV_AWS_24&lt;/code>、&lt;code>CKV_AWS_25&lt;/code>）這類規則命中就是真問題。營運層在 IaC 覆蓋率夠高時啟用，否則會掃到大量不在 IaC 管理內的資源。規範層等團隊有能力消化命中量再開。&lt;/p>
&lt;h3 id="checkov-的規則過濾">checkov 的規則過濾&lt;/h3>





&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">checkov -d . --check CKV_AWS_19,CKV_AWS_53,CKV_AWS_24,CKV_AWS_25,CKV_AWS_40,CKV_AWS_145
&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"># 或者用 framework 過濾（只掃 Terraform）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">checkov -d . --framework terraform --compact --quiet&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>checkov 支援 &lt;code>--check&lt;/code>（白名單，只跑這些）和 &lt;code>--skip-check&lt;/code>（黑名單，跳過這些）。初期用 &lt;code>--check&lt;/code> 白名單比較可控——明確列出要跑的規則，而非從全集去扣。隨著團隊消化能力提升再擴大白名單。&lt;/p>
&lt;h3 id="tfsec-的嚴重度過濾">tfsec 的嚴重度過濾&lt;/h3>





&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"># 只報 CRITICAL 和 HIGH&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">tfsec . --minimum-severity HIGH
&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"># 排除特定規則&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">tfsec . --exclude aws-s3-specify-public-access-block&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>tfsec 的嚴重度分 CRITICAL / HIGH / MEDIUM / LOW。初期設 &lt;code>--minimum-severity HIGH&lt;/code> 把低嚴重度的過濾掉，減少噪音量。降低閾值的時機是 HIGH 以上的命中清零後。&lt;/p>
&lt;h2 id="豁免管理">豁免管理&lt;/h2>
&lt;p>不是每個命中都是錯——對外的 ALB 在 port 443 開 &lt;code>0.0.0.0/0&lt;/code> 是設計意圖、不是漏洞。豁免的重點是讓例外顯式化、有理由、可被 review。&lt;/p>
&lt;h3 id="行內豁免">行內豁免&lt;/h3>





&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_security_group_rule&amp;#34; &amp;#34;alb_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"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;ingress&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"> from_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"> to_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">5&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;tcp&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"> cidr_blocks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;0.0.0.0/0&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&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"> #checkov:skip=CKV_AWS_24:ALB 的 HTTPS 入站需要對外開放
&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">&lt;/span>}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>tfsec 的行內豁免：&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_security_group_rule&amp;#34; &amp;#34;alb_https&amp;#34;&lt;/span> {&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">&lt;span class="c1"> #tfsec:ignore:aws-ec2-no-public-ingress-sgr -- ALB HTTPS listener requires public access
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n"> cidr_blocks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;0.0.0.0/0&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>行內豁免的好處是理由跟程式碼在一起，review 時一眼可見。壞處是散落在各檔案裡，盤點所有豁免要 grep。&lt;/p>
&lt;h3 id="集中式豁免">集中式豁免&lt;/h3>
&lt;p>checkov 支援 &lt;code>.checkov.yaml&lt;/code> 集中管理豁免：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c"># .checkov.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">skip-check&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">CKV_AWS_24 &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># ALB public-facing SG rules&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">CKV_AWS_19 &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># Legacy S3 buckets pending migration&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>集中式的好處是一個地方看到所有豁免，適合全域性的例外（如「這批 legacy S3 bucket 還沒遷完、暫時跳過 public access 檢查」）。壞處是理由離程式碼太遠，三個月後沒人記得為什麼跳過。&lt;/p>
&lt;h3 id="豁免紀律">豁免紀律&lt;/h3>
&lt;p>每個豁免都要寫理由（&lt;code>--&lt;/code> 之後的文字）。沒有理由的豁免等於靜默跳過——review 時看不出是故意的還是為了讓 CI 過而隨手加的。定期（每季度）跑一次豁免盤點：&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"># 盤點所有 checkov 豁免&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">grep -rn &lt;span class="s2">&amp;#34;checkov:skip&amp;#34;&lt;/span> --include&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;*.tf&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="c1"># 盤點所有 tfsec 豁免&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">grep -rn &lt;span class="s2">&amp;#34;tfsec:ignore&amp;#34;&lt;/span> --include&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;*.tf&amp;#34;&lt;/span> .&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每個命中問一句：當初跳過的原因還成立嗎？legacy 遷移完了嗎？臨時的例外變成永久的了嗎？&lt;/p></description><content:encoded><![CDATA[<p>checkov 和 tfsec 安裝後直接跑，通常會產出幾十到幾百條命中。全部修完不切實際、全部忽略又失去價值。這篇處理的是怎麼從「裝了工具」走到「工具的產出可信且可操作」——規則選擇、嚴重度過濾、豁免管理、自訂規則、CI 整合，以及 false positive 的處理流程。</p>
<h2 id="規則選擇策略">規則選擇策略</h2>
<p>兩個工具的內建規則集都超過數百條，涵蓋從加密設定到命名慣例。全開跑會讓命中清單長到沒人看。規則選擇的判準是「這條規則命中後，團隊會不會真的去修」——答案是不會的規則，開著只是製造噪音。</p>
<h3 id="分層啟用">分層啟用</h3>
<p>把規則分成三層逐步啟用，而非一次全開：</p>
<table>
  <thead>
      <tr>
          <th>層次</th>
          <th>規則類型</th>
          <th>範例</th>
          <th>啟用時機</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>地基層</td>
          <td>資料外洩與權限失控</td>
          <td>S3 public access、SG 0.0.0.0/0、IAM wildcard</td>
          <td>day 1</td>
      </tr>
      <tr>
          <td>營運層</td>
          <td>加密與備份</td>
          <td>RDS encryption、EBS encryption、backup retention</td>
          <td>IaC 覆蓋率 &gt;50%</td>
      </tr>
      <tr>
          <td>規範層</td>
          <td>命名、tagging、logging</td>
          <td>缺 tag、缺 log group、resource naming</td>
          <td>治理成熟後</td>
      </tr>
  </tbody>
</table>
<p>地基層是即使其他規則都關掉也要開的——S3 bucket 對外公開（<code>CKV_AWS_19</code>、<code>CKV_AWS_53</code>）和 security group 全開（<code>CKV_AWS_24</code>、<code>CKV_AWS_25</code>）這類規則命中就是真問題。營運層在 IaC 覆蓋率夠高時啟用，否則會掃到大量不在 IaC 管理內的資源。規範層等團隊有能力消化命中量再開。</p>
<h3 id="checkov-的規則過濾">checkov 的規則過濾</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 只跑地基層規則</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">checkov -d . --check CKV_AWS_19,CKV_AWS_53,CKV_AWS_24,CKV_AWS_25,CKV_AWS_40,CKV_AWS_145
</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"># 或者用 framework 過濾（只掃 Terraform）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">checkov -d . --framework terraform --compact --quiet</span></span></code></pre></div><p>checkov 支援 <code>--check</code>（白名單，只跑這些）和 <code>--skip-check</code>（黑名單，跳過這些）。初期用 <code>--check</code> 白名單比較可控——明確列出要跑的規則，而非從全集去扣。隨著團隊消化能力提升再擴大白名單。</p>
<h3 id="tfsec-的嚴重度過濾">tfsec 的嚴重度過濾</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 只報 CRITICAL 和 HIGH</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">tfsec . --minimum-severity HIGH
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 排除特定規則</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">tfsec . --exclude aws-s3-specify-public-access-block</span></span></code></pre></div><p>tfsec 的嚴重度分 CRITICAL / HIGH / MEDIUM / LOW。初期設 <code>--minimum-severity HIGH</code> 把低嚴重度的過濾掉，減少噪音量。降低閾值的時機是 HIGH 以上的命中清零後。</p>
<h2 id="豁免管理">豁免管理</h2>
<p>不是每個命中都是錯——對外的 ALB 在 port 443 開 <code>0.0.0.0/0</code> 是設計意圖、不是漏洞。豁免的重點是讓例外顯式化、有理由、可被 review。</p>
<h3 id="行內豁免">行內豁免</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_security_group_rule&#34; &#34;alb_https&#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="s2">&#34;ingress&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  from_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">  to_port</span>     <span class="o">=</span> <span class="m">443</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">  protocol</span>    <span class="o">=</span> <span class="s2">&#34;tcp&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">  cidr_blocks</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;0.0.0.0/0&#34;</span><span class="p">]</span><span class="c1">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">  #checkov:skip=CKV_AWS_24:ALB 的 HTTPS 入站需要對外開放
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span>}</span></span></code></pre></div><p>tfsec 的行內豁免：</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_security_group_rule&#34; &#34;alb_https&#34;</span> {<span class="c1">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">  #tfsec:ignore:aws-ec2-no-public-ingress-sgr -- ALB HTTPS listener requires public access
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="n">  cidr_blocks</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;0.0.0.0/0&#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>行內豁免的好處是理由跟程式碼在一起，review 時一眼可見。壞處是散落在各檔案裡，盤點所有豁免要 grep。</p>
<h3 id="集中式豁免">集中式豁免</h3>
<p>checkov 支援 <code>.checkov.yaml</code> 集中管理豁免：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="c"># .checkov.yaml</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="nt">skip-check</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span>- <span class="l">CKV_AWS_24 </span><span class="w"> </span><span class="c"># ALB public-facing SG rules</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">  </span>- <span class="l">CKV_AWS_19 </span><span class="w"> </span><span class="c"># Legacy S3 buckets pending migration</span></span></span></code></pre></div><p>集中式的好處是一個地方看到所有豁免，適合全域性的例外（如「這批 legacy S3 bucket 還沒遷完、暫時跳過 public access 檢查」）。壞處是理由離程式碼太遠，三個月後沒人記得為什麼跳過。</p>
<h3 id="豁免紀律">豁免紀律</h3>
<p>每個豁免都要寫理由（<code>--</code> 之後的文字）。沒有理由的豁免等於靜默跳過——review 時看不出是故意的還是為了讓 CI 過而隨手加的。定期（每季度）跑一次豁免盤點：</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"># 盤點所有 checkov 豁免</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">grep -rn <span class="s2">&#34;checkov:skip&#34;</span> --include<span class="o">=</span><span class="s2">&#34;*.tf&#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="c1"># 盤點所有 tfsec 豁免</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">grep -rn <span class="s2">&#34;tfsec:ignore&#34;</span> --include<span class="o">=</span><span class="s2">&#34;*.tf&#34;</span> .</span></span></code></pre></div><p>每個命中問一句：當初跳過的原因還成立嗎？legacy 遷移完了嗎？臨時的例外變成永久的了嗎？</p>
<h2 id="自訂規則">自訂規則</h2>
<p>內建規則覆蓋通用安全實踐，但專案特有的規範（如「所有 RDS 必須有 <code>cost-center</code> tag」「所有 S3 bucket 名稱必須以公司前綴開頭」）需要自訂。</p>
<h3 id="checkov-自訂規則python">checkov 自訂規則（Python）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># custom_checks/require_cost_center_tag.py</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">from</span> <span class="nn">checkov.terraform.checks.resource.base_resource_check</span> <span class="kn">import</span> <span class="n">BaseResourceCheck</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kn">from</span> <span class="nn">checkov.common.models.enums</span> <span class="kn">import</span> <span class="n">CheckResult</span><span class="p">,</span> <span class="n">CheckCategories</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">class</span> <span class="nc">CostCenterTagRequired</span><span class="p">(</span><span class="n">BaseResourceCheck</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">):</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;Ensure cost-center tag is present&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nb">id</span> <span class="o">=</span> <span class="s2">&#34;CUSTOM_001&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="n">supported_resources</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;aws_instance&#34;</span><span class="p">,</span> <span class="s2">&#34;aws_db_instance&#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">10</span><span class="cl">        <span class="n">categories</span> <span class="o">=</span> <span class="p">[</span><span class="n">CheckCategories</span><span class="o">.</span><span class="n">GENERAL_SECURITY</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="fm">__init__</span><span class="p">(</span><span class="n">name</span><span class="o">=</span><span class="n">name</span><span class="p">,</span> <span class="nb">id</span><span class="o">=</span><span class="nb">id</span><span class="p">,</span> <span class="n">categories</span><span class="o">=</span><span class="n">categories</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">                         <span class="n">supported_resources</span><span class="o">=</span><span class="n">supported_resources</span><span class="p">)</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 class="k">def</span> <span class="nf">scan_resource_conf</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">conf</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="n">tags</span> <span class="o">=</span> <span class="n">conf</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;tags&#34;</span><span class="p">,</span> <span class="p">[{}])[</span><span class="mi">0</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">tags</span><span class="p">,</span> <span class="nb">dict</span><span class="p">)</span> <span class="ow">and</span> <span class="s2">&#34;cost-center&#34;</span> <span class="ow">in</span> <span class="n">tags</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">            <span class="k">return</span> <span class="n">CheckResult</span><span class="o">.</span><span class="n">PASSED</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="k">return</span> <span class="n">CheckResult</span><span class="o">.</span><span class="n">FAILED</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 class="n">check</span> <span class="o">=</span> <span class="n">CostCenterTagRequired</span><span class="p">()</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 跑自訂規則</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">checkov -d . --external-checks-dir ./custom_checks</span></span></code></pre></div><h3 id="tfsec-自訂規則yaml">tfsec 自訂規則（YAML）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># .tfsec/custom_rules.yaml</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span>- <span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">CUSTOM_001</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">description</span><span class="p">:</span><span class="w"> </span><span class="l">S3 bucket name must start with company prefix</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="nt">impact</span><span class="p">:</span><span class="w"> </span><span class="l">Non-standard naming breaks cross-account policies</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="nt">resolution</span><span class="p">:</span><span class="w"> </span><span class="l">Add company prefix to bucket name</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">  </span><span class="nt">requiredTypes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span>- <span class="l">resource</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">  </span><span class="nt">requiredLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span>- <span class="l">aws_s3_bucket</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">  </span><span class="nt">severity</span><span class="p">:</span><span class="w"> </span><span class="l">MEDIUM</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">  </span><span class="nt">matchSpec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">    </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">bucket</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">    </span><span class="nt">action</span><span class="p">:</span><span class="w"> </span><span class="l">startsWith</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">    </span><span class="nt">value</span><span class="p">:</span><span class="w"> </span><span class="l">acme-</span></span></span></code></pre></div><p>自訂規則的數量保持精簡——每條規則都是維護成本。只有「違反後會在後續流程造成問題」的規範值得寫成自動化規則，純粹的風格偏好留給 review 時口頭提醒。</p>
<h2 id="ci-整合">CI 整合</h2>
<p>把掃描接進 CI 的目標是「PR 合併前就攔下問題」，而非 apply 之後才發現。</p>
<h3 id="github-actions-範例">GitHub Actions 範例</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">security-scan</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Run checkov</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">bridgecrewio/checkov-action@v12</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">          </span><span class="nt">directory</span><span class="p">:</span><span class="w"> </span><span class="l">.</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">          </span><span class="nt">check</span><span class="p">:</span><span class="w"> </span><span class="l">CKV_AWS_19,CKV_AWS_53,CKV_AWS_24,CKV_AWS_25</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">          </span><span class="nt">quiet</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">          </span><span class="nt">compact</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">          </span><span class="nt">soft_fail</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Run tfsec</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">aquasecurity/tfsec-action@v1</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">          </span><span class="nt">minimum_severity</span><span class="p">:</span><span class="w"> </span><span class="l">HIGH</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">          </span><span class="nt">soft_fail</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span></span></span></code></pre></div><p><code>soft_fail: false</code> 讓掃描命中時 CI 失敗、阻擋合併。初期可以先設 <code>soft_fail: true</code>（掃描報告但不阻擋），讓團隊觀察命中量，確認規則集合理後再切成強制。</p>
<h3 id="掃描結果回貼-pr">掃描結果回貼 PR</h3>
<p>checkov 和 tfsec 的 GitHub Actions 都支援把結果以 PR comment 回貼。讓 reviewer 在 PR 頁面直接看到掃描結果，不用去翻 CI log。checkov-action 預設會回貼；tfsec-action 需要額外的 <code>github_token</code> 設定。</p>
<h3 id="漸進式導入">漸進式導入</h3>





<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">Week 1-2：soft_fail=true，觀察命中量和 false positive 率
</span></span><span class="line"><span class="ln">2</span><span class="cl">Week 3：修完所有真問題，豁免所有合理的 false positive
</span></span><span class="line"><span class="ln">3</span><span class="cl">Week 4：切 soft_fail=false，掃描變成強制 gate</span></span></code></pre></div><p>這個節奏讓團隊在掃描變成強制之前就清理完存量，避免「一開 hard fail 所有 PR 都過不了」的窘境。</p>
<h2 id="false-positive-處理">False positive 處理</h2>
<p>false positive 的處理有三條路，依復發頻率選：</p>
<table>
  <thead>
      <tr>
          <th>路徑</th>
          <th>適用情境</th>
          <th>做法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>行內豁免</td>
          <td>單一資源的合理例外</td>
          <td>在該資源加 <code>checkov:skip</code> + 理由</td>
      </tr>
      <tr>
          <td>全域跳過</td>
          <td>整個規則不適用於此專案</td>
          <td>加進 <code>.checkov.yaml</code> skip-check</td>
      </tr>
      <tr>
          <td>自訂規則覆蓋</td>
          <td>內建規則的判準不適合</td>
          <td>寫自訂規則取代內建規則</td>
      </tr>
  </tbody>
</table>
<p>最常見的 false positive 是 ALB 的 public-facing security group（設計就是要開 443）和開發環境的寬鬆設定（dev 允許、prod 不允許）。後者可以用 checkov 的 <code>--var-file</code> 搭配環境變數區分——dev 跑寬鬆規則集、prod 跑嚴格規則集。</p>
<p>處理 false positive 時要抵抗「加 skip 讓 CI 過」的捷徑衝動。每個 skip 都要問：這是設計意圖（ALB 要開放）還是技術債（dev 環境暫時放寬）？前者寫永久豁免加理由，後者寫臨時豁免加 TODO 和預計修復時間。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">infra 走 PR 流程與自動化護欄</a>：掃描在 PR 流程裡的定位與 plan/apply 的關係</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/terraform-ci-pipeline-setup/" data-link-title="Terraform CI Pipeline 設定指南" data-link-desc="用 GitHub Actions 建立完整的 Terraform CI pipeline：fmt → validate → tflint → plan → PR comment → apply，含 OIDC credential 與環境保護規則">Terraform CI Pipeline 設定</a>：掃描步驟怎麼嵌入完整的 CI workflow</li>
<li>→ <a href="/blog/infra/03-network-foundation/security-group-audit-cleanup/" data-link-title="Security Group 稽核與清理" data-link-desc="盤點所有 security group 規則、找出 0.0.0.0/0 全開與未使用的 SG、依賴檢查後安全刪除、自動化治理">模組三：Security Group 稽核與清理</a>：掃描命中 0.0.0.0/0 後的處理流程</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>平台遷移</title><link>https://tarrragon.github.io/blog/infra/upgrade/platform-migration/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/upgrade/platform-migration/</guid><description>&lt;p>平台遷移改變的是系統跑在哪裡，不是系統跑什麼。應用程式碼不動，改變的是網路拓樸、儲存位置、運算環境與存取方式。遷移成功的判準是應用程式在新平台上以等同或更好的效能運作，且舊平台可以被安全退役。&lt;/p>
&lt;p>遷移的核心約束是帶電施工——系統在搬遷過程中要持續服務。這決定了操作模式：在新平台建起平行環境、驗證通過後用 DNS 切換流量、確認沒問題再拆舊環境。每一步都保留回退到舊環境的能力，直到新環境穩定運行一段時間。&lt;/p>
&lt;h2 id="遷移路徑的常見組合">遷移路徑的常見組合&lt;/h2>
&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>共享主機 → VPS&lt;/td>
 &lt;td>SSH、cron 彈性、自訂軟體安裝&lt;/td>
 &lt;td>主機商代管的面板、email、自動備份&lt;/td>
 &lt;td>需要自己管 OS、web server、SSL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>VPS → 雲端&lt;/td>
 &lt;td>Auto-scaling、managed DB、IaC、多 AZ&lt;/td>
 &lt;td>固定月費的簡單計費&lt;/td>
 &lt;td>計費模型改按用量、運維複雜度上升&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>地端 → 雲端&lt;/td>
 &lt;td>彈性擴縮、不管硬體&lt;/td>
 &lt;td>對硬體的直接控制&lt;/td>
 &lt;td>網路重新設計、合規審查、資料主權確認&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每條路徑的遷移工程量級不同：共享主機 → VPS 是最輕的（應用層搬家）、地端 → 雲端是最重的（整個基礎設施重建）。選擇遷移路徑時先確認商業目標——如果目標是「能裝自訂軟體」，共享主機 → VPS 就夠了，不需要一步跳到雲端。&lt;/p>
&lt;h2 id="共享主機--vps-遷移">共享主機 → VPS 遷移&lt;/h2>
&lt;h3 id="遷移前的記錄">遷移前的記錄&lt;/h3>
&lt;p>把共享主機的所有設定記下來，作為 VPS 上重建的 checklist。需要記錄的項目：&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>PHP 版本與模組&lt;/td>
 &lt;td>&lt;code>phpinfo()&lt;/code> 匯出&lt;/td>
 &lt;td>VPS 上安裝對應版本&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cron jobs&lt;/td>
 &lt;td>主機面板截圖或匯出&lt;/td>
 &lt;td>VPS 上重建 crontab&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Email 帳號與轉發規則&lt;/td>
 &lt;td>面板匯出&lt;/td>
 &lt;td>另外處理（見下方）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>DNS 記錄（A / CNAME / MX）&lt;/td>
 &lt;td>域名管理介面匯出&lt;/td>
 &lt;td>切換時需要&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>SSL 憑證&lt;/td>
 &lt;td>簽發者、到期日&lt;/td>
 &lt;td>VPS 上重新簽發或遷移&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>.htaccess 規則&lt;/td>
 &lt;td>從站台下載&lt;/td>
 &lt;td>轉換成 nginx 設定&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>接手維運模組的&lt;a href="https://tarrragon.github.io/blog/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">環境設定拍照&lt;/a>有更完整的盤點方法。&lt;/p>
&lt;h3 id="vps-環境建立">VPS 環境建立&lt;/h3>
&lt;p>VPS 上從零安裝 web stack：&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"># Ubuntu 22.04 為例&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">sudo apt update &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> sudo apt upgrade -y
&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"># Web server&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">sudo apt install nginx -y
&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"># PHP（對齊共享主機的版本）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">sudo apt install php8.1-fpm php8.1-mysql php8.1-curl php8.1-mbstring php8.1-gd php8.1-xml -y
&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="c1"># MySQL&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">sudo apt install mysql-server -y
&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 class="c1"># SSL（Let&amp;#39;s Encrypt）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">sudo apt install certbot python3-certbot-nginx -y
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">sudo certbot --nginx -d example.com -d www.example.com&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>安裝完成後用 &lt;code>php -m&lt;/code> 比對共享主機的 phpinfo 記錄，確認所有模組都已安裝。缺少的模組用 &lt;code>apt install php8.1-&amp;lt;module&amp;gt;&lt;/code> 補上。&lt;/p>
&lt;h3 id="資料搬移">資料搬移&lt;/h3>





&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"># 程式碼：從本地 Git repo 部署（不從共享主機直接搬）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">git clone git@github.com:org/site.git /var/www/site
&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"># 資料庫：從備份匯入&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">mysql -u root -p site_db &amp;lt; backup-latest.sql
&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"># 使用者上傳檔案：從共享主機 FTP 下載後 rsync 到 VPS&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">rsync -avz /local/backup/uploads/ user@vps:/var/www/site/uploads/&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="htaccess--nginx-設定轉換">.htaccess → nginx 設定轉換&lt;/h3>
&lt;p>共享主機用 Apache 的 &lt;code>.htaccess&lt;/code>，VPS 如果改用 nginx 需要手動轉換。常見的規則對照：&lt;/p></description><content:encoded><![CDATA[<p>平台遷移改變的是系統跑在哪裡，不是系統跑什麼。應用程式碼不動，改變的是網路拓樸、儲存位置、運算環境與存取方式。遷移成功的判準是應用程式在新平台上以等同或更好的效能運作，且舊平台可以被安全退役。</p>
<p>遷移的核心約束是帶電施工——系統在搬遷過程中要持續服務。這決定了操作模式：在新平台建起平行環境、驗證通過後用 DNS 切換流量、確認沒問題再拆舊環境。每一步都保留回退到舊環境的能力，直到新環境穩定運行一段時間。</p>
<h2 id="遷移路徑的常見組合">遷移路徑的常見組合</h2>
<table>
  <thead>
      <tr>
          <th>路徑</th>
          <th>獲得</th>
          <th>失去</th>
          <th>主要變動</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>共享主機 → VPS</td>
          <td>SSH、cron 彈性、自訂軟體安裝</td>
          <td>主機商代管的面板、email、自動備份</td>
          <td>需要自己管 OS、web server、SSL</td>
      </tr>
      <tr>
          <td>VPS → 雲端</td>
          <td>Auto-scaling、managed DB、IaC、多 AZ</td>
          <td>固定月費的簡單計費</td>
          <td>計費模型改按用量、運維複雜度上升</td>
      </tr>
      <tr>
          <td>地端 → 雲端</td>
          <td>彈性擴縮、不管硬體</td>
          <td>對硬體的直接控制</td>
          <td>網路重新設計、合規審查、資料主權確認</td>
      </tr>
  </tbody>
</table>
<p>每條路徑的遷移工程量級不同：共享主機 → VPS 是最輕的（應用層搬家）、地端 → 雲端是最重的（整個基礎設施重建）。選擇遷移路徑時先確認商業目標——如果目標是「能裝自訂軟體」，共享主機 → VPS 就夠了，不需要一步跳到雲端。</p>
<h2 id="共享主機--vps-遷移">共享主機 → VPS 遷移</h2>
<h3 id="遷移前的記錄">遷移前的記錄</h3>
<p>把共享主機的所有設定記下來，作為 VPS 上重建的 checklist。需要記錄的項目：</p>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>記錄方式</th>
          <th>用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>PHP 版本與模組</td>
          <td><code>phpinfo()</code> 匯出</td>
          <td>VPS 上安裝對應版本</td>
      </tr>
      <tr>
          <td>Cron jobs</td>
          <td>主機面板截圖或匯出</td>
          <td>VPS 上重建 crontab</td>
      </tr>
      <tr>
          <td>Email 帳號與轉發規則</td>
          <td>面板匯出</td>
          <td>另外處理（見下方）</td>
      </tr>
      <tr>
          <td>DNS 記錄（A / CNAME / MX）</td>
          <td>域名管理介面匯出</td>
          <td>切換時需要</td>
      </tr>
      <tr>
          <td>SSL 憑證</td>
          <td>簽發者、到期日</td>
          <td>VPS 上重新簽發或遷移</td>
      </tr>
      <tr>
          <td>.htaccess 規則</td>
          <td>從站台下載</td>
          <td>轉換成 nginx 設定</td>
      </tr>
  </tbody>
</table>
<p>接手維運模組的<a href="/blog/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">環境設定拍照</a>有更完整的盤點方法。</p>
<h3 id="vps-環境建立">VPS 環境建立</h3>
<p>VPS 上從零安裝 web stack：</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"># Ubuntu 22.04 為例</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">sudo apt update <span class="o">&amp;&amp;</span> sudo apt upgrade -y
</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"># Web server</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">sudo apt install nginx -y
</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"># PHP（對齊共享主機的版本）</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">sudo apt install php8.1-fpm php8.1-mysql php8.1-curl php8.1-mbstring php8.1-gd php8.1-xml -y
</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="c1"># MySQL</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">sudo apt install mysql-server -y
</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="c1"># SSL（Let&#39;s Encrypt）</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">sudo apt install certbot python3-certbot-nginx -y
</span></span><span class="line"><span class="ln">15</span><span class="cl">sudo certbot --nginx -d example.com -d www.example.com</span></span></code></pre></div><p>安裝完成後用 <code>php -m</code> 比對共享主機的 phpinfo 記錄，確認所有模組都已安裝。缺少的模組用 <code>apt install php8.1-&lt;module&gt;</code> 補上。</p>
<h3 id="資料搬移">資料搬移</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 程式碼：從本地 Git repo 部署（不從共享主機直接搬）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git clone git@github.com:org/site.git /var/www/site
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 資料庫：從備份匯入</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">mysql -u root -p site_db &lt; backup-latest.sql
</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"># 使用者上傳檔案：從共享主機 FTP 下載後 rsync 到 VPS</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">rsync -avz /local/backup/uploads/ user@vps:/var/www/site/uploads/</span></span></code></pre></div><h3 id="htaccess--nginx-設定轉換">.htaccess → nginx 設定轉換</h3>
<p>共享主機用 Apache 的 <code>.htaccess</code>，VPS 如果改用 nginx 需要手動轉換。常見的規則對照：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># .htaccess: RewriteEngine On / RewriteRule ^(.*)$ index.php/$1
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># nginx 等價：
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span><span class="k">location</span> <span class="s">/</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="kn">try_files</span> <span class="nv">$uri</span> <span class="nv">$uri/</span> <span class="s">/index.php?</span><span class="nv">$query_string</span><span class="p">;</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></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># .htaccess: Options -Indexes
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># nginx 等價：
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="k">autoindex</span> <span class="no">off</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># .htaccess: deny from all (某目錄)
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># nginx 等價：
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span><span class="k">location</span> <span class="p">~</span> <span class="sr">/\.env</span> <span class="p">{</span> <span class="kn">deny</span> <span class="s">all</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>轉換後在本地或 staging 驗證每條規則的行為是否一致。WordPress、Laravel 等框架有現成的 nginx 設定範例可參考。</p>
<h3 id="email-處理">Email 處理</h3>
<p>共享主機通常附帶 email 服務（用主機面板建 email 帳號）。VPS 預設不含 email。三個處理方式：</p>
<ul>
<li>自架 email server（Postfix + Dovecot）：維運成本高、不推薦除非有特殊需求</li>
<li>改用第三方 email 服務（Google Workspace / Zoho Mail）：設定 MX 記錄指向服務商</li>
<li>只轉發（不收信）：應用程式的寄信功能改用 SMTP relay（SendGrid / Mailgun）</li>
</ul>
<p>DNS 的 MX 記錄要在切換前就改好指向新的 email 服務，否則切換後 email 會中斷。</p>
<h3 id="ssl-自動續期">SSL 自動續期</h3>
<p>共享主機的 SSL 通常由主機商代管續期。VPS 上用 Let&rsquo;s Encrypt 的 certbot 會自動設定 systemd timer 或 cron 做續期，但要驗證它確實在跑：</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"># 確認 certbot 的自動續期排程存在</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sudo systemctl list-timers <span class="p">|</span> grep certbot
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 模擬續期測試（不實際續期）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">sudo certbot renew --dry-run</span></span></code></pre></div><h2 id="vps--雲端遷移">VPS → 雲端遷移</h2>
<h3 id="服務盤點與雲端對照">服務盤點與雲端對照</h3>
<p>VPS 上的每個 process 都需要對應到雲端的服務：</p>
<table>
  <thead>
      <tr>
          <th>VPS 上的角色</th>
          <th>雲端對應</th>
          <th>備註</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>nginx + PHP-FPM</td>
          <td>ECS Fargate / EC2 + ALB</td>
          <td>容器化或直接搬</td>
      </tr>
      <tr>
          <td>MySQL</td>
          <td>RDS</td>
          <td>managed DB、自動備份</td>
      </tr>
      <tr>
          <td>cron jobs</td>
          <td>EventBridge + Lambda / ECS task</td>
          <td>排程觸發的獨立 task</td>
      </tr>
      <tr>
          <td>背景 worker</td>
          <td>ECS service / SQS + Lambda</td>
          <td>依工作模式選型</td>
      </tr>
      <tr>
          <td>檔案儲存</td>
          <td>S3 + CloudFront</td>
          <td>上傳檔案搬到物件儲存</td>
      </tr>
  </tbody>
</table>
<h3 id="自動化遷移工具">自動化遷移工具</h3>
<p>AWS Application Migration Service（MGN）可以自動化 VM workload 的搬遷——把現有 server 的 block-level data 持續複製到 AWS、切換時啟動 EC2 instance。適合大量 VM 的 lift-and-shift，但不處理應用層的重構（nginx config、cron 轉 EventBridge 等仍需手動）。單台 VM 的遷移用 MGN 反而比手動 dump/restore 多一層設定成本，適用場景是同時搬 5 台以上。</p>
<h3 id="iac-的導入時機">IaC 的導入時機</h3>
<p>VPS → 雲端是導入 IaC 的最佳時機——新環境從零建起，沒有歷史包袱。用 Terraform 描述 VPC、subnet、RDS、ECS、ALB 等資源，讓新環境可重現（見<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 直接成為持續維運的基礎。</p>
<h3 id="資料庫遷移">資料庫遷移</h3>
<p>小型資料庫（&lt; 10GB）：mysqldump + 匯入 RDS，遷移期間短暫唯讀即可。</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"># 從 VPS dump</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">mysqldump -u user -p --single-transaction site_db <span class="p">|</span> gzip &gt; site_db.sql.gz
</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"># 匯入 RDS</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">gunzip -c site_db.sql.gz <span class="p">|</span> mysql -h rds-endpoint.region.rds.amazonaws.com -u admin -p site_db</span></span></code></pre></div><p>大型資料庫（&gt; 10GB 或需要零停機）：使用 AWS DMS（Database Migration Service）做持續複寫，VPS 上的 MySQL 作為 source、RDS 作為 target，DMS 做初始全量複製後持續同步增量，切換時把應用指向 RDS 端點。</p>
<h3 id="網路設計">網路設計</h3>
<p>雲端環境的網路要在遷移前規劃好。VPC、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>。VPS 上的 iptables 規則要映射成 security group 規則——iptables 的每條 accept 對應一條 SG ingress rule，但 SG 不支援 deny（用「不開就是 deny」的白名單模式）。</p>
<h2 id="資料同步策略">資料同步策略</h2>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>停機時間</th>
          <th>複雜度</th>
          <th>適用場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>一次性 dump + restore</td>
          <td>分鐘到小時級</td>
          <td>低</td>
          <td>資料 &lt; 10GB、可接受維護窗口</td>
      </tr>
      <tr>
          <td>持續複寫（DMS / 邏輯複寫）</td>
          <td>秒級（切換瞬間）</td>
          <td>高</td>
          <td>資料大、不允許停機</td>
      </tr>
      <tr>
          <td>檔案 rsync 增量同步</td>
          <td>取決於差異量</td>
          <td>低</td>
          <td>靜態檔案、上傳內容</td>
      </tr>
  </tbody>
</table>
<p>選擇策略時先問兩個問題：資料量多大（決定 dump 時間）、業務能接受多長的唯讀或停機窗口（決定要不要持續複寫）。</p>
<p>對於上傳檔案（圖片、文件），遷移到雲端時通常從本地檔案系統搬到 S3：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 從 VPS 同步上傳目錄到 S3</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws s3 sync /var/www/site/uploads/ s3://site-uploads/ --delete</span></span></code></pre></div><p>應用程式碼裡的檔案路徑要改成 S3 URL 或用 CDN 代理。</p>
<h2 id="dns-切換與驗證">DNS 切換與驗證</h2>
<h3 id="切換前準備">切換前準備</h3>
<p>遷移前 48 小時，降低 DNS TTL 到 300 秒（5 分鐘）。正常的 TTL 通常是 3600 秒（1 小時）或更長——如果切換出問題需要回退，短 TTL 讓 DNS 傳播更快。</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"># 確認當前 TTL</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">dig example.com +short +ttlid</span></span></code></pre></div><h3 id="切換操作">切換操作</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 更新 A record 指向新平台的 IP / ALB endpoint</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 如果用 Route 53：</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">aws route53 change-resource-record-sets --hosted-zone-id Z123 --change-batch <span class="s1">&#39;{
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s1">  &#34;Changes&#34;: [{&#34;Action&#34;: &#34;UPSERT&#34;, &#34;ResourceRecordSet&#34;: {
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s1">    &#34;Name&#34;: &#34;example.com&#34;, &#34;Type&#34;: &#34;A&#34;,
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s1">    &#34;AliasTarget&#34;: {&#34;HostedZoneId&#34;: &#34;Z456&#34;, &#34;DNSName&#34;: &#34;alb-xxx.region.elb.amazonaws.com&#34;, &#34;EvaluateTargetHealth&#34;: true}
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="s1">  }}]
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="s1">}&#39;</span></span></span></code></pre></div><h3 id="切換後監控">切換後監控</h3>
<p>切換後的驗證窗口至少等 2 倍 TTL（短 TTL 設 300 秒的話，至少等 10 分鐘）。在這段時間內：</p>
<ul>
<li>新平台：監控 HTTP 狀態碼、回應時間、錯誤率</li>
<li>舊平台：觀察流量是否遞減到零（仍有流量代表 DNS 還沒完全傳播）</li>
<li>功能驗證：跑一次關鍵流程（登入、查詢、交易）</li>
</ul>
<h3 id="回退">回退</h3>
<p>如果新平台出問題，回退方式是把 DNS 切回舊平台的 IP。回退的生效時間等於當前的 TTL——這正是切換前降低 TTL 的理由。舊平台在 DNS 切換後要保留至少 72 小時（全球 DNS 快取最慢的清除時間），確認完全沒有流量後再退役。</p>
<h3 id="切換後收尾">切換後收尾</h3>
<p>穩定運行 1-2 週後：</p>
<ul>
<li>把 DNS TTL 恢復到正常值（3600 秒）</li>
<li>退役舊平台（關機 → 保留快照 → 一個月後刪除）</li>
<li>更新文件：新環境的存取方式、部署流程、監控端點</li>
</ul>
<h2 id="時程與管理層溝通">時程與管理層溝通</h2>
<table>
  <thead>
      <tr>
          <th>遷移路徑</th>
          <th>典型時程</th>
          <th>主要風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>共享主機 → VPS</td>
          <td>1-2 週</td>
          <td>.htaccess 轉換、email 處理、SSL 續期</td>
      </tr>
      <tr>
          <td>VPS → 雲端</td>
          <td>2-4 週</td>
          <td>資料庫遷移、網路設計、IaC 建立</td>
      </tr>
      <tr>
          <td>地端 → 雲端</td>
          <td>4-8 週</td>
          <td>網路重建、合規審查、資料主權</td>
      </tr>
  </tbody>
</table>
<p>向管理層溝通時的關鍵訊息：「應用程式碼不變、改的是運行環境。風險集中在資料搬移和 DNS 切換這兩個步驟，兩者都有回退路徑。」</p>
<p>成本變化也要提前說明：共享主機 → VPS 的月費通常持平或略增（$5-30/月）；VPS → 雲端的月費取決於資源用量，初期可能增加 50-200%（換到的是彈性和 managed 服務），但可以透過 reserved instance 和 rightsizing 後續優化。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/upgrade/upgrade-framework/" data-link-title="升級的共通操作框架" data-link-desc="任何環境或系統升級的四階段模型：差異評估、平行環境驗證、分批切換、退役舊環境，以及貫穿全程的升級紀律">升級的共通操作框架</a>：評估差異 → 平行環境 → 切換 → 退役的四階段模型</li>
<li>→ <a href="/blog/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">接手維運：無 SSH 的 FTP 環境</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/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>：雲端環境的 VPC / subnet 設計</li>
</ul>
]]></content:encoded></item><item><title>有半套 IaC 但文件缺失的環境接管</title><link>https://tarrragon.github.io/blog/infra/takeover/partial-iac-no-docs/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/takeover/partial-iac-no-docs/</guid><description>&lt;p>接手一個有半套 IaC 的環境，比接手全手動的環境更難處理。全手動環境的規則簡單：所有東西都在 Console，逐一盤點就好。半套 IaC 的環境則有兩套真相並存 — 有些資源由程式碼管理、有些是手動加的、有些曾經由程式碼管理但後來被手動改過。&lt;code>terraform plan&lt;/code> 跑出來一長串 diff，哪些是該收進來的手動變更、哪些是該回退的設定漂移、哪些資源根本不在 state 裡，都要逐一判斷。在搞清楚這些之前，任何 &lt;code>apply&lt;/code> 都可能覆蓋正在服務客戶的設定。&lt;/p>
&lt;p>本篇的操作流程從盤點差距開始，經過 state 健康檢查、drift 收斂、文件重建，到最後排出收斂的優先序。每一步都在不影響線上服務的前提下進行。&lt;/p>
&lt;h2 id="state-與現實的差距盤點">state 與現實的差距盤點&lt;/h2>
&lt;p>盤點的第一步是跑 &lt;code>terraform plan&lt;/code> 但不 apply — plan 的輸出就是程式碼描述的狀態與雲端現實之間的完整差距清單。&lt;/p>





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





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





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





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





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





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





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





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





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





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





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





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




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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">moved</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  from</span> <span class="o">=</span> <span class="k">aws_security_group</span><span class="p">.</span><span class="k">old_name</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  to</span>   <span class="o">=</span> <span class="k">module</span><span class="p">.</span><span class="k">network</span><span class="p">.</span><span class="k">aws_security_group</span><span class="p">.</span><span class="k">app</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">}</span></span></code></pre></div><h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/01-minimal-iac/iac-tool-state-backend/" data-link-title="IaC 工具選型與 state 地基" data-link-desc="Terraform / OpenTofu / CDK / Pulumi 的選型判準，state 作為 IaC 工具對現實的唯一記憶，以及 remote state backend 的自管與託管路線">IaC 工具選型與 state 地基</a>：state 怎麼從 local 搬到 remote backend</li>
<li>→ <a href="/blog/infra/01-minimal-iac/console-readonly-minimal-viable/" data-link-title="Console 唯讀鐵律與最小可行資源集合" data-link-desc="Console 只用來看不用來改的操作紀律、drift 的延遲浮現與偵測，以及能跑出第一個完整 apply 迴路的最小資源集合">Console 唯讀鐵律</a>：drift 的來源與偵測</li>
<li>→ <a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">環境分離與模組化</a>：收斂完成後怎麼把單環境拆成 per-env module</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/" data-link-title="模組七：infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">infra 走 PR 流程</a>：收斂完成後的變更怎麼走 review</li>
<li>→ <a href="/blog/infra/takeover/partial-iac-state-repair/" data-link-title="State 修復與清理" data-link-desc="接手的 Terraform state 損壞、有 orphaned entry、或需要搬遷時，怎麼診斷問題、安全操作、以及從錯誤中回復">State 修復與清理</a>：state 損壞的操作修復步驟</li>
<li>→ <a href="/blog/infra/takeover/partial-iac-drift-triage/" data-link-title="Drift 分類處理指南" data-link-desc="接手半套 IaC 環境時，怎麼讀 plan 輸出分類 drift、判斷保留還是回退、處理 stateful 資源的高風險漂移，以及批次收斂的工作流">Drift 分類處理</a>：逐項判斷 adopt vs revert</li>
<li>→ <a href="/blog/infra/takeover/partial-iac-bulk-import/" data-link-title="Unmanaged Resource 批次 Import 工作流" data-link-desc="把 Terraform state 外的雲端資源有系統地納入 IaC 管理：優先序判斷、import block 語法、generated HCL 的 review 要點、批次策略與常見失敗處理">批次 Import 工作流</a>：unmanaged resource 的 import 操作</li>
<li>→ <a href="/blog/infra/takeover/partial-iac-dual-truth-operation/" data-link-title="兩套真相並存的過渡期操作" data-link-desc="部分資源在 IaC、部分在手動時，怎麼安全操作避免比全手動更危險，以及怎麼縮短這個過渡期">過渡期操作</a>：兩套真相並存時的安全操作規則</li>
</ul>
]]></content:encoded></item><item><title>團隊權限分級與存取管理</title><link>https://tarrragon.github.io/blog/infra/02-identity-credentials/team-access-management/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/02-identity-credentials/team-access-management/</guid><description>&lt;p>IAM 的 role 與 policy 提供「某個身分能不能對某個資源做某件事」的技術機制（見&lt;a href="https://tarrragon.github.io/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">身分與憑證地基&lt;/a>）。機制備妥後，下一個問題是組織層面的設計：團隊裡每個角色該拿到哪一級權限、臨時需要更高權限時怎麼提權、離職或合約結束時怎麼確保存取被回收。這些設計的目的是讓「誰能動什麼」在任何時間點都有可稽核的答案。&lt;/p>
&lt;h2 id="權限分級admin--operator--viewer">權限分級：admin / operator / viewer&lt;/h2>
&lt;p>團隊成員的日常操作權限用三級來劃分，每一級對應不同的操作範圍與風險。分級的依據是「這個角色的日常工作需要碰到什麼層級的資源」，不是職稱或年資。&lt;/p>
&lt;h3 id="admin">Admin&lt;/h3>
&lt;p>Admin 能修改 IAM policy、網路拓撲、帳號層級設定（Organizations、SCP、billing）。這是影響範圍最大的一級——一條 SCP 寫錯可以鎖死整個帳號的操作，一條 IAM policy 開太寬可以讓任何角色取得不該有的權限。&lt;/p>
&lt;p>持有 admin 權限的人數應該收斂到最少：通常是平台團隊的 1-2 人加上一個 break-glass 備援角色。Admin 權限不應該是某個人的「日常身分」——即使是平台工程師，日常操作也用 operator 等級，只有在需要改 IAM 或帳號設定時才 assume 到 admin role。&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"># Admin role 的信任政策：只允許特定 IAM user assume
&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">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;admin_trust&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="k">statement&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"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sts:AssumeRole&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">principals&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="s2">&amp;#34;AWS&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="n"> identifiers&lt;/span> &lt;span class="o">=&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 class="s2">&amp;#34;arn:aws:iam::123456789012:user/platform-lead&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 class="s2">&amp;#34;arn:aws:iam::123456789012:user/platform-backup&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">]&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 class="k">condition&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"> test&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Bool&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="n"> variable&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;aws:MultiFactorAuthPresent&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"> values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;true&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> }
&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 class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role&amp;#34; &amp;#34;admin&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="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;infra-admin&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"> assume_role_policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">admin_trust&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&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"> max_session_duration&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">3600&lt;/span>&lt;span class="c1"> # 1 小時後自動失效
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>max_session_duration&lt;/code> 限制 assume 後的有效時間。Admin session 設 1 小時是讓操作者完成當次任務後權限自動回收，不需要手動登出。MFA 條件確保即使帳號密碼外洩，沒有第二因素也無法提權。&lt;/p>
&lt;h3 id="operator">Operator&lt;/h3>
&lt;p>Operator 能部署服務、修改應用層資源（ECS task、RDS parameter group、S3 lifecycle）、查看與操作日常維運所需的一切。多數工程師的日常身分落在這一級。&lt;/p>
&lt;p>Operator 的 policy 用 resource scope 限制它碰不到 IAM 和帳號層級設定——能改 ECS service 但不能改 ECS service 用的 IAM role，能改 RDS 參數但不能改 RDS 的 subnet group。這個邊界讓 operator 的操作失誤影響範圍停在服務層，不會擴散到地基層。&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">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;operator&amp;#34;&lt;/span> {&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">&lt;span class="c1"> # 允許操作應用層資源
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">statement&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"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;ecs:UpdateService&amp;#34;, &amp;#34;ecs:DescribeServices&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 class="s2">&amp;#34;rds:ModifyDBInstance&amp;#34;, &amp;#34;rds:DescribeDBInstances&amp;#34;&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 class="s2">&amp;#34;s3:GetObject&amp;#34;, &amp;#34;s3:PutObject&amp;#34;&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 class="s2">&amp;#34;logs:GetLogEvents&amp;#34;, &amp;#34;logs:FilterLogEvents&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 class="p">]&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"> resources&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;*&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> }&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="c1"> # 明確拒絕碰 IAM 和帳號設定
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">statement&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"> effect&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Deny&amp;#34;&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"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;iam:*&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;organizations:*&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;account:*&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">&lt;span class="n"> resources&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;*&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Deny 語句確保即使未來有人不小心把過寬的 managed policy attach 到 operator role，IAM 和帳號操作仍然被擋。Deny 在 IAM 評估中優先於 Allow。&lt;/p></description><content:encoded><![CDATA[<p>IAM 的 role 與 policy 提供「某個身分能不能對某個資源做某件事」的技術機制（見<a href="/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">身分與憑證地基</a>）。機制備妥後，下一個問題是組織層面的設計：團隊裡每個角色該拿到哪一級權限、臨時需要更高權限時怎麼提權、離職或合約結束時怎麼確保存取被回收。這些設計的目的是讓「誰能動什麼」在任何時間點都有可稽核的答案。</p>
<h2 id="權限分級admin--operator--viewer">權限分級：admin / operator / viewer</h2>
<p>團隊成員的日常操作權限用三級來劃分，每一級對應不同的操作範圍與風險。分級的依據是「這個角色的日常工作需要碰到什麼層級的資源」，不是職稱或年資。</p>
<h3 id="admin">Admin</h3>
<p>Admin 能修改 IAM policy、網路拓撲、帳號層級設定（Organizations、SCP、billing）。這是影響範圍最大的一級——一條 SCP 寫錯可以鎖死整個帳號的操作，一條 IAM policy 開太寬可以讓任何角色取得不該有的權限。</p>
<p>持有 admin 權限的人數應該收斂到最少：通常是平台團隊的 1-2 人加上一個 break-glass 備援角色。Admin 權限不應該是某個人的「日常身分」——即使是平台工程師，日常操作也用 operator 等級，只有在需要改 IAM 或帳號設定時才 assume 到 admin role。</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"># Admin role 的信任政策：只允許特定 IAM user assume
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">data</span> <span class="s2">&#34;aws_iam_policy_document&#34; &#34;admin_trust&#34;</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">statement</span> {
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">    actions</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;sts:AssumeRole&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">principals</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="s2">&#34;AWS&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">      identifiers</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="s2">&#34;arn:aws:iam::123456789012:user/platform-lead&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="s2">&#34;arn:aws:iam::123456789012:user/platform-backup&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">      <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="k">condition</span> {
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">      test</span>     <span class="o">=</span> <span class="s2">&#34;Bool&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">      variable</span> <span class="o">=</span> <span class="s2">&#34;aws:MultiFactorAuthPresent&#34;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">      values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;true&#34;</span><span class="p">]</span>
</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></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">resource</span> <span class="s2">&#34;aws_iam_role&#34; &#34;admin&#34;</span> {
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="n">  name</span>               <span class="o">=</span> <span class="s2">&#34;infra-admin&#34;</span>
</span></span><span class="line"><span class="ln">22</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">admin_trust</span><span class="p">.</span><span class="k">json</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="n">  max_session_duration</span> <span class="o">=</span> <span class="m">3600</span><span class="c1">  # 1 小時後自動失效
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="c1"></span>}</span></span></code></pre></div><p><code>max_session_duration</code> 限制 assume 後的有效時間。Admin session 設 1 小時是讓操作者完成當次任務後權限自動回收，不需要手動登出。MFA 條件確保即使帳號密碼外洩，沒有第二因素也無法提權。</p>
<h3 id="operator">Operator</h3>
<p>Operator 能部署服務、修改應用層資源（ECS task、RDS parameter group、S3 lifecycle）、查看與操作日常維運所需的一切。多數工程師的日常身分落在這一級。</p>
<p>Operator 的 policy 用 resource scope 限制它碰不到 IAM 和帳號層級設定——能改 ECS service 但不能改 ECS service 用的 IAM role，能改 RDS 參數但不能改 RDS 的 subnet group。這個邊界讓 operator 的操作失誤影響範圍停在服務層，不會擴散到地基層。</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_iam_policy_document&#34; &#34;operator&#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="k">statement</span> {
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">    actions</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="s2">&#34;ecs:UpdateService&#34;, &#34;ecs:DescribeServices&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">      <span class="s2">&#34;rds:ModifyDBInstance&#34;, &#34;rds:DescribeDBInstances&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="s2">&#34;s3:GetObject&#34;, &#34;s3:PutObject&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">      <span class="s2">&#34;logs:GetLogEvents&#34;, &#34;logs:FilterLogEvents&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">]</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">    resources</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;*&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  }<span class="c1">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">  # 明確拒絕碰 IAM 和帳號設定
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span>  <span class="k">statement</span> {
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">    effect</span> <span class="o">=</span> <span class="s2">&#34;Deny&#34;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="n">    actions</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">      <span class="s2">&#34;iam:*&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">      <span class="s2">&#34;organizations:*&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">      <span class="s2">&#34;account:*&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="p">]</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="n">    resources</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;*&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">  }
</span></span><span class="line"><span class="ln">23</span><span class="cl">}</span></span></code></pre></div><p>Deny 語句確保即使未來有人不小心把過寬的 managed policy attach 到 operator role，IAM 和帳號操作仍然被擋。Deny 在 IAM 評估中優先於 Allow。</p>
<h3 id="viewer">Viewer</h3>
<p>Viewer 能讀取 Console、查 log、看 metric dashboard，但不能修改任何資源。適合的角色包括：值班但不需要改設定的 on-call、需要查 log 排查問題的 support 團隊、需要看資源狀態的管理層。</p>
<p>Viewer 用 AWS 的 managed policy <code>ReadOnlyAccess</code> 作為基線，再根據需要排除敏感資料的讀取（例如 Secrets Manager 的 <code>GetSecretValue</code>）。</p>
<p>三級的對應關係：</p>
<table>
  <thead>
      <tr>
          <th>級別</th>
          <th>能做什麼</th>
          <th>典型角色</th>
          <th>人數控制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Admin</td>
          <td>改 IAM、網路、帳號設定</td>
          <td>平台 lead + break-glass</td>
          <td>2-3 人</td>
      </tr>
      <tr>
          <td>Operator</td>
          <td>部署、改服務設定、查 log</td>
          <td>工程師</td>
          <td>團隊規模</td>
      </tr>
      <tr>
          <td>Viewer</td>
          <td>讀 Console、查 log、看 metrics</td>
          <td>on-call、support、管理層</td>
          <td>依需求開放</td>
      </tr>
  </tbody>
</table>
<p>導入時程參考：三級權限的 IAM role 與 policy 建立約需 1-2 天，包含 trust policy 設定與初次分配。後續的權限變更走版本控制的 PR 流程，讓每次 policy 調整都有提案、審查與歷史紀錄（見<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="臨時提權break-glass">臨時提權（break-glass）</h2>
<p>Operator 在日常工作中偶爾需要 admin 層級的操作——排查一個涉及 IAM 的事故、緊急修改一條 security group 規則、回應安全事件。常態性地把 admin 權限開給所有 operator 會讓三級分級失效，但每次都等 admin 角色的人上線又太慢。Break-glass 流程處理的就是這個中間地帶。</p>
<h3 id="機制">機制</h3>
<p>Break-glass 的實作是一個平時不被 assume 的 admin role，加上一套提權紀錄。Operator 在需要時 assume 這個 role，取得一段時效有限的 admin session。這個 assume 動作會在 CloudTrail 留下紀錄（誰、什麼時候、session 多長），事後可稽核。</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;break_glass&#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;infra-break-glass&#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">break_glass_trust</span><span class="p">.</span><span class="k">json</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  max_session_duration</span> <span class="o">=</span> <span class="m">3600</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">  tags</span> <span class="o">=</span><span class="n"> { Purpose</span> <span class="o">=</span> <span class="s2">&#34;emergency-escalation&#34;</span> }
</span></span><span class="line"><span class="ln">7</span><span class="cl">}</span></span></code></pre></div><p>如果團隊有 ChatOps 或 ticketing 系統，把 break-glass 的觸發綁進去可以增加一層人為確認：operator 在 Slack 或 ticket 裡申請提權、另一個人核可、系統開放 assume。這層確認的目的是在事後稽核時留下一條清楚的「誰授權了這次提權」紀錄，而非阻止操作本身。</p>
<h3 id="事後回顧">事後回顧</h3>
<p>每一次 break-glass 使用都應該進入事後回顧：為什麼需要提權？這個操作能不能改寫成 operator 層級的權限就能完成？如果某類操作反覆觸發 break-glass，代表 operator 的權限邊界需要調整——把那類操作從 admin 降到 operator，而不是讓 break-glass 變成常態。</p>
<p>回顧的輸出是權限邊界的校準，不是對操作者的檢討。</p>
<h2 id="定期-access-review">定期 access review</h2>
<p>權限分配不是一次性的設定。人會換組、離職、從 contractor 轉正職、從開發角色轉管理角色，每一次角色變動都可能讓既有的權限配置過期。定期 review 的責任是找出「權限比當前角色需要的更寬」的身分，把它們收斂回來。</p>
<h3 id="節奏與方法">節奏與方法</h3>
<p>每季做一次 access review 是多數團隊能維持的最小節奏。Review 的步驟：</p>
<ol>
<li>拉出所有 IAM user 和 role 的清單，標注每個身分目前的分級（admin / operator / viewer）</li>
<li>比對每個身分的實際角色——這個人現在還在做需要 operator 權限的工作嗎？</li>
<li>用 IAM Access Analyzer 檢查哪些權限在過去 90 天沒被使用過——沒用到的權限是收斂候選</li>
<li>特別檢查 break-glass 的使用紀錄——有沒有人的 break-glass 使用頻率高到代表他的基線權限該調整</li>
</ol>





<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"># 產出 credential report，列出所有 user 的 key 建立時間與使用時間</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws iam generate-credential-report
</span></span><span class="line"><span class="ln">3</span><span class="cl">aws iam get-credential-report --output text --query Content <span class="p">|</span> base64 -d <span class="p">|</span> head -20
</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"># 查 Access Analyzer 的 finding（哪些權限可收斂）</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">aws accessanalyzer list-findings --analyzer-arn &lt;analyzer-arn&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --filter <span class="s1">&#39;{&#34;status&#34;: {&#34;eq&#34;: [&#34;ACTIVE&#34;]}}&#39;</span></span></span></code></pre></div><h3 id="管理層報告">管理層報告</h3>
<p>Access review 的結果適合用兩個數字向管理層報告：<strong>覆蓋率</strong>（已 review 的身分數 / 總身分數）與<strong>異常數</strong>（權限過寬或長期未使用的身分數）。異常數的趨勢比單次數字更有意義——持續上升代表新人 onboarding 時的權限配置流程有缺口，持續下降代表 review 在發揮作用。</p>
<p>導入時程參考：第一次 access review 約需半天到一天（盤點 + 比對 + 收斂），後續每季約需 2-4 小時。</p>
<h2 id="職務交接與離職處理">職務交接與離職處理</h2>
<p>一個人離開團隊時，他持有的所有存取路徑都需要被回收。手動建立的存取路徑越多，離職處理越容易遺漏。</p>
<h3 id="離職-checklist">離職 checklist</h3>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>操作</th>
          <th>驗證方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>IAM user / SSO 帳號</td>
          <td>停用或刪除</td>
          <td>credential report 裡不再出現</td>
      </tr>
      <tr>
          <td>長期 access key</td>
          <td>撤銷所有 key</td>
          <td><code>list-access-keys</code> 回傳空</td>
      </tr>
      <tr>
          <td>個人 MFA 裝置</td>
          <td>解除綁定</td>
          <td><code>list-mfa-devices</code> 回傳空</td>
      </tr>
      <tr>
          <td>被加進的 IAM group</td>
          <td>移除成員</td>
          <td><code>get-group</code> 裡不再出現</td>
      </tr>
      <tr>
          <td>可 assume 的 role trust policy</td>
          <td>從 principal 清單移除</td>
          <td>trust policy 裡沒有該 user ARN</td>
      </tr>
      <tr>
          <td>第三方服務的 SSO 授權</td>
          <td>撤銷（GitHub org、CI 平台、Slack workspace 等）</td>
          <td>該帳號無法登入</td>
      </tr>
      <tr>
          <td>共用密碼 / shared credential</td>
          <td>輪替（如果存在的話）</td>
          <td>Secrets Manager 版本更新</td>
      </tr>
  </tbody>
</table>
<p>權限設計越集中在 role-based（用 IAM group 或 SSO permission set），離職處理越簡單——停用 SSO 帳號就自動切斷所有透過 SSO 取得的 role。反過來，如果有大量手動 attach 的 policy 或直接寫在 trust policy 裡的 user ARN，離職時要逐一找出並移除，容易遺漏。</p>
<p>離職後的 credential rotation 有一個常被忽略的風險：輪替範圍沒有按作用域分批。一個反例是多個服務共用同一把 secret，輪替時切新憑證的服務跟還只認舊憑證的服務之間出現認證窗口不一致，導致跨系統連鎖中斷。穩定的做法是先分域隔離受影響服務、恢復雙憑證窗口、再逐批收斂（見 <a href="/blog/backend/07-security-data-protection/cases/failure-credential-rotation-without-scope/" data-link-title="7.C9 反例：憑證輪替未分 Scope" data-link-desc="憑證輪替若未分域分批，容易造成跨系統連鎖中斷。">反例：憑證輪替未分 Scope</a>）。</p>
<h3 id="交接的可執行性">交接的可執行性</h3>
<p>交接的成本取決於知識有多少沉澱在程式碼裡、有多少留在個人腦中。如果環境的建立方式是一份 IaC、變更方式是 PR 歷史，新接手的人讀 code 跟 PR 描述就能重建脈絡。如果關鍵操作（某台資料庫的特殊 parameter、某條 security group 規則的理由）只存在離職者的記憶裡，交接窗口一過就永久遺失。</p>
<p>可操作的檢驗：問「如果這個人下週離職，團隊能不能只靠讀 repo 就安全地操作他負責的環境？」答案是否定的部分，就是交接的優先補強項——優先把它們寫進 IaC 或 PR 描述，而不是寫進交接文件（交接文件會過期，IaC 跟著環境一起演進）。</p>
<p>這個議題在<a href="/blog/infra/09-driving-adoption/trust-alignment-knowledge-sharing/" data-link-title="怎麼把 infra 推動起來 — 信任赤字、期望值對齊與知識共享" data-link-desc="技術正確不等於推得動 — infra 在商業優先級裡吃虧的結構性原因，以及用可回退切片、期望值對齊與知識分散來跨過組織關卡">知識共享優於個人英雄主義</a>有組織層面的展開。</p>
<h2 id="contractor-與外部-vendor-存取">Contractor 與外部 vendor 存取</h2>
<p>外部人員（contractor、顧問、SaaS vendor 的技術支援）需要存取雲端環境時，原則是給最小範圍、設明確時限、留完整紀錄。</p>
<h3 id="範圍限制">範圍限制</h3>
<p>外部人員的 role 用 Permissions Boundary 設定權限天花板，確保即使有人誤 attach 了過寬的 policy，操作範圍也不超過 boundary 允許的上限。Scope 到具體的資源 ARN（某個 S3 bucket、某台 RDS instance），而非帳號級別的 wildcard。</p>
<p>如果團隊已經有<a href="/blog/infra/02-identity-credentials/multi-account-strategy/" data-link-title="跨帳號策略 — Organizations、SCP 與帳號工廠" data-link-desc="用 AWS Organizations 把環境拆成獨立帳號、用 SCP 設定連管理員都越不過的護欄、用帳號工廠讓每個新帳號自帶安全基線">跨帳號策略</a>，把外部人員的 workload 放在獨立帳號或 sandbox OU 裡，用 SCP 限制該帳號能操作的服務類型，是比 role 級別限制更強的隔離。</p>
<h3 id="時限控制">時限控制</h3>
<p>外部存取的 IAM user 或 SSO 帳號在建立時就設定到期日。多數雲端平台支援 session duration 限制（role 的 <code>max_session_duration</code>）和帳號層級的停用排程。合約結束日應該對應到存取到期日——這個對應關係寫進 IaC（用 tag 標注到期日）或團隊的 access review checklist，避免合約結束後存取仍然開著。</p>
<h3 id="稽核紀錄">稽核紀錄</h3>
<p>外部人員的操作需要比內部人員更嚴格的稽核。CloudTrail 預設記錄所有 API 呼叫，但 review 的頻率要提高——外部人員的操作紀錄每週抽查，而非等到季度 access review 才回頭看。查的是：有沒有存取超出約定範圍的資源？有沒有在非工作時間操作？有沒有大量的 read 操作指向敏感資料？</p>
<p>這些紀錄同時也是合約管理的依據——如果外部 vendor 的技術支援存取了超出約定範圍的資源，紀錄是釐清責任的事實基礎。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">身分與憑證地基</a>：IAM role / policy / OIDC 的技術機制</li>
<li>→ <a href="/blog/infra/02-identity-credentials/multi-account-strategy/" data-link-title="跨帳號策略 — Organizations、SCP 與帳號工廠" data-link-desc="用 AWS Organizations 把環境拆成獨立帳號、用 SCP 設定連管理員都越不過的護欄、用帳號工廠讓每個新帳號自帶安全基線">跨帳號策略</a>：用 OU 和 SCP 在帳號層級隔離外部人員</li>
<li>→ <a href="/blog/infra/08-governance-habits/tagging-secrets/" data-link-title="Tagging 規範與 Secrets 不進 code" data-link-desc="tag 讓資源可盤點、可清理、可歸屬；密鑰存在專用服務裡而非 code 或 state，兩者都屬於 day-1 就該立的治理地基">治理好習慣</a>：tagging 標注存取到期日、secrets 不進 code</li>
<li>→ <a href="/blog/infra/09-driving-adoption/trust-alignment-knowledge-sharing/" data-link-title="怎麼把 infra 推動起來 — 信任赤字、期望值對齊與知識共享" data-link-desc="技術正確不等於推得動 — infra 在商業優先級裡吃虧的結構性原因，以及用可回退切片、期望值對齊與知識分散來跨過組織關卡">怎麼把 infra 推動起來</a>：知識共享與交接的組織面</li>
</ul>
]]></content:encoded></item><item><title>模組三：網路地基 — VPC 與分層</title><link>https://tarrragon.github.io/blog/infra/03-network-foundation/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/03-network-foundation/</guid><description>&lt;p>網路地基要先於核心服務存在。VPC、subnet、route table 與 security group 構成一張「服務能落在哪、誰能跟誰講話」的地圖，資料庫、運算節點與對外入口都得落在這張地圖規劃好的格子裡。先把邊界畫清楚，後面每個核心服務上線時只需要選一塊已經定義好安全等級的位置，而不是邊開服務邊補洞。&lt;/p>
&lt;p>這一章建立四層邊界：最外層的 VPC 隔離、中層的 public / private subnet 切分、流量進出的 route table 與 NAT、以及最貼近服務的 security group。每一層解決的問題不同，疊起來才是一個可審計、可收斂的網路。&lt;/p>
&lt;h2 id="vpc網路隔離的最外層邊界">VPC：網路隔離的最外層邊界&lt;/h2>
&lt;p>VPC（Virtual Private Cloud）先圈定整個系統的網路地址空間 — 一塊邏輯隔離的私有網段，是其餘所有網路切分的起點。在 VPC 裡開出來的所有資源預設只看得到同一個 VPC 內的成員，與其他 VPC、與其他帳號的網路天然隔離。它是後面所有切分動作的容器 — 沒有 VPC，subnet 與 security group 無處依附。&lt;/p>
&lt;p>建立 VPC 時最關鍵的決策是 CIDR 區塊的大小，例如 &lt;code>10.0.0.0/16&lt;/code> 提供約六萬五千個位址。這個範圍要一次規劃足夠大，因為事後擴張地址空間在多數雲上是麻煩且容易出錯的操作。同時要避免與公司其他網段重疊：未來若要透過 VPC peering、Transit Gateway 或 VPN 把這個 VPC 接回地端機房或其他環境，重疊的 CIDR 會讓路由無法解析。&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_vpc&amp;#34; &amp;#34;main&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"> cidr_block&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;10.0.0.0/16&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"> enable_dns_support&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"> 4&lt;/span>&lt;span class="cl">&lt;span class="n"> enable_dns_hostnames&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"> 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="n"> tags&lt;/span> &lt;span class="o">=&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"> Name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;platform-main&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"> Environment&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;production&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 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>判讀訊號：規劃 CIDR 時先問「這個環境三年後會有幾個 subnet、跨幾個可用區、要不要接地端」。風險集中在地址耗盡與網段衝突 — 兩者都得在開第一個 subnet 之前定案。邊界是：VPC 只負責隔離與定址，它不決定哪個服務能對外，那是 subnet 與 security group 的工作。環境之間的 VPC 該怎麼分，是「模組四：環境分離與模組化」的主題，這裡只先確保單一 VPC 的地址規劃站得住。&lt;/p>
&lt;h2 id="public-與-private-subnet-的切分原則">public 與 private subnet 的切分原則&lt;/h2>
&lt;p>一塊資源對外暴露到什麼程度，取決於它被放進哪個 subnet — VPC 內部按可用區與暴露程度切出來的子網段，決定資源有沒有一條通往網際網路的路徑。判斷一個資源該放 public 還是 private，問題只有一個：它需不需要被網際網路直接定址。&lt;/p>
&lt;p>public subnet 放的是必須接收外部入站流量的元件 — 對外的負載平衡器、需要公開的 NAT Gateway、堡壘主機（bastion）。這些資源透過 route table 連到 Internet Gateway，因此能被外部 IP 直接觸及。private subnet 放的是只該在內網被存取的元件 — 應用伺服器、資料庫、快取、內部佇列。它們沒有通往 Internet Gateway 的路由，外部無法主動連入，需要對外時才透過 NAT 出去。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Subnet 類型&lt;/th>
 &lt;th>典型住戶&lt;/th>
 &lt;th>對外路徑&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>public&lt;/td>
 &lt;td>對外 LB、NAT Gateway、bastion&lt;/td>
 &lt;td>經 Internet Gateway 雙向&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>private&lt;/td>
 &lt;td>應用節點、資料庫、快取、佇列&lt;/td>
 &lt;td>僅經 NAT 單向出站、不可入站&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>public subnet 的真實樣貌是「薄薄一層」：它通常只住負載平衡器與 NAT 這類入口設施，而不是業務邏輯。常見陷阱是為了 SSH 方便把應用伺服器直接開在 public subnet 並配公網 IP，等於把每一台業務主機的管理埠暴露在掃描流量下。private subnet 的住戶反而是系統的主體 — 資料庫放這裡是因為它一旦能被外網定址，攻擊面就從「打穿入口層」變成「直接連資料庫埠試密碼」。&lt;/p>
&lt;p>每個 subnet 綁定單一可用區，所以高可用設計通常是每種角色跨至少兩個可用區各開一個 subnet：兩個 public、兩個 private，讓單一可用區故障時另一區的同類 subnet 還能承接。對外入口怎麼把流量分到跨可用區的 private 後端，是「devops 模組一：負載平衡」的範圍。&lt;/p>
&lt;h2 id="route-table-與-nat流量的進出路徑">route table 與 NAT：流量的進出路徑&lt;/h2>
&lt;p>離開一個 subnet 的封包往哪走，逐條寫在 route table 這組轉送規則裡 — 它掛在 subnet 上，是封包出口方向的依據。一個 subnet 是 public 還是 private，技術上的差別就在它關聯的 route table 裡有沒有一條指向 Internet Gateway 的預設路由。換句話說，subnet 的對外性質由它關聯的 route table 賦予，而非寫在 subnet 自身。&lt;/p></description><content:encoded><![CDATA[<p>網路地基要先於核心服務存在。VPC、subnet、route table 與 security group 構成一張「服務能落在哪、誰能跟誰講話」的地圖，資料庫、運算節點與對外入口都得落在這張地圖規劃好的格子裡。先把邊界畫清楚，後面每個核心服務上線時只需要選一塊已經定義好安全等級的位置，而不是邊開服務邊補洞。</p>
<p>這一章建立四層邊界：最外層的 VPC 隔離、中層的 public / private subnet 切分、流量進出的 route table 與 NAT、以及最貼近服務的 security group。每一層解決的問題不同，疊起來才是一個可審計、可收斂的網路。</p>
<h2 id="vpc網路隔離的最外層邊界">VPC：網路隔離的最外層邊界</h2>
<p>VPC（Virtual Private Cloud）先圈定整個系統的網路地址空間 — 一塊邏輯隔離的私有網段，是其餘所有網路切分的起點。在 VPC 裡開出來的所有資源預設只看得到同一個 VPC 內的成員，與其他 VPC、與其他帳號的網路天然隔離。它是後面所有切分動作的容器 — 沒有 VPC，subnet 與 security group 無處依附。</p>
<p>建立 VPC 時最關鍵的決策是 CIDR 區塊的大小，例如 <code>10.0.0.0/16</code> 提供約六萬五千個位址。這個範圍要一次規劃足夠大，因為事後擴張地址空間在多數雲上是麻煩且容易出錯的操作。同時要避免與公司其他網段重疊：未來若要透過 VPC peering、Transit Gateway 或 VPN 把這個 VPC 接回地端機房或其他環境，重疊的 CIDR 會讓路由無法解析。</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_vpc&#34; &#34;main&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  cidr_block</span>           <span class="o">=</span> <span class="s2">&#34;10.0.0.0/16&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  enable_dns_support</span>   <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  enable_dns_hostnames</span> <span class="o">=</span> <span class="kt">true</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">  tags</span> <span class="o">=</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;platform-main&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">    Environment</span> <span class="o">=</span> <span class="s2">&#34;production&#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>判讀訊號：規劃 CIDR 時先問「這個環境三年後會有幾個 subnet、跨幾個可用區、要不要接地端」。風險集中在地址耗盡與網段衝突 — 兩者都得在開第一個 subnet 之前定案。邊界是：VPC 只負責隔離與定址，它不決定哪個服務能對外，那是 subnet 與 security group 的工作。環境之間的 VPC 該怎麼分，是「模組四：環境分離與模組化」的主題，這裡只先確保單一 VPC 的地址規劃站得住。</p>
<h2 id="public-與-private-subnet-的切分原則">public 與 private subnet 的切分原則</h2>
<p>一塊資源對外暴露到什麼程度，取決於它被放進哪個 subnet — VPC 內部按可用區與暴露程度切出來的子網段，決定資源有沒有一條通往網際網路的路徑。判斷一個資源該放 public 還是 private，問題只有一個：它需不需要被網際網路直接定址。</p>
<p>public subnet 放的是必須接收外部入站流量的元件 — 對外的負載平衡器、需要公開的 NAT Gateway、堡壘主機（bastion）。這些資源透過 route table 連到 Internet Gateway，因此能被外部 IP 直接觸及。private subnet 放的是只該在內網被存取的元件 — 應用伺服器、資料庫、快取、內部佇列。它們沒有通往 Internet Gateway 的路由，外部無法主動連入，需要對外時才透過 NAT 出去。</p>
<table>
  <thead>
      <tr>
          <th>Subnet 類型</th>
          <th>典型住戶</th>
          <th>對外路徑</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>public</td>
          <td>對外 LB、NAT Gateway、bastion</td>
          <td>經 Internet Gateway 雙向</td>
      </tr>
      <tr>
          <td>private</td>
          <td>應用節點、資料庫、快取、佇列</td>
          <td>僅經 NAT 單向出站、不可入站</td>
      </tr>
  </tbody>
</table>
<p>public subnet 的真實樣貌是「薄薄一層」：它通常只住負載平衡器與 NAT 這類入口設施，而不是業務邏輯。常見陷阱是為了 SSH 方便把應用伺服器直接開在 public subnet 並配公網 IP，等於把每一台業務主機的管理埠暴露在掃描流量下。private subnet 的住戶反而是系統的主體 — 資料庫放這裡是因為它一旦能被外網定址，攻擊面就從「打穿入口層」變成「直接連資料庫埠試密碼」。</p>
<p>每個 subnet 綁定單一可用區，所以高可用設計通常是每種角色跨至少兩個可用區各開一個 subnet：兩個 public、兩個 private，讓單一可用區故障時另一區的同類 subnet 還能承接。對外入口怎麼把流量分到跨可用區的 private 後端，是「devops 模組一：負載平衡」的範圍。</p>
<h2 id="route-table-與-nat流量的進出路徑">route table 與 NAT：流量的進出路徑</h2>
<p>離開一個 subnet 的封包往哪走，逐條寫在 route table 這組轉送規則裡 — 它掛在 subnet 上，是封包出口方向的依據。一個 subnet 是 public 還是 private，技術上的差別就在它關聯的 route table 裡有沒有一條指向 Internet Gateway 的預設路由。換句話說，subnet 的對外性質由它關聯的 route table 賦予，而非寫在 subnet 自身。</p>
<p>public subnet 的 route table 有一條 <code>0.0.0.0/0 → Internet Gateway</code>，讓未知目的地的流量直接出網、也讓外部可達。private subnet 的 route table 則把 <code>0.0.0.0/0</code> 指向 NAT Gateway。NAT（Network Address Translation）解決的問題是：private subnet 的資源需要主動對外（拉套件、呼叫第三方 API、抓 OS 更新），但不能因此變得可被外部入站連入。NAT 讓出站流量借用一個公網位址出去、把回應導回原請求者，同時不開放任何外部主動發起的連線。</p>
<p>NAT Gateway 的核心取捨是成本與可用性。它是綁定單一可用區的資源 — 一個 NAT Gateway 活在某一個 public subnet、也就活在那個可用區裡。若全部 private subnet 的 route table 都指向同一個 NAT，這個設計用一份 NAT 成本服務整個 VPC，代價是把 NAT 所在的可用區變成出站方向的單點：該可用區故障時，所有 private subnet 的對外連線同時中斷，即使其他可用區的節點本身健康。要讓出站路徑與 subnet 的跨可用區冗餘對齊，做法是每個可用區各放一個 NAT Gateway，並讓每一區的 private subnet route table 指向同區的 NAT。下面用 <code>for_each</code> 在每個可用區建立一個 NAT，再讓每個 private subnet 的 route table 走本區出口。</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_nat_gateway&#34; &#34;per_az&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  for_each</span>      <span class="o">=</span> <span class="k">aws_subnet</span><span class="p">.</span><span class="k">public</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  allocation_id</span> <span class="o">=</span> <span class="k">aws_eip</span><span class="p">.</span><span class="k">nat</span><span class="p">[</span><span class="k">each</span><span class="p">.</span><span class="k">key</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">  subnet_id</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">id</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_route_table&#34; &#34;private&#34;</span> {
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">  for_each</span> <span class="o">=</span> <span class="k">aws_subnet</span><span class="p">.</span><span class="k">private</span>
</span></span><span class="line"><span class="ln"> 9</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">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="k">route</span> {
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">    cidr_block</span>     <span class="o">=</span> <span class="s2">&#34;0.0.0.0/0&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">    nat_gateway_id</span> <span class="o">=</span> <span class="k">aws_nat_gateway</span><span class="p">.</span><span class="k">per_az</span><span class="p">[</span><span class="k">each</span><span class="p">.</span><span class="k">key</span><span class="p">].</span><span class="k">id</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="n">  tags</span> <span class="o">=</span><span class="n"> { Name</span> <span class="o">=</span> <span class="s2">&#34;private-rt-${each.key}&#34;</span> }
</span></span><span class="line"><span class="ln">17</span><span class="cl">}</span></span></code></pre></div><p>每個可用區一個 NAT 是可用性優先的版本；若環境對成本敏感、且能接受出站在單一可用區故障時短暫中斷，也可以退回單一 NAT，但要把它當成明示的取捨、而非預設。判讀訊號：private subnet 的服務拉不到外部套件、或第三方 API 全部逾時，先查它關聯的 route table 有沒有指向健康的 NAT；若只有某一個可用區的節點受影響，多半是那一區的 NAT 或其所在 subnet 出狀況。風險與成本在這裡交會 — NAT Gateway 按處理流量計費，把大量出站流量（例如備份上傳、跨區同步）長期走 NAT 會讓帳單可觀，這類流量較划算的做法是改走 VPC Endpoint 直連雲服務、繞過 NAT。NAT 的數量取捨與出站成本在「devops 模組八：成本管理」有更完整的討論。邊界是：route table 與 NAT 只管「能不能出去、走哪條路」，至於某個埠允不允許連，是 security group 的職責。</p>
<h2 id="security-group-設計最小開放">security group 設計：最小開放</h2>
<p>一條連線究竟能不能打到某個埠，由 security group 逐埠拍板 — 它是掛在資源網卡層級的有狀態防火牆，規則描述的是哪些來源連得進這個資源。它是貼著服務的最後一道網路邊界 — 即使封包順著 route table 抵達了 private subnet，security group 仍能逐埠決定放不放行。它有狀態的意思是：放行一條入站連線後，對應的回應出站自動允許，規則只需描述入站方向想開放什麼。</p>
<p>設計原則是最小開放：每條規則只開「這個服務確實需要被誰連的那個埠」。資料庫的 security group 入站只允許來自應用層 security group 的資料庫埠，而不是某個 IP 範圍。用 security group 互相引用、而非寫死網段，是因為應用節點會隨擴縮而換 IP，引用來源 group 讓規則跟著成員身分走、不跟著位址走。</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_security_group_rule&#34; &#34;db_from_app&#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="s2">&#34;ingress&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  from_port</span>                <span class="o">=</span> <span class="m">5432</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  to_port</span>                  <span class="o">=</span> <span class="m">5432</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">  protocol</span>                 <span class="o">=</span> <span class="s2">&#34;tcp&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">  security_group_id</span>        <span class="o">=</span> <span class="k">aws_security_group</span><span class="p">.</span><span class="k">database</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="n">  source_security_group_id</span> <span class="o">=</span> <span class="k">aws_security_group</span><span class="p">.</span><span class="k">app</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">}</span></span></code></pre></div><p>要特別防的是 <code>0.0.0.0/0</code> 全開。把入站來源設成 <code>0.0.0.0/0</code> 等於允許整個網際網路連這個埠，對資料庫埠（5432、3306、6379）或管理埠（22、3389）這麼做，會讓服務暴露在持續性的自動掃描與暴力嘗試下。合理出現 <code>0.0.0.0/0</code> 的位置只有對外負載平衡器的 80 / 443 入站 — 因為它的工作本來就是接收公開流量。判讀訊號：盤點所有 security group，列出 source 是 <code>0.0.0.0/0</code> 的規則，逐條問「這個埠真的需要全世界都連得到嗎」；資料庫埠、SSH、內部 API 出現在這份清單上就是該收斂的目標。管理埠的存取較划算的替代方案是 SSM Session Manager 或堡壘主機，把 22 埠從公網清單上拿掉。誰能透過 IAM 改動這些規則，銜接「模組二：身分與憑證地基」。</p>
<p>subnet 這一層還有另一道防火牆 — network ACL（NACL），它與 security group 分工在兩個層級。NACL 掛在 subnet 上、作用於進出整個 subnet 的流量，而且是無狀態的：入站與出站要各寫一條規則，放行了入站不代表回應的出站自動放行，回程封包得自己對得上另一條規則。security group 則掛在資源網卡（ENI）層、有狀態，放行入站後對應回應自動允許。兩者的另一個差別是 NACL 支援顯式 deny、security group 只能列允許清單，所以 NACL 適合做 subnet 層的粗篩或針對特定來源的明確封鎖。實務上多數設計的主力是 security group：它貼著服務、用 group 互相引用就能表達「誰能連誰」，已經涵蓋大部分最小開放需求。NACL 留給少數情境 — 需要在 subnet 邊界擋掉一整段已知惡意網段、或要對某類流量做顯式 deny 時才展開；多數環境讓 NACL 維持預設全通、把存取控制集中在 security group，是可以接受的選擇，重點是知道這一層存在、在需要 subnet 層粗篩時記得它。</p>
<h2 id="為什麼網路要先於核心服務鋪好">為什麼網路要先於核心服務鋪好</h2>
<p>網路地基先行，是因為核心服務的安全位置由網路拓樸決定，而不是反過來。資料庫該落在哪個 private subnet、它的 security group 只接受哪個來源、它的出站走不走 NAT — 這些都是服務「出生時」就該確定的屬性。先有規劃好的 subnet 與 security group，新服務上線只是挑一塊已定義安全等級的位置放進去；網路還沒鋪就先開服務，則往往落在預設 VPC 與寬鬆規則上，事後再回頭收斂，要在服務已經有流量、有依賴的情況下改網段與防火牆，風險和協調成本都高得多。</p>
<p>這也呼應「模組零：infra 是什麼」的 day-1 鐵律：邊界與隔離屬於一開始就該存在的地基，不是長出問題後才補的修補。網路規劃好之後，照「從零建置」路線下一步先進「模組四：環境分離與模組化」確定環境怎麼切，再讓核心服務落進這些 subnet。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/infra/03-network-foundation/vpc-subnet-security-group/" data-link-title="網路地基 — VPC、subnet 分層與 security group 設計" data-link-desc="VPC CIDR 規劃、public / private subnet 切分、route table 與 NAT 的可用性成本取捨、security group 最小開放設計，以及 NACL 的定位">網路地基 — VPC、subnet 分層與 security group 設計</a></td>
          <td>VPC CIDR 規劃、public / private subnet 切分、route table 與 NAT 的可用性成本取捨、security group 最小開放設計與 NACL 定位</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/03-network-foundation/security-group-audit-cleanup/" data-link-title="Security Group 稽核與清理" data-link-desc="盤點所有 security group 規則、找出 0.0.0.0/0 全開與未使用的 SG、依賴檢查後安全刪除、自動化治理">Security Group 稽核與清理</a></td>
          <td>0.0.0.0/0 偵測、未使用 SG 識別、依賴檢查、清理工作流、自動化治理</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>：誰有權改動 security group 與路由表</li>
<li>→ <a href="/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a>：核心服務怎麼落進規劃好的 subnet</li>
<li>→ <a href="/blog/devops/01-load-balancing/" data-link-title="模組一：負載平衡與反向代理" data-link-desc="流量進來怎麼分給多個服務實例 — nginx / HAProxy / DNS round-robin 的選型和健康檢查路由設計">devops 模組一：負載平衡</a>：入口流量怎麼分到 private subnet 的後端</li>
<li>→ <a href="/blog/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">devops 模組八：成本管理</a>：NAT 與出站流量的成本取捨</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>斷網環境的容器與映像管理</title><link>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-container/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-container/</guid><description>&lt;p>容器化應用在斷網環境的主要挑戰不是容器本身——Docker 和 containerd 不需要網路就能啟動容器。挑戰在映像的取得和更新：沒有 Docker Hub、沒有 ECR、沒有 ghcr.io，每一個 base image 和應用映像都要經過搬運路徑進入隔離網路。映像的管理在斷網環境裡需要一條完整的 pipeline：外部下載 → 安全掃描 → 搬運 → 推送到內部 registry → 各節點 pull。&lt;/p>
&lt;h2 id="private-registry">Private Registry&lt;/h2>
&lt;p>隔離網路裡需要一個容器映像倉庫，讓內部的 Docker host / Kubernetes 節點能 pull image。&lt;/p>
&lt;h3 id="harbor">Harbor&lt;/h3>
&lt;p>Harbor 是 VMware 開源的企業級 registry，功能包含：映像儲存、漏洞掃描（整合 Trivy）、存取控制（RBAC）、映像簽章（Cosign / Notary）、複製策略。適合中大規模的斷網環境。&lt;/p>
&lt;p>離線安裝：Harbor 提供 offline installer（&lt;code>.tgz&lt;/code>，約 600MB），包含所有需要的容器映像。搬進隔離網路後解壓、跑 &lt;code>install.sh&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="c1"># 外部：下載 offline installer&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">wget https://github.com/goharbor/harbor/releases/download/v2.11.0/harbor-offline-installer-v2.11.0.tgz
&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"># 搬運後，在內部解壓安裝&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">tar xzf harbor-offline-installer-v2.11.0.tgz
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="nb">cd&lt;/span> harbor
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">cp harbor.yml.tmpl harbor.yml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="c1"># 編輯 harbor.yml：設定 hostname、HTTPS 憑證、admin 密碼&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">./install.sh&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="docker-registry官方輕量版">Docker Registry（官方輕量版）&lt;/h3>
&lt;p>如果不需要 Harbor 的進階功能（RBAC、掃描），官方的 Docker Registry 是單一容器、設定最簡單：&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"># registry image 也要先搬進來&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">docker load &amp;lt; registry-2.8.3.tar
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">docker run -d -p 5000:5000 --restart&lt;span class="o">=&lt;/span>always --name registry &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> -v /data/registry:/var/lib/registry &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> registry:2.8.3&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>內部機器的 Docker daemon 要設定信任這個 registry（如果是 HTTP 而非 HTTPS）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;insecure-registries&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;registry.internal:5000&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="映像搬運">映像搬運&lt;/h2>
&lt;h3 id="docker-save--load">docker save / load&lt;/h3>
&lt;p>最直接的搬運方式——把映像匯出成 tar 檔、搬運後匯入：&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">docker pull nginx:1.25-alpine
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">docker save nginx:1.25-alpine -o nginx-1.25-alpine.tar
&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"># 搬運後，內部匯入&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">docker load &amp;lt; nginx-1.25-alpine.tar
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1"># 重新 tag 指向內部 registry&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">docker tag nginx:1.25-alpine registry.internal:5000/nginx:1.25-alpine
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">docker push registry.internal:5000/nginx:1.25-alpine&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>多個映像可以打包成一個 tar：&lt;code>docker save img1 img2 img3 -o bundle.tar&lt;/code>。&lt;/p>
&lt;h3 id="skopeo-copy">skopeo copy&lt;/h3>
&lt;p>skopeo 是不需要 Docker daemon 的映像操作工具，適合 CI 環境或沒有裝 Docker 的工作站：&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"># 外部：從 Docker Hub 複製到本地目錄&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">skopeo copy docker://nginx:1.25-alpine dir:/path/to/export/nginx-1.25
&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"># 搬運後，從本地目錄推送到內部 registry&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">skopeo copy dir:/path/to/export/nginx-1.25 docker://registry.internal:5000/nginx:1.25-alpine&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>skopeo 的優勢是不需要 pull 整個映像到本地 Docker（省磁碟空間）、支援 OCI layout、且可以在沒有 root 權限的環境執行。&lt;/p>
&lt;h3 id="搬運清單管理">搬運清單管理&lt;/h3>
&lt;p>映像搬運容易變成「需要什麼才搬什麼」的臨時操作。建議維護一份搬運清單（manifest），列出所有需要的 base image 和版本：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c"># image-manifest.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">images&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nginx&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tag&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.25&lt;/span>-&lt;span class="l">alpine&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">source&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">docker.io/library/nginx&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">postgres&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tag&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16.3&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">source&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">docker.io/library/postgres&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">node&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tag&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">20&lt;/span>-&lt;span class="l">alpine&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">source&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">docker.io/library/node&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>搬運腳本讀這份清單自動 pull + save，確保每次搬運的內容一致且可追蹤。&lt;/p></description><content:encoded><![CDATA[<p>容器化應用在斷網環境的主要挑戰不是容器本身——Docker 和 containerd 不需要網路就能啟動容器。挑戰在映像的取得和更新：沒有 Docker Hub、沒有 ECR、沒有 ghcr.io，每一個 base image 和應用映像都要經過搬運路徑進入隔離網路。映像的管理在斷網環境裡需要一條完整的 pipeline：外部下載 → 安全掃描 → 搬運 → 推送到內部 registry → 各節點 pull。</p>
<h2 id="private-registry">Private Registry</h2>
<p>隔離網路裡需要一個容器映像倉庫，讓內部的 Docker host / Kubernetes 節點能 pull image。</p>
<h3 id="harbor">Harbor</h3>
<p>Harbor 是 VMware 開源的企業級 registry，功能包含：映像儲存、漏洞掃描（整合 Trivy）、存取控制（RBAC）、映像簽章（Cosign / Notary）、複製策略。適合中大規模的斷網環境。</p>
<p>離線安裝：Harbor 提供 offline installer（<code>.tgz</code>，約 600MB），包含所有需要的容器映像。搬進隔離網路後解壓、跑 <code>install.sh</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"># 外部：下載 offline installer</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">wget https://github.com/goharbor/harbor/releases/download/v2.11.0/harbor-offline-installer-v2.11.0.tgz
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 搬運後，在內部解壓安裝</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">tar xzf harbor-offline-installer-v2.11.0.tgz
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nb">cd</span> harbor
</span></span><span class="line"><span class="ln">7</span><span class="cl">cp harbor.yml.tmpl harbor.yml
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"># 編輯 harbor.yml：設定 hostname、HTTPS 憑證、admin 密碼</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">./install.sh</span></span></code></pre></div><h3 id="docker-registry官方輕量版">Docker Registry（官方輕量版）</h3>
<p>如果不需要 Harbor 的進階功能（RBAC、掃描），官方的 Docker Registry 是單一容器、設定最簡單：</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"># registry image 也要先搬進來</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker load &lt; registry-2.8.3.tar
</span></span><span class="line"><span class="ln">3</span><span class="cl">docker run -d -p 5000:5000 --restart<span class="o">=</span>always --name registry <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  -v /data/registry:/var/lib/registry <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  registry:2.8.3</span></span></code></pre></div><p>內部機器的 Docker daemon 要設定信任這個 registry（如果是 HTTP 而非 HTTPS）：</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;insecure-registries&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;registry.internal:5000&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h2 id="映像搬運">映像搬運</h2>
<h3 id="docker-save--load">docker save / load</h3>
<p>最直接的搬運方式——把映像匯出成 tar 檔、搬運後匯入：</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">docker pull nginx:1.25-alpine
</span></span><span class="line"><span class="ln">3</span><span class="cl">docker save nginx:1.25-alpine -o nginx-1.25-alpine.tar
</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"># 搬運後，內部匯入</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">docker load &lt; nginx-1.25-alpine.tar
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># 重新 tag 指向內部 registry</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">docker tag nginx:1.25-alpine registry.internal:5000/nginx:1.25-alpine
</span></span><span class="line"><span class="ln">9</span><span class="cl">docker push registry.internal:5000/nginx:1.25-alpine</span></span></code></pre></div><p>多個映像可以打包成一個 tar：<code>docker save img1 img2 img3 -o bundle.tar</code>。</p>
<h3 id="skopeo-copy">skopeo copy</h3>
<p>skopeo 是不需要 Docker daemon 的映像操作工具，適合 CI 環境或沒有裝 Docker 的工作站：</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"># 外部：從 Docker Hub 複製到本地目錄</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">skopeo copy docker://nginx:1.25-alpine dir:/path/to/export/nginx-1.25
</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"># 搬運後，從本地目錄推送到內部 registry</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">skopeo copy dir:/path/to/export/nginx-1.25 docker://registry.internal:5000/nginx:1.25-alpine</span></span></code></pre></div><p>skopeo 的優勢是不需要 pull 整個映像到本地 Docker（省磁碟空間）、支援 OCI layout、且可以在沒有 root 權限的環境執行。</p>
<h3 id="搬運清單管理">搬運清單管理</h3>
<p>映像搬運容易變成「需要什麼才搬什麼」的臨時操作。建議維護一份搬運清單（manifest），列出所有需要的 base image 和版本：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># image-manifest.yaml</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">images</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">nginx</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">tag</span><span class="p">:</span><span class="w"> </span><span class="m">1.25</span>-<span class="l">alpine</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">source</span><span class="p">:</span><span class="w"> </span><span class="l">docker.io/library/nginx</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">postgres</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span><span class="nt">tag</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;16.3&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span><span class="nt">source</span><span class="p">:</span><span class="w"> </span><span class="l">docker.io/library/postgres</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">node</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">    </span><span class="nt">tag</span><span class="p">:</span><span class="w"> </span><span class="m">20</span>-<span class="l">alpine</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">    </span><span class="nt">source</span><span class="p">:</span><span class="w"> </span><span class="l">docker.io/library/node</span></span></span></code></pre></div><p>搬運腳本讀這份清單自動 pull + save，確保每次搬運的內容一致且可追蹤。</p>
<h2 id="base-image-更新週期">Base Image 更新週期</h2>
<p>斷網環境的 base image 不會自動更新——<code>nginx:1.25-alpine</code> 搬進去之後就是那個版本，裡面的 Alpine 套件不會收到安全補丁。需要定期用新版 base image 替換舊的。</p>
<h3 id="更新流程">更新流程</h3>
<ol>
<li><strong>外部</strong>：pull 最新版 base image</li>
<li><strong>外部</strong>：用 Trivy 掃描漏洞（見下一節）</li>
<li><strong>搬運</strong>：走 content ferry 帶進內部</li>
<li><strong>內部</strong>：push 到內部 registry、更新 tag</li>
<li><strong>內部</strong>：重新 build 所有依賴這個 base image 的應用映像</li>
<li><strong>內部</strong>：部署更新後的應用映像</li>
</ol>
<p>更新頻率：安全敏感環境月更、一般環境季更。每次更新都要記錄哪些 base image 換了、從哪個版本換到哪個版本。</p>
<h3 id="helm-chart-離線">Helm Chart 離線</h3>
<p>如果內部有 Kubernetes 且使用 Helm，chart 也要離線管理：</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"># 外部：下載 chart</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">helm pull bitnami/postgresql --version 15.5.0
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 搬運後，內部用本地檔案安裝</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">helm install pg ./postgresql-15.5.0.tgz -f values.yaml</span></span></code></pre></div><p>或架設 ChartMuseum 作為內部 Helm repo：chart 搬進來後 push 到 ChartMuseum，<code>helm repo add</code> 指向它。</p>
<h2 id="離線漏洞掃描">離線漏洞掃描</h2>
<p>連網環境的 Trivy 會自動下載漏洞資料庫（CVE DB）。斷網環境要先在外部下載 DB、搬進來。</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"># 外部：下載 Trivy 漏洞資料庫</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">trivy image --download-db-only --cache-dir /path/to/trivy-db/
</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"># 搬運 DB 檔案（~30MB）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># db.tar.gz 在 /path/to/trivy-db/db/ 裡</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># 內部：用離線 DB 掃描</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">trivy image --skip-db-update --cache-dir /path/to/trivy-db/ registry.internal:5000/nginx:1.25-alpine</span></span></code></pre></div><p>掃描結果的處理方式跟連網環境相同——critical 和 high 的 CVE 要評估是否影響、是否有 base image 更新可修。差別是斷網環境的修復週期更長（要走搬運流程），所以掃描要更頻繁（至少跟 base image 更新同步）。</p>
<p>Harbor 整合 Trivy 後可以在 push 時自動掃描——Trivy DB 的更新同樣需要定期搬運。</p>
<p>時程參考：Private registry 建置（Harbor offline）約需 1 天。映像搬運流程建立約需半天。第一批 base image 搬運 + 掃描約需半天。之後每次更新約 2-4 小時。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">斷網環境的通用原則</a>：映像搬運走 content ferry 模式</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/infra/knowledge-cards/ecs/" data-link-title="ECS" data-link-desc="AWS Elastic Container Service — 受管的容器編排服務，用 task definition 描述容器配置、由平台負責排程與健康管理">ECS 知識卡</a>：容器編排的基礎概念</li>
</ul>
]]></content:encoded></item><item><title>職務交接與存取撤銷設計</title><link>https://tarrragon.github.io/blog/infra/08-governance-habits/handover-design/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/08-governance-habits/handover-design/</guid><description>&lt;p>人員異動（離職、轉調、承包合約結束）是常態營運事件。基礎設施的設計決定了這件事的成本：如果環境的建立方式寫在程式碼裡、存取路徑收斂在 SSO、變更歷史留在 PR，交接是一兩天的帳號操作加上 repo 權限移交。如果環境靠個人記憶維護、存取散落在多組長期 key、變更歷史只在當事人的 shell history 裡，交接是數週的考古加上「不確定有沒有漏掉什麼」的持續焦慮。這篇文章處理兩件事：人走的時候怎麼安全撤銷存取，以及怎麼設計 infra 讓未來的交接成本結構性降低。&lt;/p>
&lt;h2 id="離職或轉調的存取撤銷清單">離職或轉調的存取撤銷清單&lt;/h2>
&lt;p>存取撤銷的目標是在人員離開的同一天（最晚 24 小時內）關閉所有該身分能存取雲端資源的路徑。撤銷的順序按影響範圍從大到小排：先關能連鎖失效的上游入口，再逐一清理下游殘留。&lt;/p>
&lt;h3 id="第一步停用-sso--idp-帳號">第一步：停用 SSO / IdP 帳號&lt;/h3>
&lt;p>如果雲端存取統一走 SSO（如 AWS IAM Identity Center、Okta、Google Workspace），停用 IdP 帳號會連鎖撤銷所有透過 SSO 取得的雲端權限 — 這是單一操作影響最大的一步。停用後，該人無法再透過 SSO 登入任何已接 SSO 的 AWS 帳號、CI 平台或內部工具。&lt;/p>
&lt;p>這一步能覆蓋多少取決於 SSO 的覆蓋率。如果某些雲端帳號還沒接 SSO（用獨立 IAM user 登入），停用 IdP 帳號不會影響那些路徑，需要額外處理。&lt;/p>
&lt;h3 id="第二步處理長期-access-key">第二步：處理長期 access key&lt;/h3>
&lt;p>從 credential report 找出該人名下的所有長期 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 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> &lt;span class="p">|&lt;/span> grep &lt;span class="s2">&amp;#34;departed-user&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每把 key 判斷處理方式：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>key 狀態&lt;/th>
 &lt;th>處理方式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>只有該人在用&lt;/td>
 &lt;td>直接 deactivate，觀察 24 小時無異常後刪除&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>被自動化腳本引用&lt;/td>
 &lt;td>先建新 key 並更新引用處，再 deactivate 舊 key&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>用途不明&lt;/td>
 &lt;td>先 deactivate（不刪），監控 CloudTrail 看有沒有存取失敗&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>deactivate 而非直接刪除是因為刪除不可逆 — 如果某個沒記錄在案的自動化正在用這把 key，deactivate 會讓它報權限錯誤，CloudTrail 會記錄失敗的 API 呼叫，方便追蹤；直接刪除後這把 key 的 ID 就消失了，追蹤更困難。&lt;/p>
&lt;h3 id="第三步刪除個人-iam-user">第三步：刪除個人 IAM user&lt;/h3>
&lt;p>確認沒有自動化依賴這個 user 後刪除。刪除前先檢查該 user 是否有 inline policy 或 group membership 被其他流程引用：&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 list-user-policies --user-name departed-user
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">aws iam list-groups-for-user --user-name departed-user
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">aws iam list-attached-user-policies --user-name departed-user&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="第四步第三方服務帳號">第四步：第三方服務帳號&lt;/h3>
&lt;p>雲端以外的存取路徑同樣需要撤銷：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>版本控制&lt;/strong>（GitHub / GitLab）：移除組織 membership 或降為 read-only&lt;/li>
&lt;li>&lt;strong>CI 平台&lt;/strong>（GitHub Actions secrets、GitLab CI variables）：如果該人曾設定過 CI secret，確認那些 secret 是否需要輪替&lt;/li>
&lt;li>&lt;strong>監控與告警&lt;/strong>（Grafana、PagerDuty、Datadog）：移除帳號或降權&lt;/li>
&lt;li>&lt;strong>基礎設施管理平台&lt;/strong>（Terraform Cloud、Spacelift）：移除 team membership&lt;/li>
&lt;/ul>
&lt;h3 id="第五步mfa-裝置解除註冊">第五步：MFA 裝置解除註冊&lt;/h3>
&lt;p>如果該人的 MFA 裝置仍然綁在帳號上（例如 root account 的 MFA），需要管理員介入解除並重新綁定。root account 的 MFA 裝置異動屬於高敏感操作，需要有第二人確認。&lt;/p>
&lt;h3 id="時程與回報">時程與回報&lt;/h3>
&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>SSO 停用&lt;/td>
 &lt;td>離職當天&lt;/td>
 &lt;td>確認 IdP 帳號已停用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>長期 key 處理&lt;/td>
 &lt;td>24 小時內&lt;/td>
 &lt;td>key 數量、各 key 處理方式（deactivate / 替換 / 刪除）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>IAM user 刪除&lt;/td>
 &lt;td>48 小時內&lt;/td>
 &lt;td>確認無殘留 user&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>第三方服務&lt;/td>
 &lt;td>48 小時內&lt;/td>
 &lt;td>各平台的處理狀態&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>管理層回報&lt;/td>
 &lt;td>48 小時內&lt;/td>
 &lt;td>一份清單確認所有存取路徑已關閉&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這份回報不是形式 — 它是對管理層證明「離職者已無法存取任何系統」的書面紀錄，合規稽核時會被要求出示。&lt;/p></description><content:encoded><![CDATA[<p>人員異動（離職、轉調、承包合約結束）是常態營運事件。基礎設施的設計決定了這件事的成本：如果環境的建立方式寫在程式碼裡、存取路徑收斂在 SSO、變更歷史留在 PR，交接是一兩天的帳號操作加上 repo 權限移交。如果環境靠個人記憶維護、存取散落在多組長期 key、變更歷史只在當事人的 shell history 裡，交接是數週的考古加上「不確定有沒有漏掉什麼」的持續焦慮。這篇文章處理兩件事：人走的時候怎麼安全撤銷存取，以及怎麼設計 infra 讓未來的交接成本結構性降低。</p>
<h2 id="離職或轉調的存取撤銷清單">離職或轉調的存取撤銷清單</h2>
<p>存取撤銷的目標是在人員離開的同一天（最晚 24 小時內）關閉所有該身分能存取雲端資源的路徑。撤銷的順序按影響範圍從大到小排：先關能連鎖失效的上游入口，再逐一清理下游殘留。</p>
<h3 id="第一步停用-sso--idp-帳號">第一步：停用 SSO / IdP 帳號</h3>
<p>如果雲端存取統一走 SSO（如 AWS IAM Identity Center、Okta、Google Workspace），停用 IdP 帳號會連鎖撤銷所有透過 SSO 取得的雲端權限 — 這是單一操作影響最大的一步。停用後，該人無法再透過 SSO 登入任何已接 SSO 的 AWS 帳號、CI 平台或內部工具。</p>
<p>這一步能覆蓋多少取決於 SSO 的覆蓋率。如果某些雲端帳號還沒接 SSO（用獨立 IAM user 登入），停用 IdP 帳號不會影響那些路徑，需要額外處理。</p>
<h3 id="第二步處理長期-access-key">第二步：處理長期 access key</h3>
<p>從 credential report 找出該人名下的所有長期 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 class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  <span class="p">|</span> grep <span class="s2">&#34;departed-user&#34;</span></span></span></code></pre></div><p>每把 key 判斷處理方式：</p>
<table>
  <thead>
      <tr>
          <th>key 狀態</th>
          <th>處理方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>只有該人在用</td>
          <td>直接 deactivate，觀察 24 小時無異常後刪除</td>
      </tr>
      <tr>
          <td>被自動化腳本引用</td>
          <td>先建新 key 並更新引用處，再 deactivate 舊 key</td>
      </tr>
      <tr>
          <td>用途不明</td>
          <td>先 deactivate（不刪），監控 CloudTrail 看有沒有存取失敗</td>
      </tr>
  </tbody>
</table>
<p>deactivate 而非直接刪除是因為刪除不可逆 — 如果某個沒記錄在案的自動化正在用這把 key，deactivate 會讓它報權限錯誤，CloudTrail 會記錄失敗的 API 呼叫，方便追蹤；直接刪除後這把 key 的 ID 就消失了，追蹤更困難。</p>
<h3 id="第三步刪除個人-iam-user">第三步：刪除個人 IAM user</h3>
<p>確認沒有自動化依賴這個 user 後刪除。刪除前先檢查該 user 是否有 inline policy 或 group membership 被其他流程引用：</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 list-user-policies --user-name departed-user
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws iam list-groups-for-user --user-name departed-user
</span></span><span class="line"><span class="ln">3</span><span class="cl">aws iam list-attached-user-policies --user-name departed-user</span></span></code></pre></div><h3 id="第四步第三方服務帳號">第四步：第三方服務帳號</h3>
<p>雲端以外的存取路徑同樣需要撤銷：</p>
<ul>
<li><strong>版本控制</strong>（GitHub / GitLab）：移除組織 membership 或降為 read-only</li>
<li><strong>CI 平台</strong>（GitHub Actions secrets、GitLab CI variables）：如果該人曾設定過 CI secret，確認那些 secret 是否需要輪替</li>
<li><strong>監控與告警</strong>（Grafana、PagerDuty、Datadog）：移除帳號或降權</li>
<li><strong>基礎設施管理平台</strong>（Terraform Cloud、Spacelift）：移除 team membership</li>
</ul>
<h3 id="第五步mfa-裝置解除註冊">第五步：MFA 裝置解除註冊</h3>
<p>如果該人的 MFA 裝置仍然綁在帳號上（例如 root account 的 MFA），需要管理員介入解除並重新綁定。root account 的 MFA 裝置異動屬於高敏感操作，需要有第二人確認。</p>
<h3 id="時程與回報">時程與回報</h3>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>時限</th>
          <th>回報內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SSO 停用</td>
          <td>離職當天</td>
          <td>確認 IdP 帳號已停用</td>
      </tr>
      <tr>
          <td>長期 key 處理</td>
          <td>24 小時內</td>
          <td>key 數量、各 key 處理方式（deactivate / 替換 / 刪除）</td>
      </tr>
      <tr>
          <td>IAM user 刪除</td>
          <td>48 小時內</td>
          <td>確認無殘留 user</td>
      </tr>
      <tr>
          <td>第三方服務</td>
          <td>48 小時內</td>
          <td>各平台的處理狀態</td>
      </tr>
      <tr>
          <td>管理層回報</td>
          <td>48 小時內</td>
          <td>一份清單確認所有存取路徑已關閉</td>
      </tr>
  </tbody>
</table>
<p>這份回報不是形式 — 它是對管理層證明「離職者已無法存取任何系統」的書面紀錄，合規稽核時會被要求出示。</p>
<h2 id="離職時的-credential-rotation">離職時的 credential rotation</h2>
<p>存取撤銷處理的是「這個人自己的 key 和帳號」。如果離職者曾有 admin 級別的存取權，還需要處理他可能接觸過的共用 secret。</p>
<p>rotation 的範圍取決於該人的權限等級：</p>
<table>
  <thead>
      <tr>
          <th>權限等級</th>
          <th>rotation 範圍</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>只有特定服務的讀取</td>
          <td>不需額外 rotation</td>
      </tr>
      <tr>
          <td>特定服務的讀寫</td>
          <td>該服務的 API key 和連線密碼</td>
      </tr>
      <tr>
          <td>跨服務或帳號的管理權限</td>
          <td>所有 Secrets Manager 裡該人可讀的 secret</td>
      </tr>
      <tr>
          <td>root 或 admin 等級</td>
          <td>全面 rotation + CloudTrail 審計最近 30 天活動</td>
      </tr>
  </tbody>
</table>
<p>admin 級別離職時的 CloudTrail 審計：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws cloudtrail lookup-events <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --lookup-attributes <span class="nv">AttributeKey</span><span class="o">=</span>Username,AttributeValue<span class="o">=</span>departed-user <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --start-time <span class="k">$(</span>date -v-30d +%Y-%m-%dT%H:%M:%SZ<span class="k">)</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --max-items <span class="m">100</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;Events[].[EventTime,EventName,Resources[0].ResourceName]&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --output table</span></span></code></pre></div><p>審計的目的是確認離職前 30 天內有沒有異常操作（大量資料下載、權限變更、新 key 建立），而非預設離職者有惡意。這是標準的安全衛生程序。</p>
<p>如果團隊已經全面採用 OIDC 短期憑證（見<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>），離職時的 credential rotation 範圍會大幅縮小 — 沒有長期 key 就沒有需要輪替的靜態憑證，SSO 停用後短期 token 自然失效。</p>
<h2 id="iac-與-pr-歷史怎麼降低交接成本">IaC 與 PR 歷史怎麼降低交接成本</h2>
<p>存取撤銷是離職當天的緊急操作。交接成本的高低則取決於新接手的人能多快理解環境的結構與歷史。</p>
<p>環境結構寫在 IaC 裡時，新人讀 repo 就能回答「我們有幾個 VPC、subnet 怎麼切、哪些服務在哪個 private subnet」。PR 歷史回答「為什麼 NAT 從共享改成 per-AZ」（因為上個月 ap-northeast-1a 故障時全部出站斷了）。這些資訊不依賴任何個人的記憶，新人第一天就能取得。</p>
<p>程式碼和 PR 歷史能涵蓋的是環境的結構與變更理由。以下資訊不在程式碼裡，需要額外文件或交接：</p>
<ul>
<li><strong>營運脈絡</strong>：哪些服務是流量敏感的、哪個時段不能做變更、哪些客戶有特殊 SLA</li>
<li><strong>事故歷史</strong>：過去發生過什麼事故、當時怎麼處理的、有沒有遺留的 workaround</li>
<li><strong>vendor 關係</strong>：support contract 的聯絡方式、升級路徑、合約到期時間</li>
<li><strong>進行中的工作</strong>：正在做的遷移、已知但未處理的技術債、已規劃但未執行的變更</li>
</ul>
<p>時程參考：環境完全在 IaC 裡的團隊，infra 角色交接通常 1-2 天能讓新人開始獨立操作（讀 code + 第一次 PR）。沒有 IaC 的環境，交接需要 1-2 週的口頭傳授加上新人自行摸索。</p>
<h2 id="最小交接清單">最小交接清單</h2>
<p>任何 infra 角色變更（不只是離職，包括長假、轉組、新人 onboarding）都應該走過一次這份清單：</p>
<h3 id="帳號與存取盤點">帳號與存取盤點</h3>
<ul>
<li>所有雲端帳號的列表（帳號 ID、用途、環境對應）</li>
<li>CI/CD 平台的組織與 repo 存取</li>
<li>監控與告警平台的帳號</li>
<li>DNS 管理（域名註冊商、Route 53 hosted zone）</li>
<li>SSL 憑證管理（ACM、Let&rsquo;s Encrypt）</li>
</ul>
<h3 id="憑證盤點">憑證盤點</h3>
<ul>
<li>長期 access key 清單（從 credential report 取得）</li>
<li>Secrets Manager / SSM Parameter Store 裡的 secret 清單</li>
<li>第三方服務的 API key（付費服務、SaaS 整合）</li>
</ul>
<h3 id="聯絡與升級路徑">聯絡與升級路徑</h3>
<ul>
<li>雲端 vendor 的 support 聯絡方式與 support plan 等級</li>
<li>資安事件的通報對象與流程</li>
<li>on-call chain 與升級規則</li>
</ul>
<h3 id="進行中的工作">進行中的工作</h3>
<ul>
<li>正在執行的遷移或重構（目前到哪一步、下一步是什麼）</li>
<li>已知的技術債與風險（哪些資源還沒納管、哪些 key 該輪替但還沒輪替）</li>
<li>已排程但未開始的變更</li>
</ul>
<p>這份清單的維護成本很低 — 多數項目在日常工作中已經存在（credential report、repo 結構、ticket board），交接時只需要把散落的資訊收斂到一份文件。如果每次交接都要花時間「找資訊在哪裡」，代表日常的資訊組織有改善空間。</p>
<h2 id="讓交接成本結構性降低的設計">讓交接成本結構性降低的設計</h2>
<p>上面的清單處理的是每次交接的操作成本。以下設計原則處理的是讓這個成本隨時間趨近固定值、而非隨環境複雜度增長：</p>
<p><strong>SSO 作為單一存取撤銷點</strong>：所有雲端存取走 SSO，離職時停用一個帳號就關閉所有路徑。沒有 SSO 時，每多一個平台就多一個需要手動撤銷的路徑，漏撤任何一個都是安全缺口。SSO 的覆蓋率越高，撤銷操作越接近 O(1)。</p>
<p><strong>消除個人長期 key</strong>：用 OIDC + role assumption 取代長期 access key（見<a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>）。沒有長期 key，離職時就沒有需要逐一追蹤和輪替的靜態憑證。credential rotation 的範圍從「所有 key」縮小到「共用 secret」。</p>
<p><strong>環境描述在程式碼裡</strong>：IaC 讓環境結構對任何有 repo 存取的人可讀。交接的知識成本從「口頭傳授整個環境長什麼樣」降到「讀 code + 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 自動化，讓基礎設施可審查、可回溯、可交接">模組七：infra 走 PR 流程</a>。</p>
<p><strong>PR 描述記錄「為什麼」</strong>：程式碼記錄「什麼」，PR 描述記錄「為什麼」。三個月後翻 git log，看到「把 NAT 從共享改成 per-AZ」知道改了什麼；看到 PR 描述裡的「因為上週 ap-northeast-1a 故障時全部出站斷了」才知道為什麼。這段脈絡在交接時的價值最高 — 新人最常問的問題就是「為什麼這樣設定」。</p>
<p><strong>on-call 輪替分散操作知識</strong>：讓不同人輪流負責 infra 的 review、apply 和事故處理，用操作經驗分散知識。判斷知識是否過度集中的方式：如果團隊裡只有一個人敢對 production 做 apply，那個人就是交接的瓶頸。見<a href="/blog/infra/09-driving-adoption/" data-link-title="模組九：怎麼把 infra 推動起來" data-link-desc="技術正確不等於推得動 — 信任赤字、期望值對齊、知識共享，infra 落地的組織課題">模組九：怎麼把 infra 推動起來</a>。</p>
<p>這些設計的共同效果是讓交接的固定成本保持在「停用帳號 + 移交 repo 權限 + 走一次交接清單」，不隨環境複雜度或人員流動頻率等比增長。</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>：IAM 設計、OIDC 短期憑證、權限邊界</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>：PR 作為知識載體、變更可追溯</li>
<li>→ <a href="/blog/infra/09-driving-adoption/" data-link-title="模組九：怎麼把 infra 推動起來" data-link-desc="技術正確不等於推得動 — 信任赤字、期望值對齊、知識共享，infra 落地的組織課題">模組九：怎麼把 infra 推動起來</a>：知識共享與 on-call 輪替</li>
<li>→ <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 模組七：資安與資料保護</a>：Secret 輪替策略</li>
</ul>
]]></content:encoded></item><item><title>拿到雲端帳號的第一天</title><link>https://tarrragon.github.io/blog/infra/00-infra-mindset/first-day-with-cloud-account/</link><pubDate>Tue, 30 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/00-infra-mindset/first-day-with-cloud-account/</guid><description>&lt;p>這篇寫給一種特定的讀者：你的專業可能是後端、前端、資料工程或其他領域，但因為組織需要，你被指派處理雲端基礎設施。公司（或主管）給了你一個 AWS / GCP / Azure 帳號，你登入之後看到一個很大的 Console，不確定該做什麼、也不確定動了什麼會出事。&lt;/p>
&lt;p>這是 infra 工作最常見的真實入口。比起從零自學建一套環境，「接到指派、拿到帳號、搞清楚狀況」才是多數工程師第一次碰 infra 的方式。&lt;/p>
&lt;p>這篇用 AWS 為主要範例。GCP 和 Azure 的判讀邏輯相同（安全底線 → 現況盤點 → 路線分流），但具體服務名稱、IAM 模型和 Console 操作位置不同。&lt;/p>
&lt;h2 id="第一小時安全底線">第一小時：安全底線&lt;/h2>
&lt;p>登入帳號後，在做任何其他事之前先完成這些。這些步驟的共同目的是確保帳號的存取控制處於安全狀態——雲端帳號被入侵的代價遠高於本機電腦被入侵，因為雲端資源可以在幾分鐘內被大量建立（產生帳單）或被刪除（資料遺失）。&lt;/p>
&lt;h3 id="確認-root-帳號的-mfa">確認 root 帳號的 MFA&lt;/h3>
&lt;p>Root 帳號是雲端環境的最高權限，能做任何事，包括關閉整個帳號。如果 root 帳號沒有 MFA（Multi-Factor Authentication，多因子驗證），任何拿到 root 密碼的人都能完全控制整個環境。&lt;/p>
&lt;p>確認路徑（AWS）：Console 右上角帳號名稱 → Security credentials → Multi-factor authentication (MFA)。如果顯示「No MFA device」，立刻設定一個——手機 app（Google Authenticator / Authy）或硬體 key（YubiKey）都可以。&lt;/p>
&lt;p>如果你拿到的帳號是公司用 AWS Organizations 開出來的子帳號，子帳號 root 的密碼和 MFA 是獨立的——管理帳號無法代設。子帳號 root 通常需要先用帳號 email 做密碼重置才能首次登入。確認 root MFA 後，日常操作用 IAM Identity Center 登入。&lt;/p>
&lt;h3 id="確認你的登入身分">確認你的登入身分&lt;/h3>
&lt;p>你登入用的是哪種身分？這決定了你的權限範圍和操作方式。&lt;/p>
&lt;p>&lt;strong>IAM user&lt;/strong>：Console 右上角會顯示 &lt;code>username @ account-id&lt;/code>。這是最傳統的登入方式——帳號管理員幫你建了一個使用者，給了你一組帳密。&lt;/p>
&lt;p>&lt;strong>IAM Identity Center（SSO）&lt;/strong>：你透過一個特別的登入頁面（通常是 &lt;code>https://d-xxxxxxxxxx.awsapps.com/start&lt;/code>）登入，然後選擇帳號和角色。這是較新的做法，多帳號組織常用。&lt;/p>
&lt;p>&lt;strong>Root 帳號&lt;/strong>：Console 右上角顯示帳號 email 而非 username。如果你拿到的是 root 帳號的帳密，日常操作應該換成 IAM user 或 SSO 登入——root 帳號只在需要 root-only 操作（如設定 MFA、關閉帳號）時使用。建立 IAM user 的方式見模組一的&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 的自管與託管路線">動手前的前提&lt;/a>段。&lt;/p>
&lt;h3 id="檢查既存的-access-key">檢查既存的 access key&lt;/h3>
&lt;p>帳號如果被前人用過，可能有暴露風險的 access key——之前的管理員建了 IAM user、生了 key，但那組 key 可能已經寫在某個 Git repo 或環境變數裡而沒有停用。&lt;/p>
&lt;p>確認路徑：Console → IAM → Users → 逐一點每個 user → Security credentials 分頁 → Access keys。檢查每組 key 的狀態（Active / Inactive）和建立時間。超過 90 天未 rotate 的 Active key 是風險——帳號接手後優先 rotate 或停用這些 key。如果帳號裡沒有任何 IAM user，這步跳過。&lt;/p>
&lt;h3 id="確認-cloudtrail-是否開啟">確認 CloudTrail 是否開啟&lt;/h3>
&lt;p>CloudTrail 記錄帳號內所有 API 操作（誰在什麼時間做了什麼）。AWS 預設會開啟 90 天的事件歷史，但長期保存需要建一個 Trail 把 log 寫到 S3。&lt;/p>
&lt;p>確認路徑：Console 搜尋 CloudTrail → Dashboard。如果有 Trail 已建立，表示操作紀錄有長期保存。如果只有預設的 Event history，90 天前的紀錄會消失——這是一個需要但不緊急的改善點，&lt;a href="https://tarrragon.github.io/blog/infra/06-observability-logging/" data-link-title="模組六：可觀測性與 log 一併寫進 code" data-link-desc="log group、metric、alarm 跟基礎設施同生命週期管理，出事時追得到查得到">模組六：可觀測性&lt;/a>會展開。&lt;/p></description><content:encoded><![CDATA[<p>這篇寫給一種特定的讀者：你的專業可能是後端、前端、資料工程或其他領域，但因為組織需要，你被指派處理雲端基礎設施。公司（或主管）給了你一個 AWS / GCP / Azure 帳號，你登入之後看到一個很大的 Console，不確定該做什麼、也不確定動了什麼會出事。</p>
<p>這是 infra 工作最常見的真實入口。比起從零自學建一套環境，「接到指派、拿到帳號、搞清楚狀況」才是多數工程師第一次碰 infra 的方式。</p>
<p>這篇用 AWS 為主要範例。GCP 和 Azure 的判讀邏輯相同（安全底線 → 現況盤點 → 路線分流），但具體服務名稱、IAM 模型和 Console 操作位置不同。</p>
<h2 id="第一小時安全底線">第一小時：安全底線</h2>
<p>登入帳號後，在做任何其他事之前先完成這些。這些步驟的共同目的是確保帳號的存取控制處於安全狀態——雲端帳號被入侵的代價遠高於本機電腦被入侵，因為雲端資源可以在幾分鐘內被大量建立（產生帳單）或被刪除（資料遺失）。</p>
<h3 id="確認-root-帳號的-mfa">確認 root 帳號的 MFA</h3>
<p>Root 帳號是雲端環境的最高權限，能做任何事，包括關閉整個帳號。如果 root 帳號沒有 MFA（Multi-Factor Authentication，多因子驗證），任何拿到 root 密碼的人都能完全控制整個環境。</p>
<p>確認路徑（AWS）：Console 右上角帳號名稱 → Security credentials → Multi-factor authentication (MFA)。如果顯示「No MFA device」，立刻設定一個——手機 app（Google Authenticator / Authy）或硬體 key（YubiKey）都可以。</p>
<p>如果你拿到的帳號是公司用 AWS Organizations 開出來的子帳號，子帳號 root 的密碼和 MFA 是獨立的——管理帳號無法代設。子帳號 root 通常需要先用帳號 email 做密碼重置才能首次登入。確認 root MFA 後，日常操作用 IAM Identity Center 登入。</p>
<h3 id="確認你的登入身分">確認你的登入身分</h3>
<p>你登入用的是哪種身分？這決定了你的權限範圍和操作方式。</p>
<p><strong>IAM user</strong>：Console 右上角會顯示 <code>username @ account-id</code>。這是最傳統的登入方式——帳號管理員幫你建了一個使用者，給了你一組帳密。</p>
<p><strong>IAM Identity Center（SSO）</strong>：你透過一個特別的登入頁面（通常是 <code>https://d-xxxxxxxxxx.awsapps.com/start</code>）登入，然後選擇帳號和角色。這是較新的做法，多帳號組織常用。</p>
<p><strong>Root 帳號</strong>：Console 右上角顯示帳號 email 而非 username。如果你拿到的是 root 帳號的帳密，日常操作應該換成 IAM user 或 SSO 登入——root 帳號只在需要 root-only 操作（如設定 MFA、關閉帳號）時使用。建立 IAM user 的方式見模組一的<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 的自管與託管路線">動手前的前提</a>段。</p>
<h3 id="檢查既存的-access-key">檢查既存的 access key</h3>
<p>帳號如果被前人用過，可能有暴露風險的 access key——之前的管理員建了 IAM user、生了 key，但那組 key 可能已經寫在某個 Git repo 或環境變數裡而沒有停用。</p>
<p>確認路徑：Console → IAM → Users → 逐一點每個 user → Security credentials 分頁 → Access keys。檢查每組 key 的狀態（Active / Inactive）和建立時間。超過 90 天未 rotate 的 Active key 是風險——帳號接手後優先 rotate 或停用這些 key。如果帳號裡沒有任何 IAM user，這步跳過。</p>
<h3 id="確認-cloudtrail-是否開啟">確認 CloudTrail 是否開啟</h3>
<p>CloudTrail 記錄帳號內所有 API 操作（誰在什麼時間做了什麼）。AWS 預設會開啟 90 天的事件歷史，但長期保存需要建一個 Trail 把 log 寫到 S3。</p>
<p>確認路徑：Console 搜尋 CloudTrail → Dashboard。如果有 Trail 已建立，表示操作紀錄有長期保存。如果只有預設的 Event history，90 天前的紀錄會消失——這是一個需要但不緊急的改善點，<a href="/blog/infra/06-observability-logging/" data-link-title="模組六：可觀測性與 log 一併寫進 code" data-link-desc="log group、metric、alarm 跟基礎設施同生命週期管理，出事時追得到查得到">模組六：可觀測性</a>會展開。</p>
<p>現階段只需要確認 CloudTrail 存在，不需要馬上改它。</p>
<h3 id="設定帳單警報">設定帳單警報</h3>
<p>雲端帳單是開放式的——資源跑著就持續產生費用，被入侵後被開出大量資源更可能在幾小時內累積數千美元帳單。設一個帳單警報，超過閾值時收到通知。</p>
<p>設定路徑（AWS）：Console 搜尋 Billing → Budgets → Create budget → Cost budget。設一個月預算（如 $50 或 $100，依你的環境規模），超過 80% 和 100% 時發 email 通知。</p>
<h2 id="帳號現況判讀空帳號還是有東西">帳號現況判讀：空帳號還是有東西？</h2>
<p>安全底線做完後，下一步是搞清楚帳號的現況。這決定了你接下來走哪條路線。</p>
<h3 id="怎麼判斷">怎麼判斷</h3>
<p>EC2 Dashboard 只顯示當前 region 的資源。Console 右上角有 region 選擇器——先切幾個主要 region（us-east-1、ap-northeast-1、ap-southeast-1）看一下，確認資源是否分散在不同 region。</p>
<p>打開 EC2 Dashboard（Console 搜尋 EC2）。如果 Running instances 是 0、沒有 volumes、沒有 security groups（除了 default）——大概率是空帳號。也檢查 Lambda（Console 搜尋 Lambda → Functions）——如果有 function 在跑但 EC2 是空的，可能是 serverless 架構，帳號不是空的。</p>
<p>再看 S3（Console 搜尋 S3）。S3 是全域服務，不分 region。如果沒有 bucket，或只有 CloudTrail 的 log bucket——大概率是空帳號。</p>
<p>如果有正在跑的 EC2 instance、有 Lambda function、有 RDS 資料庫、有 S3 bucket 存著資料——這是一個有東西的帳號，可能是前人建的、可能是其他團隊在用的。</p>
<h3 id="空帳號--從零建置">空帳號 → 從零建置</h3>
<p>帳號是空的，你要從零開始建基礎設施。這是最乾淨的起點。</p>
<p>路線：先讀<a href="/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">模組零</a>建立心智模型（什麼是 infra、成熟度階梯），然後照模組一到五的順序走。模組一的<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 的自管與託管路線">動手前的前提</a>段會帶你設好本機工具和認證。</p>
<h3 id="有東西的帳號--接手維運">有東西的帳號 → 接手維運</h3>
<p>帳號裡已經有資源在跑。你需要先搞清楚「有什麼」「誰建的」「哪些還在用」，再決定怎麼處理。</p>
<p>路線：讀<a href="/blog/infra/takeover/" data-link-title="接手維運：別人建的環境怎麼接管" data-link-desc="接手前人的專案時，怎麼在不搞壞東西的前提下盤點現況、建立維運能力、逐步正規化 — 從無 SSH 的 FTP 環境到有半套 IaC 的雲端環境都適用">接手維運</a>模組。它按環境類型（全手動的遺留環境、部分有 IaC、多帳號結構）分篇，教你怎麼盤點、怎麼在不搞壞的前提下逐步接管。</p>
<h3 id="不確定--先盤點再說">不確定 → 先盤點再說</h3>
<p>如果帳號裡有東西但你不確定是不是還在用、能不能動，先盤點。以下指令需要 AWS CLI 並完成認證——安裝和 <code>aws configure</code> 設定見模組一的<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 的自管與託管路線">前提段</a>（macOS 快速安裝：<code>brew install awscli &amp;&amp; aws configure</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"># 列出所有 region 的 EC2 instance</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">for</span> region in <span class="k">$(</span>aws ec2 describe-regions --query <span class="s1">&#39;Regions[].RegionName&#39;</span> --output text<span class="k">)</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;=== </span><span class="nv">$region</span><span class="s2"> ===&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  aws ec2 describe-instances --region <span class="s2">&#34;</span><span class="nv">$region</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>    --query <span class="s1">&#39;Reservations[].Instances[].[InstanceId,State.Name,Tags[?Key==`Name`].Value|[0]]&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>    --output table
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">done</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="c1"># 列出所有 S3 bucket</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">aws s3 ls
</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="c1"># 列出所有 RDS instance</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">aws rds describe-db-instances <span class="se">\
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;DBInstances[].[DBInstanceIdentifier,Engine,DBInstanceStatus]&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="se"></span>  --output table</span></span></code></pre></div><p>這些指令只做讀取，不會改變任何東西。如果輸出很多資源，去讀<a href="/blog/infra/takeover/" data-link-title="接手維運：別人建的環境怎麼接管" data-link-desc="接手前人的專案時，怎麼在不搞壞東西的前提下盤點現況、建立維運能力、逐步正規化 — 從無 SSH 的 FTP 環境到有半套 IaC 的雲端環境都適用">接手維運</a>再決定下一步。如果幾乎是空的，走「從零建置」路線。</p>
<h2 id="雲端-console-的基本導覽">雲端 Console 的基本導覽</h2>
<p>AWS Console 列出幾百個服務，日常 infra 工作常用的集中在以下幾個：</p>
<table>
  <thead>
      <tr>
          <th>服務</th>
          <th>做什麼</th>
          <th>什麼時候用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>EC2</td>
          <td>虛擬機器（運算）</td>
          <td>看有什麼機器在跑、管 security group</td>
      </tr>
      <tr>
          <td>S3</td>
          <td>物件儲存</td>
          <td>放檔案、放 Terraform state、放 log</td>
      </tr>
      <tr>
          <td>IAM</td>
          <td>身分與權限</td>
          <td>管使用者、角色、權限</td>
      </tr>
      <tr>
          <td>VPC</td>
          <td>虛擬網路</td>
          <td>管網路拓撲、子網路、路由</td>
      </tr>
      <tr>
          <td>RDS</td>
          <td>託管資料庫</td>
          <td>看有沒有資料庫在跑</td>
      </tr>
      <tr>
          <td>CloudWatch</td>
          <td>監控與 log</td>
          <td>看 metric、設 alarm、查 log</td>
      </tr>
      <tr>
          <td>CloudTrail</td>
          <td>操作審計</td>
          <td>查誰做了什麼</td>
      </tr>
      <tr>
          <td>Billing</td>
          <td>帳單</td>
          <td>看花了多少錢</td>
      </tr>
  </tbody>
</table>
<p>Console 左上角的搜尋列可以直接搜服務名稱，不用從選單找。</p>
<p>每個服務在 Console 上的操作都有一個對應的 AWS CLI 指令和 API 呼叫。這個對應關係是 IaC 的基礎——<a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一</a>會教怎麼把 Console 上的操作轉成程式碼。</p>
<h2 id="你接下來該讀什麼">你接下來該讀什麼</h2>
<p>根據你的情境選一條路線：</p>
<table>
  <thead>
      <tr>
          <th>你的情境</th>
          <th>路線</th>
          <th>從哪裡開始</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>完全沒碰過雲端、想先理解概念</td>
          <td>入門認識</td>
          <td><a href="/blog/infra/00-infra-mindset/personal-project-to-infra/" data-link-title="雲端部署裡已經存在的 infra 元件" data-link-desc="VPC、security group、IAM、儲存 — 這些元件在任何雲端部署裡都已經在運作，差別在於有沒有被有意識地管理">個人專案到團隊服務</a></td>
      </tr>
      <tr>
          <td>空帳號、要從零建 infra</td>
          <td>從零建置</td>
          <td><a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一：最小可行 IaC</a></td>
      </tr>
      <tr>
          <td>帳號有東西、要接手維運</td>
          <td>接手前人專案</td>
          <td><a href="/blog/infra/takeover/" data-link-title="接手維運：別人建的環境怎麼接管" data-link-desc="接手前人的專案時，怎麼在不搞壞東西的前提下盤點現況、建立維運能力、逐步正規化 — 從無 SSH 的 FTP 環境到有半套 IaC 的雲端環境都適用">接手維運</a></td>
      </tr>
      <tr>
          <td>手動環境、暫時無法導入 IaC</td>
          <td>還沒有 IaC</td>
          <td><a href="/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一：還沒有 infra 的環境</a></td>
      </tr>
      <tr>
          <td>要跟主管解釋為什麼要做 infra</td>
          <td>說服決策者</td>
          <td><a href="/blog/infra/09-driving-adoption/infra-explained-for-non-engineers/" data-link-title="給非工程背景決策者的 infra 說明" data-link-desc="從管理視角解釋基礎設施在解決什麼營運問題、不做的代價、出事怎麼處理，讓參與資源決策的人能判斷投入的優先級">給非工程人員的 infra 說明</a></td>
      </tr>
      <tr>
          <td>拿到一台主機、要從 OS 層連入初始化</td>
          <td>機器初始化</td>
          <td><a href="/blog/linux/install/" data-link-title="Linux 安裝與機器初始化" data-link-desc="在 VM 或新機器從零裝好 Linux、判讀安裝程式選項、驗證最小系統、或要從外部連入跑 bootstrap 時回來讀">Linux 安裝與機器初始化</a></td>
      </tr>
  </tbody>
</table>
<p>如果你不確定自己屬於哪種情境，先做完本篇的「帳號現況判讀」再決定。</p>
]]></content:encoded></item><item><title>Access Key 輪替手冊</title><link>https://tarrragon.github.io/blog/infra/02-identity-credentials/access-key-rotation-playbook/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/02-identity-credentials/access-key-rotation-playbook/</guid><description>&lt;p>長期 access key 的風險隨時間單調上升——每多存在一天，被複製到新地方的機率就多一分，而輪替的難度也跟著副本數量增長。輪替不是「發現外洩才做」的緊急動作，而是定期執行的維運操作。本篇是操作手冊，從盤點開始、逐步完成輪替、最後建立自動化。&lt;/p>
&lt;h2 id="盤點帳號裡有哪些-key">盤點：帳號裡有哪些 key&lt;/h2>
&lt;p>第一步是拿到帳號內所有 IAM user 的 access key 清單。AWS 的 credential report 是這個問題的標準資料來源，它列出每個 user 的 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 &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;Content&amp;#39;&lt;/span> --output text &lt;span class="p">|&lt;/span> base64 -d &amp;gt; credential-report.csv&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>產出的 CSV 包含每個 IAM user 的兩把 key（access_key_1、access_key_2）各自的狀態。關注的欄位：&lt;/p>
&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;code>user&lt;/code>&lt;/td>
 &lt;td>key 的擁有者&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>access_key_1_active&lt;/code>&lt;/td>
 &lt;td>key 是否啟用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>access_key_1_last_used_date&lt;/code>&lt;/td>
 &lt;td>最後使用時間——長期未使用代表可能是遺棄的 key&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>access_key_1_last_rotated&lt;/code>&lt;/td>
 &lt;td>建立或上次輪替的時間&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>用 csvkit 或試算表打開這份報告，按 &lt;code>access_key_1_last_rotated&lt;/code> 排序，最舊的 key 排最前面。超過 90 天未輪替的 key 列為第一批處理對象。&lt;/p>
&lt;p>以下腳本使用 gawk 的 &lt;code>systime()&lt;/code> 函式。如果系統的 awk 是 mawk（Ubuntu 預設），改用 &lt;code>gawk&lt;/code> 或用 &lt;code>date&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="c1"># 快速列出所有啟用中、超過 90 天的 key&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">aws iam list-users --query &lt;span class="s1">&amp;#39;Users[].UserName&amp;#39;&lt;/span> --output text &lt;span class="p">|&lt;/span> tr &lt;span class="s1">&amp;#39;\t&amp;#39;&lt;/span> &lt;span class="s1">&amp;#39;\n&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span> &lt;span class="k">while&lt;/span> &lt;span class="nb">read&lt;/span> user&lt;span class="p">;&lt;/span> &lt;span class="k">do&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> aws iam list-access-keys --user-name &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$user&lt;/span>&lt;span class="s2">&amp;#34;&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> --query &lt;span class="s2">&amp;#34;AccessKeyMetadata[?Status==&amp;#39;Active&amp;#39;].[UserName,AccessKeyId,CreateDate]&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --output text
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="k">done&lt;/span> &lt;span class="p">|&lt;/span> awk -F&lt;span class="s1">&amp;#39;\t&amp;#39;&lt;/span> &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="s1"> cmd = &amp;#34;date -d \&amp;#34;&amp;#34; $3 &amp;#34;\&amp;#34; +%s 2&amp;gt;/dev/null || date -jf \&amp;#34;%Y-%m-%dT%H:%M:%S+00:00\&amp;#34; \&amp;#34;&amp;#34; $3 &amp;#34;\&amp;#34; +%s&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="s1"> cmd | getline created; close(cmd)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="s1"> age = (systime() - created) / 86400
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="s1"> if (age &amp;gt; 90) printf &amp;#34;%s\t%s\t%.0f days\n&amp;#34;, $1, $2, age
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="s1">}&amp;#39;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="識別每把-key-的用途">識別每把 key 的用途&lt;/h2>
&lt;p>知道 key 存在之後，下一個問題是「這把 key 用在哪裡」。credential report 只告訴你 key 最後被用來呼叫什麼 service（&lt;code>access_key_1_last_used_service&lt;/code>），但不告訴你它被存放在哪裡。&lt;/p>
&lt;p>用途識別需要交叉比對多個來源：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>可能的存放位置&lt;/th>
 &lt;th>檢查方式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>CI 環境變數（GitHub Actions）&lt;/td>
 &lt;td>repo Settings → Secrets and variables → Actions&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CI 環境變數（GitLab CI）&lt;/td>
 &lt;td>repo Settings → CI/CD → Variables&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>EC2 instance 的 user data&lt;/td>
 &lt;td>&lt;code>aws ec2 describe-instance-attribute --attribute userData&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Lambda 環境變數&lt;/td>
 &lt;td>&lt;code>aws lambda get-function-configuration --function-name NAME&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>SSM Parameter Store&lt;/td>
 &lt;td>&lt;code>aws ssm get-parameters-by-path --path / --recursive&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>開發者筆電&lt;/td>
 &lt;td>&lt;code>~/.aws/credentials&lt;/code> — 需要口頭確認&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>程式碼 repo&lt;/td>
 &lt;td>&lt;code>git log --all -p | grep AKIA&lt;/code> — AKIA 是 access key 的固定前綴&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Slack / email 歷史&lt;/td>
 &lt;td>無法自動掃描，靠團隊回報&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>對每把要輪替的 key，在以上位置逐一確認。找不到用途的 key 可以先停用觀察（而非直接刪除），停用後如果有服務壞了就知道它用在哪裡。&lt;/p></description><content:encoded><![CDATA[<p>長期 access key 的風險隨時間單調上升——每多存在一天，被複製到新地方的機率就多一分，而輪替的難度也跟著副本數量增長。輪替不是「發現外洩才做」的緊急動作，而是定期執行的維運操作。本篇是操作手冊，從盤點開始、逐步完成輪替、最後建立自動化。</p>
<h2 id="盤點帳號裡有哪些-key">盤點：帳號裡有哪些 key</h2>
<p>第一步是拿到帳號內所有 IAM user 的 access key 清單。AWS 的 credential report 是這個問題的標準資料來源，它列出每個 user 的 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 <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;Content&#39;</span> --output text <span class="p">|</span> base64 -d &gt; credential-report.csv</span></span></code></pre></div><p>產出的 CSV 包含每個 IAM user 的兩把 key（access_key_1、access_key_2）各自的狀態。關注的欄位：</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>user</code></td>
          <td>key 的擁有者</td>
      </tr>
      <tr>
          <td><code>access_key_1_active</code></td>
          <td>key 是否啟用</td>
      </tr>
      <tr>
          <td><code>access_key_1_last_used_date</code></td>
          <td>最後使用時間——長期未使用代表可能是遺棄的 key</td>
      </tr>
      <tr>
          <td><code>access_key_1_last_rotated</code></td>
          <td>建立或上次輪替的時間</td>
      </tr>
  </tbody>
</table>
<p>用 csvkit 或試算表打開這份報告，按 <code>access_key_1_last_rotated</code> 排序，最舊的 key 排最前面。超過 90 天未輪替的 key 列為第一批處理對象。</p>
<p>以下腳本使用 gawk 的 <code>systime()</code> 函式。如果系統的 awk 是 mawk（Ubuntu 預設），改用 <code>gawk</code> 或用 <code>date</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"># 快速列出所有啟用中、超過 90 天的 key</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">aws iam list-users --query <span class="s1">&#39;Users[].UserName&#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="k">while</span> <span class="nb">read</span> user<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  aws iam list-access-keys --user-name <span class="s2">&#34;</span><span class="nv">$user</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>    --query <span class="s2">&#34;AccessKeyMetadata[?Status==&#39;Active&#39;].[UserName,AccessKeyId,CreateDate]&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>    --output text
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="k">done</span> <span class="p">|</span> awk -F<span class="s1">&#39;\t&#39;</span> <span class="s1">&#39;{
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s1">  cmd = &#34;date -d \&#34;&#34; $3 &#34;\&#34; +%s 2&gt;/dev/null || date -jf \&#34;%Y-%m-%dT%H:%M:%S+00:00\&#34; \&#34;&#34; $3 &#34;\&#34; +%s&#34;
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s1">  cmd | getline created; close(cmd)
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s1">  age = (systime() - created) / 86400
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s1">  if (age &gt; 90) printf &#34;%s\t%s\t%.0f days\n&#34;, $1, $2, age
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s1">}&#39;</span></span></span></code></pre></div><h2 id="識別每把-key-的用途">識別每把 key 的用途</h2>
<p>知道 key 存在之後，下一個問題是「這把 key 用在哪裡」。credential report 只告訴你 key 最後被用來呼叫什麼 service（<code>access_key_1_last_used_service</code>），但不告訴你它被存放在哪裡。</p>
<p>用途識別需要交叉比對多個來源：</p>
<table>
  <thead>
      <tr>
          <th>可能的存放位置</th>
          <th>檢查方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CI 環境變數（GitHub Actions）</td>
          <td>repo Settings → Secrets and variables → Actions</td>
      </tr>
      <tr>
          <td>CI 環境變數（GitLab CI）</td>
          <td>repo Settings → CI/CD → Variables</td>
      </tr>
      <tr>
          <td>EC2 instance 的 user data</td>
          <td><code>aws ec2 describe-instance-attribute --attribute userData</code></td>
      </tr>
      <tr>
          <td>Lambda 環境變數</td>
          <td><code>aws lambda get-function-configuration --function-name NAME</code></td>
      </tr>
      <tr>
          <td>SSM Parameter Store</td>
          <td><code>aws ssm get-parameters-by-path --path / --recursive</code></td>
      </tr>
      <tr>
          <td>開發者筆電</td>
          <td><code>~/.aws/credentials</code> — 需要口頭確認</td>
      </tr>
      <tr>
          <td>程式碼 repo</td>
          <td><code>git log --all -p | grep AKIA</code> — AKIA 是 access key 的固定前綴</td>
      </tr>
      <tr>
          <td>Slack / email 歷史</td>
          <td>無法自動掃描，靠團隊回報</td>
      </tr>
  </tbody>
</table>
<p>對每把要輪替的 key，在以上位置逐一確認。找不到用途的 key 可以先停用觀察（而非直接刪除），停用後如果有服務壞了就知道它用在哪裡。</p>
<h2 id="輪替步驟五步流程">輪替步驟：五步流程</h2>
<p>輪替一把 key 的標準流程分五步，順序不能跳：</p>
<h3 id="第一步建立新-key">第一步：建立新 key</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws iam create-access-key --user-name deploy-bot</span></span></code></pre></div><p>輸出會包含新的 AccessKeyId 和 SecretAccessKey。SecretAccessKey 只在這一刻顯示一次，存進密碼管理器或 Secrets Manager，不要貼在 Slack 或 email 裡。</p>
<p>一個 IAM user 最多同時有兩把 key。如果已經有兩把，需要先刪除一把不用的才能建新的。</p>
<h3 id="第二步更新所有消費者">第二步：更新所有消費者</h3>
<p>把新 key 部署到上一節識別出的所有存放位置。CI 變數、Lambda 環境變數、SSM Parameter Store、開發者的 <code>~/.aws/credentials</code> 都要同步更新。</p>
<p>每更新一個消費者就做一次功能驗證——CI 跑一次 pipeline、Lambda 觸發一次、開發者跑一次 <code>aws sts get-caller-identity</code> 確認新 key 能用。</p>
<h3 id="第三步驗證新-key-生效">第三步：驗證新 key 生效</h3>
<p>所有消費者更新完後，等待一個完整的業務週期（至少 24 小時），確認沒有任何服務還在用舊 key。檢查方式是看舊 key 的 <code>LastUsedDate</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">aws iam get-access-key-last-used --access-key-id AKIAOLD12345</span></span></code></pre></div><p>如果 <code>LastUsedDate</code> 在你更新消費者之後仍有新的使用紀錄，代表有漏網的消費者還在用舊 key。</p>
<h3 id="第四步停用舊-key">第四步：停用舊 key</h3>
<p>確認無殘留使用後，停用（不是刪除）舊 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 update-access-key <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --user-name deploy-bot <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --access-key-id AKIAOLD12345 <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --status Inactive</span></span></code></pre></div><p>停用是安全的中間狀態——用到這把 key 的服務會開始報 <code>InvalidClientTokenId</code> 錯誤，但 key 還在、可以隨時重新啟用。如果停用後有意料之外的服務壞了，重新啟用就能立刻恢復。</p>
<h3 id="第五步寬限期後刪除">第五步：寬限期後刪除</h3>
<p>停用後保持 7-14 天的寬限期。這段時間是「如果有漏掉的消費者」的安全網。寬限期內無異常，刪除：</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 delete-access-key <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --user-name deploy-bot <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --access-key-id AKIAOLD12345</span></span></code></pre></div><p>刪除後不可回復。如果有服務還在用這把 key，只能建一把新 key 然後去更新那個服務。</p>
<h2 id="自動化輪替secrets-manager">自動化輪替：Secrets Manager</h2>
<p>手動輪替的瓶頸在「找到所有消費者」這一步。如果 key 的消費者都從 Secrets Manager 讀取（而非各自存一份副本），輪替就簡化成「在 Secrets Manager 裡更新值」——所有消費者下次讀取時自動拿到新 key。</p>
<p>Secrets Manager 支援自動輪替：設定一個 Lambda function 作為 rotation function，它負責建新 key → 更新 secret value → 停用舊 key 的全流程。</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_secretsmanager_secret&#34; &#34;deploy_key&#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;prod/deploy-bot/access-key&#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_secretsmanager_secret_rotation&#34; &#34;deploy_key&#34;</span> {
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  secret_id</span>           <span class="o">=</span> <span class="k">aws_secretsmanager_secret</span><span class="p">.</span><span class="k">deploy_key</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="n">  rotation_lambda_arn</span> <span class="o">=</span> <span class="k">aws_lambda_function</span><span class="p">.</span><span class="k">key_rotator</span><span class="p">.</span><span class="k">arn</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">rotation_rules</span> {
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">    automatically_after_days</span> <span class="o">=</span> <span class="m">90</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></code></pre></div><p>自動輪替的前提是所有消費者都改成從 Secrets Manager 讀 key，而非從環境變數或設定檔。這個前提本身就是一次 migration——跟手動輪替的固定成本（盤點 + 更新 + 驗證）相比，migration 的一次性成本更高，但之後的每次輪替接近零成本。</p>
<p>判斷該不該投入自動化的依據是 key 的數量和輪替頻率。3 把 key、每季輪替一次，手動流程 2-3 小時可以完成，自動化的 ROI 不高。10 把以上、或合規要求 30 天輪替，手動已經吃掉固定的工程師時間，自動化的投入才有回報。</p>
<h2 id="key-age-監控">Key age 監控</h2>
<p>輪替做完不代表可以不管——如果沒有監控，三個月後又會回到「不知道有幾把超齡的 key」的狀態。</p>
<p>最低成本的監控是一條定期跑的 check，掃描所有 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"><span class="c1"># 列出所有超過 90 天的 active key（用 AWS Config 規則更可靠）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws configservice put-config-rule --config-rule <span class="s1">&#39;{
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s1">  &#34;ConfigRuleName&#34;: &#34;access-keys-rotated&#34;,
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s1">  &#34;Source&#34;: {
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s1">    &#34;Owner&#34;: &#34;AWS&#34;,
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s1">    &#34;SourceIdentifier&#34;: &#34;ACCESS_KEYS_ROTATED&#34;
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="s1">  },
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="s1">  &#34;InputParameters&#34;: &#34;{\&#34;maxAccessKeyAge\&#34;:\&#34;90\&#34;}&#34;
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="s1">}&#39;</span></span></span></code></pre></div><p>AWS Config 的 <code>ACCESS_KEYS_ROTATED</code> managed rule 會持續掃描所有 IAM user 的 key age，超過設定天數的標記為 non-compliant。把 Config 的 non-compliant 事件接到 SNS → Slack 或 email，就有了持續的 key 超齡告警。</p>
<p>Prowler 也提供 key age 檢查（<code>prowler aws --checks access_key_1_rotated</code>），適合當一次性掃描工具。Config rule 適合持續監控。</p>
<p>管理層報告可以用 Config 的 compliance dashboard：compliant key 數 / 總 key 數 = key rotation 覆蓋率，這個百分比適合放進月報。</p>
<p>IAM Access Analyzer 的 unused access 功能（需啟用 analyzer）可以持續掃描帳號內未使用的 key 和 permission，跟 Config rule 互補——Config 看 key age，Access Analyzer 看 key 是否被使用。兩者搭配可以同時回答「這把 key 多久沒輪替」和「這把 key 有沒有在用」。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">身分與憑證地基</a>：access key 風險的系統性分析、OIDC 作為長期 key 的替代方案</li>
<li>→ <a href="/blog/infra/02-identity-credentials/team-access-management/" data-link-title="團隊權限分級與存取管理" data-link-desc="用 admin / operator / viewer 三級劃分團隊成員的雲端操作權限，設計臨時提權流程、定期 access review 節奏，以及 contractor 與外部 vendor 的存取邊界">團隊權限分級與存取管理</a>：離職時的 key 撤銷流程</li>
<li>→ <a href="/blog/infra/08-governance-habits/tagging-secrets/" data-link-title="Tagging 規範與 Secrets 不進 code" data-link-desc="tag 讓資源可盤點、可清理、可歸屬；密鑰存在專用服務裡而非 code 或 state，兩者都屬於 day-1 就該立的治理地基">治理好習慣</a>：secret 的儲存與引用紀律</li>
</ul>
]]></content:encoded></item><item><title>VPC（Virtual Private Cloud）</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/vpc/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/vpc/</guid><description>&lt;p>VPC（Virtual Private Cloud）是雲端帳號內的一塊邏輯隔離私有網段，是其餘所有網路切分的起點。在 VPC 裡開出來的所有資源預設只看得到同一個 VPC 內的成員，與其他 VPC、與其他帳號的網路天然隔離。沒有 VPC，&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">subnet&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">security group&lt;/a> 無處依附。&lt;/p>
&lt;p>VPC 用 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/cidr/" data-link-title="CIDR（Classless Inter-Domain Routing）" data-link-desc="用前綴長度表示 IP 地址範圍的表示法，決定 VPC 與 subnet 的地址空間大小">CIDR&lt;/a> 區塊定義地址空間。建立時的 CIDR 大小是一次性決策——事後擴張地址空間在多數雲端平台上是麻煩且容易出錯的操作（AWS 允許追加 secondary CIDR，但追加的網段在 routing 與服務相容性上有限制）。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>VPC 是&lt;a href="https://tarrragon.github.io/blog/infra/03-network-foundation/vpc-subnet-security-group/" data-link-title="網路地基 — VPC、subnet 分層與 security group 設計" data-link-desc="VPC CIDR 規劃、public / private subnet 切分、route table 與 NAT 的可用性成本取捨、security group 最小開放設計，以及 NACL 的定位">模組三：網路地基&lt;/a>的最外層邊界。Infra 系列的網路設計從 VPC 開始：先圈定地址空間，再往內切 subnet、掛 route table、設 security group。環境之間的 VPC 怎麼分（每個環境一個 VPC），屬於&lt;a href="https://tarrragon.github.io/blog/infra/04-environment-separation/directory-module-parameterization/" data-link-title="環境分離與模組化 — 目錄結構、module 參數化與 retrofit 路徑" data-link-desc="用目錄結構在第一天就隔開 dev 與 prod 的 state，用 module 讓環境共用同一套邏輯只差參數，以及已經單環境跑起來後怎麼安全拆分">模組四：環境分離&lt;/a>的設計決策。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>VPC 設計需要關注的訊號：CIDR 空間快用完（subnet 切不出新的子網段）、需要跟其他 VPC 或地端互連時發現 CIDR 重疊（peering 無法建立）、服務被放在預設 VPC 裡（預設 VPC 是所有人共享的、CIDR 不可控的、security group 預設全通的）。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>規劃 VPC 時要決定：&lt;/p>
&lt;ul>
&lt;li>CIDR 大小：&lt;code>/16&lt;/code> 提供約六萬五千個位址，對多數單一環境足夠&lt;/li>
&lt;li>不重疊：多個 VPC（不同環境或產品線）用連續但不重疊的大段分配&lt;/li>
&lt;li>DNS 設定：&lt;code>enable_dns_support&lt;/code> 和 &lt;code>enable_dns_hostnames&lt;/code> 在多數場景都該開啟&lt;/li>
&lt;li>預設 VPC 的處理：正式服務不該放在預設 VPC，新帳號的預設 VPC 可以刪除或保留唯讀&lt;/li>
&lt;/ul>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">Subnet&lt;/a> — VPC 內按可用區與暴露程度切出的子網段&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group&lt;/a> — 掛在資源上的有狀態防火牆&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/cidr/" data-link-title="CIDR（Classless Inter-Domain Routing）" data-link-desc="用前綴長度表示 IP 地址範圍的表示法，決定 VPC 與 subnet 的地址空間大小">CIDR&lt;/a> — VPC 的地址空間定義方式&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="讓 private subnet 的資源主動對外連線、同時不被外部入站觸及的網路地址轉換服務">NAT&lt;/a> — 讓 private subnet 出站的地址轉換機制&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>VPC（Virtual Private Cloud）是雲端帳號內的一塊邏輯隔離私有網段，是其餘所有網路切分的起點。在 VPC 裡開出來的所有資源預設只看得到同一個 VPC 內的成員，與其他 VPC、與其他帳號的網路天然隔離。沒有 VPC，<a href="/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">subnet</a> 與 <a href="/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">security group</a> 無處依附。</p>
<p>VPC 用 <a href="/blog/infra/knowledge-cards/cidr/" data-link-title="CIDR（Classless Inter-Domain Routing）" data-link-desc="用前綴長度表示 IP 地址範圍的表示法，決定 VPC 與 subnet 的地址空間大小">CIDR</a> 區塊定義地址空間。建立時的 CIDR 大小是一次性決策——事後擴張地址空間在多數雲端平台上是麻煩且容易出錯的操作（AWS 允許追加 secondary CIDR，但追加的網段在 routing 與服務相容性上有限制）。</p>
<h2 id="概念位置">概念位置</h2>
<p>VPC 是<a href="/blog/infra/03-network-foundation/vpc-subnet-security-group/" data-link-title="網路地基 — VPC、subnet 分層與 security group 設計" data-link-desc="VPC CIDR 規劃、public / private subnet 切分、route table 與 NAT 的可用性成本取捨、security group 最小開放設計，以及 NACL 的定位">模組三：網路地基</a>的最外層邊界。Infra 系列的網路設計從 VPC 開始：先圈定地址空間，再往內切 subnet、掛 route table、設 security group。環境之間的 VPC 怎麼分（每個環境一個 VPC），屬於<a href="/blog/infra/04-environment-separation/directory-module-parameterization/" data-link-title="環境分離與模組化 — 目錄結構、module 參數化與 retrofit 路徑" data-link-desc="用目錄結構在第一天就隔開 dev 與 prod 的 state，用 module 讓環境共用同一套邏輯只差參數，以及已經單環境跑起來後怎麼安全拆分">模組四：環境分離</a>的設計決策。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>VPC 設計需要關注的訊號：CIDR 空間快用完（subnet 切不出新的子網段）、需要跟其他 VPC 或地端互連時發現 CIDR 重疊（peering 無法建立）、服務被放在預設 VPC 裡（預設 VPC 是所有人共享的、CIDR 不可控的、security group 預設全通的）。</p>
<h2 id="設計責任">設計責任</h2>
<p>規劃 VPC 時要決定：</p>
<ul>
<li>CIDR 大小：<code>/16</code> 提供約六萬五千個位址，對多數單一環境足夠</li>
<li>不重疊：多個 VPC（不同環境或產品線）用連續但不重疊的大段分配</li>
<li>DNS 設定：<code>enable_dns_support</code> 和 <code>enable_dns_hostnames</code> 在多數場景都該開啟</li>
<li>預設 VPC 的處理：正式服務不該放在預設 VPC，新帳號的預設 VPC 可以刪除或保留唯讀</li>
</ul>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">Subnet</a> — VPC 內按可用區與暴露程度切出的子網段</li>
<li><a href="/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group</a> — 掛在資源上的有狀態防火牆</li>
<li><a href="/blog/infra/knowledge-cards/cidr/" data-link-title="CIDR（Classless Inter-Domain Routing）" data-link-desc="用前綴長度表示 IP 地址範圍的表示法，決定 VPC 與 subnet 的地址空間大小">CIDR</a> — VPC 的地址空間定義方式</li>
<li><a href="/blog/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="讓 private subnet 的資源主動對外連線、同時不被外部入站觸及的網路地址轉換服務">NAT</a> — 讓 private subnet 出站的地址轉換機制</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/upgrade/database-major-upgrade/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/upgrade/database-major-upgrade/</guid><description>&lt;p>資料庫大版本升級是所有升級類型中風險最高的一種，因為資料庫承載的是不可重建的狀態。Runtime 升級（PHP 5.6→8.x）改壞了可以切回舊版本重新部署（切換 PHP 版本即可回退）；平台遷移（共享主機→雲端）改壞了可以把 DNS 切回去（TTL 期間內生效）。資料庫升級改壞了，回退手段是從備份還原——而還原需要時間，還原期間服務不可用，且還原點之後的寫入會遺失。這個不對稱決定了資料庫升級的操作模式：每一步都需要驗證通過才進下一步，且每一步都有明確的回退路徑。&lt;/p>
&lt;h2 id="升級前的相容性評估">升級前的相容性評估&lt;/h2>
&lt;p>大版本升級不只是換一個二進位檔——新版本可能改變 SQL 行為、儲存格式、認證方式與預設值。在動任何生產資源之前，先在本地或測試環境把相容性問題找出來。&lt;/p>
&lt;h3 id="mysql-57--80-的常見破壞性變更">MySQL 5.7 → 8.0 的常見破壞性變更&lt;/h3>
&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;code>GROUP BY&lt;/code> 隱式排序移除&lt;/td>
 &lt;td>依賴 &lt;code>GROUP BY&lt;/code> 順序的查詢結果可能改變&lt;/td>
 &lt;td>搜尋沒有 &lt;code>ORDER BY&lt;/code> 的 &lt;code>GROUP BY&lt;/code> 查詢&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>預設字元集 utf8 → utf8mb4&lt;/td>
 &lt;td>欄位長度與索引大小計算改變，索引可能超過限制&lt;/td>
 &lt;td>檢查 &lt;code>VARCHAR(255)&lt;/code> + 唯一索引的欄位&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>認證方式改為 caching_sha2&lt;/td>
 &lt;td>舊版 client / driver 可能無法連線&lt;/td>
 &lt;td>確認應用程式的 MySQL driver 版本支援 caching_sha2_password&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>保留字新增（RANK、ROW_NUMBER）&lt;/td>
 &lt;td>用這些字當欄位名或別名的查詢會報語法錯&lt;/td>
 &lt;td>&lt;code>grep -rn &amp;quot;RANK|ROW_NUMBER|GROUPS|CUME_DIST&amp;quot; --include=&amp;quot;*.sql&amp;quot;&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>JSON 函式行為變更&lt;/td>
 &lt;td>&lt;code>JSON_MERGE&lt;/code> 改名為 &lt;code>JSON_MERGE_PRESERVE&lt;/code>、行為語意不同&lt;/td>
 &lt;td>搜尋 &lt;code>JSON_MERGE&lt;/code> 呼叫&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="postgresql-大版本升級的檢查點">PostgreSQL 大版本升級的檢查點&lt;/h3>
&lt;p>PostgreSQL 的大版本升級相對穩定，但仍有需要確認的項目：extension 版本是否跟新 PostgreSQL 版本相容（特別是 PostGIS、pg_partman、timescaledb 這類複雜 extension）、&lt;code>pg_upgrade&lt;/code> 的 &lt;code>--check&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="c1"># PostgreSQL: 升級前 dry-run 檢查&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">pg_upgrade --old-datadir /var/lib/postgresql/13/main &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> --new-datadir /var/lib/postgresql/16/main &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> --old-bindir /usr/lib/postgresql/13/bin &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --new-bindir /usr/lib/postgresql/16/bin &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --check&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="應用程式層的查詢相容性">應用程式層的查詢相容性&lt;/h3>
&lt;p>把應用程式的所有 SQL 查詢（ORM 產生的也算）對新版本跑一遍。重點是行為變更而非語法錯誤——語法錯誤會立刻報錯、容易抓；行為變更（排序結果不同、型別轉換規則不同）不會報錯、但結果錯誤。&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"># MySQL 升級前檢查工具&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">mysqlcheck --all-databases --check-upgrade
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">mysql_upgrade --upgrade-system-tables --dry-run&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>ORM 和 database driver 也要確認版本支援。PHP 的 &lt;code>mysqli&lt;/code> 在 PHP 7.4+ 預設支援 caching_sha2_password、但舊版不支援。Node.js 的 &lt;code>mysql2&lt;/code> 原生支援、但 &lt;code>mysql&lt;/code>（舊套件）不支援。Python 的 &lt;code>mysqlclient&lt;/code> 1.4+ 支援。&lt;/p>
&lt;h2 id="備份升級前的保險">備份：升級前的保險&lt;/h2>
&lt;p>升級前的備份不是日常備份——它是一份明確的、經過驗證的、標記為「升級前保險點」的快照。&lt;/p>
&lt;h3 id="備份操作">備份操作&lt;/h3>





&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"># MySQL: 完整 dump（InnoDB 用 --single-transaction 避免鎖表）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">mysqldump --all-databases --single-transaction --routines --triggers &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> --set-gtid-purged&lt;span class="o">=&lt;/span>OFF &amp;gt; pre-upgrade-&lt;span class="k">$(&lt;/span>date +%Y%m%d-%H%M&lt;span class="k">)&lt;/span>.sql
&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"># PostgreSQL: 完整 dump&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">pg_dumpall &amp;gt; pre-upgrade-&lt;span class="k">$(&lt;/span>date +%Y%m%d-%H%M&lt;span class="k">)&lt;/span>.sql&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>RDS 環境：在升級操作前手動建立 snapshot，而非依賴自動備份。自動備份在升級過程中可能被新的快照覆蓋，手動 snapshot 不會被自動清除。&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 create-db-snapshot &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 mydb-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> --db-snapshot-identifier pre-upgrade-&lt;span class="k">$(&lt;/span>date +%Y%m%d&lt;span class="k">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="備份驗證">備份驗證&lt;/h3>
&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"># 還原到測試實例&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">mysql -h test-instance -u admin -p &amp;lt; pre-upgrade-20260626-1400.sql
&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"># 驗證關鍵表的 row count&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">mysql -h test-instance -e &lt;span class="s2">&amp;#34;SELECT COUNT(*) FROM orders; SELECT COUNT(*) FROM users;&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>記錄還原時間：「從這份備份還原到可服務狀態需要 N 分鐘/小時」。這個數字是升級失敗時的停機時間下限——管理層需要這個數字來評估升級的風險。&lt;/p></description><content:encoded><![CDATA[<p>資料庫大版本升級是所有升級類型中風險最高的一種，因為資料庫承載的是不可重建的狀態。Runtime 升級（PHP 5.6→8.x）改壞了可以切回舊版本重新部署（切換 PHP 版本即可回退）；平台遷移（共享主機→雲端）改壞了可以把 DNS 切回去（TTL 期間內生效）。資料庫升級改壞了，回退手段是從備份還原——而還原需要時間，還原期間服務不可用，且還原點之後的寫入會遺失。這個不對稱決定了資料庫升級的操作模式：每一步都需要驗證通過才進下一步，且每一步都有明確的回退路徑。</p>
<h2 id="升級前的相容性評估">升級前的相容性評估</h2>
<p>大版本升級不只是換一個二進位檔——新版本可能改變 SQL 行為、儲存格式、認證方式與預設值。在動任何生產資源之前，先在本地或測試環境把相容性問題找出來。</p>
<h3 id="mysql-57--80-的常見破壞性變更">MySQL 5.7 → 8.0 的常見破壞性變更</h3>
<table>
  <thead>
      <tr>
          <th>變更項</th>
          <th>影響</th>
          <th>檢查方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>GROUP BY</code> 隱式排序移除</td>
          <td>依賴 <code>GROUP BY</code> 順序的查詢結果可能改變</td>
          <td>搜尋沒有 <code>ORDER BY</code> 的 <code>GROUP BY</code> 查詢</td>
      </tr>
      <tr>
          <td>預設字元集 utf8 → utf8mb4</td>
          <td>欄位長度與索引大小計算改變，索引可能超過限制</td>
          <td>檢查 <code>VARCHAR(255)</code> + 唯一索引的欄位</td>
      </tr>
      <tr>
          <td>認證方式改為 caching_sha2</td>
          <td>舊版 client / driver 可能無法連線</td>
          <td>確認應用程式的 MySQL driver 版本支援 caching_sha2_password</td>
      </tr>
      <tr>
          <td>保留字新增（RANK、ROW_NUMBER）</td>
          <td>用這些字當欄位名或別名的查詢會報語法錯</td>
          <td><code>grep -rn &quot;RANK|ROW_NUMBER|GROUPS|CUME_DIST&quot; --include=&quot;*.sql&quot;</code></td>
      </tr>
      <tr>
          <td>JSON 函式行為變更</td>
          <td><code>JSON_MERGE</code> 改名為 <code>JSON_MERGE_PRESERVE</code>、行為語意不同</td>
          <td>搜尋 <code>JSON_MERGE</code> 呼叫</td>
      </tr>
  </tbody>
</table>
<h3 id="postgresql-大版本升級的檢查點">PostgreSQL 大版本升級的檢查點</h3>
<p>PostgreSQL 的大版本升級相對穩定，但仍有需要確認的項目：extension 版本是否跟新 PostgreSQL 版本相容（特別是 PostGIS、pg_partman、timescaledb 這類複雜 extension）、<code>pg_upgrade</code> 的 <code>--check</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"># PostgreSQL: 升級前 dry-run 檢查</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">pg_upgrade --old-datadir /var/lib/postgresql/13/main <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>           --new-datadir /var/lib/postgresql/16/main <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>           --old-bindir /usr/lib/postgresql/13/bin <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>           --new-bindir /usr/lib/postgresql/16/bin <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>           --check</span></span></code></pre></div><h3 id="應用程式層的查詢相容性">應用程式層的查詢相容性</h3>
<p>把應用程式的所有 SQL 查詢（ORM 產生的也算）對新版本跑一遍。重點是行為變更而非語法錯誤——語法錯誤會立刻報錯、容易抓；行為變更（排序結果不同、型別轉換規則不同）不會報錯、但結果錯誤。</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"># MySQL 升級前檢查工具</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">mysqlcheck --all-databases --check-upgrade
</span></span><span class="line"><span class="ln">3</span><span class="cl">mysql_upgrade --upgrade-system-tables --dry-run</span></span></code></pre></div><p>ORM 和 database driver 也要確認版本支援。PHP 的 <code>mysqli</code> 在 PHP 7.4+ 預設支援 caching_sha2_password、但舊版不支援。Node.js 的 <code>mysql2</code> 原生支援、但 <code>mysql</code>（舊套件）不支援。Python 的 <code>mysqlclient</code> 1.4+ 支援。</p>
<h2 id="備份升級前的保險">備份：升級前的保險</h2>
<p>升級前的備份不是日常備份——它是一份明確的、經過驗證的、標記為「升級前保險點」的快照。</p>
<h3 id="備份操作">備份操作</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># MySQL: 完整 dump（InnoDB 用 --single-transaction 避免鎖表）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">mysqldump --all-databases --single-transaction --routines --triggers <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --set-gtid-purged<span class="o">=</span>OFF &gt; pre-upgrade-<span class="k">$(</span>date +%Y%m%d-%H%M<span class="k">)</span>.sql
</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"># PostgreSQL: 完整 dump</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">pg_dumpall &gt; pre-upgrade-<span class="k">$(</span>date +%Y%m%d-%H%M<span class="k">)</span>.sql</span></span></code></pre></div><p>RDS 環境：在升級操作前手動建立 snapshot，而非依賴自動備份。自動備份在升級過程中可能被新的快照覆蓋，手動 snapshot 不會被自動清除。</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 create-db-snapshot <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --db-instance-identifier mydb-prod <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --db-snapshot-identifier pre-upgrade-<span class="k">$(</span>date +%Y%m%d<span class="k">)</span></span></span></code></pre></div><h3 id="備份驗證">備份驗證</h3>
<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"># 還原到測試實例</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">mysql -h test-instance -u admin -p &lt; pre-upgrade-20260626-1400.sql
</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"># 驗證關鍵表的 row count</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">mysql -h test-instance -e <span class="s2">&#34;SELECT COUNT(*) FROM orders; SELECT COUNT(*) FROM users;&#34;</span></span></span></code></pre></div><p>記錄還原時間：「從這份備份還原到可服務狀態需要 N 分鐘/小時」。這個數字是升級失敗時的停機時間下限——管理層需要這個數字來評估升級的風險。</p>
<h2 id="平行驗證策略">平行驗證策略</h2>
<p>在生產環境切換之前，先在新版本的平行環境上跑完所有驗證。平行驗證的目標是讓切換那一刻的風險降到最低——切換時已經知道新版本在相同資料和相同負載下的行為。</p>
<h3 id="建立平行環境">建立平行環境</h3>
<table>
  <thead>
      <tr>
          <th>方式</th>
          <th>適用情境</th>
          <th>資料同步方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Read replica + 版本升級</td>
          <td>RDS 環境、支援跨版本 replica</td>
          <td>RDS 原生複寫</td>
      </tr>
      <tr>
          <td>Logical replication</td>
          <td>需要跨大版本</td>
          <td>pg_logical / binlog → 新實例</td>
      </tr>
      <tr>
          <td>Dump / restore</td>
          <td>任何環境、資料量可控</td>
          <td>一次性 dump + 增量 binlog 回放</td>
      </tr>
  </tbody>
</table>
<h3 id="驗證項目">驗證項目</h3>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>方法</th>
          <th>通過標準</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>應用程式測試套件</td>
          <td>對新版本實例跑完整測試</td>
          <td>0 failure</td>
      </tr>
      <tr>
          <td>查詢效能</td>
          <td>對比兩個版本的 slow query log</td>
          <td>p99 延遲無顯著退化（&lt;10% 差異）</td>
      </tr>
      <tr>
          <td>資料一致性</td>
          <td>關鍵表 row count + checksum</td>
          <td>完全一致</td>
      </tr>
      <tr>
          <td>連線行為</td>
          <td>應用程式連新版本、觀察連線池</td>
          <td>無 authentication failure</td>
      </tr>
      <tr>
          <td>備份還原</td>
          <td>從新版本做一次 dump + restore</td>
          <td>還原成功、資料完整</td>
      </tr>
  </tbody>
</table>
<p>平行驗證至少跑一週。時間越長、覆蓋到的邊界情境越多——月結批次、週期性報表、低頻排程任務都可能觸發只在特定條件下才出現的相容性問題。</p>
<h2 id="切換策略">切換策略</h2>
<p>切換策略的選擇取決於三個變數的取捨：操作複雜度、停機時間、回退速度。</p>
<h3 id="in-place-升級">In-place 升級</h3>
<p>直接在原實例上升級版本。RDS 的操作是修改 engine version、等待升級完成。</p>
<ul>
<li><strong>停機</strong>：升級期間實例不可用（MySQL 5.7→8.0 在 RDS 上約 10-30 分鐘，視資料量而定）</li>
<li><strong>回退</strong>：從 pre-upgrade snapshot 還原，需要 snapshot restore 時間（分鐘到小時級）</li>
<li><strong>適用</strong>：可接受計畫性停機的環境、資料量不大</li>
</ul>
<h3 id="blue-green-切換">Blue-green 切換</h3>
<p>在新版本上建立獨立實例、透過 replication 同步資料、切換應用程式的連線端點。</p>
<ul>
<li><strong>停機</strong>：接近零（DNS TTL 或 endpoint 切換的傳播時間）</li>
<li><strong>回退</strong>：把連線端點切回舊實例，舊實例持續運行</li>
<li><strong>複雜度</strong>：需要維護兩個實例的同步、切換時要處理複寫延遲</li>
<li><strong>適用</strong>：不能接受停機的 production 環境</li>
</ul>
<p>RDS 從 2022 年開始提供原生的 Blue/Green Deployments 功能，簡化了同步與切換的操作：</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 create-blue-green-deployment <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --blue-green-deployment-name mydb-upgrade <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --source arn:aws:rds:ap-northeast-1:123456789012:db:mydb-prod <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --target-engine-version 8.0.35</span></span></code></pre></div><h3 id="read-replica-升級後提升">Read replica 升級後提升</h3>
<p>建立指定新版本的 read replica，replica 同步完成後提升為獨立實例，應用程式切換連線。</p>
<ul>
<li><strong>停機</strong>：提升 replica 的幾秒 + 連線切換</li>
<li><strong>回退</strong>：舊 primary 仍在，切回即可</li>
<li><strong>限制</strong>：不是所有版本組合都支援跨版本 replica</li>
</ul>
<h3 id="選型判準">選型判準</h3>
<table>
  <thead>
      <tr>
          <th>考量</th>
          <th>In-place</th>
          <th>Blue-green</th>
          <th>Replica 提升</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>操作複雜度</td>
          <td>低</td>
          <td>中</td>
          <td>中</td>
      </tr>
      <tr>
          <td>停機時間</td>
          <td>10-30 分鐘</td>
          <td>接近零</td>
          <td>幾秒</td>
      </tr>
      <tr>
          <td>回退速度</td>
          <td>慢（snapshot restore）</td>
          <td>快（切回舊端點）</td>
          <td>快（切回舊 primary）</td>
      </tr>
      <tr>
          <td>成本</td>
          <td>最低</td>
          <td>升級期間雙倍</td>
          <td>升級期間雙倍</td>
      </tr>
  </tbody>
</table>
<h2 id="升級後的驗證與監控">升級後的驗證與監控</h2>
<p>切換完成後的 48-72 小時是觀察期。這段時間舊實例保持可用狀態，直到確認新版本穩定才退役。</p>
<h3 id="切換後立即驗證">切換後立即驗證</h3>
<ol>
<li>應用程式的所有關鍵路徑可正常操作（登入、查詢、寫入、交易）</li>
<li>連線池行為正常（沒有持續的 authentication failure 或 connection reset）</li>
<li>排程任務（cron job、背景 worker）正常連線並執行</li>
</ol>
<h3 id="效能監控">效能監控</h3>
<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"># 觀察升級後的 slow query 數量</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">mysql -e <span class="s2">&#34;SHOW GLOBAL STATUS LIKE &#39;Slow_queries&#39;;&#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="c1"># 比較 p99 延遲（需要 application-level metrics）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># CloudWatch: DBInstanceIdentifier → ReadLatency, WriteLatency</span></span></span></code></pre></div><p>升級後效能退化的常見原因：optimizer 行為改變（新版本選了不同的執行計畫）、buffer pool 冷啟動（升級後快取是空的、前幾小時延遲偏高是正常的）。如果 48 小時後延遲仍未回到基線，檢查 slow query log 找出退化的具體查詢。</p>
<h3 id="舊實例退役">舊實例退役</h3>
<p>觀察期結束、新版本確認穩定後：</p>
<ol>
<li>停止舊實例的 replication（如果仍在同步）</li>
<li>保留舊實例的 final snapshot</li>
<li>刪除舊實例（先確認 deletion protection 關閉是刻意的、不是誤操作）</li>
<li>更新文件：記錄升級日期、版本號、升級過程中遇到的問題</li>
</ol>
<h2 id="時程與管理層溝通">時程與管理層溝通</h2>
<table>
  <thead>
      <tr>
          <th>升級類型</th>
          <th>典型時程</th>
          <th>停機窗口</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Minor version（5.7.x → 5.7.y）</td>
          <td>2-4 小時計畫維護</td>
          <td>10-15 分鐘</td>
      </tr>
      <tr>
          <td>Major version（5.7 → 8.0）in-place</td>
          <td>1-2 週（評估 + 驗證 + 切換 + 監控）</td>
          <td>10-30 分鐘</td>
      </tr>
      <tr>
          <td>Major version blue-green</td>
          <td>2-3 週（含平行運行期）</td>
          <td>接近零</td>
      </tr>
  </tbody>
</table>
<p>向管理層說明時的關鍵框架：資料是不可重建的，升級策略是「在旁邊建一個新版本的資料庫、驗證它在相同資料和相同負載下行為正確、然後切過去」。多出來的時間買的是「切換那一刻的信心」和「出問題時能快速回退」——兩者對生產服務都是必要的保險。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/upgrade/upgrade-framework/" data-link-title="升級的共通操作框架" data-link-desc="任何環境或系統升級的四階段模型：差異評估、平行環境驗證、分批切換、退役舊環境，以及貫穿全程的升級紀律">升級的共通操作框架</a>：四階段模型的通用說明</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>：multi-AZ、備份、deletion protection 的 IaC 描述</li>
<li>→ <a href="/blog/infra/takeover/legacy-database-backup-migration/" data-link-title="無 SSH 環境的資料庫備份與變更管理" data-link-desc="在只有 phpMyAdmin 或有限遠端連線的無 SSH 環境裡，怎麼建立可靠的資料庫備份策略、schema 變更紀律與還原演練流程">無 SSH 環境的資料庫備份與變更管理</a>：接手環境的資料庫備份策略</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>斷網環境的監控與可觀測性</title><link>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-monitoring/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-monitoring/</guid><description>&lt;p>斷網環境不能用 Datadog、New Relic、Sentry Cloud、PagerDuty Cloud 這些 SaaS 監控服務——它們全部需要往外發送資料。監控的三個核心能力（metric 收集、log 彙整、告警通知）全部要用 self-hosted 的開源工具在隔離網路內搭建。原則跟連網環境相同（metric 跟資源同生命週期、alarm 要連到動作），差別在工具的部署和儲存規劃要自己管。&lt;/p>
&lt;h2 id="metric-收集prometheus--grafana">Metric 收集：Prometheus + Grafana&lt;/h2>
&lt;p>Prometheus 是 pull-based 的 metric 收集系統——它主動去 scrape 各服務的 metric endpoint，不需要服務往外推資料。這個架構天然適合斷網：所有流量都在內網、不需要出站連線。&lt;/p>
&lt;h3 id="離線安裝">離線安裝&lt;/h3>
&lt;p>Prometheus 和 Grafana 都是單一二進位或容器映像，離線安裝跟&lt;a href="https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-container/" data-link-title="斷網環境的容器與映像管理" data-link-desc="Private registry 架設、映像搬運（docker save/load、skopeo）、base image 更新週期、離線漏洞掃描">映像搬運&lt;/a>相同的流程：&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"># 外部：下載 release binary&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">wget https://github.com/prometheus/prometheus/releases/download/v2.53.0/prometheus-2.53.0.linux-amd64.tar.gz
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">wget https://dl.grafana.com/oss/release/grafana-11.1.0.linux-amd64.tar.gz
&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"># 搬運後解壓、設定 systemd service&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">tar xzf prometheus-2.53.0.linux-amd64.tar.gz
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">sudo mv prometheus-2.53.0.linux-amd64 /opt/prometheus&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果用容器部署，先把映像搬進內部 registry 再 pull：&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"># 內部：從內部 registry 啟動&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">docker run -d -p 9090:9090 &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> -v /etc/prometheus:/etc/prometheus &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> -v /data/prometheus:/prometheus &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> registry.internal:5000/prometheus:v2.53.0&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="scrape-設定">Scrape 設定&lt;/h3>
&lt;p>Prometheus 的 &lt;code>prometheus.yml&lt;/code> 定義要 scrape 的目標。斷網環境通常用 static config（手動列出目標）而非 service discovery（需要雲端 API）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="nt">scrape_configs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">job_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;node-exporter&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">static_configs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">targets&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s1">&amp;#39;server-01:9100&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s1">&amp;#39;server-02:9100&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s1">&amp;#39;db-01:9100&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">job_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;app&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">static_configs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">targets&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s1">&amp;#39;app-01:8080&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s1">&amp;#39;app-02:8080&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metrics_path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;/metrics&amp;#39;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>新增機器時手動把它加進 targets 清單。如果用 Consul（內網 service discovery），Prometheus 支援 Consul SD、可以自動發現新服務。&lt;/p>
&lt;h3 id="node-exporter">Node Exporter&lt;/h3>
&lt;p>每台需要監控的 Linux 機器裝一個 node_exporter（單一二進位、無依賴），暴露 CPU、記憶體、磁碟、網路等系統 metric。離線安裝同理——下載 binary、搬運、解壓、設成 service。&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">tar xzf node_exporter-1.8.1.linux-amd64.tar.gz
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">sudo cp node_exporter-1.8.1.linux-amd64/node_exporter /usr/local/bin/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">sudo useradd --no-create-home --shell /bin/false node_exporter
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1"># 建立 systemd service（略）&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="log-收集loki-或-elk">Log 收集：Loki 或 ELK&lt;/h2>
&lt;h3 id="grafana-loki輕量">Grafana Loki（輕量）&lt;/h3>
&lt;p>Loki 是 Grafana 生態的 log 彙整系統，架構類似 Prometheus（pull/push 都支援），但儲存的是 log stream 而非 metric。它不索引 log 內容（只索引 label），所以儲存成本遠低於 Elasticsearch。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c"># loki-config.yaml 基本設定&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">auth_enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">server&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">http_listen_port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3100&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">storage_config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">filesystem&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">directory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/data/loki/chunks&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">schema_config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">configs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">from&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="ld">2024-01-01&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">store&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">tsdb&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">object_store&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">filesystem&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">schema&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v13&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prefix&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">index_&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">period&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">24h&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>搭配 Promtail（log 收集 agent）在每台機器上收集 log 並推送到 Loki：&lt;/p></description><content:encoded><![CDATA[<p>斷網環境不能用 Datadog、New Relic、Sentry Cloud、PagerDuty Cloud 這些 SaaS 監控服務——它們全部需要往外發送資料。監控的三個核心能力（metric 收集、log 彙整、告警通知）全部要用 self-hosted 的開源工具在隔離網路內搭建。原則跟連網環境相同（metric 跟資源同生命週期、alarm 要連到動作），差別在工具的部署和儲存規劃要自己管。</p>
<h2 id="metric-收集prometheus--grafana">Metric 收集：Prometheus + Grafana</h2>
<p>Prometheus 是 pull-based 的 metric 收集系統——它主動去 scrape 各服務的 metric endpoint，不需要服務往外推資料。這個架構天然適合斷網：所有流量都在內網、不需要出站連線。</p>
<h3 id="離線安裝">離線安裝</h3>
<p>Prometheus 和 Grafana 都是單一二進位或容器映像，離線安裝跟<a href="/blog/infra/air-gapped/air-gapped-container/" data-link-title="斷網環境的容器與映像管理" data-link-desc="Private registry 架設、映像搬運（docker save/load、skopeo）、base image 更新週期、離線漏洞掃描">映像搬運</a>相同的流程：</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"># 外部：下載 release binary</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">wget https://github.com/prometheus/prometheus/releases/download/v2.53.0/prometheus-2.53.0.linux-amd64.tar.gz
</span></span><span class="line"><span class="ln">3</span><span class="cl">wget https://dl.grafana.com/oss/release/grafana-11.1.0.linux-amd64.tar.gz
</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"># 搬運後解壓、設定 systemd service</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">tar xzf prometheus-2.53.0.linux-amd64.tar.gz
</span></span><span class="line"><span class="ln">7</span><span class="cl">sudo mv prometheus-2.53.0.linux-amd64 /opt/prometheus</span></span></code></pre></div><p>如果用容器部署，先把映像搬進內部 registry 再 pull：</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"># 內部：從內部 registry 啟動</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker run -d -p 9090:9090 <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  -v /etc/prometheus:/etc/prometheus <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  -v /data/prometheus:/prometheus <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  registry.internal:5000/prometheus:v2.53.0</span></span></code></pre></div><h3 id="scrape-設定">Scrape 設定</h3>
<p>Prometheus 的 <code>prometheus.yml</code> 定義要 scrape 的目標。斷網環境通常用 static config（手動列出目標）而非 service discovery（需要雲端 API）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">scrape_configs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span>- <span class="nt">job_name</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;node-exporter&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span><span class="nt">static_configs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">      </span>- <span class="nt">targets</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">          </span>- <span class="s1">&#39;server-01:9100&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">          </span>- <span class="s1">&#39;server-02:9100&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">          </span>- <span class="s1">&#39;db-01:9100&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span>- <span class="nt">job_name</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;app&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">    </span><span class="nt">static_configs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span>- <span class="nt">targets</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">          </span>- <span class="s1">&#39;app-01:8080&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">          </span>- <span class="s1">&#39;app-02:8080&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">    </span><span class="nt">metrics_path</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;/metrics&#39;</span></span></span></code></pre></div><p>新增機器時手動把它加進 targets 清單。如果用 Consul（內網 service discovery），Prometheus 支援 Consul SD、可以自動發現新服務。</p>
<h3 id="node-exporter">Node Exporter</h3>
<p>每台需要監控的 Linux 機器裝一個 node_exporter（單一二進位、無依賴），暴露 CPU、記憶體、磁碟、網路等系統 metric。離線安裝同理——下載 binary、搬運、解壓、設成 service。</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">tar xzf node_exporter-1.8.1.linux-amd64.tar.gz
</span></span><span class="line"><span class="ln">3</span><span class="cl">sudo cp node_exporter-1.8.1.linux-amd64/node_exporter /usr/local/bin/
</span></span><span class="line"><span class="ln">4</span><span class="cl">sudo useradd --no-create-home --shell /bin/false node_exporter
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 建立 systemd service（略）</span></span></span></code></pre></div><h2 id="log-收集loki-或-elk">Log 收集：Loki 或 ELK</h2>
<h3 id="grafana-loki輕量">Grafana Loki（輕量）</h3>
<p>Loki 是 Grafana 生態的 log 彙整系統，架構類似 Prometheus（pull/push 都支援），但儲存的是 log stream 而非 metric。它不索引 log 內容（只索引 label），所以儲存成本遠低於 Elasticsearch。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># loki-config.yaml 基本設定</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">auth_enabled</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="nt">server</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="nt">http_listen_port</span><span class="p">:</span><span class="w"> </span><span class="m">3100</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="nt">storage_config</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">  </span><span class="nt">filesystem</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span><span class="nt">directory</span><span class="p">:</span><span class="w"> </span><span class="l">/data/loki/chunks</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="nt">schema_config</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span><span class="nt">configs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">    </span>- <span class="nt">from</span><span class="p">:</span><span class="w"> </span><span class="ld">2024-01-01</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span><span class="nt">store</span><span class="p">:</span><span class="w"> </span><span class="l">tsdb</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">      </span><span class="nt">object_store</span><span class="p">:</span><span class="w"> </span><span class="l">filesystem</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">      </span><span class="nt">schema</span><span class="p">:</span><span class="w"> </span><span class="l">v13</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">      </span><span class="nt">index</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">        </span><span class="nt">prefix</span><span class="p">:</span><span class="w"> </span><span class="l">index_</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">        </span><span class="nt">period</span><span class="p">:</span><span class="w"> </span><span class="l">24h</span></span></span></code></pre></div><p>搭配 Promtail（log 收集 agent）在每台機器上收集 log 並推送到 Loki：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># promtail-config.yaml</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">clients</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span>- <span class="nt">url</span><span class="p">:</span><span class="w"> </span><span class="l">http://loki.internal:3100/loki/api/v1/push</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="nt">scrape_configs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span>- <span class="nt">job_name</span><span class="p">:</span><span class="w"> </span><span class="l">system</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">static_configs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span>- <span class="nt">targets</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">localhost]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">        </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">          </span><span class="nt">job</span><span class="p">:</span><span class="w"> </span><span class="l">syslog</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">          </span><span class="nt">__path__</span><span class="p">:</span><span class="w"> </span><span class="l">/var/log/*.log</span></span></span></code></pre></div><h3 id="elk-stack功能豐富">ELK Stack（功能豐富）</h3>
<p>Elasticsearch + Logstash + Kibana 是功能最完整的 log 平台，但資源消耗大（Elasticsearch 建議至少 4GB RAM 起跳）。適合需要全文搜索 log 內容的場景。</p>
<p>離線安裝：Elastic 提供離線安裝包（<code>.deb</code> / <code>.rpm</code>），或用 Docker 映像。三個組件都要搬運。</p>
<p>選型判準：5 台以下的小環境用 Loki（輕量、跟 Prometheus + Grafana 同一套 dashboard）。需要全文搜索、已有 ELK 經驗的團隊用 ELK。</p>
<h2 id="告警沒有外部-webhook-怎麼通知">告警：沒有外部 webhook 怎麼通知</h2>
<p>連網環境的告警通常發到 Slack webhook、PagerDuty API、或 email relay service。斷網環境這些路徑都不通。</p>
<h3 id="內部-smtp">內部 SMTP</h3>
<p>如果隔離網路內有 email server（很多企業內網有 Exchange 或 Postfix），Prometheus Alertmanager 可以發 email 告警：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># alertmanager.yml</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">route</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">receiver</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;email-team&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="nt">receivers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;email-team&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">email_configs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span>- <span class="nt">to</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;oncall@internal.corp&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">        </span><span class="nt">from</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;alertmanager@internal.corp&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">        </span><span class="nt">smarthost</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;smtp.internal.corp:25&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">        </span><span class="nt">require_tls</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span></span></span></code></pre></div><h3 id="內部即時通訊">內部即時通訊</h3>
<p>如果內網有 Mattermost（Slack 的 self-hosted 替代）或 Rocket.Chat，Alertmanager 可以用 webhook 發送到這些工具的 incoming webhook endpoint。</p>
<h3 id="實體告警">實體告警</h3>
<p>極端情境（沒有 email、沒有 chat）：Alertmanager 把告警寫到檔案或資料庫、搭配值班制度定期查看。或用 Grafana 的 dashboard + 控制室大螢幕，值班人員直接看板。</p>
<p>告警的設計原則跟連網環境相同——symptom-based（錯誤率、延遲）優先於 cause-based（CPU、記憶體），閾值設計避免告警疲勞。差別在通知的到達速度可能慢一些（email 比 Slack push 慢），所以閾值要稍微保守（提早告警）。</p>
<h2 id="metric-與-log-的儲存規劃">Metric 與 Log 的儲存規劃</h2>
<p>SaaS 監控的儲存是雲端自動擴展的。Self-hosted 的儲存要自己規劃——磁碟滿了 Prometheus 就停止收集、Loki 就停止寫入。</p>
<h3 id="容量估算">容量估算</h3>
<p>Prometheus 的儲存量取決於 series 數量 × scrape 間隔 × 保留天數。粗估公式：</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">每日儲存 ≈ active_series × sample_size(2B) × (86400 / scrape_interval) × compression_ratio(~0.1)</span></span></code></pre></div><p>1 萬個 active series、15 秒 scrape interval、保留 30 天 ≈ 約 5GB。保留 90 天 ≈ 約 15GB。</p>
<p>Loki 的儲存量取決於 log 流量。粗估：每天 10GB 的 raw log 在 Loki 壓縮後約 1-2GB，保留 30 天 ≈ 30-60GB。</p>
<h3 id="retention-設定">Retention 設定</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="c"># prometheus.yml</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="nt">global</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span><span class="nt">scrape_interval</span><span class="p">:</span><span class="w"> </span><span class="l">15s</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="nt">storage</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">  </span><span class="nt">tsdb</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">    </span><span class="nt">retention.time</span><span class="p">:</span><span class="w"> </span><span class="l">30d</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w">    </span><span class="nt">retention.size</span><span class="p">:</span><span class="w"> </span><span class="l">10GB </span><span class="w"> </span><span class="c"># 以先到的為準</span></span></span></code></pre></div><p>超過容量時 Prometheus 自動刪除最舊的資料。設定 retention 前先確認磁碟空間足夠——斷網環境擴容磁碟的流程（採購 + 安裝）可能需要週到月級的時間。</p>
<h2 id="ntp-時間同步">NTP 時間同步</h2>
<p>斷網環境容易被忽略的一個問題是時間同步。沒有 NTP server（<code>pool.ntp.org</code>）可連的機器，時鐘會漂移——幾天後各台機器的時間差可能達到秒級。當 Prometheus 收到的 metric timestamp 跟 Loki 收到的 log timestamp 有幾秒落差，事故排查時 metric 跟 log 對不上。</p>
<p>解法是在隔離網路內架一台 NTP server，所有機器從它同步：</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"># 內部 NTP server（chrony）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># /etc/chrony/chrony.conf</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nb">local</span> stratum <span class="m">10</span>         <span class="c1"># 沒有外部來源時、自己當 stratum 10</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">allow 10.0.0.0/16        <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="c1"># 其他機器指向內部 NTP</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">server ntp.internal iburst</span></span></code></pre></div><p>如果隔離網路的閘道可以開 NTP（UDP 123），讓閘道從外部 NTP 同步、內部機器從閘道同步，時間精度可以維持在毫秒級。</p>
<p>時程參考：Prometheus + Grafana + Alertmanager 的初次建置約需 1-2 天。Loki + Promtail 約需半天到一天。NTP server 約需 2 小時。後續維護主要是 Prometheus/Loki 版本更新的搬運（每次 1-2 小時）和儲存容量監控。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">斷網環境的通用原則</a>：監控工具的離線安裝走 content ferry 模式</li>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-container/" data-link-title="斷網環境的容器與映像管理" data-link-desc="Private registry 架設、映像搬運（docker save/load、skopeo）、base image 更新週期、離線漏洞掃描">斷網環境的容器管理</a>：Prometheus/Grafana/Loki 的容器映像搬運</li>
<li>→ <a href="/blog/infra/06-observability-logging/" data-link-title="模組六：可觀測性與 log 一併寫進 code" data-link-desc="log group、metric、alarm 跟基礎設施同生命週期管理，出事時追得到查得到">模組六：可觀測性與 log</a>：連網環境的可觀測性 IaC</li>
<li>→ <a href="/blog/infra/takeover/legacy-external-monitoring/" data-link-title="無 SSH 環境的監控與告警" data-link-desc="無 SSH 環境沒辦法裝 agent、沒辦法串 log pipeline，用外部 HTTP check、錯誤追蹤服務與效能基線建立最低成本的監控能力">無 SSH 環境的監控與告警</a>：另一個極端——完全外部監控</li>
<li>→ <a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">Monitoring 04：Collector 架構與部署</a>：SDK 和 Collector 的應用層監控，斷網環境需要把 Collector endpoint 指向 self-hosted backend</li>
<li>→ <a href="/blog/monitoring/06-commercial-comparison/self-hosted-vs-commercial/" data-link-title="自架 vs 商業的判斷決策表" data-link-desc="使用者數、網路範圍、功能需求、合規要求四個維度判斷該自架還是用商業方案">Monitoring 06：Self-hosted vs Commercial</a>：斷網環境只能走 self-hosted 路線</li>
</ul>
]]></content:encoded></item><item><title>OIDC Trust Policy 設定指南</title><link>https://tarrragon.github.io/blog/infra/02-identity-credentials/oidc-trust-policy-setup/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/02-identity-credentials/oidc-trust-policy-setup/</guid><description>&lt;p>OIDC 聯合讓 CI/CD pipeline 用短期 token 取代長期 access key 存取雲端資源。設定本身不複雜，但 trust policy 的 claim 條件寫錯一個字就會變成「任何 repo 都能假扮這個 role」或「完全無法 assume」。本篇是 GitHub Actions 與 AWS 之間的 OIDC 聯合的完整設定步驟，從建立 provider 到 trust policy 設計到測試驗證。其他 CI 平台（GitLab CI、CircleCI）的原理相同，差別只在 issuer URL 和 claim 結構：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>平台&lt;/th>
 &lt;th>Issuer URL&lt;/th>
 &lt;th>sub claim 格式範例&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>GitHub Actions&lt;/td>
 &lt;td>&lt;code>token.actions.githubusercontent.com&lt;/code>&lt;/td>
 &lt;td>&lt;code>repo:{org}/{repo}:ref:refs/heads/{branch}&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GitLab CI&lt;/td>
 &lt;td>&lt;code>gitlab.com&lt;/code>&lt;/td>
 &lt;td>&lt;code>project_path:{group}/{project}:ref_type:branch:ref:main&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CircleCI&lt;/td>
 &lt;td>&lt;code>oidc.circleci.com/org/{org-id}&lt;/code>&lt;/td>
 &lt;td>&lt;code>org/{org-id}/project/{project-id}/user/{user-id}&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>本篇以 GitHub Actions 為主，其他平台替換 issuer URL 和 sub condition 即可。&lt;/p>
&lt;h2 id="建立-oidc-provider">建立 OIDC Provider&lt;/h2>
&lt;p>OIDC provider 是 AWS 帳號裡的一個資源，聲明「我信任這個外部 identity provider 簽發的 token」。GitHub Actions 的 OIDC issuer URL 是固定的，每個 AWS 帳號只需要建一個 provider。&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_iam_openid_connect_provider&amp;#34; &amp;#34;github&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"> url&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;https://token.actions.githubusercontent.com&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"> client_id_list&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sts.amazonaws.com&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 class="n"> thumbprint_list&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ffffffffffffffffffffffffffffffffffffffff&amp;#34;&lt;/span>&lt;span class="p">]&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>client_id_list&lt;/code> 設為 &lt;code>sts.amazonaws.com&lt;/code> 是 GitHub 官方建議的 audience 值。&lt;code>thumbprint_list&lt;/code> 在 2023 年之後 AWS 不再用它驗證 GitHub 的憑證鏈（改用 AWS 自己維護的根憑證清單），但欄位仍然是必填，填 40 個 &lt;code>f&lt;/code> 作為佔位值即可。&lt;/p>
&lt;p>這個 provider 建一次就好。多個 role 可以共用同一個 provider，差別在各自的 trust policy 怎麼寫。&lt;/p>
&lt;h2 id="trust-policy-設計claim-收斂">Trust Policy 設計：claim 收斂&lt;/h2>
&lt;p>Trust policy 決定「誰能假扮這個 role」。OIDC token 裡帶有多個 claim（描述「這是哪個 repo、哪個 branch、哪個 workflow 在跑」），trust policy 用 condition 比對這些 claim，全部命中才允許 assume。&lt;/p>
&lt;h3 id="最小可行的-trust-policy">最小可行的 trust policy&lt;/h3>





&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">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;ci_trust&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="k">statement&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"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sts:AssumeRoleWithWebIdentity&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;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">principals&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="s2">&amp;#34;Federated&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="n"> identifiers&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="k">aws_iam_openid_connect_provider&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">github&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&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;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">condition&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"> test&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;StringEquals&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"> variable&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;token.actions.githubusercontent.com:aud&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"> values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sts.amazonaws.com&amp;#34;&lt;/span>&lt;span class="p">]&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;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="k">condition&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"> test&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;StringLike&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 class="n"> variable&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;token.actions.githubusercontent.com:sub&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="n"> values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;repo:my-org/my-app:ref:refs/heads/main&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&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>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩個 condition 各守一個邊界。&lt;code>aud&lt;/code> 驗證 audience 對不對（防止其他用途的 token 被拿來 assume）。&lt;code>sub&lt;/code> 驗證請求來自哪個 repo 和 branch——這是最關鍵的收斂點。&lt;/p></description><content:encoded><![CDATA[<p>OIDC 聯合讓 CI/CD pipeline 用短期 token 取代長期 access key 存取雲端資源。設定本身不複雜，但 trust policy 的 claim 條件寫錯一個字就會變成「任何 repo 都能假扮這個 role」或「完全無法 assume」。本篇是 GitHub Actions 與 AWS 之間的 OIDC 聯合的完整設定步驟，從建立 provider 到 trust policy 設計到測試驗證。其他 CI 平台（GitLab CI、CircleCI）的原理相同，差別只在 issuer URL 和 claim 結構：</p>
<table>
  <thead>
      <tr>
          <th>平台</th>
          <th>Issuer URL</th>
          <th>sub claim 格式範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>GitHub Actions</td>
          <td><code>token.actions.githubusercontent.com</code></td>
          <td><code>repo:{org}/{repo}:ref:refs/heads/{branch}</code></td>
      </tr>
      <tr>
          <td>GitLab CI</td>
          <td><code>gitlab.com</code></td>
          <td><code>project_path:{group}/{project}:ref_type:branch:ref:main</code></td>
      </tr>
      <tr>
          <td>CircleCI</td>
          <td><code>oidc.circleci.com/org/{org-id}</code></td>
          <td><code>org/{org-id}/project/{project-id}/user/{user-id}</code></td>
      </tr>
  </tbody>
</table>
<p>本篇以 GitHub Actions 為主，其他平台替換 issuer URL 和 sub condition 即可。</p>
<h2 id="建立-oidc-provider">建立 OIDC Provider</h2>
<p>OIDC provider 是 AWS 帳號裡的一個資源，聲明「我信任這個外部 identity provider 簽發的 token」。GitHub Actions 的 OIDC issuer URL 是固定的，每個 AWS 帳號只需要建一個 provider。</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_openid_connect_provider&#34; &#34;github&#34;</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  url</span>             <span class="o">=</span> <span class="s2">&#34;https://token.actions.githubusercontent.com&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  client_id_list</span>  <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;sts.amazonaws.com&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  thumbprint_list</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;ffffffffffffffffffffffffffffffffffffffff&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">}</span></span></code></pre></div><p><code>client_id_list</code> 設為 <code>sts.amazonaws.com</code> 是 GitHub 官方建議的 audience 值。<code>thumbprint_list</code> 在 2023 年之後 AWS 不再用它驗證 GitHub 的憑證鏈（改用 AWS 自己維護的根憑證清單），但欄位仍然是必填，填 40 個 <code>f</code> 作為佔位值即可。</p>
<p>這個 provider 建一次就好。多個 role 可以共用同一個 provider，差別在各自的 trust policy 怎麼寫。</p>
<h2 id="trust-policy-設計claim-收斂">Trust Policy 設計：claim 收斂</h2>
<p>Trust policy 決定「誰能假扮這個 role」。OIDC token 裡帶有多個 claim（描述「這是哪個 repo、哪個 branch、哪個 workflow 在跑」），trust policy 用 condition 比對這些 claim，全部命中才允許 assume。</p>
<h3 id="最小可行的-trust-policy">最小可行的 trust policy</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">data</span> <span class="s2">&#34;aws_iam_policy_document&#34; &#34;ci_trust&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="k">statement</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">    actions</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;sts:AssumeRoleWithWebIdentity&#34;</span><span class="p">]</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">principals</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="s2">&#34;Federated&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">      identifiers</span> <span class="o">=</span> <span class="p">[</span><span class="k">aws_iam_openid_connect_provider</span><span class="p">.</span><span class="k">github</span><span class="p">.</span><span class="k">arn</span><span class="p">]</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">condition</span> {
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">      test</span>     <span class="o">=</span> <span class="s2">&#34;StringEquals&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">      variable</span> <span class="o">=</span> <span class="s2">&#34;token.actions.githubusercontent.com:aud&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">      values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;sts.amazonaws.com&#34;</span><span class="p">]</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">condition</span> {
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="n">      test</span>     <span class="o">=</span> <span class="s2">&#34;StringLike&#34;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="n">      variable</span> <span class="o">=</span> <span class="s2">&#34;token.actions.githubusercontent.com:sub&#34;</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="n">      values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;repo:my-org/my-app:ref:refs/heads/main&#34;</span><span class="p">]</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></span></code></pre></div><p>兩個 condition 各守一個邊界。<code>aud</code> 驗證 audience 對不對（防止其他用途的 token 被拿來 assume）。<code>sub</code> 驗證請求來自哪個 repo 和 branch——這是最關鍵的收斂點。</p>
<h3 id="sub-claim-的結構">sub claim 的結構</h3>
<p>GitHub Actions 的 <code>sub</code> claim 格式是 <code>repo:{owner}/{repo}:{context}</code>，其中 context 隨觸發方式不同：</p>
<table>
  <thead>
      <tr>
          <th>觸發方式</th>
          <th>sub claim 值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>push to branch</td>
          <td><code>repo:my-org/my-app:ref:refs/heads/main</code></td>
      </tr>
      <tr>
          <td>pull request</td>
          <td><code>repo:my-org/my-app:pull_request</code></td>
      </tr>
      <tr>
          <td>environment deploy</td>
          <td><code>repo:my-org/my-app:environment:production</code></td>
      </tr>
      <tr>
          <td>tag push</td>
          <td><code>repo:my-org/my-app:ref:refs/tags/v1.0.0</code></td>
      </tr>
      <tr>
          <td>manual dispatch</td>
          <td><code>repo:my-org/my-app:ref:refs/heads/main</code></td>
      </tr>
  </tbody>
</table>
<p>Trust policy 的 <code>sub</code> condition 要根據實際需要選擇收斂到哪個層級。只允許 main branch 的 push 就寫 <code>repo:my-org/my-app:ref:refs/heads/main</code>；只允許 production environment 的 deploy 就寫 <code>repo:my-org/my-app:environment:production</code>。</p>
<h3 id="environment-based-收斂推薦">environment-based 收斂（推薦）</h3>
<p>GitHub Actions 的 environment 功能讓 <code>sub</code> claim 帶上 environment 名稱。搭配 environment protection rules（required reviewers、wait timer），可以在 trust policy 層和 GitHub 層各設一道 gate：</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">condition</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  test</span>     <span class="o">=</span> <span class="s2">&#34;StringEquals&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  variable</span> <span class="o">=</span> <span class="s2">&#34;token.actions.githubusercontent.com:sub&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;repo:my-org/my-app:environment:production&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">}</span></span></code></pre></div><p>Workflow 裡對應的設定：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="nt">apply</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w"> </span><span class="l">production</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span></span></span></code></pre></div><p>只有 workflow 宣告了 <code>environment: production</code> 且通過 environment 的 protection rules 後，runner 拿到的 token 才會帶上 <code>environment:production</code> 的 sub claim，才能 assume 這個 role。</p>
<h2 id="plan-role-與-apply-role-分離">Plan Role 與 Apply Role 分離</h2>
<p>把 plan 和 apply 拆成兩個 role，各自給最小權限。plan 只需要 read 權限（讀 state、讀雲端現況），apply 需要 write 權限（建立/修改/刪除資源）。分離的好處是 PR 階段的 plan 即使被攻破，攻擊者也只能讀不能改。</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;infra_plan&#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;infra-plan&#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">plan_trust</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&#34; &#34;infra_apply&#34;</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;infra-apply&#34;</span>
</span></span><span class="line"><span class="ln"> 8</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">apply_trust</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">resource</span> <span class="s2">&#34;aws_iam_role_policy_attachment&#34; &#34;plan_readonly&#34;</span> {
</span></span><span class="line"><span class="ln">12</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">infra_plan</span><span class="p">.</span><span class="k">name</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">  policy_arn</span> <span class="o">=</span> <span class="s2">&#34;arn:aws:iam::aws:policy/ReadOnlyAccess&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">}</span></span></code></pre></div><p>Trust policy 的差異：plan role 允許任何 branch 的 PR 觸發（<code>repo:my-org/my-app:pull_request</code>）；apply role 只允許 main branch 或 production environment（<code>repo:my-org/my-app:environment:production</code>）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">plan</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">github.event_name == &#39;pull_request&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span><span class="nt">pull-requests</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">aws-actions/configure-aws-credentials@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">          </span><span class="nt">role-to-assume</span><span class="p">:</span><span class="w"> </span><span class="l">arn:aws:iam::123456789012:role/infra-plan</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">          </span><span class="nt">aws-region</span><span class="p">:</span><span class="w"> </span><span class="l">ap-northeast-1</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform plan -out=plan.tfplan</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">  </span><span class="nt">apply</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">github.ref == &#39;refs/heads/main&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w"> </span><span class="l">production</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">aws-actions/configure-aws-credentials@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w">          </span><span class="nt">role-to-assume</span><span class="p">:</span><span class="w"> </span><span class="l">arn:aws:iam::123456789012:role/infra-apply</span><span class="w">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="w">          </span><span class="nt">aws-region</span><span class="p">:</span><span class="w"> </span><span class="l">ap-northeast-1</span><span class="w">
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform apply -auto-approve</span></span></span></code></pre></div><h2 id="常見設定錯誤">常見設定錯誤</h2>
<h3 id="audience-不匹配">audience 不匹配</h3>





<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">Error: Not authorized to perform sts:AssumeRoleWithWebIdentity</span></span></code></pre></div><p>最常見的原因是 trust policy 的 <code>aud</code> condition 值跟 OIDC provider 的 <code>client_id_list</code> 不一致。兩者都要是 <code>sts.amazonaws.com</code>。如果用了舊版的 <code>configure-aws-credentials</code> action（v1），它預設用 <code>sigstore</code> 作為 audience，跟 <code>sts.amazonaws.com</code> 對不上。確認 action 版本是 v4+。</p>
<h3 id="sub-condition-太寬">sub condition 太寬</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">condition</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  test</span>     <span class="o">=</span> <span class="s2">&#34;StringLike&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  variable</span> <span class="o">=</span> <span class="s2">&#34;token.actions.githubusercontent.com:sub&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;repo:my-org/*&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">}</span></span></code></pre></div><p>這允許 <code>my-org</code> 底下任何 repo 的任何 branch assume 這個 role。如果組織裡有公開 repo 或 fork 權限寬鬆的 repo，攻擊者可以在那些 repo 裡觸發 workflow 來 assume 生產環境的 role。至少收斂到 repo 層級（<code>repo:my-org/my-app:*</code>），生產環境收斂到 branch 或 environment。</p>
<h3 id="sub-condition-太緊">sub condition 太緊</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">condition</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  test</span>     <span class="o">=</span> <span class="s2">&#34;StringEquals&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  variable</span> <span class="o">=</span> <span class="s2">&#34;token.actions.githubusercontent.com:sub&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;repo:my-org/my-app:ref:refs/heads/main&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">}</span></span></code></pre></div><p>這只允許 push to main 觸發的 workflow。PR 觸發的 workflow 拿到的 sub 是 <code>repo:my-org/my-app:pull_request</code>，跟這個 condition 不匹配，plan 階段會失敗。如果 plan 需要在 PR 階段跑，plan role 的 trust policy 要加 PR 的 sub pattern。</p>
<h3 id="忘記設-permissions">忘記設 permissions</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">    </span><span class="c"># 缺少 permissions 區塊</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">aws-actions/configure-aws-credentials@v4</span></span></span></code></pre></div><p>GitHub Actions 的 OIDC token 只有在 workflow 宣告 <code>permissions: { id-token: write }</code> 時才會簽發。缺了這一行，<code>configure-aws-credentials</code> 拿不到 token，報「OIDC token not available」。這個錯誤訊息不直觀——它說的是 token 不存在，不是權限不夠。</p>
<h3 id="多帳號時忘記指定-provider">多帳號時忘記指定 provider</h3>
<p>如果組織有多個 AWS 帳號，每個帳號都要各自建 OIDC provider。trust policy 的 <code>Federated</code> principal 要指向本帳號的 provider ARN，不能跨帳號引用。跨帳號部署時，workflow 用不同的 <code>role-to-assume</code> 切換帳號，每個帳號的 role 各自信任同一個 GitHub OIDC issuer 但是各自獨立的 provider 資源。</p>
<h2 id="測試與驗證">測試與驗證</h2>
<p>設定完成後的驗證步驟：</p>
<ol>
<li><strong>手動觸發 workflow</strong>：push 一個無害的 commit 到 main、開一個 test PR，觀察 <code>configure-aws-credentials</code> 步驟是否成功</li>
<li><strong>檢查 CloudTrail</strong>：搜尋 <code>AssumeRoleWithWebIdentity</code> 事件，確認 source identity 和 assumed role 正確</li>
<li><strong>反向驗證</strong>：從一個不在 trust policy 允許範圍的 repo 或 branch 觸發 workflow，確認 assume 被拒絕</li>
<li><strong>權限範圍驗證</strong>：在 plan job 裡嘗試一個 write 操作（如 <code>aws s3 rm</code>），確認被拒絕——驗證 plan role 的 read-only 限制確實生效</li>
</ol>





<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"># 在 CloudTrail 搜尋 OIDC assume 事件</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws cloudtrail lookup-events <span class="se">\
</span></span></span><span class="line"><span class="ln">3</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>AssumeRoleWithWebIdentity <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --max-items <span class="m">5</span></span></span></code></pre></div><p>驗證通過後，這套 OIDC 設定就取代了所有存放在 CI 環境變數裡的 access key。原有的 key 可以排程停用和刪除，排程的節奏見<a href="/blog/infra/02-identity-credentials/access-key-rotation-playbook/" data-link-title="Access Key 輪替手冊" data-link-desc="從 credential report 盤點散落的長期 access key，到逐把輪替、自動化輪替與 key age 監控的完整操作步驟">access key 輪替</a>。trust policy 的持續維護重點是：新增 repo 時 sub condition 要同步更新、組織改名時 issuer 的 repo 路徑要全面修正。</p>
<p>時程參考：OIDC provider 建立 + trust policy 設計 + workflow 驗證約需 1-2 小時。OIDC provider 與 IAM role 本身不產生額外費用。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">身分與憑證地基</a>：OIDC 的概念基礎與權限邊界設計</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">infra 走 PR 流程</a>：plan/apply 的 CI pipeline 怎麼用這裡設定好的 role</li>
<li>→ <a href="/blog/infra/02-identity-credentials/multi-account-strategy/" data-link-title="跨帳號策略 — Organizations、SCP 與帳號工廠" data-link-desc="用 AWS Organizations 把環境拆成獨立帳號、用 SCP 設定連管理員都越不過的護欄、用帳號工廠讓每個新帳號自帶安全基線">跨帳號策略</a>：多帳號環境下的 OIDC provider 配置</li>
</ul>
]]></content:encoded></item><item><title>OS 與基礎軟體更換</title><link>https://tarrragon.github.io/blog/infra/upgrade/os-base-software-upgrade/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/upgrade/os-base-software-upgrade/</guid><description>&lt;p>作業系統到達 end-of-life（EOL）後不再收到安全修補——每一個新發現的漏洞都會永久敞開。EOL OS 上跑的服務不是「可能有風險」，而是「風險只會隨時間單調增加」。遷移的問題是何時做和怎麼做，不是要不要做。&lt;/p>
&lt;h2 id="eol-風險評估">EOL 風險評估&lt;/h2>
&lt;p>EOL 在操作層面的意義是三件事同時停止：安全修補（CVE 不再被回填到該版本的 patch release）、核心更新（kernel 的錯誤修正與硬體支援停止）、套件庫維護（官方 repository 凍結或下架，新裝套件或更新依賴都做不到）。&lt;/p>
&lt;h3 id="風險時間軸">風險時間軸&lt;/h3>
&lt;p>EOL 是一段逐漸惡化的過程，而非單一時間點：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>階段&lt;/th>
 &lt;th>事件&lt;/th>
 &lt;th>影響&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>宣告&lt;/td>
 &lt;td>官方公布 EOL 日期（通常提前 1-2 年）&lt;/td>
 &lt;td>開始規劃遷移的訊號&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>正式 EOL&lt;/td>
 &lt;td>最後一個安全修補發布&lt;/td>
 &lt;td>新 CVE 不再有 patch&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>套件庫凍結&lt;/td>
 &lt;td>官方 mirror 停止同步或下架&lt;/td>
 &lt;td>&lt;code>yum update&lt;/code> / &lt;code>apt update&lt;/code> 失敗&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>合規失效&lt;/td>
 &lt;td>稽核認定執行環境不符標準&lt;/td>
 &lt;td>PCI DSS / SOC 2 / ISO 27001 判定不合規&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="常見的-eol-情境">常見的 EOL 情境&lt;/h3>
&lt;p>CentOS 7 在 2024 年 6 月結束支援，但仍有大量 production 環境在使用。CentOS 8 在 2021 年 12 月被轉向 CentOS Stream，打破了原本預期到 2029 年的支援承諾，迫使使用者重新選型。Ubuntu 18.04 的標準支援在 2023 年 4 月結束，Canonical 提供 ESM（Extended Security Maintenance）付費延長到 2028 年，但 ESM 只涵蓋 main 套件庫。&lt;/p>
&lt;p>ESM 或類似的付費延長支援（RHEL 的 ELS、CentOS 的第三方 TuxCare）是「買時間做遷移」的合理策略——付月費取得額外 2-5 年的安全修補，讓團隊有餘裕規劃平行建置而非被迫緊急遷移。Ubuntu Pro 免費涵蓋 5 台 instance 的 ESM，超過才需要付費。ESM 是給遷移專案爭取時間的保險，而非長期方案——延長支援的套件覆蓋範圍通常比標準期窄。&lt;/p>
&lt;p>合規的影響很直接：PCI DSS 要求所有面對持卡人資料的系統都執行在有安全修補支援的軟體上；SOC 2 和 ISO 27001 的定期稽核會檢查作業系統的支援狀態。在 EOL OS 上跑的 production 環境會讓稽核結果出現 finding，需要額外的補償控制（compensating control）才能通過——而補償控制的維護成本通常高於遷移本身。&lt;/p>
&lt;h2 id="目標-os-選型">目標 OS 選型&lt;/h2>
&lt;p>選型看四個維度：LTS 發布週期（支援年限多長）、社群與商業支援（問題能不能查到答案、能不能買付費支援）、套件可用性（應用層需要的 runtime 和 library 在官方 repo 裡有沒有）、團隊熟悉度（操作指令和設定路徑的學習成本）。&lt;/p>
&lt;h3 id="常見選擇">常見選擇&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>OS&lt;/th>
 &lt;th>支援週期&lt;/th>
 &lt;th>適用情境&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Ubuntu 22.04 / 24.04 LTS&lt;/td>
 &lt;td>5 年標準 + 5 年 ESM&lt;/td>
 &lt;td>社群最大、套件最新、學習資源最多&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Debian 12 (Bookworm)&lt;/td>
 &lt;td>~5 年&lt;/td>
 &lt;td>穩定性優先、更新保守&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Linux 2023&lt;/td>
 &lt;td>5 年&lt;/td>
 &lt;td>AWS 生態深度整合、EC2 預設選項&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Rocky Linux 9 / AlmaLinux 9&lt;/td>
 &lt;td>~10 年&lt;/td>
 &lt;td>CentOS 替代、RHEL 相容&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="同家族-vs-跨家族">同家族 vs 跨家族&lt;/h3>
&lt;p>CentOS → Rocky Linux / AlmaLinux 是同家族遷移：套件名稱、設定路徑、init 系統（systemd）幾乎不變，應用層的改動最少。CentOS → Ubuntu 是跨家族遷移：套件管理從 yum/dnf 換成 apt、設定路徑從 &lt;code>/etc/httpd/&lt;/code> 變成 &lt;code>/etc/apache2/&lt;/code>、某些服務名稱不同。&lt;/p>
&lt;p>同家族遷移的優勢是應用層風險低——多數設定檔可以直接搬過去。跨家族遷移的優勢是可以借機切到更活躍的生態（Ubuntu 的社群回答量和第三方套件支援在多數指標上領先），代價是設定檔要全面調整。&lt;/p>
&lt;p>選型判準：如果團隊已經有 Ubuntu 經驗、或其他系統已經跑 Ubuntu，統一到 Ubuntu 的長期維護成本較低。如果團隊對 RHEL 系操作更熟、或有 RHEL 付費支援合約，Rocky/Alma 是阻力最小的路。&lt;/p></description><content:encoded><![CDATA[<p>作業系統到達 end-of-life（EOL）後不再收到安全修補——每一個新發現的漏洞都會永久敞開。EOL OS 上跑的服務不是「可能有風險」，而是「風險只會隨時間單調增加」。遷移的問題是何時做和怎麼做，不是要不要做。</p>
<h2 id="eol-風險評估">EOL 風險評估</h2>
<p>EOL 在操作層面的意義是三件事同時停止：安全修補（CVE 不再被回填到該版本的 patch release）、核心更新（kernel 的錯誤修正與硬體支援停止）、套件庫維護（官方 repository 凍結或下架，新裝套件或更新依賴都做不到）。</p>
<h3 id="風險時間軸">風險時間軸</h3>
<p>EOL 是一段逐漸惡化的過程，而非單一時間點：</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>事件</th>
          <th>影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>宣告</td>
          <td>官方公布 EOL 日期（通常提前 1-2 年）</td>
          <td>開始規劃遷移的訊號</td>
      </tr>
      <tr>
          <td>正式 EOL</td>
          <td>最後一個安全修補發布</td>
          <td>新 CVE 不再有 patch</td>
      </tr>
      <tr>
          <td>套件庫凍結</td>
          <td>官方 mirror 停止同步或下架</td>
          <td><code>yum update</code> / <code>apt update</code> 失敗</td>
      </tr>
      <tr>
          <td>合規失效</td>
          <td>稽核認定執行環境不符標準</td>
          <td>PCI DSS / SOC 2 / ISO 27001 判定不合規</td>
      </tr>
  </tbody>
</table>
<h3 id="常見的-eol-情境">常見的 EOL 情境</h3>
<p>CentOS 7 在 2024 年 6 月結束支援，但仍有大量 production 環境在使用。CentOS 8 在 2021 年 12 月被轉向 CentOS Stream，打破了原本預期到 2029 年的支援承諾，迫使使用者重新選型。Ubuntu 18.04 的標準支援在 2023 年 4 月結束，Canonical 提供 ESM（Extended Security Maintenance）付費延長到 2028 年，但 ESM 只涵蓋 main 套件庫。</p>
<p>ESM 或類似的付費延長支援（RHEL 的 ELS、CentOS 的第三方 TuxCare）是「買時間做遷移」的合理策略——付月費取得額外 2-5 年的安全修補，讓團隊有餘裕規劃平行建置而非被迫緊急遷移。Ubuntu Pro 免費涵蓋 5 台 instance 的 ESM，超過才需要付費。ESM 是給遷移專案爭取時間的保險，而非長期方案——延長支援的套件覆蓋範圍通常比標準期窄。</p>
<p>合規的影響很直接：PCI DSS 要求所有面對持卡人資料的系統都執行在有安全修補支援的軟體上；SOC 2 和 ISO 27001 的定期稽核會檢查作業系統的支援狀態。在 EOL OS 上跑的 production 環境會讓稽核結果出現 finding，需要額外的補償控制（compensating control）才能通過——而補償控制的維護成本通常高於遷移本身。</p>
<h2 id="目標-os-選型">目標 OS 選型</h2>
<p>選型看四個維度：LTS 發布週期（支援年限多長）、社群與商業支援（問題能不能查到答案、能不能買付費支援）、套件可用性（應用層需要的 runtime 和 library 在官方 repo 裡有沒有）、團隊熟悉度（操作指令和設定路徑的學習成本）。</p>
<h3 id="常見選擇">常見選擇</h3>
<table>
  <thead>
      <tr>
          <th>OS</th>
          <th>支援週期</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Ubuntu 22.04 / 24.04 LTS</td>
          <td>5 年標準 + 5 年 ESM</td>
          <td>社群最大、套件最新、學習資源最多</td>
      </tr>
      <tr>
          <td>Debian 12 (Bookworm)</td>
          <td>~5 年</td>
          <td>穩定性優先、更新保守</td>
      </tr>
      <tr>
          <td>Amazon Linux 2023</td>
          <td>5 年</td>
          <td>AWS 生態深度整合、EC2 預設選項</td>
      </tr>
      <tr>
          <td>Rocky Linux 9 / AlmaLinux 9</td>
          <td>~10 年</td>
          <td>CentOS 替代、RHEL 相容</td>
      </tr>
  </tbody>
</table>
<h3 id="同家族-vs-跨家族">同家族 vs 跨家族</h3>
<p>CentOS → Rocky Linux / AlmaLinux 是同家族遷移：套件名稱、設定路徑、init 系統（systemd）幾乎不變，應用層的改動最少。CentOS → Ubuntu 是跨家族遷移：套件管理從 yum/dnf 換成 apt、設定路徑從 <code>/etc/httpd/</code> 變成 <code>/etc/apache2/</code>、某些服務名稱不同。</p>
<p>同家族遷移的優勢是應用層風險低——多數設定檔可以直接搬過去。跨家族遷移的優勢是可以借機切到更活躍的生態（Ubuntu 的社群回答量和第三方套件支援在多數指標上領先），代價是設定檔要全面調整。</p>
<p>選型判準：如果團隊已經有 Ubuntu 經驗、或其他系統已經跑 Ubuntu，統一到 Ubuntu 的長期維護成本較低。如果團隊對 RHEL 系操作更熟、或有 RHEL 付費支援合約，Rocky/Alma 是阻力最小的路。</p>
<h2 id="遷移策略原地升級-vs-平行建置">遷移策略：原地升級 vs 平行建置</h2>
<h3 id="原地升級">原地升級</h3>
<p>在現有伺服器上直接換 OS 版本。做法是用 OS 提供的升級工具（如 <code>do-release-upgrade</code>、<code>leapp</code>）在跑著的系統上切換。</p>
<p>風險集中在升級過程中系統處於不確定狀態——kernel 換了但 userland 還沒、init 系統切了但服務設定還指向舊路徑。如果中途失敗、伺服器可能開不了機，而 rollback 意味著從備份還原整台機器。原地升級只在同 OS 家族的小版本升級（如 Ubuntu 20.04 → 22.04）且有完整 VM 快照保底時才值得考慮。</p>
<h3 id="平行建置">平行建置</h3>
<p>在旁邊建一台新 OS 的伺服器、安裝應用層、遷移資料、用 DNS 或 load balancer 切換流量。舊伺服器保留作為 rollback 目標，確認新環境穩定後再退役。</p>
<p>平行建置的成本是短期多付一台伺服器的費用（通常是幾天到幾週）。收益是：升級失敗時舊伺服器完好無損、切回去只需要改 DNS 或 LB 的 target；新伺服器可以在切換前充分測試、不影響線上服務；整個過程可以在非尖峰時段進行。</p>
<p>對多數環境來說平行建置是預設策略。原地升級只在無法多開一台伺服器（預算極度受限、或裸機硬體無備品）時才退而求其次。</p>
<h2 id="應用層的遷移清單">應用層的遷移清單</h2>
<p>新 OS 上要重建整個應用執行環境。以下是逐項需要確認的面向：</p>
<h3 id="web-伺服器">Web 伺服器</h3>
<p>如果新舊 OS 都用 Apache，設定檔的路徑可能不同（RHEL 系 <code>/etc/httpd/conf.d/</code>、Debian 系 <code>/etc/apache2/sites-available/</code>），模組載入方式也不同（<code>LoadModule</code> 指令 vs <code>a2enmod</code> 工具）。逐一比對現有的 VirtualHost 設定、rewrite 規則、SSL 設定。</p>
<p>如果同時換成 nginx，見下一節。</p>
<h3 id="runtime-版本對齊">Runtime 版本對齊</h3>
<p>新 OS 的官方 repo 裡的 PHP / Node / Python 版本可能跟舊 OS 不同。Ubuntu 22.04 預設 PHP 8.1、如果應用需要 PHP 7.4 要加第三方 PPA（如 ondrej/php）。確認所有 PHP extension（mysqli、curl、gd、mbstring、redis）在新 OS 上都有對應的套件名稱且已安裝。</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"># 舊伺服器：列出所有已載入的 PHP module</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">php -m &gt; old-php-modules.txt
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 新伺服器：比對缺了什麼</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">php -m &gt; new-php-modules.txt
</span></span><span class="line"><span class="ln">6</span><span class="cl">diff old-php-modules.txt new-php-modules.txt</span></span></code></pre></div><h3 id="資料庫客戶端程式庫">資料庫客戶端程式庫</h3>
<p>應用連接 MySQL / PostgreSQL 用的 client library（<code>libmysqlclient</code>、<code>libpq</code>）版本要跟資料庫伺服器相容。跨大版本（MySQL 5.7 client → MySQL 8.0 server）通常向前相容，但反過來可能有驗證方式不匹配的問題（如 MySQL 8.0 的 <code>caching_sha2_password</code> 預設驗證方式）。</p>
<h3 id="cron-jobs">Cron jobs</h3>
<p>從舊伺服器匯出 crontab（<code>crontab -l</code>），在新伺服器重建。如果舊 OS 使用 <code>/etc/cron.d/</code> 的檔案式 cron，確認新 OS 的 cron daemon 支援同樣的格式。Cron 的環境變數（PATH、MAILTO）在不同 OS 可能有不同預設。</p>
<h3 id="日誌路徑">日誌路徑</h3>
<p>Apache 的預設 log 路徑在 RHEL 系是 <code>/var/log/httpd/</code>、Debian 系是 <code>/var/log/apache2/</code>。應用程式如果 hardcode 了日誌路徑，要在新 OS 上對齊。同時確認 logrotate 的設定在新 OS 上存在且正確。</p>
<h3 id="檔案權限與使用者">檔案權限與使用者</h3>
<p>不同 OS 的 web server 執行使用者不同（RHEL 的 <code>apache</code>、Debian 的 <code>www-data</code>）。如果應用依賴特定使用者名稱的檔案權限（如 upload 目錄的 owner），遷移後要調整 <code>chown</code>。</p>
<h3 id="服務管理">服務管理</h3>
<p>現代 OS 都使用 systemd。但如果舊 OS 還有 sysvinit 腳本（<code>/etc/init.d/</code>），遷移時要轉換成 systemd unit file。轉換的核心是把 init 腳本的 start/stop/restart 邏輯對應到 systemd 的 <code>ExecStart</code>、<code>ExecStop</code>、<code>Restart</code> 欄位。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># /etc/systemd/system/myapp.service</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">My Application</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="na">After</span><span class="o">=</span><span class="s">network.target mysql.service</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">[Service]</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">simple</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="na">User</span><span class="o">=</span><span class="s">www-data</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/usr/bin/php /var/www/myapp/worker.php</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="na">Restart</span><span class="o">=</span><span class="s">on-failure</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="na">RestartSec</span><span class="o">=</span><span class="s">5</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="k">[Install]</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="na">WantedBy</span><span class="o">=</span><span class="s">multi-user.target</span></span></span></code></pre></div><h2 id="基礎軟體切換apache--nginx">基礎軟體切換（Apache → nginx）</h2>
<p>如果已經在為 OS 遷移建新伺服器，同時切換 web server 是成本最低的時機——反正設定檔要重寫、不如一次到位。分開做的話要拆兩次遷移、測兩次、承受兩次風險。</p>
<h3 id="htaccess--nginx-設定轉換">.htaccess → nginx 設定轉換</h3>
<p>Apache 的 .htaccess 是分散式設定——每個目錄可以有自己的 <code>.htaccess</code>，Apache 在每次請求時逐層讀取。nginx 沒有這個機制，所有設定集中在 <code>/etc/nginx/</code> 的設定檔裡。</p>
<p>轉換的第一步是找出所有 .htaccess 檔案：</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">find /var/www/ -name <span class="s2">&#34;.htaccess&#34;</span> -exec <span class="nb">echo</span> <span class="s2">&#34;=== {} ===&#34;</span> <span class="se">\;</span> -exec cat <span class="o">{}</span> <span class="se">\;</span></span></span></code></pre></div><p>常見的轉換對應：</p>
<table>
  <thead>
      <tr>
          <th>Apache .htaccess</th>
          <th>nginx 對應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>RewriteRule ^old$ /new [R=301]</code></td>
          <td><code>rewrite ^/old$ /new permanent;</code></td>
      </tr>
      <tr>
          <td><code>RewriteCond %{HTTPS} off</code> + <code>RewriteRule</code></td>
          <td><code>if ($scheme = http) { return 301 https://...; }</code></td>
      </tr>
      <tr>
          <td><code>Options -Indexes</code></td>
          <td><code>autoindex off;</code>（通常是預設）</td>
      </tr>
      <tr>
          <td><code>php_flag engine off</code></td>
          <td><code>location /uploads/ { deny all; }</code> 或不傳給 PHP</td>
      </tr>
      <tr>
          <td><code>&lt;Files .env&gt;</code> + <code>Deny from all</code></td>
          <td><code>location ~ /\.env { deny all; }</code></td>
      </tr>
      <tr>
          <td><code>AuthType Basic</code> + <code>.htpasswd</code></td>
          <td><code>auth_basic</code> + <code>auth_basic_user_file</code></td>
      </tr>
  </tbody>
</table>
<h3 id="平行測試">平行測試</h3>
<p>在新伺服器上同時安裝 nginx（port 80）和 Apache（port 8080）。用 curl 比對兩者的回應：</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">diff &lt;<span class="o">(</span>curl -s http://new-server/<span class="o">)</span> &lt;<span class="o">(</span>curl -s http://new-server:8080/<span class="o">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 比對一個有 rewrite 規則的 URL</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">diff &lt;<span class="o">(</span>curl -sI http://new-server/old-path<span class="o">)</span> &lt;<span class="o">(</span>curl -sI http://new-server:8080/old-path<span class="o">)</span></span></span></code></pre></div><p>回應一致後再把 Apache 移除。重點比對項：HTTP status code（rewrite 的 301/302）、response body（PHP 輸出）、response header（cache control、security header）。</p>
<h3 id="常見陷阱">常見陷阱</h3>
<p>.htaccess 的分散式設定在 WordPress 或其他 CMS 中常被用來動態控制 URL rewrite。WordPress 的 permalink 功能依賴根目錄的 <code>.htaccess</code>，切到 nginx 需要在設定檔裡加 <code>try_files $uri $uri/ /index.php?$args;</code> 才能讓 permalink 運作。其他 CMS（Drupal、Laravel）也有各自的 nginx 設定範例，通常在官方文件裡可以找到。</p>
<h2 id="時程與管理層溝通">時程與管理層溝通</h2>
<p>OS 遷移（平行建置）的時程取決於應用層的複雜度：</p>
<table>
  <thead>
      <tr>
          <th>環境複雜度</th>
          <th>時程估算</th>
          <th>典型特徵</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>簡單</td>
          <td>1-2 週</td>
          <td>單一 web app、標準 LAMP/LEMP stack</td>
      </tr>
      <tr>
          <td>中等</td>
          <td>2-3 週</td>
          <td>多個服務、自訂套件、cron 密集</td>
      </tr>
      <tr>
          <td>複雜</td>
          <td>3-4 週</td>
          <td>多台伺服器、叢集、自建 daemon</td>
      </tr>
  </tbody>
</table>
<p>跟管理層溝通時用三個框架：</p>
<p><strong>為什麼現在做</strong>：「目前的 OS 已經停止安全修補，每個月不遷移等於多一個月的曝險窗口。如果有合規要求（PCI DSS / SOC 2），下次稽核會被標記。」</p>
<p><strong>做什麼</strong>：「在旁邊建一台新 OS 的伺服器，把應用搬過去、驗證通過後切換。舊伺服器保留一到兩週作為 rollback。」</p>
<p><strong>花多久和多少錢</strong>：「工程師時間 1-3 週（依複雜度）。多一台伺服器的費用只有切換期間的短期成本。不做的隱藏成本是安全事故的潛在損失和合規罰款。」</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/upgrade/upgrade-framework/" data-link-title="升級的共通操作框架" data-link-desc="任何環境或系統升級的四階段模型：差異評估、平行環境驗證、分批切換、退役舊環境，以及貫穿全程的升級紀律">升級的共通操作框架</a>：四階段模型（評估差異 → 平行環境 → 分批切換 → 退役）</li>
<li>→ <a href="/blog/infra/upgrade/platform-migration/" data-link-title="平台遷移" data-link-desc="FTP 面板主機到 VPS、VPS 到雲端、地端到雲端的遷移路徑 — 資料同步策略、DNS 切換、驗證與回退">平台遷移</a>：如果 OS 遷移同時伴隨平台搬遷（地端 → 雲端）</li>
<li>→ <a href="/blog/infra/upgrade/runtime-version-upgrade/" data-link-title="Runtime 版本升級" data-link-desc="PHP / Node.js / Python 大版本升級的相容性評估、本地驗證、分批部署策略與常見陷阱">Runtime 版本升級</a>：PHP / Node 版本升級常伴隨 OS 遷移</li>
<li>→ <a href="/blog/infra/takeover/" data-link-title="接手維運：別人建的環境怎麼接管" data-link-desc="接手前人的專案時，怎麼在不搞壞東西的前提下盤點現況、建立維運能力、逐步正規化 — 從無 SSH 的 FTP 環境到有半套 IaC 的雲端環境都適用">接手維運</a>：接手一個 EOL OS 的環境後的下一步</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>Subnet（子網路）</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/subnet/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/subnet/</guid><description>&lt;p>Subnet 是 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/vpc/" data-link-title="VPC（Virtual Private Cloud）" data-link-desc="雲端帳號內的一塊邏輯隔離私有網段，是所有網路切分的起點與容器">VPC&lt;/a> 內部按可用區（Availability Zone）與暴露程度切出來的子網段。一塊資源對外暴露到什麼程度，取決於它被放進哪個 subnet——技術上的差別在於該 subnet 關聯的 route table 裡有沒有一條指向 Internet Gateway 的預設路由。&lt;/p>
&lt;p>Subnet 分兩類：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Public subnet&lt;/strong>：route table 有 &lt;code>0.0.0.0/0 → Internet Gateway&lt;/code>，讓資源能被外部 IP 直接觸及。典型住戶是對外負載平衡器、&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="讓 private subnet 的資源主動對外連線、同時不被外部入站觸及的網路地址轉換服務">NAT&lt;/a> Gateway。&lt;/li>
&lt;li>&lt;strong>Private subnet&lt;/strong>：route table 把 &lt;code>0.0.0.0/0&lt;/code> 指向 NAT Gateway，外部無法主動連入。典型住戶是應用伺服器、資料庫、快取。&lt;/li>
&lt;/ul>
&lt;p>Public subnet 的真實樣貌是「薄薄一層」——它通常只住入口設施，業務邏輯跟資料儲存都在 private subnet。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Subnet 是&lt;a href="https://tarrragon.github.io/blog/infra/03-network-foundation/vpc-subnet-security-group/" data-link-title="網路地基 — VPC、subnet 分層與 security group 設計" data-link-desc="VPC CIDR 規劃、public / private subnet 切分、route table 與 NAT 的可用性成本取捨、security group 最小開放設計，以及 NACL 的定位">模組三：網路地基&lt;/a>的中層邊界。VPC 定好地址空間後，subnet 決定「哪些資源能被外網碰到、哪些只能在內網存取」。每個 subnet 綁定單一可用區，高可用設計通常是每種角色跨至少兩個可用區各開一個 subnet。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>Subnet 配置有問題的訊號：應用伺服器被放在 public subnet 並配了公網 IP（管理埠暴露在掃描流量下）、private subnet 的服務拉不到外部套件（route table 沒指向健康的 NAT）、新服務上線時找不到適合的 subnet（CIDR 切得太小、空間不夠）。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>規劃 subnet 時要決定：&lt;/p>
&lt;ul>
&lt;li>CIDR 切法：VPC 是 &lt;code>/16&lt;/code> 時，每個 subnet 用 &lt;code>/20&lt;/code>（約四千位址）可以在三個可用區各開 public + private 共六個 subnet&lt;/li>
&lt;li>跨可用區對稱：每種角色至少跨兩個 AZ，讓單一 AZ 故障時另一區能承接&lt;/li>
&lt;li>public 的住戶限制：只放入口設施，業務邏輯一律放 private&lt;/li>
&lt;/ul>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/vpc/" data-link-title="VPC（Virtual Private Cloud）" data-link-desc="雲端帳號內的一塊邏輯隔離私有網段，是所有網路切分的起點與容器">VPC&lt;/a> — subnet 的容器&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="讓 private subnet 的資源主動對外連線、同時不被外部入站觸及的網路地址轉換服務">NAT&lt;/a> — 讓 private subnet 出站的機制&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group&lt;/a> — 掛在資源上的埠級存取控制&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Subnet 是 <a href="/blog/infra/knowledge-cards/vpc/" data-link-title="VPC（Virtual Private Cloud）" data-link-desc="雲端帳號內的一塊邏輯隔離私有網段，是所有網路切分的起點與容器">VPC</a> 內部按可用區（Availability Zone）與暴露程度切出來的子網段。一塊資源對外暴露到什麼程度，取決於它被放進哪個 subnet——技術上的差別在於該 subnet 關聯的 route table 裡有沒有一條指向 Internet Gateway 的預設路由。</p>
<p>Subnet 分兩類：</p>
<ul>
<li><strong>Public subnet</strong>：route table 有 <code>0.0.0.0/0 → Internet Gateway</code>，讓資源能被外部 IP 直接觸及。典型住戶是對外負載平衡器、<a href="/blog/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="讓 private subnet 的資源主動對外連線、同時不被外部入站觸及的網路地址轉換服務">NAT</a> Gateway。</li>
<li><strong>Private subnet</strong>：route table 把 <code>0.0.0.0/0</code> 指向 NAT Gateway，外部無法主動連入。典型住戶是應用伺服器、資料庫、快取。</li>
</ul>
<p>Public subnet 的真實樣貌是「薄薄一層」——它通常只住入口設施，業務邏輯跟資料儲存都在 private subnet。</p>
<h2 id="概念位置">概念位置</h2>
<p>Subnet 是<a href="/blog/infra/03-network-foundation/vpc-subnet-security-group/" data-link-title="網路地基 — VPC、subnet 分層與 security group 設計" data-link-desc="VPC CIDR 規劃、public / private subnet 切分、route table 與 NAT 的可用性成本取捨、security group 最小開放設計，以及 NACL 的定位">模組三：網路地基</a>的中層邊界。VPC 定好地址空間後，subnet 決定「哪些資源能被外網碰到、哪些只能在內網存取」。每個 subnet 綁定單一可用區，高可用設計通常是每種角色跨至少兩個可用區各開一個 subnet。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>Subnet 配置有問題的訊號：應用伺服器被放在 public subnet 並配了公網 IP（管理埠暴露在掃描流量下）、private subnet 的服務拉不到外部套件（route table 沒指向健康的 NAT）、新服務上線時找不到適合的 subnet（CIDR 切得太小、空間不夠）。</p>
<h2 id="設計責任">設計責任</h2>
<p>規劃 subnet 時要決定：</p>
<ul>
<li>CIDR 切法：VPC 是 <code>/16</code> 時，每個 subnet 用 <code>/20</code>（約四千位址）可以在三個可用區各開 public + private 共六個 subnet</li>
<li>跨可用區對稱：每種角色至少跨兩個 AZ，讓單一 AZ 故障時另一區能承接</li>
<li>public 的住戶限制：只放入口設施，業務邏輯一律放 private</li>
</ul>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/vpc/" data-link-title="VPC（Virtual Private Cloud）" data-link-desc="雲端帳號內的一塊邏輯隔離私有網段，是所有網路切分的起點與容器">VPC</a> — subnet 的容器</li>
<li><a href="/blog/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="讓 private subnet 的資源主動對外連線、同時不被外部入站觸及的網路地址轉換服務">NAT</a> — 讓 private subnet 出站的機制</li>
<li><a href="/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group</a> — 掛在資源上的埠級存取控制</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>斷網環境要自建的服務清單</title><link>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-self-hosted-services/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-self-hosted-services/</guid><description>&lt;p>連網環境的 infra 團隊消費數十個 SaaS 服務：程式碼放 GitHub、CI 用 GitHub Actions、套件從 npm 和 PyPI 拉、container image 從 Docker Hub pull、憑證用 Let&amp;rsquo;s Encrypt 自動簽、監控用 Datadog。這些服務的共同特性是「有人幫你維護」——infra 團隊只需要設定和使用，不需要部署、升級、備份。&lt;/p>
&lt;p>斷網環境裡這些服務全部要自建。每一個 SaaS 變成一個內部服務，infra 團隊承擔它的部署、設定、升級、備份、監控和使用者管理。這篇文章盤點完整的服務清單、推薦的自建工具、部署順序，以及容易被低估的維護成本。&lt;/p>
&lt;h2 id="服務清單與選型">服務清單與選型&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>服務類別&lt;/th>
 &lt;th>連網環境的 SaaS&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>GitHub / GitLab.com&lt;/td>
 &lt;td>GitLab CE / Gitea&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>月級更新&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CI/CD&lt;/td>
 &lt;td>GitHub Actions&lt;/td>
 &lt;td>Jenkins / GitLab CI&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>週級維護&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>套件 registry&lt;/td>
 &lt;td>npm / PyPI / Maven / apt&lt;/td>
 &lt;td>Nexus Repository&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>月級更新&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>容器 registry&lt;/td>
 &lt;td>Docker Hub / ECR&lt;/td>
 &lt;td>Harbor / Docker Registry&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>月級更新&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>內部 CA&lt;/td>
 &lt;td>Let&amp;rsquo;s Encrypt&lt;/td>
 &lt;td>step-ca / cfssl&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>季級輪替&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>內部 DNS&lt;/td>
 &lt;td>Route 53 / Cloud DNS&lt;/td>
 &lt;td>CoreDNS / BIND&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>變更時維護&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>時間同步&lt;/td>
 &lt;td>pool.ntp.org&lt;/td>
 &lt;td>chrony&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>部署後極少&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>監控&lt;/td>
 &lt;td>Datadog / New Relic&lt;/td>
 &lt;td>Prometheus + Grafana + Loki&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>週級維護&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>機密管理&lt;/td>
 &lt;td>AWS Secrets Manager&lt;/td>
 &lt;td>HashiCorp Vault&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>月級維護&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>IaC state 後端&lt;/td>
 &lt;td>S3 + DynamoDB&lt;/td>
 &lt;td>PostgreSQL / Consul&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>變更時維護&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「部署複雜度」指首次部署到可用狀態的工程量。「維護頻率」指部署完成後的持續性工作——安全更新、容量擴充、故障排查。&lt;/p>
&lt;h3 id="各服務的選型判斷">各服務的選型判斷&lt;/h3>
&lt;p>&lt;strong>版本控制&lt;/strong>：GitLab CE 功能完整（含 CI/CD、container registry、package registry），但資源消耗大（建議 4 核 / 8GB 以上）。Gitea 輕量（512MB 記憶體可跑），適合小團隊或只需要 Git hosting 的情境。如果選 GitLab CE，版控 + CI/CD + registry 可以用同一個實例，減少部署數量。&lt;/p>
&lt;p>&lt;strong>CI/CD&lt;/strong>：如果已部署 GitLab CE，內建的 GitLab CI 是最低成本的選擇——Runner 裝在同一網段的機器上即可。Jenkins 的生態更大（plugin 多），但 plugin 的離線安裝和更新需要額外的搬運流程。&lt;/p>
&lt;p>&lt;strong>套件 registry&lt;/strong>：Nexus Repository 是斷網環境的首選，因為它用一個實例同時支援 apt / yum / npm / Maven / PyPI / Docker / Helm——維護一個服務取代六個獨立的離線 repo mirror。Artifactory 是商業替代品，功能相似但需要授權費。&lt;/p>
&lt;p>&lt;strong>容器 registry&lt;/strong>：Harbor 提供映像掃描（整合 Trivy）、RBAC、複寫、稽核 log。如果只需要儲存和拉取映像、不需要掃描和稽核，Docker Registry（開源）足夠。&lt;/p>
&lt;p>&lt;strong>內部 CA&lt;/strong>：step-ca 支援 ACME 協定（跟 Let&amp;rsquo;s Encrypt 相同的自動簽發流程），內部服務可以用跟外部一樣的 certbot 工具自動續期。cfssl 是更輕量的選擇但沒有 ACME 支援、需要手動或腳本續期。&lt;/p></description><content:encoded><![CDATA[<p>連網環境的 infra 團隊消費數十個 SaaS 服務：程式碼放 GitHub、CI 用 GitHub Actions、套件從 npm 和 PyPI 拉、container image 從 Docker Hub pull、憑證用 Let&rsquo;s Encrypt 自動簽、監控用 Datadog。這些服務的共同特性是「有人幫你維護」——infra 團隊只需要設定和使用，不需要部署、升級、備份。</p>
<p>斷網環境裡這些服務全部要自建。每一個 SaaS 變成一個內部服務，infra 團隊承擔它的部署、設定、升級、備份、監控和使用者管理。這篇文章盤點完整的服務清單、推薦的自建工具、部署順序，以及容易被低估的維護成本。</p>
<h2 id="服務清單與選型">服務清單與選型</h2>
<table>
  <thead>
      <tr>
          <th>服務類別</th>
          <th>連網環境的 SaaS</th>
          <th>自建替代</th>
          <th>部署複雜度</th>
          <th>維護頻率</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>版本控制</td>
          <td>GitHub / GitLab.com</td>
          <td>GitLab CE / Gitea</td>
          <td>中</td>
          <td>月級更新</td>
      </tr>
      <tr>
          <td>CI/CD</td>
          <td>GitHub Actions</td>
          <td>Jenkins / GitLab CI</td>
          <td>高</td>
          <td>週級維護</td>
      </tr>
      <tr>
          <td>套件 registry</td>
          <td>npm / PyPI / Maven / apt</td>
          <td>Nexus Repository</td>
          <td>中</td>
          <td>月級更新</td>
      </tr>
      <tr>
          <td>容器 registry</td>
          <td>Docker Hub / ECR</td>
          <td>Harbor / Docker Registry</td>
          <td>中</td>
          <td>月級更新</td>
      </tr>
      <tr>
          <td>內部 CA</td>
          <td>Let&rsquo;s Encrypt</td>
          <td>step-ca / cfssl</td>
          <td>低</td>
          <td>季級輪替</td>
      </tr>
      <tr>
          <td>內部 DNS</td>
          <td>Route 53 / Cloud DNS</td>
          <td>CoreDNS / BIND</td>
          <td>低</td>
          <td>變更時維護</td>
      </tr>
      <tr>
          <td>時間同步</td>
          <td>pool.ntp.org</td>
          <td>chrony</td>
          <td>低</td>
          <td>部署後極少</td>
      </tr>
      <tr>
          <td>監控</td>
          <td>Datadog / New Relic</td>
          <td>Prometheus + Grafana + Loki</td>
          <td>高</td>
          <td>週級維護</td>
      </tr>
      <tr>
          <td>機密管理</td>
          <td>AWS Secrets Manager</td>
          <td>HashiCorp Vault</td>
          <td>高</td>
          <td>月級維護</td>
      </tr>
      <tr>
          <td>IaC state 後端</td>
          <td>S3 + DynamoDB</td>
          <td>PostgreSQL / Consul</td>
          <td>低</td>
          <td>變更時維護</td>
      </tr>
  </tbody>
</table>
<p>「部署複雜度」指首次部署到可用狀態的工程量。「維護頻率」指部署完成後的持續性工作——安全更新、容量擴充、故障排查。</p>
<h3 id="各服務的選型判斷">各服務的選型判斷</h3>
<p><strong>版本控制</strong>：GitLab CE 功能完整（含 CI/CD、container registry、package registry），但資源消耗大（建議 4 核 / 8GB 以上）。Gitea 輕量（512MB 記憶體可跑），適合小團隊或只需要 Git hosting 的情境。如果選 GitLab CE，版控 + CI/CD + registry 可以用同一個實例，減少部署數量。</p>
<p><strong>CI/CD</strong>：如果已部署 GitLab CE，內建的 GitLab CI 是最低成本的選擇——Runner 裝在同一網段的機器上即可。Jenkins 的生態更大（plugin 多），但 plugin 的離線安裝和更新需要額外的搬運流程。</p>
<p><strong>套件 registry</strong>：Nexus Repository 是斷網環境的首選，因為它用一個實例同時支援 apt / yum / npm / Maven / PyPI / Docker / Helm——維護一個服務取代六個獨立的離線 repo mirror。Artifactory 是商業替代品，功能相似但需要授權費。</p>
<p><strong>容器 registry</strong>：Harbor 提供映像掃描（整合 Trivy）、RBAC、複寫、稽核 log。如果只需要儲存和拉取映像、不需要掃描和稽核，Docker Registry（開源）足夠。</p>
<p><strong>內部 CA</strong>：step-ca 支援 ACME 協定（跟 Let&rsquo;s Encrypt 相同的自動簽發流程），內部服務可以用跟外部一樣的 certbot 工具自動續期。cfssl 是更輕量的選擇但沒有 ACME 支援、需要手動或腳本續期。</p>
<p><strong>內部 DNS</strong>：CoreDNS 用設定檔驅動、輕量、適合 Kubernetes 環境。BIND 是傳統選擇、功能完整但設定複雜。多數斷網環境的 DNS 需求簡單（幾十筆 A record），CoreDNS 的 file plugin 足夠。</p>
<p><strong>時間同步</strong>：chrony 是 NTP 的現代替代——啟動快、適應性強、低資源。內網裡指定一台機器當 NTP server（stratum 1 如果有 GPS 時鐘、stratum 2 如果手動校時），其他機器指向它。時間不同步會讓 log correlation 失效、TLS 憑證驗證失敗、Kerberos 認證拒絕。</p>
<p><strong>監控</strong>：Prometheus（metric 收集）+ Grafana（視覺化）+ Loki（log 聚合）是最常見的 self-hosted 監控組合。三者都支援離線部署、不需要外部依賴。詳見<a href="/blog/infra/air-gapped/air-gapped-monitoring/" data-link-title="斷網環境的監控與可觀測性" data-link-desc="Self-hosted 監控（Prometheus &#43; Grafana）、離線 log 收集（Loki / ELK）、不能 phone home 的告警、NTP 時間同步">斷網環境的監控與可觀測性</a>。</p>
<p><strong>機密管理</strong>：HashiCorp Vault 提供 secret 儲存、動態 secret 產生、PKI、加密即服務。部署和維護複雜度高——Vault 本身需要 unseal、HA 需要 Raft 或 Consul 後端、稽核 log 需要儲存規劃。如果機密數量少且變更不頻繁，加密的 ansible-vault 或 git-crypt 是輕量替代。</p>
<p><strong>IaC state 後端</strong>：PostgreSQL 是 Terraform 支援的 state backend 之一（<code>backend &quot;pg&quot;</code>），斷網環境裡用既有的 PostgreSQL 實例存 state、用 PostgreSQL 的 advisory lock 防並行。比自建 S3 + DynamoDB 簡單得多。Consul 是另一個選擇（Terraform 原生支援），但引入 Consul 只為了存 state 的 ROI 通常不划算、除非環境裡已經有 Consul 跑 service discovery。</p>
<h2 id="部署順序">部署順序</h2>
<p>服務之間有依賴關係，部署順序由依賴方向決定：</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">第一層（基礎設施服務）
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  DNS → 所有服務都需要名稱解析
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  NTP → 所有服務都需要時間同步
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  CA  → 所有服務都需要 TLS 憑證
</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">  版本控制 → 程式碼要有地方存才能跑 CI
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  套件 + 容器 registry → build 需要依賴
</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">  CI/CD → 依賴版控 + registry
</span></span><span class="line"><span class="ln">12</span><span class="cl">  IaC state backend → Terraform 需要 state 存放處
</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><span class="line"><span class="ln">15</span><span class="cl">  機密管理 → 其他服務的 secret 集中管理
</span></span><span class="line"><span class="ln">16</span><span class="cl">  監控 → 監控所有上述服務的健康</span></span></code></pre></div><p>第一層的三個服務可以平行部署——它們彼此不依賴。第四層的監控放最後是因為它要監控的對象都還沒就位時、設定 target 沒有意義。</p>
<p>每一層部署完成後做一次整體驗證（所有服務能互相連通、TLS 正常、時間同步），再進下一層。</p>
<h2 id="統一管理-vs-個別部署">統一管理 vs 個別部署</h2>
<p>GitLab CE 把版控、CI/CD、container registry、package registry 打包在一個實例裡。用 GitLab CE 取代四個獨立服務的優缺點：</p>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>統一（GitLab CE）</th>
          <th>個別部署</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>部署成本</td>
          <td>部署 1 個服務</td>
          <td>部署 4 個服務</td>
      </tr>
      <tr>
          <td>維護</td>
          <td>升級 1 個服務</td>
          <td>各自升級週期</td>
      </tr>
      <tr>
          <td>資源消耗</td>
          <td>單機 8GB+ 記憶體</td>
          <td>分散在多台</td>
      </tr>
      <tr>
          <td>故障半徑</td>
          <td>GitLab 掛 = 版控 + CI + registry 全停</td>
          <td>某一個掛不影響其他</td>
      </tr>
      <tr>
          <td>靈活性</td>
          <td>綁 GitLab 生態</td>
          <td>各服務可獨立替換</td>
      </tr>
  </tbody>
</table>
<p>小團隊（5-15 人）的斷網環境，GitLab CE 統一管理的 ROI 通常較高——維護一個服務比維護四個省力，故障半徑的風險靠備份和 HA（GitLab 支援 Geo replication）緩解。</p>
<p>大團隊或高安全環境，個別部署的隔離性較好——CI runner 跟版控分開、registry 跟 CI 分開，每個服務的存取控制和稽核獨立。</p>
<p>同樣的邏輯適用於 Nexus：它用一個實例服務 6 種格式的套件，比為每種格式各建一個離線 mirror 省力。</p>
<h2 id="維護的隱藏成本">維護的隱藏成本</h2>
<p>自建服務的維護成本容易被低估，因為部署完成時感覺「已經做完了」，但持續性維護才剛開始。每個自建服務需要：</p>
<table>
  <thead>
      <tr>
          <th>維護項目</th>
          <th>頻率</th>
          <th>漏做的後果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>安全更新</td>
          <td>月級</td>
          <td>已知漏洞暴露在內網（斷網不代表零風險）</td>
      </tr>
      <tr>
          <td>備份</td>
          <td>日級</td>
          <td>服務掛了資料沒了</td>
      </tr>
      <tr>
          <td>容量監控</td>
          <td>週級</td>
          <td>磁碟滿了服務停擺</td>
      </tr>
      <tr>
          <td>憑證續期</td>
          <td>季級</td>
          <td>TLS 過期、服務拒絕連線</td>
      </tr>
      <tr>
          <td>使用者管理</td>
          <td>變更時</td>
          <td>離職員工仍有存取權</td>
      </tr>
      <tr>
          <td>監控的監控</td>
          <td>持續</td>
          <td>監控系統本身掛了沒人知道</td>
      </tr>
  </tbody>
</table>
<p>10 個自建服務各自都有這六項維護需求。時程參考：每月的例行維護（安全更新 + 備份驗證 + 容量檢查）約需 2-3 天工程師時間。這筆時間是隱性的——不在任何 sprint 或 ticket 裡，但不做的後果是累積的。</p>
<p>管理層溝通時的關鍵數字：自建 10 個服務的維護成本約等於 0.3-0.5 個全職工程師。這筆人力投入是斷網環境的結構性成本，跟應用開發無關。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">斷網環境的通用原則</a>：內容搬運、離線套件管理的共通模式</li>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-iac/" data-link-title="斷網環境的 IaC" data-link-desc="Terraform provider mirror、離線 plugin cache、本地 state backend、沒有雲端時的 plan/apply 流程與內網 CI">斷網環境的 IaC</a>：state backend（PostgreSQL）和 CI 的詳細設定</li>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-container/" data-link-title="斷網環境的容器與映像管理" data-link-desc="Private registry 架設、映像搬運（docker save/load、skopeo）、base image 更新週期、離線漏洞掃描">斷網環境的容器與映像管理</a>：Harbor 和映像搬運的詳細操作</li>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-monitoring/" data-link-title="斷網環境的監控與可觀測性" data-link-desc="Self-hosted 監控（Prometheus &#43; Grafana）、離線 log 收集（Loki / ELK）、不能 phone home 的告警、NTP 時間同步">斷網環境的監控與可觀測性</a>：Prometheus + Grafana + Loki 的部署</li>
<li>→ <a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>：Vault 的身分管理與 infra IAM 的關係</li>
<li>→ <a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>：自建服務的 secret 管理與成本歸因</li>
</ul>
]]></content:encoded></item><item><title>ACM 憑證、DNS 與 HTTPS 設定</title><link>https://tarrragon.github.io/blog/infra/05-core-services/acm-tls-dns-setup/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/05-core-services/acm-tls-dns-setup/</guid><description>&lt;p>HTTPS 的運作需要三個元件配合：一個管理網域記錄的 DNS zone、一張證明網域所有權的 TLS 憑證、以及一個用這張憑證終結 TLS 連線的入口（ALB listener）。這三者在 IaC 裡各自是獨立資源，但建立順序有依賴——zone 先存在、憑證才能用 DNS 驗證、驗證通過才能掛到 listener。把這條鏈路寫進 Terraform，讓憑證的申請、驗證與續期都在版本控制裡，是避免「憑證過期才發現沒人盯」的結構性做法。&lt;/p>
&lt;h2 id="route-53-hosted-zone">Route 53 Hosted Zone&lt;/h2>
&lt;p>Hosted zone 是 Route 53 用來管理某個網域的 DNS 記錄集合。建立 zone 後，Route 53 會分配一組 NS（Name Server）記錄，網域的 DNS 解析就由這組 NS 負責。&lt;/p>
&lt;h3 id="public-vs-private-zone">Public vs Private Zone&lt;/h3>
&lt;p>Public hosted zone 對應的是可從網際網路解析的網域（如 &lt;code>example.com&lt;/code>），用於對外服務的 A / CNAME / MX 記錄。Private hosted zone 只在指定的 VPC 內可解析，用於內部服務發現（如 &lt;code>db.internal.example.com&lt;/code> 解析到 RDS 的 private IP）。多數專案兩者都需要：public zone 給對外流量、private zone 給內部服務互連。&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_route53_zone&amp;#34; &amp;#34;public&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;example.com&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"> tags&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> { Environment&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;production&amp;#34;&lt;/span> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_route53_zone&amp;#34; &amp;#34;private&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;internal.example.com&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 class="k">vpc&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"> vpc_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_vpc&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">main&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">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 class="n"> tags&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> { Environment&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;production&amp;#34;&lt;/span> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="子網域-delegation">子網域 delegation&lt;/h3>
&lt;p>當 dev / staging / prod 各用獨立帳號時，每個帳號建自己的 hosted zone 管理子網域（如 &lt;code>dev.example.com&lt;/code>）。父網域的 zone 需要加一組 NS 記錄指向子網域的 zone，這個動作叫 delegation。&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_route53_record&amp;#34; &amp;#34;dev_ns&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"> zone_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_route53_zone&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">public&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">zone_id&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"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;dev.example.com&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"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;NS&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"> ttl&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">300&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"> records&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_route53_zone&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dev&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">name_servers&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;p>delegation 的 NS 記錄指向子帳號 zone 的 name server。子帳號內的所有 DNS 記錄（如 &lt;code>api.dev.example.com&lt;/code>）由子帳號的 zone 管理，父帳號不需要逐條設定。跨帳號 delegation 需要兩邊的 Terraform 各自管理自己的 zone，NS 記錄在父帳號的 state 裡。&lt;/p>
&lt;p>判讀設定是否正確：用 &lt;code>dig dev.example.com NS&lt;/code> 查回的 name server 應該是子帳號 zone 的 NS，不是父帳號的。如果查回父帳號的 NS，代表 delegation 沒生效，子網域的 DNS 記錄不會被解析。&lt;/p>
&lt;h2 id="acm-憑證申請與-dns-驗證">ACM 憑證申請與 DNS 驗證&lt;/h2>
&lt;p>AWS Certificate Manager（ACM）提供免費的 TLS 憑證，條件是透過 DNS 或 email 驗證網域所有權。DNS 驗證是 IaC 友善的方式——ACM 要求在指定網域下建一條 CNAME 記錄，記錄值由 ACM 提供，驗證通過後憑證自動簽發。&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_acm_certificate&amp;#34; &amp;#34;main&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"> domain_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;example.com&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"> subject_alternative_names&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;*.example.com&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 class="n"> validation_method&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;DNS&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">lifecycle&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"> create_before_destroy&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"> 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="n"> tags&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> { Environment&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;production&amp;#34;&lt;/span> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>subject_alternative_names&lt;/code> 加 &lt;code>*.example.com&lt;/code> 讓同一張憑證涵蓋所有子網域（如 &lt;code>api.example.com&lt;/code>、&lt;code>admin.example.com&lt;/code>），省去為每個子網域各申請一張。&lt;/p></description><content:encoded><![CDATA[<p>HTTPS 的運作需要三個元件配合：一個管理網域記錄的 DNS zone、一張證明網域所有權的 TLS 憑證、以及一個用這張憑證終結 TLS 連線的入口（ALB listener）。這三者在 IaC 裡各自是獨立資源，但建立順序有依賴——zone 先存在、憑證才能用 DNS 驗證、驗證通過才能掛到 listener。把這條鏈路寫進 Terraform，讓憑證的申請、驗證與續期都在版本控制裡，是避免「憑證過期才發現沒人盯」的結構性做法。</p>
<h2 id="route-53-hosted-zone">Route 53 Hosted Zone</h2>
<p>Hosted zone 是 Route 53 用來管理某個網域的 DNS 記錄集合。建立 zone 後，Route 53 會分配一組 NS（Name Server）記錄，網域的 DNS 解析就由這組 NS 負責。</p>
<h3 id="public-vs-private-zone">Public vs Private Zone</h3>
<p>Public hosted zone 對應的是可從網際網路解析的網域（如 <code>example.com</code>），用於對外服務的 A / CNAME / MX 記錄。Private hosted zone 只在指定的 VPC 內可解析，用於內部服務發現（如 <code>db.internal.example.com</code> 解析到 RDS 的 private IP）。多數專案兩者都需要：public zone 給對外流量、private zone 給內部服務互連。</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;public&#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;example.com&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  tags</span> <span class="o">=</span><span class="n"> { Environment</span> <span class="o">=</span> <span class="s2">&#34;production&#34;</span> }
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">}
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_route53_zone&#34; &#34;private&#34;</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;internal.example.com&#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">vpc</span> {
</span></span><span class="line"><span class="ln">10</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">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 class="n">  tags</span> <span class="o">=</span><span class="n"> { Environment</span> <span class="o">=</span> <span class="s2">&#34;production&#34;</span> }
</span></span><span class="line"><span class="ln">14</span><span class="cl">}</span></span></code></pre></div><h3 id="子網域-delegation">子網域 delegation</h3>
<p>當 dev / staging / prod 各用獨立帳號時，每個帳號建自己的 hosted zone 管理子網域（如 <code>dev.example.com</code>）。父網域的 zone 需要加一組 NS 記錄指向子網域的 zone，這個動作叫 delegation。</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;dev_ns&#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">aws_route53_zone</span><span class="p">.</span><span class="k">public</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;dev.example.com&#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;NS&#34;</span>
</span></span><span class="line"><span class="ln">5</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">6</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">dev</span><span class="p">.</span><span class="k">name_servers</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">}</span></span></code></pre></div><p>delegation 的 NS 記錄指向子帳號 zone 的 name server。子帳號內的所有 DNS 記錄（如 <code>api.dev.example.com</code>）由子帳號的 zone 管理，父帳號不需要逐條設定。跨帳號 delegation 需要兩邊的 Terraform 各自管理自己的 zone，NS 記錄在父帳號的 state 裡。</p>
<p>判讀設定是否正確：用 <code>dig dev.example.com NS</code> 查回的 name server 應該是子帳號 zone 的 NS，不是父帳號的。如果查回父帳號的 NS，代表 delegation 沒生效，子網域的 DNS 記錄不會被解析。</p>
<h2 id="acm-憑證申請與-dns-驗證">ACM 憑證申請與 DNS 驗證</h2>
<p>AWS Certificate Manager（ACM）提供免費的 TLS 憑證，條件是透過 DNS 或 email 驗證網域所有權。DNS 驗證是 IaC 友善的方式——ACM 要求在指定網域下建一條 CNAME 記錄，記錄值由 ACM 提供，驗證通過後憑證自動簽發。</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;main&#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;example.com&#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;*.example.com&#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="k">lifecycle</span> {
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">    create_before_destroy</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></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">  tags</span> <span class="o">=</span><span class="n"> { Environment</span> <span class="o">=</span> <span class="s2">&#34;production&#34;</span> }
</span></span><span class="line"><span class="ln">11</span><span class="cl">}</span></span></code></pre></div><p><code>subject_alternative_names</code> 加 <code>*.example.com</code> 讓同一張憑證涵蓋所有子網域（如 <code>api.example.com</code>、<code>admin.example.com</code>），省去為每個子網域各申請一張。</p>
<h3 id="dns-驗證記錄">DNS 驗證記錄</h3>
<p>ACM 簽發後會產出一組驗證用的 CNAME 記錄。用 Terraform 自動在 Route 53 建立這些記錄，讓驗證流程不需要手動操作：</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;cert_validation&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  for_each</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">    for dvo in aws_acm_certificate.main.domain_validation_options : dvo.domain_name</span> <span class="o">=</span><span class="err">&gt;</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="k">dvo</span><span class="p">.</span><span class="k">resource_record_name</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">      record</span> <span class="o">=</span> <span class="k">dvo</span><span class="p">.</span><span class="k">resource_record_value</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">dvo</span><span class="p">.</span><span class="k">resource_record_type</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></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">public</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="k">each</span><span class="p">.</span><span class="k">value</span><span class="p">.</span><span class="k">name</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">each</span><span class="p">.</span><span class="k">value</span><span class="p">.</span><span class="k">type</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="p">[</span><span class="k">each</span><span class="p">.</span><span class="k">value</span><span class="p">.</span><span class="k">record</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="n">  allow_overwrite</span> <span class="o">=</span> <span class="kt">true</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;main&#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">main</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">record</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">record</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><p><code>aws_acm_certificate_validation</code> 資源會等到 ACM 確認驗證通過才算 apply 成功。如果 DNS 記錄設錯或 zone 的 NS delegation 有問題，這個資源會卡住直到 timeout——排查方向是先確認驗證 CNAME 記錄能被公網 DNS 解析。</p>
<h3 id="create_before_destroy">create_before_destroy</h3>
<p><code>lifecycle { create_before_destroy = true }</code> 在憑證需要替換時（如增加 SAN、更換網域），讓 Terraform 先建新憑證、再刪舊憑證。沒有這個設定，預設行為是先刪後建——刪除的瞬間 ALB listener 失去憑證，HTTPS 連線全部中斷直到新憑證驗證通過（可能要幾分鐘到幾十分鐘）。</p>
<h2 id="alb-https-listener">ALB HTTPS Listener</h2>
<p>憑證驗證通過後，把它掛到 ALB 的 HTTPS listener：</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">main</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_validation</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">certificate_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">app</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></code></pre></div><p><code>ssl_policy</code> 決定 TLS 版本與加密套件。<code>ELBSecurityPolicy-TLS13-1-2-2021-06</code> 支援 TLS 1.2 和 1.3、停用已知不安全的舊版協定。選型判準是相容性與安全性的平衡——TLS 1.3-only policy 最安全但可能排除舊版客戶端，多數場景用 1.2+1.3 的組合。</p>
<p><code>certificate_arn</code> 引用的是 <code>aws_acm_certificate_validation</code> 而非直接引用 <code>aws_acm_certificate</code>，確保 listener 只在憑證驗證通過後才建立。</p>
<h3 id="http--https-重導">HTTP → HTTPS 重導</h3>
<p>同時建立一個 HTTP listener，把所有 80 埠流量重導到 443：</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;http_redirect&#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">main</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">80</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></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="k">default_action</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="s2">&#34;redirect&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">redirect</span> {
</span></span><span class="line"><span class="ln"> 9</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">10</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">11</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">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></span></code></pre></div><p>301 永久重導讓瀏覽器記住後續直接走 HTTPS。security group 仍然需要開放 80 埠入站，否則重導不會發生——client 連 80 埠被擋、收到的是連線失敗而非重導回應。</p>
<h2 id="多網域與-san-憑證">多網域與 SAN 憑證</h2>
<p>一張 ACM 憑證最多支援 10 個 SAN（Subject Alternative Name）。多數場景用主網域 + wildcard（<code>example.com</code> + <code>*.example.com</code>）就夠用。如果有多個不同根網域（如 <code>example.com</code> 和 <code>example-app.com</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_domain&#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;example.com&#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></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="s2">&#34;*.example.com&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="s2">&#34;example-app.com&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="s2">&#34;*.example-app.com&#34;</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="n">  validation_method</span> <span class="o">=</span> <span class="s2">&#34;DNS&#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 class="k">lifecycle</span> {
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">    create_before_destroy</span> <span class="o">=</span> <span class="kt">true</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>每個 SAN 網域都需要獨立的 DNS 驗證記錄。如果不同網域在不同的 hosted zone 裡，驗證記錄的建立要分別指向各自的 zone。</p>
<p>當 SAN 數量超過 10、或不同網域的憑證需要獨立管理（不同 team 負責不同網域），改用 <code>aws_lb_listener_certificate</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_certificate&#34; &#34;additional&#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">  certificate_arn</span> <span class="o">=</span> <span class="k">aws_acm_certificate</span><span class="p">.</span><span class="k">other_domain</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">}</span></span></code></pre></div><p>ALB 會根據 SNI（Server Name Indication）自動選擇匹配的憑證。</p>
<h2 id="穩定的-dns-別名記錄">穩定的 DNS 別名記錄</h2>
<p>ALB 重建後 DNS 名稱會改變，對外服務不應該直接用 ALB 的 DNS 名稱。用 Route 53 的 alias record 把穩定的網域名指向 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;app&#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">aws_route53_zone</span><span class="p">.</span><span class="k">public</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.example.com&#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">main</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">main</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>alias record 不收費（一般的 A/CNAME 記錄每百萬次查詢 $0.40，alias 到 AWS 資源免費），且支援 zone apex（如 <code>example.com</code>，一般 CNAME 不支援 zone apex）。<code>evaluate_target_health = true</code> 讓 Route 53 在 ALB 不健康時停止回應該記錄，配合 failover routing 使用。</p>
<h2 id="憑證續期監控">憑證續期監控</h2>
<p>ACM 的 DNS 驗證憑證會自動續期——條件是驗證用的 CNAME 記錄仍然存在且可解析。只要那條記錄沒被刪掉，憑證到期前 60 天 ACM 會自動續期。</p>
<p>自動續期失敗的常見原因：驗證 CNAME 記錄被手動刪除、hosted zone 的 NS delegation 失效、或 zone 本身被刪除重建導致 NS 改變。用 CloudWatch alarm 監控憑證到期日，在自動續期失敗時提前收到通知：</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_metric_alarm&#34; &#34;cert_expiry&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  alarm_name</span>          <span class="o">=</span> <span class="s2">&#34;acm-cert-expiry-${aws_acm_certificate.main.domain_name}&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  comparison_operator</span> <span class="o">=</span> <span class="s2">&#34;LessThanThreshold&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  evaluation_periods</span>  <span class="o">=</span> <span class="m">1</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">  metric_name</span>         <span class="o">=</span> <span class="s2">&#34;DaysToExpiry&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  namespace</span>           <span class="o">=</span> <span class="s2">&#34;AWS/CertificateManager&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">  period</span>              <span class="o">=</span> <span class="m">86400</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">  statistic</span>           <span class="o">=</span> <span class="s2">&#34;Minimum&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  threshold</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">  alarm_actions</span>       <span class="o">=</span> <span class="p">[</span><span class="k">aws_sns_topic</span><span class="p">.</span><span class="k">oncall</span><span class="p">.</span><span class="k">arn</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">  dimensions</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">    CertificateArn</span> <span class="o">=</span> <span class="k">aws_acm_certificate</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">arn</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></code></pre></div><p>這個 alarm 在憑證距離到期不足 30 天時觸發。正常情況下 ACM 在到期前 60 天就會完成續期，收到 30 天警報代表自動續期失敗了、需要人工介入確認驗證記錄。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <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</a>：ALB listener、target group、健康檢查的完整設定</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>：ALB 所在的 public subnet 與 security group 設計</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>：憑證與 DNS 變更走 PR review</li>
</ul>
]]></content:encoded></item><item><title>Security Group</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/security-group/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/security-group/</guid><description>&lt;p>Security group 是掛在資源網卡（ENI）層級的有狀態防火牆，規則描述的是「哪些來源能連到這個資源的哪個埠」。「有狀態」的意思是放行一條入站連線後，對應的回應出站自動允許——規則只需描述入站方向想開放什麼。&lt;/p>
&lt;p>設計原則是最小開放：每條規則只開「這個服務確實需要被誰連的那個埠」。資料庫的 security group 入站只允許來自應用層 security group 的資料庫埠（如 5432），而不是某個 IP 範圍。用 security group 互相引用（source 指向另一個 group 而非 CIDR）讓規則跟著成員身分走、不跟著位址走——應用節點會隨擴縮而換 IP，引用 group 不會因此失效。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Security group 是&lt;a href="https://tarrragon.github.io/blog/infra/03-network-foundation/vpc-subnet-security-group/" data-link-title="網路地基 — VPC、subnet 分層與 security group 設計" data-link-desc="VPC CIDR 規劃、public / private subnet 切分、route table 與 NAT 的可用性成本取捨、security group 最小開放設計，以及 NACL 的定位">模組三：網路地基&lt;/a>的最內層邊界——貼著服務的最後一道網路防線。即使封包順著 route table 抵達了 private &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">subnet&lt;/a>，security group 仍能逐埠決定放不放行。&lt;a href="https://tarrragon.github.io/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">模組七：infra 走 PR 流程&lt;/a>用 tfsec / checkov 在 CI 攔截 &lt;code>0.0.0.0/0&lt;/code> 全開的規則。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>Security group 需要收斂的訊號：入站來源是 &lt;code>0.0.0.0/0&lt;/code>（允許全網連入），且目標埠是資料庫（5432、3306、6379）或管理埠（22、3389）——合理出現 &lt;code>0.0.0.0/0&lt;/code> 的位置只有對外負載平衡器的 80 / 443。盤點方式是列出所有 source 為 &lt;code>0.0.0.0/0&lt;/code> 的規則，逐條問「這個埠需要全世界都連得到嗎」。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>設計 security group 時要決定：&lt;/p>
&lt;ul>
&lt;li>引用方式：用 group 互相引用（推薦）vs 用 CIDR 限定範圍&lt;/li>
&lt;li>開放範圍：只開需要的埠與來源，&lt;code>0.0.0.0/0&lt;/code> 只給對外 LB&lt;/li>
&lt;li>管理埠存取：SSH（22）改用 SSM Session Manager 取代，從公網清單上拿掉&lt;/li>
&lt;li>與 NACL 的分工：security group 是主力（有狀態、group 引用），NACL 留給少數需要 subnet 層顯式 deny 的情境&lt;/li>
&lt;/ul>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/vpc/" data-link-title="VPC（Virtual Private Cloud）" data-link-desc="雲端帳號內的一塊邏輯隔離私有網段，是所有網路切分的起點與容器">VPC&lt;/a> — security group 依附的網路容器&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">Subnet&lt;/a> — security group 與 subnet 各守不同層級的邊界&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Security group 是掛在資源網卡（ENI）層級的有狀態防火牆，規則描述的是「哪些來源能連到這個資源的哪個埠」。「有狀態」的意思是放行一條入站連線後，對應的回應出站自動允許——規則只需描述入站方向想開放什麼。</p>
<p>設計原則是最小開放：每條規則只開「這個服務確實需要被誰連的那個埠」。資料庫的 security group 入站只允許來自應用層 security group 的資料庫埠（如 5432），而不是某個 IP 範圍。用 security group 互相引用（source 指向另一個 group 而非 CIDR）讓規則跟著成員身分走、不跟著位址走——應用節點會隨擴縮而換 IP，引用 group 不會因此失效。</p>
<h2 id="概念位置">概念位置</h2>
<p>Security group 是<a href="/blog/infra/03-network-foundation/vpc-subnet-security-group/" data-link-title="網路地基 — VPC、subnet 分層與 security group 設計" data-link-desc="VPC CIDR 規劃、public / private subnet 切分、route table 與 NAT 的可用性成本取捨、security group 最小開放設計，以及 NACL 的定位">模組三：網路地基</a>的最內層邊界——貼著服務的最後一道網路防線。即使封包順著 route table 抵達了 private <a href="/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">subnet</a>，security group 仍能逐埠決定放不放行。<a href="/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">模組七：infra 走 PR 流程</a>用 tfsec / checkov 在 CI 攔截 <code>0.0.0.0/0</code> 全開的規則。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>Security group 需要收斂的訊號：入站來源是 <code>0.0.0.0/0</code>（允許全網連入），且目標埠是資料庫（5432、3306、6379）或管理埠（22、3389）——合理出現 <code>0.0.0.0/0</code> 的位置只有對外負載平衡器的 80 / 443。盤點方式是列出所有 source 為 <code>0.0.0.0/0</code> 的規則，逐條問「這個埠需要全世界都連得到嗎」。</p>
<h2 id="設計責任">設計責任</h2>
<p>設計 security group 時要決定：</p>
<ul>
<li>引用方式：用 group 互相引用（推薦）vs 用 CIDR 限定範圍</li>
<li>開放範圍：只開需要的埠與來源，<code>0.0.0.0/0</code> 只給對外 LB</li>
<li>管理埠存取：SSH（22）改用 SSM Session Manager 取代，從公網清單上拿掉</li>
<li>與 NACL 的分工：security group 是主力（有狀態、group 引用），NACL 留給少數需要 subnet 層顯式 deny 的情境</li>
</ul>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/vpc/" data-link-title="VPC（Virtual Private Cloud）" data-link-desc="雲端帳號內的一塊邏輯隔離私有網段，是所有網路切分的起點與容器">VPC</a> — security group 依附的網路容器</li>
<li><a href="/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">Subnet</a> — security group 與 subnet 各守不同層級的邊界</li>
</ul>
]]></content:encoded></item><item><title>模組六：可觀測性與 log 一併寫進 code</title><link>https://tarrragon.github.io/blog/infra/06-observability-logging/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/06-observability-logging/</guid><description>&lt;p>可觀測性要跟它監控的資源同生命週期：log group、metric 與 alarm 寫進建立資源的同一套 IaC，資源開出來的那一刻監控就在線，而非等出事才補。少了這條規則的代價很具體：凌晨資料庫 CPU 飆到 100%、API 開始逾時，值班工程師打開 console 想看 log，卻發現那個服務根本沒接 log group、metric 也只有 vendor 預設的幾條粗線，追不到呼叫鏈、查不到錯誤訊息，只能靠重啟賭它恢復。&lt;/p>
&lt;h2 id="observability-跟-infra-同一套-code同生命週期">observability 跟 infra 同一套 code、同生命週期&lt;/h2>
&lt;p>可觀測性是基礎設施的一部分，承擔「讓資源在出事時可被追查」的責任，因此它的建立、變更與銷毀要跟被監控的資源綁在同一個生命週期裡。一個 RDS 實例、一個 Lambda、一個 ECS service 被 IaC 建立時，它的 log group、它的關鍵 metric alarm 應該在同一份 plan 裡一起 apply；這個資源被 destroy 時，對應的 alarm 也一起收掉，不留下對著空資源狂叫的孤兒告警。&lt;/p>
&lt;p>把監控外掛在資源之外會製造兩種漂移。第一種是新資源沒有監控：service 透過 PR 加上去了，但 alarm 要某人事後手動進 console 點，於是有些 service 有 alarm、有些沒有，覆蓋率取決於誰記得。第二種是死資源留下殘響：資源砍了但 alarm 還在，半夜對著不存在的 target 噴 &lt;code>INSUFFICIENT_DATA&lt;/code>，值班的人學會忽略它，告警疲勞讓真的事故也被一起忽略。兩種漂移的共同根因都是監控跟資源不在同一個 apply 單位裡。&lt;/p>
&lt;p>判讀訊號很直接：如果有人能回答「這個服務有沒有 alarm」要去翻 console 而不是讀 code，監控就已經跟資源脫鉤了。修法是把監控宣告收進該資源的 module——模組四（環境分離與模組化）談的模組化在這裡延伸成「每個服務模組自帶它的 observability 宣告」，模組五（核心服務上 IaC）談的每個核心服務也應該在同一個 module 裡帶上自己的 log 與 alarm。&lt;/p>
&lt;h2 id="log-group-與-retention-設計">log group 與 retention 設計&lt;/h2>
&lt;p>Log group 是日誌的歸屬與保存單位，它要回答兩個治理問題：留多久、誰能讀。這兩個問題寫進 IaC 才能稽核，而非依賴 vendor 的隱性預設。許多雲端服務在你沒宣告 log group 時會自動建一個、套上「永久保留」的預設值，於是日誌無限堆積、帳單緩慢長大，而真正敏感的內容反而沒人管控存取。&lt;/p>
&lt;p>Retention 是成本、合規與除錯需求的三方取捨。除錯通常只需要近幾天到幾週的熱資料；合規（如稽核軌跡、金流紀錄）可能要求保留數年；而每多留一天就多一天的儲存費。划算的做法是按日誌類型分層：高頻、除錯用的 application log 設短 retention（例如 14 到 30 天），稽核相關的 access log 按合規要求設長期保留，必要時再把冷資料歸檔到更便宜的物件儲存。把這些值寫進 IaC，讓「為什麼這條 log 留 90 天」是一個能在 PR 上被討論的決定。&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_cloudwatch_log_group&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;/app/${var.env}/api&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"> retention_in_days&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> var.env&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;prod&amp;#34;&lt;/span> &lt;span class="err">?&lt;/span> &lt;span class="m">30&lt;/span> &lt;span class="err">:&lt;/span> &lt;span class="m">7&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"> kms_key_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_kms_key&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">logs&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">5&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>「誰能讀」是 retention 之外的另一半，因為 log 經常夾帶 PII、token 或內部結構，讀取權限要跟身分地基一起管。存取控制掛在模組二（身分與憑證地基）建立的 IAM 角色上，加密金鑰則對應模組三、模組七一路延伸的金鑰治理。常見陷阱是 log 在傳輸與儲存都加密了，卻對整個團隊開放讀取，等於把敏感資料攤在所有人面前；read 權限應該縮到值班與稽核需要的最小集合。應用層該怎麼決定哪些欄位根本不該進 log，屬於資料保護的範圍，可往 &lt;code>/backend/07-security-data-protection/&lt;/code> 對齊。&lt;/p>
&lt;h2 id="metric-與-alarm-寫進-iac">metric 與 alarm 寫進 IaC&lt;/h2>
&lt;p>Metric 與 alarm 寫進 IaC，目的是讓「資源被建立的同時就帶著它的健康判準」。Alarm 不只是一個閾值，它是一份對「這個資源什麼狀態算不正常」的成文約定：哪條 metric、跨多長的評估窗口、超過什麼值要通知誰。把這份約定寫進 code，它就能被 review、被版本控制、被跨環境複用，而不是散落在某個人腦中或 console 的某個角落。&lt;/p>
&lt;p>Alarm 的價值在於它連到動作，而非只是亮一盞燈。一條有用的 alarm 至少要綁定通知去向（on-call 的 SNS topic、PagerDuty、Slack），並寫清楚 &lt;code>INSUFFICIENT_DATA&lt;/code> 怎麼處理——資料不足到底算正常還是異常，取決於這條 metric 平常是否持續有資料。閾值設計是訊號與雜訊的取捨：設太敏感會頻繁誤報、養出告警疲勞，設太鈍則錯過真正的劣化。划算的起點是針對「使用者已經受影響」的症狀型 metric 設 alarm（錯誤率、p99 延遲、佇列積壓），而把成因型指標（CPU、記憶體）留作 dashboard 上的診斷線索，避免每個成因都獨立告警。&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_cloudwatch_metric_alarm&amp;#34; &amp;#34;api_5xx&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"> alarm_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.env}-api-5xx-rate&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"> comparison_operator&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;GreaterThanThreshold&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"> evaluation_periods&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"> 5&lt;/span>&lt;span class="cl">&lt;span class="n"> metric_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;5XXError&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"> namespace&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;AWS/ApiGateway&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="n"> period&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">60&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"> statistic&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Sum&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="n"> threshold&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">10&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"> treat_missing_data&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;notBreaching&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"> alarm_actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="k">aws_sns_topic&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">oncall&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>判讀訊號是：每次新服務上線都要有人「記得」去加 alarm，代表 alarm 還沒進 module 模板。修法是把基礎告警（錯誤率、延遲、健康檢查失敗）做成服務模組的預設輸出，讓開新服務時 alarm 跟著資源一起生出來，調整閾值才是該服務 owner 的選配。&lt;/p></description><content:encoded><![CDATA[<p>可觀測性要跟它監控的資源同生命週期：log group、metric 與 alarm 寫進建立資源的同一套 IaC，資源開出來的那一刻監控就在線，而非等出事才補。少了這條規則的代價很具體：凌晨資料庫 CPU 飆到 100%、API 開始逾時，值班工程師打開 console 想看 log，卻發現那個服務根本沒接 log group、metric 也只有 vendor 預設的幾條粗線，追不到呼叫鏈、查不到錯誤訊息，只能靠重啟賭它恢復。</p>
<h2 id="observability-跟-infra-同一套-code同生命週期">observability 跟 infra 同一套 code、同生命週期</h2>
<p>可觀測性是基礎設施的一部分，承擔「讓資源在出事時可被追查」的責任，因此它的建立、變更與銷毀要跟被監控的資源綁在同一個生命週期裡。一個 RDS 實例、一個 Lambda、一個 ECS service 被 IaC 建立時，它的 log group、它的關鍵 metric alarm 應該在同一份 plan 裡一起 apply；這個資源被 destroy 時，對應的 alarm 也一起收掉，不留下對著空資源狂叫的孤兒告警。</p>
<p>把監控外掛在資源之外會製造兩種漂移。第一種是新資源沒有監控：service 透過 PR 加上去了，但 alarm 要某人事後手動進 console 點，於是有些 service 有 alarm、有些沒有，覆蓋率取決於誰記得。第二種是死資源留下殘響：資源砍了但 alarm 還在，半夜對著不存在的 target 噴 <code>INSUFFICIENT_DATA</code>，值班的人學會忽略它，告警疲勞讓真的事故也被一起忽略。兩種漂移的共同根因都是監控跟資源不在同一個 apply 單位裡。</p>
<p>判讀訊號很直接：如果有人能回答「這個服務有沒有 alarm」要去翻 console 而不是讀 code，監控就已經跟資源脫鉤了。修法是把監控宣告收進該資源的 module——模組四（環境分離與模組化）談的模組化在這裡延伸成「每個服務模組自帶它的 observability 宣告」，模組五（核心服務上 IaC）談的每個核心服務也應該在同一個 module 裡帶上自己的 log 與 alarm。</p>
<h2 id="log-group-與-retention-設計">log group 與 retention 設計</h2>
<p>Log group 是日誌的歸屬與保存單位，它要回答兩個治理問題：留多久、誰能讀。這兩個問題寫進 IaC 才能稽核，而非依賴 vendor 的隱性預設。許多雲端服務在你沒宣告 log group 時會自動建一個、套上「永久保留」的預設值，於是日誌無限堆積、帳單緩慢長大，而真正敏感的內容反而沒人管控存取。</p>
<p>Retention 是成本、合規與除錯需求的三方取捨。除錯通常只需要近幾天到幾週的熱資料；合規（如稽核軌跡、金流紀錄）可能要求保留數年；而每多留一天就多一天的儲存費。划算的做法是按日誌類型分層：高頻、除錯用的 application log 設短 retention（例如 14 到 30 天），稽核相關的 access log 按合規要求設長期保留，必要時再把冷資料歸檔到更便宜的物件儲存。把這些值寫進 IaC，讓「為什麼這條 log 留 90 天」是一個能在 PR 上被討論的決定。</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;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;/app/${var.env}/api&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  retention_in_days</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">30</span> <span class="err">:</span> <span class="m">7</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  kms_key_id</span>        <span class="o">=</span> <span class="k">aws_kms_key</span><span class="p">.</span><span class="k">logs</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">}</span></span></code></pre></div><p>「誰能讀」是 retention 之外的另一半，因為 log 經常夾帶 PII、token 或內部結構，讀取權限要跟身分地基一起管。存取控制掛在模組二（身分與憑證地基）建立的 IAM 角色上，加密金鑰則對應模組三、模組七一路延伸的金鑰治理。常見陷阱是 log 在傳輸與儲存都加密了，卻對整個團隊開放讀取，等於把敏感資料攤在所有人面前；read 權限應該縮到值班與稽核需要的最小集合。應用層該怎麼決定哪些欄位根本不該進 log，屬於資料保護的範圍，可往 <code>/backend/07-security-data-protection/</code> 對齊。</p>
<h2 id="metric-與-alarm-寫進-iac">metric 與 alarm 寫進 IaC</h2>
<p>Metric 與 alarm 寫進 IaC，目的是讓「資源被建立的同時就帶著它的健康判準」。Alarm 不只是一個閾值，它是一份對「這個資源什麼狀態算不正常」的成文約定：哪條 metric、跨多長的評估窗口、超過什麼值要通知誰。把這份約定寫進 code，它就能被 review、被版本控制、被跨環境複用，而不是散落在某個人腦中或 console 的某個角落。</p>
<p>Alarm 的價值在於它連到動作，而非只是亮一盞燈。一條有用的 alarm 至少要綁定通知去向（on-call 的 SNS topic、PagerDuty、Slack），並寫清楚 <code>INSUFFICIENT_DATA</code> 怎麼處理——資料不足到底算正常還是異常，取決於這條 metric 平常是否持續有資料。閾值設計是訊號與雜訊的取捨：設太敏感會頻繁誤報、養出告警疲勞，設太鈍則錯過真正的劣化。划算的起點是針對「使用者已經受影響」的症狀型 metric 設 alarm（錯誤率、p99 延遲、佇列積壓），而把成因型指標（CPU、記憶體）留作 dashboard 上的診斷線索，避免每個成因都獨立告警。</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_metric_alarm&#34; &#34;api_5xx&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  alarm_name</span>          <span class="o">=</span> <span class="s2">&#34;${var.env}-api-5xx-rate&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  comparison_operator</span> <span class="o">=</span> <span class="s2">&#34;GreaterThanThreshold&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  evaluation_periods</span>  <span class="o">=</span> <span class="m">3</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">  metric_name</span>         <span class="o">=</span> <span class="s2">&#34;5XXError&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  namespace</span>           <span class="o">=</span> <span class="s2">&#34;AWS/ApiGateway&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">  period</span>              <span class="o">=</span> <span class="m">60</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">  statistic</span>           <span class="o">=</span> <span class="s2">&#34;Sum&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  threshold</span>           <span class="o">=</span> <span class="m">10</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">  treat_missing_data</span>  <span class="o">=</span> <span class="s2">&#34;notBreaching&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">  alarm_actions</span>       <span class="o">=</span> <span class="p">[</span><span class="k">aws_sns_topic</span><span class="p">.</span><span class="k">oncall</span><span class="p">.</span><span class="k">arn</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">}</span></span></code></pre></div><p>判讀訊號是：每次新服務上線都要有人「記得」去加 alarm，代表 alarm 還沒進 module 模板。修法是把基礎告警（錯誤率、延遲、健康檢查失敗）做成服務模組的預設輸出，讓開新服務時 alarm 跟著資源一起生出來，調整閾值才是該服務 owner 的選配。</p>
<h2 id="跟-monitoring-系列的分工基礎設施訊號-vs-客戶端行為訊號">跟 monitoring 系列的分工：基礎設施訊號 vs 客戶端行為訊號</h2>
<p>本模組的可觀測性處理基礎設施訊號，monitoring 系列處理客戶端與業務行為訊號，兩者觀測的對象不同、生命週期也不同，因此分屬不同的 code 與不同的章節。基礎設施訊號是資源層的健康狀態：log group、CPU、佇列深度、5xx 比例、實例存活，它們跟著資源被 IaC 建立與銷毀，回答「這個系統還活著嗎、哪裡壞了」。</p>
<p>客戶端行為訊號則是 SDK、Collector、業務埋點那一層：使用者點了什麼、轉換漏斗、前端錯誤、自訂事件，它們跟著產品功能演進、不跟著基礎設施資源同生共滅，所以放在 <code>/monitoring/</code>。判讀分界的問法是：這個訊號是「資源建立時就該存在」還是「功能開發時才埋」。前者進本模組的 IaC，後者進 monitoring 那層的應用程式碼。兩者在事故排查時會合流——基礎設施 alarm 告訴你哪個資源異常，客戶端訊號告訴你使用者實際受了什麼影響——但它們的擁有者、變更節奏與部署管道不同，混在一起會讓「誰負責這條訊號」變模糊。</p>
<p>收斂成一句判準：資源建立時就該存在的訊號歸本模組的 IaC，功能開發時才埋的客戶端行為訊號歸另一層；各條延伸章節見下方跨分類引用。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/infra/06-observability-logging/log-metric-alarm-lifecycle/" data-link-title="可觀測性與 log 同生命週期管理" data-link-desc="log group、metric、alarm 寫進建立資源的同一套 IaC，讓監控跟資源同生共滅，出事時追得到查得到">可觀測性與 log 同生命週期管理</a></td>
          <td>log group、metric、alarm 寫進同一套 IaC，讓監控跟資源同生共滅，出事時追得到查得到</td>
      </tr>
  </tbody>
</table>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">Monitoring 監控體系</a>：客戶端 SDK / Collector 那層的監控</li>
<li>→ <a href="/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a>：每個核心服務帶自己的 log 與 alarm</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>：observability 變更也走 PR 與自動化護欄</li>
<li>→ <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 模組七：資安與資料保護</a>：哪些欄位不該進 log、PII 處理</li>
</ul>
]]></content:encoded></item><item><title>斷網環境的版本控制與 CI/CD</title><link>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-vcs-ci/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-vcs-ci/</guid><description>&lt;p>版本控制和 CI/CD 是所有 infra 操作的前提——程式碼要有地方存、變更要能被 review、build 和 deploy 要自動化。正常環境裡這些由 GitHub + GitHub Actions 提供，斷網環境裡這兩個服務都不存在，需要在內網自建替代品。&lt;/p>
&lt;h2 id="gitlab-ce-vs-gitea選型判準">GitLab CE vs Gitea：選型判準&lt;/h2>
&lt;p>兩個主流的自建版本控制方案定位不同：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>GitLab CE&lt;/th>
 &lt;th>Gitea&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>定位&lt;/td>
 &lt;td>VCS + CI + Container Registry + Issue Tracker 一體&lt;/td>
 &lt;td>純 VCS（輕量 Git 伺服器）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>資源需求&lt;/td>
 &lt;td>4GB+ RAM、推薦 8GB&lt;/td>
 &lt;td>512MB RAM 即可運作&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CI 內建&lt;/td>
 &lt;td>GitLab CI（&lt;code>.gitlab-ci.yml&lt;/code>）&lt;/td>
 &lt;td>無（搭配 Drone / Woodpecker / Jenkins）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Container Registry&lt;/td>
 &lt;td>內建&lt;/td>
 &lt;td>無（搭配 Harbor）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>安裝複雜度&lt;/td>
 &lt;td>中（Omnibus 包裝簡化了安裝、但設定項多）&lt;/td>
 &lt;td>低（單一二進位檔、啟動即可用）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>維護負擔&lt;/td>
 &lt;td>高（PostgreSQL、Redis、Sidekiq 都在裡面）&lt;/td>
 &lt;td>低（SQLite 或 MySQL、無背景服務）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>選型判準是團隊規模和需要的功能範圍。5 人以下、只需要 VCS + 輕量 CI 的團隊，Gitea + Drone 的組合維護成本低。10 人以上、需要 MR review + CI pipeline + Container Registry 一站到位的團隊，GitLab CE 的整合度值得它的資源消耗。&lt;/p>
&lt;p>接下來以 GitLab CE 為主線說明（功能最完整），Gitea 的差異在各段附註。&lt;/p>
&lt;h2 id="gitlab-ce-離線安裝">GitLab CE 離線安裝&lt;/h2>
&lt;p>GitLab Omnibus 包把所有依賴打包成單一安裝檔，不需要在目標機器上 &lt;code>apt install&lt;/code> 任何前置套件。&lt;/p>
&lt;h3 id="在外網機器下載安裝包">在外網機器下載安裝包&lt;/h3>





&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"># Ubuntu/Debian&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">wget https://packages.gitlab.com/gitlab/gitlab-ce/packages/ubuntu/jammy/gitlab-ce_17.0.0-ce.0_amd64.deb/download.deb
&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"># RHEL/CentOS&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">wget https://packages.gitlab.com/gitlab/gitlab-ce/packages/el/9/gitlab-ce-17.0.0-ce.0.el9.x86_64.rpm/download.rpm&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>把下載的 &lt;code>.deb&lt;/code> 或 &lt;code>.rpm&lt;/code> 透過&lt;a href="https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">內容搬運機制&lt;/a>（USB、光碟、跨邊界傳輸站）帶進斷網環境。&lt;/p>
&lt;h3 id="在斷網機器安裝">在斷網機器安裝&lt;/h3>





&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"># Ubuntu/Debian&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">sudo dpkg -i gitlab-ce_17.0.0-ce.0_amd64.deb
&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"># RHEL/CentOS&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">sudo yum localinstall gitlab-ce-17.0.0-ce.0.el9.x86_64.rpm&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="離線設定">離線設定&lt;/h3>
&lt;p>安裝後編輯 &lt;code>/etc/gitlab/gitlab.rb&lt;/code>，把所有外部連線關掉：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ruby" data-lang="ruby">&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">&lt;span class="n">external_url&lt;/span> &lt;span class="s1">&amp;#39;https://gitlab.internal.example.com&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 關閉 Gravatar（頭像服務、需要外網）&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">gitlab_rails&lt;/span>&lt;span class="o">[&lt;/span>&lt;span class="s1">&amp;#39;gravatar_enabled&amp;#39;&lt;/span>&lt;span class="o">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kp">false&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="c1"># 關閉 usage ping（回報使用統計到 GitLab Inc）&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">gitlab_rails&lt;/span>&lt;span class="o">[&lt;/span>&lt;span class="s1">&amp;#39;usage_ping_enabled&amp;#39;&lt;/span>&lt;span class="o">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kp">false&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 class="c1"># 關閉 version check&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">gitlab_rails&lt;/span>&lt;span class="o">[&lt;/span>&lt;span class="s1">&amp;#39;gitlab_check_on_connect&amp;#39;&lt;/span>&lt;span class="o">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kp">false&lt;/span>
&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 class="c1"># 如果沒有內部 SMTP，用 sendmail 或關閉 email&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="n">gitlab_rails&lt;/span>&lt;span class="o">[&lt;/span>&lt;span class="s1">&amp;#39;smtp_enable&amp;#39;&lt;/span>&lt;span class="o">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kp">false&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="c1"># TLS 憑證用內部 CA 簽發&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">nginx&lt;/span>&lt;span class="o">[&lt;/span>&lt;span class="s1">&amp;#39;ssl_certificate&amp;#39;&lt;/span>&lt;span class="o">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;/etc/gitlab/ssl/gitlab.crt&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 class="n">nginx&lt;/span>&lt;span class="o">[&lt;/span>&lt;span class="s1">&amp;#39;ssl_certificate_key&amp;#39;&lt;/span>&lt;span class="o">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;/etc/gitlab/ssl/gitlab.key&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">sudo gitlab-ctl reconfigure&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Gitea 的離線安裝更簡單：下載單一二進位檔 &lt;code>gitea&lt;/code>、設定 &lt;code>app.ini&lt;/code>、用 systemd 管理即可。&lt;/p>
&lt;h3 id="升級策略">升級策略&lt;/h3>
&lt;p>GitLab CE 的升級包也要從外部下載帶進來。升級前先備份（&lt;code>gitlab-backup create&lt;/code>），升級路徑要按 GitLab 的&lt;a href="https://docs.gitlab.com/ee/update/index.html#upgrade-paths">版本跳級規則&lt;/a>——不能任意跳版、某些大版本之間需要中繼版本。在斷網環境裡，每次升級要預先規劃中繼版本、一次帶進所有需要的安裝包。&lt;/p></description><content:encoded><![CDATA[<p>版本控制和 CI/CD 是所有 infra 操作的前提——程式碼要有地方存、變更要能被 review、build 和 deploy 要自動化。正常環境裡這些由 GitHub + GitHub Actions 提供，斷網環境裡這兩個服務都不存在，需要在內網自建替代品。</p>
<h2 id="gitlab-ce-vs-gitea選型判準">GitLab CE vs Gitea：選型判準</h2>
<p>兩個主流的自建版本控制方案定位不同：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>GitLab CE</th>
          <th>Gitea</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>定位</td>
          <td>VCS + CI + Container Registry + Issue Tracker 一體</td>
          <td>純 VCS（輕量 Git 伺服器）</td>
      </tr>
      <tr>
          <td>資源需求</td>
          <td>4GB+ RAM、推薦 8GB</td>
          <td>512MB RAM 即可運作</td>
      </tr>
      <tr>
          <td>CI 內建</td>
          <td>GitLab CI（<code>.gitlab-ci.yml</code>）</td>
          <td>無（搭配 Drone / Woodpecker / Jenkins）</td>
      </tr>
      <tr>
          <td>Container Registry</td>
          <td>內建</td>
          <td>無（搭配 Harbor）</td>
      </tr>
      <tr>
          <td>安裝複雜度</td>
          <td>中（Omnibus 包裝簡化了安裝、但設定項多）</td>
          <td>低（單一二進位檔、啟動即可用）</td>
      </tr>
      <tr>
          <td>維護負擔</td>
          <td>高（PostgreSQL、Redis、Sidekiq 都在裡面）</td>
          <td>低（SQLite 或 MySQL、無背景服務）</td>
      </tr>
  </tbody>
</table>
<p>選型判準是團隊規模和需要的功能範圍。5 人以下、只需要 VCS + 輕量 CI 的團隊，Gitea + Drone 的組合維護成本低。10 人以上、需要 MR review + CI pipeline + Container Registry 一站到位的團隊，GitLab CE 的整合度值得它的資源消耗。</p>
<p>接下來以 GitLab CE 為主線說明（功能最完整），Gitea 的差異在各段附註。</p>
<h2 id="gitlab-ce-離線安裝">GitLab CE 離線安裝</h2>
<p>GitLab Omnibus 包把所有依賴打包成單一安裝檔，不需要在目標機器上 <code>apt install</code> 任何前置套件。</p>
<h3 id="在外網機器下載安裝包">在外網機器下載安裝包</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Ubuntu/Debian</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">wget https://packages.gitlab.com/gitlab/gitlab-ce/packages/ubuntu/jammy/gitlab-ce_17.0.0-ce.0_amd64.deb/download.deb
</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"># RHEL/CentOS</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">wget https://packages.gitlab.com/gitlab/gitlab-ce/packages/el/9/gitlab-ce-17.0.0-ce.0.el9.x86_64.rpm/download.rpm</span></span></code></pre></div><p>把下載的 <code>.deb</code> 或 <code>.rpm</code> 透過<a href="/blog/infra/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">內容搬運機制</a>（USB、光碟、跨邊界傳輸站）帶進斷網環境。</p>
<h3 id="在斷網機器安裝">在斷網機器安裝</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Ubuntu/Debian</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sudo dpkg -i gitlab-ce_17.0.0-ce.0_amd64.deb
</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"># RHEL/CentOS</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">sudo yum localinstall gitlab-ce-17.0.0-ce.0.el9.x86_64.rpm</span></span></code></pre></div><h3 id="離線設定">離線設定</h3>
<p>安裝後編輯 <code>/etc/gitlab/gitlab.rb</code>，把所有外部連線關掉：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><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"><span class="n">external_url</span> <span class="s1">&#39;https://gitlab.internal.example.com&#39;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># 關閉 Gravatar（頭像服務、需要外網）</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">gitlab_rails</span><span class="o">[</span><span class="s1">&#39;gravatar_enabled&#39;</span><span class="o">]</span> <span class="o">=</span> <span class="kp">false</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># 關閉 usage ping（回報使用統計到 GitLab Inc）</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">gitlab_rails</span><span class="o">[</span><span class="s1">&#39;usage_ping_enabled&#39;</span><span class="o">]</span> <span class="o">=</span> <span class="kp">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 class="c1"># 關閉 version check</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">gitlab_rails</span><span class="o">[</span><span class="s1">&#39;gitlab_check_on_connect&#39;</span><span class="o">]</span> <span class="o">=</span> <span class="kp">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 class="c1"># 如果沒有內部 SMTP，用 sendmail 或關閉 email</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">gitlab_rails</span><span class="o">[</span><span class="s1">&#39;smtp_enable&#39;</span><span class="o">]</span> <span class="o">=</span> <span class="kp">false</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1"># TLS 憑證用內部 CA 簽發</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="n">nginx</span><span class="o">[</span><span class="s1">&#39;ssl_certificate&#39;</span><span class="o">]</span> <span class="o">=</span> <span class="s2">&#34;/etc/gitlab/ssl/gitlab.crt&#34;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="n">nginx</span><span class="o">[</span><span class="s1">&#39;ssl_certificate_key&#39;</span><span class="o">]</span> <span class="o">=</span> <span class="s2">&#34;/etc/gitlab/ssl/gitlab.key&#34;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">sudo gitlab-ctl reconfigure</span></span></code></pre></div><p>Gitea 的離線安裝更簡單：下載單一二進位檔 <code>gitea</code>、設定 <code>app.ini</code>、用 systemd 管理即可。</p>
<h3 id="升級策略">升級策略</h3>
<p>GitLab CE 的升級包也要從外部下載帶進來。升級前先備份（<code>gitlab-backup create</code>），升級路徑要按 GitLab 的<a href="https://docs.gitlab.com/ee/update/index.html#upgrade-paths">版本跳級規則</a>——不能任意跳版、某些大版本之間需要中繼版本。在斷網環境裡，每次升級要預先規劃中繼版本、一次帶進所有需要的安裝包。</p>
<h2 id="ci-runner-離線設定">CI Runner 離線設定</h2>
<p>CI pipeline 在斷網環境裡跑的最大差異是 runner 不能即時拉依賴。</p>
<h3 id="runner-安裝與註冊">Runner 安裝與註冊</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 下載 runner 二進位檔（外網下載、帶進來）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># https://docs.gitlab.com/runner/install/linux-manually.html</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">sudo gitlab-runner register <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --url https://gitlab.internal.example.com <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --token <span class="nv">$RUNNER_TOKEN</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --executor docker <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  --docker-image alpine:3.20</span></span></code></pre></div><h3 id="executor-選擇">Executor 選擇</h3>
<table>
  <thead>
      <tr>
          <th>Executor</th>
          <th>隔離性</th>
          <th>前置條件</th>
          <th>斷網適用度</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>shell</td>
          <td>低（直接跑在 runner 機器上）</td>
          <td>無</td>
          <td>高（最簡單）</td>
      </tr>
      <tr>
          <td>docker</td>
          <td>高（每個 job 一個容器）</td>
          <td>需要 Docker + 預拉 image</td>
          <td>中（image 管理成本）</td>
      </tr>
      <tr>
          <td>kubernetes</td>
          <td>高（每個 job 一個 pod）</td>
          <td>需要 K8s cluster</td>
          <td>低（斷網 K8s 維護重）</td>
      </tr>
  </tbody>
</table>
<p>斷網環境推薦 shell executor（最少依賴）或 docker executor 搭配預拉好的 image。</p>
<h3 id="docker-executor-的-image-管理">Docker executor 的 image 管理</h3>
<p>Docker executor 的每個 job 都基於一個 base image。斷網環境裡這些 image 必須預先存在於內網的 <a href="/blog/infra/air-gapped/air-gapped-container/" data-link-title="斷網環境的容器與映像管理" data-link-desc="Private registry 架設、映像搬運（docker save/load、skopeo）、base image 更新週期、離線漏洞掃描">private registry</a>：</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"># runner 的 /etc/docker/daemon.json 指向內部 registry</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="o">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="s2">&#34;insecure-registries&#34;</span>: <span class="o">[</span><span class="s2">&#34;registry.internal:5000&#34;</span><span class="o">]</span>,
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="s2">&#34;registry-mirrors&#34;</span>: <span class="o">[</span><span class="s2">&#34;https://registry.internal:5000&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="o">}</span></span></span></code></pre></div><p>CI pipeline 裡用到的每個 image（build 用的 golang/node/php、lint 用的 tflint/checkov、deploy 用的 awscli）都要事先搬進內部 registry。</p>
<h3 id="依賴快取">依賴快取</h3>
<p>沒有 npm registry / PyPI / Maven Central 可以拉，CI job 的依賴安裝必須用本地來源：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="c"># .gitlab-ci.yml — 使用內部 Nexus 作為套件來源</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="nt">variables</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span><span class="nt">NPM_CONFIG_REGISTRY</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;https://nexus.internal/repository/npm-proxy/&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">  </span><span class="nt">PIP_INDEX_URL</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;https://nexus.internal/repository/pypi-proxy/simple/&#34;</span></span></span></code></pre></div><p>或者把 <code>node_modules</code> / <code>vendor</code> 打包成 CI artifact 快取，避免每次 job 都重新安裝。</p>
<h2 id="git-bundle-跨邊界傳輸">Git Bundle 跨邊界傳輸</h2>
<p>某些斷網環境不允許直接 <code>git push</code> 到內網 GitLab（例如開發在外網、部署在內網）。Git bundle 是把 commit 歷史打包成單一檔案的機制：</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"># 外網開發機：打包最近的 commit</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git bundle create changes.bundle main~5..main
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 帶進斷網環境後</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git bundle verify changes.bundle
</span></span><span class="line"><span class="ln">6</span><span class="cl">git fetch changes.bundle main:incoming
</span></span><span class="line"><span class="ln">7</span><span class="cl">git merge incoming</span></span></code></pre></div><p>bundle 檔案包含完整的 Git 物件（commit、tree、blob），可以通過任何檔案傳輸方式帶過邊界——USB、光碟、審批後的檔案傳輸閘道。</p>
<p>跨邊界傳輸的安全考量：bundle 的內容應該在傳入前被掃描（至少 <code>git bundle verify</code>），確認不包含預期外的分支或異常大的物件。某些高安全環境要求所有跨邊界檔案經過人工審批。</p>
<h2 id="mr-review-流程">MR Review 流程</h2>
<p>斷網環境的 MR（Merge Request）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>的原則相同——變更走 MR → CI 跑 plan → reviewer 看 diff + plan 輸出 → 合併 → apply。差別在於所有環節都在內網：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># .gitlab-ci.yml — Terraform plan 貼回 MR comment</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">plan</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">stage</span><span class="p">:</span><span class="w"> </span><span class="l">plan</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="nt">script</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span>- <span class="l">terraform init -plugin-dir=/opt/terraform/plugins</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span>- <span class="l">terraform plan -no-color -out=plan.tfplan | tee plan.txt</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span>- <span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="sd">      curl --request POST \
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="sd">        --header &#34;PRIVATE-TOKEN: $GITLAB_TOKEN&#34; \
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="sd">        --data-urlencode &#34;body=$(cat plan.txt)&#34; \
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="sd">        &#34;https://gitlab.internal/api/v4/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">  </span><span class="nt">only</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">    </span>- <span class="l">merge_requests</span></span></span></code></pre></div><p>GitLab CI 的 <code>merge_requests</code> trigger 跟 GitHub Actions 的 <code>pull_request</code> 等價——MR 開啟或更新時自動跑 pipeline。</p>
<p>reviewer 在 GitLab 的 MR 頁面看 code diff + plan 輸出 comment，approve 後合併，合併觸發 apply pipeline。流程跟有網路時完全相同，只是所有元件（GitLab、runner、Terraform、provider plugin）都在內網。</p>
<h2 id="時程與維護">時程與維護</h2>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>初始設定</th>
          <th>持續維護</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>GitLab CE 安裝 + 設定</td>
          <td>1 天</td>
          <td>每季升級（含帶包 + 備份 + 升級 + 驗證）~半天</td>
      </tr>
      <tr>
          <td>CI runner 設定</td>
          <td>半天</td>
          <td>image 更新隨 registry 同步</td>
      </tr>
      <tr>
          <td>Gitea + Drone（替代方案）</td>
          <td>半天</td>
          <td>極低（二進位更新即可）</td>
      </tr>
      <tr>
          <td>Git bundle 流程建立</td>
          <td>2 小時</td>
          <td>按需（有跨邊界需求時）</td>
      </tr>
  </tbody>
</table>
<p>GitLab CE 的主要維護成本在升級——斷網環境的升級不能一鍵 <code>apt upgrade</code>，要預先下載正確版本的安裝包帶進來。跳版規則讓這個過程比正常環境多一層規劃。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">斷網環境的通用原則</a>：內容搬運、離線套件管理的共通模式</li>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-container/" data-link-title="斷網環境的容器與映像管理" data-link-desc="Private registry 架設、映像搬運（docker save/load、skopeo）、base image 更新週期、離線漏洞掃描">斷網環境的容器與映像管理</a>：CI runner 的 Docker image 管理</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>：MR review 流程的原則與護欄</li>
</ul>
]]></content:encoded></item><item><title>ECS Fargate 成本分析與優化</title><link>https://tarrragon.github.io/blog/infra/05-core-services/ecs-fargate-cost-optimization/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/05-core-services/ecs-fargate-cost-optimization/</guid><description>&lt;p>Fargate 把運算的維運面外包給 AWS — 不需要管 EC2 instance、不需要管 AMI 更新、不需要管 capacity provider 的擴縮邏輯。這份簡化的代價是單位成本較高。當服務規模小或流量不穩定時，Fargate 的簡化值回票價；當服務規模穩定且持續運行時，EC2 launch type 的單位成本優勢會累積到值得切換的量級。本篇的目標是讓讀者能判斷自己的服務在成本曲線的哪個位置、以及有哪些槓桿可以調。&lt;/p>
&lt;h2 id="fargate-計價模型">Fargate 計價模型&lt;/h2>
&lt;p>Fargate 按 task 的 vCPU 時數和記憶體時數分別計費，從 task 啟動（pull image 完成、進入 RUNNING）到停止。計費的最小粒度是一分鐘，不足一分鐘按一分鐘算。&lt;/p>
&lt;p>以 ap-northeast-1（東京）為例的單價（截至撰寫時的量級參考，實際以 AWS 定價頁為準）：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>資源&lt;/th>
 &lt;th>單價（每小時）&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1 vCPU&lt;/td>
 &lt;td>~$0.05056&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>1 GB RAM&lt;/td>
 &lt;td>~$0.00553&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>一個 1 vCPU / 2 GB 的 task 持續運行一個月（730 小時）的費用約為 $0.05056 × 730 + $0.00553 × 2 × 730 ≈ $44.97。這個數字是所有後續比較的基線。&lt;/p>
&lt;p>Fargate 的計費粒度還有一個常被忽略的面向：task 規格只能從 AWS 預定義的 vCPU/memory 組合中選。如果應用只需要 0.3 vCPU / 512 MB，最小可選的配置是 0.25 vCPU / 0.5 GB，但如果需要 0.3 vCPU / 1 GB，就得選 0.5 vCPU / 1 GB — 多付了 0.2 vCPU 的費用。這個「階梯式浪費」在小規格 task 上比例最高。&lt;/p>
&lt;h2 id="fargate-vs-ec2-launch-type-的成本比較">Fargate vs EC2 launch type 的成本比較&lt;/h2>
&lt;p>EC2 launch type 的成本結構不同：付的是 EC2 instance 的時數（不管上面跑幾個 task），加上 ECS 本身不收費。省的是 Fargate 的 markup，多的是 instance 管理（AMI 更新、capacity provider 設定、instance 閒置時仍計費）。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>場景&lt;/th>
 &lt;th>Fargate 月費&lt;/th>
 &lt;th>EC2（t3.medium）月費&lt;/th>
 &lt;th>差異&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1 task, 1 vCPU / 2 GB, 持續&lt;/td>
 &lt;td>~$45&lt;/td>
 &lt;td>~$30（共享 instance）&lt;/td>
 &lt;td>+50%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>5 tasks, 各 0.5 vCPU / 1 GB&lt;/td>
 &lt;td>~$113&lt;/td>
 &lt;td>~$30（1 台 t3.medium 裝得下）&lt;/td>
 &lt;td>+277%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>20 tasks, 各 1 vCPU / 2 GB&lt;/td>
 &lt;td>~$900&lt;/td>
 &lt;td>~$240（4 台 t3.xlarge）&lt;/td>
 &lt;td>+275%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>流量波動大，尖峰 10 tasks / 離峰 1&lt;/td>
 &lt;td>~$180（加權平均）&lt;/td>
 &lt;td>~$150（需預留尖峰容量）&lt;/td>
 &lt;td>+20%&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>幾個判讀要點：&lt;/p>
&lt;ul>
&lt;li>task 數量少且持續運行時，Fargate 的溢價比例最高（+50% 到 +277%），但絕對金額小（$15-$80/月的差距），不值得為此承擔 instance 管理的維運負擔&lt;/li>
&lt;li>task 數量多且持續運行時，EC2 的絕對節省量開始可觀（$660/月），這時候切換的維運成本有回報&lt;/li>
&lt;li>流量波動大時，Fargate 的優勢是按需計費 — 離峰時 task 數降下來就停止計費，EC2 instance 閒置時仍然計費。波動越大，Fargate 的成本效益越接近或超過 EC2&lt;/li>
&lt;/ul>
&lt;h2 id="fargate-spot">Fargate Spot&lt;/h2>
&lt;p>Fargate Spot 使用 AWS 的閒置容量，價格約為 on-demand 的 30%（折扣幅度 ~70%），代價是 AWS 可以隨時回收容量、task 會收到 SIGTERM 後被終止。&lt;/p></description><content:encoded><![CDATA[<p>Fargate 把運算的維運面外包給 AWS — 不需要管 EC2 instance、不需要管 AMI 更新、不需要管 capacity provider 的擴縮邏輯。這份簡化的代價是單位成本較高。當服務規模小或流量不穩定時，Fargate 的簡化值回票價；當服務規模穩定且持續運行時，EC2 launch type 的單位成本優勢會累積到值得切換的量級。本篇的目標是讓讀者能判斷自己的服務在成本曲線的哪個位置、以及有哪些槓桿可以調。</p>
<h2 id="fargate-計價模型">Fargate 計價模型</h2>
<p>Fargate 按 task 的 vCPU 時數和記憶體時數分別計費，從 task 啟動（pull image 完成、進入 RUNNING）到停止。計費的最小粒度是一分鐘，不足一分鐘按一分鐘算。</p>
<p>以 ap-northeast-1（東京）為例的單價（截至撰寫時的量級參考，實際以 AWS 定價頁為準）：</p>
<table>
  <thead>
      <tr>
          <th>資源</th>
          <th>單價（每小時）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1 vCPU</td>
          <td>~$0.05056</td>
      </tr>
      <tr>
          <td>1 GB RAM</td>
          <td>~$0.00553</td>
      </tr>
  </tbody>
</table>
<p>一個 1 vCPU / 2 GB 的 task 持續運行一個月（730 小時）的費用約為 $0.05056 × 730 + $0.00553 × 2 × 730 ≈ $44.97。這個數字是所有後續比較的基線。</p>
<p>Fargate 的計費粒度還有一個常被忽略的面向：task 規格只能從 AWS 預定義的 vCPU/memory 組合中選。如果應用只需要 0.3 vCPU / 512 MB，最小可選的配置是 0.25 vCPU / 0.5 GB，但如果需要 0.3 vCPU / 1 GB，就得選 0.5 vCPU / 1 GB — 多付了 0.2 vCPU 的費用。這個「階梯式浪費」在小規格 task 上比例最高。</p>
<h2 id="fargate-vs-ec2-launch-type-的成本比較">Fargate vs EC2 launch type 的成本比較</h2>
<p>EC2 launch type 的成本結構不同：付的是 EC2 instance 的時數（不管上面跑幾個 task），加上 ECS 本身不收費。省的是 Fargate 的 markup，多的是 instance 管理（AMI 更新、capacity provider 設定、instance 閒置時仍計費）。</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>Fargate 月費</th>
          <th>EC2（t3.medium）月費</th>
          <th>差異</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1 task, 1 vCPU / 2 GB, 持續</td>
          <td>~$45</td>
          <td>~$30（共享 instance）</td>
          <td>+50%</td>
      </tr>
      <tr>
          <td>5 tasks, 各 0.5 vCPU / 1 GB</td>
          <td>~$113</td>
          <td>~$30（1 台 t3.medium 裝得下）</td>
          <td>+277%</td>
      </tr>
      <tr>
          <td>20 tasks, 各 1 vCPU / 2 GB</td>
          <td>~$900</td>
          <td>~$240（4 台 t3.xlarge）</td>
          <td>+275%</td>
      </tr>
      <tr>
          <td>流量波動大，尖峰 10 tasks / 離峰 1</td>
          <td>~$180（加權平均）</td>
          <td>~$150（需預留尖峰容量）</td>
          <td>+20%</td>
      </tr>
  </tbody>
</table>
<p>幾個判讀要點：</p>
<ul>
<li>task 數量少且持續運行時，Fargate 的溢價比例最高（+50% 到 +277%），但絕對金額小（$15-$80/月的差距），不值得為此承擔 instance 管理的維運負擔</li>
<li>task 數量多且持續運行時，EC2 的絕對節省量開始可觀（$660/月），這時候切換的維運成本有回報</li>
<li>流量波動大時，Fargate 的優勢是按需計費 — 離峰時 task 數降下來就停止計費，EC2 instance 閒置時仍然計費。波動越大，Fargate 的成本效益越接近或超過 EC2</li>
</ul>
<h2 id="fargate-spot">Fargate Spot</h2>
<p>Fargate Spot 使用 AWS 的閒置容量，價格約為 on-demand 的 30%（折扣幅度 ~70%），代價是 AWS 可以隨時回收容量、task 會收到 SIGTERM 後被終止。</p>
<p>適用條件：task 能在 120 秒內優雅停止、應用有重試機制或上游有 load balancer 自動移除不健康的 target。批次處理、背景 worker、可中斷的佇列消費者是典型的 Spot 候選。對外直接服務的 API 通常混合部署 — 基線容量用 on-demand、彈性擴張部分用 Spot。</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></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="k">capacity_provider_strategy</span> {
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">    capacity_provider</span> <span class="o">=</span> <span class="s2">&#34;FARGATE&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">    weight</span>            <span class="o">=</span> <span class="m">1</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">    base</span>              <span class="o">=</span> <span class="m">2</span><span class="c1">  # 至少 2 個 on-demand task 保底
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>  }
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="k">capacity_provider_strategy</span> {
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">    capacity_provider</span> <span class="o">=</span> <span class="s2">&#34;FARGATE_SPOT&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">    weight</span>            <span class="o">=</span> <span class="m">3</span><span class="c1">  # 擴張時 3/4 的 task 用 Spot
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span>  }
</span></span><span class="line"><span class="ln">14</span><span class="cl">}</span></span></code></pre></div><p><code>base = 2</code> 確保至少有兩個 on-demand task 在線（不會被回收），<code>weight</code> 比例讓後續擴張的 task 優先使用 Spot。中斷發生時 ECS 會自動在 on-demand 上補充，但補充需要時間（task 啟動 + health check 通過），這段期間服務容量會短暫下降。</p>
<h2 id="compute-savings-plans">Compute Savings Plans</h2>
<p>Compute Savings Plans 是對 Fargate（和 EC2、Lambda）的預付承諾折扣：承諾每小時固定消費 X 美元的運算量，換取 1 年或 3 年的折扣（1 年約 -20%、3 年約 -40%，視具體方案）。</p>
<p>關鍵判斷：承諾量（$/hr）設在實際用量的多少比例。保守做法是設在過去 3 個月最低用量的 80% — 這部分幾乎確定會用到，享受折扣；超過承諾量的部分自動按 on-demand 計費，不會浪費。</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"># 查過去 90 天的 Fargate 用量趨勢</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws ce get-cost-and-usage <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --time-period <span class="nv">Start</span><span class="o">=</span>2026-03-01,End<span class="o">=</span>2026-06-01 <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --granularity MONTHLY <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --metrics <span class="s2">&#34;UnblendedCost&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --filter <span class="s1">&#39;{&#34;Dimensions&#34;:{&#34;Key&#34;:&#34;SERVICE&#34;,&#34;Values&#34;:[&#34;Amazon Elastic Container Service&#34;]}}&#39;</span></span></span></code></pre></div><p>Savings Plans 跟 Fargate Spot 可以疊加：Spot task 的費用也能用 Savings Plans 折抵。先用 Savings Plans 降低基線成本，再用 Spot 降低彈性擴張的成本，兩層折扣疊起來可以把 Fargate 的實際單價壓到接近 EC2 on-demand。</p>
<h2 id="task-規格的-rightsizing">Task 規格的 rightsizing</h2>
<p>Fargate task 的 vCPU 和記憶體配置如果設得過大，多出來的資源每小時都在計費。rightsizing 的目標是讓 task 規格貼合實際使用量，但留足安全餘裕。</p>
<h3 id="量測實際使用量">量測實際使用量</h3>
<p>開啟 CloudWatch Container Insights 後，每個 task 的 CPU 和記憶體使用量會自動上報。觀察 7-14 天的 p95 值：</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"># 查 ECS service 過去 7 天的 CPU p95</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws cloudwatch get-metric-statistics <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --namespace ECS/ContainerInsights <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --metric-name CpuUtilized <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --dimensions <span class="nv">Name</span><span class="o">=</span>ServiceName,Value<span class="o">=</span>api <span class="nv">Name</span><span class="o">=</span>ClusterName,Value<span class="o">=</span>prod <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --start-time 2026-06-19T00:00:00Z <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --end-time 2026-06-26T00:00:00Z <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  --period <span class="m">3600</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="se"></span>  --statistics p95</span></span></code></pre></div><h3 id="判斷調整方向">判斷調整方向</h3>
<table>
  <thead>
      <tr>
          <th>p95 使用率</th>
          <th>判斷</th>
          <th>動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CPU &lt; 30%</td>
          <td>過度配置，浪費明顯</td>
          <td>降一級 vCPU</td>
      </tr>
      <tr>
          <td>CPU 30-70%</td>
          <td>合理範圍，有足夠餘裕應對尖峰</td>
          <td>維持</td>
      </tr>
      <tr>
          <td>CPU &gt; 80%</td>
          <td>餘裕不足，尖峰時可能觸發 throttling</td>
          <td>升一級 vCPU 或增加 task 數</td>
      </tr>
      <tr>
          <td>Memory &lt; 40%</td>
          <td>過度配置</td>
          <td>降一級 memory</td>
      </tr>
      <tr>
          <td>Memory &gt; 80%</td>
          <td>OOM kill 風險</td>
          <td>升一級 memory</td>
      </tr>
  </tbody>
</table>
<p>調整後觀察 3-5 天確認沒有效能退化再進入下一輪。每次只調一個維度（CPU 或 memory），避免同時改兩個變數無法歸因。</p>
<h3 id="fargate-可選的規格組合">Fargate 可選的規格組合</h3>
<p>Fargate 的 vCPU 和 memory 不能任意搭配。常用的組合：</p>
<table>
  <thead>
      <tr>
          <th>vCPU</th>
          <th>可選 Memory 範圍</th>
          <th>典型用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>0.25</td>
          <td>0.5 / 1 / 2 GB</td>
          <td>輕量 sidecar、cron job</td>
      </tr>
      <tr>
          <td>0.5</td>
          <td>1 / 2 / 3 / 4 GB</td>
          <td>小型 API、worker</td>
      </tr>
      <tr>
          <td>1</td>
          <td>2 / 3 / 4 / 5 / 6 / 7 / 8 GB</td>
          <td>標準 API、中型 worker</td>
      </tr>
      <tr>
          <td>2</td>
          <td>4 ~ 16 GB</td>
          <td>高負載 API、批次處理</td>
      </tr>
      <tr>
          <td>4</td>
          <td>8 ~ 30 GB</td>
          <td>資料處理、ML inference</td>
      </tr>
  </tbody>
</table>
<p>選的時候從最小的「能跑」組合開始，用 Container Insights 量測後再調。常見的浪費是把所有 task 都設成 1 vCPU / 2 GB — 一個只用 0.1 vCPU / 256 MB 的 sidecar 也配了同樣的規格。</p>
<h2 id="何時從-fargate-切到-ec2">何時從 Fargate 切到 EC2</h2>
<p>切換的判斷不只看成本差額，還要看維運能力。EC2 launch type 需要管理：AMI 更新（安全 patch）、instance draining（rolling update 時把 task 遷走再關 instance）、capacity provider 的擴縮邏輯、instance 的 security group 與 IAM role。</p>
<table>
  <thead>
      <tr>
          <th>判斷維度</th>
          <th>留在 Fargate</th>
          <th>切到 EC2</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>月費差額</td>
          <td>&lt; $200</td>
          <td>&gt; $500 且持續 3 個月</td>
      </tr>
      <tr>
          <td>團隊維運能力</td>
          <td>沒有專人管 instance</td>
          <td>有平台工程師或 DevOps</td>
      </tr>
      <tr>
          <td>流量型態</td>
          <td>波動大、有明顯離峰</td>
          <td>穩定、24/7 持續運行</td>
      </tr>
      <tr>
          <td>GPU 需求</td>
          <td>不需要</td>
          <td>需要（Fargate 不支援 GPU）</td>
      </tr>
      <tr>
          <td>啟動速度</td>
          <td>可接受 cold start</td>
          <td>需要 &lt;1s 啟動（EC2 instance 已在線）</td>
      </tr>
  </tbody>
</table>
<p>混合部署是常見的中間路線：基線容量用 EC2（成本低、啟動快），尖峰彈性用 Fargate Spot（按需、不需預留）。這需要同時維護兩種 capacity provider，複雜度較高。</p>
<h2 id="成本監控">成本監控</h2>
<p>把 ECS 的成本歸因到服務層級需要兩個機制：task 層的 tag propagation 和 Cost Explorer 的 tag 維度。</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">  propagate_tags</span> <span class="o">=</span> <span class="s2">&#34;SERVICE&#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">  tags</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">    service</span>     <span class="o">=</span> <span class="s2">&#34;payment-api&#34;</span>
</span></span><span class="line"><span class="ln"> 7</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"> 8</span><span class="cl"><span class="n">    cost-center</span> <span class="o">=</span> <span class="s2">&#34;cc-payments&#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><code>propagate_tags = &quot;SERVICE&quot;</code> 讓 service 的 tag 自動傳播到每個 task，Cost Explorer 就能按 <code>service</code> 或 <code>cost-center</code> 維度拆分 Fargate 費用。這跟<a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>的 tagging 規範對齊 — tag 是成本可見性的地基。</p>
<p>定期（月初或月中）檢查 Cost Explorer 的 Fargate 費用趨勢：</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 ce get-cost-and-usage <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --time-period <span class="nv">Start</span><span class="o">=</span>2026-06-01,End<span class="o">=</span>2026-06-26 <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --granularity DAILY <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --metrics <span class="s2">&#34;UnblendedCost&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --group-by <span class="nv">Type</span><span class="o">=</span>TAG,Key<span class="o">=</span>service <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --filter <span class="s1">&#39;{&#34;Dimensions&#34;:{&#34;Key&#34;:&#34;SERVICE&#34;,&#34;Values&#34;:[&#34;Amazon Elastic Container Service&#34;]}}&#39;</span></span></span></code></pre></div><p>費用突然跳升時，先看是 task 數增加（auto-scaling 觸發）還是單價變化（Savings Plans 過期或 Spot 中斷後自動回補為 on-demand）。這兩者的處理方式不同：前者檢查 scaling policy、後者檢查 Savings Plans 到期日和 Spot 回收頻率。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<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>：ECS vs EKS 選型、Fargate 的定位</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/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">devops 模組八：成本管理</a>：運行期的 RI / Spot / rightsizing 策略</li>
</ul>
]]></content:encoded></item><item><title>NAT Gateway</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/nat/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/nat/</guid><description>&lt;p>NAT Gateway（Network Address Translation Gateway）的核心職責是讓 private subnet 的資源能主動發起對外連線（拉套件、呼叫第三方 API、下載 OS 更新），同時不開放任何外部主動發起的入站連線。它借用一個公網 IP 把出站封包送出去，再把回應導回原請求者。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>NAT Gateway 在網路地基裡的角色是 private subnet 的出站閘道。它解決的問題是：private subnet 的設計意圖是「外部連不進來」，但服務仍需要主動對外。沒有 NAT，private subnet 的資源完全無法對外通訊 — 連 &lt;code>apt update&lt;/code> 或 &lt;code>pip install&lt;/code> 都做不到。&lt;/p>
&lt;p>NAT Gateway 是綁定單一可用區的資源，活在某個 public subnet 裡。這帶來一個架構取捨：共享一個 NAT（成本低、出站方向有單點）還是每個可用區各放一個（成本高、出站與 subnet 冗餘對齊）。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>以下狀況指向 NAT 相關問題：&lt;/p>
&lt;ul>
&lt;li>Private subnet 的服務拉不到外部套件或第三方 API 全部逾時 — 先查 route table 有沒有指向健康的 NAT&lt;/li>
&lt;li>只有某一個可用區的節點受影響 — 該區的 NAT 或其所在 subnet 可能故障&lt;/li>
&lt;li>雲帳單裡 NAT Gateway 的流量費用異常高 — 大量走 NAT 的流量（S3 備份、跨區同步）可用 VPC Endpoint 繞過&lt;/li>
&lt;/ul>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>使用 NAT Gateway 時要決定：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>數量&lt;/strong>：每個可用區一個（可用性優先）還是全 VPC 共享一個（成本優先）。每個 NAT 固定月費約 $32 加流量費 $0.045/GB&lt;/li>
&lt;li>&lt;strong>高流量路徑&lt;/strong>：對 AWS 自家服務的流量（S3、DynamoDB）改用 Gateway Endpoint 直連，繞過 NAT 省流量費&lt;/li>
&lt;li>&lt;strong>route table 關聯&lt;/strong>：每個 private subnet 的 route table 要明確指向哪個 NAT&lt;/li>
&lt;/ul>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">Subnet&lt;/a> — NAT 放在 public subnet、服務放在 private subnet&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/vpc/" data-link-title="VPC（Virtual Private Cloud）" data-link-desc="雲端帳號內的一塊邏輯隔離私有網段，是所有網路切分的起點與容器">VPC&lt;/a> — NAT 屬於 VPC 內部的出站路徑設施&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>NAT Gateway（Network Address Translation Gateway）的核心職責是讓 private subnet 的資源能主動發起對外連線（拉套件、呼叫第三方 API、下載 OS 更新），同時不開放任何外部主動發起的入站連線。它借用一個公網 IP 把出站封包送出去，再把回應導回原請求者。</p>
<h2 id="概念位置">概念位置</h2>
<p>NAT Gateway 在網路地基裡的角色是 private subnet 的出站閘道。它解決的問題是：private subnet 的設計意圖是「外部連不進來」，但服務仍需要主動對外。沒有 NAT，private subnet 的資源完全無法對外通訊 — 連 <code>apt update</code> 或 <code>pip install</code> 都做不到。</p>
<p>NAT Gateway 是綁定單一可用區的資源，活在某個 public subnet 裡。這帶來一個架構取捨：共享一個 NAT（成本低、出站方向有單點）還是每個可用區各放一個（成本高、出站與 subnet 冗餘對齊）。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>以下狀況指向 NAT 相關問題：</p>
<ul>
<li>Private subnet 的服務拉不到外部套件或第三方 API 全部逾時 — 先查 route table 有沒有指向健康的 NAT</li>
<li>只有某一個可用區的節點受影響 — 該區的 NAT 或其所在 subnet 可能故障</li>
<li>雲帳單裡 NAT Gateway 的流量費用異常高 — 大量走 NAT 的流量（S3 備份、跨區同步）可用 VPC Endpoint 繞過</li>
</ul>
<h2 id="設計責任">設計責任</h2>
<p>使用 NAT Gateway 時要決定：</p>
<ul>
<li><strong>數量</strong>：每個可用區一個（可用性優先）還是全 VPC 共享一個（成本優先）。每個 NAT 固定月費約 $32 加流量費 $0.045/GB</li>
<li><strong>高流量路徑</strong>：對 AWS 自家服務的流量（S3、DynamoDB）改用 Gateway Endpoint 直連，繞過 NAT 省流量費</li>
<li><strong>route table 關聯</strong>：每個 private subnet 的 route table 要明確指向哪個 NAT</li>
</ul>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">Subnet</a> — NAT 放在 public subnet、服務放在 private subnet</li>
<li><a href="/blog/infra/knowledge-cards/vpc/" data-link-title="VPC（Virtual Private Cloud）" data-link-desc="雲端帳號內的一塊邏輯隔離私有網段，是所有網路切分的起點與容器">VPC</a> — NAT 屬於 VPC 內部的出站路徑設施</li>
</ul>
]]></content:encoded></item><item><title>模組七：infra 走 PR 流程與自動化護欄</title><link>https://tarrragon.github.io/blog/infra/07-infra-as-pr/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/07-infra-as-pr/</guid><description>&lt;p>infra 變更要走跟 application code 一樣的流程：開分支、提 PR、跑檢查、review diff、合併、發布。這條原則把基礎設施變更從「某個人在自己終端機 apply」轉成「團隊可審查的紀錄」，是 IaC 真正兌現價值的地方，也是解開「只有我懂 infra」這個單點依賴的關鍵。基礎設施跟程式碼一樣會出錯、會需要回溯、會交接給別人，所以它需要同一套保護機制。&lt;/p>
&lt;h2 id="infra-變更走-code-流程">infra 變更走 code 流程&lt;/h2>
&lt;p>infra 變更的標準路徑是 PR → plan → review diff → 合併 → apply。這個順序的核心責任是把「執行前先看清楚要改什麼」變成強制步驟，而不是 apply 之後才從事故裡發現改錯了。每個環節各自承擔一段審查責任，少掉任一段，infra 就退回到不可審查的狀態。&lt;/p>
&lt;p>&lt;code>terraform plan&lt;/code> 是這條鏈裡最關鍵的一環。它把當前 state、雲端實際資源、與目標設定三方比對，產出一份「會新增 / 修改 / 刪除哪些資源」的 diff。這份 diff 是 review 的對象：reviewer 直接看 plan 算出來的實際變更，而非讀 HCL 自行想像結果。一個容易被低估的判讀訊號是 plan 裡的 &lt;code>destroy&lt;/code> 與 &lt;code>replace&lt;/code>（顯示為 &lt;code>-/+&lt;/code>）— 改一個看似無害的欄位（例如某些雲資源的 &lt;code>name&lt;/code>、或資料庫的 &lt;code>identifier&lt;/code>）可能觸發整個資源重建，對有狀態的服務代表資料遺失或停機。Review 階段抓到這個 &lt;code>-/+&lt;/code>，比 apply 到一半才發現便宜太多。&lt;/p>
&lt;p>把 plan 結果貼回 PR 是讓 review 真正生效的做法。流程上，PR 觸發 CI 跑 plan，plan 輸出回貼成 PR comment，reviewer 連同程式碼 diff 一起看；approve 後才允許合併，合併才觸發 apply。這裡有個取捨：plan 與 apply 之間若隔了很久，雲端實際狀態可能已經漂移（有人手動改了、或別的 PR 先 apply 了），導致 apply 時的 plan 跟 review 時看到的不一致。多數團隊在 apply 階段會重跑一次 plan 並要求它與 review 時一致，代價是流程多一道、但換到「review 看到的就是實際執行的」這個保證。&lt;/p>
&lt;p>風險邊界落在 apply 失敗的回退上。infra apply 不像程式碼部署可以直接 rollback 到上一版 image — 中途失敗時部分資源已經建立、state 可能處於半完成狀態。所以 PR 流程的價值不只在事前審查，也在事後可追溯：每次變更都對應一個 commit 與一個 PR，要回溯時知道是哪次改的、為什麼改、誰 review 的。&lt;/p>
&lt;h2 id="fmt-與-validate最便宜的第一道檢查">fmt 與 validate：最便宜的第一道檢查&lt;/h2>
&lt;p>&lt;code>fmt&lt;/code> 與 &lt;code>validate&lt;/code> 是進到任何安全掃描之前的基礎檢查，責任是擋掉格式不一致與語法 / 型別錯誤這類不需要動腦判斷的問題。它們跑得快、沒有誤判空間，適合放在 CI 最前面當作快速 fail 的關卡。&lt;/p>
&lt;p>&lt;code>terraform fmt -check&lt;/code> 驗證程式碼是否符合標準排版。它本身不影響基礎設施行為，價值在於消除 diff 噪音：當每個人的編輯器縮排習慣不同，PR diff 會混入大量純排版變動，把真正的邏輯變更淹沒，reviewer 更容易看漏。統一格式後，diff 裡剩下的就是語意變更。&lt;code>validate&lt;/code> 則檢查設定在語法與內部一致性上是否成立 — reference 到不存在的變數、型別不匹配、必填參數缺漏，這些在 validate 階段就會報錯，不必等到 plan 連線雲端才發現。&lt;/p>
&lt;p>判讀上，fmt 與 validate 失敗代表的是「這份 code 還沒準備好被認真 review」，屬於作者自己該先修掉的問題，不該佔用 reviewer 注意力。把它們設成 CI 必過的 gate，作者在本地就會先跑、先修，PR 送出時已經是乾淨的。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c"># .github/workflows/terraform.yml — plan 前的基礎檢查&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">jobs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">validate&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">runs-on&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ubuntu-latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">steps&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">uses&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">actions/checkout@v4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">uses&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hashicorp/setup-terraform@v3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">run&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">terraform fmt -check -recursive&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">run&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">terraform init -backend=false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">run&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">terraform validate&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="tflint--checkov--tfsec抓壞寫法與安全漏洞">tflint / checkov / tfsec：抓壞寫法與安全漏洞&lt;/h2>
&lt;p>fmt 與 validate 確認 code「語法正確」，但語法正確的設定仍然可能是危險的設定。tflint、checkov、tfsec 這類靜態掃描工具承擔的是「語意正確」這層：在不實際建立資源的前提下，從 HCL 裡比對已知的壞寫法與安全反模式，把問題擋在 plan 之前。它們補的是 reviewer 肉眼容易漏掉的盲區 — 人會看漏一個 &lt;code>0.0.0.0/0&lt;/code>，規則不會。&lt;/p></description><content:encoded><![CDATA[<p>infra 變更要走跟 application code 一樣的流程：開分支、提 PR、跑檢查、review diff、合併、發布。這條原則把基礎設施變更從「某個人在自己終端機 apply」轉成「團隊可審查的紀錄」，是 IaC 真正兌現價值的地方，也是解開「只有我懂 infra」這個單點依賴的關鍵。基礎設施跟程式碼一樣會出錯、會需要回溯、會交接給別人，所以它需要同一套保護機制。</p>
<h2 id="infra-變更走-code-流程">infra 變更走 code 流程</h2>
<p>infra 變更的標準路徑是 PR → plan → review diff → 合併 → apply。這個順序的核心責任是把「執行前先看清楚要改什麼」變成強制步驟，而不是 apply 之後才從事故裡發現改錯了。每個環節各自承擔一段審查責任，少掉任一段，infra 就退回到不可審查的狀態。</p>
<p><code>terraform plan</code> 是這條鏈裡最關鍵的一環。它把當前 state、雲端實際資源、與目標設定三方比對，產出一份「會新增 / 修改 / 刪除哪些資源」的 diff。這份 diff 是 review 的對象：reviewer 直接看 plan 算出來的實際變更，而非讀 HCL 自行想像結果。一個容易被低估的判讀訊號是 plan 裡的 <code>destroy</code> 與 <code>replace</code>（顯示為 <code>-/+</code>）— 改一個看似無害的欄位（例如某些雲資源的 <code>name</code>、或資料庫的 <code>identifier</code>）可能觸發整個資源重建，對有狀態的服務代表資料遺失或停機。Review 階段抓到這個 <code>-/+</code>，比 apply 到一半才發現便宜太多。</p>
<p>把 plan 結果貼回 PR 是讓 review 真正生效的做法。流程上，PR 觸發 CI 跑 plan，plan 輸出回貼成 PR comment，reviewer 連同程式碼 diff 一起看；approve 後才允許合併，合併才觸發 apply。這裡有個取捨：plan 與 apply 之間若隔了很久，雲端實際狀態可能已經漂移（有人手動改了、或別的 PR 先 apply 了），導致 apply 時的 plan 跟 review 時看到的不一致。多數團隊在 apply 階段會重跑一次 plan 並要求它與 review 時一致，代價是流程多一道、但換到「review 看到的就是實際執行的」這個保證。</p>
<p>風險邊界落在 apply 失敗的回退上。infra apply 不像程式碼部署可以直接 rollback 到上一版 image — 中途失敗時部分資源已經建立、state 可能處於半完成狀態。所以 PR 流程的價值不只在事前審查，也在事後可追溯：每次變更都對應一個 commit 與一個 PR，要回溯時知道是哪次改的、為什麼改、誰 review 的。</p>
<h2 id="fmt-與-validate最便宜的第一道檢查">fmt 與 validate：最便宜的第一道檢查</h2>
<p><code>fmt</code> 與 <code>validate</code> 是進到任何安全掃描之前的基礎檢查，責任是擋掉格式不一致與語法 / 型別錯誤這類不需要動腦判斷的問題。它們跑得快、沒有誤判空間，適合放在 CI 最前面當作快速 fail 的關卡。</p>
<p><code>terraform fmt -check</code> 驗證程式碼是否符合標準排版。它本身不影響基礎設施行為，價值在於消除 diff 噪音：當每個人的編輯器縮排習慣不同，PR diff 會混入大量純排版變動，把真正的邏輯變更淹沒，reviewer 更容易看漏。統一格式後，diff 裡剩下的就是語意變更。<code>validate</code> 則檢查設定在語法與內部一致性上是否成立 — reference 到不存在的變數、型別不匹配、必填參數缺漏，這些在 validate 階段就會報錯，不必等到 plan 連線雲端才發現。</p>
<p>判讀上，fmt 與 validate 失敗代表的是「這份 code 還沒準備好被認真 review」，屬於作者自己該先修掉的問題，不該佔用 reviewer 注意力。把它們設成 CI 必過的 gate，作者在本地就會先跑、先修，PR 送出時已經是乾淨的。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># .github/workflows/terraform.yml — plan 前的基礎檢查</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">validate</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">hashicorp/setup-terraform@v3</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform fmt -check -recursive</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform init -backend=false</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform validate</span></span></span></code></pre></div><h2 id="tflint--checkov--tfsec抓壞寫法與安全漏洞">tflint / checkov / tfsec：抓壞寫法與安全漏洞</h2>
<p>fmt 與 validate 確認 code「語法正確」，但語法正確的設定仍然可能是危險的設定。tflint、checkov、tfsec 這類靜態掃描工具承擔的是「語意正確」這層：在不實際建立資源的前提下，從 HCL 裡比對已知的壞寫法與安全反模式，把問題擋在 plan 之前。它們補的是 reviewer 肉眼容易漏掉的盲區 — 人會看漏一個 <code>0.0.0.0/0</code>，規則不會。</p>
<p>這三者的側重不同，組合起來覆蓋面才完整。tflint 偏向 provider 層的正確性與慣例規範：用了已棄用的參數、instance type 在該 region 不存在、命名不符規範。checkov 與 tfsec 偏向安全與合規：掃的是會造成資料外洩或權限過大的設定。兩個最常被它們攔下、也最常釀成真實事故的模式，值得單獨說明。</p>
<p>第一個是 S3 bucket 對外公開。一個漏設 <code>block_public_access</code> 或 ACL 寫成 <code>public-read</code> 的 bucket，會讓裡面的物件對整個網際網路可讀。這類設定在 HCL 裡只是一兩行，肉眼 review 時很容易因為「看起來像樣板」而放過，但後果是資料外洩。checkov 有專門規則比對 bucket 的 public access 設定，命中就讓 CI fail，逼作者在合併前說明或修正。</p>
<p>第二個是 security group 對全世界開放。一條 ingress 寫成 <code>cidr_blocks = [&quot;0.0.0.0/0&quot;]</code> 加上 port 22 或 3306，等於把 SSH 或資料庫埠暴露給全網掃描器。tfsec 與 checkov 都會標記這種「敏感埠 + 全開 CIDR」的組合。這條規則跟模組三：網路地基講的 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"><span class="c1"># 三道掃描串在一起，任一 fail 就中斷</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">tflint --recursive
</span></span><span class="line"><span class="ln">3</span><span class="cl">checkov -d . --quiet --compact
</span></span><span class="line"><span class="ln">4</span><span class="cl">tfsec . --soft-fail<span class="o">=</span>false</span></span></code></pre></div><p>判讀這些工具的命中時，要區分「真漏洞」與「情境合理的例外」。並非每個 <code>0.0.0.0/0</code> 都是錯 — 一個對外的 HTTPS load balancer 在 port 443 開全網是設計本意。所以這些掃描的命中是候選不是判決：多數工具支援用行內註解標記豁免（例如 checkov 的 <code>#checkov:skip</code>），代價是豁免要寫理由、要被 review，避免變成無聲略過。把例外顯式化、留下為什麼豁免的紀錄，比關掉整條規則安全。</p>
<h2 id="atlantis-與-github-actions自動化-plan-與-apply">Atlantis 與 GitHub Actions：自動化 plan 與 apply</h2>
<p>把上述流程自動化，需要一個能監聽 PR 事件、在對的時機跑 plan 與 apply 的執行層。兩種常見做法是直接用 CI 平台（如 GitHub Actions）寫 workflow，或用 Atlantis 這類專為 Terraform PR 流程設計的工具。Atlantis 是一個常駐服務，掛在 git 平台的 webhook 上：PR 開啟時它自動跑 <code>plan</code> 並把結果貼回 PR comment，reviewer approve 後在 PR 留言 <code>atlantis apply</code>，它才執行 apply 並回報結果。它的價值在於把「誰能 apply、apply 前要不要 approve、plan 結果在哪看」這些規則收斂成一致的、可設定的流程，而不是散落在各 repo 各自的 workflow 腳本裡。</p>
<p>選哪一種是機會成本的取捨。GitHub Actions workflow 的優點是不必額外維運一個服務、跟既有 CI 共用同一套權限與 runner；缺點是 apply 的 gating 邏輯（approve 後才能 apply、apply lock 避免兩個 PR 同時改同一份 state）要自己用 workflow 條件拼出來。Atlantis 的優點是這些 gating 與 state lock 是內建語意、跨多 repo 一致；缺點是它本身是一個要部署、要升級、要保護的常駐服務。團隊 repo 少、流程簡單時 Actions 划算；管理大量 Terraform repo、需要統一 apply 治理時 Atlantis 划算。</p>
<p>無論哪種執行層，自動化的 apply 都需要對雲端的寫入權限，而這個權限怎麼來是整條管線的安全根基。這裡正是模組二：身分與憑證地基鋪設的 OIDC 兌現的地方 — 管線不該存放長期的 access key，而是在 runner 執行時用 OIDC 向雲端換取短期 token。模組二講的是怎麼建立這個信任關係，本章是它的回報處：因為有了 OIDC，自動 apply 才能在不持有靜態憑證的前提下安全執行，憑證外洩的攻擊面從「一把長期金鑰」縮到「單次執行的短期 token」。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># 合併到主幹後，用 OIDC 換短期憑證再 apply（呼應模組二）</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">apply</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">github.ref == &#39;refs/heads/main&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write  </span><span class="w"> </span><span class="c"># 允許 runner 取得 OIDC token</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">aws-actions/configure-aws-credentials@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">          </span><span class="nt">role-to-assume</span><span class="p">:</span><span class="w"> </span><span class="l">arn:aws:iam::123456789012:role/infra-apply</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">          </span><span class="nt">aws-region</span><span class="p">:</span><span class="w"> </span><span class="l">ap-northeast-1</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">hashicorp/setup-terraform@v3</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform init</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform apply -auto-approve</span></span></span></code></pre></div><p>判讀自動 apply 的邊界時，要留意它不適合所有變更。對會觸發資源重建或刪除的高風險 plan，多數團隊會保留人工 apply 的關卡（Atlantis 的手動 <code>atlantis apply</code>、或 workflow 加 environment protection rule 要人按確認），不讓這類變更在合併瞬間無人看管地執行。自動化的目的是消除重複勞動與人為遺漏，不是把判斷也一起省掉。</p>
<h2 id="知識留在-code而不是留在個人腦中">知識留在 code，而不是留在個人腦中</h2>
<p>走完整套 PR 流程後，infra 的真正收穫是知識從個人的記憶移到了 repo 裡。每一次「為什麼這個 security group 開這個埠」「為什麼這台機器選這個 instance type」的決策，都以 code + PR 描述 + review 討論的形式留下，新人讀 repo 就能還原當初的判斷，不必去問那個「只有他懂 infra」的人。這是這個模組從第一章開始累積的目的地：基礎設施可被閱讀，等於它可被交接。</p>
<p>可 revert 是這套機制最直接的兌現。當某次變更引發問題，回退手段是 <code>git revert</code> 那個 commit 再走一次 PR 流程，讓基礎設施回到變更前的設定 — 跟回退一段壞掉的程式碼是同一個動作。對照「只有我懂 infra」的舊狀態：那時候回退靠的是當事人記得自己改了什麼、手動在 console 改回去，記錯或人不在就無從回退。把變更歷史留在 git，回退就從「依賴某人的記憶」變成「依賴版本紀錄」。</p>
<p>這份 revert 能力的邊界要講清楚，跟本章前面講的 apply 半完成 state 是同一個誠實。revert code 救得回的是「設定」，救不回已經被銷毀的狀態與資料：revert 掉一個刪除 stateful 資源的 commit，只是讓設定回到「該資源存在」，但被刪掉的資料庫內容不會跟著回來；rename 或 replace 類的變更 revert 後，可能再觸發一次資源重建。所以 stateful 變更的真正回退仍然靠備份與快照，這正是模組五 stateful 處理與模組八 secret / state 保護要顧的事。把 git revert 當「設定層回退」就誠實，把它當「資料層回退」就會在事故裡踩空。</p>
<p>這條知識共享的路線會在模組九：怎麼把 infra 推動起來展開到組織層。本章解決的是技術機制 — code 留得住知識；模組九解決的是怎麼讓一個習慣手動操作的團隊真的願意走這套流程、把知識交出來。技術上能審查、能回溯、能交接是前提，但讓團隊實際採用它是另一層問題。</p>
<p>判讀一個團隊是否真的把知識留在 code 的訊號很具體：當主要負責 infra 的人請假，其他人能不能只靠讀 repo 就理解現狀並安全地改一個小設定。如果答案是「得等他回來」，那不論工具鏈多完整，知識還在個人腦中，PR 流程只是形式。這個訊號比任何工具設定都更能反映 infra 的成熟度。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">infra 走 PR 流程與自動化護欄</a></td>
          <td>PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/07-infra-as-pr/terraform-ci-pipeline-setup/" data-link-title="Terraform CI Pipeline 設定指南" data-link-desc="用 GitHub Actions 建立完整的 Terraform CI pipeline：fmt → validate → tflint → plan → PR comment → apply，含 OIDC credential 與環境保護規則">Terraform CI Pipeline 設定指南</a></td>
          <td>GitHub Actions 完整 workflow（fmt → validate → tflint → plan → PR comment → apply）、OIDC credential、環境保護規則</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/07-infra-as-pr/checkov-tfsec-rule-customization/" data-link-title="checkov 與 tfsec 規則配置" data-link-desc="靜態掃描工具的規則選擇策略、自訂規則、豁免管理、false positive 處理與 CI 整合，讓掃描從噪音來源變成可信的品質關卡">checkov 與 tfsec 規則配置</a></td>
          <td>三階段漸進啟用、規則選擇策略、inline vs 集中式豁免管理、自訂規則、false positive 處理</td>
      </tr>
  </tbody>
</table>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/ci/" data-link-title="CI/CD 教學" data-link-desc="整理 CI/CD 的驗證、建置、發布 gate 與不同部署場域的流程差異，讓每次變更都能被穩定驗證與交付">CI/CD 教學</a>：infra 管線用的就是這套驗證 / 發布 gate，plan / apply 對應 build / deploy 階段</li>
<li>→ <a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>：管線用 OIDC 取得 apply 權限，本章是該章 OIDC 設計的回報兌現處</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 收斂原則，本章用 tfsec / checkov 在 CI 攔下寫錯的全開規則</li>
<li>→ <a href="/blog/infra/09-driving-adoption/" data-link-title="模組九：怎麼把 infra 推動起來" data-link-desc="技術正確不等於推得動 — 信任赤字、期望值對齊、知識共享，infra 落地的組織課題">模組九：怎麼把 infra 推動起來</a>：本章把知識留在 code 的技術機制，在該章展開成組織層的採用與知識共享</li>
<li>→ <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 模組七：資安與資料保護</a>：S3 公開、敏感埠全開這類掃描攔截的反模式，對應的資料保護原則</li>
</ul>
]]></content:encoded></item><item><title>斷網環境的套件與容器映像 Registry</title><link>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-package-registry/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-package-registry/</guid><description>&lt;p>連網環境的套件安裝和映像拉取，背後都有一個公開的 registry 在服務：apt 走 archive.ubuntu.com、npm 走 registry.npmjs.org、Docker 走 Docker Hub。斷網環境裡這些 endpoint 全部不可達，每一條 &lt;code>apt install&lt;/code>、&lt;code>npm install&lt;/code>、&lt;code>pip install&lt;/code>、&lt;code>docker pull&lt;/code> 都會失敗。替代做法是在內網部署自己的 registry，把需要的套件和映像從外部下載、經過安全審查後搬進來。&lt;/p>
&lt;p>本篇涵蓋兩個 registry 的部署與操作：Nexus Repository（多格式套件）和 Harbor（容器映像）。兩者可以獨立運作，也可以搭配使用——Nexus 管套件依賴、Harbor 管容器映像，各自負責不同的 artifact 類型。&lt;/p>
&lt;h2 id="nexus-repository統一的離線套件-proxy">Nexus Repository：統一的離線套件 proxy&lt;/h2>
&lt;p>Nexus Repository OSS（開源版）支援 apt、yum、npm、PyPI、Maven、NuGet、Go modules 等多種格式，用一個實例取代多個獨立的離線 repo mirror。部署在內網後，所有開發機器和 CI runner 把套件 source 指向 Nexus。&lt;/p>
&lt;h3 id="部署">部署&lt;/h3>
&lt;p>Nexus 本身是一個 Java 應用，用 Docker 部署最簡單。映像需要事先從外部搬進來：&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">docker pull sonatype/nexus3:latest
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">docker save sonatype/nexus3:latest -o nexus3.tar
&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"># 搬運到內網後載入&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">docker load -i nexus3.tar
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">docker run -d -p 8081:8081 --name nexus &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> -v nexus-data:/nexus-data &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> sonatype/nexus3:latest&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>初始管理員密碼在容器內 &lt;code>/nexus-data/admin.password&lt;/code>，首次登入後強制修改。&lt;/p>
&lt;h3 id="hosted-repo-模式">Hosted repo 模式&lt;/h3>
&lt;p>連網環境的 Nexus 通常用 proxy repo（代理公開 registry、快取下載過的套件）。斷網環境 proxy 模式無法運作，改用 hosted repo——手動上傳套件到 Nexus，Nexus 作為唯一的分發來源。&lt;/p>
&lt;p>以 npm 為例，workflow 是在外部機器打包、搬運、上傳：&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">npm pack --pack-destination ./npm-packages/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="c1"># 或用 npm-offline-packager 批次下載整棵依賴樹&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">npx npm-offline-packager --package ./package.json --output ./npm-packages/
&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"># 搬運到內網後上傳到 Nexus&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="k">for&lt;/span> pkg in ./npm-packages/*.tgz&lt;span class="p">;&lt;/span> &lt;span class="k">do&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> curl -u admin:password &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> --upload-file &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$pkg&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="s2">&amp;#34;http://nexus.internal:8081/repository/npm-hosted/&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="k">done&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>apt 和 yum 的做法類似：外部機器用 &lt;code>apt-get download&lt;/code> 或 &lt;code>yumdownloader&lt;/code> 抓 .deb / .rpm 檔案，搬進來後上傳到 Nexus 的 hosted repo。&lt;/p>
&lt;h3 id="客戶端設定">客戶端設定&lt;/h3>
&lt;p>開發機器和 CI runner 的套件 source 指向 Nexus：&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"># npm&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">npm config &lt;span class="nb">set&lt;/span> registry http://nexus.internal:8081/repository/npm-hosted/
&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"># pip&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">pip install --index-url http://nexus.internal:8081/repository/pypi-hosted/simple/ package-name
&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"># apt（在 /etc/apt/sources.list.d/ 加一份）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">deb http://nexus.internal:8081/repository/apt-hosted/ focal main&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="harbor容器映像的-private-registry">Harbor：容器映像的 private registry&lt;/h2>
&lt;p>Harbor 是 CNCF 畢業專案的企業級容器 registry，支援映像簽章、漏洞掃描（Trivy）、存取控制、映像複製。在斷網環境裡它是 Docker Hub 和 ECR 的替代品。&lt;/p></description><content:encoded><![CDATA[<p>連網環境的套件安裝和映像拉取，背後都有一個公開的 registry 在服務：apt 走 archive.ubuntu.com、npm 走 registry.npmjs.org、Docker 走 Docker Hub。斷網環境裡這些 endpoint 全部不可達，每一條 <code>apt install</code>、<code>npm install</code>、<code>pip install</code>、<code>docker pull</code> 都會失敗。替代做法是在內網部署自己的 registry，把需要的套件和映像從外部下載、經過安全審查後搬進來。</p>
<p>本篇涵蓋兩個 registry 的部署與操作：Nexus Repository（多格式套件）和 Harbor（容器映像）。兩者可以獨立運作，也可以搭配使用——Nexus 管套件依賴、Harbor 管容器映像，各自負責不同的 artifact 類型。</p>
<h2 id="nexus-repository統一的離線套件-proxy">Nexus Repository：統一的離線套件 proxy</h2>
<p>Nexus Repository OSS（開源版）支援 apt、yum、npm、PyPI、Maven、NuGet、Go modules 等多種格式，用一個實例取代多個獨立的離線 repo mirror。部署在內網後，所有開發機器和 CI runner 把套件 source 指向 Nexus。</p>
<h3 id="部署">部署</h3>
<p>Nexus 本身是一個 Java 應用，用 Docker 部署最簡單。映像需要事先從外部搬進來：</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">docker pull sonatype/nexus3:latest
</span></span><span class="line"><span class="ln">3</span><span class="cl">docker save sonatype/nexus3:latest -o nexus3.tar
</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"># 搬運到內網後載入</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">docker load -i nexus3.tar
</span></span><span class="line"><span class="ln">7</span><span class="cl">docker run -d -p 8081:8081 --name nexus <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  -v nexus-data:/nexus-data <span class="se">\
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="se"></span>  sonatype/nexus3:latest</span></span></code></pre></div><p>初始管理員密碼在容器內 <code>/nexus-data/admin.password</code>，首次登入後強制修改。</p>
<h3 id="hosted-repo-模式">Hosted repo 模式</h3>
<p>連網環境的 Nexus 通常用 proxy repo（代理公開 registry、快取下載過的套件）。斷網環境 proxy 模式無法運作，改用 hosted repo——手動上傳套件到 Nexus，Nexus 作為唯一的分發來源。</p>
<p>以 npm 為例，workflow 是在外部機器打包、搬運、上傳：</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">npm pack --pack-destination ./npm-packages/
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"># 或用 npm-offline-packager 批次下載整棵依賴樹</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">npx npm-offline-packager --package ./package.json --output ./npm-packages/
</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"># 搬運到內網後上傳到 Nexus</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">for</span> pkg in ./npm-packages/*.tgz<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  curl -u admin:password <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>    --upload-file <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>    <span class="s2">&#34;http://nexus.internal:8081/repository/npm-hosted/&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="k">done</span></span></span></code></pre></div><p>apt 和 yum 的做法類似：外部機器用 <code>apt-get download</code> 或 <code>yumdownloader</code> 抓 .deb / .rpm 檔案，搬進來後上傳到 Nexus 的 hosted repo。</p>
<h3 id="客戶端設定">客戶端設定</h3>
<p>開發機器和 CI runner 的套件 source 指向 Nexus：</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"># npm</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">npm config <span class="nb">set</span> registry http://nexus.internal:8081/repository/npm-hosted/
</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"># pip</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">pip install --index-url http://nexus.internal:8081/repository/pypi-hosted/simple/ package-name
</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"># apt（在 /etc/apt/sources.list.d/ 加一份）</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">deb http://nexus.internal:8081/repository/apt-hosted/ focal main</span></span></code></pre></div><h2 id="harbor容器映像的-private-registry">Harbor：容器映像的 private registry</h2>
<p>Harbor 是 CNCF 畢業專案的企業級容器 registry，支援映像簽章、漏洞掃描（Trivy）、存取控制、映像複製。在斷網環境裡它是 Docker Hub 和 ECR 的替代品。</p>
<h3 id="部署-1">部署</h3>
<p>Harbor 用 Docker Compose 部署。安裝包需要從外部下載後搬進來：</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">wget https://github.com/goharbor/harbor/releases/download/v2.11.0/harbor-offline-installer-v2.11.0.tgz
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># 搬運到內網後解壓</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">tar xzf harbor-offline-installer-v2.11.0.tgz
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="nb">cd</span> harbor
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 複製並編輯設定</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">cp harbor.yml.tmpl harbor.yml
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"># 修改 hostname、storage 路徑、HTTPS 憑證（內部 CA 簽發）</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="c1"># 安裝</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">./install.sh --with-trivy</span></span></code></pre></div><p><code>--with-trivy</code> 啟用內建的漏洞掃描。Trivy 的漏洞資料庫需要離線更新——從外部下載 DB 檔案、搬進來放到指定路徑。</p>
<h3 id="專案與存取控制">專案與存取控制</h3>
<p>Harbor 用「專案」（project）組織映像。每個專案可以設定獨立的存取控制：</p>
<ul>
<li><code>library</code>：公開專案、所有使用者可 pull</li>
<li><code>platform</code>：平台團隊專用、限定成員可 push</li>
<li><code>vendor</code>：第三方 base image、由 infra 團隊管理更新</li>
</ul>
<p>robot account 提供 CI/CD 用的非互動式認證（限定 pull / push 權限、可設定到期時間）。</p>
<h2 id="映像搬運-sop">映像搬運 SOP</h2>
<p>映像從外部搬進斷網環境是一個需要標準化的操作，涉及格式、大小、多架構支援：</p>
<h3 id="搬運工具比較">搬運工具比較</h3>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>優點</th>
          <th>限制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>docker save/load</code></td>
          <td>最直覺、不需要額外安裝</td>
          <td>只能處理本地已 pull 的映像、不支援跨 registry 直接搬</td>
      </tr>
      <tr>
          <td><code>skopeo copy</code></td>
          <td>不需要 Docker daemon、支援跨 registry、支援 manifest list</td>
          <td>需要安裝 skopeo</td>
      </tr>
      <tr>
          <td><code>crane</code></td>
          <td>輕量 CLI、支援 manifest 操作</td>
          <td>功能比 skopeo 少</td>
      </tr>
  </tbody>
</table>
<p>skopeo 的操作流程：</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"># 外部機器：從 Docker Hub 複製到本地目錄</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">skopeo copy docker://nginx:1.25-alpine dir:./images/nginx-1.25-alpine
</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"># 搬運到內網後：從本地目錄推到 Harbor</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">skopeo copy dir:./images/nginx-1.25-alpine <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  docker://harbor.internal/library/nginx:1.25-alpine <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --dest-tls-verify<span class="o">=</span><span class="nb">false</span>  <span class="c1"># 如果 Harbor 用內部 CA</span></span></span></code></pre></div><h3 id="多架構映像">多架構映像</h3>
<p>如果環境同時有 amd64 和 arm64 的機器，搬運時要帶整個 manifest list：</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">skopeo copy --all docker://nginx:1.25-alpine <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  dir:./images/nginx-1.25-alpine-multiarch
</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"># 內網：推送所有架構</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">skopeo copy --all dir:./images/nginx-1.25-alpine-multiarch <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  docker://harbor.internal/library/nginx:1.25-alpine</span></span></code></pre></div><p><code>--all</code> flag 確保 manifest list 裡的每個架構都被複製，而非只複製本機架構。</p>
<h2 id="套件與映像的更新週期">套件與映像的更新週期</h2>
<p>斷網環境的套件和映像不會自動更新——每一次更新都是一次有意識的搬運操作。更新週期的頻率由安全需求決定：</p>
<table>
  <thead>
      <tr>
          <th>安全等級</th>
          <th>更新頻率</th>
          <th>適用場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>一般</td>
          <td>每月一次</td>
          <td>開發工具、非直接面對外部的服務</td>
      </tr>
      <tr>
          <td>中等</td>
          <td>每兩週</td>
          <td>有外部接口的服務、包含網路元件</td>
      </tr>
      <tr>
          <td>高</td>
          <td>每週或 CVE 驅動</td>
          <td>安全敏感環境、合規要求</td>
      </tr>
  </tbody>
</table>
<p>每次更新的標準流程：</p>
<ol>
<li><strong>外部機器下載</strong>：按清單下載指定版本的套件和映像</li>
<li><strong>安全掃描</strong>：在外部（或 staging gateway）跑 Trivy / Snyk 掃描，確認沒有已知的高風險 CVE</li>
<li><strong>審查核准</strong>：掃描報告給安全團隊或負責人簽核</li>
<li><strong>搬運</strong>：核准的 artifact 寫入唯讀媒體或加密通道搬進內網</li>
<li><strong>上傳到 registry</strong>：推到 Nexus 和 Harbor</li>
<li><strong>通知團隊</strong>：哪些套件/映像有新版本可用</li>
</ol>
<p>這個流程的產出是一份更新清單（什麼版本、掃描結果、核准人），存進版控作為稽核紀錄。</p>
<h2 id="helm-chart-離線管理">Helm chart 離線管理</h2>
<p>Kubernetes 環境用 Helm 部署應用。斷網時 Helm chart 需要離線管理：</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"># 外部機器：下載 chart</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">helm repo add bitnami https://charts.bitnami.com/bitnami
</span></span><span class="line"><span class="ln">3</span><span class="cl">helm pull bitnami/postgresql --version 15.5.0
</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"># 搬運到內網後有兩個存放選項</span></span></span></code></pre></div><p><strong>選項一：Harbor 內建 chart 支援</strong>。Harbor 2.0+ 支援 OCI artifact，Helm chart 可以直接推到 Harbor：</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">helm push postgresql-15.5.0.tgz oci://harbor.internal/charts</span></span></code></pre></div><p><strong>選項二：ChartMuseum</strong>。獨立的 chart repository server：</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"># 上傳 chart</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">curl --data-binary <span class="s2">&#34;@postgresql-15.5.0.tgz&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  http://chartmuseum.internal:8080/api/charts</span></span></code></pre></div><p>Harbor 的 OCI 方式較簡單（不需要額外維護 ChartMuseum），但需要 Helm 3.8+ 的 OCI 支援。</p>
<h2 id="時程與管理層溝通">時程與管理層溝通</h2>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>初次部署時間</th>
          <th>持續維護</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Nexus Repository</td>
          <td>1 天（部署 + 初始套件上傳）</td>
          <td>每次更新週期 2-4 小時</td>
      </tr>
      <tr>
          <td>Harbor</td>
          <td>1 天（部署 + 初始映像搬運）</td>
          <td>每次更新週期 2-4 小時</td>
      </tr>
      <tr>
          <td>搬運 SOP 建立</td>
          <td>半天（腳本化 + 文件）</td>
          <td>每次執行 1-2 小時</td>
      </tr>
      <tr>
          <td>Trivy 離線 DB 更新</td>
          <td>含在 Harbor 部署內</td>
          <td>每次更新週期 30 分鐘</td>
      </tr>
  </tbody>
</table>
<p>管理層需要知道的成本：registry 的維護不是一次性投入，每個更新週期都需要工程師時間執行搬運和掃描。這筆成本在連網環境裡由公開 registry 和自動更新吸收，斷網環境裡由團隊承擔。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">斷網環境的通用原則</a>：content ferry pattern 和安全審查流程</li>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-container/" data-link-title="斷網環境的容器與映像管理" data-link-desc="Private registry 架設、映像搬運（docker save/load、skopeo）、base image 更新週期、離線漏洞掃描">斷網環境的容器與映像管理</a>：映像搬運的更完整討論（本篇聚焦 registry 部署、該篇聚焦映像生命週期）</li>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-iac/" data-link-title="斷網環境的 IaC" data-link-desc="Terraform provider mirror、離線 plugin cache、本地 state backend、沒有雲端時的 plan/apply 流程與內網 CI">斷網環境的 IaC</a>：Terraform provider 也需要離線 mirror、可用 Nexus 的 raw hosted repo 存放</li>
</ul>
]]></content:encoded></item><item><title>OIDC 聯合</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/oidc/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/oidc/</guid><description>&lt;p>OIDC（OpenID Connect）聯合的核心職責是讓跑在雲外的 CI/CD 平台（GitHub Actions、GitLab CI）用每次執行才簽發、幾分鐘後就失效的短期憑證存取雲端資源，從根本上消除「在 CI 環境裡存放長期 access key」這個攻擊面。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>OIDC 聯合在身分與憑證地基裡的角色是「雲外機器身分的認證機制」。跑在雲上的 workload（EC2、ECS task）可以用平台原生的 instance profile 或 task role 取得短期憑證；跑在雲外的 CI/CD 沒有這個管道，OIDC 就是替代方案。&lt;/p>
&lt;p>運作方式是建立信任關係：雲端帳號信任某個外部 identity provider（如 GitHub Actions 的 OIDC issuer），CI 執行時平台簽發一個帶 claim 的 token（描述哪個 repo、哪個 branch、哪個 workflow），雲端用這個 token 換出一段臨時憑證。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>以下狀況指向 OIDC 相關問題：&lt;/p>
&lt;ul>
&lt;li>CI pipeline 裡有 &lt;code>AWS_ACCESS_KEY_ID&lt;/code> 和 &lt;code>AWS_SECRET_ACCESS_KEY&lt;/code> 環境變數 — 這是長期 key，應該替換成 OIDC&lt;/li>
&lt;li>Trust policy 只驗 issuer 不驗 repo — 任何掛在同一個 CI 平台的專案都能假扮這個 role&lt;/li>
&lt;li>Pipeline 突然無法取得權限 — 可能是 trust policy 的 condition 跟 token claim 不匹配（常見於 repo 改名或 branch 改名後）&lt;/li>
&lt;/ul>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>設定 OIDC 聯合時要決定：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Trust policy 的 claim 收斂&lt;/strong>：限定 issuer + audience + 特定 repo + 特定 branch，每個條件都收到最緊&lt;/li>
&lt;li>&lt;strong>Role 的權限範圍&lt;/strong>：OIDC 換到的 role 仍然要遵循最小權限 — 只給 pipeline 需要的 action&lt;/li>
&lt;li>&lt;strong>Plan 與 apply 分開的 role&lt;/strong>：plan 只需要 read 權限、apply 需要 write 權限，用兩個 role 降低 PR 階段的風險&lt;/li>
&lt;/ul>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM&lt;/a> — OIDC 是 IAM 身分系統的一種外部身分來源&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group&lt;/a> — OIDC 解的是身分層的認證問題，跟網路層的 security group 正交&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>OIDC（OpenID Connect）聯合的核心職責是讓跑在雲外的 CI/CD 平台（GitHub Actions、GitLab CI）用每次執行才簽發、幾分鐘後就失效的短期憑證存取雲端資源，從根本上消除「在 CI 環境裡存放長期 access key」這個攻擊面。</p>
<h2 id="概念位置">概念位置</h2>
<p>OIDC 聯合在身分與憑證地基裡的角色是「雲外機器身分的認證機制」。跑在雲上的 workload（EC2、ECS task）可以用平台原生的 instance profile 或 task role 取得短期憑證；跑在雲外的 CI/CD 沒有這個管道，OIDC 就是替代方案。</p>
<p>運作方式是建立信任關係：雲端帳號信任某個外部 identity provider（如 GitHub Actions 的 OIDC issuer），CI 執行時平台簽發一個帶 claim 的 token（描述哪個 repo、哪個 branch、哪個 workflow），雲端用這個 token 換出一段臨時憑證。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>以下狀況指向 OIDC 相關問題：</p>
<ul>
<li>CI pipeline 裡有 <code>AWS_ACCESS_KEY_ID</code> 和 <code>AWS_SECRET_ACCESS_KEY</code> 環境變數 — 這是長期 key，應該替換成 OIDC</li>
<li>Trust policy 只驗 issuer 不驗 repo — 任何掛在同一個 CI 平台的專案都能假扮這個 role</li>
<li>Pipeline 突然無法取得權限 — 可能是 trust policy 的 condition 跟 token claim 不匹配（常見於 repo 改名或 branch 改名後）</li>
</ul>
<h2 id="設計責任">設計責任</h2>
<p>設定 OIDC 聯合時要決定：</p>
<ul>
<li><strong>Trust policy 的 claim 收斂</strong>：限定 issuer + audience + 特定 repo + 特定 branch，每個條件都收到最緊</li>
<li><strong>Role 的權限範圍</strong>：OIDC 換到的 role 仍然要遵循最小權限 — 只給 pipeline 需要的 action</li>
<li><strong>Plan 與 apply 分開的 role</strong>：plan 只需要 read 權限、apply 需要 write 權限，用兩個 role 降低 PR 階段的風險</li>
</ul>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM</a> — OIDC 是 IAM 身分系統的一種外部身分來源</li>
<li><a href="/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group</a> — OIDC 解的是身分層的認證問題，跟網路層的 security group 正交</li>
</ul>
]]></content:encoded></item><item><title>模組八：治理好習慣 — 規模長大後不失控的最小節奏</title><link>https://tarrragon.github.io/blog/infra/08-governance-habits/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/08-governance-habits/</guid><description>&lt;p>每一個治理習慣單獨看都很小：在資源上多打三個 tag、把一段連線字串挪去別的地方、給帳單欄位填個用途。但少了這些習慣，半年後的代價是另一個量級 — 翻著一頁兩百筆沒有歸屬的資源猜哪個能砍、為了輪替一把外洩的密鑰回頭 grep 整個 repo、對著一張看不出誰花的雲帳單開跨部門會議。這一章談的就是這組「現在花幾分鐘、未來省幾天」的最小節奏。&lt;/p>
&lt;p>治理習慣的責任是讓基礎設施在規模長大後仍然可被盤點、可被追責、可被回收。資源數量從幾十個長到幾百個時，「這是誰的、為什麼存在、花了多少」這三個問題若沒有預先在資源上留下答案，就只能靠人腦記憶與口頭考古，而記憶會隨著人員流動蒸發。&lt;/p>
&lt;p>先界定這一章的邊界。身分與憑證本身怎麼設計 — IAM role、OIDC、最小權限 — 是模組二「身分與憑證地基」的範圍，這一章只談 secret 的儲存與引用：機密值放在哪、IaC 怎麼安全地指到它。成本這一塊也分兩層：把資源歸屬到擁有者與用途的地基（tagging、chargeback 的依據）在這一章，運行期怎麼用 reserved instance、spot、rightsizing 去壓低帳單，是 &lt;a href="https://tarrragon.github.io/blog/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">devops 模組八：成本管理&lt;/a> 的範圍。&lt;/p>
&lt;h2 id="tagging-規範查帳與清資源的依據">Tagging 規範：查帳與清資源的依據&lt;/h2>
&lt;p>Tag 是貼在每個資源上的結構化標籤，承擔「讓資源能被機器查詢與分群」的責任。沒有 tag 的資源在 console 裡只剩一個隨機後綴的名字，人能勉強認得幾個，但一旦數量過百，任何「列出所有 staging 的資源」「算出 team-a 這個月花多少」的問題都無法用查詢回答，只能逐筆翻。Tag 把這些問題從人工考古變成一行 filter。&lt;/p>
&lt;p>值得從第一天就強制的最小 tag 集合是三個維度，各自回答一個治理問題：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Tag&lt;/th>
 &lt;th>回答的問題&lt;/th>
 &lt;th>典型值&lt;/th>
 &lt;th>缺了會怎樣&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>env&lt;/code>&lt;/td>
 &lt;td>這是哪個環境&lt;/td>
 &lt;td>&lt;code>prod&lt;/code> / &lt;code>staging&lt;/code> / &lt;code>dev&lt;/code>&lt;/td>
 &lt;td>清資源時不敢動、怕誤刪生產&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>owner&lt;/code>&lt;/td>
 &lt;td>出事找誰&lt;/td>
 &lt;td>&lt;code>team-payments&lt;/code> / &lt;code>platform&lt;/code>&lt;/td>
 &lt;td>資源孤兒化、沒人認領也沒人敢回收&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>cost-center&lt;/code>&lt;/td>
 &lt;td>這筆錢算誰的&lt;/td>
 &lt;td>&lt;code>cc-1024&lt;/code> / &lt;code>growth&lt;/code>&lt;/td>
 &lt;td>帳單無法拆分、成本變成一筆沒人負責的公共支出&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>env&lt;/code> 是清資源時的安全護欄。回收動作最大的恐懼是誤刪生產資源，當每個資源都標了 &lt;code>env&lt;/code>，「列出所有 &lt;code>env=dev&lt;/code> 且 30 天無流量的資源」就是一條可以放心執行的清理查詢，而 &lt;code>env=prod&lt;/code> 的資源自動被排除在批次刪除之外。沒有這個 tag，任何自動化清理都因為怕誤傷而不敢落地，最後退回人工逐筆確認，於是根本沒人去清。&lt;/p>
&lt;p>&lt;code>owner&lt;/code> 解決資源孤兒化。服務出狀況、或是看到一個用途不明的資源時，第一個問題是「這誰的」。標了 owner，告警可以自動路由、清理前可以自動通知認領；沒標，這個資源就停在「沒人敢動、因為不知道砍了會不會弄壞什麼」的狀態，永久占用配額與費用。團隊命名比個人名好 — 人會離職，團隊邊界相對穩定。&lt;/p>
&lt;p>&lt;code>cost-center&lt;/code> 是成本歸屬的地基，下一節展開。&lt;/p>
&lt;p>關鍵在於 tag 必須在資源建立時就由 IaC 寫進去，而不是事後補。Terraform 的 &lt;code>default_tags&lt;/code> 讓一個 provider 區塊內的所有資源自動繼承一組 tag，避免逐個資源手動標、也避免漏標：&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">provider&lt;/span> &lt;span class="s2">&amp;#34;aws&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"> 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"> 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="k">default_tags&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"> tags&lt;/span> &lt;span class="o">=&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"> env&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;staging&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="n"> owner&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;team-payments&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"> cost-center&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;cc-1024&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="n"> managed-by&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;terraform&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>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>事後補 tag 是個會無限拖延的工作，因為它不影響任何功能、沒有 deadline、永遠排在 backlog 最後。判讀訊號很簡單：定期跑一條「列出缺少必填 tag 的資源」的查詢，數字若持續成長，代表有人繞過 IaC 手動開資源 — 這既是 tag 問題，也是模組一「Console 唯讀」紀律鬆動的徵兆。可以用 AWS 的 tag policy 或 OPA 這類策略引擎把「缺 tag 的資源」擋在 PR 階段，讓規範變成自動護欄而不是靠人自律。&lt;/p>
&lt;h2 id="secrets-不進-code機密值的儲存與引用">Secrets 不進 code：機密值的儲存與引用&lt;/h2>
&lt;p>機密值 — 資料庫密碼、第三方 API key、簽章用的私鑰 — 要存在專用的密鑰管理服務裡，而 code 與 IaC 只持有指向它的參照，不持有值本身。這條規則承擔的責任是把「機密外洩的爆炸半徑」與「程式碼的散布範圍」脫鉤：一旦密碼寫進 repo，它就跟著每一次 clone、每一份 CI 快取、每一個 fork 擴散，輪替時無法保證所有副本都更新，git 歷史更是會把它永久留存，即使後來刪掉那一行。&lt;/p>
&lt;p>密鑰管理服務 — AWS Secrets Manager、SSM Parameter Store、HashiCorp Vault、GCP Secret Manager — 提供的是一個有存取控制、有審計紀錄、可輪替的集中儲存。值放在這裡，誰讀過、什麼時候讀的都有 log，輪替時只改一個地方，所有引用方下次讀取就拿到新值。&lt;/p>
&lt;p>關鍵在 IaC 怎麼引用。IaC 應該存的是密鑰的 ARN（或等價的資源識別碼）與「在執行期去讀它」的指令，而不是密鑰的明文。下面這段把 RDS 密碼從 Secrets Manager 引用進來，state 與 plan 裡出現的是 secret 的 reference，不是密碼字串：&lt;/p></description><content:encoded><![CDATA[<p>每一個治理習慣單獨看都很小：在資源上多打三個 tag、把一段連線字串挪去別的地方、給帳單欄位填個用途。但少了這些習慣，半年後的代價是另一個量級 — 翻著一頁兩百筆沒有歸屬的資源猜哪個能砍、為了輪替一把外洩的密鑰回頭 grep 整個 repo、對著一張看不出誰花的雲帳單開跨部門會議。這一章談的就是這組「現在花幾分鐘、未來省幾天」的最小節奏。</p>
<p>治理習慣的責任是讓基礎設施在規模長大後仍然可被盤點、可被追責、可被回收。資源數量從幾十個長到幾百個時，「這是誰的、為什麼存在、花了多少」這三個問題若沒有預先在資源上留下答案，就只能靠人腦記憶與口頭考古，而記憶會隨著人員流動蒸發。</p>
<p>先界定這一章的邊界。身分與憑證本身怎麼設計 — IAM role、OIDC、最小權限 — 是模組二「身分與憑證地基」的範圍，這一章只談 secret 的儲存與引用：機密值放在哪、IaC 怎麼安全地指到它。成本這一塊也分兩層：把資源歸屬到擁有者與用途的地基（tagging、chargeback 的依據）在這一章，運行期怎麼用 reserved instance、spot、rightsizing 去壓低帳單，是 <a href="/blog/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">devops 模組八：成本管理</a> 的範圍。</p>
<h2 id="tagging-規範查帳與清資源的依據">Tagging 規範：查帳與清資源的依據</h2>
<p>Tag 是貼在每個資源上的結構化標籤，承擔「讓資源能被機器查詢與分群」的責任。沒有 tag 的資源在 console 裡只剩一個隨機後綴的名字，人能勉強認得幾個，但一旦數量過百，任何「列出所有 staging 的資源」「算出 team-a 這個月花多少」的問題都無法用查詢回答，只能逐筆翻。Tag 把這些問題從人工考古變成一行 filter。</p>
<p>值得從第一天就強制的最小 tag 集合是三個維度，各自回答一個治理問題：</p>
<table>
  <thead>
      <tr>
          <th>Tag</th>
          <th>回答的問題</th>
          <th>典型值</th>
          <th>缺了會怎樣</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>env</code></td>
          <td>這是哪個環境</td>
          <td><code>prod</code> / <code>staging</code> / <code>dev</code></td>
          <td>清資源時不敢動、怕誤刪生產</td>
      </tr>
      <tr>
          <td><code>owner</code></td>
          <td>出事找誰</td>
          <td><code>team-payments</code> / <code>platform</code></td>
          <td>資源孤兒化、沒人認領也沒人敢回收</td>
      </tr>
      <tr>
          <td><code>cost-center</code></td>
          <td>這筆錢算誰的</td>
          <td><code>cc-1024</code> / <code>growth</code></td>
          <td>帳單無法拆分、成本變成一筆沒人負責的公共支出</td>
      </tr>
  </tbody>
</table>
<p><code>env</code> 是清資源時的安全護欄。回收動作最大的恐懼是誤刪生產資源，當每個資源都標了 <code>env</code>，「列出所有 <code>env=dev</code> 且 30 天無流量的資源」就是一條可以放心執行的清理查詢，而 <code>env=prod</code> 的資源自動被排除在批次刪除之外。沒有這個 tag，任何自動化清理都因為怕誤傷而不敢落地，最後退回人工逐筆確認，於是根本沒人去清。</p>
<p><code>owner</code> 解決資源孤兒化。服務出狀況、或是看到一個用途不明的資源時，第一個問題是「這誰的」。標了 owner，告警可以自動路由、清理前可以自動通知認領；沒標，這個資源就停在「沒人敢動、因為不知道砍了會不會弄壞什麼」的狀態，永久占用配額與費用。團隊命名比個人名好 — 人會離職，團隊邊界相對穩定。</p>
<p><code>cost-center</code> 是成本歸屬的地基，下一節展開。</p>
<p>關鍵在於 tag 必須在資源建立時就由 IaC 寫進去，而不是事後補。Terraform 的 <code>default_tags</code> 讓一個 provider 區塊內的所有資源自動繼承一組 tag，避免逐個資源手動標、也避免漏標：</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">provider</span> <span class="s2">&#34;aws&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</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"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="k">default_tags</span> {
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">    tags</span> <span class="o">=</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="s2">&#34;staging&#34;</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;team-payments&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">      cost-center</span> <span class="o">=</span> <span class="s2">&#34;cc-1024&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">      managed-by</span>  <span class="o">=</span> <span class="s2">&#34;terraform&#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></span></code></pre></div><p>事後補 tag 是個會無限拖延的工作，因為它不影響任何功能、沒有 deadline、永遠排在 backlog 最後。判讀訊號很簡單：定期跑一條「列出缺少必填 tag 的資源」的查詢，數字若持續成長，代表有人繞過 IaC 手動開資源 — 這既是 tag 問題，也是模組一「Console 唯讀」紀律鬆動的徵兆。可以用 AWS 的 tag policy 或 OPA 這類策略引擎把「缺 tag 的資源」擋在 PR 階段，讓規範變成自動護欄而不是靠人自律。</p>
<h2 id="secrets-不進-code機密值的儲存與引用">Secrets 不進 code：機密值的儲存與引用</h2>
<p>機密值 — 資料庫密碼、第三方 API key、簽章用的私鑰 — 要存在專用的密鑰管理服務裡，而 code 與 IaC 只持有指向它的參照，不持有值本身。這條規則承擔的責任是把「機密外洩的爆炸半徑」與「程式碼的散布範圍」脫鉤：一旦密碼寫進 repo，它就跟著每一次 clone、每一份 CI 快取、每一個 fork 擴散，輪替時無法保證所有副本都更新，git 歷史更是會把它永久留存，即使後來刪掉那一行。</p>
<p>密鑰管理服務 — AWS Secrets Manager、SSM Parameter Store、HashiCorp Vault、GCP Secret Manager — 提供的是一個有存取控制、有審計紀錄、可輪替的集中儲存。值放在這裡，誰讀過、什麼時候讀的都有 log，輪替時只改一個地方，所有引用方下次讀取就拿到新值。</p>
<p>關鍵在 IaC 怎麼引用。IaC 應該存的是密鑰的 ARN（或等價的資源識別碼）與「在執行期去讀它」的指令，而不是密鑰的明文。下面這段把 RDS 密碼從 Secrets Manager 引用進來，state 與 plan 裡出現的是 secret 的 reference，不是密碼字串：</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_secretsmanager_secret&#34; &#34;db&#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;prod/payments/db-password&#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_secretsmanager_secret_version&#34; &#34;db&#34;</span> {
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  secret_id</span> <span class="o">=</span> <span class="k">data</span><span class="p">.</span><span class="k">aws_secretsmanager_secret</span><span class="p">.</span><span class="k">db</span><span class="p">.</span><span class="k">id</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_db_instance&#34; &#34;payments&#34;</span> {<span class="c1">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">  # 引用 secret 的值、但這個值不是寫在 code 裡
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="n">  password</span> <span class="o">=</span> <span class="k">data</span><span class="p">.</span><span class="k">aws_secretsmanager_secret_version</span><span class="p">.</span><span class="k">db</span><span class="p">.</span><span class="k">secret_string</span><span class="c1">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1">  # ...
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span>}</span></span></code></pre></div><p>這裡有一個常被忽略的邊界：Terraform 即使從 Secrets Manager 讀值，那個值仍然會以明文落進 state file。所以「不進 code」只是第一道，state 後端的加密與存取控制（模組一的 state 地基）是同等重要的第二道 — 否則密鑰只是從 repo 搬到了一個沒鎖好的 state bucket。判讀訊號：定期用 secret 掃描工具（gitleaks、trufflehog）掃 repo 與 CI log，任何命中都當成需要輪替的外洩事件處理，而不是刪掉那行就算了，因為 git 歷史與既有 clone 已經保不住了。</p>
<p>機密的命名也值得約定。用 <code>env/service/purpose</code> 這類有結構的路徑（如 <code>prod/payments/db-password</code>），讓存取策略可以用前綴授權 — 給某個 service 的 role 只能讀 <code>prod/payments/*</code>，自然落實最小權限。誰能讀哪些 secret 的權限設計屬於模組二，更完整的密鑰生命週期、輪替策略與資料保護在 <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 模組七：資安與資料保護</a>。</p>
<h2 id="成本可見性每筆花費都對得到擁有者與用途">成本可見性：每筆花費都對得到擁有者與用途</h2>
<p>成本可見性的目標是讓帳單上的每一筆花費都能回答「這是誰的、為了什麼」。雲帳單預設是一筆按服務類型加總的數字 — EC2 多少、RDS 多少 — 這個視角能告訴你花在哪類資源，卻答不出花在哪個團隊、哪個產品線、哪個功能。當這個問題答不出來，成本就變成一筆沒人負責的公共支出，沒有人有動機去優化自己看不到的帳。</p>
<p>把成本拆解到擁有者的地基，正是前面的 tagging。雲廠商的成本分攤工具（AWS Cost Explorer、Cost Allocation Tags、GCP 的 billing label）能用 tag 當分群維度，前提是那些 tag 要先在 billing 後台啟用為「成本分攤標籤」。啟用後，<code>cost-center</code> 和 <code>owner</code> 就從單純的標籤升級成帳單的可查詢維度，於是「team-payments 這個月花多少」「staging 環境占總成本幾成」變成一張報表而不是一場會議。</p>
<p>可見性先於優化，這個順序不能反。看不見的成本無法被歸屬，無法歸屬就無法問責，沒有問責就沒有人去做優化。所以這一章把地基鋪好 — 資源有 tag、tag 進了 billing 維度、報表能拆到團隊 — 之後運行期那些真正省錢的手段才有施力點。判讀訊號：設一條成本異常告警（如日均花費超過基線某個百分比就通知），當告警觸發時，因為有 tag，你能立刻定位是哪個團隊的哪類資源在漲，而不是面對一個總數乾瞪眼。</p>
<p>到了「知道誰花多少、接下來怎麼省」這一步 — reserved instance 的承諾折扣、spot 的可中斷算力、閒置資源的 rightsizing 與排程關機 — 就進入 <a href="/blog/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">devops 模組八：成本管理</a> 的運行期優化範圍。這一章負責的是讓那些優化「有帳可查、有人可問」。</p>
<h2 id="最小可行節奏先把地基跑起來再逐步加">最小可行節奏：先把地基跑起來，再逐步加</h2>
<p>治理的最小可行節奏，是早期只立「拔掉就會痛、補起來很貴」的那幾條規範，其餘留到規模逼出需求時再加。治理機制本身有維護成本 — 每一條策略規則、每一個審批關卡、每一套標籤分類法都要有人維護、有人解釋、有人在它擋錯東西時來救。在團隊還小、資源還少時堆滿企業級治理框架，付出的是當下的速度，換來的是一套還用不到的複雜度。</p>
<p>判斷一條治理規範該不該現在就立，看它的「補救成本曲線」。有些規範越晚補越貴，因為它要改的是既有資源的既成事實：</p>
<ul>
<li><strong>Tagging</strong>：越晚補越貴。幾百個沒 tag 的資源要回頭逐個考古歸屬，而當初建立時順手標只要幾秒。屬於 day-1 該立。</li>
<li><strong>Secrets 不進 code</strong>：幾乎無法事後補救。一旦密鑰進了 git 歷史就回不去，只能輪替所有外洩的密鑰。屬於 day-1 鐵律。</li>
<li><strong>成本分攤維度</strong>：依賴 tagging，tag 立了它就近乎免費啟用。地基屬於早期，細緻的 chargeback 報表可以晚點做。</li>
<li><strong>細緻的審批流程 / 多層級策略引擎</strong>：補救成本低、可以隨時加。早期硬上反而拖慢交付。屬於規模逼出需求再做。</li>
</ul>
<p>這個曲線給出的節奏是：補救成本陡的（tagging、secrets）從第一天就用 IaC 強制進去，因為它們事後補的代價是逐筆考古或全面輪替；補救成本平的（複雜審批、精細策略）等到痛點真的出現 — 開始有人手滑誤刪、開始有跨團隊的權限爭議 — 再有針對性地加，那時你也才知道該往哪個方向加。</p>
<p>這個節奏跟模組零的成熟度階梯是同一套思路：基礎設施的治理跟基礎設施本身一樣，是逐級長出來的，不是一次到位設計完的。過度設計的治理框架跟過度設計的架構一樣，會在還沒帶來價值之前就先收走團隊的速度。把規範變成自動護欄的工程（PR 階段擋缺 tag、CI 掃 secret）值得早投入，因為自動化的護欄維護成本低、且越早接管越省人力 — 這部分怎麼落地在 <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 模組七：infra 走 PR 流程</a> 展開。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/infra/08-governance-habits/tagging-secrets/" data-link-title="Tagging 規範與 Secrets 不進 code" data-link-desc="tag 讓資源可盤點、可清理、可歸屬；密鑰存在專用服務裡而非 code 或 state，兩者都屬於 day-1 就該立的治理地基">Tagging 規範與 Secrets 不進 code</a></td>
          <td>tag 讓資源可盤點可歸屬；密鑰存在專用服務裡而非 code 或 state，兩者都屬於 day-1 治理地基</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/08-governance-habits/cost-visibility-rhythm/" data-link-title="成本可見性與最小可行治理節奏" data-link-desc="用 tag 驅動的成本分攤讓帳單有人負責，以及判斷什麼治理該 day-1 就立、什麼等規模逼出來再加">成本可見性與最小可行治理節奏</a></td>
          <td>用 tag 驅動的成本分攤讓帳單有人負責，以及判斷什麼治理該 day-1 就立、什麼等規模逼出來再加</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/08-governance-habits/handover-design/" data-link-title="職務交接與存取撤銷設計" data-link-desc="人員異動時的存取撤銷順序、credential rotation、最小交接清單，以及讓交接成本結構性降低的 infra 設計原則">職務交接與存取撤銷設計</a></td>
          <td>人員異動時的存取撤銷清單、credential rotation、IaC 降低交接成本、最小交接清單與結構性設計</td>
      </tr>
  </tbody>
</table>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 模組七：資安與資料保護</a>：secret 管理的更完整討論</li>
<li>→ <a href="/blog/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">devops 模組八：成本管理</a>：運行期的成本控制</li>
</ul>
]]></content:encoded></item><item><title>斷網環境的基礎服務：DNS、NTP、CA 與 Secret Management</title><link>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-infrastructure-services/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-infrastructure-services/</guid><description>&lt;p>斷網環境裡的 GitLab、&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/harbor/" data-link-title="Harbor" data-link-desc="開源的 container image registry，支援映像掃描、RBAC、複製，斷網環境取代 Docker Hub 的方案">Harbor&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/prometheus/" data-link-title="Prometheus" data-link-desc="開源的 metrics 收集與告警系統，用 pull 模式從 target 拉取指標，斷網環境的預設監控方案">Prometheus&lt;/a>、Nexus 都有一個共同前提：它們需要名稱解析（&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/dns/" data-link-title="DNS" data-link-desc="Domain Name System — 把域名轉成 IP 位址的系統，以及 A record、CNAME、NS、TTL 的角色">DNS&lt;/a>）才能互相找到、需要時間同步（NTP）才能讓 log 和憑證有效、需要 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/ssl-tls/" data-link-title="SSL / TLS" data-link-desc="加密 client 與 server 之間通訊的協定，讓 HTTPS 成為可能。TLS 是 SSL 的後繼者，但 SSL 憑證的稱呼仍廣泛使用">TLS&lt;/a> 憑證（CA）才能走 HTTPS、需要機密儲存（&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/vault/" data-link-title="HashiCorp Vault" data-link-desc="機密管理系統，集中存放密碼、API key、TLS 私鑰，提供存取控制、稽核和自動輪替">Vault&lt;/a>）才能安全管理密碼和 token。這四個是「服務的服務」——沒有它們，其他自建服務要麼無法啟動、要麼只能用不安全的 HTTP 明文通訊。&lt;/p>
&lt;h2 id="internal-dns內部名稱解析">Internal DNS：內部名稱解析&lt;/h2>
&lt;p>斷網環境沒有公開 DNS 可用。內部服務之間的互相引用（GitLab 連 PostgreSQL、Harbor 連 storage backend）如果靠 IP 位址，每次 IP 變動都要改一輪設定。內部 DNS 讓服務用 hostname（&lt;code>gitlab.internal&lt;/code>、&lt;code>harbor.internal&lt;/code>）互相引用，IP 變動只改 DNS zone 一處。&lt;/p>
&lt;h3 id="coredns-vs-bind">CoreDNS vs BIND&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>面向&lt;/th>
 &lt;th>CoreDNS&lt;/th>
 &lt;th>BIND&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>設定方式&lt;/td>
 &lt;td>Corefile（宣告式、短）&lt;/td>
 &lt;td>named.conf（傳統、長）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>部署方式&lt;/td>
 &lt;td>單一 binary / container&lt;/td>
 &lt;td>系統套件&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>適合情境&lt;/td>
 &lt;td>Kubernetes 原生整合、輕量&lt;/td>
 &lt;td>複雜 DNS 需求（split-horizon、DNSSEC）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>學習曲線&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>中高&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>多數斷網環境用 CoreDNS 就夠——zone 檔案放在磁碟上、Corefile 幾行就能啟動。&lt;/p>
&lt;h3 id="最小設定">最小設定&lt;/h3>





&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"># Corefile
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">internal:53 {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> file /etc/coredns/zones/internal.zone
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> log
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> errors
&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">.:53 {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> forward . /dev/null
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> log
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>第一個 block 處理 &lt;code>internal&lt;/code> 域名的查詢、從 zone 檔案回應。第二個 block 攔截所有其他查詢——斷網環境不能轉發到上游 DNS，&lt;code>forward . /dev/null&lt;/code> 讓非內部域名直接返回 NXDOMAIN 而非 timeout。&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">; /etc/coredns/zones/internal.zone
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">$ORIGIN internal.
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">@ IN SOA ns1.internal. admin.internal. (
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> 2026062601 ; serial
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> 3600 ; refresh
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> 600 ; retry
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> 86400 ; expire
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> 60 ; minimum
&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"> IN NS ns1.internal.
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">ns1 IN A 10.0.1.10
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">gitlab IN A 10.0.1.20
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">harbor IN A 10.0.1.21
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">vault IN A 10.0.1.22
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">nexus IN A 10.0.1.23
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">prom IN A 10.0.1.24
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">grafana IN A 10.0.1.25
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">ntp IN A 10.0.1.11&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>新增服務時加一行 A record、重載 CoreDNS（&lt;code>kill -SIGUSR1 $(pidof coredns)&lt;/code> 或重啟 container）。serial 號遞增讓變更可追蹤。&lt;/p></description><content:encoded><![CDATA[<p>斷網環境裡的 GitLab、<a href="/blog/infra/knowledge-cards/harbor/" data-link-title="Harbor" data-link-desc="開源的 container image registry，支援映像掃描、RBAC、複製，斷網環境取代 Docker Hub 的方案">Harbor</a>、<a href="/blog/infra/knowledge-cards/prometheus/" data-link-title="Prometheus" data-link-desc="開源的 metrics 收集與告警系統，用 pull 模式從 target 拉取指標，斷網環境的預設監控方案">Prometheus</a>、Nexus 都有一個共同前提：它們需要名稱解析（<a href="/blog/infra/knowledge-cards/dns/" data-link-title="DNS" data-link-desc="Domain Name System — 把域名轉成 IP 位址的系統，以及 A record、CNAME、NS、TTL 的角色">DNS</a>）才能互相找到、需要時間同步（NTP）才能讓 log 和憑證有效、需要 <a href="/blog/infra/knowledge-cards/ssl-tls/" data-link-title="SSL / TLS" data-link-desc="加密 client 與 server 之間通訊的協定，讓 HTTPS 成為可能。TLS 是 SSL 的後繼者，但 SSL 憑證的稱呼仍廣泛使用">TLS</a> 憑證（CA）才能走 HTTPS、需要機密儲存（<a href="/blog/infra/knowledge-cards/vault/" data-link-title="HashiCorp Vault" data-link-desc="機密管理系統，集中存放密碼、API key、TLS 私鑰，提供存取控制、稽核和自動輪替">Vault</a>）才能安全管理密碼和 token。這四個是「服務的服務」——沒有它們，其他自建服務要麼無法啟動、要麼只能用不安全的 HTTP 明文通訊。</p>
<h2 id="internal-dns內部名稱解析">Internal DNS：內部名稱解析</h2>
<p>斷網環境沒有公開 DNS 可用。內部服務之間的互相引用（GitLab 連 PostgreSQL、Harbor 連 storage backend）如果靠 IP 位址，每次 IP 變動都要改一輪設定。內部 DNS 讓服務用 hostname（<code>gitlab.internal</code>、<code>harbor.internal</code>）互相引用，IP 變動只改 DNS zone 一處。</p>
<h3 id="coredns-vs-bind">CoreDNS vs BIND</h3>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>CoreDNS</th>
          <th>BIND</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>設定方式</td>
          <td>Corefile（宣告式、短）</td>
          <td>named.conf（傳統、長）</td>
      </tr>
      <tr>
          <td>部署方式</td>
          <td>單一 binary / container</td>
          <td>系統套件</td>
      </tr>
      <tr>
          <td>適合情境</td>
          <td>Kubernetes 原生整合、輕量</td>
          <td>複雜 DNS 需求（split-horizon、DNSSEC）</td>
      </tr>
      <tr>
          <td>學習曲線</td>
          <td>低</td>
          <td>中高</td>
      </tr>
  </tbody>
</table>
<p>多數斷網環境用 CoreDNS 就夠——zone 檔案放在磁碟上、Corefile 幾行就能啟動。</p>
<h3 id="最小設定">最小設定</h3>





<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"># Corefile
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">internal:53 {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    file /etc/coredns/zones/internal.zone
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    log
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    errors
</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">.:53 {
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    forward . /dev/null
</span></span><span class="line"><span class="ln">10</span><span class="cl">    log
</span></span><span class="line"><span class="ln">11</span><span class="cl">}</span></span></code></pre></div><p>第一個 block 處理 <code>internal</code> 域名的查詢、從 zone 檔案回應。第二個 block 攔截所有其他查詢——斷網環境不能轉發到上游 DNS，<code>forward . /dev/null</code> 讓非內部域名直接返回 NXDOMAIN 而非 timeout。</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">; /etc/coredns/zones/internal.zone
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">$ORIGIN internal.
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">@       IN SOA  ns1.internal. admin.internal. (
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        2026062601 ; serial
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        3600       ; refresh
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        600        ; retry
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        86400      ; expire
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        60         ; minimum
</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">        IN NS   ns1.internal.
</span></span><span class="line"><span class="ln">11</span><span class="cl">ns1     IN A    10.0.1.10
</span></span><span class="line"><span class="ln">12</span><span class="cl">gitlab  IN A    10.0.1.20
</span></span><span class="line"><span class="ln">13</span><span class="cl">harbor  IN A    10.0.1.21
</span></span><span class="line"><span class="ln">14</span><span class="cl">vault   IN A    10.0.1.22
</span></span><span class="line"><span class="ln">15</span><span class="cl">nexus   IN A    10.0.1.23
</span></span><span class="line"><span class="ln">16</span><span class="cl">prom    IN A    10.0.1.24
</span></span><span class="line"><span class="ln">17</span><span class="cl">grafana IN A    10.0.1.25
</span></span><span class="line"><span class="ln">18</span><span class="cl">ntp     IN A    10.0.1.11</span></span></code></pre></div><p>新增服務時加一行 A record、重載 CoreDNS（<code>kill -SIGUSR1 $(pidof coredns)</code> 或重啟 container）。serial 號遞增讓變更可追蹤。</p>
<h3 id="客戶端設定">客戶端設定</h3>
<p>每台機器的 <code>/etc/resolv.conf</code> 指向 CoreDNS 的 IP：</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">nameserver 10.0.1.10
</span></span><span class="line"><span class="ln">2</span><span class="cl">search internal</span></span></code></pre></div><p>如果環境有 DHCP server，在 DHCP option 裡配 DNS server 位址，新加入的機器自動取得。沒有 DHCP 就靠 provisioning 腳本或 Ansible playbook 推送。</p>
<h2 id="ntp內部時間同步">NTP：內部時間同步</h2>
<p>時間不同步在斷網環境會引發三類問題：log 的時間戳錯亂讓事故排查無法跨機器對齊、TLS 憑證的有效期判斷出錯導致合法憑證被拒絕、以及 Kerberos 等時間敏感的認證協定直接失敗。正常環境從 <code>pool.ntp.org</code> 取得時間，斷網環境需要自己的時間源。</p>
<h3 id="chrony-作為-ntp-server">chrony 作為 NTP server</h3>
<p>chrony 比傳統的 ntpd 更適合網路不穩或隔離的環境——它的時鐘修正演算法在長時間無外部時間源時仍能保持較準確的漂移補償。</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"># /etc/chrony.conf（NTP server 端）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 斷網環境：沒有上游 NTP、用本機時鐘作為最後手段</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nb">local</span> stratum <span class="m">10</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">allow 10.0.0.0/8
</span></span><span class="line"><span class="ln">5</span><span class="cl">driftfile /var/lib/chrony/drift</span></span></code></pre></div><p><code>local stratum 10</code> 宣告「我自己是時間源、但 stratum 很低（精度不高）」。其他機器的 chrony 設定指向這台 server：</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"># /etc/chrony.conf（客戶端）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">server ntp.internal iburst
</span></span><span class="line"><span class="ln">3</span><span class="cl">makestep 1.0 <span class="m">3</span></span></span></code></pre></div><p><code>iburst</code> 讓開機時快速同步、<code>makestep 1.0 3</code> 允許前三次校正時跳大步（修正啟動時的大偏差）。</p>
<h3 id="高精度需求">高精度需求</h3>
<p>如果環境對時間精度有要求（金融交易、工控系統），NTP server 需要硬體時間源——GPS 接收器或原子鐘模組。GPS 天線不需要網路連線、只需要看得到衛星的位置（屋頂或窗邊）。chrony 支援 PPS（Pulse Per Second）輸入、可以達到微秒級精度。</p>
<p>多數斷網環境不需要這個精度——毫秒級一致（chrony 預設行為）對 log 對齊和 TLS 驗證已經足夠。</p>
<h2 id="internal-ca內部憑證簽發">Internal CA：內部憑證簽發</h2>
<p>斷網環境的每個內部 HTTPS 服務都需要 TLS 憑證。Let&rsquo;s Encrypt 的 ACME challenge 需要連網驗證，在斷網環境無法使用。替代方案是建立內部 CA（Certificate Authority），自己簽發憑證。</p>
<h3 id="step-casmallstep">step-ca（Smallstep）</h3>
<p>step-ca 是一個輕量的 CA server，支援 ACME 協定——內部服務可以用跟 Let&rsquo;s Encrypt 相同的流程自動申請和續期憑證，只是 ACME server 是內網的 step-ca 而非 Let&rsquo;s Encrypt。</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"># 初始化 CA</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">step ca init --name<span class="o">=</span><span class="s2">&#34;Internal CA&#34;</span> --dns<span class="o">=</span><span class="s2">&#34;ca.internal&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --address<span class="o">=</span><span class="s2">&#34;:443&#34;</span> --provisioner<span class="o">=</span><span class="s2">&#34;admin&#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="c1"># 啟動 CA server</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">step-ca <span class="k">$(</span>step path<span class="k">)</span>/config/ca.json</span></span></code></pre></div><p>初始化會產生 root CA 和 intermediate CA 的 key pair。root CA 的私鑰是整個信任鏈的根——它的保護等級要最高（離線儲存、存取紀錄）。</p>
<h3 id="憑證簽發流程">憑證簽發流程</h3>
<p>服務用 ACME client 向 step-ca 申請憑證：</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"># 用 step CLI 申請憑證（手動方式）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">step ca certificate <span class="s2">&#34;gitlab.internal&#34;</span> gitlab.crt gitlab.key
</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"># 用 ACME 自動續期（搭配 certbot 或 step 的 renewal daemon）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">step ca renew --daemon gitlab.crt gitlab.key</span></span></code></pre></div><p>certbot 也能配合 step-ca 使用——把 ACME server URL 從 Let&rsquo;s Encrypt 改成 <code>https://ca.internal/acme/acme/directory</code>。已有 certbot 自動續期腳本的服務只要改一行設定。</p>
<h3 id="root-ca-分發">Root CA 分發</h3>
<p>每台機器和每個服務都要信任內部 CA 的 root certificate：</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"># Debian/Ubuntu</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">cp root_ca.crt /usr/local/share/ca-certificates/internal-ca.crt
</span></span><span class="line"><span class="ln">3</span><span class="cl">update-ca-certificates
</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"># RHEL/CentOS</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">cp root_ca.crt /etc/pki/ca-trust/source/anchors/internal-ca.crt
</span></span><span class="line"><span class="ln">7</span><span class="cl">update-ca-trust</span></span></code></pre></div><p>Docker daemon 也需要信任內部 CA（否則 <code>docker pull harbor.internal/image</code> 會報 TLS 錯誤）：</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 -p /etc/docker/certs.d/harbor.internal
</span></span><span class="line"><span class="ln">2</span><span class="cl">cp root_ca.crt /etc/docker/certs.d/harbor.internal/ca.crt
</span></span><span class="line"><span class="ln">3</span><span class="cl">systemctl restart docker</span></span></code></pre></div><p>Ansible playbook 批量推送 root CA 到所有機器，是初始部署的標準做法。</p>
<h3 id="cfssl-作為替代">cfssl 作為替代</h3>
<p>cfssl（Cloudflare 的 PKI 工具組）比 step-ca 更簡單但沒有 ACME 自動化——每張憑證要手動簽發。適合只有 5-10 個服務、不需要自動續期的小規模環境。</p>
<h2 id="secret-managementhashicorp-vault">Secret Management：HashiCorp Vault</h2>
<p>資料庫密碼、API token、TLS 私鑰這些機密值需要一個集中的安全儲存。斷網環境不能用 AWS Secrets Manager 或 GCP Secret Manager，HashiCorp Vault 是最常見的自建選項。</p>
<h3 id="斷網環境的-vault-初始化">斷網環境的 Vault 初始化</h3>
<p>Vault 的初始化（unsealing）在雲端環境通常用 AWS KMS 或 GCP Cloud KMS 自動 unseal。斷網環境沒有雲端 KMS，退回 Shamir&rsquo;s Secret Sharing——初始化時產生 N 個 unseal key、啟動時需要 M 個 key 才能解鎖（典型設定：5 個 key、3 個即可 unseal）。</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"># 初始化 Vault（5 key shares、3 threshold）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">vault operator init -key-shares<span class="o">=</span><span class="m">5</span> -key-threshold<span class="o">=</span><span class="m">3</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Unseal（需要 3 次、每次用不同的 key）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">vault operator unseal &lt;key-1&gt;
</span></span><span class="line"><span class="ln">6</span><span class="cl">vault operator unseal &lt;key-2&gt;
</span></span><span class="line"><span class="ln">7</span><span class="cl">vault operator unseal &lt;key-3&gt;</span></span></code></pre></div><p>5 個 unseal key 分別交給不同的人保管。任何單一個人都無法獨自解鎖 Vault——這是刻意的安全設計。Vault 重啟後需要重新 unseal，所以 unseal key 的保管和取用流程要事先演練。</p>
<h3 id="機器身分認證">機器身分認證</h3>
<p>服務從 Vault 讀取 secret 時需要認證自己的身分。雲端環境用 IAM role，斷網環境用 AppRole——每個服務拿到一組 role_id + secret_id、用它們換取短期 token。</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"># 建立 AppRole</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">vault auth <span class="nb">enable</span> approle
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">vault write auth/approle/role/gitlab <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  <span class="nv">token_ttl</span><span class="o">=</span>1h <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  <span class="nv">token_max_ttl</span><span class="o">=</span>4h <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  <span class="nv">policies</span><span class="o">=</span>gitlab-secrets
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 服務端取得 token</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">vault write auth/approle/login <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  <span class="nv">role_id</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$ROLE_ID</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  <span class="nv">secret_id</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$SECRET_ID</span><span class="s2">&#34;</span></span></span></code></pre></div><p>secret_id 本身也是 secret——初次部署時由 Vault admin 手動提供給服務、或透過 Ansible 的 encrypted variable 推送。</p>
<h3 id="儲存後端">儲存後端</h3>
<p>Vault 需要一個持久化的儲存後端。雲端用 DynamoDB 或 Consul，斷網環境用：</p>
<table>
  <thead>
      <tr>
          <th>後端</th>
          <th>適用情境</th>
          <th>特性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>檔案系統</td>
          <td>單節點、小規模</td>
          <td>最簡單、但沒有 HA</td>
      </tr>
      <tr>
          <td>PostgreSQL</td>
          <td>已有 PostgreSQL 的環境</td>
          <td>利用現有基礎設施</td>
      </tr>
      <tr>
          <td>Consul</td>
          <td>需要 HA 的環境</td>
          <td>Vault + Consul 是官方推薦的 HA 組合</td>
      </tr>
  </tbody>
</table>
<h2 id="部署順序的相互依賴">部署順序的相互依賴</h2>
<p>四個服務之間有依賴鏈：</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">DNS → NTP → CA → Vault
</span></span><span class="line"><span class="ln">2</span><span class="cl"> ↑_________________↓（Vault 的 FQDN 要 DNS 解析）</span></span></code></pre></div><p>DNS 先啟動（其他服務靠它解析 hostname）→ NTP 跟著（CA 簽發憑證時需要準確的時間、否則 notBefore/notAfter 判斷會出問題）→ CA 啟動（Vault 的 HTTPS 需要 TLS 憑證）→ Vault 最後（依賴 DNS 和 TLS）。</p>
<p>DNS 跟 CA 之間有一個循環依賴：CA 簽發憑證時需要 DNS 解析（ACME challenge 或 CSR 裡的 SAN），但 DNS server 本身要不要 TLS？解法是 DNS 第一次啟動時用明文（不走 HTTPS），CA 啟動後回頭替 DNS 簽一張憑證、再切到 DNS-over-TLS。多數內網環境 DNS 維持明文即可——DNS 查詢在內網不加密是常見做法，風險可控。</p>
<h2 id="時程與維護">時程與維護</h2>
<table>
  <thead>
      <tr>
          <th>服務</th>
          <th>初始部署</th>
          <th>持續維護</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CoreDNS</td>
          <td>2-4 小時</td>
          <td>新增服務時加 zone record（分鐘級）</td>
      </tr>
      <tr>
          <td>chrony</td>
          <td>1-2 小時</td>
          <td>幾乎不需要（漂移補償自動運作）</td>
      </tr>
      <tr>
          <td>step-ca</td>
          <td>3-4 小時</td>
          <td>憑證到期前的監控和續期（自動化後接近零）</td>
      </tr>
      <tr>
          <td>Vault</td>
          <td>4-8 小時</td>
          <td>unseal key 管理、policy 更新、備份</td>
      </tr>
  </tbody>
</table>
<p>四個服務合計約 1.5-2 個工作天完成初始部署。部署完成後的日常維護負擔集中在 Vault（unseal key 管理和 policy 維護）和 DNS zone 更新。CA 的憑證續期如果用 ACME 自動化就接近零維護。</p>
<p>向管理層溝通時的框架：「這四個服務是所有其他服務的地基——沒有它們，其他服務要麼找不到彼此（DNS）、時間對不上（NTP）、通訊不加密（CA）、密碼寫在設定檔裡（Vault）。部署一次、之後幾乎自動運作。」</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">斷網環境的通用原則</a>：content ferry 和離線套件管理的通用操作模式</li>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-iac/" data-link-title="斷網環境的 IaC" data-link-desc="Terraform provider mirror、離線 plugin cache、本地 state backend、沒有雲端時的 plan/apply 流程與內網 CI">斷網環境的 IaC</a>：Vault 作為 Terraform 的 secret backend</li>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-container/" data-link-title="斷網環境的容器與映像管理" data-link-desc="Private registry 架設、映像搬運（docker save/load、skopeo）、base image 更新週期、離線漏洞掃描">斷網環境的容器與映像管理</a>：Harbor 依賴 DNS 和 TLS、映像拉取需要信任內部 CA</li>
<li>→ <a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>：Vault 的角色跟雲端的 Secrets Manager 對應</li>
<li>→ <a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>：Secret 不進 code 的原則在斷網環境用 Vault 落地</li>
</ul>
]]></content:encoded></item><item><title>模組九：怎麼把 infra 推動起來</title><link>https://tarrragon.github.io/blog/infra/09-driving-adoption/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/09-driving-adoption/</guid><description>&lt;p>一套技術上正確的 infra 推不動，後果會往回退、不只是停在原地。state 上了版控但團隊照樣手改 Console、PR 護欄建好了卻被 &lt;code>--no-verify&lt;/code> 繞過、tagging 規範寫進文件但沒人填，這些都會讓 infra 從「資產」變成「擺設」。更糟的情況是推到一半就停：一部分環境上了 IaC、一部分還是手動，兩套真相並存，排查問題時不知道該信哪邊，infra 反而成了扣分項。前面八個模組講技術怎麼做對，這一章講技術做對之後、怎麼跨過商業優先級與組織信任這兩道更難的關卡。這是全系列的組織層收尾。&lt;/p>
&lt;h2 id="為什麼-infra-常推不動">為什麼 infra 常推不動&lt;/h2>
&lt;p>infra 是一種看不到立即回報的成本，這是它在商業優先級裡天然吃虧的根本原因。產品功能上線當天就能看到使用者數字、營收曲線、客訴下降；infra 投入當天看到的只有「花了時間，但畫面上什麼都沒變」。把 state 搬上遠端後端、把 IAM 從長期 access key 換成 OIDC、把環境拆成獨立帳號，這些工作的價值要等到某次事故、某次稽核、某次擴張才會兌現。在價值兌現之前，它在排程會議上跟一個能立刻帶來轉換率的功能競爭，幾乎必輸。&lt;/p>
&lt;p>徵兆很直接：當 infra 工作總是被排進「有空再做」的待辦、季度結束時總是第一個被砍，根源在於它的回報曲線跟決策者的時間視窗對不上，而不是團隊不重視。決策者看的是這一季的可交付，infra 的回報落在下一次危機，兩者中間隔著一段沒有反饋的真空期。&lt;/p>
&lt;p>理解這個落差，就不會把推不動歸因成「同事不懂技術」。把它當成溝通態度問題去硬碰，結果是工程端越說越委屈、業務端越聽越像本位主義。也別矯枉過正——infra 確實有一部分屬於可以延後的優化，不是每一項都該現在做。真正該做的是把「哪些 infra 屬於不能延後的地基」跟「哪些屬於可排程的優化」分開談，這條線在「模組零：infra 是什麼」的成熟度階梯與 day1 鐵律裡有完整討論。&lt;/p>
&lt;h2 id="信任赤字下的兩難">信任赤字下的兩難&lt;/h2>
&lt;p>信任赤字指的是團隊對「動 infra 會不會把東西弄壞」的預設懷疑，它決定了一次改動能拿到多大的授權。當一個服務跑得好好的，任何對它底層的改動在旁人眼裡都是「沒事找事」，一旦改出問題，責任全記在發起改動的人頭上。這種不對稱讓人傾向不動，於是技術債持續累積，而累積本身又讓下一次改動更危險，形成越不敢動就越不能動的循環。&lt;/p>
&lt;p>兩難的具體形狀是這樣：大改動風險高、需要的信任額度也高，但信任正是現在缺的；小改動安全，卻又解不了結構性的問題。更尷尬的中間態是改到一半——把一半服務遷上 IaC、另一半留在手動，這時系統同時揹著舊流程的隨意性跟新流程的約束，兩邊的缺點都拿到、好處都沒拿滿。排查問題的人要先猜這個資源歸哪套管，認知成本比改造前還高。&lt;/p>
&lt;p>可操作的判準是用改動的「可回退性」換取授權，而不是用「保證不出錯」去爭取。把一次大遷移切成多個獨立可回退的 PR，每個 PR 都能單獨 review、單獨 apply、單獨 revert，這樣每一步的風險都是有界的，團隊願意給的信任額度也跟著提高。切片不能切到讓中間態長期懸著——每個切片都要讓系統落在一個自洽的狀態、而不是半套真相並存。每完成一個可回退的小步，下一步能拿到的授權就多一點，原本越不敢動就越不能動的循環才會倒過來轉。把改動綁進 PR 流程取得 review 與自動護欄的做法，見「模組七：infra 走 PR 流程」。&lt;/p>
&lt;h2 id="期望值對齊">期望值對齊&lt;/h2>
&lt;p>期望值對齊指的是在動工之前，先跟相關角色講好 infra 工作的價值、時程、以及它「慢」的原因，讓慢成為事前的共識而不是事後的指責。infra 的改造之所以慢，是因為它要動的是正在承載流量的地基——每一步都得確認沒有破壞既有服務、得保留回退路徑、得跨環境驗證。這種慢是風險控制的成本，不是效率問題。但如果沒有事先說明，旁人看到的只有「一個簡單的事情做了兩週」。&lt;/p>
&lt;p>對齊要對齊三件事。第一是價值要翻成對方語言：對 PM 講的是「這個改動讓未來新環境從三天縮到三十分鐘」，不是「我們把 state 上了遠端後端」。第二是時程要給範圍而非單點，並標出哪些步驟是不可壓縮的驗證、哪些是可以平行的。第三是把「慢」的來源攤開——告訴對方哪幾步是在跨環境驗證、哪幾步是在等 plan review，讓等待變成可理解的過程。&lt;/p>
&lt;p>一個具體的自測：如果每次進度同步都要重新解釋「為什麼還沒好」，代表期望值沒對齊在前面。最常見的失手是把對齊做成單向報告，真正的對齊需要對方有機會在動工前提出他的時間壓力，雙方各退一步排出優先序。對齊也不等於承諾零風險，反而要在這個階段就把可能的失敗模式講清楚——這跟「模組七：infra 走 PR 流程」裡用 plan 預覽變更、讓改動在 apply 前就被看見是同一個邏輯，只是把對象從程式碼擴大到人。&lt;/p>
&lt;h2 id="知識共享優於個人英雄主義">知識共享優於個人英雄主義&lt;/h2>
&lt;p>infra 知識要分散在團隊裡、並盡量沉澱進可執行的程式碼，這樣組織才不會把營運連續性押在單一個人身上。當只有一個人懂整套 infra 怎麼運作，這個人請假、轉組、離職的那一刻，組織就失去了安全改動地基的能力——剩下的人不敢動，因為沒人知道動了會牽連到什麼。這是一種典型的單點故障，只是故障點是人不是機器。&lt;/p>
&lt;p>個人英雄主義在短期看起來很有效率：一個熟手能繞過所有流程、直接在 Console 把問題解掉。問題是這種效率不會留下痕跡，下一個人遇到同樣狀況時得從零重來，而那個熟手變成了所有人的瓶頸——每個改動都要等他有空、每個決策都要問過他。組織越依賴他，他越難抽身去做別的事，這對個人跟組織都是負擔。&lt;/p>
&lt;p>把知識搬出個人腦袋有兩條路徑，互補使用。一條是把運作邏輯寫進程式碼與流程：當環境的建立方式是一份 IaC、變更方式是一個 PR，知識就內建在可執行的物件裡，新人讀 code 跟 PR 歷史就能重建脈絡，這正是「模組七：infra 走 PR 流程」的核心價值之一。另一條是刻意的輪替與配對：讓不同人輪流負責 infra 的 review 與 apply，用實際操作累積分散的熟悉度。檢驗有沒有做到，問自己一句就夠：如果最懂 infra 的人下週離職，團隊還敢動 production 的網路設定嗎——答案是否定的，就代表知識過度集中，那個熟手仍然是繞不開的瓶頸。共享不必走到人人都是專家，只要關鍵操作有第二個人能接手、關鍵決策的脈絡留得下來，瓶頸就不再卡在單一個人身上。&lt;/p>
&lt;h2 id="把-infra-重要性翻成商業語言">把 infra 重要性翻成商業語言&lt;/h2>
&lt;p>infra 的重要性要翻譯成商業後果才能進入決策者的優先級，因為決策者用的是成本與風險的語言，不是技術術語的語言。「我們缺乏環境分離」對 PM 沒有重量，但「測試環境的一次誤操作可以直接打到正式資料庫、波及全部客戶」有重量，因為後者描述的是一個可以標價的損失。翻譯的本質是把抽象的技術缺口換算成一個具體的、會痛的場景。&lt;/p>
&lt;p>最有說服力的素材是「環境爆炸時的代價」——把地基失效的那一刻會發生什麼攤開來算。沒有 state 版控時，一次併發修改可能讓整個環境的記錄錯亂，重建要幾天、期間服務不可用；沒有身分隔離時，一把外洩的長期憑證可以橫向存取所有資源；沒有環境分離時，一次本該打在 staging 的變更直接改了 production。這些場景的共同點是平時完全看不見、爆炸時一次性兌現巨大成本，這也正是「模組零：infra 是什麼」裡地基隱形、出事才現形的論證。把這條論證從技術語境搬到商業語境，就是這一章要做的翻譯。&lt;/p>
&lt;p>可操作的做法是替每一項想推動的 infra 工作，準備一句「不做的話，最壞情況是什麼、影響多少客戶、要救多久」。這句話本身就是一道篩子：講不出對應商業後果的工作，可能真的優先級不高、可以排到後面；講得出而且後果嚴重的，這句話就是排程的籌碼。要小心的陷阱是把每件事都講成世界末日，幾次之後狼來了效應會讓所有警告失效——所以翻譯要誠實分級，把真正的地基跟可延後的優化分開。商業語言是用來爭取優先級、不是用來嚇人；爭取到之後，怎麼安全地做仍然回到前面八個模組的技術判準。把成本量化的延伸方法，可參考 &lt;a href="https://tarrragon.github.io/blog/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">/devops/08-cost-management/&lt;/a> 對基礎設施成本的拆解視角。&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/09-driving-adoption/infra-explained-for-non-engineers/" data-link-title="給非工程背景決策者的 infra 說明" data-link-desc="從管理視角解釋基礎設施在解決什麼營運問題、不做的代價、出事怎麼處理，讓參與資源決策的人能判斷投入的優先級">給非工程人員的 infra 說明&lt;/a>&lt;/td>
 &lt;td>用辦公室比喻解釋 VPC / IAM / IaC，讓非技術背景的人 10 分鐘內理解工程團隊在做什麼&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/09-driving-adoption/infra-business-justification/" data-link-title="infra 投資的商業論證" data-link-desc="用成本、風險、速度三條論述線把 infra 投資翻譯成商業語言，附一頁簡報邏輯與常見反對意見的回應">infra 投資的商業論證&lt;/a>&lt;/td>
 &lt;td>用成本、風險、速度三條論述線翻譯成商業語言，附簡報邏輯與常見反對意見的回應&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/09-driving-adoption/trust-alignment-knowledge-sharing/" data-link-title="怎麼把 infra 推動起來 — 信任赤字、期望值對齊與知識共享" data-link-desc="技術正確不等於推得動 — infra 在商業優先級裡吃虧的結構性原因，以及用可回退切片、期望值對齊與知識分散來跨過組織關卡">怎麼把 infra 推動起來 — 信任赤字、期望值對齊與知識共享&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/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/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>：用流程把 infra 知識從個人腦裡搬進 code&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">/devops/08-cost-management/&lt;/a>：把 infra 缺口換算成可標價成本的拆解視角&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>一套技術上正確的 infra 推不動，後果會往回退、不只是停在原地。state 上了版控但團隊照樣手改 Console、PR 護欄建好了卻被 <code>--no-verify</code> 繞過、tagging 規範寫進文件但沒人填，這些都會讓 infra 從「資產」變成「擺設」。更糟的情況是推到一半就停：一部分環境上了 IaC、一部分還是手動，兩套真相並存，排查問題時不知道該信哪邊，infra 反而成了扣分項。前面八個模組講技術怎麼做對，這一章講技術做對之後、怎麼跨過商業優先級與組織信任這兩道更難的關卡。這是全系列的組織層收尾。</p>
<h2 id="為什麼-infra-常推不動">為什麼 infra 常推不動</h2>
<p>infra 是一種看不到立即回報的成本，這是它在商業優先級裡天然吃虧的根本原因。產品功能上線當天就能看到使用者數字、營收曲線、客訴下降；infra 投入當天看到的只有「花了時間，但畫面上什麼都沒變」。把 state 搬上遠端後端、把 IAM 從長期 access key 換成 OIDC、把環境拆成獨立帳號，這些工作的價值要等到某次事故、某次稽核、某次擴張才會兌現。在價值兌現之前，它在排程會議上跟一個能立刻帶來轉換率的功能競爭，幾乎必輸。</p>
<p>徵兆很直接：當 infra 工作總是被排進「有空再做」的待辦、季度結束時總是第一個被砍，根源在於它的回報曲線跟決策者的時間視窗對不上，而不是團隊不重視。決策者看的是這一季的可交付，infra 的回報落在下一次危機，兩者中間隔著一段沒有反饋的真空期。</p>
<p>理解這個落差，就不會把推不動歸因成「同事不懂技術」。把它當成溝通態度問題去硬碰，結果是工程端越說越委屈、業務端越聽越像本位主義。也別矯枉過正——infra 確實有一部分屬於可以延後的優化，不是每一項都該現在做。真正該做的是把「哪些 infra 屬於不能延後的地基」跟「哪些屬於可排程的優化」分開談，這條線在「模組零：infra 是什麼」的成熟度階梯與 day1 鐵律裡有完整討論。</p>
<h2 id="信任赤字下的兩難">信任赤字下的兩難</h2>
<p>信任赤字指的是團隊對「動 infra 會不會把東西弄壞」的預設懷疑，它決定了一次改動能拿到多大的授權。當一個服務跑得好好的，任何對它底層的改動在旁人眼裡都是「沒事找事」，一旦改出問題，責任全記在發起改動的人頭上。這種不對稱讓人傾向不動，於是技術債持續累積，而累積本身又讓下一次改動更危險，形成越不敢動就越不能動的循環。</p>
<p>兩難的具體形狀是這樣：大改動風險高、需要的信任額度也高，但信任正是現在缺的；小改動安全，卻又解不了結構性的問題。更尷尬的中間態是改到一半——把一半服務遷上 IaC、另一半留在手動，這時系統同時揹著舊流程的隨意性跟新流程的約束，兩邊的缺點都拿到、好處都沒拿滿。排查問題的人要先猜這個資源歸哪套管，認知成本比改造前還高。</p>
<p>可操作的判準是用改動的「可回退性」換取授權，而不是用「保證不出錯」去爭取。把一次大遷移切成多個獨立可回退的 PR，每個 PR 都能單獨 review、單獨 apply、單獨 revert，這樣每一步的風險都是有界的，團隊願意給的信任額度也跟著提高。切片不能切到讓中間態長期懸著——每個切片都要讓系統落在一個自洽的狀態、而不是半套真相並存。每完成一個可回退的小步，下一步能拿到的授權就多一點，原本越不敢動就越不能動的循環才會倒過來轉。把改動綁進 PR 流程取得 review 與自動護欄的做法，見「模組七：infra 走 PR 流程」。</p>
<h2 id="期望值對齊">期望值對齊</h2>
<p>期望值對齊指的是在動工之前，先跟相關角色講好 infra 工作的價值、時程、以及它「慢」的原因，讓慢成為事前的共識而不是事後的指責。infra 的改造之所以慢，是因為它要動的是正在承載流量的地基——每一步都得確認沒有破壞既有服務、得保留回退路徑、得跨環境驗證。這種慢是風險控制的成本，不是效率問題。但如果沒有事先說明，旁人看到的只有「一個簡單的事情做了兩週」。</p>
<p>對齊要對齊三件事。第一是價值要翻成對方語言：對 PM 講的是「這個改動讓未來新環境從三天縮到三十分鐘」，不是「我們把 state 上了遠端後端」。第二是時程要給範圍而非單點，並標出哪些步驟是不可壓縮的驗證、哪些是可以平行的。第三是把「慢」的來源攤開——告訴對方哪幾步是在跨環境驗證、哪幾步是在等 plan review，讓等待變成可理解的過程。</p>
<p>一個具體的自測：如果每次進度同步都要重新解釋「為什麼還沒好」，代表期望值沒對齊在前面。最常見的失手是把對齊做成單向報告，真正的對齊需要對方有機會在動工前提出他的時間壓力，雙方各退一步排出優先序。對齊也不等於承諾零風險，反而要在這個階段就把可能的失敗模式講清楚——這跟「模組七：infra 走 PR 流程」裡用 plan 預覽變更、讓改動在 apply 前就被看見是同一個邏輯，只是把對象從程式碼擴大到人。</p>
<h2 id="知識共享優於個人英雄主義">知識共享優於個人英雄主義</h2>
<p>infra 知識要分散在團隊裡、並盡量沉澱進可執行的程式碼，這樣組織才不會把營運連續性押在單一個人身上。當只有一個人懂整套 infra 怎麼運作，這個人請假、轉組、離職的那一刻，組織就失去了安全改動地基的能力——剩下的人不敢動，因為沒人知道動了會牽連到什麼。這是一種典型的單點故障，只是故障點是人不是機器。</p>
<p>個人英雄主義在短期看起來很有效率：一個熟手能繞過所有流程、直接在 Console 把問題解掉。問題是這種效率不會留下痕跡，下一個人遇到同樣狀況時得從零重來，而那個熟手變成了所有人的瓶頸——每個改動都要等他有空、每個決策都要問過他。組織越依賴他，他越難抽身去做別的事，這對個人跟組織都是負擔。</p>
<p>把知識搬出個人腦袋有兩條路徑，互補使用。一條是把運作邏輯寫進程式碼與流程：當環境的建立方式是一份 IaC、變更方式是一個 PR，知識就內建在可執行的物件裡，新人讀 code 跟 PR 歷史就能重建脈絡，這正是「模組七：infra 走 PR 流程」的核心價值之一。另一條是刻意的輪替與配對：讓不同人輪流負責 infra 的 review 與 apply，用實際操作累積分散的熟悉度。檢驗有沒有做到，問自己一句就夠：如果最懂 infra 的人下週離職，團隊還敢動 production 的網路設定嗎——答案是否定的，就代表知識過度集中，那個熟手仍然是繞不開的瓶頸。共享不必走到人人都是專家，只要關鍵操作有第二個人能接手、關鍵決策的脈絡留得下來，瓶頸就不再卡在單一個人身上。</p>
<h2 id="把-infra-重要性翻成商業語言">把 infra 重要性翻成商業語言</h2>
<p>infra 的重要性要翻譯成商業後果才能進入決策者的優先級，因為決策者用的是成本與風險的語言，不是技術術語的語言。「我們缺乏環境分離」對 PM 沒有重量，但「測試環境的一次誤操作可以直接打到正式資料庫、波及全部客戶」有重量，因為後者描述的是一個可以標價的損失。翻譯的本質是把抽象的技術缺口換算成一個具體的、會痛的場景。</p>
<p>最有說服力的素材是「環境爆炸時的代價」——把地基失效的那一刻會發生什麼攤開來算。沒有 state 版控時，一次併發修改可能讓整個環境的記錄錯亂，重建要幾天、期間服務不可用；沒有身分隔離時，一把外洩的長期憑證可以橫向存取所有資源；沒有環境分離時，一次本該打在 staging 的變更直接改了 production。這些場景的共同點是平時完全看不見、爆炸時一次性兌現巨大成本，這也正是「模組零：infra 是什麼」裡地基隱形、出事才現形的論證。把這條論證從技術語境搬到商業語境，就是這一章要做的翻譯。</p>
<p>可操作的做法是替每一項想推動的 infra 工作，準備一句「不做的話，最壞情況是什麼、影響多少客戶、要救多久」。這句話本身就是一道篩子：講不出對應商業後果的工作，可能真的優先級不高、可以排到後面；講得出而且後果嚴重的，這句話就是排程的籌碼。要小心的陷阱是把每件事都講成世界末日，幾次之後狼來了效應會讓所有警告失效——所以翻譯要誠實分級，把真正的地基跟可延後的優化分開。商業語言是用來爭取優先級、不是用來嚇人；爭取到之後，怎麼安全地做仍然回到前面八個模組的技術判準。把成本量化的延伸方法，可參考 <a href="/blog/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">/devops/08-cost-management/</a> 對基礎設施成本的拆解視角。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/infra/09-driving-adoption/infra-explained-for-non-engineers/" data-link-title="給非工程背景決策者的 infra 說明" data-link-desc="從管理視角解釋基礎設施在解決什麼營運問題、不做的代價、出事怎麼處理，讓參與資源決策的人能判斷投入的優先級">給非工程人員的 infra 說明</a></td>
          <td>用辦公室比喻解釋 VPC / IAM / IaC，讓非技術背景的人 10 分鐘內理解工程團隊在做什麼</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/09-driving-adoption/infra-business-justification/" data-link-title="infra 投資的商業論證" data-link-desc="用成本、風險、速度三條論述線把 infra 投資翻譯成商業語言，附一頁簡報邏輯與常見反對意見的回應">infra 投資的商業論證</a></td>
          <td>用成本、風險、速度三條論述線翻譯成商業語言，附簡報邏輯與常見反對意見的回應</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/09-driving-adoption/trust-alignment-knowledge-sharing/" data-link-title="怎麼把 infra 推動起來 — 信任赤字、期望值對齊與知識共享" data-link-desc="技術正確不等於推得動 — infra 在商業優先級裡吃虧的結構性原因，以及用可回退切片、期望值對齊與知識分散來跨過組織關卡">怎麼把 infra 推動起來 — 信任赤字、期望值對齊與知識共享</a></td>
          <td>infra 在商業優先級裡吃虧的結構性原因，以及用可回退切片、期望值對齊與知識分散來跨過組織關卡</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/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>：用流程把 infra 知識從個人腦裡搬進 code</li>
<li>→ <a href="/blog/devops/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing、成本監控告警">/devops/08-cost-management/</a>：把 infra 缺口換算成可標價成本的拆解視角</li>
</ul>
]]></content:encoded></item><item><title>環境分離</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/environment-separation/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/environment-separation/</guid><description>&lt;p>環境分離的核心職責是讓 dev 的實驗、staging 的驗證、production 的真實流量彼此不可見也不可達 — 在 dev 跑壞一個資料庫、套錯一條 security group 規則時，production 完全無感。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>環境分離在 infra 成熟度階梯上對應第三階。它建立在宣告式 IaC（第二階）的基礎上 — 有了 state 追蹤和模組化描述之後，才能用「同一份 code、不同參數」的方式複製出多個隔離環境。&lt;/p>
&lt;p>分離的實作方式有一條隔離強度光譜：從帳號級（不同雲端帳號，最強隔離）到目錄級（同一 repo 內各環境一個目錄，各自持有 state）到 workspace 級（同一份 code 用執行期切換 state，隔離最弱）。多數早期團隊在目錄級落腳，因為它在顯式邊界與維運成本之間取得平衡。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>以下狀況指向環境分離不足：&lt;/p>
&lt;ul>
&lt;li>在 staging 測試的變更意外影響了 production 的資源 — dev 跟 prod 共用同一份 state&lt;/li>
&lt;li>某人的 &lt;code>terraform apply&lt;/code> 把另一個環境的資源改掉了 — workspace 的隱性狀態切換導致打錯環境&lt;/li>
&lt;li>dev 與 prod 的設定差異散落在 code 裡的 &lt;code>if env == &amp;quot;prod&amp;quot;&lt;/code> 判斷 — 環境差異沒有集中在參數值裡&lt;/li>
&lt;/ul>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>環境分離的設計要決定：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>隔離層級&lt;/strong>：帳號級、目錄級、還是 workspace 級。判斷依據是團隊規模、合規要求、與維運餘裕&lt;/li>
&lt;li>&lt;strong>參數化邊界&lt;/strong>：dev 與 prod 之間的差異全部用參數表達（instance size、multi-AZ、backup retention），module 內部不寫環境判斷&lt;/li>
&lt;li>&lt;strong>state 位址分離&lt;/strong>：每個環境的 state backend 位址獨立，互不交叉&lt;/li>
&lt;/ul>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC&lt;/a> — 環境分離的前提是有可重用的 IaC 描述&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> — 每個環境持有獨立的 state 檔&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> — 環境分離降低 drift 的跨環境影響範圍&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>環境分離的核心職責是讓 dev 的實驗、staging 的驗證、production 的真實流量彼此不可見也不可達 — 在 dev 跑壞一個資料庫、套錯一條 security group 規則時，production 完全無感。</p>
<h2 id="概念位置">概念位置</h2>
<p>環境分離在 infra 成熟度階梯上對應第三階。它建立在宣告式 IaC（第二階）的基礎上 — 有了 state 追蹤和模組化描述之後，才能用「同一份 code、不同參數」的方式複製出多個隔離環境。</p>
<p>分離的實作方式有一條隔離強度光譜：從帳號級（不同雲端帳號，最強隔離）到目錄級（同一 repo 內各環境一個目錄，各自持有 state）到 workspace 級（同一份 code 用執行期切換 state，隔離最弱）。多數早期團隊在目錄級落腳，因為它在顯式邊界與維運成本之間取得平衡。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>以下狀況指向環境分離不足：</p>
<ul>
<li>在 staging 測試的變更意外影響了 production 的資源 — dev 跟 prod 共用同一份 state</li>
<li>某人的 <code>terraform apply</code> 把另一個環境的資源改掉了 — workspace 的隱性狀態切換導致打錯環境</li>
<li>dev 與 prod 的設定差異散落在 code 裡的 <code>if env == &quot;prod&quot;</code> 判斷 — 環境差異沒有集中在參數值裡</li>
</ul>
<h2 id="設計責任">設計責任</h2>
<p>環境分離的設計要決定：</p>
<ul>
<li><strong>隔離層級</strong>：帳號級、目錄級、還是 workspace 級。判斷依據是團隊規模、合規要求、與維運餘裕</li>
<li><strong>參數化邊界</strong>：dev 與 prod 之間的差異全部用參數表達（instance size、multi-AZ、backup retention），module 內部不寫環境判斷</li>
<li><strong>state 位址分離</strong>：每個環境的 state backend 位址獨立，互不交叉</li>
</ul>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC</a> — 環境分離的前提是有可重用的 IaC 描述</li>
<li><a href="/blog/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">State</a> — 每個環境持有獨立的 state 檔</li>
<li><a href="/blog/infra/knowledge-cards/drift/" data-link-title="Drift（設定漂移）" data-link-desc="IaC 的 state 與雲端實際狀態之間的不一致，通常因為有人繞過 IaC 直接在 Console 改設定">Drift</a> — 環境分離降低 drift 的跨環境影響範圍</li>
</ul>
]]></content:encoded></item><item><title>斷網環境的資安與權限控管</title><link>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-security-access/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-security-access/</guid><description>&lt;p>斷網環境的安全假設跟連網環境相反。連網環境的主要威脅是外部攻擊者透過網路入侵——防火牆、WAF、IDS 構成防禦層。斷網環境的實體隔離幾乎消除了遠端攻擊的可能，但威脅沒有消失，而是轉向兩個方向：有權限存取內部系統的人員（insider threat），以及透過合法管道跨越隔離邊界的內容（supply chain）。每一個刻意建立的橋樑——USB 隨身碟、資料搬運站、data diode——都是攻擊面。&lt;/p>
&lt;h2 id="威脅模型的轉變">威脅模型的轉變&lt;/h2>
&lt;p>連網環境的安全投資集中在邊界防禦：防火牆規則、DDoS 防護、入侵偵測、漏洞修補的速度。斷網環境的邊界是物理的——網路線沒有接上去，防火牆規則不是問題。威脅從「外面的人怎麼進來」變成「裡面的人怎麼把東西帶出去、或把有害的東西帶進來」。&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>極低&lt;/td>
 &lt;td>—&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>釣魚 / 社交工程&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>低（無外部 email）&lt;/td>
 &lt;td>但內部通訊仍可能被利用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>USB / 可移除媒體&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>人員帶入的 USB、外接硬碟&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>供應鏈污染&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>搬運進來的套件、映像、更新檔&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>內部人員濫用權限&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>有實體存取權的操作人員&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>資料外洩&lt;/td>
 &lt;td>高（網路）&lt;/td>
 &lt;td>中（實體）&lt;/td>
 &lt;td>USB 複製、列印、手機拍照&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>橫向移動&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>內部網路扁平時仍然可能&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>斷網環境的安全投資因此集中在三個面向：控制誰能碰什麼（存取控制）、記錄誰碰了什麼（稽核日誌）、審查什麼東西跨越邊界（傳輸審查）。&lt;/p>
&lt;h2 id="實體安全是-infra-的責任">實體安全是 infra 的責任&lt;/h2>
&lt;p>連網環境的實體安全通常歸 facility team——機房門禁、監視器、電力冗餘。infra 團隊負責的是邏輯層的安全（IAM、security group、加密）。斷網環境裡這條分界線消失了：「誰能帶 USB 進機房」直接等於「誰能把任意程式碼注入生產環境」，這是 infra 的安全邊界，不是 facility 的。&lt;/p>
&lt;p>需要 infra 團隊參與制定的實體安全政策：&lt;/p>
&lt;p>&lt;strong>可移除媒體管控&lt;/strong>：哪些人被授權攜帶 USB / 外接硬碟進入安全區域。媒體是否需要預先登記和加密。進入前是否要在掃描站過掃。政策的嚴格度依環境敏感度而定——最嚴格的環境禁止所有個人裝置、只使用登記在冊的專用搬運媒體。&lt;/p>
&lt;p>&lt;strong>機房存取控制&lt;/strong>：門禁卡 / 生物辨識的日誌要進入 infra 的稽核系統。每一次實體進出都要有記錄——誰、什麼時候、待了多久。伺服器機櫃如果有獨立的鎖，鎖的鑰匙管理也歸 infra。&lt;/p>
&lt;p>&lt;strong>Console 存取&lt;/strong>：能直接操作伺服器 console（KVM、IPMI、iLO）的人等於擁有最高權限——可以繞過所有 OS 層的認證。console 存取要限制到最小人數，每次使用要記錄。&lt;/p>
&lt;p>&lt;strong>螢幕與攝影裝置&lt;/strong>：敏感環境可能限制在安全區域內使用手機（防止拍攝螢幕上的資料）。這個政策的執行通常是 facility 負責，但政策的制定依據（什麼資料在螢幕上算敏感）是 infra 定義的。&lt;/p>
&lt;h2 id="身分與認證沒有雲端-iam">身分與認證（沒有雲端 IAM）&lt;/h2>
&lt;p>連網環境用 OIDC / SSO / 雲端 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM&lt;/a> 管理身分。斷網環境沒有這些——需要自建身分基礎設施。&lt;/p>
&lt;p>&lt;strong>集中身分管理&lt;/strong>：FreeIPA（整合 LDAP + Kerberos + DNS + CA）或 OpenLDAP 作為統一的使用者目錄。所有內部服務（GitLab、Nexus、Harbor、Vault、Grafana）都配置 LDAP 認證，避免每個服務各自管一套使用者帳號。FreeIPA 的優勢是把 LDAP、Kerberos、DNS 和 CA 整合在一個管理介面——在資源有限的斷網環境裡減少維運面。&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"># FreeIPA 安裝（CentOS/Rocky）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">sudo yum install -y ipa-server ipa-server-dns
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">sudo ipa-server-install --setup-dns --no-forwarders&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>MFA（沒有網路的情況下）&lt;/strong>：TOTP（如 Google Authenticator）完全在本地運作、不需要網路連線。硬體 token（YubiKey）支援 FIDO2 / PIV / TOTP，在高安全環境是標準做法。智慧卡（CAC / PIV card）在政府和軍事環境最常見。&lt;/p>
&lt;p>&lt;strong>服務帳號&lt;/strong>：機器對機器的認證用 Vault 的 AppRole（role_id + secret_id 換取短期 token）或本地 SSL client certificate。不使用長期密碼或寫死的 token。&lt;/p>
&lt;h2 id="稽核日誌沒有-cloudtrail">稽核日誌（沒有 CloudTrail）&lt;/h2>
&lt;p>連網環境用 CloudTrail / GCP Audit Log 自動記錄所有 API 操作。斷網環境要自建整條稽核鏈：收集 → 傳輸 → 儲存 → 查詢 → 告警。&lt;/p>
&lt;p>&lt;strong>OS 層級&lt;/strong>：Linux auditd 記錄 kernel 層的操作——誰執行了什麼指令、誰存取了什麼檔案、誰修改了什麼系統設定。規則用 &lt;code>auditctl&lt;/code> 或 &lt;code>/etc/audit/rules.d/&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="c1"># 監控所有 sudo 操作&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">-a always,exit -F &lt;span class="nv">arch&lt;/span>&lt;span class="o">=&lt;/span>b64 -S execve -F &lt;span class="nv">euid&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">0&lt;/span> -k root-commands
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1"># 監控 /etc/ 目錄的修改&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">-w /etc/ -p wa -k etc-changes&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>服務層級&lt;/strong>：每個自建服務都有自己的 audit log——GitLab 的 audit events、Vault 的 audit device（可設成 file 或 syslog）、Harbor 的 activity log。這些日誌要匯聚到中央 log server。&lt;/p></description><content:encoded><![CDATA[<p>斷網環境的安全假設跟連網環境相反。連網環境的主要威脅是外部攻擊者透過網路入侵——防火牆、WAF、IDS 構成防禦層。斷網環境的實體隔離幾乎消除了遠端攻擊的可能，但威脅沒有消失，而是轉向兩個方向：有權限存取內部系統的人員（insider threat），以及透過合法管道跨越隔離邊界的內容（supply chain）。每一個刻意建立的橋樑——USB 隨身碟、資料搬運站、data diode——都是攻擊面。</p>
<h2 id="威脅模型的轉變">威脅模型的轉變</h2>
<p>連網環境的安全投資集中在邊界防禦：防火牆規則、DDoS 防護、入侵偵測、漏洞修補的速度。斷網環境的邊界是物理的——網路線沒有接上去，防火牆規則不是問題。威脅從「外面的人怎麼進來」變成「裡面的人怎麼把東西帶出去、或把有害的東西帶進來」。</p>
<table>
  <thead>
      <tr>
          <th>威脅類型</th>
          <th>連網環境的可能性</th>
          <th>斷網環境的可能性</th>
          <th>斷網環境的主要載體</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>遠端漏洞利用</td>
          <td>高</td>
          <td>極低</td>
          <td>—</td>
      </tr>
      <tr>
          <td>釣魚 / 社交工程</td>
          <td>高</td>
          <td>低（無外部 email）</td>
          <td>但內部通訊仍可能被利用</td>
      </tr>
      <tr>
          <td>USB / 可移除媒體</td>
          <td>中</td>
          <td>高</td>
          <td>人員帶入的 USB、外接硬碟</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>USB 複製、列印、手機拍照</td>
      </tr>
      <tr>
          <td>橫向移動</td>
          <td>高</td>
          <td>中</td>
          <td>內部網路扁平時仍然可能</td>
      </tr>
  </tbody>
</table>
<p>斷網環境的安全投資因此集中在三個面向：控制誰能碰什麼（存取控制）、記錄誰碰了什麼（稽核日誌）、審查什麼東西跨越邊界（傳輸審查）。</p>
<h2 id="實體安全是-infra-的責任">實體安全是 infra 的責任</h2>
<p>連網環境的實體安全通常歸 facility team——機房門禁、監視器、電力冗餘。infra 團隊負責的是邏輯層的安全（IAM、security group、加密）。斷網環境裡這條分界線消失了：「誰能帶 USB 進機房」直接等於「誰能把任意程式碼注入生產環境」，這是 infra 的安全邊界，不是 facility 的。</p>
<p>需要 infra 團隊參與制定的實體安全政策：</p>
<p><strong>可移除媒體管控</strong>：哪些人被授權攜帶 USB / 外接硬碟進入安全區域。媒體是否需要預先登記和加密。進入前是否要在掃描站過掃。政策的嚴格度依環境敏感度而定——最嚴格的環境禁止所有個人裝置、只使用登記在冊的專用搬運媒體。</p>
<p><strong>機房存取控制</strong>：門禁卡 / 生物辨識的日誌要進入 infra 的稽核系統。每一次實體進出都要有記錄——誰、什麼時候、待了多久。伺服器機櫃如果有獨立的鎖，鎖的鑰匙管理也歸 infra。</p>
<p><strong>Console 存取</strong>：能直接操作伺服器 console（KVM、IPMI、iLO）的人等於擁有最高權限——可以繞過所有 OS 層的認證。console 存取要限制到最小人數，每次使用要記錄。</p>
<p><strong>螢幕與攝影裝置</strong>：敏感環境可能限制在安全區域內使用手機（防止拍攝螢幕上的資料）。這個政策的執行通常是 facility 負責，但政策的制定依據（什麼資料在螢幕上算敏感）是 infra 定義的。</p>
<h2 id="身分與認證沒有雲端-iam">身分與認證（沒有雲端 IAM）</h2>
<p>連網環境用 OIDC / SSO / 雲端 <a href="/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM</a> 管理身分。斷網環境沒有這些——需要自建身分基礎設施。</p>
<p><strong>集中身分管理</strong>：FreeIPA（整合 LDAP + Kerberos + DNS + CA）或 OpenLDAP 作為統一的使用者目錄。所有內部服務（GitLab、Nexus、Harbor、Vault、Grafana）都配置 LDAP 認證，避免每個服務各自管一套使用者帳號。FreeIPA 的優勢是把 LDAP、Kerberos、DNS 和 CA 整合在一個管理介面——在資源有限的斷網環境裡減少維運面。</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"># FreeIPA 安裝（CentOS/Rocky）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sudo yum install -y ipa-server ipa-server-dns
</span></span><span class="line"><span class="ln">3</span><span class="cl">sudo ipa-server-install --setup-dns --no-forwarders</span></span></code></pre></div><p><strong>MFA（沒有網路的情況下）</strong>：TOTP（如 Google Authenticator）完全在本地運作、不需要網路連線。硬體 token（YubiKey）支援 FIDO2 / PIV / TOTP，在高安全環境是標準做法。智慧卡（CAC / PIV card）在政府和軍事環境最常見。</p>
<p><strong>服務帳號</strong>：機器對機器的認證用 Vault 的 AppRole（role_id + secret_id 換取短期 token）或本地 SSL client certificate。不使用長期密碼或寫死的 token。</p>
<h2 id="稽核日誌沒有-cloudtrail">稽核日誌（沒有 CloudTrail）</h2>
<p>連網環境用 CloudTrail / GCP Audit Log 自動記錄所有 API 操作。斷網環境要自建整條稽核鏈：收集 → 傳輸 → 儲存 → 查詢 → 告警。</p>
<p><strong>OS 層級</strong>：Linux auditd 記錄 kernel 層的操作——誰執行了什麼指令、誰存取了什麼檔案、誰修改了什麼系統設定。規則用 <code>auditctl</code> 或 <code>/etc/audit/rules.d/</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"># 監控所有 sudo 操作</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">-a always,exit -F <span class="nv">arch</span><span class="o">=</span>b64 -S execve -F <span class="nv">euid</span><span class="o">=</span><span class="m">0</span> -k root-commands
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 監控 /etc/ 目錄的修改</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">-w /etc/ -p wa -k etc-changes</span></span></code></pre></div><p><strong>服務層級</strong>：每個自建服務都有自己的 audit log——GitLab 的 audit events、Vault 的 audit device（可設成 file 或 syslog）、Harbor 的 activity log。這些日誌要匯聚到中央 log server。</p>
<p><strong>集中收集</strong>：rsyslog 或 syslog-ng 把各主機的 audit log 轉送到一台專用的 log server。log server 的儲存用 append-only 或 write-once 媒體（防止日誌被竄改）。</p>
<p><strong>日誌完整性</strong>：定期對日誌檔做 hash（<code>sha256sum</code>）並把 hash 存到獨立的位置。如果日誌內容被修改，hash 不匹配會被發現。在最高安全等級的環境裡，日誌會同時寫到光碟或 WORM（Write Once Read Many）儲存。</p>
<p><strong>審閱與告警</strong>：日誌收集了但沒人看等於沒有。定義哪些事件觸發主動通知（root 登入、非工作時段的操作、大量檔案存取）、哪些事件定期審閱（每週掃描異常模式）。</p>
<h2 id="更新的延遲窗口">更新的延遲窗口</h2>
<p>連網環境的 CVE 修補可以在小時到天的層級完成——<code>apt update &amp;&amp; apt upgrade</code>。斷網環境的修補從「得知漏洞」到「修補上線」之間有結構性的延遲。</p>
<p>典型的延遲鏈：外部公告 CVE → 安全團隊評估影響（1-2 天）→ 在外部環境下載修補（同日）→ 掃描修補本身的安全性（1 天）→ 審批跨邊界傳輸（1-3 天）→ 在斷網測試環境驗證（1-2 天）→ 部署到生產環境（同日）。總延遲 5-10 個工作天。</p>
<p>這個延遲窗口是已知的、可管理的風險。管理方式：</p>
<p><strong>風險接受文件</strong>：記錄哪些 CVE 在「已知但尚未修補」的窗口內，每條標註預計修補時間和暫時的補償控制。</p>
<p><strong>補償控制</strong>：在修補到位之前降低漏洞的可利用性——禁用受影響的服務功能、收緊網路分段、限制受影響服務的存取權限。</p>
<p><strong>分級修補</strong>：不是所有 CVE 都需要緊急處理。Critical（CVSS 9+）走加速通道（目標 3 天內修補）、High（CVSS 7-8.9）走正常通道（目標 10 天）、Medium 以下排進常規更新週期。</p>
<h2 id="跨邊界傳輸的安全審查">跨邊界傳輸的安全審查</h2>
<p>每一個跨越隔離邊界的物件都需要審查——套件、映像、設定檔、資料匯出。搬運的操作流程在<a href="/blog/infra/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">通用原則篇</a>描述，這裡聚焦安全審查的部分。</p>
<p><strong>掃描站</strong>：在邊界設置一台專用的掃描機器，所有入境的媒體先在這裡過掃——防毒掃描、檔案類型驗證、hash 比對（確認下載的套件跟官方發布的 hash 一致）。掃描站本身的病毒定義也需要定期更新（走相同的搬運流程）。</p>
<p><strong>傳輸審批日誌</strong>：每次跨邊界傳輸記錄：搬運的內容清單、搬運者、審批者、搬運日期、每個檔案的 hash。這份日誌是稽核的依據——如果內部發現惡意軟體，可以回溯「它是什麼時候、由誰搬進來的」。</p>
<p><strong>Data diode（單向網路裝置）</strong>：在最高安全等級的環境裡，跨邊界的網路連線用 data diode——物理上只允許資料往一個方向流動（外部→內部，或反過來）。這比軟體防火牆更難繞過，因為它是硬體限制。data diode 的限制是不支援雙向協定（如 TCP handshake），需要用 UDP-based 的傳輸工具。</p>
<h2 id="主機層入侵偵測">主機層入侵偵測</h2>
<p>斷網環境的網路流量監控（NIDS）效果有限——內部網路通常扁平、流量加密後難以檢查。主機層入侵偵測（HIDS）是更適合斷網環境的選擇：在每台主機上監控檔案完整性、程序行為、登入模式，而非在網路層攔截。OSSEC 和 Wazuh（OSSEC 的積極維護分支）是開源的 HIDS 方案，agent 裝在每台主機、manager 集中收集告警，不需要連外。</p>
<h2 id="時程與管理層溝通">時程與管理層溝通</h2>
<p>斷網環境的安全管控初始建置時程：FreeIPA 部署 + 跟所有內部服務（GitLab、Nexus、Harbor、Vault）的 LDAP 整合約需 2-3 天。auditd 規則設定 + syslog 聚合到中央 log server 約需 1 天。掃描站建置（防毒 + hash 驗證 + 傳輸日誌）約需半天。HIDS 部署（Wazuh manager + 各主機 agent）約需 1-2 天。整體安全管控從零到運作約需 5-7 個工作天。</p>
<p>持續維護的主要工作是病毒定義更新搬運（跟隨套件更新週期）、稽核日誌的定期審閱（每週）、以及 CVE 修補的分級處理（依 CVSS 嚴重度排程）。</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>：連網環境的 IAM 設計，跟本篇的離線身分方案互補</li>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">斷網環境的通用原則</a>：content ferry 模式的操作流程</li>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-infrastructure-services/" data-link-title="斷網環境的基礎服務：DNS、NTP、CA 與 Secret Management" data-link-desc="斷網環境裡其他所有服務的前提——內部 DNS 做名稱解析、NTP 做時間同步、內部 CA 簽發 TLS 憑證、Vault 管理機密值。這四個服務先部署、其他才能跟上。">斷網環境的基礎服務</a>：CA 和 Vault 是本篇認證和機密管理的技術基礎</li>
<li>→ <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 模組七：資安與資料保護</a>：應用層的安全措施</li>
</ul>
]]></content:encoded></item><item><title>CloudTrail</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/cloudtrail/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/cloudtrail/</guid><description>&lt;p>CloudTrail 的核心職責是把 AWS 帳號內每一個 API 呼叫記錄成可查詢的稽核日誌 — 哪個身分、在什麼時間、對哪個資源、呼叫了哪個 API、結果是成功還是拒絕。它是事故排查和合規稽核的事實來源。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>CloudTrail 在 infra 治理裡的角色是「發生了什麼」的最後防線。人工變更日誌記錄「為什麼改」，CloudTrail 記錄「改了什麼」— 兩者一起才能從事故回推到可回退的操作。&lt;/p>
&lt;p>CloudTrail 預設記錄 management event（建立、修改、刪除資源的 API 呼叫）並保留 90 天可查閱。要長期保存或記錄 data event（S3 物件存取、Lambda 呼叫等更細粒度的操作），需要建立 trail 並指定 S3 bucket 儲存。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>以下狀況指向 CloudTrail 的使用場景：&lt;/p>
&lt;ul>
&lt;li>事故排查需要回答「誰在過去 24 小時改過這個 security group」— CloudTrail 的 &lt;code>LookupEvents&lt;/code> API 可以按事件名稱、資源類型或使用者名稱查詢&lt;/li>
&lt;li>安全稽核要求提供「過去 90 天內所有 IAM policy 變更的紀錄」— CloudTrail 是標準的證據來源&lt;/li>
&lt;li>發現不預期的資源變更（drift），需要確認是人為操作還是自動化觸發 — CloudTrail 的 &lt;code>userIdentity&lt;/code> 欄位區分人類使用者和 assume-role 的服務&lt;/li>
&lt;/ul>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>使用 CloudTrail 時要決定：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>保留期限&lt;/strong>：預設 90 天免費查閱；超過需要建 trail 存到 S3，費用是 S3 儲存成本&lt;/li>
&lt;li>&lt;strong>事件範圍&lt;/strong>：management event 預設開啟；data event（S3 物件讀寫、Lambda invoke）要額外設定，且量大時儲存成本可觀&lt;/li>
&lt;li>&lt;strong>跨帳號整合&lt;/strong>：多帳號架構下，Organization trail 可以把所有帳號的事件集中到一個 S3 bucket&lt;/li>
&lt;li>&lt;strong>存取控制&lt;/strong>：CloudTrail 的 S3 bucket 本身要限制存取 — 能修改稽核日誌等於能掩蓋操作痕跡&lt;/li>
&lt;/ul>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM&lt;/a> — CloudTrail 記錄的是 IAM 身分的 API 呼叫&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> — CloudTrail 是追查 drift 來源（誰手動改了什麼）的工具&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>CloudTrail 的核心職責是把 AWS 帳號內每一個 API 呼叫記錄成可查詢的稽核日誌 — 哪個身分、在什麼時間、對哪個資源、呼叫了哪個 API、結果是成功還是拒絕。它是事故排查和合規稽核的事實來源。</p>
<h2 id="概念位置">概念位置</h2>
<p>CloudTrail 在 infra 治理裡的角色是「發生了什麼」的最後防線。人工變更日誌記錄「為什麼改」，CloudTrail 記錄「改了什麼」— 兩者一起才能從事故回推到可回退的操作。</p>
<p>CloudTrail 預設記錄 management event（建立、修改、刪除資源的 API 呼叫）並保留 90 天可查閱。要長期保存或記錄 data event（S3 物件存取、Lambda 呼叫等更細粒度的操作），需要建立 trail 並指定 S3 bucket 儲存。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>以下狀況指向 CloudTrail 的使用場景：</p>
<ul>
<li>事故排查需要回答「誰在過去 24 小時改過這個 security group」— CloudTrail 的 <code>LookupEvents</code> API 可以按事件名稱、資源類型或使用者名稱查詢</li>
<li>安全稽核要求提供「過去 90 天內所有 IAM policy 變更的紀錄」— CloudTrail 是標準的證據來源</li>
<li>發現不預期的資源變更（drift），需要確認是人為操作還是自動化觸發 — CloudTrail 的 <code>userIdentity</code> 欄位區分人類使用者和 assume-role 的服務</li>
</ul>
<h2 id="設計責任">設計責任</h2>
<p>使用 CloudTrail 時要決定：</p>
<ul>
<li><strong>保留期限</strong>：預設 90 天免費查閱；超過需要建 trail 存到 S3，費用是 S3 儲存成本</li>
<li><strong>事件範圍</strong>：management event 預設開啟；data event（S3 物件讀寫、Lambda invoke）要額外設定，且量大時儲存成本可觀</li>
<li><strong>跨帳號整合</strong>：多帳號架構下，Organization trail 可以把所有帳號的事件集中到一個 S3 bucket</li>
<li><strong>存取控制</strong>：CloudTrail 的 S3 bucket 本身要限制存取 — 能修改稽核日誌等於能掩蓋操作痕跡</li>
</ul>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM</a> — CloudTrail 記錄的是 IAM 身分的 API 呼叫</li>
<li><a href="/blog/infra/knowledge-cards/drift/" data-link-title="Drift（設定漂移）" data-link-desc="IaC 的 state 與雲端實際狀態之間的不一致，通常因為有人繞過 IaC 直接在 Console 改設定">Drift</a> — CloudTrail 是追查 drift 來源（誰手動改了什麼）的工具</li>
</ul>
]]></content:encoded></item><item><title>無 SSH 環境的資料庫備份與變更管理</title><link>https://tarrragon.github.io/blog/infra/takeover/legacy-database-backup-migration/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/takeover/legacy-database-backup-migration/</guid><description>&lt;p>程式碼可以從 Git repo 重新上傳，資料庫裡的資料一旦遺失或損壞就回不來。在無 SSH 的環境裡，資料庫的備份與變更管理比程式碼更需要紀律，因為可用的工具受限（通常只有 phpMyAdmin）、沒有 point-in-time recovery（PITR）、也沒有自動化快照。本篇從工具限制出發，建立一套在這些約束條件下仍能可靠運作的備份與變更流程。&lt;/p>
&lt;p>本篇是&lt;a href="https://tarrragon.github.io/blog/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">無 SSH 的 FTP / 面板管理環境接管&lt;/a>的延伸，聚焦在資料庫層面。程式碼與部署紀律見主文。&lt;/p>
&lt;h2 id="phpmyadmin-的限制與對策">phpMyAdmin 的限制與對策&lt;/h2>
&lt;p>phpMyAdmin 是多數無 SSH 環境預裝的資料庫管理介面，匯出功能涵蓋完整 SQL dump，但它跑在 PHP 執行環境裡，受限於 &lt;code>max_execution_time&lt;/code> 和記憶體上限。資料庫超過 50MB 時，匯出經常在執行到一半就因 timeout 中斷，產出不完整的 SQL 檔案——而不完整的 dump 在還原時只會匯入前半段的表、後面的表靜靜消失。&lt;/p>
&lt;h3 id="大資料庫的匯出對策">大資料庫的匯出對策&lt;/h3>
&lt;p>第一個選項是分表匯出。phpMyAdmin 的匯出頁面允許選擇要匯出的資料表，把一次完整匯出拆成 3-5 批，每批在 timeout 之前完成。缺點是匯出不是原子操作——不同批次之間如果有寫入，表之間的參照關係可能不一致（例如訂單表引用的商品 ID 在商品表的那一批裡還沒匯出）。對多數讀取為主的站台，這個不一致窗口可接受；對交易密集的站台，需要在低流量時段操作。&lt;/p>
&lt;p>第二個選項是調整 phpMyAdmin 的 timeout。部分主機允許在 phpMyAdmin 的設定目錄放自訂的 &lt;code>config.inc.php&lt;/code>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-php" data-lang="php">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nv">$cfg&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;ExecTimeLimit&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">600&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 從預設 300 秒增加到 600 秒
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>cPanel 主機通常在「軟體」區塊的 phpMyAdmin 設定裡有對應的 UI 選項。Plesk 的路徑是「資料庫」→「phpMyAdmin 設定」。能不能改取決於主機商的權限政策，改之前先確認。&lt;/p>
&lt;p>第三個選項是繞過 phpMyAdmin。如果主機允許遠端 MySQL 連線（在 cPanel 的「遠端 MySQL」頁面加白名單 IP），就能用桌面工具直連資料庫匯出：&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>DBeaver&lt;/td>
 &lt;td>跨平台&lt;/td>
 &lt;td>免費&lt;/td>
 &lt;td>右鍵資料庫 → 匯出 → SQL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TablePlus&lt;/td>
 &lt;td>macOS / Windows&lt;/td>
 &lt;td>付費&lt;/td>
 &lt;td>Cmd+Shift+E 匯出&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>HeidiSQL&lt;/td>
 &lt;td>Windows&lt;/td>
 &lt;td>免費&lt;/td>
 &lt;td>工具 → 匯出資料庫為 SQL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>mysqldump&lt;/td>
 &lt;td>CLI（需本機安裝）&lt;/td>
 &lt;td>免費&lt;/td>
 &lt;td>見下方指令&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>桌面工具直連 MySQL 比 phpMyAdmin 穩定，因為匯出跑在本機、不受主機的 PHP timeout 限制。mysqldump 是最可靠的選項：&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">mysqldump -h db-host.example.com -u dbuser -p &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> --single-transaction --routines --triggers &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> dbname &amp;gt; backup_&lt;span class="k">$(&lt;/span>date +%Y%m%d_%H%M&lt;span class="k">)&lt;/span>.sql&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>--single-transaction&lt;/code> 對 InnoDB 表做一致性快照，不需要鎖表。&lt;code>--routines&lt;/code> 和 &lt;code>--triggers&lt;/code> 確保 stored procedure 和觸發器也被包含在 dump 裡——phpMyAdmin 匯出預設也包含，但容易在手動選項時漏勾。&lt;/p>
&lt;h3 id="匯出後的驗證">匯出後的驗證&lt;/h3>
&lt;p>匯出完成後檢查 SQL 檔案的結尾。完整的 mysqldump 結尾會有 &lt;code>-- Dump completed on YYYY-MM-DD HH:MM:SS&lt;/code>。phpMyAdmin 匯出的結尾會有 &lt;code>-- phpMyAdmin SQL Dump&lt;/code> 的對應結尾標記。如果檔案在某個 &lt;code>INSERT INTO&lt;/code> 語句中間斷掉，這份 dump 就是不完整的，還原時會靜靜丟失後面的資料。&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">tail -5 backup_20260626_1430.sql
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1"># 預期看到 &amp;#34;Dump completed&amp;#34; 或完整的結尾註解&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="備份策略頻率與保留">備份策略：頻率與保留&lt;/h2>
&lt;p>備份頻率由資料的變更速率決定。一個每天只有幾筆訂單的小型電商，每週備份加上每次變更前備份就夠用。一個每天有數百筆交易的服務，需要每日備份。判斷依據是：如果最新的備份丟了、要用上一份還原，能接受丟失多少資料？這個時間差就是實際的 RPO（Recovery Point Objective）。&lt;/p>
&lt;h3 id="保留策略">保留策略&lt;/h3>
&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>7 份&lt;/td>
 &lt;td>近期資料遺失的還原&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>每週&lt;/td>
 &lt;td>每週一&lt;/td>
 &lt;td>4 份&lt;/td>
 &lt;td>一到四週前的回溯&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>變更前&lt;/td>
 &lt;td>每次&lt;/td>
 &lt;td>長期保留&lt;/td>
 &lt;td>schema 變更的回退保險點&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>命名用時間戳避免覆蓋：&lt;code>dbname_20260626_1430.sql.gz&lt;/code>。壓縮用 gzip（&lt;code>gzip backup.sql&lt;/code>），50MB 的 SQL dump 通常壓到 5-10MB。&lt;/p></description><content:encoded><![CDATA[<p>程式碼可以從 Git repo 重新上傳，資料庫裡的資料一旦遺失或損壞就回不來。在無 SSH 的環境裡，資料庫的備份與變更管理比程式碼更需要紀律，因為可用的工具受限（通常只有 phpMyAdmin）、沒有 point-in-time recovery（PITR）、也沒有自動化快照。本篇從工具限制出發，建立一套在這些約束條件下仍能可靠運作的備份與變更流程。</p>
<p>本篇是<a href="/blog/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">無 SSH 的 FTP / 面板管理環境接管</a>的延伸，聚焦在資料庫層面。程式碼與部署紀律見主文。</p>
<h2 id="phpmyadmin-的限制與對策">phpMyAdmin 的限制與對策</h2>
<p>phpMyAdmin 是多數無 SSH 環境預裝的資料庫管理介面，匯出功能涵蓋完整 SQL dump，但它跑在 PHP 執行環境裡，受限於 <code>max_execution_time</code> 和記憶體上限。資料庫超過 50MB 時，匯出經常在執行到一半就因 timeout 中斷，產出不完整的 SQL 檔案——而不完整的 dump 在還原時只會匯入前半段的表、後面的表靜靜消失。</p>
<h3 id="大資料庫的匯出對策">大資料庫的匯出對策</h3>
<p>第一個選項是分表匯出。phpMyAdmin 的匯出頁面允許選擇要匯出的資料表，把一次完整匯出拆成 3-5 批，每批在 timeout 之前完成。缺點是匯出不是原子操作——不同批次之間如果有寫入，表之間的參照關係可能不一致（例如訂單表引用的商品 ID 在商品表的那一批裡還沒匯出）。對多數讀取為主的站台，這個不一致窗口可接受；對交易密集的站台，需要在低流量時段操作。</p>
<p>第二個選項是調整 phpMyAdmin 的 timeout。部分主機允許在 phpMyAdmin 的設定目錄放自訂的 <code>config.inc.php</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="nv">$cfg</span><span class="p">[</span><span class="s1">&#39;ExecTimeLimit&#39;</span><span class="p">]</span> <span class="o">=</span> <span class="mi">600</span><span class="p">;</span> <span class="c1">// 從預設 300 秒增加到 600 秒
</span></span></span></code></pre></div><p>cPanel 主機通常在「軟體」區塊的 phpMyAdmin 設定裡有對應的 UI 選項。Plesk 的路徑是「資料庫」→「phpMyAdmin 設定」。能不能改取決於主機商的權限政策，改之前先確認。</p>
<p>第三個選項是繞過 phpMyAdmin。如果主機允許遠端 MySQL 連線（在 cPanel 的「遠端 MySQL」頁面加白名單 IP），就能用桌面工具直連資料庫匯出：</p>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>平台</th>
          <th>費用</th>
          <th>匯出方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DBeaver</td>
          <td>跨平台</td>
          <td>免費</td>
          <td>右鍵資料庫 → 匯出 → SQL</td>
      </tr>
      <tr>
          <td>TablePlus</td>
          <td>macOS / Windows</td>
          <td>付費</td>
          <td>Cmd+Shift+E 匯出</td>
      </tr>
      <tr>
          <td>HeidiSQL</td>
          <td>Windows</td>
          <td>免費</td>
          <td>工具 → 匯出資料庫為 SQL</td>
      </tr>
      <tr>
          <td>mysqldump</td>
          <td>CLI（需本機安裝）</td>
          <td>免費</td>
          <td>見下方指令</td>
      </tr>
  </tbody>
</table>
<p>桌面工具直連 MySQL 比 phpMyAdmin 穩定，因為匯出跑在本機、不受主機的 PHP timeout 限制。mysqldump 是最可靠的選項：</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">mysqldump -h db-host.example.com -u dbuser -p <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --single-transaction --routines --triggers <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  dbname &gt; backup_<span class="k">$(</span>date +%Y%m%d_%H%M<span class="k">)</span>.sql</span></span></code></pre></div><p><code>--single-transaction</code> 對 InnoDB 表做一致性快照，不需要鎖表。<code>--routines</code> 和 <code>--triggers</code> 確保 stored procedure 和觸發器也被包含在 dump 裡——phpMyAdmin 匯出預設也包含，但容易在手動選項時漏勾。</p>
<h3 id="匯出後的驗證">匯出後的驗證</h3>
<p>匯出完成後檢查 SQL 檔案的結尾。完整的 mysqldump 結尾會有 <code>-- Dump completed on YYYY-MM-DD HH:MM:SS</code>。phpMyAdmin 匯出的結尾會有 <code>-- phpMyAdmin SQL Dump</code> 的對應結尾標記。如果檔案在某個 <code>INSERT INTO</code> 語句中間斷掉，這份 dump 就是不完整的，還原時會靜靜丟失後面的資料。</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">tail -5 backup_20260626_1430.sql
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 預期看到 &#34;Dump completed&#34; 或完整的結尾註解</span></span></span></code></pre></div><h2 id="備份策略頻率與保留">備份策略：頻率與保留</h2>
<p>備份頻率由資料的變更速率決定。一個每天只有幾筆訂單的小型電商，每週備份加上每次變更前備份就夠用。一個每天有數百筆交易的服務，需要每日備份。判斷依據是：如果最新的備份丟了、要用上一份還原，能接受丟失多少資料？這個時間差就是實際的 RPO（Recovery Point Objective）。</p>
<h3 id="保留策略">保留策略</h3>
<table>
  <thead>
      <tr>
          <th>備份類型</th>
          <th>頻率</th>
          <th>保留數量</th>
          <th>用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>每日</td>
          <td>每天</td>
          <td>7 份</td>
          <td>近期資料遺失的還原</td>
      </tr>
      <tr>
          <td>每週</td>
          <td>每週一</td>
          <td>4 份</td>
          <td>一到四週前的回溯</td>
      </tr>
      <tr>
          <td>變更前</td>
          <td>每次</td>
          <td>長期保留</td>
          <td>schema 變更的回退保險點</td>
      </tr>
  </tbody>
</table>
<p>命名用時間戳避免覆蓋：<code>dbname_20260626_1430.sql.gz</code>。壓縮用 gzip（<code>gzip backup.sql</code>），50MB 的 SQL dump 通常壓到 5-10MB。</p>
<h3 id="儲存位置">儲存位置</h3>
<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"># rclone 同步到 Google Drive（事先用 rclone config 設定 remote）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">rclone copy /local/backups/db/ gdrive:project-backups/db/ --max-age 7d
</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"># 或推到 S3</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">aws s3 sync /local/backups/db/ s3://my-project-backups/db/ --storage-class STANDARD_IA</span></span></code></pre></div><h3 id="備份驗證">備份驗證</h3>
<p>備份存在不等於備份可用。每月至少做一次驗證：把最新的 dump 匯入本地 MySQL，檢查關鍵表的 row count 跟 prod 一致、應用程式能正常啟動。如果匯入報錯或 row count 差異超過預期，備份流程有問題要立刻排查。</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">mysql -u root -p local_testdb &lt; backup_20260626_1430.sql
</span></span><span class="line"><span class="ln">2</span><span class="cl">mysql -u root -p -e <span class="s2">&#34;SELECT COUNT(*) FROM orders;&#34;</span> local_testdb</span></span></code></pre></div><h2 id="自動化備份無-ssh-環境的限制下">自動化備份（無 SSH 環境的限制下）</h2>
<p>無 SSH 環境的自動化受限程度取決於主機提供的能力。三個層級由好到差：</p>
<p><strong>主機有 cron + mysqldump 路徑</strong>：部分主機在 cPanel 的「cron 工作」裡允許設定排程指令。mysqldump 通常安裝在 <code>/usr/bin/mysqldump</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"># cPanel cron job（每天凌晨 3 點）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="m">0</span> <span class="m">3</span> * * * /usr/bin/mysqldump -u dbuser -p<span class="s1">&#39;password&#39;</span> dbname <span class="p">|</span> gzip &gt; /home/user/backups/db_<span class="k">$(</span>date +<span class="se">\%</span>Y<span class="se">\%</span>m<span class="se">\%</span>d<span class="k">)</span>.sql.gz</span></span></code></pre></div><p>密碼寫在 cron 指令裡不理想但在無 SSH 環境選擇有限。用 <code>.my.cnf</code> 檔案存密碼（<code>chmod 600</code>）較安全，但不是所有主機都支援。</p>
<p><strong>主機有遠端 MySQL 但沒 cron</strong>：用本機排程（macOS launchd / Windows Task Scheduler / Linux cron）跑 mysqldump 遠端連線：</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="cp">#!/bin/bash
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="cp"></span><span class="c1"># local-backup.sh — 本機排程每天跑</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nv">BACKUP_DIR</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$HOME</span><span class="s2">/backups/myproject/db&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">mkdir -p <span class="s2">&#34;</span><span class="nv">$BACKUP_DIR</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">mysqldump -h db-host.example.com -u dbuser -p<span class="s1">&#39;password&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --single-transaction dbname <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  <span class="p">|</span> gzip &gt; <span class="s2">&#34;</span><span class="nv">$BACKUP_DIR</span><span class="s2">/db_</span><span class="k">$(</span>date +%Y%m%d_%H%M<span class="k">)</span><span class="s2">.sql.gz&#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="c1"># 推到雲端</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">rclone copy <span class="s2">&#34;</span><span class="nv">$BACKUP_DIR</span><span class="s2">&#34;</span> gdrive:project-backups/db/ --max-age 7d
</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="c1"># 清理超過 30 天的本地備份</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">find <span class="s2">&#34;</span><span class="nv">$BACKUP_DIR</span><span class="s2">&#34;</span> -name <span class="s2">&#34;*.sql.gz&#34;</span> -mtime +30 -delete</span></span></code></pre></div><p><strong>沒有 cron 也沒有遠端 MySQL</strong>：只能靠手動的 phpMyAdmin 匯出，加上 cPanel 的「備份精靈」（如果主機方案包含）。cPanel 備份精靈可以設定每日或每週的完整備份（含資料庫 + 檔案），但免費方案通常不支援排程。這是最受限的情境——如果連手動匯出都嫌麻煩，最高優先的升級路徑是開通遠端 MySQL 存取。</p>
<h2 id="資料庫變更的-migration-紀律">資料庫變更的 migration 紀律</h2>
<p>Schema 變更（加欄位、改索引、拆表）在沒有 migration 工具的 legacy PHP 專案裡，全靠手動在 phpMyAdmin 執行 SQL。migration 紀律的目標是讓每一次 schema 變更有紀錄、可重播、可回退。</p>
<h3 id="migration-檔案格式">Migration 檔案格式</h3>
<p>每次 schema 變更寫成一個獨立的 SQL 檔案，存在 repo 的 <code>migrations/</code> 目錄：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- migrations/2026-06-26-001-add-users-email-verified.sql
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">-- 目的：新增 email 驗證欄位，支援 email 驗證流程
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1">-- 回退：ALTER TABLE users DROP COLUMN email_verified;
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="c1">-- UP
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">COLUMN</span><span class="w"> </span><span class="n">email_verified</span><span class="w"> </span><span class="n">TINYINT</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="k">AFTER</span><span class="w"> </span><span class="n">email</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">idx_users_email_verified</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="p">(</span><span class="n">email_verified</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="c1">-- DOWN（回退用，不自動執行）
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">-- DROP INDEX idx_users_email_verified ON users;
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">-- ALTER TABLE users DROP COLUMN email_verified;</span></span></span></code></pre></div><p>檔名的結構是 <code>日期-序號-描述</code>，序號處理同一天多次變更的排序。UP 段是要執行的 SQL，DOWN 段是回退 SQL（註解掉，手動需要時才用）。</p>
<h3 id="追蹤哪些-migration-已執行">追蹤哪些 migration 已執行</h3>
<p>在資料庫建一張追蹤表：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="k">IF</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">EXISTS</span><span class="w"> </span><span class="n">migrations_log</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">    </span><span class="n">id</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="n">AUTO_INCREMENT</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">    </span><span class="n">filename</span><span class="w"> </span><span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">255</span><span class="p">)</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">    </span><span class="n">applied_at</span><span class="w"> </span><span class="n">DATETIME</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="k">CURRENT_TIMESTAMP</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">    </span><span class="n">applied_by</span><span class="w"> </span><span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">100</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="p">);</span></span></span></code></pre></div><p>每次在 prod 執行完一個 migration，手動插入一筆紀錄：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">migrations_log</span><span class="w"> </span><span class="p">(</span><span class="n">filename</span><span class="p">,</span><span class="w"> </span><span class="n">applied_by</span><span class="p">)</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="s1">&#39;2026-06-26-001-add-users-email-verified.sql&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;alice&#39;</span><span class="p">);</span></span></span></code></pre></div><p>查哪些 migration 還沒跑：比對 <code>migrations/</code> 目錄的檔案清單跟 <code>migrations_log</code> 表的 filename 欄。這不是自動化的 migration runner（像 Laravel 的 artisan migrate），但在沒有框架支援的 legacy 專案裡，一張表加一個目錄就能達到可追蹤的最低標準。</p>
<h3 id="執行流程">執行流程</h3>
<table>
  <thead>
      <tr>
          <th>步驟</th>
          <th>動作</th>
          <th>失敗時</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>在本地 DB 執行 migration、確認語法正確</td>
          <td>修正 SQL 再試</td>
      </tr>
      <tr>
          <td>2</td>
          <td>備份 prod DB（完整 dump 或受影響的表）</td>
          <td>如果備份失敗、不繼續</td>
      </tr>
      <tr>
          <td>3</td>
          <td>在 prod 的 phpMyAdmin 執行 UP 段</td>
          <td>用 DOWN 段回退、還原備份</td>
      </tr>
      <tr>
          <td>4</td>
          <td>驗證：檢查表結構、跑應用程式確認正常</td>
          <td>用 DOWN 段回退、還原備份</td>
      </tr>
      <tr>
          <td>5</td>
          <td>插入 migrations_log 紀錄</td>
          <td>—</td>
      </tr>
  </tbody>
</table>
<p>高風險的 migration（改大表結構、刪欄位、改資料類型）在步驟 2 要做完整的資料庫 dump 而非只備份受影響的表，因為外鍵和觸發器可能讓影響範圍超出目標表。</p>
<h2 id="還原演練">還原演練</h2>
<p>備份的價值在還原成功的那一刻才被驗證。沒有演練過的備份等同於不存在——匯出可能不完整、SQL 版本可能不相容、匯入順序可能因為外鍵而失敗。</p>
<h3 id="演練流程">演練流程</h3>
<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"># 建一個測試用的空資料庫</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">mysql -u root -p -e <span class="s2">&#34;CREATE DATABASE restore_test;&#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="c1"># 匯入備份</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">mysql -u root -p restore_test &lt; backup_20260626_1430.sql
</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">mysql -u root -p -e <span class="s2">&#34;SHOW TABLES;&#34;</span> restore_test
</span></span><span class="line"><span class="ln">9</span><span class="cl">mysql -u root -p -e <span class="s2">&#34;SELECT COUNT(*) FROM orders;&#34;</span> restore_test</span></span></code></pre></div><p>驗證三件事：表結構完整（<code>SHOW TABLES</code> 的表數量跟 prod 一致）、資料完整（關鍵表的 row count 一致）、應用程式能跑（把本地應用指向 restore_test 資料庫、打開首頁和幾個關鍵流程）。</p>
<h3 id="還原時間的量測">還原時間的量測</h3>
<p>記錄從開始匯入到驗證完成的時間。這個數字就是事故時的最快恢復時間。如果一個 500MB 的資料庫匯入需要 40 分鐘，加上排查原因和決策的時間，實際恢復可能超過一小時。知道這個數字，才能在事故時給管理層一個實際的時間預期。</p>
<h3 id="無-ssh-環境沒有-pitr">無 SSH 環境沒有 PITR</h3>
<p>無 SSH 的主機環境的 MySQL 通常不提供 binlog 層級的 point-in-time recovery。能還原到的最近時間點就是最新備份的時間點——備份是每天凌晨做的、下午三點出事，那就是丟失當天的所有寫入。這是備份頻率需要跟資料變更速率對齊的根本原因。交易密集的站台如果無法接受一天的資料丟失，升級到有 binlog / PITR 的環境（VPS 或 managed MySQL）是必要的投資。</p>
<h2 id="大資料庫的特殊處理">大資料庫的特殊處理</h2>
<p>資料庫超過 500MB 時，備份和還原的操作時間和失敗風險都會上升。需要針對大表做特殊處理。</p>
<p>超過 1GB 的單表通常是 log 表、歷史紀錄表、或含有二進位大物件（BLOB）的表。對這類表的備份策略跟業務表不同：</p>
<ul>
<li><strong>log / 歷史表</strong>：備份時可以加 <code>--where=&quot;created_at &gt; DATE_SUB(NOW(), INTERVAL 90 DAY)&quot;</code> 只匯出近期資料，歷史資料另做一次性歸檔</li>
<li><strong>BLOB 欄位</strong>（圖片、PDF）：用 <code>--no-data</code> 單獨匯出 schema，BLOB 內容如果已經搬到檔案系統或 CDN，資料庫裡只需要保留路徑參考</li>
<li><strong>InnoDB 大表</strong>：<code>--single-transaction</code> 避免鎖表，但匯出期間的記憶體消耗跟表大小成正比，本機如果記憶體不足可以加 <code>--quick</code>（逐行讀取、不緩衝整張表）</li>
</ul>





<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">mysqldump -h db-host.example.com -u dbuser -p <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --single-transaction --quick <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  dbname large_table <span class="p">|</span> gzip &gt; large_table_<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>.sql.gz</span></span></code></pre></div><p>資料庫規模成長到備份時間超過維護視窗（例如匯出要兩小時但只有一小時的低流量時段），代表這類環境的備份能力已經到頂，需要評估升級到有 automated snapshot 的 managed MySQL 或 VPS。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">無 SSH 的 FTP / 面板管理環境接管</a>：主文，涵蓋程式碼備份、部署紀律與整體接管流程</li>
<li>→ <a href="/blog/infra/takeover/legacy-code-versioning-deployment/" data-link-title="程式碼版控與 FTP 部署紀律" data-link-desc="無 SSH 環境的 PHP 專案的程式碼怎麼從 FTP 拉回來建 Git repo、設定檔怎麼分離、FTP 部署怎麼建立可追蹤的流程、以及怎麼用 CI 取代手動上傳">程式碼版控與 FTP 部署紀律</a>：DB migration 跟 code deploy 要同步——schema 改了但 code 沒跟上會讓服務壞掉</li>
<li>→ <a href="/blog/infra/takeover/legacy-php-security-audit/" data-link-title="Legacy PHP 的安全盤點" data-link-desc="接手 legacy PHP 專案後的系統性安全審查：credential 掃描、PHP 版本風險、常見漏洞模式的 grep 偵測、.htaccess 防線、檔案權限、外部依賴與掃描工具">Legacy PHP 的安全盤點</a>：DB credential 的掃描與保護、SQL injection 風險評估</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>：IaC 環境裡的備份、deletion protection 與 PITR 設計</li>
<li>→ <a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">治理好習慣</a>：tagging、secret 管理與成本可見性的長期治理</li>
</ul>
]]></content:encoded></item><item><title>ECS</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/ecs/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/ecs/</guid><description>&lt;p>ECS（Elastic Container Service）的核心職責是把容器映像排程到運算資源上執行，並管理它們的生命週期 — 健康檢查、失敗重啟、滾動更新。它是 AWS 上容器工作負載的預設起點，心智負擔低於 Kubernetes（EKS），但編排彈性也較受限。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>ECS 在核心服務層裡的角色是「應用程式的執行載體」。它跑在 VPC 的 private subnet 裡，用 IAM task role 存取其他 AWS 資源，前面掛 ALB 接收流量。IaC 描述 ECS 時，重點在「接線」（subnet、security group、IAM role、target group）而非容器映像版本 — 映像版本由 CI/CD 在部署期注入。&lt;/p>
&lt;p>ECS 的執行模式分 EC2 launch type（自己管運算實例、要管 AMI 更新與 capacity provider）和 Fargate launch type（AWS 代管運算、不需管實例）。Fargate 進一步降低運維面，代價是單位成本較高（同規格約多 20-40%）且不支援 GPU workload。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>以下狀況指向 ECS 相關問題：&lt;/p>
&lt;ul>
&lt;li>Task 頻繁被 kill 後重啟 — 健康檢查失敗或 OOM，先看 task 的 stopped reason 和 CloudWatch log&lt;/li>
&lt;li>部署後新版本遲遲不上線 — rolling update 的 minimum healthy percent 設太高，新 task 啟動空間不足&lt;/li>
&lt;li>Task 無法拉到 ECR image — 通常是 private subnet 沒有 NAT 或 VPC Endpoint 到 ECR&lt;/li>
&lt;/ul>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>使用 ECS 時要決定：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Launch type&lt;/strong>：Fargate（低運維、較高成本）還是 EC2（低成本、要管實例）。多數 web API 的初始選擇是 Fargate，流量穩定後再評估 EC2&lt;/li>
&lt;li>&lt;strong>Task IAM role&lt;/strong>：task execution role（拉 image 和寫 log 用）和 task role（應用程式存取其他 AWS 資源用）是兩個不同的 role，不要混用&lt;/li>
&lt;li>&lt;strong>映像版本解耦&lt;/strong>：task definition 裡的 image tag 由 CI/CD 部署期注入，infra code 不寫死版本號&lt;/li>
&lt;li>&lt;strong>Auto-scaling 指標&lt;/strong>：用 CPU / memory 還是 ALB request count，取決於服務是計算密集還是 IO 密集&lt;/li>
&lt;/ul>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">Subnet&lt;/a> — ECS task 跑在 private subnet 裡&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group&lt;/a> — ECS service 套用 security group 控制入站&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM&lt;/a> — task role 與 execution role 是 ECS 的兩個身分接線&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB&lt;/a> — 流量透過 ALB target group 導入 ECS task&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>ECS（Elastic Container Service）的核心職責是把容器映像排程到運算資源上執行，並管理它們的生命週期 — 健康檢查、失敗重啟、滾動更新。它是 AWS 上容器工作負載的預設起點，心智負擔低於 Kubernetes（EKS），但編排彈性也較受限。</p>
<h2 id="概念位置">概念位置</h2>
<p>ECS 在核心服務層裡的角色是「應用程式的執行載體」。它跑在 VPC 的 private subnet 裡，用 IAM task role 存取其他 AWS 資源，前面掛 ALB 接收流量。IaC 描述 ECS 時，重點在「接線」（subnet、security group、IAM role、target group）而非容器映像版本 — 映像版本由 CI/CD 在部署期注入。</p>
<p>ECS 的執行模式分 EC2 launch type（自己管運算實例、要管 AMI 更新與 capacity provider）和 Fargate launch type（AWS 代管運算、不需管實例）。Fargate 進一步降低運維面，代價是單位成本較高（同規格約多 20-40%）且不支援 GPU workload。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>以下狀況指向 ECS 相關問題：</p>
<ul>
<li>Task 頻繁被 kill 後重啟 — 健康檢查失敗或 OOM，先看 task 的 stopped reason 和 CloudWatch log</li>
<li>部署後新版本遲遲不上線 — rolling update 的 minimum healthy percent 設太高，新 task 啟動空間不足</li>
<li>Task 無法拉到 ECR image — 通常是 private subnet 沒有 NAT 或 VPC Endpoint 到 ECR</li>
</ul>
<h2 id="設計責任">設計責任</h2>
<p>使用 ECS 時要決定：</p>
<ul>
<li><strong>Launch type</strong>：Fargate（低運維、較高成本）還是 EC2（低成本、要管實例）。多數 web API 的初始選擇是 Fargate，流量穩定後再評估 EC2</li>
<li><strong>Task IAM role</strong>：task execution role（拉 image 和寫 log 用）和 task role（應用程式存取其他 AWS 資源用）是兩個不同的 role，不要混用</li>
<li><strong>映像版本解耦</strong>：task definition 裡的 image tag 由 CI/CD 部署期注入，infra code 不寫死版本號</li>
<li><strong>Auto-scaling 指標</strong>：用 CPU / memory 還是 ALB request count，取決於服務是計算密集還是 IO 密集</li>
</ul>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">Subnet</a> — ECS task 跑在 private subnet 裡</li>
<li><a href="/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group</a> — ECS service 套用 security group 控制入站</li>
<li><a href="/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM</a> — task role 與 execution role 是 ECS 的兩個身分接線</li>
<li><a href="/blog/infra/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB</a> — 流量透過 ALB target group 導入 ECS task</li>
</ul>
]]></content:encoded></item><item><title>程式碼版控與 FTP 部署紀律</title><link>https://tarrragon.github.io/blog/infra/takeover/legacy-code-versioning-deployment/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/takeover/legacy-code-versioning-deployment/</guid><description>&lt;p>無 SSH 環境的 PHP 專案通常沒有版本歷史——程式碼直接透過 FTP 覆蓋伺服器上的檔案，每次上傳就是一次不可回溯的覆寫。接手這類專案時，第一步是在本地建立 Git repo 作為程式碼的唯一事實來源，第二步是把 FTP 上傳從「隨手改隨手傳」轉成有紀錄、可回退的部署流程。本篇聚焦在程式碼端的版控與部署；資料庫的備份與變更紀律見&lt;a href="https://tarrragon.github.io/blog/infra/takeover/legacy-database-backup-migration/" data-link-title="無 SSH 環境的資料庫備份與變更管理" data-link-desc="在只有 phpMyAdmin 或有限遠端連線的無 SSH 環境裡，怎麼建立可靠的資料庫備份策略、schema 變更紀律與還原演練流程">資料庫備份與變更管理&lt;/a>；帳號與存取的安全管理見&lt;a href="https://tarrragon.github.io/blog/infra/takeover/legacy-php-security-audit/" data-link-title="Legacy PHP 的安全盤點" data-link-desc="接手 legacy PHP 專案後的系統性安全審查：credential 掃描、PHP 版本風險、常見漏洞模式的 grep 偵測、.htaccess 防線、檔案權限、外部依賴與掃描工具">Legacy PHP 的安全盤點&lt;/a>。&lt;/p>
&lt;h2 id="從-ftp-拉下來建立-git-repo">從 FTP 拉下來建立 Git repo&lt;/h2>
&lt;p>用 FTP client 把整個站台完整下載到本地目錄，這份下載就是 production 的快照。下載完成後在該目錄初始化 Git：&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">cd&lt;/span> /path/to/downloaded-site
&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;/code>&lt;/pre>&lt;/div>&lt;p>在第一次 commit 之前先處理 &lt;code>.gitignore&lt;/code>。PHP 專案需要排除的檔案分三類：套件依賴（由 Composer 或 npm 管理、可重建）、執行期產物（快取、session、上傳檔案）、以及含有機密值的設定檔。&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"># 套件依賴
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">vendor/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">node_modules/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"># 執行期產物
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">cache/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">tmp/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">sessions/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">*.log
&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">uploads/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">media/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">wp-content/uploads/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"># 機密設定（下一節處理）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">.env
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">config.local.php
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">wp-config.php&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>使用者上傳的內容（&lt;code>uploads/&lt;/code>、&lt;code>media/&lt;/code>）不進 Git 的理由是它屬於資料層：檔案數量可能成千上萬、總容量可能數 GB，Git 不適合管理這類大量二進位檔案。這些檔案的備份策略跟程式碼不同——用 FTP mirror 或 rclone 定期同步到本地即可。&lt;/p>
&lt;p>設好 &lt;code>.gitignore&lt;/code> 後做第一次 commit：&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">git add -A
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">git commit -m &lt;span class="s2">&amp;#34;production snapshot &lt;/span>&lt;span class="k">$(&lt;/span>date +%Y-%m-%d&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 commit 就是「接手時 production 長什麼樣」的基準線。後續所有改動都從這裡開始有版本歷史。&lt;/p>
&lt;h2 id="config-分離讓-git-repo-不含機密值">Config 分離：讓 Git repo 不含機密值&lt;/h2>
&lt;p>無 SSH 環境的 PHP 專案常把資料庫密碼、API key、SMTP 憑證直接寫在 &lt;code>config.php&lt;/code> 或 &lt;code>wp-config.php&lt;/code> 裡。這些檔案如果進了 Git，機密值就跟著 repo 走——推到 GitHub 就等於公開。&lt;/p>
&lt;p>分離的模式是把設定拆成兩份：一份進 Git（結構與預設值）、一份不進 Git（實際機密值）。&lt;/p>
&lt;h3 id="模式一env-檔案">模式一：.env 檔案&lt;/h3>
&lt;p>使用 &lt;code>vlucas/phpdotenv&lt;/code> 套件或手動解析，讓程式碼從 &lt;code>.env&lt;/code> 檔案讀取環境變數：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-php" data-lang="php">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// config.php — 進 Git
&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="nv">$dotenv&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">Dotenv\Dotenv&lt;/span>&lt;span class="o">::&lt;/span>&lt;span class="na">createImmutable&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="no">__DIR__&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nv">$dotenv&lt;/span>&lt;span class="o">-&amp;gt;&lt;/span>&lt;span class="na">load&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;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="nv">$db_host&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nv">$_ENV&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;DB_HOST&amp;#39;&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="nv">$db_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nv">$_ENV&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;DB_NAME&amp;#39;&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 class="nv">$db_user&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nv">$_ENV&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;DB_USER&amp;#39;&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 class="nv">$db_pass&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nv">$_ENV&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;DB_PASS&amp;#39;&lt;/span>&lt;span class="p">];&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl"># .env — 不進 Git（.gitignore 已排除）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">DB_HOST=localhost
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">DB_NAME=mysite_prod
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">DB_USER=mysite_user
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">DB_PASS=actual-password-here&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>同時在 repo 裡放一份 &lt;code>.env.example&lt;/code>（進 Git），列出所有需要的環境變數但不填實際值：&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"># .env.example — 進 Git，作為範本
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">DB_HOST=
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">DB_NAME=
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">DB_USER=
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">DB_PASS=
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">SMTP_HOST=
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">SMTP_USER=
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">SMTP_PASS=&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="模式二configlocalphp">模式二：config.local.php&lt;/h3>
&lt;p>如果專案不使用 Composer、引入 phpdotenv 成本太高，用 PHP include 分離：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-php" data-lang="php">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// config.php — 進 Git
&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">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">file_exists&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="no">__DIR__&lt;/span> &lt;span class="o">.&lt;/span> &lt;span class="s1">&amp;#39;/config.local.php&amp;#39;&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="p">{&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">require&lt;/span> &lt;span class="no">__DIR__&lt;/span> &lt;span class="o">.&lt;/span> &lt;span class="s1">&amp;#39;/config.local.php&amp;#39;&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 class="p">}&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="k">die&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;config.local.php not found. Copy config.local.example.php and fill in values.&amp;#39;&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-php" data-lang="php">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// config.local.php — 不進 Git
&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="nv">$db_host&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;localhost&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nv">$db_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;mysite_prod&amp;#39;&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 class="nv">$db_user&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;mysite_user&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="nv">$db_pass&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;actual-password-here&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="wordpress-的處理">WordPress 的處理&lt;/h3>
&lt;p>WordPress 的 &lt;code>wp-config.php&lt;/code> 同時包含機密值和非機密設定。把整份排除再 include 一份 local 版是最簡單的做法，但也可以只把機密值抽到 &lt;code>.env&lt;/code>、&lt;code>wp-config.php&lt;/code> 本身保留在 Git 裡：&lt;/p></description><content:encoded><![CDATA[<p>無 SSH 環境的 PHP 專案通常沒有版本歷史——程式碼直接透過 FTP 覆蓋伺服器上的檔案，每次上傳就是一次不可回溯的覆寫。接手這類專案時，第一步是在本地建立 Git repo 作為程式碼的唯一事實來源，第二步是把 FTP 上傳從「隨手改隨手傳」轉成有紀錄、可回退的部署流程。本篇聚焦在程式碼端的版控與部署；資料庫的備份與變更紀律見<a href="/blog/infra/takeover/legacy-database-backup-migration/" data-link-title="無 SSH 環境的資料庫備份與變更管理" data-link-desc="在只有 phpMyAdmin 或有限遠端連線的無 SSH 環境裡，怎麼建立可靠的資料庫備份策略、schema 變更紀律與還原演練流程">資料庫備份與變更管理</a>；帳號與存取的安全管理見<a href="/blog/infra/takeover/legacy-php-security-audit/" data-link-title="Legacy PHP 的安全盤點" data-link-desc="接手 legacy PHP 專案後的系統性安全審查：credential 掃描、PHP 版本風險、常見漏洞模式的 grep 偵測、.htaccess 防線、檔案權限、外部依賴與掃描工具">Legacy PHP 的安全盤點</a>。</p>
<h2 id="從-ftp-拉下來建立-git-repo">從 FTP 拉下來建立 Git repo</h2>
<p>用 FTP client 把整個站台完整下載到本地目錄，這份下載就是 production 的快照。下載完成後在該目錄初始化 Git：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">cd</span> /path/to/downloaded-site
</span></span><span class="line"><span class="ln">2</span><span class="cl">git init</span></span></code></pre></div><p>在第一次 commit 之前先處理 <code>.gitignore</code>。PHP 專案需要排除的檔案分三類：套件依賴（由 Composer 或 npm 管理、可重建）、執行期產物（快取、session、上傳檔案）、以及含有機密值的設定檔。</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"># 套件依賴
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">vendor/
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">node_modules/
</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">cache/
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">tmp/
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">sessions/
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">*.log
</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">uploads/
</span></span><span class="line"><span class="ln">13</span><span class="cl">media/
</span></span><span class="line"><span class="ln">14</span><span class="cl">wp-content/uploads/
</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">.env
</span></span><span class="line"><span class="ln">18</span><span class="cl">config.local.php
</span></span><span class="line"><span class="ln">19</span><span class="cl">wp-config.php</span></span></code></pre></div><p>使用者上傳的內容（<code>uploads/</code>、<code>media/</code>）不進 Git 的理由是它屬於資料層：檔案數量可能成千上萬、總容量可能數 GB，Git 不適合管理這類大量二進位檔案。這些檔案的備份策略跟程式碼不同——用 FTP mirror 或 rclone 定期同步到本地即可。</p>
<p>設好 <code>.gitignore</code> 後做第一次 commit：</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 add -A
</span></span><span class="line"><span class="ln">2</span><span class="cl">git commit -m <span class="s2">&#34;production snapshot </span><span class="k">$(</span>date +%Y-%m-%d<span class="k">)</span><span class="s2">&#34;</span></span></span></code></pre></div><p>這個 commit 就是「接手時 production 長什麼樣」的基準線。後續所有改動都從這裡開始有版本歷史。</p>
<h2 id="config-分離讓-git-repo-不含機密值">Config 分離：讓 Git repo 不含機密值</h2>
<p>無 SSH 環境的 PHP 專案常把資料庫密碼、API key、SMTP 憑證直接寫在 <code>config.php</code> 或 <code>wp-config.php</code> 裡。這些檔案如果進了 Git，機密值就跟著 repo 走——推到 GitHub 就等於公開。</p>
<p>分離的模式是把設定拆成兩份：一份進 Git（結構與預設值）、一份不進 Git（實際機密值）。</p>
<h3 id="模式一env-檔案">模式一：.env 檔案</h3>
<p>使用 <code>vlucas/phpdotenv</code> 套件或手動解析，讓程式碼從 <code>.env</code> 檔案讀取環境變數：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// config.php — 進 Git
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nv">$dotenv</span> <span class="o">=</span> <span class="nx">Dotenv\Dotenv</span><span class="o">::</span><span class="na">createImmutable</span><span class="p">(</span><span class="no">__DIR__</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nv">$dotenv</span><span class="o">-&gt;</span><span class="na">load</span><span class="p">();</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="nv">$db_host</span> <span class="o">=</span> <span class="nv">$_ENV</span><span class="p">[</span><span class="s1">&#39;DB_HOST&#39;</span><span class="p">];</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nv">$db_name</span> <span class="o">=</span> <span class="nv">$_ENV</span><span class="p">[</span><span class="s1">&#39;DB_NAME&#39;</span><span class="p">];</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="nv">$db_user</span> <span class="o">=</span> <span class="nv">$_ENV</span><span class="p">[</span><span class="s1">&#39;DB_USER&#39;</span><span class="p">];</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="nv">$db_pass</span> <span class="o">=</span> <span class="nv">$_ENV</span><span class="p">[</span><span class="s1">&#39;DB_PASS&#39;</span><span class="p">];</span></span></span></code></pre></div>




<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"># .env — 不進 Git（.gitignore 已排除）
</span></span><span class="line"><span class="ln">2</span><span class="cl">DB_HOST=localhost
</span></span><span class="line"><span class="ln">3</span><span class="cl">DB_NAME=mysite_prod
</span></span><span class="line"><span class="ln">4</span><span class="cl">DB_USER=mysite_user
</span></span><span class="line"><span class="ln">5</span><span class="cl">DB_PASS=actual-password-here</span></span></code></pre></div><p>同時在 repo 裡放一份 <code>.env.example</code>（進 Git），列出所有需要的環境變數但不填實際值：</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"># .env.example — 進 Git，作為範本
</span></span><span class="line"><span class="ln">2</span><span class="cl">DB_HOST=
</span></span><span class="line"><span class="ln">3</span><span class="cl">DB_NAME=
</span></span><span class="line"><span class="ln">4</span><span class="cl">DB_USER=
</span></span><span class="line"><span class="ln">5</span><span class="cl">DB_PASS=
</span></span><span class="line"><span class="ln">6</span><span class="cl">SMTP_HOST=
</span></span><span class="line"><span class="ln">7</span><span class="cl">SMTP_USER=
</span></span><span class="line"><span class="ln">8</span><span class="cl">SMTP_PASS=</span></span></code></pre></div><h3 id="模式二configlocalphp">模式二：config.local.php</h3>
<p>如果專案不使用 Composer、引入 phpdotenv 成本太高，用 PHP include 分離：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// config.php — 進 Git
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">if</span> <span class="p">(</span><span class="nx">file_exists</span><span class="p">(</span><span class="no">__DIR__</span> <span class="o">.</span> <span class="s1">&#39;/config.local.php&#39;</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">require</span> <span class="no">__DIR__</span> <span class="o">.</span> <span class="s1">&#39;/config.local.php&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">die</span><span class="p">(</span><span class="s1">&#39;config.local.php not found. Copy config.local.example.php and fill in values.&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// config.local.php — 不進 Git
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nv">$db_host</span> <span class="o">=</span> <span class="s1">&#39;localhost&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nv">$db_name</span> <span class="o">=</span> <span class="s1">&#39;mysite_prod&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nv">$db_user</span> <span class="o">=</span> <span class="s1">&#39;mysite_user&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nv">$db_pass</span> <span class="o">=</span> <span class="s1">&#39;actual-password-here&#39;</span><span class="p">;</span></span></span></code></pre></div><h3 id="wordpress-的處理">WordPress 的處理</h3>
<p>WordPress 的 <code>wp-config.php</code> 同時包含機密值和非機密設定。把整份排除再 include 一份 local 版是最簡單的做法，但也可以只把機密值抽到 <code>.env</code>、<code>wp-config.php</code> 本身保留在 Git 裡：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// wp-config.php — 進 Git（機密值從 .env 讀）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nv">$dotenv</span> <span class="o">=</span> <span class="nx">Dotenv\Dotenv</span><span class="o">::</span><span class="na">createImmutable</span><span class="p">(</span><span class="no">__DIR__</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nv">$dotenv</span><span class="o">-&gt;</span><span class="na">load</span><span class="p">();</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="nx">define</span><span class="p">(</span><span class="s1">&#39;DB_NAME&#39;</span><span class="p">,</span> <span class="nv">$_ENV</span><span class="p">[</span><span class="s1">&#39;DB_NAME&#39;</span><span class="p">]);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nx">define</span><span class="p">(</span><span class="s1">&#39;DB_USER&#39;</span><span class="p">,</span> <span class="nv">$_ENV</span><span class="p">[</span><span class="s1">&#39;DB_USER&#39;</span><span class="p">]);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="nx">define</span><span class="p">(</span><span class="s1">&#39;DB_PASSWORD&#39;</span><span class="p">,</span> <span class="nv">$_ENV</span><span class="p">[</span><span class="s1">&#39;DB_PASSWORD&#39;</span><span class="p">]);</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="nx">define</span><span class="p">(</span><span class="s1">&#39;DB_HOST&#39;</span><span class="p">,</span> <span class="nv">$_ENV</span><span class="p">[</span><span class="s1">&#39;DB_HOST&#39;</span><span class="p">]</span> <span class="o">??</span> <span class="s1">&#39;localhost&#39;</span><span class="p">);</span></span></span></code></pre></div><p>分離完成後，用 <code>grep</code> 確認 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 grep -in <span class="s2">&#34;password\|passwd\|secret\|api_key\|smtp&#34;</span> -- <span class="s1">&#39;*.php&#39;</span> <span class="s1">&#39;:!*.example*&#39;</span></span></span></code></pre></div><p>任何命中都要評估：是真的機密值（要移到 .env）還是變數名稱（可以保留）。</p>
<h2 id="ftp-部署的風險控制">FTP 部署的風險控制</h2>
<p>FTP 上傳是逐檔覆寫，沒有交易性——上傳到一半斷線、或上傳了有語法錯誤的 PHP 檔案，站台會立刻出問題。風險控制的核心是「每次上傳前知道在改什麼、上傳後知道改了什麼」。</p>
<h3 id="上傳前的比對">上傳前的比對</h3>
<p>FileZilla 的目錄比較功能（「檢視 → 目錄比較 → 啟用」）可以在上傳前看到本地與遠端的差異：哪些檔案是本地較新、哪些是遠端較新、哪些只存在於一邊。上傳前先跑比較、確認差異清單符合預期——如果出現預期外的「遠端較新」檔案，代表有人在伺服器上直接改了東西，要先下載回來合併再上傳。</p>
<h3 id="只上傳改過的檔案">只上傳改過的檔案</h3>
<p>一次上傳整個站台目錄既慢又危險。只上傳 Git diff 顯示的改動檔案：</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"># 列出相對於上次部署 tag 改了哪些檔案</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git diff --name-only deploy-2026-06-25 HEAD</span></span></code></pre></div><p>把這份清單對照 FileZilla 的比較結果，逐一上傳。量大時用 lftp 的 mirror 指令加 <code>--only-newer</code> flag 只傳新檔。</p>
<h3 id="關鍵檔案的額外保護">關鍵檔案的額外保護</h3>
<p><code>index.php</code>、<code>.htaccess</code>、設定檔這類檔案壞掉會讓整個站台無法存取。上傳這些檔案之前，先從伺服器下載一份當前版本存到本地的 <code>_backup/</code> 目錄（gitignored）。如果上傳後站台出問題，可以立刻把備份版本傳回去。</p>
<h2 id="部署前後的驗證">部署前後的驗證</h2>
<h3 id="部署前檢查">部署前檢查</h3>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>確認方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>本地測試通過</td>
          <td>在本地環境跑過改動的頁面 / 功能</td>
      </tr>
      <tr>
          <td>Git 已 commit</td>
          <td><code>git status</code> 顯示 clean</td>
      </tr>
      <tr>
          <td>要上傳的檔案清單已確認</td>
          <td><code>git diff --name-only</code> 輸出符合預期</td>
      </tr>
      <tr>
          <td>關鍵檔案已備份</td>
          <td><code>_backup/</code> 有當前版本</td>
      </tr>
  </tbody>
</table>
<h3 id="部署後驗證">部署後驗證</h3>
<p>上傳完成後立刻驗證：</p>
<ol>
<li>首頁能正常載入（HTTP 200、頁面內容正確）</li>
<li>本次改動涉及的功能可正常操作</li>
<li>如果是電商站：結帳流程、金流 callback 測試</li>
<li>檢查 PHP error log（cPanel → 錯誤日誌、或 FTP 下載 <code>error_log</code> 檔案）</li>
</ol>
<p>如果驗證失敗，回退方式是從 Git 歷史取出上一個版本的受影響檔案重新上傳：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 取出上一個部署 tag 的特定檔案</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git show deploy-2026-06-25:path/to/file.php &gt; _rollback/file.php
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 用 FTP 上傳 _rollback/file.php 覆蓋 prod</span></span></span></code></pre></div><h2 id="ci-化-ftp-部署">CI 化 FTP 部署</h2>
<p>手動 FTP 部署的問題是它依賴特定人的 FTP client 和操作紀律。用 GitHub Actions 把 FTP 上傳自動化，可以讓部署變成「push 到 main → CI 跑測試 → CI 上傳到伺服器」的流程，不依賴任何人的本地環境。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy via FTP</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">  </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">          </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">2</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy to FTP</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">SamKirkland/FTP-Deploy-Action@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">          </span><span class="nt">server</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.FTP_HOST }}</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">          </span><span class="nt">username</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.FTP_USER }}</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">          </span><span class="nt">password</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.FTP_PASS }}</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">          </span><span class="nt">server-dir</span><span class="p">:</span><span class="w"> </span><span class="l">/public_html/</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w">          </span><span class="nt">exclude</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="sd">            **/.git*
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="sd">            **/.git*/**
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="sd">            **/node_modules/**
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="sd">            **/.env
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="sd">            **/config.local.php</span></span></span></code></pre></div><p>FTP 憑證存在 GitHub repo 的 Secrets 裡（Settings → Secrets and variables → Actions），不寫在 workflow 檔案裡。</p>
<h3 id="ci-化後的改變">CI 化後的改變</h3>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>手動 FTP</th>
          <th>CI 化 FTP</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>部署紀錄</td>
          <td>FTP client 的 log（通常不保留）</td>
          <td>GitHub Actions 的 run history（永久保留）</td>
      </tr>
      <tr>
          <td>部署觸發</td>
          <td>某人手動操作</td>
          <td>push 到 main 自動觸發</td>
      </tr>
      <tr>
          <td>上傳前測試</td>
          <td>依賴個人紀律</td>
          <td>CI 可加 lint / test step</td>
      </tr>
      <tr>
          <td>多人協作</td>
          <td>需要共用 FTP 帳密</td>
          <td>帳密在 GitHub Secrets、workflow 共用</td>
      </tr>
  </tbody>
</table>
<h3 id="限制">限制</h3>
<p>FTP 部署沒有原子性（atomic deployment）——檔案逐一上傳的過程中，伺服器上同時存在新舊版本的檔案混合狀態。如果上傳的檔案之間有依賴關係（新的 A.php 引用新的 B.php，但 B.php 還沒上傳完），短暫的錯誤窗口無法避免。流量高的站台如果需要零停機部署，需要升級到 SSH + symlink 切換的部署方式，那屬於 VPS 遷移之後的能力。</p>
<h2 id="git-tagging-部署紀錄">Git tagging 部署紀錄</h2>
<p>每次部署前在 Git 打一個 tag，讓「這次部署的是哪個版本」有明確的錨點：</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 tag deploy-<span class="k">$(</span>date +%Y-%m-%d-%H%M<span class="k">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git push origin --tags</span></span></code></pre></div><p>tag 的命名用日期時間戳而非版號，因為這類專案通常沒有語意化版號的概念。tag 的作用是：</p>
<ul>
<li>回退時知道要退到哪個版本（<code>git diff deploy-previous deploy-current</code> 看這次改了什麼）</li>
<li>多次部署之間的差異可追蹤</li>
<li>CI 化後可以用 tag 觸發部署而非每次 push 都部署</li>
</ul>
<p>資料庫變更的回退跟程式碼獨立處理——程式碼可以靠 Git 回退，資料庫要靠 SQL dump 回退，兩者的回退點要對齊但機制不同。資料庫的備份策略見<a href="/blog/infra/takeover/legacy-database-backup-migration/" data-link-title="無 SSH 環境的資料庫備份與變更管理" data-link-desc="在只有 phpMyAdmin 或有限遠端連線的無 SSH 環境裡，怎麼建立可靠的資料庫備份策略、schema 變更紀律與還原演練流程">資料庫備份與變更管理</a>。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">無 SSH 的 FTP / 面板管理環境接管</a>：本篇的母文章，涵蓋接手的完整流程</li>
<li>→ <a href="/blog/infra/takeover/legacy-database-backup-migration/" data-link-title="無 SSH 環境的資料庫備份與變更管理" data-link-desc="在只有 phpMyAdmin 或有限遠端連線的無 SSH 環境裡，怎麼建立可靠的資料庫備份策略、schema 變更紀律與還原演練流程">資料庫備份與變更管理</a>：資料庫端的備份、migration 紀律與回退策略</li>
<li>→ <a href="/blog/infra/takeover/legacy-php-security-audit/" data-link-title="Legacy PHP 的安全盤點" data-link-desc="接手 legacy PHP 專案後的系統性安全審查：credential 掃描、PHP 版本風險、常見漏洞模式的 grep 偵測、.htaccess 防線、檔案權限、外部依賴與掃描工具">Legacy PHP 的安全盤點</a>：credential 分離之後的存取控制與安全掃描</li>
<li>→ <a href="/blog/infra/takeover/legacy-external-monitoring/" data-link-title="無 SSH 環境的監控與告警" data-link-desc="無 SSH 環境沒辦法裝 agent、沒辦法串 log pipeline，用外部 HTTP check、錯誤追蹤服務與效能基線建立最低成本的監控能力">無 SSH 環境的監控與告警</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>：從 FTP CI 化進一步演進到完整的 PR review 流程</li>
</ul>
]]></content:encoded></item><item><title>ALB</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/alb/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/alb/</guid><description>&lt;p>ALB（Application Load Balancer）的核心職責是接收外部流量、根據規則（path、host header）把請求路由到後端的 target group，並用健康檢查持續驗證後端是否能服務。它是系統對外的第一個接觸點，跑在 public subnet 裡。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>ALB 在核心服務層裡的角色是「入口設施」。它掛在 public subnet 的 security group 上（入站允許 80/443），把流量導向 private subnet 裡的 ECS task 或 EC2 instance。ALB 本身是 stateless 的 — 重建一個 ALB 不會遺失資料，但會換掉它的 DNS 名稱，所以對外服務通常在 ALB 前面掛一個穩定的 Route 53 alias record。&lt;/p>
&lt;p>TLS 終結是 ALB 的標準職責：HTTPS listener 引用 ACM（AWS Certificate Manager）簽發的憑證，ALB 處理加解密，後端收到的是 HTTP 明文。憑證由 ACM 自動續期，IaC 用 DNS 驗證方式描述憑證 — 讓「憑證存在、續期、掛載」整條鏈都進版本控制。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>以下狀況指向 ALB 相關問題：&lt;/p>
&lt;ul>
&lt;li>使用者看到 502 — ALB 轉發請求但後端回應異常（健康檢查可能通過但實際請求處理失敗），查 target group 的健康狀態和後端 log&lt;/li>
&lt;li>使用者看到 503 — target group 裡沒有健康的後端，通常是部署期間所有舊 task 停了但新 task 還沒通過健康檢查&lt;/li>
&lt;li>HTTPS 憑證過期警告 — 如果用 ACM 搭配 DNS 驗證，憑證自動續期；看到過期警告代表 DNS 驗證記錄被刪了或 ACM 服務異常&lt;/li>
&lt;/ul>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>使用 ALB 時要決定：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>健康檢查參數&lt;/strong>：檢查路徑（用應用層的 health endpoint、不用根路徑）、間隔、閾值。閾值太寬鬆會把壞掉的後端留在輪替裡，太嚴格會在部署瞬間誤判&lt;/li>
&lt;li>&lt;strong>HTTP → HTTPS redirect&lt;/strong>：port 80 的 listener 設定固定回應 301 redirect 到 443，確保所有流量走加密&lt;/li>
&lt;li>&lt;strong>TLS 憑證&lt;/strong>：用 ACM 搭配 DNS 驗證，讓憑證的簽發和續期自動化&lt;/li>
&lt;li>&lt;strong>穩定 DNS&lt;/strong>：ALB 前面掛 Route 53 alias record，對外暴露的是自己的 domain name 而非 ALB 的隨機 hostname&lt;/li>
&lt;/ul>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">Subnet&lt;/a> — ALB 跑在 public subnet，後端跑在 private subnet&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group&lt;/a> — ALB 的 security group 是系統對外唯一合理開放 0.0.0.0/0 的位置（僅限 80/443）&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/ecs/" data-link-title="ECS" data-link-desc="AWS Elastic Container Service — 受管的容器編排服務，用 task definition 描述容器配置、由平台負責排程與健康管理">ECS&lt;/a> — ALB 透過 target group 把流量導向 ECS task&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>ALB（Application Load Balancer）的核心職責是接收外部流量、根據規則（path、host header）把請求路由到後端的 target group，並用健康檢查持續驗證後端是否能服務。它是系統對外的第一個接觸點，跑在 public subnet 裡。</p>
<h2 id="概念位置">概念位置</h2>
<p>ALB 在核心服務層裡的角色是「入口設施」。它掛在 public subnet 的 security group 上（入站允許 80/443），把流量導向 private subnet 裡的 ECS task 或 EC2 instance。ALB 本身是 stateless 的 — 重建一個 ALB 不會遺失資料，但會換掉它的 DNS 名稱，所以對外服務通常在 ALB 前面掛一個穩定的 Route 53 alias record。</p>
<p>TLS 終結是 ALB 的標準職責：HTTPS listener 引用 ACM（AWS Certificate Manager）簽發的憑證，ALB 處理加解密，後端收到的是 HTTP 明文。憑證由 ACM 自動續期，IaC 用 DNS 驗證方式描述憑證 — 讓「憑證存在、續期、掛載」整條鏈都進版本控制。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>以下狀況指向 ALB 相關問題：</p>
<ul>
<li>使用者看到 502 — ALB 轉發請求但後端回應異常（健康檢查可能通過但實際請求處理失敗），查 target group 的健康狀態和後端 log</li>
<li>使用者看到 503 — target group 裡沒有健康的後端，通常是部署期間所有舊 task 停了但新 task 還沒通過健康檢查</li>
<li>HTTPS 憑證過期警告 — 如果用 ACM 搭配 DNS 驗證，憑證自動續期；看到過期警告代表 DNS 驗證記錄被刪了或 ACM 服務異常</li>
</ul>
<h2 id="設計責任">設計責任</h2>
<p>使用 ALB 時要決定：</p>
<ul>
<li><strong>健康檢查參數</strong>：檢查路徑（用應用層的 health endpoint、不用根路徑）、間隔、閾值。閾值太寬鬆會把壞掉的後端留在輪替裡，太嚴格會在部署瞬間誤判</li>
<li><strong>HTTP → HTTPS redirect</strong>：port 80 的 listener 設定固定回應 301 redirect 到 443，確保所有流量走加密</li>
<li><strong>TLS 憑證</strong>：用 ACM 搭配 DNS 驗證，讓憑證的簽發和續期自動化</li>
<li><strong>穩定 DNS</strong>：ALB 前面掛 Route 53 alias record，對外暴露的是自己的 domain name 而非 ALB 的隨機 hostname</li>
</ul>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">Subnet</a> — ALB 跑在 public subnet，後端跑在 private subnet</li>
<li><a href="/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group</a> — ALB 的 security group 是系統對外唯一合理開放 0.0.0.0/0 的位置（僅限 80/443）</li>
<li><a href="/blog/infra/knowledge-cards/ecs/" data-link-title="ECS" data-link-desc="AWS Elastic Container Service — 受管的容器編排服務，用 task definition 描述容器配置、由平台負責排程與健康管理">ECS</a> — ALB 透過 target group 把流量導向 ECS task</li>
</ul>
]]></content:encoded></item><item><title>Legacy PHP 的安全盤點</title><link>https://tarrragon.github.io/blog/infra/takeover/legacy-php-security-audit/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/takeover/legacy-php-security-audit/</guid><description>&lt;p>接手的 legacy PHP 專案在做完&lt;a href="https://tarrragon.github.io/blog/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">程式碼與資料庫的現況快照&lt;/a>之後，下一步是安全盤點。安全狀態在盤點之前是未知的——前一位維護者可能所有表單都用 prepared statement，也可能每個查詢都直接拼接使用者輸入。盤點的範圍涵蓋 credential 散落、PHP 版本風險、程式碼層的漏洞模式、伺服器端的 .htaccess 與權限設定、以及外部依賴的已知漏洞。&lt;/p>
&lt;h2 id="credential-掃描與處理">Credential 掃描與處理&lt;/h2>
&lt;p>寫死在程式碼裡的 credential 是接手後最先要掌握的風險面。資料庫密碼、API key、SMTP 帳號這些值如果散落在多個 PHP 檔案裡，每一個都是外洩路徑。&lt;/p>
&lt;h3 id="掃描方式">掃描方式&lt;/h3>
&lt;p>用 grep 對整個 codebase 搜尋常見的 credential 關鍵字：&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">grep -rn &lt;span class="s2">&amp;#34;password\|passwd\|secret\|api_key\|app_key\|mysql_connect\|mysqli_connect\|PDO(&amp;#34;&lt;/span> &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> --include&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;*.php&amp;#34;&lt;/span> .&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>常見的集中位置是 &lt;code>config.php&lt;/code>、&lt;code>wp-config.php&lt;/code>、&lt;code>database.php&lt;/code>、&lt;code>settings.php&lt;/code>，以及專案根目錄的 &lt;code>.env&lt;/code>。但 legacy 專案的 credential 經常散落在意想不到的地方——寫在某個 helper function 的預設參數裡、硬編碼在 cron job 的 PHP 檔案裡、或藏在某個很久沒改的 email 發送模組裡。grep 的涵蓋範圍應該是整個專案目錄，不只是已知的 config 檔案。&lt;/p>
&lt;p>如果專案已經在本地 Git repo（見&lt;a href="https://tarrragon.github.io/blog/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">主文&lt;/a>的快照步驟），檢查 Git 歷史裡有沒有曾經存在但後來被刪除的 credential：&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">git log --all -p -- &lt;span class="s1">&amp;#39;*.php&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span> grep -i &lt;span class="s2">&amp;#34;password\|secret\|api_key&amp;#34;&lt;/span> &lt;span class="p">|&lt;/span> head -30&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>歷史裡的 credential 無法從 Git 裡真正移除（rewrite history 可以但成本高），所以找到的 credential 都要列入輪替清單。&lt;/p>
&lt;h3 id="處理方式">處理方式&lt;/h3>
&lt;p>掃描結果彙整成一張清單，每筆記錄：credential 類型、所在檔案、用途、是否可輪替。處理優先序：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>類型&lt;/th>
 &lt;th>處理方式&lt;/th>
 &lt;th>優先級&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>資料庫密碼&lt;/td>
 &lt;td>移到 &lt;code>.env&lt;/code> 或 &lt;code>config.local.php&lt;/code>（gitignore）&lt;/td>
 &lt;td>立刻&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>第三方 API key（金流、簡訊）&lt;/td>
 &lt;td>移到 config + 確認可輪替&lt;/td>
 &lt;td>立刻&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>SMTP 密碼&lt;/td>
 &lt;td>移到 config&lt;/td>
 &lt;td>第二順位&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>內部服務 token&lt;/td>
 &lt;td>移到 config + 確認對方端有沒有輪替機制&lt;/td>
 &lt;td>第二順位&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>已停用的 credential&lt;/td>
 &lt;td>確認停用後從 code 移除&lt;/td>
 &lt;td>第三順位&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>把 credential 從 code 移到 &lt;code>.env&lt;/code> 後，用 &lt;code>getenv('DB_PASSWORD')&lt;/code> 或框架的 config 機制讀取。&lt;code>.env&lt;/code> 加進 &lt;code>.gitignore&lt;/code>，prod 的 &lt;code>.env&lt;/code> 透過 FTP 單獨上傳、不進版本控制。&lt;/p>
&lt;h2 id="php-版本與已知漏洞">PHP 版本與已知漏洞&lt;/h2>
&lt;p>PHP 版本決定了這個專案暴露在什麼層級的平台風險下。已結束安全支援（EOL）的 PHP 版本不代表「馬上會被攻擊」，但代表任何未來被發現的漏洞都不會得到官方修補。&lt;/p>
&lt;h3 id="版本確認">版本確認&lt;/h3>
&lt;p>在站台放一個 &lt;code>phpinfo.php&lt;/code>，瀏覽後記錄版本號，完成後立刻刪除（&lt;code>phpinfo()&lt;/code> 輸出含伺服器路徑與配置細節，留在 prod 上是資訊外洩）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-php" data-lang="php">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="o">&amp;lt;?&lt;/span>&lt;span class="nx">php&lt;/span> &lt;span class="nx">phpinfo&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="cp">?&amp;gt;&lt;/span>&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>或在 cPanel / Plesk 的 PHP 設定頁面直接查看。&lt;/p>
&lt;h3 id="版本風險對照">版本風險對照&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>版本&lt;/th>
 &lt;th>安全支援狀態（2026）&lt;/th>
 &lt;th>風險等級&lt;/th>
 &lt;th>行動&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>5.6 以下&lt;/td>
 &lt;td>已 EOL 超過 8 年&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>列入升級計畫、優先處理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>7.0 - 7.4&lt;/td>
 &lt;td>已 EOL&lt;/td>
 &lt;td>中高&lt;/td>
 &lt;td>排進季度 roadmap&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>8.0&lt;/td>
 &lt;td>已 EOL（2023-11）&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>排進半年 roadmap&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>8.1&lt;/td>
 &lt;td>安全修補中（至 2025-12）&lt;/td>
 &lt;td>已接近 EOL&lt;/td>
 &lt;td>規劃升級到 8.2+&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>8.2+&lt;/td>
 &lt;td>活躍支援中&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>維持更新&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>版本升級是獨立的工程專案——可能會觸發函式棄用警告、行為變更、甚至語法不相容。盤點階段的任務是記錄版本和風險等級，升級規劃放在穩定維運之後。&lt;/p></description><content:encoded><![CDATA[<p>接手的 legacy PHP 專案在做完<a href="/blog/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">程式碼與資料庫的現況快照</a>之後，下一步是安全盤點。安全狀態在盤點之前是未知的——前一位維護者可能所有表單都用 prepared statement，也可能每個查詢都直接拼接使用者輸入。盤點的範圍涵蓋 credential 散落、PHP 版本風險、程式碼層的漏洞模式、伺服器端的 .htaccess 與權限設定、以及外部依賴的已知漏洞。</p>
<h2 id="credential-掃描與處理">Credential 掃描與處理</h2>
<p>寫死在程式碼裡的 credential 是接手後最先要掌握的風險面。資料庫密碼、API key、SMTP 帳號這些值如果散落在多個 PHP 檔案裡，每一個都是外洩路徑。</p>
<h3 id="掃描方式">掃描方式</h3>
<p>用 grep 對整個 codebase 搜尋常見的 credential 關鍵字：</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">grep -rn <span class="s2">&#34;password\|passwd\|secret\|api_key\|app_key\|mysql_connect\|mysqli_connect\|PDO(&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --include<span class="o">=</span><span class="s2">&#34;*.php&#34;</span> .</span></span></code></pre></div><p>常見的集中位置是 <code>config.php</code>、<code>wp-config.php</code>、<code>database.php</code>、<code>settings.php</code>，以及專案根目錄的 <code>.env</code>。但 legacy 專案的 credential 經常散落在意想不到的地方——寫在某個 helper function 的預設參數裡、硬編碼在 cron job 的 PHP 檔案裡、或藏在某個很久沒改的 email 發送模組裡。grep 的涵蓋範圍應該是整個專案目錄，不只是已知的 config 檔案。</p>
<p>如果專案已經在本地 Git repo（見<a href="/blog/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">主文</a>的快照步驟），檢查 Git 歷史裡有沒有曾經存在但後來被刪除的 credential：</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 -p -- <span class="s1">&#39;*.php&#39;</span> <span class="p">|</span> grep -i <span class="s2">&#34;password\|secret\|api_key&#34;</span> <span class="p">|</span> head -30</span></span></code></pre></div><p>歷史裡的 credential 無法從 Git 裡真正移除（rewrite history 可以但成本高），所以找到的 credential 都要列入輪替清單。</p>
<h3 id="處理方式">處理方式</h3>
<p>掃描結果彙整成一張清單，每筆記錄：credential 類型、所在檔案、用途、是否可輪替。處理優先序：</p>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>處理方式</th>
          <th>優先級</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>資料庫密碼</td>
          <td>移到 <code>.env</code> 或 <code>config.local.php</code>（gitignore）</td>
          <td>立刻</td>
      </tr>
      <tr>
          <td>第三方 API key（金流、簡訊）</td>
          <td>移到 config + 確認可輪替</td>
          <td>立刻</td>
      </tr>
      <tr>
          <td>SMTP 密碼</td>
          <td>移到 config</td>
          <td>第二順位</td>
      </tr>
      <tr>
          <td>內部服務 token</td>
          <td>移到 config + 確認對方端有沒有輪替機制</td>
          <td>第二順位</td>
      </tr>
      <tr>
          <td>已停用的 credential</td>
          <td>確認停用後從 code 移除</td>
          <td>第三順位</td>
      </tr>
  </tbody>
</table>
<p>把 credential 從 code 移到 <code>.env</code> 後，用 <code>getenv('DB_PASSWORD')</code> 或框架的 config 機制讀取。<code>.env</code> 加進 <code>.gitignore</code>，prod 的 <code>.env</code> 透過 FTP 單獨上傳、不進版本控制。</p>
<h2 id="php-版本與已知漏洞">PHP 版本與已知漏洞</h2>
<p>PHP 版本決定了這個專案暴露在什麼層級的平台風險下。已結束安全支援（EOL）的 PHP 版本不代表「馬上會被攻擊」，但代表任何未來被發現的漏洞都不會得到官方修補。</p>
<h3 id="版本確認">版本確認</h3>
<p>在站台放一個 <code>phpinfo.php</code>，瀏覽後記錄版本號，完成後立刻刪除（<code>phpinfo()</code> 輸出含伺服器路徑與配置細節，留在 prod 上是資訊外洩）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="o">&lt;?</span><span class="nx">php</span> <span class="nx">phpinfo</span><span class="p">();</span> <span class="cp">?&gt;</span><span class="err">
</span></span></span></code></pre></div><p>或在 cPanel / Plesk 的 PHP 設定頁面直接查看。</p>
<h3 id="版本風險對照">版本風險對照</h3>
<table>
  <thead>
      <tr>
          <th>版本</th>
          <th>安全支援狀態（2026）</th>
          <th>風險等級</th>
          <th>行動</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>5.6 以下</td>
          <td>已 EOL 超過 8 年</td>
          <td>高</td>
          <td>列入升級計畫、優先處理</td>
      </tr>
      <tr>
          <td>7.0 - 7.4</td>
          <td>已 EOL</td>
          <td>中高</td>
          <td>排進季度 roadmap</td>
      </tr>
      <tr>
          <td>8.0</td>
          <td>已 EOL（2023-11）</td>
          <td>中</td>
          <td>排進半年 roadmap</td>
      </tr>
      <tr>
          <td>8.1</td>
          <td>安全修補中（至 2025-12）</td>
          <td>已接近 EOL</td>
          <td>規劃升級到 8.2+</td>
      </tr>
      <tr>
          <td>8.2+</td>
          <td>活躍支援中</td>
          <td>低</td>
          <td>維持更新</td>
      </tr>
  </tbody>
</table>
<p>版本升級是獨立的工程專案——可能會觸發函式棄用警告、行為變更、甚至語法不相容。盤點階段的任務是記錄版本和風險等級，升級規劃放在穩定維運之後。</p>
<h2 id="常見的-php-安全漏洞模式">常見的 PHP 安全漏洞模式</h2>
<p>Legacy PHP 專案最常見的四類漏洞都可以用 grep 做初步掃描。掃描結果是候選清單、不是確認的漏洞——每個命中都需要讀上下文確認是否有防護。</p>
<h3 id="sql-injection">SQL injection</h3>
<p>任何把使用者輸入直接拼接到 SQL 查詢裡的寫法都是 SQL injection 的候選：</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"># 找使用 mysql_query / mysqli_query 但沒有 prepare/bind 的查詢</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">grep -rn <span class="s2">&#34;mysql_query\|mysqli_query&#34;</span> --include<span class="o">=</span><span class="s2">&#34;*.php&#34;</span> . <span class="p">|</span> grep -v <span class="s2">&#34;prepare\|bind_param&#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="c1"># 找字串拼接的 SQL 查詢</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">grep -rn <span class="s2">&#34;query.*\\\$_GET\|query.*\\\$_POST\|query.*\\\$_REQUEST&#34;</span> --include<span class="o">=</span><span class="s2">&#34;*.php&#34;</span> .</span></span></code></pre></div><p>修法是改用 prepared statement（PDO 或 mysqli 的 <code>prepare</code> + <code>bind_param</code>）。如果 codebase 大量使用 <code>mysql_*</code> 函式（PHP 7.0 已移除），這本身就是版本升級的阻礙——需要同時處理。</p>
<h3 id="xss跨站腳本">XSS（跨站腳本）</h3>
<p>把使用者輸入直接輸出到 HTML 而沒有跳脫：</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"># 找直接 echo/print 使用者輸入的地方</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">grep -rn <span class="s2">&#34;echo.*\\\$_GET\|echo.*\\\$_POST\|echo.*\\\$_REQUEST\|echo.*\\\$_COOKIE&#34;</span> --include<span class="o">=</span><span class="s2">&#34;*.php&#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="c1"># 找 PHP 短標籤輸出</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">grep -rn <span class="s2">&#34;&lt;?=.*\\\$_&#34;</span> --include<span class="o">=</span><span class="s2">&#34;*.php&#34;</span> .</span></span></code></pre></div><p>修法是所有輸出都經過 <code>htmlspecialchars($var, ENT_QUOTES, 'UTF-8')</code>。模板引擎（如 Twig、Blade）預設會做跳脫，使用模板引擎的專案 XSS 風險較低。</p>
<h3 id="檔案包含file-inclusion">檔案包含（File Inclusion）</h3>
<p>把使用者輸入當作 <code>include</code> 或 <code>require</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">grep -rn <span class="s2">&#34;include.*\\\$_\|require.*\\\$_\|include_once.*\\\$_\|require_once.*\\\$_&#34;</span> --include<span class="o">=</span><span class="s2">&#34;*.php&#34;</span> .</span></span></code></pre></div><p>這類寫法讓攻擊者可以指定載入任意檔案（本地或遠端）。修法是用白名單限制可載入的檔案路徑。</p>
<h3 id="檔案上傳">檔案上傳</h3>
<p>檢查上傳處理的三個面向：副檔名驗證（只允許白名單）、上傳目錄是否可執行 PHP（不應該）、檔案大小限制。</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">grep -rn <span class="s2">&#34;move_uploaded_file\|\\\$_FILES&#34;</span> --include<span class="o">=</span><span class="s2">&#34;*.php&#34;</span> .</span></span></code></pre></div><p>每個命中的上傳處理都要確認：有沒有驗證副檔名（黑名單不夠、要白名單）、上傳目錄有沒有 <code>.htaccess</code> 禁止 PHP 執行（見下節）、有沒有重新命名上傳的檔案（避免覆寫攻擊）。</p>
<h3 id="session-管理">Session 管理</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 找 session 相關設定</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">grep -rn <span class="s2">&#34;session_start\|session_regenerate_id\|session\.cookie_httponly\|session\.cookie_secure&#34;</span> --include<span class="o">=</span><span class="s2">&#34;*.php&#34;</span> .</span></span></code></pre></div><p>確認：登入成功後有沒有呼叫 <code>session_regenerate_id(true)</code> 防止 session fixation、<code>session.cookie_httponly</code> 是否為 on（防止 JavaScript 讀取 session cookie）、<code>session.cookie_secure</code> 在 HTTPS 站台是否為 on。</p>
<h2 id="htaccess-安全設定">.htaccess 安全設定</h2>
<p>無 SSH 的 Apache 環境中 <code>.htaccess</code> 是可用的伺服器端安全防線。盤點時確認這些設定是否存在，缺少的補上。</p>
<h3 id="基礎安全設定">基礎安全設定</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-apache" data-lang="apache"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># 禁止目錄列表 — 防止瀏覽上傳目錄的檔案清單</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nb">Options</span> -Indexes
</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="c"># 阻擋敏感檔案的 HTTP 存取</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="nt">&lt;FilesMatch</span> <span class="s">&#34;\.(env|local|bak|sql|log|ini|conf|yml|json|lock|md)$&#34;</span><span class="nt">&gt;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nb">Require</span> <span class="k">all</span> denied
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="nt">&lt;/FilesMatch&gt;</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="c"># 阻擋隱藏檔案與目錄（.git、.env 等）</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="nt">&lt;IfModule</span> <span class="s">mod_rewrite.c</span><span class="nt">&gt;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nb">RewriteEngine</span> <span class="k">On</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nb">RewriteRule</span> (^\.|/\.) - [F]
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="nt">&lt;/IfModule&gt;</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="c"># 強制 HTTPS</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="nt">&lt;IfModule</span> <span class="s">mod_rewrite.c</span><span class="nt">&gt;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nb">RewriteCond</span> %{HTTPS} <span class="k">off</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nb">RewriteRule</span> ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="nt">&lt;/IfModule&gt;</span></span></span></code></pre></div><h3 id="上傳目錄的-php-執行禁令">上傳目錄的 PHP 執行禁令</h3>
<p>在上傳目錄（如 <code>uploads/</code>、<code>wp-content/uploads/</code>）放一個獨立的 <code>.htaccess</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-apache" data-lang="apache"><span class="line"><span class="ln">1</span><span class="cl"><span class="c"># 禁止此目錄下的 PHP 執行</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">php_flag</span> engine <span class="k">off</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="c"># 只允許靜態檔案類型</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nt">&lt;FilesMatch</span> <span class="s">&#34;\.(?!jpg|jpeg|png|gif|pdf|webp|svg|css|js)&#34;</span><span class="nt">&gt;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nb">Require</span> <span class="k">all</span> denied
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="nt">&lt;/FilesMatch&gt;</span></span></span></code></pre></div><p>這條設定讓即使攻擊者成功上傳了 <code>.php</code> 檔案，也無法透過 HTTP 請求觸發執行。</p>
<h3 id="安全-header">安全 header</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-apache" data-lang="apache"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># 防止 MIME type sniffing</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nb">Header</span> set X-Content-Type-Options <span class="s2">&#34;nosniff&#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="c"># 防止 clickjacking</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="nb">Header</span> set X-Frame-Options <span class="s2">&#34;SAMEORIGIN&#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 class="c"># XSS 防護（現代瀏覽器多已內建、但舊站加上無害）</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="nb">Header</span> set X-XSS-Protection <span class="s2">&#34;1; mode=block&#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 class="c"># Referrer 資訊控制</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="nb">Header</span> set Referrer-Policy <span class="s2">&#34;strict-origin-when-cross-origin&#34;</span></span></span></code></pre></div><h2 id="檔案權限">檔案權限</h2>
<p>無 SSH 環境的權限控制能力有限——多數情況下透過 FTP client 檢查和調整。</p>
<table>
  <thead>
      <tr>
          <th>對象</th>
          <th>建議權限</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>目錄</td>
          <td>755</td>
          <td>owner 可讀寫執行、group/other 可讀可執行（Apache 需要執行權才能進入目錄）</td>
      </tr>
      <tr>
          <td>PHP 檔案</td>
          <td>644</td>
          <td>owner 可讀寫、group/other 只讀</td>
      </tr>
      <tr>
          <td>Config 檔案（含 credential）</td>
          <td>640</td>
          <td>group 可讀（Apache 通常跟 owner 同 group）、other 不可讀</td>
      </tr>
      <tr>
          <td>上傳目錄</td>
          <td>755</td>
          <td>跟一般目錄相同，搭配 .htaccess 禁止 PHP 執行</td>
      </tr>
  </tbody>
</table>
<p>777 權限（所有人可讀寫執行）在多租戶主機上等於同一台伺服器的其他租戶也能讀寫這些檔案。如果發現任何目錄或檔案是 777，立刻改回 755/644。FileZilla 在檔案上按右鍵 → 「File permissions」可以查看和修改。</p>
<h2 id="外部依賴的安全性">外部依賴的安全性</h2>
<h3 id="composer-管理的依賴">Composer 管理的依賴</h3>
<p>如果專案使用 Composer，在本地跑一次已知漏洞檢查：</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">composer audit</span></span></code></pre></div><p>這條指令比對 <code>composer.lock</code> 裡的每個套件版本與 Packagist 的安全公告資料庫，列出有已知 CVE 的套件。</p>
<h3 id="手動管理的依賴">手動管理的依賴</h3>
<p>沒有 Composer 的 legacy 專案可能直接把第三方程式碼複製進專案目錄。常見的高風險依賴：</p>
<table>
  <thead>
      <tr>
          <th>依賴</th>
          <th>常見位置</th>
          <th>檢查方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>PHPMailer</td>
          <td><code>class.phpmailer.php</code>、<code>PHPMailer/</code></td>
          <td>比對版本號與 GitHub releases 的安全公告</td>
      </tr>
      <tr>
          <td>jQuery</td>
          <td><code>js/jquery.min.js</code></td>
          <td>打開檔案看版本號、低於 3.5.0 有 XSS 漏洞</td>
      </tr>
      <tr>
          <td>CKEditor / TinyMCE</td>
          <td><code>editor/</code>、<code>tinymce/</code></td>
          <td>舊版有 XSS 漏洞、比對 CVE</td>
      </tr>
      <tr>
          <td>WordPress plugins</td>
          <td><code>wp-content/plugins/</code></td>
          <td>用 WPScan 掃描</td>
      </tr>
  </tbody>
</table>
<h3 id="javascript-cdn-引用">JavaScript CDN 引用</h3>
<p>檢查 HTML 裡引用的外部 JavaScript CDN 連結，確認：使用 <code>integrity</code> 屬性（Subresource Integrity）防止 CDN 被竄改、引用的 CDN 是否仍在維護。</p>
<h2 id="掃描工具">掃描工具</h2>
<p>除了手動 grep，可以用工具做自動化掃描。這些工具都從本地或外部執行，不需要在 prod 伺服器上安裝任何東西。</p>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>類型</th>
          <th>用途</th>
          <th>費用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>PHP_CodeSniffer + Security Standard</td>
          <td>靜態分析</td>
          <td>掃描 PHP 程式碼的安全反模式</td>
          <td>免費</td>
      </tr>
      <tr>
          <td>PHPStan / Psalm</td>
          <td>靜態分析</td>
          <td>型別檢查間接發現不安全的資料流</td>
          <td>免費</td>
      </tr>
      <tr>
          <td>WPScan</td>
          <td>WordPress 專用</td>
          <td>掃描 WordPress 核心、plugin、theme 漏洞</td>
          <td>免費（API key 有額度限制）</td>
      </tr>
      <tr>
          <td>Nikto</td>
          <td>Web server 掃描</td>
          <td>從外部掃描 HTTP server 的已知弱點</td>
          <td>免費</td>
      </tr>
      <tr>
          <td>Mozilla Observatory</td>
          <td>線上掃描</td>
          <td>檢查 HTTP security header 設定</td>
          <td>免費</td>
      </tr>
      <tr>
          <td>Snyk</td>
          <td>依賴掃描</td>
          <td>類似 <code>composer audit</code> 但涵蓋更廣</td>
          <td>免費方案可用</td>
      </tr>
  </tbody>
</table>
<p>WordPress 站台的掃描指令：</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"># WPScan 掃描（從本地執行、掃描遠端站台）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">wpscan --url https://example.com --enumerate vp,vt,u
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># vp = vulnerable plugins, vt = vulnerable themes, u = users</span></span></span></code></pre></div><p>所有掃描結果存進 repo 的 <code>security-audit/</code> 目錄，標上日期。這份報告是後續修補計畫的輸入，也是向管理層說明安全狀態的依據。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">無 SSH 的 FTP / 面板管理環境接管</a>：本文的前置步驟（程式碼與資料庫快照）</li>
<li>→ <a href="/blog/infra/takeover/legacy-database-backup-migration/" data-link-title="無 SSH 環境的資料庫備份與變更管理" data-link-desc="在只有 phpMyAdmin 或有限遠端連線的無 SSH 環境裡，怎麼建立可靠的資料庫備份策略、schema 變更紀律與還原演練流程">資料庫備份與變更管理</a>：SQL injection 修復前先備份，避免修補過程造成資料遺失</li>
<li>→ <a href="/blog/infra/takeover/legacy-external-monitoring/" data-link-title="無 SSH 環境的監控與告警" data-link-desc="無 SSH 環境沒辦法裝 agent、沒辦法串 log pipeline，用外部 HTTP check、錯誤追蹤服務與效能基線建立最低成本的監控能力">無 SSH 環境的監控與告警</a>：安全事件的持續偵測與錯誤追蹤</li>
<li>→ <a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a>：credential 管理的系統性設計</li>
<li>→ <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">Backend 模組七：資安與資料保護</a>：應用層安全的完整討論</li>
</ul>
]]></content:encoded></item><item><title>CIDR（Classless Inter-Domain Routing）</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/cidr/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/cidr/</guid><description>&lt;p>CIDR（Classless Inter-Domain Routing）用前綴長度表示一段 IP 地址範圍。&lt;code>10.0.0.0/16&lt;/code> 表示前 16 bit 是網路位址、後 16 bit 是主機位址，提供約六萬五千個可用位址。前綴越短、範圍越大：&lt;code>/16&lt;/code> 比 &lt;code>/24&lt;/code>（約 256 個位址）大 256 倍。&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/vpc/" data-link-title="VPC（Virtual Private Cloud）" data-link-desc="雲端帳號內的一塊邏輯隔離私有網段，是所有網路切分的起點與容器">VPC&lt;/a> 和 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">subnet&lt;/a> 的地址空間都用 CIDR 表示。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>CIDR 是 VPC 規劃的起點決策。建立 VPC 時指定的 CIDR 區塊決定了這個 VPC 能容納多少 subnet 和多少資源。這個決策在建立後難以修改——事後擴張意味著追加 secondary CIDR，而追加的網段在 routing 與服務相容性上有限制。&lt;/p>
&lt;p>在 infra 系列中，CIDR 規劃出現在&lt;a href="https://tarrragon.github.io/blog/infra/03-network-foundation/vpc-subnet-security-group/" data-link-title="網路地基 — VPC、subnet 分層與 security group 設計" data-link-desc="VPC CIDR 規劃、public / private subnet 切分、route table 與 NAT 的可用性成本取捨、security group 最小開放設計，以及 NACL 的定位">模組三：網路地基&lt;/a>的 VPC 段落。Terraform 的 &lt;code>cidrsubnet&lt;/code> 函式可以從 VPC 的 CIDR 自動切出 subnet 的子網段，避免手動計算。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>CIDR 規劃出問題的訊號有兩類。第一類是地址耗盡：subnet 切不出新的子網段、或 subnet 內的 IP 分配用完，新資源無法取得位址。第二類是網段衝突：需要透過 VPC peering、Transit Gateway 或 VPN 互連兩個 VPC 時，發現兩端的 CIDR 重疊，路由無法解析，peering 建立失敗。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>規劃 CIDR 時要決定：&lt;/p>
&lt;ul>
&lt;li>大小：單一環境用 &lt;code>/16&lt;/code> 通常足夠寬裕，切成 &lt;code>/20&lt;/code> 的 subnet 可分配 16 個子網段&lt;/li>
&lt;li>不重疊：多個環境（dev &lt;code>10.0.0.0/16&lt;/code>、staging &lt;code>10.1.0.0/16&lt;/code>、prod &lt;code>10.2.0.0/16&lt;/code>）用連續但不重疊的區段，為日後互連預留空間&lt;/li>
&lt;li>與地端的協調：如果未來可能接 VPN 回地端機房，CIDR 要避開地端已使用的私有網段&lt;/li>
&lt;/ul>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/vpc/" data-link-title="VPC（Virtual Private Cloud）" data-link-desc="雲端帳號內的一塊邏輯隔離私有網段，是所有網路切分的起點與容器">VPC&lt;/a> — 用 CIDR 區塊定義的邏輯隔離網段&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">Subnet&lt;/a> — 從 VPC CIDR 切出的子網段&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>CIDR（Classless Inter-Domain Routing）用前綴長度表示一段 IP 地址範圍。<code>10.0.0.0/16</code> 表示前 16 bit 是網路位址、後 16 bit 是主機位址，提供約六萬五千個可用位址。前綴越短、範圍越大：<code>/16</code> 比 <code>/24</code>（約 256 個位址）大 256 倍。<a href="/blog/infra/knowledge-cards/vpc/" data-link-title="VPC（Virtual Private Cloud）" data-link-desc="雲端帳號內的一塊邏輯隔離私有網段，是所有網路切分的起點與容器">VPC</a> 和 <a href="/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">subnet</a> 的地址空間都用 CIDR 表示。</p>
<h2 id="概念位置">概念位置</h2>
<p>CIDR 是 VPC 規劃的起點決策。建立 VPC 時指定的 CIDR 區塊決定了這個 VPC 能容納多少 subnet 和多少資源。這個決策在建立後難以修改——事後擴張意味著追加 secondary CIDR，而追加的網段在 routing 與服務相容性上有限制。</p>
<p>在 infra 系列中，CIDR 規劃出現在<a href="/blog/infra/03-network-foundation/vpc-subnet-security-group/" data-link-title="網路地基 — VPC、subnet 分層與 security group 設計" data-link-desc="VPC CIDR 規劃、public / private subnet 切分、route table 與 NAT 的可用性成本取捨、security group 最小開放設計，以及 NACL 的定位">模組三：網路地基</a>的 VPC 段落。Terraform 的 <code>cidrsubnet</code> 函式可以從 VPC 的 CIDR 自動切出 subnet 的子網段，避免手動計算。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>CIDR 規劃出問題的訊號有兩類。第一類是地址耗盡：subnet 切不出新的子網段、或 subnet 內的 IP 分配用完，新資源無法取得位址。第二類是網段衝突：需要透過 VPC peering、Transit Gateway 或 VPN 互連兩個 VPC 時，發現兩端的 CIDR 重疊，路由無法解析，peering 建立失敗。</p>
<h2 id="設計責任">設計責任</h2>
<p>規劃 CIDR 時要決定：</p>
<ul>
<li>大小：單一環境用 <code>/16</code> 通常足夠寬裕，切成 <code>/20</code> 的 subnet 可分配 16 個子網段</li>
<li>不重疊：多個環境（dev <code>10.0.0.0/16</code>、staging <code>10.1.0.0/16</code>、prod <code>10.2.0.0/16</code>）用連續但不重疊的區段，為日後互連預留空間</li>
<li>與地端的協調：如果未來可能接 VPN 回地端機房，CIDR 要避開地端已使用的私有網段</li>
</ul>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/vpc/" data-link-title="VPC（Virtual Private Cloud）" data-link-desc="雲端帳號內的一塊邏輯隔離私有網段，是所有網路切分的起點與容器">VPC</a> — 用 CIDR 區塊定義的邏輯隔離網段</li>
<li><a href="/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">Subnet</a> — 從 VPC CIDR 切出的子網段</li>
</ul>
]]></content:encoded></item><item><title>無 SSH 環境的監控與告警</title><link>https://tarrragon.github.io/blog/infra/takeover/legacy-external-monitoring/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/takeover/legacy-external-monitoring/</guid><description>&lt;p>無 SSH 的環境通常不允許安裝監控 agent（Datadog agent、New Relic APM daemon 都需要 daemon 常駐或 root 權限），伺服器的內部指標（CPU、記憶體、磁碟）只能從主機商的控制面板看到靜態數值，沒有告警機制。這種環境的監控策略是從外部觀測——用 HTTP check 確認服務存活、用不需要 agent 的錯誤追蹤服務捕捉例外、用定期量測建立效能基線。每一層都不依賴 server 端安裝任何東西。&lt;/p>
&lt;h2 id="可用性監控外部-http-check">可用性監控（外部 HTTP check）&lt;/h2>
&lt;p>外部 HTTP check 的運作方式是從第三方伺服器定期對目標 URL 發 HTTP 請求，驗證回應狀態碼、回應時間、以及頁面內容是否包含預期的文字。服務掛了或回應異常時觸發告警。&lt;/p>
&lt;h3 id="工具選型">工具選型&lt;/h3>
&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>UptimeRobot&lt;/td>
 &lt;td>50 個 monitor&lt;/td>
 &lt;td>5 分鐘&lt;/td>
 &lt;td>設定簡單、API 可整合&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Better Stack&lt;/td>
 &lt;td>10 個 monitor&lt;/td>
 &lt;td>3 分鐘&lt;/td>
 &lt;td>含 incident 管理與 status page&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Pingdom&lt;/td>
 &lt;td>1 個 monitor（試用）&lt;/td>
 &lt;td>1 分鐘&lt;/td>
 &lt;td>Synthetic monitoring、付費功能完整&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>UptimeRobot 的免費方案對多數無 SSH 環境的站台足夠——50 個 monitor 可以覆蓋一個站台的主要入口。&lt;/p>
&lt;h3 id="該監控哪些-url">該監控哪些 URL&lt;/h3>
&lt;p>選監控目標的判準是「這個 URL 掛了代表哪一層出問題」：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>URL&lt;/th>
 &lt;th>驗證的層次&lt;/th>
 &lt;th>掛了代表什麼&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>首頁&lt;/td>
 &lt;td>web server 存活&lt;/td>
 &lt;td>Apache/Nginx 或 PHP 本身掛了&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>登入頁&lt;/td>
 &lt;td>應用框架正常運作&lt;/td>
 &lt;td>PHP session 或框架初始化失敗&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>一個資料庫相依的頁面&lt;/td>
 &lt;td>DB 連線存活&lt;/td>
 &lt;td>MySQL 掛了或連線數滿了&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>金流 callback URL&lt;/td>
 &lt;td>第三方服務可達&lt;/td>
 &lt;td>付款回調會失敗、訂單狀態卡住&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每個 monitor 設兩層閾值：回應時間 &amp;gt;3 秒為警告（效能劣化的早期訊號）、&amp;gt;10 秒或非 200 狀態碼為嚴重（服務已不可用）。&lt;/p>
&lt;h3 id="告警通道">告警通道&lt;/h3>
&lt;p>免費方案通常支援 email 與 webhook（可串 Slack）。付費方案加 SMS 和電話。接手初期用 email + Slack 即可，等確認告警不會誤報後再決定要不要升級到 SMS。頻繁誤報會讓團隊學會忽略通知——閾值要設在「真的有問題才響」的水位。&lt;/p>
&lt;h2 id="錯誤追蹤不需要-server-agent">錯誤追蹤（不需要 server agent）&lt;/h2>
&lt;p>PHP 的錯誤追蹤在無 SSH 環境有兩條路徑：server 端用 PHP 內建的 error_log、client 端用不需要安裝的 SaaS 服務。&lt;/p>
&lt;h3 id="php-error_logserver-端不需-ssh">PHP error_log（server 端、不需 SSH）&lt;/h3>
&lt;p>PHP 可以把錯誤寫進檔案，設定方式是在 &lt;code>.htaccess&lt;/code> 或 &lt;code>php.ini&lt;/code>（如果主機允許）加入：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-apache" data-lang="apache">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c"># .htaccess — 啟用錯誤記錄、關閉畫面顯示&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">php_flag&lt;/span> display_errors &lt;span class="k">off&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nb">php_flag&lt;/span> log_errors &lt;span class="k">on&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="nb">php_value&lt;/span> error_log &lt;span class="sx">/home/user/logs/php_errors.log&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>error_log&lt;/code> 的路徑要指向 web root 之外的目錄，避免錯誤訊息被外部存取。設定後透過 FTP 定期下載這個檔案、用 grep 篩選嚴重等級：&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"># 篩選 Fatal 和 Warning（過濾掉 Notice / Deprecated）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">grep -E &lt;span class="s2">&amp;#34;Fatal|Warning&amp;#34;&lt;/span> php_errors.log &lt;span class="p">|&lt;/span> tail -50&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="sentryphp--javascript不需-server-agent">Sentry（PHP + JavaScript、不需 server agent）&lt;/h3>
&lt;p>Sentry 的 PHP SDK 不需要系統層 agent，只需要在應用程式碼裡初始化：&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">composer require sentry/sentry&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-php" data-lang="php">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 在應用程式進入點（如 index.php 最前面）加入
&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="nx">\Sentry\init&lt;/span>&lt;span class="p">([&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="s1">&amp;#39;dsn&amp;#39;&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="s1">&amp;#39;https://examplekey@o0.ingest.sentry.io/0&amp;#39;&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 class="s1">&amp;#39;traces_sample_rate&amp;#39;&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="mf">0.1&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">]);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式碼會在 PHP 拋出未捕捉的例外或觸發 error 時，把錯誤資訊（stack trace、request context、使用者資訊）透過 HTTP 送到 Sentry 的 SaaS 平台。免費方案每月 5,000 個事件，對流量不大的流量不大的站台通常足夠。&lt;/p></description><content:encoded><![CDATA[<p>無 SSH 的環境通常不允許安裝監控 agent（Datadog agent、New Relic APM daemon 都需要 daemon 常駐或 root 權限），伺服器的內部指標（CPU、記憶體、磁碟）只能從主機商的控制面板看到靜態數值，沒有告警機制。這種環境的監控策略是從外部觀測——用 HTTP check 確認服務存活、用不需要 agent 的錯誤追蹤服務捕捉例外、用定期量測建立效能基線。每一層都不依賴 server 端安裝任何東西。</p>
<h2 id="可用性監控外部-http-check">可用性監控（外部 HTTP check）</h2>
<p>外部 HTTP check 的運作方式是從第三方伺服器定期對目標 URL 發 HTTP 請求，驗證回應狀態碼、回應時間、以及頁面內容是否包含預期的文字。服務掛了或回應異常時觸發告警。</p>
<h3 id="工具選型">工具選型</h3>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>免費方案</th>
          <th>檢查間隔</th>
          <th>特色</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>UptimeRobot</td>
          <td>50 個 monitor</td>
          <td>5 分鐘</td>
          <td>設定簡單、API 可整合</td>
      </tr>
      <tr>
          <td>Better Stack</td>
          <td>10 個 monitor</td>
          <td>3 分鐘</td>
          <td>含 incident 管理與 status page</td>
      </tr>
      <tr>
          <td>Pingdom</td>
          <td>1 個 monitor（試用）</td>
          <td>1 分鐘</td>
          <td>Synthetic monitoring、付費功能完整</td>
      </tr>
  </tbody>
</table>
<p>UptimeRobot 的免費方案對多數無 SSH 環境的站台足夠——50 個 monitor 可以覆蓋一個站台的主要入口。</p>
<h3 id="該監控哪些-url">該監控哪些 URL</h3>
<p>選監控目標的判準是「這個 URL 掛了代表哪一層出問題」：</p>
<table>
  <thead>
      <tr>
          <th>URL</th>
          <th>驗證的層次</th>
          <th>掛了代表什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>首頁</td>
          <td>web server 存活</td>
          <td>Apache/Nginx 或 PHP 本身掛了</td>
      </tr>
      <tr>
          <td>登入頁</td>
          <td>應用框架正常運作</td>
          <td>PHP session 或框架初始化失敗</td>
      </tr>
      <tr>
          <td>一個資料庫相依的頁面</td>
          <td>DB 連線存活</td>
          <td>MySQL 掛了或連線數滿了</td>
      </tr>
      <tr>
          <td>金流 callback URL</td>
          <td>第三方服務可達</td>
          <td>付款回調會失敗、訂單狀態卡住</td>
      </tr>
  </tbody>
</table>
<p>每個 monitor 設兩層閾值：回應時間 &gt;3 秒為警告（效能劣化的早期訊號）、&gt;10 秒或非 200 狀態碼為嚴重（服務已不可用）。</p>
<h3 id="告警通道">告警通道</h3>
<p>免費方案通常支援 email 與 webhook（可串 Slack）。付費方案加 SMS 和電話。接手初期用 email + Slack 即可，等確認告警不會誤報後再決定要不要升級到 SMS。頻繁誤報會讓團隊學會忽略通知——閾值要設在「真的有問題才響」的水位。</p>
<h2 id="錯誤追蹤不需要-server-agent">錯誤追蹤（不需要 server agent）</h2>
<p>PHP 的錯誤追蹤在無 SSH 環境有兩條路徑：server 端用 PHP 內建的 error_log、client 端用不需要安裝的 SaaS 服務。</p>
<h3 id="php-error_logserver-端不需-ssh">PHP error_log（server 端、不需 SSH）</h3>
<p>PHP 可以把錯誤寫進檔案，設定方式是在 <code>.htaccess</code> 或 <code>php.ini</code>（如果主機允許）加入：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-apache" data-lang="apache"><span class="line"><span class="ln">1</span><span class="cl"><span class="c"># .htaccess — 啟用錯誤記錄、關閉畫面顯示</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">php_flag</span> display_errors <span class="k">off</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nb">php_flag</span> log_errors <span class="k">on</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nb">php_value</span> error_log <span class="sx">/home/user/logs/php_errors.log</span></span></span></code></pre></div><p><code>error_log</code> 的路徑要指向 web root 之外的目錄，避免錯誤訊息被外部存取。設定後透過 FTP 定期下載這個檔案、用 grep 篩選嚴重等級：</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"># 篩選 Fatal 和 Warning（過濾掉 Notice / Deprecated）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">grep -E <span class="s2">&#34;Fatal|Warning&#34;</span> php_errors.log <span class="p">|</span> tail -50</span></span></code></pre></div><h3 id="sentryphp--javascript不需-server-agent">Sentry（PHP + JavaScript、不需 server agent）</h3>
<p>Sentry 的 PHP SDK 不需要系統層 agent，只需要在應用程式碼裡初始化：</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">composer require sentry/sentry</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 在應用程式進入點（如 index.php 最前面）加入
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nx">\Sentry\init</span><span class="p">([</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s1">&#39;dsn&#39;</span> <span class="o">=&gt;</span> <span class="s1">&#39;https://examplekey@o0.ingest.sentry.io/0&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="s1">&#39;traces_sample_rate&#39;</span> <span class="o">=&gt;</span> <span class="mf">0.1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">]);</span></span></span></code></pre></div><p>這段程式碼會在 PHP 拋出未捕捉的例外或觸發 error 時，把錯誤資訊（stack trace、request context、使用者資訊）透過 HTTP 送到 Sentry 的 SaaS 平台。免費方案每月 5,000 個事件，對流量不大的流量不大的站台通常足夠。</p>
<p>前端的 JavaScript 錯誤追蹤更簡單——在 HTML 的 <code>&lt;head&gt;</code> 加一行 Sentry 的 CDN script，不需要修改 server 設定：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">script</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="na">src</span><span class="o">=</span><span class="s">&#34;https://browser.sentry-cdn.com/8.x/bundle.tracing.min.js&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="na">crossorigin</span><span class="o">=</span><span class="s">&#34;anonymous&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">&gt;&lt;/</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">&lt;</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nx">Sentry</span><span class="p">.</span><span class="nx">init</span><span class="p">({</span> <span class="nx">dsn</span><span class="o">:</span> <span class="s2">&#34;https://examplekey@o0.ingest.sentry.io/0&#34;</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span></span></span></code></pre></div><p>JavaScript SDK 捕捉的是瀏覽器端的錯誤——DOM 操作失敗、AJAX 請求異常、未處理的 Promise rejection。跟 PHP 端的 SDK 各抓不同層的問題。</p>
<h3 id="error_log-vs-sentry-的分工">error_log vs Sentry 的分工</h3>
<p>error_log 是 server 端的文字紀錄，需要手動下載和篩選；Sentry 有搜尋、聚合、告警和 stack trace 視覺化。兩者互補：error_log 保留完整紀錄作為備份、Sentry 提供可操作的告警和分析介面。error_log 在 PHP 嚴重到 Sentry SDK 自己也掛掉的情況下仍然有紀錄。</p>
<h2 id="效能基線">效能基線</h2>
<p>效能基線的責任是回答「正常狀態下回應時間是多少」，讓異常浮現時有比對的參考。沒有基線時，回應時間從 200ms 劣化到 2 秒、但因為「好像一直都這麼慢」而沒人察覺。</p>
<h3 id="量測方式">量測方式</h3>
<p>最簡單的量測是從本機或 CI 環境定期 curl：</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"># 量測回應時間（秒），只看 time_total</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">curl -o /dev/null -s -w <span class="s2">&#34;%{time_total}\n&#34;</span> https://example.com</span></span></code></pre></div><p>把這段做成 GitHub Actions 的 scheduled workflow，每小時跑一次、把結果追加到 repo 的 CSV 檔案，就有了一條回應時間的趨勢線：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">schedule</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span>- <span class="nt">cron</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;0 * * * *&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="nt">perf-check</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="sd">          TIME=$(curl -o /dev/null -s -w &#34;%{time_total}&#34; https://example.com)
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="sd">          echo &#34;$(date -u +%Y-%m-%dT%H:%M:%SZ),$TIME&#34; &gt;&gt; perf-log.csv</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">git add perf-log.csv &amp;&amp; git commit -m &#34;perf check&#34; &amp;&amp; git push</span></span></span></code></pre></div><p>這條趨勢線本身就是監控：回應時間連續幾個小時上升，代表某個東西在劣化（DB 查詢變慢、磁碟快滿、PHP process 卡住）。</p>
<h3 id="頁面效能">頁面效能</h3>
<p>Google PageSpeed Insights（免費、不需安裝）分析前端載入效能，包含 LCP、CLS、FID 等 Core Web Vitals。對 legacy PHP 站台有用的是它會指出渲染阻塞的 CSS/JS、未壓縮的圖片、缺少快取 header 這類不需要動後端就能改善的問題。</p>
<h3 id="資料庫效能需改-code">資料庫效能（需改 code）</h3>
<p>如果能修改 PHP 程式碼，在資料庫查詢前後加計時、超過閾值就寫 error_log：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="nv">$start</span> <span class="o">=</span> <span class="nx">microtime</span><span class="p">(</span><span class="k">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nv">$result</span> <span class="o">=</span> <span class="nv">$pdo</span><span class="o">-&gt;</span><span class="na">query</span><span class="p">(</span><span class="nv">$sql</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nv">$elapsed</span> <span class="o">=</span> <span class="nx">microtime</span><span class="p">(</span><span class="k">true</span><span class="p">)</span> <span class="o">-</span> <span class="nv">$start</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$elapsed</span> <span class="o">&gt;</span> <span class="mf">1.0</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">error_log</span><span class="p">(</span><span class="nx">sprintf</span><span class="p">(</span><span class="s2">&#34;Slow query (%.2fs): %s&#34;</span><span class="p">,</span> <span class="nv">$elapsed</span><span class="p">,</span> <span class="nx">substr</span><span class="p">(</span><span class="nv">$sql</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">200</span><span class="p">)));</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>累積一段時間後，從 error_log 裡 grep <code>Slow query</code> 就能看出哪些查詢是效能瓶頸。這不是完整的 APM，但在沒有 agent 的環境裡是最接近 slow query log 的替代方案。</p>
<h2 id="帳單與流量異常偵測">帳單與流量異常偵測</h2>
<p>這類主機通常按流量或磁碟空間計費，異常流量（bot 掃描、DDoS、爬蟲）會讓帳單飆高或觸發主機商的流量限制。</p>
<h3 id="流量監控">流量監控</h3>
<p>主機控制面板（cPanel 的 AWStats 或 Webalizer）提供基本的流量分析——top referrer、top page、bot 流量佔比。每月檢查一次，重點看：</p>
<ul>
<li>bot 流量佔比是否異常高（&gt;50% 通常代表有爬蟲）</li>
<li>單一 IP 的請求量是否異常集中</li>
<li>帶寬使用量的趨勢（月增超過 20% 且沒有對應的業務成長要查原因）</li>
</ul>
<h3 id="客戶端分析不需-server-安裝">客戶端分析（不需 server 安裝）</h3>
<p>Google Analytics 或 Plausible（隱私友善替代品）只需要在頁面加一段 JavaScript。它們追蹤的是真實使用者的瀏覽行為（page view、session、referrer），跟 server 端的 access log 互補：server log 看所有請求（含 bot），GA/Plausible 只看真實瀏覽器。</p>
<h3 id="cloudflare-免費方案">Cloudflare 免費方案</h3>
<p>如果 DNS 可以切換，把 domain 接上 Cloudflare（免費方案）提供三個能力而不需要動 server：</p>
<ul>
<li><strong>流量分析</strong>：比 AWStats 更即時、有地理分佈和 bot 過濾</li>
<li><strong>DDoS 保護</strong>：基本的 Layer 3/4 防護免費</li>
<li><strong>CDN 快取</strong>：靜態資源（CSS/JS/圖片）由 Cloudflare 快取、減輕 origin 負擔</li>
</ul>
<p>設定只需要把 domain 的 nameserver 改成 Cloudflare 提供的 NS、原始 DNS record 在 Cloudflare 重建。對無 SSH 環境的站台來說這是投資報酬率最高的單一改善動作——不動 server、不改 code、但同時拿到流量可見性和基本防護。</p>
<h2 id="整合成最低成本監控方案">整合成最低成本監控方案</h2>
<p>按投入程度分三層，每一層都包含上一層：</p>
<table>
  <thead>
      <tr>
          <th>層級</th>
          <th>組成</th>
          <th>月費</th>
          <th>覆蓋</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Tier 1（零成本）</td>
          <td>UptimeRobot free + Sentry free + Google Analytics</td>
          <td>$0</td>
          <td>可用性 + 錯誤追蹤 + 流量</td>
      </tr>
      <tr>
          <td>Tier 2（最低付費）</td>
          <td>+Better Stack ($19/mo) + Cloudflare free</td>
          <td>~$19</td>
          <td>+incident 管理 + 流量分析 + CDN</td>
      </tr>
      <tr>
          <td>Tier 3（升級路徑）</td>
          <td>遷移到 VPS → 安裝 APM agent → 對齊模組六的 IaC 監控</td>
          <td>依 VPS</td>
          <td>完整 server 端可觀測性</td>
      </tr>
  </tbody>
</table>
<p>Tier 1 在接手當天就能建好（30 分鐘設定 UptimeRobot + Sentry + GA），零成本提供基本的「服務掛了會知道、程式碼出錯會收到、流量異常看得到」的覆蓋。Tier 2 適合站台有營收或合約 SLA 要求時。Tier 3 是離開無 SSH 環境後的正規化路徑，監控從外部觀測升級為 server 端全面可觀測性，見<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/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">無 SSH 的 FTP / 面板管理環境接管</a>：本篇的母篇，監控建立在盤點與本地環境之後</li>
<li>→ <a href="/blog/infra/takeover/legacy-code-versioning-deployment/" data-link-title="程式碼版控與 FTP 部署紀律" data-link-desc="無 SSH 環境的 PHP 專案的程式碼怎麼從 FTP 拉回來建 Git repo、設定檔怎麼分離、FTP 部署怎麼建立可追蹤的流程、以及怎麼用 CI 取代手動上傳">程式碼版控與 FTP 部署紀律</a>：部署後的驗證用監控確認服務正常</li>
<li>→ <a href="/blog/infra/takeover/legacy-php-security-audit/" data-link-title="Legacy PHP 的安全盤點" data-link-desc="接手 legacy PHP 專案後的系統性安全審查：credential 掃描、PHP 版本風險、常見漏洞模式的 grep 偵測、.htaccess 防線、檔案權限、外部依賴與掃描工具">Legacy PHP 的安全盤點</a>：錯誤追蹤可能暴露安全問題（未捕捉的 SQL error、路徑洩漏）</li>
<li>→ <a href="/blog/infra/06-observability-logging/" data-link-title="模組六：可觀測性與 log 一併寫進 code" data-link-desc="log group、metric、alarm 跟基礎設施同生命週期管理，出事時追得到查得到">模組六：可觀測性與 log</a>：Tier 3 升級路徑的目標——有 server 存取後的 IaC 監控</li>
<li>→ <a href="/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">Monitoring 監控體系</a>：客戶端行為訊號（SDK / Collector）的完整討論</li>
</ul>
]]></content:encoded></item><item><title>IAM（Identity and Access Management）</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/iam/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/iam/</guid><description>&lt;p>IAM（Identity and Access Management）是雲端平台用來回答「某個身分能不能對某個資源做某件事」的授權系統。它把授權拆成三個獨立的元件：identity（身分，發起動作的主體）、policy（政策，描述「允許或拒絕對哪些資源做哪些動作」的規則）、role（角色，一組可以被臨時取得的權限集合）。這三者的分工是後面所有憑證決策的前提。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>IAM 是&lt;a href="https://tarrragon.github.io/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">模組二：身分與憑證地基&lt;/a>的核心機制。它決定了誰能動什麼——人、服務、CI pipeline 各拿剛好夠用的權限（最小權限），憑證有明確的生命週期。身分層失守的代價在五個 infra 責任面向中最高，因為它是其他所有資源的閘門。&lt;/p>
&lt;p>在 infra 系列中，IAM 的設計從三個維度展開：最小權限的持續收斂（不是一次設定就結束）、用 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/oidc/" data-link-title="OIDC 聯合" data-link-desc="讓 CI/CD 平台用短期 token 取代長期 access key 存取雲端資源的身分聯合機制">OIDC&lt;/a> 短期憑證取代長期 access key、以及跨帳號的權限邊界（SCP + Permissions Boundary）。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>IAM 需要關注的訊號：某個 role 的 policy 有 &lt;code>*:*&lt;/code> 或 &lt;code>AdministratorAccess&lt;/code>（權限過大）；credential report 顯示有長期 access key 超過 90 天未輪替（憑證散落風險）；Access Analyzer 顯示某個 role 的實際使用 action 遠少於授予的 action（權限擴散）；dev 環境的 CI role 能列出 production 的資源（環境隔離失效）。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>IAM 設計時要決定：&lt;/p>
&lt;ul>
&lt;li>身分類型區分：人用 SSO 登入（強制 MFA）、雲上服務用 instance profile / task role、雲外 CI 用 OIDC 聯合&lt;/li>
&lt;li>權限分級：admin / operator / viewer 三級，見&lt;a href="https://tarrragon.github.io/blog/infra/02-identity-credentials/team-access-management/" data-link-title="團隊權限分級與存取管理" data-link-desc="用 admin / operator / viewer 三級劃分團隊成員的雲端操作權限，設計臨時提權流程、定期 access review 節奏，以及 contractor 與外部 vendor 的存取邊界">團隊權限分級&lt;/a>&lt;/li>
&lt;li>環境隔離：每個環境的 role 不能存取其他環境的資源&lt;/li>
&lt;li>收斂節奏：定期用 Access Analyzer 觀察實際使用的 action，收掉沒用到的權限&lt;/li>
&lt;/ul>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/oidc/" data-link-title="OIDC 聯合" data-link-desc="讓 CI/CD 平台用短期 token 取代長期 access key 存取雲端資源的身分聯合機制">OIDC&lt;/a> — 用短期 token 取代長期 access key 的聯合機制&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group&lt;/a> — 網路層的存取控制（IAM 是 API 層的存取控制）&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/cloudtrail/" data-link-title="CloudTrail" data-link-desc="AWS 的 API 層稽核日誌服務，記錄誰在什麼時候對什麼資源做了什麼操作">CloudTrail&lt;/a> — 記錄 IAM 身分的 API 呼叫歷史&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>IAM（Identity and Access Management）是雲端平台用來回答「某個身分能不能對某個資源做某件事」的授權系統。它把授權拆成三個獨立的元件：identity（身分，發起動作的主體）、policy（政策，描述「允許或拒絕對哪些資源做哪些動作」的規則）、role（角色，一組可以被臨時取得的權限集合）。這三者的分工是後面所有憑證決策的前提。</p>
<h2 id="概念位置">概念位置</h2>
<p>IAM 是<a href="/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">模組二：身分與憑證地基</a>的核心機制。它決定了誰能動什麼——人、服務、CI pipeline 各拿剛好夠用的權限（最小權限），憑證有明確的生命週期。身分層失守的代價在五個 infra 責任面向中最高，因為它是其他所有資源的閘門。</p>
<p>在 infra 系列中，IAM 的設計從三個維度展開：最小權限的持續收斂（不是一次設定就結束）、用 <a href="/blog/infra/knowledge-cards/oidc/" data-link-title="OIDC 聯合" data-link-desc="讓 CI/CD 平台用短期 token 取代長期 access key 存取雲端資源的身分聯合機制">OIDC</a> 短期憑證取代長期 access key、以及跨帳號的權限邊界（SCP + Permissions Boundary）。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>IAM 需要關注的訊號：某個 role 的 policy 有 <code>*:*</code> 或 <code>AdministratorAccess</code>（權限過大）；credential report 顯示有長期 access key 超過 90 天未輪替（憑證散落風險）；Access Analyzer 顯示某個 role 的實際使用 action 遠少於授予的 action（權限擴散）；dev 環境的 CI role 能列出 production 的資源（環境隔離失效）。</p>
<h2 id="設計責任">設計責任</h2>
<p>IAM 設計時要決定：</p>
<ul>
<li>身分類型區分：人用 SSO 登入（強制 MFA）、雲上服務用 instance profile / task role、雲外 CI 用 OIDC 聯合</li>
<li>權限分級：admin / operator / viewer 三級，見<a href="/blog/infra/02-identity-credentials/team-access-management/" data-link-title="團隊權限分級與存取管理" data-link-desc="用 admin / operator / viewer 三級劃分團隊成員的雲端操作權限，設計臨時提權流程、定期 access review 節奏，以及 contractor 與外部 vendor 的存取邊界">團隊權限分級</a></li>
<li>環境隔離：每個環境的 role 不能存取其他環境的資源</li>
<li>收斂節奏：定期用 Access Analyzer 觀察實際使用的 action，收掉沒用到的權限</li>
</ul>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/oidc/" data-link-title="OIDC 聯合" data-link-desc="讓 CI/CD 平台用短期 token 取代長期 access key 存取雲端資源的身分聯合機制">OIDC</a> — 用短期 token 取代長期 access key 的聯合機制</li>
<li><a href="/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group</a> — 網路層的存取控制（IAM 是 API 層的存取控制）</li>
<li><a href="/blog/infra/knowledge-cards/cloudtrail/" data-link-title="CloudTrail" data-link-desc="AWS 的 API 層稽核日誌服務，記錄誰在什麼時候對什麼資源做了什麼操作">CloudTrail</a> — 記錄 IAM 身分的 API 呼叫歷史</li>
</ul>
]]></content:encoded></item><item><title>Route Table</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/route-table/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/route-table/</guid><description>&lt;p>Route table 是一組轉送規則，掛在 subnet 上，定義「目的地是某個網段的封包該往哪送」。每個 subnet 關聯一張 route table，封包離開 subnet 時逐條比對規則、走最長前綴匹配的那一條。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Route table 決定了一個 subnet 是 public 還是 private。技術上的差別只有一行：route table 裡有沒有一條 &lt;code>0.0.0.0/0 → Internet Gateway&lt;/code> 的預設路由。有這條路由的 subnet 是 public（封包可以直接出網、外部也可以連入）；把預設路由指向 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="讓 private subnet 的資源主動對外連線、同時不被外部入站觸及的網路地址轉換服務">NAT Gateway&lt;/a> 的 subnet 是 private（只能主動出站、外部無法入站）。subnet 本身的屬性不含 public/private 標記，性質完全由關聯的 route table 賦予。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>private subnet 的服務突然拉不到外部套件或第三方 API 全部逾時時，排查路徑的第一步是檢查該 subnet 關聯的 route table：預設路由是否指向健康的 NAT Gateway。如果只有某一個可用區的節點受影響，通常是那一區的 NAT Gateway 或其所在 subnet 出狀況。&lt;/p>
&lt;p>另一個常見訊號是新建的 subnet 沒有手動關聯 route table，被 VPC 的 main route table 自動關聯——main route table 的預設設定可能跟預期不符。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>使用 route table 時要決定：每個 subnet 的預設路由指向什麼（Internet Gateway / NAT Gateway / Transit Gateway / 無）、VPC 內部流量是否需要自訂路由（peering、endpoint）、以及 main route table 是否該保持空白以避免新 subnet 意外取得對外路由。每一條路由的目的地網段和目標要在 IaC 裡明確描述，讓 route table 的語意可被 review。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">Subnet&lt;/a> — route table 掛在 subnet 上&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="讓 private subnet 的資源主動對外連線、同時不被外部入站觸及的網路地址轉換服務">NAT&lt;/a> — private subnet 的預設路由目標&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/vpc/" data-link-title="VPC（Virtual Private Cloud）" data-link-desc="雲端帳號內的一塊邏輯隔離私有網段，是所有網路切分的起點與容器">VPC&lt;/a> — route table 存在於 VPC 內&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Route table 是一組轉送規則，掛在 subnet 上，定義「目的地是某個網段的封包該往哪送」。每個 subnet 關聯一張 route table，封包離開 subnet 時逐條比對規則、走最長前綴匹配的那一條。</p>
<h2 id="概念位置">概念位置</h2>
<p>Route table 決定了一個 subnet 是 public 還是 private。技術上的差別只有一行：route table 裡有沒有一條 <code>0.0.0.0/0 → Internet Gateway</code> 的預設路由。有這條路由的 subnet 是 public（封包可以直接出網、外部也可以連入）；把預設路由指向 <a href="/blog/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="讓 private subnet 的資源主動對外連線、同時不被外部入站觸及的網路地址轉換服務">NAT Gateway</a> 的 subnet 是 private（只能主動出站、外部無法入站）。subnet 本身的屬性不含 public/private 標記，性質完全由關聯的 route table 賦予。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>private subnet 的服務突然拉不到外部套件或第三方 API 全部逾時時，排查路徑的第一步是檢查該 subnet 關聯的 route table：預設路由是否指向健康的 NAT Gateway。如果只有某一個可用區的節點受影響，通常是那一區的 NAT Gateway 或其所在 subnet 出狀況。</p>
<p>另一個常見訊號是新建的 subnet 沒有手動關聯 route table，被 VPC 的 main route table 自動關聯——main route table 的預設設定可能跟預期不符。</p>
<h2 id="設計責任">設計責任</h2>
<p>使用 route table 時要決定：每個 subnet 的預設路由指向什麼（Internet Gateway / NAT Gateway / Transit Gateway / 無）、VPC 內部流量是否需要自訂路由（peering、endpoint）、以及 main route table 是否該保持空白以避免新 subnet 意外取得對外路由。每一條路由的目的地網段和目標要在 IaC 裡明確描述，讓 route table 的語意可被 review。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">Subnet</a> — route table 掛在 subnet 上</li>
<li><a href="/blog/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="讓 private subnet 的資源主動對外連線、同時不被外部入站觸及的網路地址轉換服務">NAT</a> — private subnet 的預設路由目標</li>
<li><a href="/blog/infra/knowledge-cards/vpc/" data-link-title="VPC（Virtual Private Cloud）" data-link-desc="雲端帳號內的一塊邏輯隔離私有網段，是所有網路切分的起點與容器">VPC</a> — route table 存在於 VPC 內</li>
</ul>
]]></content:encoded></item><item><title>SCP (Service Control Policy)</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/scp/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/scp/</guid><description>&lt;p>Service Control Policy（SCP）是 AWS Organizations 裡套用在 OU 或帳號上的權限上限。SCP 不授予權限——它設定一個天花板，限制該範圍內的 IAM 能做什麼。即使帳號內有 &lt;code>AdministratorAccess&lt;/code> 的 IAM role，SCP deny 的操作仍然被擋下。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>SCP 跟 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM&lt;/a> policy 的關係是交集而非覆蓋：一個操作要同時被 SCP 允許且被 IAM policy 允許才會生效。SCP 的設計目的是讓組織管理者設定「即使帳號管理員也做不了」的護欄，常見的 day-1 SCP 包括：禁止關閉 CloudTrail、禁止離開指定 region、禁止刪除 VPC Flow Logs。&lt;/p>
&lt;p>SCP 套用在 OU 上時會繼承給 OU 下所有帳號和子 OU。Management account（Organizations 的根帳號）不受 SCP 約束——這是設計上的逃生門，也是 management account 應該盡量不跑 workload 的原因。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>當帳號內的 IAM role 執行某個操作時收到 &lt;code>AccessDeniedException&lt;/code>、但該 role 的 IAM policy 確實允許該操作，SCP 是第一個要檢查的位置。另一個訊號是新帳號加入 OU 後某些原本能用的服務突然不可用——通常是繼承了 OU 的 SCP deny list。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>SCP 的設計要決定：用 deny-list 策略（預設全開、明確列出禁止項）還是 allow-list 策略（預設全關、明確列出允許項）。Deny-list 較常見也較易維護——只需要管「哪些該禁」。Allow-list 更嚴格但維護成本高——每次有新服務需求都要更新 SCP。&lt;/p>
&lt;p>套用 SCP 前要確認不會擋到正在運作的服務——先在 sandbox OU 測試，確認既有 workload 不受影響再推到 workload OU。SCP 的變更跟 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM&lt;/a> 一樣要走 PR review。跨帳號策略的完整設計見&lt;a href="https://tarrragon.github.io/blog/infra/02-identity-credentials/multi-account-strategy/" data-link-title="跨帳號策略 — Organizations、SCP 與帳號工廠" data-link-desc="用 AWS Organizations 把環境拆成獨立帳號、用 SCP 設定連管理員都越不過的護欄、用帳號工廠讓每個新帳號自帶安全基線">跨帳號策略文章&lt;/a>。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM&lt;/a> — SCP 是 IAM policy 的上層天花板&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> — SCP 靠 OU 結構實現環境之間的權限隔離&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Service Control Policy（SCP）是 AWS Organizations 裡套用在 OU 或帳號上的權限上限。SCP 不授予權限——它設定一個天花板，限制該範圍內的 IAM 能做什麼。即使帳號內有 <code>AdministratorAccess</code> 的 IAM role，SCP deny 的操作仍然被擋下。</p>
<h2 id="概念位置">概念位置</h2>
<p>SCP 跟 <a href="/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM</a> policy 的關係是交集而非覆蓋：一個操作要同時被 SCP 允許且被 IAM policy 允許才會生效。SCP 的設計目的是讓組織管理者設定「即使帳號管理員也做不了」的護欄，常見的 day-1 SCP 包括：禁止關閉 CloudTrail、禁止離開指定 region、禁止刪除 VPC Flow Logs。</p>
<p>SCP 套用在 OU 上時會繼承給 OU 下所有帳號和子 OU。Management account（Organizations 的根帳號）不受 SCP 約束——這是設計上的逃生門，也是 management account 應該盡量不跑 workload 的原因。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>當帳號內的 IAM role 執行某個操作時收到 <code>AccessDeniedException</code>、但該 role 的 IAM policy 確實允許該操作，SCP 是第一個要檢查的位置。另一個訊號是新帳號加入 OU 後某些原本能用的服務突然不可用——通常是繼承了 OU 的 SCP deny list。</p>
<h2 id="設計責任">設計責任</h2>
<p>SCP 的設計要決定：用 deny-list 策略（預設全開、明確列出禁止項）還是 allow-list 策略（預設全關、明確列出允許項）。Deny-list 較常見也較易維護——只需要管「哪些該禁」。Allow-list 更嚴格但維護成本高——每次有新服務需求都要更新 SCP。</p>
<p>套用 SCP 前要確認不會擋到正在運作的服務——先在 sandbox OU 測試，確認既有 workload 不受影響再推到 workload OU。SCP 的變更跟 <a href="/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM</a> 一樣要走 PR review。跨帳號策略的完整設計見<a href="/blog/infra/02-identity-credentials/multi-account-strategy/" data-link-title="跨帳號策略 — Organizations、SCP 與帳號工廠" data-link-desc="用 AWS Organizations 把環境拆成獨立帳號、用 SCP 設定連管理員都越不過的護欄、用帳號工廠讓每個新帳號自帶安全基線">跨帳號策略文章</a>。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM</a> — SCP 是 IAM policy 的上層天花板</li>
<li><a href="/blog/infra/knowledge-cards/environment-separation/" data-link-title="環境分離" data-link-desc="把同一套基礎設施定義複製成多份隔離的執行實例，各有獨立 state 與故障半徑">環境分離</a> — SCP 靠 OU 結構實現環境之間的權限隔離</li>
</ul>
]]></content:encoded></item><item><title>Remote State Backend</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/remote-state-backend/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/remote-state-backend/</guid><description>&lt;p>Remote state backend 是 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> 的共享儲存機制。它要同時滿足三件事：持久保存（不會因為某台筆電故障而遺失）、防止並行寫入衝突（兩個人不能同時 apply）、以及保護敏感內容（state 內含資源的真實屬性，可能包含密碼或 key）。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>State 是 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC&lt;/a> 工具對現實的唯一記憶。把它放在本地檔案系統等於把整個基礎設施的記憶綁在一台機器上——換人接手、換台電腦、或兩人同時 apply，記憶就分裂了。Remote state backend 解決的是「讓 state 變成團隊共用的、有保護的事實來源」。&lt;/p>
&lt;p>典型的自管組合是 S3（存放 state 檔、開 versioning 和加密）加上 DynamoDB（提供 apply 時的並行鎖）。託管服務（Terraform Cloud、Spacelift）把存放、鎖和加密包在一起，用月費換掉配置和維運負擔。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>本地 state 的失敗訊號是：跑 &lt;code>terraform plan&lt;/code> 時出現「想刪掉」明知存在的資源——通常代表本地 state 跟雲端實際狀態已經脫節。另一個訊號是兩個人同時跑 apply 但沒有任何鎖機制阻擋——結果是互相覆蓋對方的變更，state 進入不一致狀態。&lt;/p>
&lt;p>Remote backend 設定後，如果 &lt;code>terraform init&lt;/code> 提示 state 遷移確認，代表正在從本地搬到遠端——這是正確的一次性操作，但搬遷過程中不能有其他人在 apply。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>選擇 remote state backend 時要決定：自管還是託管（取決於團隊規模和維運餘裕）、state bucket 的加密與存取控制（誰能讀 state 等於誰能看到所有資源的敏感屬性）、versioning 是否開啟（是 state 回捲的唯一退路）、以及鎖表的設定（DynamoDB 的表名和 partition key）。&lt;/p>
&lt;p>State 絕不能進 git——它含明文敏感值，推進版控等於把密碼寫進每個 clone 的歷史裡。Backend 設定本身（bucket name、region、鎖表名稱）寫在 HCL 裡進 git，state 檔本身只存在 backend 裡。&lt;/p>
&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> — remote backend 存放的對象&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/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC&lt;/a> — remote state backend 是 IaC 工具的基礎設施&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Remote state backend 是 IaC 工具用來存放 <a href="/blog/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">state</a> 的共享儲存機制。它要同時滿足三件事：持久保存（不會因為某台筆電故障而遺失）、防止並行寫入衝突（兩個人不能同時 apply）、以及保護敏感內容（state 內含資源的真實屬性，可能包含密碼或 key）。</p>
<h2 id="概念位置">概念位置</h2>
<p>State 是 <a href="/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC</a> 工具對現實的唯一記憶。把它放在本地檔案系統等於把整個基礎設施的記憶綁在一台機器上——換人接手、換台電腦、或兩人同時 apply，記憶就分裂了。Remote state backend 解決的是「讓 state 變成團隊共用的、有保護的事實來源」。</p>
<p>典型的自管組合是 S3（存放 state 檔、開 versioning 和加密）加上 DynamoDB（提供 apply 時的並行鎖）。託管服務（Terraform Cloud、Spacelift）把存放、鎖和加密包在一起，用月費換掉配置和維運負擔。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>本地 state 的失敗訊號是：跑 <code>terraform plan</code> 時出現「想刪掉」明知存在的資源——通常代表本地 state 跟雲端實際狀態已經脫節。另一個訊號是兩個人同時跑 apply 但沒有任何鎖機制阻擋——結果是互相覆蓋對方的變更，state 進入不一致狀態。</p>
<p>Remote backend 設定後，如果 <code>terraform init</code> 提示 state 遷移確認，代表正在從本地搬到遠端——這是正確的一次性操作，但搬遷過程中不能有其他人在 apply。</p>
<h2 id="設計責任">設計責任</h2>
<p>選擇 remote state backend 時要決定：自管還是託管（取決於團隊規模和維運餘裕）、state bucket 的加密與存取控制（誰能讀 state 等於誰能看到所有資源的敏感屬性）、versioning 是否開啟（是 state 回捲的唯一退路）、以及鎖表的設定（DynamoDB 的表名和 partition key）。</p>
<p>State 絕不能進 git——它含明文敏感值，推進版控等於把密碼寫進每個 clone 的歷史裡。Backend 設定本身（bucket name、region、鎖表名稱）寫在 HCL 裡進 git，state 檔本身只存在 backend 裡。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">State</a> — remote backend 存放的對象</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/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC</a> — remote state backend 是 IaC 工具的基礎設施</li>
</ul>
]]></content:encoded></item><item><title>Trust Policy</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/trust-policy/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/trust-policy/</guid><description>&lt;p>Trust policy 是附加在 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM&lt;/a> role 上的一份 JSON 文件，定義「誰被允許臨時取得（assume）這個 role 的權限」。跟 IAM policy 的差別是：IAM policy 描述「這個 role 能做什麼」，trust policy 描述「誰能變成這個 role」。兩者合在一起才構成完整的授權——先過 trust policy 的門、再受 IAM policy 的限。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Trust policy 是 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/oidc/" data-link-title="OIDC 聯合" data-link-desc="讓 CI/CD 平台用短期 token 取代長期 access key 存取雲端資源的身分聯合機制">OIDC&lt;/a> 聯合的核心配件。當 CI/CD 平台（GitHub Actions、GitLab CI）要用短期憑證存取雲端資源時，trust policy 用 OIDC token 裡的 claim（issuer、audience、subject）決定「這個 token 代表的身分能不能 assume 這個 role」。&lt;/p>
&lt;p>Trust policy 的設計要點是 claim 的收斂程度。只驗 issuer 而不驗 repo 和 branch，等於同一個 CI 平台上所有專案都能 assume 這個 role——這是常見的設定陷阱。收到最緊意味著限定到「某個 org 的某個 repo 的某個 branch 或 environment」。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>&lt;code>sts:AssumeRoleWithWebIdentity&lt;/code> 呼叫失敗、回傳 &lt;code>AccessDenied&lt;/code> 時，問題通常在 trust policy 的 condition 比對不上。排查路徑是把 CI 平台簽發的 OIDC token decode（JWT 的 payload 部分），逐一比對 token 裡的 &lt;code>iss&lt;/code>、&lt;code>aud&lt;/code>、&lt;code>sub&lt;/code> 跟 trust policy 的 condition 值。&lt;/p>
&lt;p>另一個訊號是 trust policy 的 condition 用了 &lt;code>StringLike&lt;/code> 但 pattern 太寬（如 &lt;code>repo:my-org/*&lt;/code>），讓非預期的 repo 也能 assume——這類過寬的 trust policy 在安全稽核時會被標記。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>設計 trust policy 時要決定：允許哪些外部身分 assume（issuer + subject 的精確匹配）、audience 是否需要額外驗證（AWS 預設 &lt;code>sts.amazonaws.com&lt;/code>）、以及是否把 plan role 和 apply role 分開（plan 只需 read-only、apply 需要 write，用兩個 role 各自設不同 trust condition 來區分 branch 或 environment）。&lt;/p>
&lt;p>Trust policy 的變更跟 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM&lt;/a> policy 一樣要走 PR review——因為改寬一個 condition 就等於給更多外部身分開門。設定指南見 &lt;a href="https://tarrragon.github.io/blog/infra/02-identity-credentials/oidc-trust-policy-setup/" data-link-title="OIDC Trust Policy 設定指南" data-link-desc="GitHub Actions 與 AWS 之間的 OIDC 聯合設定：建立 provider、設計 trust policy 的 claim 收斂、plan 與 apply role 分離、常見錯誤排查">OIDC Trust Policy 設定指南&lt;/a>。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM&lt;/a> — trust policy 是 IAM role 的一部分&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/oidc/" data-link-title="OIDC 聯合" data-link-desc="讓 CI/CD 平台用短期 token 取代長期 access key 存取雲端資源的身分聯合機制">OIDC&lt;/a> — trust policy 用 OIDC token 的 claim 做 assume 判斷&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Trust policy 是附加在 <a href="/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM</a> role 上的一份 JSON 文件，定義「誰被允許臨時取得（assume）這個 role 的權限」。跟 IAM policy 的差別是：IAM policy 描述「這個 role 能做什麼」，trust policy 描述「誰能變成這個 role」。兩者合在一起才構成完整的授權——先過 trust policy 的門、再受 IAM policy 的限。</p>
<h2 id="概念位置">概念位置</h2>
<p>Trust policy 是 <a href="/blog/infra/knowledge-cards/oidc/" data-link-title="OIDC 聯合" data-link-desc="讓 CI/CD 平台用短期 token 取代長期 access key 存取雲端資源的身分聯合機制">OIDC</a> 聯合的核心配件。當 CI/CD 平台（GitHub Actions、GitLab CI）要用短期憑證存取雲端資源時，trust policy 用 OIDC token 裡的 claim（issuer、audience、subject）決定「這個 token 代表的身分能不能 assume 這個 role」。</p>
<p>Trust policy 的設計要點是 claim 的收斂程度。只驗 issuer 而不驗 repo 和 branch，等於同一個 CI 平台上所有專案都能 assume 這個 role——這是常見的設定陷阱。收到最緊意味著限定到「某個 org 的某個 repo 的某個 branch 或 environment」。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p><code>sts:AssumeRoleWithWebIdentity</code> 呼叫失敗、回傳 <code>AccessDenied</code> 時，問題通常在 trust policy 的 condition 比對不上。排查路徑是把 CI 平台簽發的 OIDC token decode（JWT 的 payload 部分），逐一比對 token 裡的 <code>iss</code>、<code>aud</code>、<code>sub</code> 跟 trust policy 的 condition 值。</p>
<p>另一個訊號是 trust policy 的 condition 用了 <code>StringLike</code> 但 pattern 太寬（如 <code>repo:my-org/*</code>），讓非預期的 repo 也能 assume——這類過寬的 trust policy 在安全稽核時會被標記。</p>
<h2 id="設計責任">設計責任</h2>
<p>設計 trust policy 時要決定：允許哪些外部身分 assume（issuer + subject 的精確匹配）、audience 是否需要額外驗證（AWS 預設 <code>sts.amazonaws.com</code>）、以及是否把 plan role 和 apply role 分開（plan 只需 read-only、apply 需要 write，用兩個 role 各自設不同 trust condition 來區分 branch 或 environment）。</p>
<p>Trust policy 的變更跟 <a href="/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM</a> policy 一樣要走 PR review——因為改寬一個 condition 就等於給更多外部身分開門。設定指南見 <a href="/blog/infra/02-identity-credentials/oidc-trust-policy-setup/" data-link-title="OIDC Trust Policy 設定指南" data-link-desc="GitHub Actions 與 AWS 之間的 OIDC 聯合設定：建立 provider、設計 trust policy 的 claim 收斂、plan 與 apply role 分離、常見錯誤排查">OIDC Trust Policy 設定指南</a>。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM</a> — trust policy 是 IAM role 的一部分</li>
<li><a href="/blog/infra/knowledge-cards/oidc/" data-link-title="OIDC 聯合" data-link-desc="讓 CI/CD 平台用短期 token 取代長期 access key 存取雲端資源的身分聯合機制">OIDC</a> — trust policy 用 OIDC token 的 claim 做 assume 判斷</li>
</ul>
]]></content:encoded></item><item><title>Deletion Protection</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/deletion-protection/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/deletion-protection/</guid><description>&lt;p>Deletion protection 是雲端平台在資源層級提供的防護機制：開啟後，任何刪除該資源的操作（Console 點按、CLI 指令、IaC 的 destroy）都會被擋下，必須先顯式關閉保護才能執行刪除。這個額外步驟的目的是防止手滑、批次操作誤傷、以及 Terraform plan 裡意外出現的 destroy。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Deletion protection 是 &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 表達服務間依賴">stateful 資源保護&lt;/a>的第一道防線。運算節點可以隨時重建，資料一旦遺失通常無法重來——這條分界線決定了哪些資源該開保護。對 stateful 資源（資料庫、持久化儲存）來說，這是 day-1 該開的設定，不是「等穩定再開」的選項。&lt;/p>
&lt;p>不同 AWS 服務的保護機制名稱不同但行為一致：&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>RDS&lt;/td>
 &lt;td>&lt;code>deletion_protection&lt;/code>&lt;/td>
 &lt;td>資料庫 instance&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>EC2&lt;/td>
 &lt;td>&lt;code>disable_api_termination&lt;/code>&lt;/td>
 &lt;td>運算 instance&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>S3&lt;/td>
 &lt;td>MFA delete&lt;/td>
 &lt;td>bucket 版本控制&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>DynamoDB&lt;/td>
 &lt;td>&lt;code>deletion_protection_enabled&lt;/code>&lt;/td>
 &lt;td>表格&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>需要開啟 deletion protection 的訊號是資源承載了不可重建的狀態。判斷方式是問一個問題：「這個資源被刪除後，能不能在 10 分鐘內從程式碼或備份完整恢復？」不能的就該開。&lt;/p>
&lt;p>&lt;code>terraform plan&lt;/code> 輸出裡出現 &lt;code>destroy&lt;/code> 或 &lt;code>forces replacement&lt;/code>（&lt;code>-/+&lt;/code>）時，deletion protection 是阻擋意外資料遺失的最後一道閘門。有保護的資源在 apply 時會報錯而非直接刪除，讓操作者有機會停下來確認。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>用 &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> 描述 stateful 資源時，把 deletion protection 寫進程式碼而非手動在 Console 開啟——這讓保護策略本身成為可審查、可追蹤的設定。同時搭配 &lt;code>skip_final_snapshot = false&lt;/code>（RDS）確保刪除前自動做最後一份快照。&lt;/p>
&lt;p>Deletion protection 擋的是刪除操作，不擋資料覆寫或邏輯損壞——一段錯誤的 UPDATE 不會被 deletion protection 攔截。資料層的完整防線還需要備份保留與時間點還原（PITR），跟 deletion protection 正交。&lt;/p>
&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> — deletion protection 在 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">state&lt;/a> 裡記錄為資源屬性，plan 會顯示保護狀態&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> — 保護策略寫進 IaC 讓它可審查&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Deletion protection 是雲端平台在資源層級提供的防護機制：開啟後，任何刪除該資源的操作（Console 點按、CLI 指令、IaC 的 destroy）都會被擋下，必須先顯式關閉保護才能執行刪除。這個額外步驟的目的是防止手滑、批次操作誤傷、以及 Terraform plan 裡意外出現的 destroy。</p>
<h2 id="概念位置">概念位置</h2>
<p>Deletion protection 是 <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>的第一道防線。運算節點可以隨時重建，資料一旦遺失通常無法重來——這條分界線決定了哪些資源該開保護。對 stateful 資源（資料庫、持久化儲存）來說，這是 day-1 該開的設定，不是「等穩定再開」的選項。</p>
<p>不同 AWS 服務的保護機制名稱不同但行為一致：</p>
<table>
  <thead>
      <tr>
          <th>服務</th>
          <th>屬性名稱</th>
          <th>保護對象</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>RDS</td>
          <td><code>deletion_protection</code></td>
          <td>資料庫 instance</td>
      </tr>
      <tr>
          <td>EC2</td>
          <td><code>disable_api_termination</code></td>
          <td>運算 instance</td>
      </tr>
      <tr>
          <td>S3</td>
          <td>MFA delete</td>
          <td>bucket 版本控制</td>
      </tr>
      <tr>
          <td>DynamoDB</td>
          <td><code>deletion_protection_enabled</code></td>
          <td>表格</td>
      </tr>
  </tbody>
</table>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>需要開啟 deletion protection 的訊號是資源承載了不可重建的狀態。判斷方式是問一個問題：「這個資源被刪除後，能不能在 10 分鐘內從程式碼或備份完整恢復？」不能的就該開。</p>
<p><code>terraform plan</code> 輸出裡出現 <code>destroy</code> 或 <code>forces replacement</code>（<code>-/+</code>）時，deletion protection 是阻擋意外資料遺失的最後一道閘門。有保護的資源在 apply 時會報錯而非直接刪除，讓操作者有機會停下來確認。</p>
<h2 id="設計責任">設計責任</h2>
<p>用 <a href="/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC</a> 描述 stateful 資源時，把 deletion protection 寫進程式碼而非手動在 Console 開啟——這讓保護策略本身成為可審查、可追蹤的設定。同時搭配 <code>skip_final_snapshot = false</code>（RDS）確保刪除前自動做最後一份快照。</p>
<p>Deletion protection 擋的是刪除操作，不擋資料覆寫或邏輯損壞——一段錯誤的 UPDATE 不會被 deletion protection 攔截。資料層的完整防線還需要備份保留與時間點還原（PITR），跟 deletion protection 正交。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">State</a> — deletion protection 在 <a href="/blog/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">state</a> 裡記錄為資源屬性，plan 會顯示保護狀態</li>
<li><a href="/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC</a> — 保護策略寫進 IaC 讓它可審查</li>
</ul>
]]></content:encoded></item><item><title>checkov</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/checkov/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/checkov/</guid><description>&lt;p>checkov 是一個開源的靜態分析工具，掃描 Terraform / CloudFormation / Kubernetes 等 IaC 程式碼，比對內建的規則庫找出安全漏洞與合規違規。它在 &lt;code>plan&lt;/code> 之前或之後執行、不建立任何雲端資源，所以是 CI pipeline 裡最便宜的安全檢查之一。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>checkov 在 &lt;a href="https://tarrragon.github.io/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">infra PR 流程&lt;/a>裡的位置是 &lt;code>fmt&lt;/code> → &lt;code>validate&lt;/code> → &lt;strong>checkov / tfsec&lt;/strong> → &lt;code>plan&lt;/code>。前兩步檢查語法正確，checkov 檢查語意安全，plan 檢查實際差異。checkov 補的是 reviewer 肉眼容易漏的盲區——一條 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">security group&lt;/a> 規則寫成 &lt;code>0.0.0.0/0&lt;/code> 在 HCL 裡只是一行字串，人會看漏，規則不會。&lt;/p>
&lt;p>三個常見的 IaC 掃描工具各有側重：&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>checkov&lt;/td>
 &lt;td>安全 + 合規&lt;/td>
 &lt;td>Prisma Cloud (Palo Alto)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>tfsec&lt;/td>
 &lt;td>安全&lt;/td>
 &lt;td>Aqua Security&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>tflint&lt;/td>
 &lt;td>provider 正確性&lt;/td>
 &lt;td>社群&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>checkov 的規則庫最廣（涵蓋 CIS Benchmark、SOC 2、PCI DSS 等合規框架），tfsec 的規則更聚焦安全面，tflint 偏向「這個 instance type 在這個 region 存不存在」的 provider 正確性。三者可疊加使用。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>需要引入 checkov 的訊號是 PR review 開始漏掉安全問題——S3 bucket 缺 public access block、RDS 沒開加密、IAM policy 過寬。這些問題的 pattern 是固定的、可以用規則比對，不應該靠人記憶來擋。&lt;/p>
&lt;p>checkov 命中後要區分「真漏洞」和「情境合理的例外」。ALB 的 HTTPS listener 在 port 443 開 &lt;code>0.0.0.0/0&lt;/code> 是設計本意，不是漏洞。豁免用行內註解標記並寫理由：&lt;code>#checkov:skip=CKV_AWS_260:ALB public HTTPS listener&lt;/code>。詳細的規則配置與豁免管理見 &lt;a href="https://tarrragon.github.io/blog/infra/07-infra-as-pr/checkov-tfsec-rule-customization/" data-link-title="checkov 與 tfsec 規則配置" data-link-desc="靜態掃描工具的規則選擇策略、自訂規則、豁免管理、false positive 處理與 CI 整合，讓掃描從噪音來源變成可信的品質關卡">checkov 與 tfsec 規則配置&lt;/a>。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>引入 checkov 時要決定兩件事：啟用哪些規則（全部 vs 漸進啟用），以及命中時 CI 要不要擋（hard fail vs warning）。常見的漸進策略是先從高嚴重度規則開始、設為 hard fail，中低嚴重度設為 warning，隨團隊習慣逐步收緊。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC&lt;/a> — checkov 掃描的對象&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group&lt;/a> — checkov 最常攔截的 &lt;code>0.0.0.0/0&lt;/code> 全開規則&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>checkov 是一個開源的靜態分析工具，掃描 Terraform / CloudFormation / Kubernetes 等 IaC 程式碼，比對內建的規則庫找出安全漏洞與合規違規。它在 <code>plan</code> 之前或之後執行、不建立任何雲端資源，所以是 CI pipeline 裡最便宜的安全檢查之一。</p>
<h2 id="概念位置">概念位置</h2>
<p>checkov 在 <a href="/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">infra PR 流程</a>裡的位置是 <code>fmt</code> → <code>validate</code> → <strong>checkov / tfsec</strong> → <code>plan</code>。前兩步檢查語法正確，checkov 檢查語意安全，plan 檢查實際差異。checkov 補的是 reviewer 肉眼容易漏的盲區——一條 <a href="/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">security group</a> 規則寫成 <code>0.0.0.0/0</code> 在 HCL 裡只是一行字串，人會看漏，規則不會。</p>
<p>三個常見的 IaC 掃描工具各有側重：</p>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>側重</th>
          <th>維護方</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>checkov</td>
          <td>安全 + 合規</td>
          <td>Prisma Cloud (Palo Alto)</td>
      </tr>
      <tr>
          <td>tfsec</td>
          <td>安全</td>
          <td>Aqua Security</td>
      </tr>
      <tr>
          <td>tflint</td>
          <td>provider 正確性</td>
          <td>社群</td>
      </tr>
  </tbody>
</table>
<p>checkov 的規則庫最廣（涵蓋 CIS Benchmark、SOC 2、PCI DSS 等合規框架），tfsec 的規則更聚焦安全面，tflint 偏向「這個 instance type 在這個 region 存不存在」的 provider 正確性。三者可疊加使用。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>需要引入 checkov 的訊號是 PR review 開始漏掉安全問題——S3 bucket 缺 public access block、RDS 沒開加密、IAM policy 過寬。這些問題的 pattern 是固定的、可以用規則比對，不應該靠人記憶來擋。</p>
<p>checkov 命中後要區分「真漏洞」和「情境合理的例外」。ALB 的 HTTPS listener 在 port 443 開 <code>0.0.0.0/0</code> 是設計本意，不是漏洞。豁免用行內註解標記並寫理由：<code>#checkov:skip=CKV_AWS_260:ALB public HTTPS listener</code>。詳細的規則配置與豁免管理見 <a href="/blog/infra/07-infra-as-pr/checkov-tfsec-rule-customization/" data-link-title="checkov 與 tfsec 規則配置" data-link-desc="靜態掃描工具的規則選擇策略、自訂規則、豁免管理、false positive 處理與 CI 整合，讓掃描從噪音來源變成可信的品質關卡">checkov 與 tfsec 規則配置</a>。</p>
<h2 id="設計責任">設計責任</h2>
<p>引入 checkov 時要決定兩件事：啟用哪些規則（全部 vs 漸進啟用），以及命中時 CI 要不要擋（hard fail vs warning）。常見的漸進策略是先從高嚴重度規則開始、設為 hard fail，中低嚴重度設為 warning，隨團隊習慣逐步收緊。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC</a> — checkov 掃描的對象</li>
<li><a href="/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group</a> — checkov 最常攔截的 <code>0.0.0.0/0</code> 全開規則</li>
</ul>
]]></content:encoded></item><item><title>State 修復與清理</title><link>https://tarrragon.github.io/blog/infra/takeover/partial-iac-state-repair/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/takeover/partial-iac-state-repair/</guid><description>&lt;p>接手一個有半套 IaC 的環境時，state 是工具對現實的唯一記憶，但這份記憶可能已經失真——有些記錄對應的雲端資源已經不存在、有些雲端資源從來沒被記錄、有些記錄的屬性跟現實對不上。在動任何資源之前，先把 state 修到一個可信的狀態，是所有後續操作的前提。&lt;/p>
&lt;h2 id="診斷-state-的健康狀態">診斷 state 的健康狀態&lt;/h2>
&lt;p>&lt;code>terraform plan&lt;/code> 的輸出是診斷 state 健康度的主要工具。在不做任何 code 變更的前提下跑 plan，輸出的每一行差異都代表 state 與現實的落差：&lt;/p>





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





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





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





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





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





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





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





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





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





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





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





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




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





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">terraform apply -refresh-only</span></span></code></pre></div><p>refresh-only 之後，state 跟雲端一致了，但 state 跟 HCL 之間的差異仍然存在——下次跑 plan 仍會看到 drift。它解的是「state 過時」的問題，不是「code 跟現實不一致」的問題。兩者要分開處理：先 refresh-only 讓 state 乾淨，再逐項決定 HCL 要不要對齊。</p>
<p>使用 refresh-only 的前提是確認 state backend 有 versioning——如果 refresh-only 把 state 改壞了（例如併發操作導致 state 衝突），需要能回捲到上一個版本。</p>
<h2 id="批次-drift-收斂工作流">批次 drift 收斂工作流</h2>
<p>接手環境的 drift 通常不是一兩條，可能有幾十條。逐條處理可以但效率低，按類型批次處理比較實際：</p>
<p><strong>第一批：安全類</strong>。security group 規則、IAM policy 的 drift 優先處理，因為它們直接影響存取邊界。全開的規則該關就關（回退），故意開的規則 adopt 進 code。</p>
<p><strong>第二批：stateless 資源的 in-place drift</strong>。tag 不一致、description 不一致、非關鍵屬性的變更。這類 drift 風險低，可以批次 adopt（把 HCL 改成跟雲端一致）然後一次 apply 驗證。</p>
<p><strong>第三批：stateful 資源</strong>。RDS parameter、backup retention、instance class 的變更。逐個處理，每個都要確認是 in-place update 而非 forces replacement。</p>
<p><strong>第四批：擱置項</strong>。forces replacement、無法判斷的改動。加 <code>ignore_changes</code> 暫緩，排進 backlog 定期回顧。</p>
<p>每一批處理完後跑一次 plan，確認該批的 drift 消失、其他批次的 drift 沒被影響。不要一次 apply 所有批次——分批的目的是控制每次 apply 的影響範圍。</p>
<p>整個 drift 收斂流程的時程取決於 drift 數量和 stateful 資源的比例。20 條以內的 drift、多數是 stateless 的 in-place 變更，2-3 天可以收完。50 條以上、含多個 stateful 資源的 forces replacement，需要 1-2 週分階段處理。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/takeover/partial-iac-no-docs/" data-link-title="有半套 IaC 但文件缺失的環境接管" data-link-desc="IaC 覆蓋不完整、部分資源在 state 外、文件缺失的環境怎麼盤點差距、修復 state 健康、收斂 drift 並重建文件">有半套 IaC 但文件缺失的環境接管</a>：本文的上層總覽</li>
<li>→ <a href="/blog/infra/takeover/partial-iac-state-repair/" data-link-title="State 修復與清理" data-link-desc="接手的 Terraform state 損壞、有 orphaned entry、或需要搬遷時，怎麼診斷問題、安全操作、以及從錯誤中回復">State 修復與清理</a>：drift 處理前先確認 state 本身是健康的</li>
<li>→ <a href="/blog/infra/takeover/partial-iac-bulk-import/" data-link-title="Unmanaged Resource 批次 Import 工作流" data-link-desc="把 Terraform state 外的雲端資源有系統地納入 IaC 管理：優先序判斷、import block 語法、generated HCL 的 review 要點、批次策略與常見失敗處理">Unmanaged resource 批次 import</a>：drift 收斂完成後，開始 import unmanaged resource</li>
<li>→ <a href="/blog/infra/01-minimal-iac/console-readonly-minimal-viable/" data-link-title="Console 唯讀鐵律與最小可行資源集合" data-link-desc="Console 只用來看不用來改的操作紀律、drift 的延遲浮現與偵測，以及能跑出第一個完整 apply 迴路的最小資源集合">Console 唯讀鐵律</a>：drift 的根本防線</li>
<li>→ <a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>：drift 收斂後的環境拆分路徑</li>
</ul>
]]></content:encoded></item><item><title>Fargate</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/fargate/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/fargate/</guid><description>&lt;p>Fargate 是 AWS &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/ecs/" data-link-title="ECS" data-link-desc="AWS Elastic Container Service — 受管的容器編排服務，用 task definition 描述容器配置、由平台負責排程與健康管理">ECS&lt;/a> 的一種 launch type，把容器的運算實例交給 AWS 代管。使用 Fargate 時不需要配 EC2 instance、不需要管 capacity provider 的 scaling、不需要更新 AMI——只描述 task 需要多少 vCPU 和記憶體，AWS 負責分配運算資源。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>ECS 有兩種 launch type，差別在運算層的管理責任：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Launch type&lt;/th>
 &lt;th>運算層管理&lt;/th>
 &lt;th>適用情境&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Fargate&lt;/td>
 &lt;td>AWS 代管&lt;/td>
 &lt;td>web API、微服務、批次任務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>EC2&lt;/td>
 &lt;td>自管 instance&lt;/td>
 &lt;td>GPU workload、高密度排程、成本敏感&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Fargate 降低的是運維面（不用管 OS patch、不用管 instance 容量），代價是單位成本較高（同規格約比 EC2 高 20-40%）和啟動延遲（cold start 通常在 30-60 秒，EC2 上的 task 因為 instance 已在所以秒級啟動）。多數 web API 的初始選擇是 Fargate，流量穩定且成本壓力大時再切回 EC2 launch type。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>評估是否從 Fargate 切到 EC2 的訊號是月費曲線。Fargate 按 vCPU-hour 和 memory-hour 計費，task 數量少時費用低、管理簡單。當 task 數量穩定在 10-20 個以上且流量模式可預測時，EC2 launch type 搭配 reserved instance 或 Savings Plans 的成本優勢開始顯著——但要承擔 instance 管理的運維負擔。詳細的成本分析見 &lt;a href="https://tarrragon.github.io/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 成本分析與優化&lt;/a>。&lt;/p>
&lt;p>Fargate Spot 是介於兩者之間的選項：費用約為 on-demand Fargate 的 30%，但 AWS 可以隨時中斷 task（提前 2 分鐘通知）。適合可容忍中斷的 workload（批次處理、非即時的資料轉換），不適合面對使用者的即時 API。常見的混合策略是用 on-demand Fargate 跑基線流量、Fargate Spot 跑彈性擴張的部分。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>選 Fargate 時要決定三件事：task 的 vCPU / memory 規格（Fargate 的可選組合是固定的，不是任意搭配）、是否混用 Spot、以及 health check 的 grace period（Fargate 的 cold start 比 EC2 長，health check 太早判定失敗會讓 task 反覆重啟）。&lt;/p>
&lt;p>task 規格的 rightsizing 靠 CloudWatch Container Insights 的 CPU / memory utilization 決定——p95 使用率低於 30% 代表規格過大、持續高於 80% 代表該升級。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/ecs/" data-link-title="ECS" data-link-desc="AWS Elastic Container Service — 受管的容器編排服務，用 task definition 描述容器配置、由平台負責排程與健康管理">ECS&lt;/a> — Fargate 是 ECS 的 launch type 之一&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB&lt;/a> — Fargate task 通常掛在 ALB 的 target group 後面&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Fargate 是 AWS <a href="/blog/infra/knowledge-cards/ecs/" data-link-title="ECS" data-link-desc="AWS Elastic Container Service — 受管的容器編排服務，用 task definition 描述容器配置、由平台負責排程與健康管理">ECS</a> 的一種 launch type，把容器的運算實例交給 AWS 代管。使用 Fargate 時不需要配 EC2 instance、不需要管 capacity provider 的 scaling、不需要更新 AMI——只描述 task 需要多少 vCPU 和記憶體，AWS 負責分配運算資源。</p>
<h2 id="概念位置">概念位置</h2>
<p>ECS 有兩種 launch type，差別在運算層的管理責任：</p>
<table>
  <thead>
      <tr>
          <th>Launch type</th>
          <th>運算層管理</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Fargate</td>
          <td>AWS 代管</td>
          <td>web API、微服務、批次任務</td>
      </tr>
      <tr>
          <td>EC2</td>
          <td>自管 instance</td>
          <td>GPU workload、高密度排程、成本敏感</td>
      </tr>
  </tbody>
</table>
<p>Fargate 降低的是運維面（不用管 OS patch、不用管 instance 容量），代價是單位成本較高（同規格約比 EC2 高 20-40%）和啟動延遲（cold start 通常在 30-60 秒，EC2 上的 task 因為 instance 已在所以秒級啟動）。多數 web API 的初始選擇是 Fargate，流量穩定且成本壓力大時再切回 EC2 launch type。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>評估是否從 Fargate 切到 EC2 的訊號是月費曲線。Fargate 按 vCPU-hour 和 memory-hour 計費，task 數量少時費用低、管理簡單。當 task 數量穩定在 10-20 個以上且流量模式可預測時，EC2 launch type 搭配 reserved instance 或 Savings Plans 的成本優勢開始顯著——但要承擔 instance 管理的運維負擔。詳細的成本分析見 <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>。</p>
<p>Fargate Spot 是介於兩者之間的選項：費用約為 on-demand Fargate 的 30%，但 AWS 可以隨時中斷 task（提前 2 分鐘通知）。適合可容忍中斷的 workload（批次處理、非即時的資料轉換），不適合面對使用者的即時 API。常見的混合策略是用 on-demand Fargate 跑基線流量、Fargate Spot 跑彈性擴張的部分。</p>
<h2 id="設計責任">設計責任</h2>
<p>選 Fargate 時要決定三件事：task 的 vCPU / memory 規格（Fargate 的可選組合是固定的，不是任意搭配）、是否混用 Spot、以及 health check 的 grace period（Fargate 的 cold start 比 EC2 長，health check 太早判定失敗會讓 task 反覆重啟）。</p>
<p>task 規格的 rightsizing 靠 CloudWatch Container Insights 的 CPU / memory utilization 決定——p95 使用率低於 30% 代表規格過大、持續高於 80% 代表該升級。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/ecs/" data-link-title="ECS" data-link-desc="AWS Elastic Container Service — 受管的容器編排服務，用 task definition 描述容器配置、由平台負責排程與健康管理">ECS</a> — Fargate 是 ECS 的 launch type 之一</li>
<li><a href="/blog/infra/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB</a> — Fargate task 通常掛在 ALB 的 target group 後面</li>
</ul>
]]></content:encoded></item><item><title>phpMyAdmin</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/phpmyadmin/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/phpmyadmin/</guid><description>&lt;p>phpMyAdmin 是一套透過瀏覽器操作 MySQL 和 MariaDB 的 Web 應用程式。它提供圖形介面執行 SQL 查詢、瀏覽資料表、匯出與匯入資料庫、修改 schema（新增欄位、改索引、刪表）、以及管理使用者權限。多數主機商在安裝 cPanel 或 Plesk 時會一併預裝，讓租用主機的使用者不需要 SSH 就能管理資料庫。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>在無 SSH 的主機環境裡，phpMyAdmin 通常是唯一可用的資料庫管理入口。它取代了 &lt;code>mysql&lt;/code> CLI client 和 &lt;code>mysqldump&lt;/code> 指令的角色——查詢用 SQL 編輯器、匯出用匯出頁面、匯入用上傳 SQL 檔。接手維運時，phpMyAdmin 是拍下資料庫現況（SQL dump）的主要工具。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>以下情境會遇到 phpMyAdmin：主機面板（cPanel / Plesk）裡有「phpMyAdmin」按鈕可以進入；接手的專案的資料庫操作文件提到「在 phpMyAdmin 裡執行」；或者專案的部署流程包含「登入 phpMyAdmin 匯入 SQL」。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>使用 phpMyAdmin 時要處理三個限制。第一是匯出 timeout：大資料庫（50MB 以上）的匯出可能因為 PHP 的 &lt;code>max_execution_time&lt;/code> 限制而中斷，需要分表匯出或調整 phpMyAdmin 設定。第二是沒有 CLI 可腳本化：所有操作都要手動點擊，無法排程自動備份。第三是安全暴露：phpMyAdmin 掛在 web 上、可被外部存取，如果沒有設密碼保護或 IP 白名單，等於把資料庫管理介面開給全世界。&lt;/p>
&lt;p>如果主機允許遠端 MySQL 連線（port 3306 開放），可以改用桌面工具（DBeaver、TablePlus、HeidiSQL）直連資料庫，繞過 phpMyAdmin 的 timeout 限制。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/cpanel/" data-link-title="cPanel" data-link-desc="Web 主機管理面板，提供 PHP 版本切換、cron、email、SSL、備份等功能的圖形介面">cPanel&lt;/a>：phpMyAdmin 通常內嵌在 cPanel 裡&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>phpMyAdmin 是一套透過瀏覽器操作 MySQL 和 MariaDB 的 Web 應用程式。它提供圖形介面執行 SQL 查詢、瀏覽資料表、匯出與匯入資料庫、修改 schema（新增欄位、改索引、刪表）、以及管理使用者權限。多數主機商在安裝 cPanel 或 Plesk 時會一併預裝，讓租用主機的使用者不需要 SSH 就能管理資料庫。</p>
<h2 id="概念位置">概念位置</h2>
<p>在無 SSH 的主機環境裡，phpMyAdmin 通常是唯一可用的資料庫管理入口。它取代了 <code>mysql</code> CLI client 和 <code>mysqldump</code> 指令的角色——查詢用 SQL 編輯器、匯出用匯出頁面、匯入用上傳 SQL 檔。接手維運時，phpMyAdmin 是拍下資料庫現況（SQL dump）的主要工具。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>以下情境會遇到 phpMyAdmin：主機面板（cPanel / Plesk）裡有「phpMyAdmin」按鈕可以進入；接手的專案的資料庫操作文件提到「在 phpMyAdmin 裡執行」；或者專案的部署流程包含「登入 phpMyAdmin 匯入 SQL」。</p>
<h2 id="設計責任">設計責任</h2>
<p>使用 phpMyAdmin 時要處理三個限制。第一是匯出 timeout：大資料庫（50MB 以上）的匯出可能因為 PHP 的 <code>max_execution_time</code> 限制而中斷，需要分表匯出或調整 phpMyAdmin 設定。第二是沒有 CLI 可腳本化：所有操作都要手動點擊，無法排程自動備份。第三是安全暴露：phpMyAdmin 掛在 web 上、可被外部存取，如果沒有設密碼保護或 IP 白名單，等於把資料庫管理介面開給全世界。</p>
<p>如果主機允許遠端 MySQL 連線（port 3306 開放），可以改用桌面工具（DBeaver、TablePlus、HeidiSQL）直連資料庫，繞過 phpMyAdmin 的 timeout 限制。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/cpanel/" data-link-title="cPanel" data-link-desc="Web 主機管理面板，提供 PHP 版本切換、cron、email、SSL、備份等功能的圖形介面">cPanel</a>：phpMyAdmin 通常內嵌在 cPanel 裡</li>
</ul>
]]></content:encoded></item><item><title>Unmanaged Resource 批次 Import 工作流</title><link>https://tarrragon.github.io/blog/infra/takeover/partial-iac-bulk-import/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/takeover/partial-iac-bulk-import/</guid><description>&lt;p>盤點階段產出的 managed vs unmanaged 兩欄清單裡（見&lt;a href="https://tarrragon.github.io/blog/infra/takeover/partial-iac-no-docs/" data-link-title="有半套 IaC 但文件缺失的環境接管" data-link-desc="IaC 覆蓋不完整、部分資源在 state 外、文件缺失的環境怎麼盤點差距、修復 state 健康、收斂 drift 並重建文件">盤點流程&lt;/a>），unmanaged 那一欄的每個資源都要決定：納入 Terraform 管理、還是維持手動並記錄原因。這篇處理的是「決定要納管」的資源怎麼有系統地 import，而不是一次全部倒進去。&lt;/p>
&lt;h2 id="優先序先-import-什麼">優先序：先 import 什麼&lt;/h2>
&lt;p>不是所有 unmanaged resource 都值得立刻 import。判斷依據是「這個資源不在 IaC 裡的風險有多高」和「import 的操作複雜度有多低」的交集。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>優先級&lt;/th>
 &lt;th>資源類型&lt;/th>
 &lt;th>理由&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>Security group、IAM role / policy&lt;/td>
 &lt;td>安全邊界資源，手動改動的風險最高，且 import 後 plan 驗證直覺&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>VPC、subnet、route table&lt;/td>
 &lt;td>網路地基，其他資源依賴它們，import 後上層資源的引用才能從 hardcode 換成引用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>RDS、ElastiCache&lt;/td>
 &lt;td>有狀態資源，import 操作本身不改資源，但 plan 不匹配時的修正要謹慎&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>4&lt;/td>
 &lt;td>S3 bucket、CloudWatch log group&lt;/td>
 &lt;td>低風險、低依賴，但數量可能很多，適合最後批次處理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>5&lt;/td>
 &lt;td>EC2 instance、Lambda&lt;/td>
 &lt;td>變動頻繁、生命週期短，import 的 ROI 低——考慮是否改用 IaC 重建而非 import&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>優先級 1-2 的資源是地基層，import 後能讓後續的 IaC 引用鏈從 hardcode ID 換成資源屬性引用，這是 import 的結構性收益。優先級 5 的資源如果生命週期短（隨部署替換），用 IaC 重新定義再 apply 比逆向 import 划算。&lt;/p>
&lt;h2 id="import-block-語法terraform-15">import block 語法（Terraform 1.5+）&lt;/h2>
&lt;p>Terraform 1.5 引入了宣告式 import block，取代舊版的 &lt;code>terraform import&lt;/code> CLI 指令。宣告式的優勢是 import 本身進版本控制、可 review、可回滾。&lt;/p>





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





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





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





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">[Infra 過渡期操作規則]
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">以下資源已由 Terraform 管理，變更請走 PR：
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">- VPC 和所有 subnet
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">- Security group: sg-0web456, sg-0app789
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">- RDS: app-prod-primary
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">- ALB: app-prod-alb
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">以下資源仍為手動管理，變更請在 Console 操作後寫 changelog：
</span></span><span class="line"><span class="ln">10</span><span class="cl">- EC2: i-0worker123
</span></span><span class="line"><span class="ln">11</span><span class="cl">- Lambda: cleanup-job
</span></span><span class="line"><span class="ln">12</span><span class="cl">- SG: sg-0legacy789
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">不確定的資源：先問再動。</span></span></code></pre></div><p>隨著 import 進展更新這則訊息。如果團隊用的是 Slack，可以把這則訊息設成 channel bookmark。</p>
<h2 id="縮短過渡期">縮短過渡期</h2>
<p>過渡期越長、兩套真相並存越久、操作事故的機率越高。縮短的方式是用 import sprint 集中處理。</p>
<h3 id="import-sprint-的排程">Import sprint 的排程</h3>
<p>一個 import sprint 是 1-2 天的集中工作，目標是把一批相關的 unmanaged 資源納入 Terraform。按風險從低到高排序：</p>
<table>
  <thead>
      <tr>
          <th>批次</th>
          <th>資源類型</th>
          <th>理由</th>
          <th>預估時間</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>SG、IAM role/policy</td>
          <td>高頻變更、drift 風險最高</td>
          <td>半天到一天</td>
      </tr>
      <tr>
          <td>2</td>
          <td>S3 bucket、CloudWatch</td>
          <td>stateless、import 風險低</td>
          <td>半天</td>
      </tr>
      <tr>
          <td>3</td>
          <td>EC2 instance、ECS</td>
          <td>中風險、需確認 user data 和 AMI</td>
          <td>一天</td>
      </tr>
      <tr>
          <td>4</td>
          <td>RDS、EBS</td>
          <td>stateful、import 失敗代價最高、最後做</td>
          <td>一天（含驗證）</td>
      </tr>
  </tbody>
</table>
<p>每批的操作流程：</p>
<ol>
<li>用 <code>import</code> block + <code>terraform plan -generate-config-out</code> 產生 HCL</li>
<li>審查生成的 HCL，修正屬性差異</li>
<li><code>plan</code> 確認零變更</li>
<li>合併 PR</li>
<li>更新管理歸屬清單</li>
</ol>
<h3 id="縮短期間不要追求完美">縮短期間不要追求完美</h3>
<p>import sprint 的目標是「納管」，不是「重構」。一個手動建的資源 import 進來後，它的 HCL 可能很醜（自動生成的 code 有大量冗餘屬性），但只要 plan 顯示零變更，它就已經是 managed 的了。重構 HCL 是 import 完成之後的事。</p>
<p>同樣，import sprint 期間不要同時做 module 化或環境分離。先把所有資源納管到同一份 state，之後再拆——拆的前提是所有資源都在 state 裡。</p>
<h2 id="過渡期結束的判準">過渡期結束的判準</h2>
<p>過渡期結束的定義是兩個條件同時滿足：</p>
<ol>
<li><strong><code>terraform plan</code> 在無 code 變更時顯示零差異</strong>：代表 state 與雲端現實一致，沒有 drift</li>
<li><strong>管理歸屬清單上的「手動」欄位清空</strong>：所有生產資源都進了 Terraform state</li>
</ol>
<p>第一個條件用定期排程驗證（每天跑一次 plan，非零就告警）。第二個條件用資源盤點比對——雲端的 resource inventory 減去 <code>terraform state list</code> 的輸出，差集為空就完成。</p>
<p>過渡期結束後，操作規則簡化為：所有變更走 IaC + PR，Console 只用來觀察和排查。這就是<a href="/blog/infra/01-minimal-iac/console-readonly-minimal-viable/" data-link-title="Console 唯讀鐵律與最小可行資源集合" data-link-desc="Console 只用來看不用來改的操作紀律、drift 的延遲浮現與偵測，以及能跑出第一個完整 apply 迴路的最小資源集合">模組一的 Console 唯讀鐵律</a>。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/takeover/partial-iac-no-docs/" data-link-title="有半套 IaC 但文件缺失的環境接管" data-link-desc="IaC 覆蓋不完整、部分資源在 state 外、文件缺失的環境怎麼盤點差距、修復 state 健康、收斂 drift 並重建文件">有半套 IaC 但文件缺失的環境接管</a>：本篇的前置操作（盤點、state 健康檢查、drift 收斂）</li>
<li>→ <a href="/blog/infra/takeover/partial-iac-state-repair/" data-link-title="State 修復與清理" data-link-desc="接手的 Terraform state 損壞、有 orphaned entry、或需要搬遷時，怎麼診斷問題、安全操作、以及從錯誤中回復">State 修復與清理</a>：過渡期出問題時可能需要 state surgery</li>
<li>→ <a href="/blog/infra/01-minimal-iac/console-readonly-minimal-viable/" data-link-title="Console 唯讀鐵律與最小可行資源集合" data-link-desc="Console 只用來看不用來改的操作紀律、drift 的延遲浮現與偵測，以及能跑出第一個完整 apply 迴路的最小資源集合">模組一：Console 唯讀鐵律</a>：過渡期結束後的操作紀律</li>
<li>→ <a href="/blog/infra/04-environment-separation/single-to-multi-env-retrofit/" data-link-title="單環境到多環境的 Retrofit 操作手冊" data-link-desc="把已經跑在單一環境的 Terraform 設定拆成 module &#43; per-env 目錄結構的完整操作步驟，含 moved block、zero-change plan 驗證與常見陷阱">模組四：環境分離 retrofit</a>：所有資源納管後的下一步</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">模組七：infra 走 PR 流程</a>：過渡期結束後的完整 PR 護欄</li>
</ul>
]]></content:encoded></item><item><title>cPanel</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/cpanel/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/cpanel/</guid><description>&lt;p>cPanel 是最常見的 Web 主機管理面板，讓租用主機的使用者透過瀏覽器管理伺服器的常用功能——PHP 版本切換、cron job 排程、email 帳號管理、SSL 憑證安裝、檔案管理、資料庫管理、以及完整備份。Plesk 是同類產品，功能範圍相似但介面和設定路徑不同。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>cPanel 是無 SSH 環境裡的「控制中心」。它整合了多種工具的圖形入口：phpMyAdmin（資料庫）、檔案管理員（web 版 FTP）、PHP 設定、cron 編輯器、SSL/TLS 管理。接手維運時，第一步是確認有沒有 cPanel 存取權——有的話很多操作（備份、PHP 版本、cron）可以在面板裡完成，不需要 SSH。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>以下情境代表環境有 cPanel：主機商提供了 cPanel 登入 URL（通常是 &lt;code>domain:2083&lt;/code>）；接手時收到的帳密包含「cPanel 帳號」；或者主機商的服務說明提到 cPanel / WHM。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>接手維運時，cPanel 有幾個關鍵功能要確認：&lt;/p>
&lt;p>&lt;strong>完整備份&lt;/strong>：「備份精靈」可以一次打包整個帳號（檔案 + 資料庫 + email + cron + DNS 設定）。這是最快的「拍下現況」方式——比 FTP 逐檔拉 + phpMyAdmin 匯出快得多。但完整備份通常只能下載、不能自動排程到外部儲存（部分主機商限制）。&lt;/p>
&lt;p>&lt;strong>PHP 版本選擇器&lt;/strong>：可以切換整個帳號或單一域名的 PHP 版本。升級 PHP 時，可以先在 staging 子域名切到新版本測試、確認沒問題再切主域名。這是無 SSH 環境裡最安全的 PHP 升級方式。&lt;/p>
&lt;p>&lt;strong>cron job 管理&lt;/strong>：圖形介面設定排程任務，語法是 cron 標準格式。接手時要截圖或匯出所有 cron——它們可能是系統運作的隱性依賴（定期清快取、寄報表、同步資料）。&lt;/p>
&lt;p>&lt;strong>SSL/TLS&lt;/strong>：管理 HTTPS 憑證。部分主機商整合了 Let&amp;rsquo;s Encrypt 自動簽發，部分需要手動上傳憑證。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/phpmyadmin/" data-link-title="phpMyAdmin" data-link-desc="Web 介面的 MySQL / MariaDB 管理工具，透過瀏覽器操作資料庫">phpMyAdmin&lt;/a>：通常內嵌在 cPanel 的「資料庫」區塊裡&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>cPanel 是最常見的 Web 主機管理面板，讓租用主機的使用者透過瀏覽器管理伺服器的常用功能——PHP 版本切換、cron job 排程、email 帳號管理、SSL 憑證安裝、檔案管理、資料庫管理、以及完整備份。Plesk 是同類產品，功能範圍相似但介面和設定路徑不同。</p>
<h2 id="概念位置">概念位置</h2>
<p>cPanel 是無 SSH 環境裡的「控制中心」。它整合了多種工具的圖形入口：phpMyAdmin（資料庫）、檔案管理員（web 版 FTP）、PHP 設定、cron 編輯器、SSL/TLS 管理。接手維運時，第一步是確認有沒有 cPanel 存取權——有的話很多操作（備份、PHP 版本、cron）可以在面板裡完成，不需要 SSH。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>以下情境代表環境有 cPanel：主機商提供了 cPanel 登入 URL（通常是 <code>domain:2083</code>）；接手時收到的帳密包含「cPanel 帳號」；或者主機商的服務說明提到 cPanel / WHM。</p>
<h2 id="設計責任">設計責任</h2>
<p>接手維運時，cPanel 有幾個關鍵功能要確認：</p>
<p><strong>完整備份</strong>：「備份精靈」可以一次打包整個帳號（檔案 + 資料庫 + email + cron + DNS 設定）。這是最快的「拍下現況」方式——比 FTP 逐檔拉 + phpMyAdmin 匯出快得多。但完整備份通常只能下載、不能自動排程到外部儲存（部分主機商限制）。</p>
<p><strong>PHP 版本選擇器</strong>：可以切換整個帳號或單一域名的 PHP 版本。升級 PHP 時，可以先在 staging 子域名切到新版本測試、確認沒問題再切主域名。這是無 SSH 環境裡最安全的 PHP 升級方式。</p>
<p><strong>cron job 管理</strong>：圖形介面設定排程任務，語法是 cron 標準格式。接手時要截圖或匯出所有 cron——它們可能是系統運作的隱性依賴（定期清快取、寄報表、同步資料）。</p>
<p><strong>SSL/TLS</strong>：管理 HTTPS 憑證。部分主機商整合了 Let&rsquo;s Encrypt 自動簽發，部分需要手動上傳憑證。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/phpmyadmin/" data-link-title="phpMyAdmin" data-link-desc="Web 介面的 MySQL / MariaDB 管理工具，透過瀏覽器操作資料庫">phpMyAdmin</a>：通常內嵌在 cPanel 的「資料庫」區塊裡</li>
</ul>
]]></content:encoded></item><item><title>.htaccess</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/htaccess/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/htaccess/</guid><description>&lt;p>&lt;code>.htaccess&lt;/code>（Hypertext Access）是 Apache Web Server 的目錄層級設定檔。它讓使用者在沒有伺服器管理員權限的情況下，覆寫 Apache 的部分全域設定——包括 URL 重寫規則、目錄存取控制、PHP 設定覆寫、HTTPS 強制跳轉、以及 HTTP 安全標頭。每個目錄可以有自己的 &lt;code>.htaccess&lt;/code>，Apache 處理請求時會從根目錄到目標目錄逐層讀取並套用。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>在 Apache 為主的主機環境（多數共享主機和部分 VPS），&lt;code>.htaccess&lt;/code> 是不需要重啟伺服器就能調整行為的設定機制。WordPress、Laravel、Drupal 等 PHP 框架都依賴 &lt;code>.htaccess&lt;/code> 的 URL rewrite 規則來實現 pretty URL（把 &lt;code>/blog/post-title&lt;/code> 轉成 &lt;code>index.php?page=post-title&lt;/code>）。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>站台根目錄有 &lt;code>.htaccess&lt;/code> 檔案（注意它是隱藏檔，FTP client 要啟用「顯示隱藏檔案」才看得到）。上傳目錄（&lt;code>uploads/&lt;/code>）、後台目錄（&lt;code>admin/&lt;/code>、&lt;code>wp-admin/&lt;/code>）可能各有一份獨立的 &lt;code>.htaccess&lt;/code> 做額外的存取控制。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>接手維運時，&lt;code>.htaccess&lt;/code> 要注意四件事：&lt;/p>
&lt;p>&lt;strong>URL rewrite 規則&lt;/strong>：這些規則決定了站台的 URL 結構。亂改或刪除會讓所有內頁都回 404。修改前先備份原始版本。&lt;/p>
&lt;p>&lt;strong>安全設定&lt;/strong>：&lt;code>Options -Indexes&lt;/code> 禁止目錄列表、&lt;code>php_flag engine off&lt;/code> 禁止上傳目錄執行 PHP、&lt;code>Require all denied&lt;/code> 禁止存取 &lt;code>.env&lt;/code> 等機密檔案。這些設定分散在多個目錄的 &lt;code>.htaccess&lt;/code> 裡，接手時要全部找出來。&lt;/p>
&lt;p>&lt;strong>PHP 設定覆寫&lt;/strong>：部分 PHP 設定（如 &lt;code>upload_max_filesize&lt;/code>、&lt;code>max_execution_time&lt;/code>）可以在 &lt;code>.htaccess&lt;/code> 裡用 &lt;code>php_value&lt;/code> 或 &lt;code>php_flag&lt;/code> 指令覆寫。這些覆寫可能不在 &lt;code>php.ini&lt;/code> 裡，只存在於 &lt;code>.htaccess&lt;/code>。&lt;/p>
&lt;p>&lt;strong>遷移到 nginx 的影響&lt;/strong>：nginx 沒有 &lt;code>.htaccess&lt;/code> 的對等機制——所有設定都在集中的 nginx 設定檔裡。從 Apache 遷移到 nginx 時，&lt;code>.htaccess&lt;/code> 裡的每一條規則都要手動轉換成 nginx 語法。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/php-ini/" data-link-title="php.ini / .user.ini" data-link-desc="PHP 的執行期設定檔，控制記憶體上限、上傳大小、錯誤報告等 runtime 行為">php.ini / .user.ini&lt;/a>：&lt;code>.htaccess&lt;/code> 管 Apache 行為，&lt;code>.user.ini&lt;/code> 管 PHP 行為，兩者互補&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p><code>.htaccess</code>（Hypertext Access）是 Apache Web Server 的目錄層級設定檔。它讓使用者在沒有伺服器管理員權限的情況下，覆寫 Apache 的部分全域設定——包括 URL 重寫規則、目錄存取控制、PHP 設定覆寫、HTTPS 強制跳轉、以及 HTTP 安全標頭。每個目錄可以有自己的 <code>.htaccess</code>，Apache 處理請求時會從根目錄到目標目錄逐層讀取並套用。</p>
<h2 id="概念位置">概念位置</h2>
<p>在 Apache 為主的主機環境（多數共享主機和部分 VPS），<code>.htaccess</code> 是不需要重啟伺服器就能調整行為的設定機制。WordPress、Laravel、Drupal 等 PHP 框架都依賴 <code>.htaccess</code> 的 URL rewrite 規則來實現 pretty URL（把 <code>/blog/post-title</code> 轉成 <code>index.php?page=post-title</code>）。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>站台根目錄有 <code>.htaccess</code> 檔案（注意它是隱藏檔，FTP client 要啟用「顯示隱藏檔案」才看得到）。上傳目錄（<code>uploads/</code>）、後台目錄（<code>admin/</code>、<code>wp-admin/</code>）可能各有一份獨立的 <code>.htaccess</code> 做額外的存取控制。</p>
<h2 id="設計責任">設計責任</h2>
<p>接手維運時，<code>.htaccess</code> 要注意四件事：</p>
<p><strong>URL rewrite 規則</strong>：這些規則決定了站台的 URL 結構。亂改或刪除會讓所有內頁都回 404。修改前先備份原始版本。</p>
<p><strong>安全設定</strong>：<code>Options -Indexes</code> 禁止目錄列表、<code>php_flag engine off</code> 禁止上傳目錄執行 PHP、<code>Require all denied</code> 禁止存取 <code>.env</code> 等機密檔案。這些設定分散在多個目錄的 <code>.htaccess</code> 裡，接手時要全部找出來。</p>
<p><strong>PHP 設定覆寫</strong>：部分 PHP 設定（如 <code>upload_max_filesize</code>、<code>max_execution_time</code>）可以在 <code>.htaccess</code> 裡用 <code>php_value</code> 或 <code>php_flag</code> 指令覆寫。這些覆寫可能不在 <code>php.ini</code> 裡，只存在於 <code>.htaccess</code>。</p>
<p><strong>遷移到 nginx 的影響</strong>：nginx 沒有 <code>.htaccess</code> 的對等機制——所有設定都在集中的 nginx 設定檔裡。從 Apache 遷移到 nginx 時，<code>.htaccess</code> 裡的每一條規則都要手動轉換成 nginx 語法。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/php-ini/" data-link-title="php.ini / .user.ini" data-link-desc="PHP 的執行期設定檔，控制記憶體上限、上傳大小、錯誤報告等 runtime 行為">php.ini / .user.ini</a>：<code>.htaccess</code> 管 Apache 行為，<code>.user.ini</code> 管 PHP 行為，兩者互補</li>
</ul>
]]></content:encoded></item><item><title>.env</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/dotenv/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/dotenv/</guid><description>&lt;p>&lt;code>.env&lt;/code> 是一個純文字檔案，每行一組 &lt;code>KEY=VALUE&lt;/code> 的環境變數定義。它的用途是把機密值（資料庫密碼、API key、SMTP 憑證）和環境專屬設定（資料庫 host、debug 模式開關）從程式碼分離出來，讓同一份程式碼在不同環境（開發、staging、production）用不同的設定值，而且機密值不進版本控制。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>&lt;code>.env&lt;/code> 是跨語言的設定分離慣例。PHP 用 &lt;code>vlucas/phpdotenv&lt;/code> 套件讀取、Node.js 用 &lt;code>dotenv&lt;/code> 套件、Python 用 &lt;code>python-dotenv&lt;/code>、Go 用 &lt;code>godotenv&lt;/code>。這些套件的行為相同：程式啟動時讀 &lt;code>.env&lt;/code> 檔案，把裡面的變數載入到執行環境的環境變數裡，讓程式碼用 &lt;code>$_ENV['KEY']&lt;/code>（PHP）或 &lt;code>process.env.KEY&lt;/code>（Node）存取。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>站台根目錄有 &lt;code>.env&lt;/code> 或 &lt;code>.env.production&lt;/code> 檔案；&lt;code>.gitignore&lt;/code> 裡有 &lt;code>.env&lt;/code> 這一行；repo 裡有 &lt;code>.env.example&lt;/code> 或 &lt;code>.env.sample&lt;/code> 列出所有需要的變數但不填實際值。如果接手的專案沒有 &lt;code>.env&lt;/code> 但 &lt;code>config.php&lt;/code> 裡直接寫了資料庫密碼，代表設定分離還沒做——這是接手後應該處理的事。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>使用 &lt;code>.env&lt;/code> 時有三個紀律：&lt;/p>
&lt;p>&lt;strong>不進 Git&lt;/strong>：&lt;code>.env&lt;/code> 包含明文密碼，進了 Git 就跟著每一次 clone、fork、CI 快取擴散。&lt;code>.gitignore&lt;/code> 必須排除 &lt;code>.env&lt;/code>。如果 &lt;code>.env&lt;/code> 已經在 Git 歷史裡，刪掉那一行不夠——密碼留在 history 裡，要輪替所有外洩的密碼。&lt;/p>
&lt;p>&lt;strong>範本檔進 Git&lt;/strong>：repo 裡放一份 &lt;code>.env.example&lt;/code>，列出所有必要的環境變數但不填實際值。新接手的人複製 &lt;code>.env.example&lt;/code> 成 &lt;code>.env&lt;/code>，再填入自己環境的值。&lt;/p>
&lt;p>&lt;strong>不用 &lt;code>.env&lt;/code> 管非機密設定&lt;/strong>：應用程式的功能開關、UI 設定、feature flag 不屬於 &lt;code>.env&lt;/code>——這些設定沒有機密性、應該進版本控制。&lt;code>.env&lt;/code> 只放「換一個環境就要改的值」和「不能被看到的值」。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/php-ini/" data-link-title="php.ini / .user.ini" data-link-desc="PHP 的執行期設定檔，控制記憶體上限、上傳大小、錯誤報告等 runtime 行為">php.ini / .user.ini&lt;/a>：&lt;code>.env&lt;/code> 管應用程式設定、php.ini 管 PHP runtime 設定&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p><code>.env</code> 是一個純文字檔案，每行一組 <code>KEY=VALUE</code> 的環境變數定義。它的用途是把機密值（資料庫密碼、API key、SMTP 憑證）和環境專屬設定（資料庫 host、debug 模式開關）從程式碼分離出來，讓同一份程式碼在不同環境（開發、staging、production）用不同的設定值，而且機密值不進版本控制。</p>
<h2 id="概念位置">概念位置</h2>
<p><code>.env</code> 是跨語言的設定分離慣例。PHP 用 <code>vlucas/phpdotenv</code> 套件讀取、Node.js 用 <code>dotenv</code> 套件、Python 用 <code>python-dotenv</code>、Go 用 <code>godotenv</code>。這些套件的行為相同：程式啟動時讀 <code>.env</code> 檔案，把裡面的變數載入到執行環境的環境變數裡，讓程式碼用 <code>$_ENV['KEY']</code>（PHP）或 <code>process.env.KEY</code>（Node）存取。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>站台根目錄有 <code>.env</code> 或 <code>.env.production</code> 檔案；<code>.gitignore</code> 裡有 <code>.env</code> 這一行；repo 裡有 <code>.env.example</code> 或 <code>.env.sample</code> 列出所有需要的變數但不填實際值。如果接手的專案沒有 <code>.env</code> 但 <code>config.php</code> 裡直接寫了資料庫密碼，代表設定分離還沒做——這是接手後應該處理的事。</p>
<h2 id="設計責任">設計責任</h2>
<p>使用 <code>.env</code> 時有三個紀律：</p>
<p><strong>不進 Git</strong>：<code>.env</code> 包含明文密碼，進了 Git 就跟著每一次 clone、fork、CI 快取擴散。<code>.gitignore</code> 必須排除 <code>.env</code>。如果 <code>.env</code> 已經在 Git 歷史裡，刪掉那一行不夠——密碼留在 history 裡，要輪替所有外洩的密碼。</p>
<p><strong>範本檔進 Git</strong>：repo 裡放一份 <code>.env.example</code>，列出所有必要的環境變數但不填實際值。新接手的人複製 <code>.env.example</code> 成 <code>.env</code>，再填入自己環境的值。</p>
<p><strong>不用 <code>.env</code> 管非機密設定</strong>：應用程式的功能開關、UI 設定、feature flag 不屬於 <code>.env</code>——這些設定沒有機密性、應該進版本控制。<code>.env</code> 只放「換一個環境就要改的值」和「不能被看到的值」。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/php-ini/" data-link-title="php.ini / .user.ini" data-link-desc="PHP 的執行期設定檔，控制記憶體上限、上傳大小、錯誤報告等 runtime 行為">php.ini / .user.ini</a>：<code>.env</code> 管應用程式設定、php.ini 管 PHP runtime 設定</li>
</ul>
]]></content:encoded></item><item><title>php.ini / .user.ini</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/php-ini/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/php-ini/</guid><description>&lt;p>&lt;code>php.ini&lt;/code> 是 PHP 的全域設定檔，控制 PHP 的 runtime 行為——記憶體上限、檔案上傳大小、最大執行時間、錯誤報告層級、時區、session 處理方式。&lt;code>.user.ini&lt;/code> 是 PHP 5.3 之後支援的目錄層級覆寫機制，放在站台目錄裡可以覆寫部分 &lt;code>php.ini&lt;/code> 的設定，不需要伺服器管理員權限。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>&lt;code>php.ini&lt;/code> 由伺服器管理員管理，租用主機的使用者通常不能直接修改。&lt;code>.user.ini&lt;/code> 是使用者層級的設定覆寫——功能上類似 &lt;code>.htaccess&lt;/code> 對 Apache 的角色，但只管 PHP 設定。在 cPanel 環境裡，部分設定也可以透過「PHP 選擇器」的圖形介面調整。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>PHP 行為異常時要檢查的第一個地方。常見的情境：上傳檔案失敗（&lt;code>upload_max_filesize&lt;/code> 太小）、長時間運算被中斷（&lt;code>max_execution_time&lt;/code> 太短）、記憶體不足錯誤（&lt;code>memory_limit&lt;/code> 太低）、看不到錯誤訊息（&lt;code>display_errors&lt;/code> 關閉）。用 &lt;code>phpinfo()&lt;/code> 可以看到每一項設定的目前值和來源（&lt;code>php.ini&lt;/code> / &lt;code>.user.ini&lt;/code> / &lt;code>.htaccess&lt;/code>）。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>接手維運時要知道的關鍵設定：&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;code>memory_limit&lt;/code>&lt;/td>
 &lt;td>PHP 程式的記憶體上限&lt;/td>
 &lt;td>128M&lt;/td>
 &lt;td>大型操作（匯出、圖片處理）是否夠用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>upload_max_filesize&lt;/code>&lt;/td>
 &lt;td>單檔上傳大小上限&lt;/td>
 &lt;td>2M&lt;/td>
 &lt;td>是否符合業務需求&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>post_max_size&lt;/code>&lt;/td>
 &lt;td>POST 請求的總大小上限&lt;/td>
 &lt;td>8M&lt;/td>
 &lt;td>要大於 upload_max_filesize&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>max_execution_time&lt;/code>&lt;/td>
 &lt;td>PHP 腳本最大執行秒數&lt;/td>
 &lt;td>30&lt;/td>
 &lt;td>長時間操作（備份、匯入）是否需要加長&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>error_reporting&lt;/code>&lt;/td>
 &lt;td>顯示哪些層級的錯誤&lt;/td>
 &lt;td>E_ALL&lt;/td>
 &lt;td>開發時開到 E_ALL、production 時關 display_errors&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>display_errors&lt;/code>&lt;/td>
 &lt;td>是否在頁面上顯示錯誤&lt;/td>
 &lt;td>Off&lt;/td>
 &lt;td>production 應該關閉（錯誤寫 log 不顯示給使用者）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>.user.ini&lt;/code> 的修改不需要重啟 Apache/nginx，但有快取時間（預設 300 秒）——改完後要等最多 5 分鐘才生效。&lt;code>php.ini&lt;/code> 的修改在多數環境需要重啟 web server。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/htaccess/" data-link-title=".htaccess" data-link-desc="Apache Web Server 的目錄層級設定檔，控制 URL rewrite、存取權限、PHP 設定覆寫與安全標頭">.htaccess&lt;/a>：&lt;code>.htaccess&lt;/code> 管 Apache 行為（URL rewrite、存取控制），&lt;code>.user.ini&lt;/code> 管 PHP 行為（記憶體、執行時間），兩者互補&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/dotenv/" data-link-title=".env" data-link-desc="存放環境變數的純文字檔案，把機密值從程式碼分離出來">.env&lt;/a>：&lt;code>.env&lt;/code> 管應用程式設定（DB 密碼、API key），&lt;code>php.ini&lt;/code> 管 PHP runtime 設定（記憶體、上傳大小）&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p><code>php.ini</code> 是 PHP 的全域設定檔，控制 PHP 的 runtime 行為——記憶體上限、檔案上傳大小、最大執行時間、錯誤報告層級、時區、session 處理方式。<code>.user.ini</code> 是 PHP 5.3 之後支援的目錄層級覆寫機制，放在站台目錄裡可以覆寫部分 <code>php.ini</code> 的設定，不需要伺服器管理員權限。</p>
<h2 id="概念位置">概念位置</h2>
<p><code>php.ini</code> 由伺服器管理員管理，租用主機的使用者通常不能直接修改。<code>.user.ini</code> 是使用者層級的設定覆寫——功能上類似 <code>.htaccess</code> 對 Apache 的角色，但只管 PHP 設定。在 cPanel 環境裡，部分設定也可以透過「PHP 選擇器」的圖形介面調整。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>PHP 行為異常時要檢查的第一個地方。常見的情境：上傳檔案失敗（<code>upload_max_filesize</code> 太小）、長時間運算被中斷（<code>max_execution_time</code> 太短）、記憶體不足錯誤（<code>memory_limit</code> 太低）、看不到錯誤訊息（<code>display_errors</code> 關閉）。用 <code>phpinfo()</code> 可以看到每一項設定的目前值和來源（<code>php.ini</code> / <code>.user.ini</code> / <code>.htaccess</code>）。</p>
<h2 id="設計責任">設計責任</h2>
<p>接手維運時要知道的關鍵設定：</p>
<table>
  <thead>
      <tr>
          <th>設定</th>
          <th>作用</th>
          <th>常見預設值</th>
          <th>接手時要確認的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>memory_limit</code></td>
          <td>PHP 程式的記憶體上限</td>
          <td>128M</td>
          <td>大型操作（匯出、圖片處理）是否夠用</td>
      </tr>
      <tr>
          <td><code>upload_max_filesize</code></td>
          <td>單檔上傳大小上限</td>
          <td>2M</td>
          <td>是否符合業務需求</td>
      </tr>
      <tr>
          <td><code>post_max_size</code></td>
          <td>POST 請求的總大小上限</td>
          <td>8M</td>
          <td>要大於 upload_max_filesize</td>
      </tr>
      <tr>
          <td><code>max_execution_time</code></td>
          <td>PHP 腳本最大執行秒數</td>
          <td>30</td>
          <td>長時間操作（備份、匯入）是否需要加長</td>
      </tr>
      <tr>
          <td><code>error_reporting</code></td>
          <td>顯示哪些層級的錯誤</td>
          <td>E_ALL</td>
          <td>開發時開到 E_ALL、production 時關 display_errors</td>
      </tr>
      <tr>
          <td><code>display_errors</code></td>
          <td>是否在頁面上顯示錯誤</td>
          <td>Off</td>
          <td>production 應該關閉（錯誤寫 log 不顯示給使用者）</td>
      </tr>
  </tbody>
</table>
<p><code>.user.ini</code> 的修改不需要重啟 Apache/nginx，但有快取時間（預設 300 秒）——改完後要等最多 5 分鐘才生效。<code>php.ini</code> 的修改在多數環境需要重啟 web server。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/htaccess/" data-link-title=".htaccess" data-link-desc="Apache Web Server 的目錄層級設定檔，控制 URL rewrite、存取權限、PHP 設定覆寫與安全標頭">.htaccess</a>：<code>.htaccess</code> 管 Apache 行為（URL rewrite、存取控制），<code>.user.ini</code> 管 PHP 行為（記憶體、執行時間），兩者互補</li>
<li><a href="/blog/infra/knowledge-cards/dotenv/" data-link-title=".env" data-link-desc="存放環境變數的純文字檔案，把機密值從程式碼分離出來">.env</a>：<code>.env</code> 管應用程式設定（DB 密碼、API key），<code>php.ini</code> 管 PHP runtime 設定（記憶體、上傳大小）</li>
</ul>
]]></content:encoded></item><item><title>DNS</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/dns/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/dns/</guid><description>&lt;p>DNS（Domain Name System）是把人類可讀的域名（&lt;code>example.com&lt;/code>）轉成機器可達的 IP 位址（&lt;code>93.184.216.34&lt;/code>）的分散式查詢系統。瀏覽器輸入網址後，作業系統先查本地快取、再逐層查詢 DNS server，最終拿到 IP 才能建立連線。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>DNS 在 infra 裡扮演「服務的門牌」角色。平台遷移、環境切換、TLS 憑證驗證都經過 DNS。ALB 或 CDN 前面通常掛一層 DNS record 作為穩定入口——IP 會隨資源重建而變，DNS 名稱不變。&lt;/p>
&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>A&lt;/td>
 &lt;td>IPv4 位址&lt;/td>
 &lt;td>主要的域名 → IP 對應&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AAAA&lt;/td>
 &lt;td>IPv6 位址&lt;/td>
 &lt;td>IPv6 環境&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CNAME&lt;/td>
 &lt;td>另一個域名&lt;/td>
 &lt;td>別名（&lt;code>www&lt;/code> → &lt;code>example.com&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>NS&lt;/td>
 &lt;td>負責管理的 DNS server&lt;/td>
 &lt;td>子域委派（dev.example.com）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>MX&lt;/td>
 &lt;td>郵件伺服器&lt;/td>
 &lt;td>email routing&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TXT&lt;/td>
 &lt;td>任意文字&lt;/td>
 &lt;td>SPF / DKIM / 域名驗證（ACM）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Alias&lt;/td>
 &lt;td>AWS 特有，指向 ALB 等&lt;/td>
 &lt;td>跟 A record 等效但支援 zone apex&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>平台遷移時 DNS 切換是最後一步也是最不可控的一步——TTL（Time To Live）決定舊記錄被各地 DNS resolver 快取多久。TTL 300 秒代表切換後最多 5 分鐘全部 client 會指向新 IP；TTL 86400（1 天）代表最慢要等一天。遷移前 48 小時先降 TTL 到 300 秒，讓快取過期後所有 resolver 都拿到短 TTL 版本，切換時才能快速生效。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>DNS 設定要決定：誰管這個域名的 zone（Route 53 / Cloudflare / 域名商）、子域怎麼委派（dev / staging 用 NS delegation 交給不同 zone）、TTL 設多少（平常 3600 秒夠用、遷移前降到 300）。ACM 的 DNS 驗證也依賴 DNS——建立 TXT 或 CNAME 記錄證明域名歸屬。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB&lt;/a> — DNS 記錄通常指向 ALB 作為流量入口&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/ssl-tls/" data-link-title="SSL / TLS" data-link-desc="加密 client 與 server 之間通訊的協定，讓 HTTPS 成為可能。TLS 是 SSL 的後繼者，但 SSL 憑證的稱呼仍廣泛使用">SSL/TLS&lt;/a> — TLS 憑證的 DNS 驗證依賴 DNS record&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>DNS（Domain Name System）是把人類可讀的域名（<code>example.com</code>）轉成機器可達的 IP 位址（<code>93.184.216.34</code>）的分散式查詢系統。瀏覽器輸入網址後，作業系統先查本地快取、再逐層查詢 DNS server，最終拿到 IP 才能建立連線。</p>
<h2 id="概念位置">概念位置</h2>
<p>DNS 在 infra 裡扮演「服務的門牌」角色。平台遷移、環境切換、TLS 憑證驗證都經過 DNS。ALB 或 CDN 前面通常掛一層 DNS record 作為穩定入口——IP 會隨資源重建而變，DNS 名稱不變。</p>
<h2 id="常見的記錄類型">常見的記錄類型</h2>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>指向什麼</th>
          <th>典型用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>A</td>
          <td>IPv4 位址</td>
          <td>主要的域名 → IP 對應</td>
      </tr>
      <tr>
          <td>AAAA</td>
          <td>IPv6 位址</td>
          <td>IPv6 環境</td>
      </tr>
      <tr>
          <td>CNAME</td>
          <td>另一個域名</td>
          <td>別名（<code>www</code> → <code>example.com</code>）</td>
      </tr>
      <tr>
          <td>NS</td>
          <td>負責管理的 DNS server</td>
          <td>子域委派（dev.example.com）</td>
      </tr>
      <tr>
          <td>MX</td>
          <td>郵件伺服器</td>
          <td>email routing</td>
      </tr>
      <tr>
          <td>TXT</td>
          <td>任意文字</td>
          <td>SPF / DKIM / 域名驗證（ACM）</td>
      </tr>
      <tr>
          <td>Alias</td>
          <td>AWS 特有，指向 ALB 等</td>
          <td>跟 A record 等效但支援 zone apex</td>
      </tr>
  </tbody>
</table>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>平台遷移時 DNS 切換是最後一步也是最不可控的一步——TTL（Time To Live）決定舊記錄被各地 DNS resolver 快取多久。TTL 300 秒代表切換後最多 5 分鐘全部 client 會指向新 IP；TTL 86400（1 天）代表最慢要等一天。遷移前 48 小時先降 TTL 到 300 秒，讓快取過期後所有 resolver 都拿到短 TTL 版本，切換時才能快速生效。</p>
<h2 id="設計責任">設計責任</h2>
<p>DNS 設定要決定：誰管這個域名的 zone（Route 53 / Cloudflare / 域名商）、子域怎麼委派（dev / staging 用 NS delegation 交給不同 zone）、TTL 設多少（平常 3600 秒夠用、遷移前降到 300）。ACM 的 DNS 驗證也依賴 DNS——建立 TXT 或 CNAME 記錄證明域名歸屬。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB</a> — DNS 記錄通常指向 ALB 作為流量入口</li>
<li><a href="/blog/infra/knowledge-cards/ssl-tls/" data-link-title="SSL / TLS" data-link-desc="加密 client 與 server 之間通訊的協定，讓 HTTPS 成為可能。TLS 是 SSL 的後繼者，但 SSL 憑證的稱呼仍廣泛使用">SSL/TLS</a> — TLS 憑證的 DNS 驗證依賴 DNS record</li>
</ul>
]]></content:encoded></item><item><title>SSL / TLS</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/ssl-tls/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/ssl-tls/</guid><description>&lt;p>TLS（Transport Layer Security）加密 client 與 server 之間的通訊，防止中間人竊聽或竄改。HTTPS 就是 HTTP 加上 TLS 加密層。SSL 是 TLS 的前身、所有版本都已被棄用，但「SSL 憑證」這個稱呼仍然廣泛使用——實際上指的是 TLS 憑證。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>TLS 在 infra 裡負責「傳輸安全」。ALB 的 HTTPS listener 需要掛一張 TLS 憑證；ACM（AWS Certificate Manager）提供免費的憑證申請與自動續期；Let&amp;rsquo;s Encrypt 是跨平台的免費 CA（Certificate Authority，憑證簽發機構）。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>TLS 憑證有到期日。過期的憑證會讓瀏覽器顯示安全警告、部分 client 直接拒絕連線。ACM 管理的憑證會自動續期（前提是 DNS 驗證記錄仍然存在）；手動上傳的憑證需要人工追蹤到期日。接手維運時要確認：憑證的簽發者是誰、到期日是什麼時候、續期是自動還是手動。&lt;/p>
&lt;p>用 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="nb">echo&lt;/span> &lt;span class="p">|&lt;/span> openssl s_client -connect example.com:443 2&amp;gt;/dev/null &lt;span class="p">|&lt;/span> openssl x509 -noout -dates -issuer&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>TLS 設定要決定：憑證從哪裡來（ACM 免費但只能用在 AWS 服務上、Let&amp;rsquo;s Encrypt 免費且跨平台）、驗證方式（DNS 驗證適合自動化、email 驗證較手動）、是否需要多域名的 SAN 憑證（一張憑證涵蓋 &lt;code>example.com&lt;/code> + &lt;code>*.example.com&lt;/code>）、HTTP → HTTPS 的強制跳轉怎麼設。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/dns/" data-link-title="DNS" data-link-desc="Domain Name System — 把域名轉成 IP 位址的系統，以及 A record、CNAME、NS、TTL 的角色">DNS&lt;/a> — TLS 憑證的 DNS 驗證依賴 DNS record&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB&lt;/a> — HTTPS listener 需要掛 TLS 憑證&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>TLS（Transport Layer Security）加密 client 與 server 之間的通訊，防止中間人竊聽或竄改。HTTPS 就是 HTTP 加上 TLS 加密層。SSL 是 TLS 的前身、所有版本都已被棄用，但「SSL 憑證」這個稱呼仍然廣泛使用——實際上指的是 TLS 憑證。</p>
<h2 id="概念位置">概念位置</h2>
<p>TLS 在 infra 裡負責「傳輸安全」。ALB 的 HTTPS listener 需要掛一張 TLS 憑證；ACM（AWS Certificate Manager）提供免費的憑證申請與自動續期；Let&rsquo;s Encrypt 是跨平台的免費 CA（Certificate Authority，憑證簽發機構）。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>TLS 憑證有到期日。過期的憑證會讓瀏覽器顯示安全警告、部分 client 直接拒絕連線。ACM 管理的憑證會自動續期（前提是 DNS 驗證記錄仍然存在）；手動上傳的憑證需要人工追蹤到期日。接手維運時要確認：憑證的簽發者是誰、到期日是什麼時候、續期是自動還是手動。</p>
<p>用 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="nb">echo</span> <span class="p">|</span> openssl s_client -connect example.com:443 2&gt;/dev/null <span class="p">|</span> openssl x509 -noout -dates -issuer</span></span></code></pre></div><h2 id="設計責任">設計責任</h2>
<p>TLS 設定要決定：憑證從哪裡來（ACM 免費但只能用在 AWS 服務上、Let&rsquo;s Encrypt 免費且跨平台）、驗證方式（DNS 驗證適合自動化、email 驗證較手動）、是否需要多域名的 SAN 憑證（一張憑證涵蓋 <code>example.com</code> + <code>*.example.com</code>）、HTTP → HTTPS 的強制跳轉怎麼設。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/dns/" data-link-title="DNS" data-link-desc="Domain Name System — 把域名轉成 IP 位址的系統，以及 A record、CNAME、NS、TTL 的角色">DNS</a> — TLS 憑證的 DNS 驗證依賴 DNS record</li>
<li><a href="/blog/infra/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB</a> — HTTPS listener 需要掛 TLS 憑證</li>
</ul>
]]></content:encoded></item><item><title>SSH</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/ssh/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/ssh/</guid><description>&lt;p>SSH（Secure Shell）是加密的遠端 shell 連線協定，讓操作者在本地終端機執行遠端伺服器上的指令。連線建立後，操作者看到的是遠端伺服器的命令列——可以跑任何該伺服器上安裝的 CLI 工具。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>SSH 在接手維運的情境裡是一條關鍵分界線：有 SSH 存取就能用 &lt;code>mysqldump&lt;/code>、&lt;code>git&lt;/code>、&lt;code>systemctl&lt;/code> 等 CLI 工具操作伺服器；沒有 SSH 就只能用 FTP 傳檔案、用 phpMyAdmin 管資料庫、用 cPanel 改設定。兩種情境的操作流程和可用工具完全不同。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>判斷有沒有 SSH 存取：嘗試 &lt;code>ssh user@host&lt;/code>。如果連線成功進入命令列就有；如果 timeout 或被拒，可能是主機不開放 SSH（共享主機常見）、或 port 不是預設的 22、或需要 IP 白名單。cPanel 的「終端機」功能有時提供 web-based SSH，但功能受限。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>SSH 的認證方式有兩種：密碼（簡單但不安全，容易被暴力嘗試）和 SSH key pair（公鑰放在 server 的 &lt;code>~/.ssh/authorized_keys&lt;/code>，私鑰留在 client）。生產環境應該用 key 認證並關閉密碼登入。&lt;/p>
&lt;p>接手維運時要確認：SSH 的登入帳號是什麼、用密碼還是 key、key 在哪裡、有沒有其他人也有存取權限。前任維護者的 SSH key 如果還在 &lt;code>authorized_keys&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="c1"># 產生 SSH key pair&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">ssh-keygen -t ed25519 -C &lt;span class="s2">&amp;#34;your-email@example.com&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="c1"># 把公鑰加到遠端 server&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">ssh-copy-id -i ~/.ssh/id_ed25519.pub user@host&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/ftp/" data-link-title="FTP" data-link-desc="File Transfer Protocol — 檔案傳輸協定，無 SSH 環境的主要檔案管理方式。SFTP 和 FTPS 是其加密變體">FTP&lt;/a> — 沒有 SSH 時的檔案傳輸替代方案&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>SSH（Secure Shell）是加密的遠端 shell 連線協定，讓操作者在本地終端機執行遠端伺服器上的指令。連線建立後，操作者看到的是遠端伺服器的命令列——可以跑任何該伺服器上安裝的 CLI 工具。</p>
<h2 id="概念位置">概念位置</h2>
<p>SSH 在接手維運的情境裡是一條關鍵分界線：有 SSH 存取就能用 <code>mysqldump</code>、<code>git</code>、<code>systemctl</code> 等 CLI 工具操作伺服器；沒有 SSH 就只能用 FTP 傳檔案、用 phpMyAdmin 管資料庫、用 cPanel 改設定。兩種情境的操作流程和可用工具完全不同。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>判斷有沒有 SSH 存取：嘗試 <code>ssh user@host</code>。如果連線成功進入命令列就有；如果 timeout 或被拒，可能是主機不開放 SSH（共享主機常見）、或 port 不是預設的 22、或需要 IP 白名單。cPanel 的「終端機」功能有時提供 web-based SSH，但功能受限。</p>
<h2 id="設計責任">設計責任</h2>
<p>SSH 的認證方式有兩種：密碼（簡單但不安全，容易被暴力嘗試）和 SSH key pair（公鑰放在 server 的 <code>~/.ssh/authorized_keys</code>，私鑰留在 client）。生產環境應該用 key 認證並關閉密碼登入。</p>
<p>接手維運時要確認：SSH 的登入帳號是什麼、用密碼還是 key、key 在哪裡、有沒有其他人也有存取權限。前任維護者的 SSH key 如果還在 <code>authorized_keys</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"># 產生 SSH key pair</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ssh-keygen -t ed25519 -C <span class="s2">&#34;your-email@example.com&#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="c1"># 把公鑰加到遠端 server</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">ssh-copy-id -i ~/.ssh/id_ed25519.pub user@host</span></span></code></pre></div><h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/ftp/" data-link-title="FTP" data-link-desc="File Transfer Protocol — 檔案傳輸協定，無 SSH 環境的主要檔案管理方式。SFTP 和 FTPS 是其加密變體">FTP</a> — 沒有 SSH 時的檔案傳輸替代方案</li>
</ul>
]]></content:encoded></item><item><title>FTP</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/ftp/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/ftp/</guid><description>&lt;p>FTP（File Transfer Protocol）是把檔案在本地電腦與遠端伺服器之間上傳/下載的協定。操作者透過 FTP client（如 FileZilla）連線到伺服器，看到遠端的目錄結構，用拖放或指令傳輸檔案。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>FTP 在無 SSH 的環境裡是唯一的檔案管理途徑——程式碼部署靠 FTP 上傳、備份靠 FTP 下載、檔案比對靠 FTP client 的目錄比較功能。它是接手維運模組「無 SSH 環境」路線的核心工具。&lt;/p>
&lt;h2 id="ftp-的變體">FTP 的變體&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>FTP&lt;/td>
 &lt;td>無加密（明文傳輸）&lt;/td>
 &lt;td>老舊主機、內部網路&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>FTPS&lt;/td>
 &lt;td>FTP + TLS 加密&lt;/td>
 &lt;td>支援 SSL 的主機&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>SFTP&lt;/td>
 &lt;td>走 SSH 通道（完全不同協定）&lt;/td>
 &lt;td>有 SSH 存取的伺服器&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>多數 FTP client（FileZilla、WinSCP）同時支援三種協定。如果伺服器有 SSH，用 SFTP 比 FTP 安全且功能更多。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>FTP 操作的三個限制在接手維運時要意識到：第一，非原子操作——檔案逐一上傳，上傳過程中伺服器上同時存在新舊版本的混合狀態。第二，不支援指令執行——只能傳檔案、不能跑腳本或重啟服務。第三，沒有版本控制——上傳覆蓋就是覆蓋，沒有 diff、沒有 rollback。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>用 FTP 部署時要建立的紀律：本地先 Git commit 再上傳（Git 提供版本控制、FTP 只負責傳輸）；上傳前用目錄比較確認差異；關鍵檔案（&lt;code>index.php&lt;/code>、&lt;code>.htaccess&lt;/code>）上傳前先從 server 下載一份備份。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/ssh/" data-link-title="SSH" data-link-desc="Secure Shell — 加密的遠端 shell 連線，有 SSH 等於有 CLI 工具鏈，沒有就只能靠 FTP 和 web 面板">SSH&lt;/a> — 有 SSH 時用 SFTP 或 SCP 替代 FTP&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/filezilla/" data-link-title="FileZilla" data-link-desc="跨平台的 FTP/SFTP client，提供目錄同步瀏覽和檔案比較功能">FileZilla&lt;/a> — 最常用的 FTP client&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>FTP（File Transfer Protocol）是把檔案在本地電腦與遠端伺服器之間上傳/下載的協定。操作者透過 FTP client（如 FileZilla）連線到伺服器，看到遠端的目錄結構，用拖放或指令傳輸檔案。</p>
<h2 id="概念位置">概念位置</h2>
<p>FTP 在無 SSH 的環境裡是唯一的檔案管理途徑——程式碼部署靠 FTP 上傳、備份靠 FTP 下載、檔案比對靠 FTP client 的目錄比較功能。它是接手維運模組「無 SSH 環境」路線的核心工具。</p>
<h2 id="ftp-的變體">FTP 的變體</h2>
<table>
  <thead>
      <tr>
          <th>協定</th>
          <th>加密方式</th>
          <th>常見情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>FTP</td>
          <td>無加密（明文傳輸）</td>
          <td>老舊主機、內部網路</td>
      </tr>
      <tr>
          <td>FTPS</td>
          <td>FTP + TLS 加密</td>
          <td>支援 SSL 的主機</td>
      </tr>
      <tr>
          <td>SFTP</td>
          <td>走 SSH 通道（完全不同協定）</td>
          <td>有 SSH 存取的伺服器</td>
      </tr>
  </tbody>
</table>
<p>多數 FTP client（FileZilla、WinSCP）同時支援三種協定。如果伺服器有 SSH，用 SFTP 比 FTP 安全且功能更多。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>FTP 操作的三個限制在接手維運時要意識到：第一，非原子操作——檔案逐一上傳，上傳過程中伺服器上同時存在新舊版本的混合狀態。第二，不支援指令執行——只能傳檔案、不能跑腳本或重啟服務。第三，沒有版本控制——上傳覆蓋就是覆蓋，沒有 diff、沒有 rollback。</p>
<h2 id="設計責任">設計責任</h2>
<p>用 FTP 部署時要建立的紀律：本地先 Git commit 再上傳（Git 提供版本控制、FTP 只負責傳輸）；上傳前用目錄比較確認差異；關鍵檔案（<code>index.php</code>、<code>.htaccess</code>）上傳前先從 server 下載一份備份。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/ssh/" data-link-title="SSH" data-link-desc="Secure Shell — 加密的遠端 shell 連線，有 SSH 等於有 CLI 工具鏈，沒有就只能靠 FTP 和 web 面板">SSH</a> — 有 SSH 時用 SFTP 或 SCP 替代 FTP</li>
<li><a href="/blog/infra/knowledge-cards/filezilla/" data-link-title="FileZilla" data-link-desc="跨平台的 FTP/SFTP client，提供目錄同步瀏覽和檔案比較功能">FileZilla</a> — 最常用的 FTP client</li>
</ul>
]]></content:encoded></item><item><title>cron</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/cron/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/cron/</guid><description>&lt;p>cron 是 Unix/Linux 系統內建的排程工作管理器，按預定的時間表自動執行指令。一個 cron job 定義「什麼時間跑什麼指令」，系統背景的 cron daemon 負責到時間就執行。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>cron 在接手維運時是容易被忽略的隱藏工作——它不像 web 服務有明顯的入口，但可能負責資料庫備份、快取清除、報表產出、日誌清理等關鍵任務。漏掉一個 cron job 可能讓備份停止、快取永不過期、報表不再更新，而且不會立刻有人發現。&lt;/p>
&lt;h2 id="crontab-格式">crontab 格式&lt;/h2>





&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"># 分 時 日 月 週 指令
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">0 3 * * * /usr/bin/php /var/www/backup.php
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">*/5 * * * * /usr/bin/curl -s https://example.com/cron/heartbeat
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">0 0 1 * * /usr/bin/find /tmp -mtime +7 -delete&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>五個時間欄位依序是分鐘（0-59）、小時（0-23）、日（1-31）、月（1-12）、星期幾（0-7，0 和 7 都是星期日）。&lt;code>*&lt;/code> 代表「每一個」，&lt;code>*/5&lt;/code> 代表「每 5 個」。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>接手維運時盤點 cron job：&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"># 當前使用者的 crontab&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">crontab -l
&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"># 所有使用者的 crontab（需要 root）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="k">for&lt;/span> user in &lt;span class="k">$(&lt;/span>cut -f1 -d: /etc/passwd&lt;span class="k">)&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="k">do&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> crontab -u &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$user&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> -l 2&amp;gt;/dev/null &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;=== &lt;/span>&lt;span class="nv">$user&lt;/span>&lt;span class="s2"> ===&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="k">done&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 class="c1"># 系統級 cron&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">cat /etc/crontab
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">ls /etc/cron.d/&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>沒有 SSH 時（cPanel 環境），在 cPanel 的「Cron 工作」頁面查看和匯出。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>cron job 要決定：排程頻率、執行失敗時的通知方式（cron 預設把輸出寄 email，但 email 常沒配好）、日誌記錄（指令的 stdout/stderr 導到 log 檔）。遷移或升級時，cron job 要隨著遷移——忘了搬等於停掉排程但沒人知道。&lt;/p>
&lt;p>雲端替代品：AWS CloudWatch Events / EventBridge、GCP Cloud Scheduler、Azure Logic Apps。這些服務提供 web UI 管理、失敗通知、執行歷史，但需要額外設定。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/ssh/" data-link-title="SSH" data-link-desc="Secure Shell — 加密的遠端 shell 連線，有 SSH 等於有 CLI 工具鏈，沒有就只能靠 FTP 和 web 面板">SSH&lt;/a> — 盤點和管理 cron 需要 SSH 存取&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>cron 是 Unix/Linux 系統內建的排程工作管理器，按預定的時間表自動執行指令。一個 cron job 定義「什麼時間跑什麼指令」，系統背景的 cron daemon 負責到時間就執行。</p>
<h2 id="概念位置">概念位置</h2>
<p>cron 在接手維運時是容易被忽略的隱藏工作——它不像 web 服務有明顯的入口，但可能負責資料庫備份、快取清除、報表產出、日誌清理等關鍵任務。漏掉一個 cron job 可能讓備份停止、快取永不過期、報表不再更新，而且不會立刻有人發現。</p>
<h2 id="crontab-格式">crontab 格式</h2>





<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"># 分 時 日 月 週  指令
</span></span><span class="line"><span class="ln">2</span><span class="cl">0  3  *  *  *    /usr/bin/php /var/www/backup.php
</span></span><span class="line"><span class="ln">3</span><span class="cl">*/5 * *  *  *    /usr/bin/curl -s https://example.com/cron/heartbeat
</span></span><span class="line"><span class="ln">4</span><span class="cl">0  0  1  *  *    /usr/bin/find /tmp -mtime +7 -delete</span></span></code></pre></div><p>五個時間欄位依序是分鐘（0-59）、小時（0-23）、日（1-31）、月（1-12）、星期幾（0-7，0 和 7 都是星期日）。<code>*</code> 代表「每一個」，<code>*/5</code> 代表「每 5 個」。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>接手維運時盤點 cron job：</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"># 當前使用者的 crontab</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">crontab -l
</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"># 所有使用者的 crontab（需要 root）</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">for</span> user in <span class="k">$(</span>cut -f1 -d: /etc/passwd<span class="k">)</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  crontab -u <span class="s2">&#34;</span><span class="nv">$user</span><span class="s2">&#34;</span> -l 2&gt;/dev/null <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">&#34;=== </span><span class="nv">$user</span><span class="s2"> ===&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">done</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="c1"># 系統級 cron</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">cat /etc/crontab
</span></span><span class="line"><span class="ln">11</span><span class="cl">ls /etc/cron.d/</span></span></code></pre></div><p>沒有 SSH 時（cPanel 環境），在 cPanel 的「Cron 工作」頁面查看和匯出。</p>
<h2 id="設計責任">設計責任</h2>
<p>cron job 要決定：排程頻率、執行失敗時的通知方式（cron 預設把輸出寄 email，但 email 常沒配好）、日誌記錄（指令的 stdout/stderr 導到 log 檔）。遷移或升級時，cron job 要隨著遷移——忘了搬等於停掉排程但沒人知道。</p>
<p>雲端替代品：AWS CloudWatch Events / EventBridge、GCP Cloud Scheduler、Azure Logic Apps。這些服務提供 web UI 管理、失敗通知、執行歷史，但需要額外設定。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/ssh/" data-link-title="SSH" data-link-desc="Secure Shell — 加密的遠端 shell 連線，有 SSH 等於有 CLI 工具鏈，沒有就只能靠 FTP 和 web 面板">SSH</a> — 盤點和管理 cron 需要 SSH 存取</li>
</ul>
]]></content:encoded></item><item><title>nginx</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/nginx/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/nginx/</guid><description>&lt;p>nginx 是高效能的 Web Server 和 Reverse Proxy，以非同步事件驅動架構處理大量並發連線。它在全球 web server 市場佔有率與 Apache 並列前二，新部署的伺服器多數選 nginx。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>nginx 在 infra 裡常見的角色有三種：作為 reverse proxy 把請求轉給後端應用（Node.js、PHP-FPM、Python WSGI）、作為靜態檔案伺服器、作為 TLS 終結點處理 HTTPS。ALB 在雲端環境承擔了部分 nginx 的職責（負載平衡、TLS 終結），但 VPS 環境裡 nginx 仍然是標準選擇。&lt;/p>
&lt;h2 id="跟-apache-的關鍵差別">跟 Apache 的關鍵差別&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>面向&lt;/th>
 &lt;th>nginx&lt;/th>
 &lt;th>Apache&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>設定模式&lt;/td>
 &lt;td>集中式（&lt;code>/etc/nginx/&lt;/code> 下的設定檔）&lt;/td>
 &lt;td>支援 .htaccess 分散式設定&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>並發模型&lt;/td>
 &lt;td>事件驅動、非阻塞&lt;/td>
 &lt;td>預設 prefork（每個請求一個 process）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>PHP 整合&lt;/td>
 &lt;td>透過 FastCGI（PHP-FPM）&lt;/td>
 &lt;td>mod_php（直接嵌入）或 FastCGI&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>URL rewrite&lt;/td>
 &lt;td>&lt;code>location&lt;/code> + &lt;code>rewrite&lt;/code> 區塊&lt;/td>
 &lt;td>&lt;code>.htaccess&lt;/code> 的 &lt;code>RewriteRule&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>OS 升級或平台遷移時，如果從 Apache 換成 nginx，所有 &lt;code>.htaccess&lt;/code> 規則要手動轉成 nginx 設定：URL rewrite、目錄保護、PHP 設定覆寫、安全標頭。nginx 沒有 &lt;code>.htaccess&lt;/code> 的等價物——所有設定都在集中的設定檔裡，需要 reload nginx 才能生效（Apache 的 &lt;code>.htaccess&lt;/code> 每次請求都重新讀取）。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>nginx 設定要決定：server block（類似 Apache 的 VirtualHost）怎麼組織、upstream 指向哪個 app server、靜態檔案的 root 路徑、TLS 憑證掛在哪裡、access log 和 error log 的路徑。設定改完跑 &lt;code>nginx -t&lt;/code> 驗證語法後再 &lt;code>nginx -s reload&lt;/code>。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/htaccess/" data-link-title=".htaccess" data-link-desc="Apache Web Server 的目錄層級設定檔，控制 URL rewrite、存取權限、PHP 設定覆寫與安全標頭">.htaccess&lt;/a> — Apache 的分散設定，遷移到 nginx 時需要轉換&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB&lt;/a> — 雲端環境裡承擔部分 nginx 職責&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>nginx 是高效能的 Web Server 和 Reverse Proxy，以非同步事件驅動架構處理大量並發連線。它在全球 web server 市場佔有率與 Apache 並列前二，新部署的伺服器多數選 nginx。</p>
<h2 id="概念位置">概念位置</h2>
<p>nginx 在 infra 裡常見的角色有三種：作為 reverse proxy 把請求轉給後端應用（Node.js、PHP-FPM、Python WSGI）、作為靜態檔案伺服器、作為 TLS 終結點處理 HTTPS。ALB 在雲端環境承擔了部分 nginx 的職責（負載平衡、TLS 終結），但 VPS 環境裡 nginx 仍然是標準選擇。</p>
<h2 id="跟-apache-的關鍵差別">跟 Apache 的關鍵差別</h2>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>nginx</th>
          <th>Apache</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>設定模式</td>
          <td>集中式（<code>/etc/nginx/</code> 下的設定檔）</td>
          <td>支援 .htaccess 分散式設定</td>
      </tr>
      <tr>
          <td>並發模型</td>
          <td>事件驅動、非阻塞</td>
          <td>預設 prefork（每個請求一個 process）</td>
      </tr>
      <tr>
          <td>PHP 整合</td>
          <td>透過 FastCGI（PHP-FPM）</td>
          <td>mod_php（直接嵌入）或 FastCGI</td>
      </tr>
      <tr>
          <td>URL rewrite</td>
          <td><code>location</code> + <code>rewrite</code> 區塊</td>
          <td><code>.htaccess</code> 的 <code>RewriteRule</code></td>
      </tr>
  </tbody>
</table>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>OS 升級或平台遷移時，如果從 Apache 換成 nginx，所有 <code>.htaccess</code> 規則要手動轉成 nginx 設定：URL rewrite、目錄保護、PHP 設定覆寫、安全標頭。nginx 沒有 <code>.htaccess</code> 的等價物——所有設定都在集中的設定檔裡，需要 reload nginx 才能生效（Apache 的 <code>.htaccess</code> 每次請求都重新讀取）。</p>
<h2 id="設計責任">設計責任</h2>
<p>nginx 設定要決定：server block（類似 Apache 的 VirtualHost）怎麼組織、upstream 指向哪個 app server、靜態檔案的 root 路徑、TLS 憑證掛在哪裡、access log 和 error log 的路徑。設定改完跑 <code>nginx -t</code> 驗證語法後再 <code>nginx -s reload</code>。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/htaccess/" data-link-title=".htaccess" data-link-desc="Apache Web Server 的目錄層級設定檔，控制 URL rewrite、存取權限、PHP 設定覆寫與安全標頭">.htaccess</a> — Apache 的分散設定，遷移到 nginx 時需要轉換</li>
<li><a href="/blog/infra/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB</a> — 雲端環境裡承擔部分 nginx 職責</li>
</ul>
]]></content:encoded></item><item><title>MySQL</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/mysql/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/mysql/</guid><description>&lt;p>MySQL 是最廣泛使用的開源關聯式資料庫，多數 PHP 應用、WordPress、以及大量 web 服務的資料層都跑在 MySQL 上。MariaDB 是 MySQL 被 Oracle 收購後社群分支出來的相容實作，多數 Linux 發行版已經把預設的 mysql 套件指向 MariaDB。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>MySQL 在 infra 裡是典型的 stateful 資源——資料不可重建、備份和刪除保護是 day-1 需求。接手維運時，MySQL 的版本、備份設定、認證方式是第一批要確認的項目。雲端環境裡 MySQL 常以 RDS 形式運行（受管服務、代管備份與 failover）。&lt;/p>
&lt;h2 id="大版本升級的關鍵差異">大版本升級的關鍵差異&lt;/h2>
&lt;p>MySQL 5.7 → 8.0 的 breaking change 在接手和升級情境裡經常遇到：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>變更項&lt;/th>
 &lt;th>5.7 行為&lt;/th>
 &lt;th>8.0 行為&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>預設認證方式&lt;/td>
 &lt;td>&lt;code>mysql_native_password&lt;/code>&lt;/td>
 &lt;td>&lt;code>caching_sha2_password&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>GROUP BY&lt;/code> 隱式排序&lt;/td>
 &lt;td>有（按 group 欄位排）&lt;/td>
 &lt;td>無（需要明確 &lt;code>ORDER BY&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>預設字元集&lt;/td>
 &lt;td>&lt;code>utf8&lt;/code>（3 byte）&lt;/td>
 &lt;td>&lt;code>utf8mb4&lt;/code>（4 byte、支援 emoji）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>GRANT&lt;/code> 同時建使用者&lt;/td>
 &lt;td>允許&lt;/td>
 &lt;td>必須先 &lt;code>CREATE USER&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>接手維運時的確認清單：&lt;code>SELECT VERSION();&lt;/code> 查版本、&lt;code>SHOW DATABASES;&lt;/code> 看有哪些資料庫、&lt;code>SHOW VARIABLES LIKE 'character_set%';&lt;/code> 確認字元集、&lt;code>SHOW VARIABLES LIKE 'max_connections';&lt;/code> 看連線上限。&lt;/p>
&lt;h2 id="cli-工具">CLI 工具&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;code>mysql&lt;/code>&lt;/td>
 &lt;td>互動式 SQL 查詢&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>mysqldump&lt;/code>&lt;/td>
 &lt;td>匯出資料庫為 SQL 文字檔&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>mysqlcheck&lt;/code>&lt;/td>
 &lt;td>檢查、修復、優化資料表&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>mysqlimport&lt;/code>&lt;/td>
 &lt;td>匯入 CSV / TSV 資料&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>mysqldump&lt;/code> 是備份的核心工具——一行指令把整個資料庫匯出成可還原的 SQL。phpMyAdmin 的匯出功能底層也是類似的邏輯，但受 web server timeout 限制，大資料庫更適合用 CLI。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>MySQL 的 infra 設計要決定：備份頻率和保留天數（RDS 預設 7 天自動備份）、是否開 multi-AZ（failover 保護）、連線池設定（RDS Proxy 或應用層 pool）、慢查詢日誌是否開啟。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/rds/" data-link-title="RDS" data-link-desc="AWS 的受管關聯式資料庫服務，代管備份、更新與 failover，讓使用者專注在 schema 和查詢">RDS&lt;/a> — AWS 的受管 MySQL 服務&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/phpmyadmin/" data-link-title="phpMyAdmin" data-link-desc="Web 介面的 MySQL / MariaDB 管理工具，透過瀏覽器操作資料庫">phpMyAdmin&lt;/a> — Web 介面的 MySQL 管理工具&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>MySQL 是最廣泛使用的開源關聯式資料庫，多數 PHP 應用、WordPress、以及大量 web 服務的資料層都跑在 MySQL 上。MariaDB 是 MySQL 被 Oracle 收購後社群分支出來的相容實作，多數 Linux 發行版已經把預設的 mysql 套件指向 MariaDB。</p>
<h2 id="概念位置">概念位置</h2>
<p>MySQL 在 infra 裡是典型的 stateful 資源——資料不可重建、備份和刪除保護是 day-1 需求。接手維運時，MySQL 的版本、備份設定、認證方式是第一批要確認的項目。雲端環境裡 MySQL 常以 RDS 形式運行（受管服務、代管備份與 failover）。</p>
<h2 id="大版本升級的關鍵差異">大版本升級的關鍵差異</h2>
<p>MySQL 5.7 → 8.0 的 breaking change 在接手和升級情境裡經常遇到：</p>
<table>
  <thead>
      <tr>
          <th>變更項</th>
          <th>5.7 行為</th>
          <th>8.0 行為</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>預設認證方式</td>
          <td><code>mysql_native_password</code></td>
          <td><code>caching_sha2_password</code></td>
      </tr>
      <tr>
          <td><code>GROUP BY</code> 隱式排序</td>
          <td>有（按 group 欄位排）</td>
          <td>無（需要明確 <code>ORDER BY</code>）</td>
      </tr>
      <tr>
          <td>預設字元集</td>
          <td><code>utf8</code>（3 byte）</td>
          <td><code>utf8mb4</code>（4 byte、支援 emoji）</td>
      </tr>
      <tr>
          <td><code>GRANT</code> 同時建使用者</td>
          <td>允許</td>
          <td>必須先 <code>CREATE USER</code></td>
      </tr>
  </tbody>
</table>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>接手維運時的確認清單：<code>SELECT VERSION();</code> 查版本、<code>SHOW DATABASES;</code> 看有哪些資料庫、<code>SHOW VARIABLES LIKE 'character_set%';</code> 確認字元集、<code>SHOW VARIABLES LIKE 'max_connections';</code> 看連線上限。</p>
<h2 id="cli-工具">CLI 工具</h2>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>功能</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>mysql</code></td>
          <td>互動式 SQL 查詢</td>
      </tr>
      <tr>
          <td><code>mysqldump</code></td>
          <td>匯出資料庫為 SQL 文字檔</td>
      </tr>
      <tr>
          <td><code>mysqlcheck</code></td>
          <td>檢查、修復、優化資料表</td>
      </tr>
      <tr>
          <td><code>mysqlimport</code></td>
          <td>匯入 CSV / TSV 資料</td>
      </tr>
  </tbody>
</table>
<p><code>mysqldump</code> 是備份的核心工具——一行指令把整個資料庫匯出成可還原的 SQL。phpMyAdmin 的匯出功能底層也是類似的邏輯，但受 web server timeout 限制，大資料庫更適合用 CLI。</p>
<h2 id="設計責任">設計責任</h2>
<p>MySQL 的 infra 設計要決定：備份頻率和保留天數（RDS 預設 7 天自動備份）、是否開 multi-AZ（failover 保護）、連線池設定（RDS Proxy 或應用層 pool）、慢查詢日誌是否開啟。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/rds/" data-link-title="RDS" data-link-desc="AWS 的受管關聯式資料庫服務，代管備份、更新與 failover，讓使用者專注在 schema 和查詢">RDS</a> — AWS 的受管 MySQL 服務</li>
<li><a href="/blog/infra/knowledge-cards/phpmyadmin/" data-link-title="phpMyAdmin" data-link-desc="Web 介面的 MySQL / MariaDB 管理工具，透過瀏覽器操作資料庫">phpMyAdmin</a> — Web 介面的 MySQL 管理工具</li>
</ul>
]]></content:encoded></item><item><title>RDS</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/rds/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/rds/</guid><description>&lt;p>RDS（Relational Database Service）是 AWS 提供的受管關聯式資料庫服務。它在 EC2 instance 上跑資料庫引擎（MySQL、PostgreSQL、MariaDB、Oracle、SQL Server），但把作業系統更新、自動備份、跨可用區 failover、磁碟擴容這些運維工作交給 AWS 代管。使用者操作的是資料庫層級的設定（schema、query、parameter group），不需要 SSH 進機器管 OS。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>RDS 是 infra 系列中 stateful 資源的代表。它持有不可重建的資料，所以它的 IaC 描述、備份策略、刪除保護、變更審查都比 stateless 資源（如 EC2 web server）嚴格。模組五（核心服務）和接手維運模組的資料庫相關段落都以 RDS 為主要範例。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>需要理解 RDS 的情境包括：接手一個已經在跑的 production 資料庫、評估要不要從自建 MySQL 遷移到 RDS、設定資料庫的備份和高可用、或在 IaC 裡描述資料庫資源。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>使用 RDS 時要決定的關鍵設定：&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>instance class&lt;/td>
 &lt;td>CPU / 記憶體規格&lt;/td>
 &lt;td>效能與成本&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>multi-AZ&lt;/td>
 &lt;td>是否跨可用區部署 standby&lt;/td>
 &lt;td>可用性（failover 分鐘級）vs 成本（約 2 倍）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>backup retention&lt;/td>
 &lt;td>自動備份保留天數（1-35）&lt;/td>
 &lt;td>可回溯的時間窗口&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>deletion protection&lt;/td>
 &lt;td>是否允許刪除&lt;/td>
 &lt;td>防誤刪（production 必開）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>parameter group&lt;/td>
 &lt;td>資料庫引擎參數（max_connections 等）&lt;/td>
 &lt;td>效能調校&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>engine version&lt;/td>
 &lt;td>資料庫版本&lt;/td>
 &lt;td>功能與相容性&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>跟自建 MySQL on EC2 的取捨：RDS 省去 OS 層運維，但 parameter group 和 option group 的可調整範圍比直接操作 my.cnf 窄。需要完全控制 OS 層（如自訂 plugin、特殊檔案系統）時，自建較合理。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/mysql/" data-link-title="MySQL" data-link-desc="最廣泛使用的開源關聯式資料庫。MariaDB 是其社群分支。大版本升級（5.7→8.0）有認證方式和查詢行為的 breaking change">MySQL&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/deletion-protection/" data-link-title="Deletion Protection" data-link-desc="雲端平台提供的防誤刪機制，開啟後刪除操作需要先顯式關閉保護才能執行">Deletion Protection&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">Subnet&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>RDS（Relational Database Service）是 AWS 提供的受管關聯式資料庫服務。它在 EC2 instance 上跑資料庫引擎（MySQL、PostgreSQL、MariaDB、Oracle、SQL Server），但把作業系統更新、自動備份、跨可用區 failover、磁碟擴容這些運維工作交給 AWS 代管。使用者操作的是資料庫層級的設定（schema、query、parameter group），不需要 SSH 進機器管 OS。</p>
<h2 id="概念位置">概念位置</h2>
<p>RDS 是 infra 系列中 stateful 資源的代表。它持有不可重建的資料，所以它的 IaC 描述、備份策略、刪除保護、變更審查都比 stateless 資源（如 EC2 web server）嚴格。模組五（核心服務）和接手維運模組的資料庫相關段落都以 RDS 為主要範例。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>需要理解 RDS 的情境包括：接手一個已經在跑的 production 資料庫、評估要不要從自建 MySQL 遷移到 RDS、設定資料庫的備份和高可用、或在 IaC 裡描述資料庫資源。</p>
<h2 id="設計責任">設計責任</h2>
<p>使用 RDS 時要決定的關鍵設定：</p>
<table>
  <thead>
      <tr>
          <th>設定</th>
          <th>決定什麼</th>
          <th>影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>instance class</td>
          <td>CPU / 記憶體規格</td>
          <td>效能與成本</td>
      </tr>
      <tr>
          <td>multi-AZ</td>
          <td>是否跨可用區部署 standby</td>
          <td>可用性（failover 分鐘級）vs 成本（約 2 倍）</td>
      </tr>
      <tr>
          <td>backup retention</td>
          <td>自動備份保留天數（1-35）</td>
          <td>可回溯的時間窗口</td>
      </tr>
      <tr>
          <td>deletion protection</td>
          <td>是否允許刪除</td>
          <td>防誤刪（production 必開）</td>
      </tr>
      <tr>
          <td>parameter group</td>
          <td>資料庫引擎參數（max_connections 等）</td>
          <td>效能調校</td>
      </tr>
      <tr>
          <td>engine version</td>
          <td>資料庫版本</td>
          <td>功能與相容性</td>
      </tr>
  </tbody>
</table>
<p>跟自建 MySQL on EC2 的取捨：RDS 省去 OS 層運維，但 parameter group 和 option group 的可調整範圍比直接操作 my.cnf 窄。需要完全控制 OS 層（如自訂 plugin、特殊檔案系統）時，自建較合理。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/mysql/" data-link-title="MySQL" data-link-desc="最廣泛使用的開源關聯式資料庫。MariaDB 是其社群分支。大版本升級（5.7→8.0）有認證方式和查詢行為的 breaking change">MySQL</a></li>
<li><a href="/blog/infra/knowledge-cards/deletion-protection/" data-link-title="Deletion Protection" data-link-desc="雲端平台提供的防誤刪機制，開啟後刪除操作需要先顯式關閉保護才能執行">Deletion Protection</a></li>
<li><a href="/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">Subnet</a></li>
</ul>
]]></content:encoded></item><item><title>S3</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/s3/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/s3/</guid><description>&lt;p>S3（Simple Storage Service）是 AWS 的物件儲存服務。每個檔案（object）放在一個 bucket 裡、用 key（路徑）定址。S3 的持久性設計為 99.999999999%（11 個 9），資料自動跨多個可用區複製，不需要手動備份 S3 本身——要保護的是「物件被覆寫或刪除」的風險，而非「S3 服務掛掉」的風險。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>S3 在 infra 系列裡有三個角色：Terraform state 的存放處（remote state backend）、應用程式的靜態檔案儲存（上傳內容、備份歸檔）、以及 log 的長期保存目的地。模組一（state backend）、模組五（storage）、模組八（治理）都涉及 S3。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>需要理解 S3 的情境包括：設定 Terraform 的 remote state backend、管理使用者上傳的檔案、設計備份歸檔策略、或評估儲存成本。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>使用 S3 時要決定的關鍵設定：&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>versioning&lt;/td>
 &lt;td>是否保留物件的歷史版本&lt;/td>
 &lt;td>覆寫或刪除後能回溯（state bucket 必開）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>encryption&lt;/td>
 &lt;td>靜態加密方式（SSE-S3 / SSE-KMS）&lt;/td>
 &lt;td>合規與金鑰管理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>public access block&lt;/td>
 &lt;td>是否封鎖公開存取&lt;/td>
 &lt;td>安全（預設全封鎖）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>lifecycle rules&lt;/td>
 &lt;td>自動移到低成本儲存類或過期刪除&lt;/td>
 &lt;td>成本控制（如 30 天後移到 Glacier）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>bucket policy&lt;/td>
 &lt;td>跨帳號或跨服務的存取規則&lt;/td>
 &lt;td>權限邊界&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>versioning 開啟後，刪除物件只是加一個 delete marker、實際資料還在。要真正刪除需要刪除 delete marker 和所有歷史版本。這是保護 state 檔的關鍵機制——誤寫 state 後可以回捲到上一個版本。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/remote-state-backend/" data-link-title="Remote State Backend" data-link-desc="把 Terraform state 從本地搬到團隊共享儲存的機制，同時滿足持久保存、並行鎖與敏感值保護">Remote State Backend&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/deletion-protection/" data-link-title="Deletion Protection" data-link-desc="雲端平台提供的防誤刪機制，開啟後刪除操作需要先顯式關閉保護才能執行">Deletion Protection&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>S3（Simple Storage Service）是 AWS 的物件儲存服務。每個檔案（object）放在一個 bucket 裡、用 key（路徑）定址。S3 的持久性設計為 99.999999999%（11 個 9），資料自動跨多個可用區複製，不需要手動備份 S3 本身——要保護的是「物件被覆寫或刪除」的風險，而非「S3 服務掛掉」的風險。</p>
<h2 id="概念位置">概念位置</h2>
<p>S3 在 infra 系列裡有三個角色：Terraform state 的存放處（remote state backend）、應用程式的靜態檔案儲存（上傳內容、備份歸檔）、以及 log 的長期保存目的地。模組一（state backend）、模組五（storage）、模組八（治理）都涉及 S3。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>需要理解 S3 的情境包括：設定 Terraform 的 remote state backend、管理使用者上傳的檔案、設計備份歸檔策略、或評估儲存成本。</p>
<h2 id="設計責任">設計責任</h2>
<p>使用 S3 時要決定的關鍵設定：</p>
<table>
  <thead>
      <tr>
          <th>設定</th>
          <th>決定什麼</th>
          <th>影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>versioning</td>
          <td>是否保留物件的歷史版本</td>
          <td>覆寫或刪除後能回溯（state bucket 必開）</td>
      </tr>
      <tr>
          <td>encryption</td>
          <td>靜態加密方式（SSE-S3 / SSE-KMS）</td>
          <td>合規與金鑰管理</td>
      </tr>
      <tr>
          <td>public access block</td>
          <td>是否封鎖公開存取</td>
          <td>安全（預設全封鎖）</td>
      </tr>
      <tr>
          <td>lifecycle rules</td>
          <td>自動移到低成本儲存類或過期刪除</td>
          <td>成本控制（如 30 天後移到 Glacier）</td>
      </tr>
      <tr>
          <td>bucket policy</td>
          <td>跨帳號或跨服務的存取規則</td>
          <td>權限邊界</td>
      </tr>
  </tbody>
</table>
<p>versioning 開啟後，刪除物件只是加一個 delete marker、實際資料還在。要真正刪除需要刪除 delete marker 和所有歷史版本。這是保護 state 檔的關鍵機制——誤寫 state 後可以回捲到上一個版本。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/remote-state-backend/" data-link-title="Remote State Backend" data-link-desc="把 Terraform state 從本地搬到團隊共享儲存的機制，同時滿足持久保存、並行鎖與敏感值保護">Remote State Backend</a></li>
<li><a href="/blog/infra/knowledge-cards/deletion-protection/" data-link-title="Deletion Protection" data-link-desc="雲端平台提供的防誤刪機制，開啟後刪除操作需要先顯式關閉保護才能執行">Deletion Protection</a></li>
</ul>
]]></content:encoded></item><item><title>EC2</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/ec2/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/ec2/</guid><description>&lt;p>EC2（Elastic Compute Cloud）是 AWS 提供的虛擬機器服務。每一台 EC2 instance 是一台完整的虛擬伺服器——有自己的 OS、CPU、記憶體、磁碟和網路介面。使用者可以 SSH 進去、安裝軟體、跑應用程式，跟操作一台實體伺服器的體驗相似。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>EC2 是 infra 系列中「運算」面向的基礎單位。容器服務（ECS、EKS）底層也跑在 EC2 上（除非用 Fargate）。模組五（核心服務）的運算段落、接手維運（雲端篇）的 VM 快照、升級模組的 OS 遷移都以 EC2 為操作對象。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>需要理解 EC2 的情境包括：接手一個跑在 VM 上的應用程式、評估容器化 vs VM 部署、設定 auto-scaling、或建立 AMI 快照作為備份。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>一台 EC2 instance 由五個組件構成：&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>AMI&lt;/td>
 &lt;td>作業系統映像（Ubuntu、Amazon Linux 等）&lt;/td>
 &lt;td>OS 偏好、軟體預裝需求&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Instance type&lt;/td>
 &lt;td>CPU / 記憶體規格（t3.micro、m6i.large 等）&lt;/td>
 &lt;td>工作負載的 CPU 和記憶體需求&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>EBS&lt;/td>
 &lt;td>持久化磁碟&lt;/td>
 &lt;td>容量、IOPS、是否需要加密&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Security group&lt;/td>
 &lt;td>網路防火牆規則&lt;/td>
 &lt;td>哪些 port 開放、來源限制&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>IAM role&lt;/td>
 &lt;td>instance 的雲端權限&lt;/td>
 &lt;td>需要存取哪些 AWS 服務&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>跟容器（ECS / EKS）的差別：EC2 管整台 VM（含 OS 更新、安全性修補、磁碟管理），容器只管應用程式及其依賴。EC2 的運維負擔較高、但控制粒度也較高。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/ami/" data-link-title="AMI" data-link-desc="EC2 instance 的作業系統映像快照，包含 OS、軟體、設定與磁碟內容，從 AMI 開出的 instance 跟原始狀態一樣">AMI&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/ecs/" data-link-title="ECS" data-link-desc="AWS Elastic Container Service — 受管的容器編排服務，用 task definition 描述容器配置、由平台負責排程與健康管理">ECS&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">Subnet&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>EC2（Elastic Compute Cloud）是 AWS 提供的虛擬機器服務。每一台 EC2 instance 是一台完整的虛擬伺服器——有自己的 OS、CPU、記憶體、磁碟和網路介面。使用者可以 SSH 進去、安裝軟體、跑應用程式，跟操作一台實體伺服器的體驗相似。</p>
<h2 id="概念位置">概念位置</h2>
<p>EC2 是 infra 系列中「運算」面向的基礎單位。容器服務（ECS、EKS）底層也跑在 EC2 上（除非用 Fargate）。模組五（核心服務）的運算段落、接手維運（雲端篇）的 VM 快照、升級模組的 OS 遷移都以 EC2 為操作對象。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>需要理解 EC2 的情境包括：接手一個跑在 VM 上的應用程式、評估容器化 vs VM 部署、設定 auto-scaling、或建立 AMI 快照作為備份。</p>
<h2 id="設計責任">設計責任</h2>
<p>一台 EC2 instance 由五個組件構成：</p>
<table>
  <thead>
      <tr>
          <th>組件</th>
          <th>角色</th>
          <th>選型判準</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>AMI</td>
          <td>作業系統映像（Ubuntu、Amazon Linux 等）</td>
          <td>OS 偏好、軟體預裝需求</td>
      </tr>
      <tr>
          <td>Instance type</td>
          <td>CPU / 記憶體規格（t3.micro、m6i.large 等）</td>
          <td>工作負載的 CPU 和記憶體需求</td>
      </tr>
      <tr>
          <td>EBS</td>
          <td>持久化磁碟</td>
          <td>容量、IOPS、是否需要加密</td>
      </tr>
      <tr>
          <td>Security group</td>
          <td>網路防火牆規則</td>
          <td>哪些 port 開放、來源限制</td>
      </tr>
      <tr>
          <td>IAM role</td>
          <td>instance 的雲端權限</td>
          <td>需要存取哪些 AWS 服務</td>
      </tr>
  </tbody>
</table>
<p>跟容器（ECS / EKS）的差別：EC2 管整台 VM（含 OS 更新、安全性修補、磁碟管理），容器只管應用程式及其依賴。EC2 的運維負擔較高、但控制粒度也較高。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/ami/" data-link-title="AMI" data-link-desc="EC2 instance 的作業系統映像快照，包含 OS、軟體、設定與磁碟內容，從 AMI 開出的 instance 跟原始狀態一樣">AMI</a></li>
<li><a href="/blog/infra/knowledge-cards/ecs/" data-link-title="ECS" data-link-desc="AWS Elastic Container Service — 受管的容器編排服務，用 task definition 描述容器配置、由平台負責排程與健康管理">ECS</a></li>
<li><a href="/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">Subnet</a></li>
<li><a href="/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group</a></li>
</ul>
]]></content:encoded></item><item><title>EBS</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/ebs/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/ebs/</guid><description>&lt;p>EBS（Elastic Block Store）是 AWS 提供的區塊儲存服務——可以把它理解為掛在 EC2 instance 上的虛擬硬碟。EBS volume 跟 EC2 instance 的生命週期獨立：instance 停止或終止時，EBS volume 上的資料不會消失（除非明確設定 &lt;code>DeleteOnTermination&lt;/code>）。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>EBS 是 infra 系列中儲存面向的底層組件。RDS 的資料實際存在 EBS 上（由 AWS 代管）、EC2 的根磁碟和附加磁碟都是 EBS volume。接手維運時對 VM 做快照（AMI），背後就是在對 EBS volume 做 snapshot。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>需要理解 EBS 的情境包括：EC2 instance 的磁碟快滿了需要擴容、要對 VM 做快照備份、評估磁碟效能（IOPS）是否足夠、或清理不再掛載的孤立 volume（殭屍 volume 持續計費）。&lt;/p>
&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>Volume type&lt;/td>
 &lt;td>gp3（通用）/ io2（高 IOPS）/ st1（高吞吐）&lt;/td>
 &lt;td>效能與成本&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Size&lt;/td>
 &lt;td>磁碟容量（GB）&lt;/td>
 &lt;td>線上擴容可行、但縮小不行&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Encryption&lt;/td>
 &lt;td>是否加密&lt;/td>
 &lt;td>合規（建立後不可更改，要加密只能建新的複製過去）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Snapshot&lt;/td>
 &lt;td>快照備份&lt;/td>
 &lt;td>EBS snapshot 是增量的（只存變更的區塊）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>DeleteOnTermination&lt;/td>
 &lt;td>instance 終止時是否跟著刪除&lt;/td>
 &lt;td>根磁碟預設 true、附加磁碟預設 false&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>跟 instance store 的差別：instance store 是 EC2 實體主機上的臨時磁碟，效能高但 instance 停止資料就消失。EBS 是持久化儲存，instance 停止資料仍在。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/ec2/" data-link-title="EC2" data-link-desc="AWS 的虛擬機器服務，提供可隨時啟停的運算實例，組成包含 AMI、instance type、EBS、security group 與 IAM role">EC2&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/deletion-protection/" data-link-title="Deletion Protection" data-link-desc="雲端平台提供的防誤刪機制，開啟後刪除操作需要先顯式關閉保護才能執行">Deletion Protection&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>EBS（Elastic Block Store）是 AWS 提供的區塊儲存服務——可以把它理解為掛在 EC2 instance 上的虛擬硬碟。EBS volume 跟 EC2 instance 的生命週期獨立：instance 停止或終止時，EBS volume 上的資料不會消失（除非明確設定 <code>DeleteOnTermination</code>）。</p>
<h2 id="概念位置">概念位置</h2>
<p>EBS 是 infra 系列中儲存面向的底層組件。RDS 的資料實際存在 EBS 上（由 AWS 代管）、EC2 的根磁碟和附加磁碟都是 EBS volume。接手維運時對 VM 做快照（AMI），背後就是在對 EBS volume 做 snapshot。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>需要理解 EBS 的情境包括：EC2 instance 的磁碟快滿了需要擴容、要對 VM 做快照備份、評估磁碟效能（IOPS）是否足夠、或清理不再掛載的孤立 volume（殭屍 volume 持續計費）。</p>
<h2 id="設計責任">設計責任</h2>
<table>
  <thead>
      <tr>
          <th>設定</th>
          <th>決定什麼</th>
          <th>影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Volume type</td>
          <td>gp3（通用）/ io2（高 IOPS）/ st1（高吞吐）</td>
          <td>效能與成本</td>
      </tr>
      <tr>
          <td>Size</td>
          <td>磁碟容量（GB）</td>
          <td>線上擴容可行、但縮小不行</td>
      </tr>
      <tr>
          <td>Encryption</td>
          <td>是否加密</td>
          <td>合規（建立後不可更改，要加密只能建新的複製過去）</td>
      </tr>
      <tr>
          <td>Snapshot</td>
          <td>快照備份</td>
          <td>EBS snapshot 是增量的（只存變更的區塊）</td>
      </tr>
      <tr>
          <td>DeleteOnTermination</td>
          <td>instance 終止時是否跟著刪除</td>
          <td>根磁碟預設 true、附加磁碟預設 false</td>
      </tr>
  </tbody>
</table>
<p>跟 instance store 的差別：instance store 是 EC2 實體主機上的臨時磁碟，效能高但 instance 停止資料就消失。EBS 是持久化儲存，instance 停止資料仍在。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/ec2/" data-link-title="EC2" data-link-desc="AWS 的虛擬機器服務，提供可隨時啟停的運算實例，組成包含 AMI、instance type、EBS、security group 與 IAM role">EC2</a></li>
<li><a href="/blog/infra/knowledge-cards/deletion-protection/" data-link-title="Deletion Protection" data-link-desc="雲端平台提供的防誤刪機制，開啟後刪除操作需要先顯式關閉保護才能執行">Deletion Protection</a></li>
</ul>
]]></content:encoded></item><item><title>HCL</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/hcl/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/hcl/</guid><description>&lt;p>HCL（HashiCorp Configuration Language）是 Terraform 和 OpenTofu 使用的設定語言。它用宣告式的 resource block 描述「環境應該長什麼樣」，由工具負責比對現況與描述、算出差異再套用。寫 HCL 的人描述目標狀態，不描述達到目標的步驟。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>HCL 是 infra 系列中 IaC 程式碼的語言層。IaC 卡講的是「用程式碼管理基礎設施」的概念，HCL 是這個概念落地時最常用的語言。模組一到八的所有 HCL 範例都用這個語言寫成。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>需要理解 HCL 的情境包括：第一次打開一份 &lt;code>.tf&lt;/code> 檔案、要讀懂 Terraform 的 plan 輸出、要修改或新增一個 resource 定義、或要 review 別人的 infra PR。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>HCL 的基本結構：&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;example&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;my-bucket&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"> tags&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> { env&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;prod&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;ul>
&lt;li>&lt;code>resource&lt;/code>：宣告一個雲端資源&lt;/li>
&lt;li>&lt;code>&amp;quot;aws_s3_bucket&amp;quot;&lt;/code>：資源類型（由 provider 決定）&lt;/li>
&lt;li>&lt;code>&amp;quot;example&amp;quot;&lt;/code>：這個資源在程式碼裡的名稱（用來引用）&lt;/li>
&lt;li>&lt;code>{}&lt;/code>：這個資源的屬性&lt;/li>
&lt;/ul>
&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>JSON / YAML&lt;/td>
 &lt;td>純資料格式、沒有邏輯&lt;/td>
 &lt;td>設定值、資料交換&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>HCL&lt;/td>
 &lt;td>支援變數、函式、條件、迴圈&lt;/td>
 &lt;td>基礎設施描述&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TypeScript / Python&lt;/td>
 &lt;td>通用程式語言、完整邏輯&lt;/td>
 &lt;td>複雜的 infra 抽象（CDK / Pulumi）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>HCL 的定位在 JSON 和通用語言之間——比 JSON 有表達力（能做迴圈和條件）、比通用語言好 review（diff 直觀、不需要在腦中「執行」程式碼才知道結果）。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC&lt;/a>&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>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>HCL（HashiCorp Configuration Language）是 Terraform 和 OpenTofu 使用的設定語言。它用宣告式的 resource block 描述「環境應該長什麼樣」，由工具負責比對現況與描述、算出差異再套用。寫 HCL 的人描述目標狀態，不描述達到目標的步驟。</p>
<h2 id="概念位置">概念位置</h2>
<p>HCL 是 infra 系列中 IaC 程式碼的語言層。IaC 卡講的是「用程式碼管理基礎設施」的概念，HCL 是這個概念落地時最常用的語言。模組一到八的所有 HCL 範例都用這個語言寫成。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>需要理解 HCL 的情境包括：第一次打開一份 <code>.tf</code> 檔案、要讀懂 Terraform 的 plan 輸出、要修改或新增一個 resource 定義、或要 review 別人的 infra PR。</p>
<h2 id="設計責任">設計責任</h2>
<p>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_s3_bucket&#34; &#34;example&#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;my-bucket&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  tags</span>   <span class="o">=</span><span class="n"> { env</span> <span class="o">=</span> <span class="s2">&#34;prod&#34;</span> }
</span></span><span class="line"><span class="ln">4</span><span class="cl">}</span></span></code></pre></div><ul>
<li><code>resource</code>：宣告一個雲端資源</li>
<li><code>&quot;aws_s3_bucket&quot;</code>：資源類型（由 provider 決定）</li>
<li><code>&quot;example&quot;</code>：這個資源在程式碼裡的名稱（用來引用）</li>
<li><code>{}</code>：這個資源的屬性</li>
</ul>
<p>跟其他格式的差別：</p>
<table>
  <thead>
      <tr>
          <th>格式</th>
          <th>特性</th>
          <th>適合場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>JSON / YAML</td>
          <td>純資料格式、沒有邏輯</td>
          <td>設定值、資料交換</td>
      </tr>
      <tr>
          <td>HCL</td>
          <td>支援變數、函式、條件、迴圈</td>
          <td>基礎設施描述</td>
      </tr>
      <tr>
          <td>TypeScript / Python</td>
          <td>通用程式語言、完整邏輯</td>
          <td>複雜的 infra 抽象（CDK / Pulumi）</td>
      </tr>
  </tbody>
</table>
<p>HCL 的定位在 JSON 和通用語言之間——比 JSON 有表達力（能做迴圈和條件）、比通用語言好 review（diff 直觀、不需要在腦中「執行」程式碼才知道結果）。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC</a></li>
<li><a href="/blog/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">State</a></li>
</ul>
]]></content:encoded></item><item><title>Infra 基礎設施建置指南</title><link>https://tarrragon.github.io/blog/infra/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/</guid><description>&lt;p>基礎設施是服務跑起來之前就必須存在的地基：運算、網路、身分、儲存與可觀測性這些資源怎麼被建立、被管理、被演進。這套指南教兩件事 — infra 是什麼，以及怎麼從零把它一階一階做起來。&lt;/p>
&lt;p>大部分公司不可能 day 1 就完整建置一整套成熟 infra，硬要一次做完反而變成另一種過度投資、一樣推不動。所以這套教材的章節核心是一條成熟度階梯：先立最小可行的地基與鐵律，再依序往上長身分、網路、環境分離、核心服務與治理能力。每一階都能獨立交付價值、也都是下一階的前提。&lt;/p>
&lt;p>infra 的責任、風險、成本與決策是理解它的必要框架，這套教材刻意保持 vendor 中立。範例多以 AWS 與 Terraform / OpenTofu 說明，但每個模組講的是能力本身（身分怎麼設計、網路怎麼分層、變更怎麼走 review），讀者換到 GCP、Azure 或其他 IaC 工具時，判準仍然適用。&lt;/p>
&lt;h2 id="和其他系列的關係">和其他系列的關係&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>系列&lt;/th>
 &lt;th>聚焦&lt;/th>
 &lt;th>和 Infra 的交集&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/" data-link-title="Backend 服務實務指南" data-link-desc="用跨語言教學路線整理資料庫、快取、訊息佇列、觀測、部署、可靠性、資安、事故與容量等後端服務能力">Backend&lt;/a>&lt;/td>
 &lt;td>服務內部設計（資料庫、快取、佇列、可觀測）&lt;/td>
 &lt;td>Backend 的部署平台與 secret / TLS 能力跑在 Infra 的地基上&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/devops/" data-link-title="DevOps 實務指南" data-link-desc="負載平衡、水平擴展、流量管控、服務探活、容量規劃、高可用、突發流量、成本管理 — 服務營運的工程基礎">DevOps&lt;/a>&lt;/td>
 &lt;td>運行期維運（負載、擴展、容量、成本）&lt;/td>
 &lt;td>Infra 把地基鋪好，DevOps 管這些地基上的服務怎麼活下來&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ci/" data-link-title="CI/CD 教學" data-link-desc="整理 CI/CD 的驗證、建置、發布 gate 與不同部署場域的流程差異，讓每次變更都能被穩定驗證與交付">CI/CD&lt;/a>&lt;/td>
 &lt;td>驗證、建置、發布 gate&lt;/td>
 &lt;td>Infra 變更走 PR 流程時，用的是 CI/CD 管線&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">Monitoring&lt;/a>&lt;/td>
 &lt;td>客戶端監控體系（SDK、Collector）&lt;/td>
 &lt;td>Infra 的 log / metric / alarm 是 observability 的底層&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/linux/dotfile/" data-link-title="Dotfile 工作環境配置指南" data-link-desc="個人開發環境的配置管理 — dotfile 結構設計、同步策略、shell 與終端機配置、平鋪式視窗管理、桌面客製化，從個人工具鏈延伸到團隊環境標準化">Dotfile&lt;/a>&lt;/td>
 &lt;td>個人工作環境配置管理&lt;/td>
 &lt;td>Infra 是組織的環境 as code，Dotfile 是個人的環境 as code，思想平行&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Backend 教「服務怎麼設計」、DevOps 教「設計好的服務怎麼營運」，Infra 教「這些服務跑在什麼樣的地基上、這套地基怎麼從零長出來」。模組九同時處理 infra 的組織面 — 怎麼向非技術決策者說明地基的價值、怎麼在信任不足的環境裡推動採用。&lt;/p>
&lt;h2 id="教學模組">教學模組&lt;/h2>
&lt;p>模組編號標示成熟度階梯的位置，不是閱讀順序。模組負一是還沒站上 IaC 第一階的真實起點（全手動環境）；模組零是貫穿全系列的共用心智模型、不佔階梯刻度；模組一以後才是一階一階往上的建置順序。「接手維運」和「環境升級」是橫切模組，跟成熟度階梯平行——接手和升級可能發生在任何階段。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>模組&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;th>回答什麼問題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/takeover/" data-link-title="接手維運：別人建的環境怎麼接管" data-link-desc="接手前人的專案時，怎麼在不搞壞東西的前提下盤點現況、建立維運能力、逐步正規化 — 從無 SSH 的 FTP 環境到有半套 IaC 的雲端環境都適用">接手維運&lt;/a>&lt;/td>
 &lt;td>別人建的環境怎麼盤點、接管、逐步正規化&lt;/td>
 &lt;td>接手前人的專案，怎麼在不搞壞的前提下接管&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/upgrade/" data-link-title="環境與系統升級：帶電施工的遷移操作" data-link-desc="系統在升級過程中要持續服務 — runtime 版本升級、平台遷移、資料庫大版本升級、OS 更換、架構轉型的共通操作框架與各類型的專屬風險">環境與系統升級&lt;/a>&lt;/td>
 &lt;td>runtime / 平台 / 資料庫 / OS 的帶電施工遷移&lt;/td>
 &lt;td>系統在服務中怎麼升級而不中斷&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/air-gapped/" data-link-title="斷網環境的 infra：沒有網路時怎麼做" data-link-desc="實體隔離或無法連網的環境裡，IaC、套件管理、容器映像、監控、CI/CD 怎麼運作 — 原則不變、工具路徑全部要換">斷網環境的 infra&lt;/a>&lt;/td>
 &lt;td>離線套件、provider mirror、private registry、self-hosted 監控&lt;/td>
 &lt;td>沒有網路時 IaC 和維運怎麼做&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一：還沒有 infra 的環境&lt;/a>&lt;/td>
 &lt;td>手動環境的底線、降低未來納管成本、導入訊號&lt;/td>
 &lt;td>還沒有 IaC 時怎麼盡量把事情做對&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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;/td>
 &lt;td>責任邊界、成熟度階梯、day 1 鐵律&lt;/td>
 &lt;td>為什麼地基要先做、不先做的代價是什麼&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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>&lt;/td>
 &lt;td>工具選型、remote state、Console 唯讀鐵律&lt;/td>
 &lt;td>第一行 IaC 該從哪裡開始&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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;/td>
 &lt;td>IAM role / policy、OIDC 取代長期 key&lt;/td>
 &lt;td>誰能動什麼、憑證怎麼不外洩&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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;/td>
 &lt;td>VPC、subnet 切分、route / NAT、security group&lt;/td>
 &lt;td>服務之間的網路邊界怎麼劃&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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;/td>
 &lt;td>dev / staging / prod、目錄結構、可重用 module&lt;/td>
 &lt;td>怎麼讓 dev 跟 prod 不互相污染又共用一套 code&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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;/td>
 &lt;td>資料庫、運算、儲存、load balancer&lt;/td>
 &lt;td>地基鋪好後核心服務怎麼接上去&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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;/td>
 &lt;td>log group、metric、alarm 同生命週期管理&lt;/td>
 &lt;td>出事時怎麼追得到、查得到&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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>&lt;/td>
 &lt;td>plan / apply review、tflint / checkov 護欄&lt;/td>
 &lt;td>infra 變更怎麼像 code 一樣可審查可回溯&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣&lt;/a>&lt;/td>
 &lt;td>tagging、secrets、成本可見性、最小可行節奏&lt;/td>
 &lt;td>規模長大後怎麼不失控&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/09-driving-adoption/" data-link-title="模組九：怎麼把 infra 推動起來" data-link-desc="技術正確不等於推得動 — 信任赤字、期望值對齊、知識共享，infra 落地的組織課題">模組九：怎麼把 infra 推動起來&lt;/a>&lt;/td>
 &lt;td>技術正確 ≠ 推得動、期望值對齊、知識共享&lt;/td>
 &lt;td>為什麼 infra 常推不動、怎麼推得動&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&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>被指派 infra&lt;/td>
 &lt;td>其他領域工程師、拿到公司雲端帳號&lt;/td>
 &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;/tr>
 &lt;tr>
 &lt;td>入門認識&lt;/td>
 &lt;td>從沒碰過 infra 的個人開發者&lt;/td>
 &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、儲存 — 這些元件在任何雲端部署裡都已經在運作，差別在於有沒有被有意識地管理">個人專案到團隊服務&lt;/a> → &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 關注點">一台機器到三個環境&lt;/a> → 模組零&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>從零建置&lt;/td>
 &lt;td>新專案 day 1、想一開始就鋪對地基&lt;/td>
 &lt;td>模組零 → 模組一 → 模組二（含&lt;a href="https://tarrragon.github.io/blog/infra/02-identity-credentials/multi-account-strategy/" data-link-title="跨帳號策略 — Organizations、SCP 與帳號工廠" data-link-desc="用 AWS Organizations 把環境拆成獨立帳號、用 SCP 設定連管理員都越不過的護欄、用帳號工廠讓每個新帳號自帶安全基線">跨帳號策略&lt;/a>）→ 模組三 → 模組四 → 模組五&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>還沒有 IaC&lt;/td>
 &lt;td>全手動環境、暫時沒資源導入&lt;/td>
 &lt;td>模組負一 → 模組零&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>救火後納管&lt;/td>
 &lt;td>已經有一堆手動資源、想收進 IaC&lt;/td>
 &lt;td>模組負一 → 模組一 → 模組二 → 模組四 → 模組七&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>治理與成本&lt;/td>
 &lt;td>infra 跑得起來但開始失控&lt;/td>
 &lt;td>模組八（含&lt;a href="https://tarrragon.github.io/blog/infra/08-governance-habits/handover-design/" data-link-title="職務交接與存取撤銷設計" data-link-desc="人員異動時的存取撤銷順序、credential rotation、最小交接清單，以及讓交接成本結構性降低的 infra 設計原則">職務交接設計&lt;/a>）→ 模組六 → 模組七&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>說服決策者&lt;/td>
 &lt;td>要向非技術人解釋的工程師&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/09-driving-adoption/infra-explained-for-non-engineers/" data-link-title="給非工程背景決策者的 infra 說明" data-link-desc="從管理視角解釋基礎設施在解決什麼營運問題、不做的代價、出事怎麼處理，讓參與資源決策的人能判斷投入的優先級">給非工程人員的 infra 說明&lt;/a> → &lt;a href="https://tarrragon.github.io/blog/infra/09-driving-adoption/infra-business-justification/" data-link-title="infra 投資的商業論證" data-link-desc="用成本、風險、速度三條論述線把 infra 投資翻譯成商業語言，附一頁簡報邏輯與常見反對意見的回應">infra 投資的商業論證&lt;/a> → 模組九&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>接手前人專案&lt;/td>
 &lt;td>繼承了別人建的環境、要接管維運&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/takeover/" data-link-title="接手維運：別人建的環境怎麼接管" data-link-desc="接手前人的專案時，怎麼在不搞壞東西的前提下盤點現況、建立維運能力、逐步正規化 — 從無 SSH 的 FTP 環境到有半套 IaC 的雲端環境都適用">接手維運&lt;/a>（依環境類型選篇）→ 模組負一 → 模組一&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>環境升級&lt;/td>
 &lt;td>需要升級 runtime / 平台 / DB / OS&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/upgrade/upgrade-framework/" data-link-title="升級的共通操作框架" data-link-desc="任何環境或系統升級的四階段模型：差異評估、平行環境驗證、分批切換、退役舊環境，以及貫穿全程的升級紀律">升級框架&lt;/a> → 依升級類型選篇 → 模組五（stateful 保護）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>斷網環境&lt;/td>
 &lt;td>實體隔離或無法連網的環境&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">斷網通用原則&lt;/a> → 依面向選篇（IaC / 容器 / 監控）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>推不動的困局&lt;/td>
 &lt;td>技術做了一半、上層不買單&lt;/td>
 &lt;td>模組九 → 模組零&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table></description><content:encoded><![CDATA[<p>基礎設施是服務跑起來之前就必須存在的地基：運算、網路、身分、儲存與可觀測性這些資源怎麼被建立、被管理、被演進。這套指南教兩件事 — infra 是什麼，以及怎麼從零把它一階一階做起來。</p>
<p>大部分公司不可能 day 1 就完整建置一整套成熟 infra，硬要一次做完反而變成另一種過度投資、一樣推不動。所以這套教材的章節核心是一條成熟度階梯：先立最小可行的地基與鐵律，再依序往上長身分、網路、環境分離、核心服務與治理能力。每一階都能獨立交付價值、也都是下一階的前提。</p>
<p>infra 的責任、風險、成本與決策是理解它的必要框架，這套教材刻意保持 vendor 中立。範例多以 AWS 與 Terraform / OpenTofu 說明，但每個模組講的是能力本身（身分怎麼設計、網路怎麼分層、變更怎麼走 review），讀者換到 GCP、Azure 或其他 IaC 工具時，判準仍然適用。</p>
<h2 id="和其他系列的關係">和其他系列的關係</h2>
<table>
  <thead>
      <tr>
          <th>系列</th>
          <th>聚焦</th>
          <th>和 Infra 的交集</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/" data-link-title="Backend 服務實務指南" data-link-desc="用跨語言教學路線整理資料庫、快取、訊息佇列、觀測、部署、可靠性、資安、事故與容量等後端服務能力">Backend</a></td>
          <td>服務內部設計（資料庫、快取、佇列、可觀測）</td>
          <td>Backend 的部署平台與 secret / TLS 能力跑在 Infra 的地基上</td>
      </tr>
      <tr>
          <td><a href="/blog/devops/" data-link-title="DevOps 實務指南" data-link-desc="負載平衡、水平擴展、流量管控、服務探活、容量規劃、高可用、突發流量、成本管理 — 服務營運的工程基礎">DevOps</a></td>
          <td>運行期維運（負載、擴展、容量、成本）</td>
          <td>Infra 把地基鋪好，DevOps 管這些地基上的服務怎麼活下來</td>
      </tr>
      <tr>
          <td><a href="/blog/ci/" data-link-title="CI/CD 教學" data-link-desc="整理 CI/CD 的驗證、建置、發布 gate 與不同部署場域的流程差異，讓每次變更都能被穩定驗證與交付">CI/CD</a></td>
          <td>驗證、建置、發布 gate</td>
          <td>Infra 變更走 PR 流程時，用的是 CI/CD 管線</td>
      </tr>
      <tr>
          <td><a href="/blog/monitoring/" data-link-title="監控實務指南" data-link-desc="整理非伺服器端運行時的監控體系 — 行為蒐集、錯誤回報、效能指標、生命週期追蹤，從自架方案到商業方案的完整知識路線">Monitoring</a></td>
          <td>客戶端監控體系（SDK、Collector）</td>
          <td>Infra 的 log / metric / alarm 是 observability 的底層</td>
      </tr>
      <tr>
          <td><a href="/blog/linux/dotfile/" data-link-title="Dotfile 工作環境配置指南" data-link-desc="個人開發環境的配置管理 — dotfile 結構設計、同步策略、shell 與終端機配置、平鋪式視窗管理、桌面客製化，從個人工具鏈延伸到團隊環境標準化">Dotfile</a></td>
          <td>個人工作環境配置管理</td>
          <td>Infra 是組織的環境 as code，Dotfile 是個人的環境 as code，思想平行</td>
      </tr>
  </tbody>
</table>
<p>Backend 教「服務怎麼設計」、DevOps 教「設計好的服務怎麼營運」，Infra 教「這些服務跑在什麼樣的地基上、這套地基怎麼從零長出來」。模組九同時處理 infra 的組織面 — 怎麼向非技術決策者說明地基的價值、怎麼在信任不足的環境裡推動採用。</p>
<h2 id="教學模組">教學模組</h2>
<p>模組編號標示成熟度階梯的位置，不是閱讀順序。模組負一是還沒站上 IaC 第一階的真實起點（全手動環境）；模組零是貫穿全系列的共用心智模型、不佔階梯刻度；模組一以後才是一階一階往上的建置順序。「接手維運」和「環境升級」是橫切模組，跟成熟度階梯平行——接手和升級可能發生在任何階段。</p>
<table>
  <thead>
      <tr>
          <th>模組</th>
          <th>主題</th>
          <th>回答什麼問題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/infra/takeover/" data-link-title="接手維運：別人建的環境怎麼接管" data-link-desc="接手前人的專案時，怎麼在不搞壞東西的前提下盤點現況、建立維運能力、逐步正規化 — 從無 SSH 的 FTP 環境到有半套 IaC 的雲端環境都適用">接手維運</a></td>
          <td>別人建的環境怎麼盤點、接管、逐步正規化</td>
          <td>接手前人的專案，怎麼在不搞壞的前提下接管</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/upgrade/" data-link-title="環境與系統升級：帶電施工的遷移操作" data-link-desc="系統在升級過程中要持續服務 — runtime 版本升級、平台遷移、資料庫大版本升級、OS 更換、架構轉型的共通操作框架與各類型的專屬風險">環境與系統升級</a></td>
          <td>runtime / 平台 / 資料庫 / OS 的帶電施工遷移</td>
          <td>系統在服務中怎麼升級而不中斷</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/air-gapped/" data-link-title="斷網環境的 infra：沒有網路時怎麼做" data-link-desc="實體隔離或無法連網的環境裡，IaC、套件管理、容器映像、監控、CI/CD 怎麼運作 — 原則不變、工具路徑全部要換">斷網環境的 infra</a></td>
          <td>離線套件、provider mirror、private registry、self-hosted 監控</td>
          <td>沒有網路時 IaC 和維運怎麼做</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一：還沒有 infra 的環境</a></td>
          <td>手動環境的底線、降低未來納管成本、導入訊號</td>
          <td>還沒有 IaC 時怎麼盡量把事情做對</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">模組零：infra 是什麼</a></td>
          <td>責任邊界、成熟度階梯、day 1 鐵律</td>
          <td>為什麼地基要先做、不先做的代價是什麼</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一：最小可行 IaC</a></td>
          <td>工具選型、remote state、Console 唯讀鐵律</td>
          <td>第一行 IaC 該從哪裡開始</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/02-identity-credentials/" data-link-title="模組二：身分與憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與憑證地基</a></td>
          <td>IAM role / policy、OIDC 取代長期 key</td>
          <td>誰能動什麼、憑證怎麼不外洩</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a></td>
          <td>VPC、subnet 切分、route / NAT、security group</td>
          <td>服務之間的網路邊界怎麼劃</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a></td>
          <td>dev / staging / prod、目錄結構、可重用 module</td>
          <td>怎麼讓 dev 跟 prod 不互相污染又共用一套 code</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a></td>
          <td>資料庫、運算、儲存、load balancer</td>
          <td>地基鋪好後核心服務怎麼接上去</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/06-observability-logging/" data-link-title="模組六：可觀測性與 log 一併寫進 code" data-link-desc="log group、metric、alarm 跟基礎設施同生命週期管理，出事時追得到查得到">模組六：可觀測性與 log</a></td>
          <td>log group、metric、alarm 同生命週期管理</td>
          <td>出事時怎麼追得到、查得到</td>
      </tr>
      <tr>
          <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 自動化，讓基礎設施可審查、可回溯、可交接">模組七：infra 走 PR 流程</a></td>
          <td>plan / apply review、tflint / checkov 護欄</td>
          <td>infra 變更怎麼像 code 一樣可審查可回溯</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a></td>
          <td>tagging、secrets、成本可見性、最小可行節奏</td>
          <td>規模長大後怎麼不失控</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/09-driving-adoption/" data-link-title="模組九：怎麼把 infra 推動起來" data-link-desc="技術正確不等於推得動 — 信任赤字、期望值對齊、知識共享，infra 落地的組織課題">模組九：怎麼把 infra 推動起來</a></td>
          <td>技術正確 ≠ 推得動、期望值對齊、知識共享</td>
          <td>為什麼 infra 常推不動、怎麼推得動</td>
      </tr>
  </tbody>
</table>
<h2 id="學習路線">學習路線</h2>
<table>
  <thead>
      <tr>
          <th>路線</th>
          <th>適合讀者</th>
          <th>建議順序</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>被指派 infra</td>
          <td>其他領域工程師、拿到公司雲端帳號</td>
          <td><a href="/blog/infra/00-infra-mindset/first-day-with-cloud-account/" data-link-title="拿到雲端帳號的第一天" data-link-desc="被指派 infra 工作、拿到 AWS 或 GCP 帳號、不確定該先做什麼時讀 — 第一小時安全底線、帳號現況判讀、後續學習路線分流">拿到雲端帳號的第一天</a> → 依帳號狀態：空帳號走「從零建置」、有東西走「接手前人專案」</td>
      </tr>
      <tr>
          <td>入門認識</td>
          <td>從沒碰過 infra 的個人開發者</td>
          <td><a href="/blog/infra/00-infra-mindset/personal-project-to-infra/" data-link-title="雲端部署裡已經存在的 infra 元件" data-link-desc="VPC、security group、IAM、儲存 — 這些元件在任何雲端部署裡都已經在運作，差別在於有沒有被有意識地管理">個人專案到團隊服務</a> → <a href="/blog/infra/00-infra-mindset/one-machine-to-environments/" data-link-title="從單一環境到環境分離：infra 需求的浮現過程" data-link-desc="單一 EC2 &#43; RDS 的結構在需要測試環境、多人協作時會撞到哪些操作極限，以及環境分離怎麼牽出身分、網路、變更流程等後續 infra 關注點">一台機器到三個環境</a> → 模組零</td>
      </tr>
      <tr>
          <td>從零建置</td>
          <td>新專案 day 1、想一開始就鋪對地基</td>
          <td>模組零 → 模組一 → 模組二（含<a href="/blog/infra/02-identity-credentials/multi-account-strategy/" data-link-title="跨帳號策略 — Organizations、SCP 與帳號工廠" data-link-desc="用 AWS Organizations 把環境拆成獨立帳號、用 SCP 設定連管理員都越不過的護欄、用帳號工廠讓每個新帳號自帶安全基線">跨帳號策略</a>）→ 模組三 → 模組四 → 模組五</td>
      </tr>
      <tr>
          <td>還沒有 IaC</td>
          <td>全手動環境、暫時沒資源導入</td>
          <td>模組負一 → 模組零</td>
      </tr>
      <tr>
          <td>救火後納管</td>
          <td>已經有一堆手動資源、想收進 IaC</td>
          <td>模組負一 → 模組一 → 模組二 → 模組四 → 模組七</td>
      </tr>
      <tr>
          <td>治理與成本</td>
          <td>infra 跑得起來但開始失控</td>
          <td>模組八（含<a href="/blog/infra/08-governance-habits/handover-design/" data-link-title="職務交接與存取撤銷設計" data-link-desc="人員異動時的存取撤銷順序、credential rotation、最小交接清單，以及讓交接成本結構性降低的 infra 設計原則">職務交接設計</a>）→ 模組六 → 模組七</td>
      </tr>
      <tr>
          <td>說服決策者</td>
          <td>要向非技術人解釋的工程師</td>
          <td><a href="/blog/infra/09-driving-adoption/infra-explained-for-non-engineers/" data-link-title="給非工程背景決策者的 infra 說明" data-link-desc="從管理視角解釋基礎設施在解決什麼營運問題、不做的代價、出事怎麼處理，讓參與資源決策的人能判斷投入的優先級">給非工程人員的 infra 說明</a> → <a href="/blog/infra/09-driving-adoption/infra-business-justification/" data-link-title="infra 投資的商業論證" data-link-desc="用成本、風險、速度三條論述線把 infra 投資翻譯成商業語言，附一頁簡報邏輯與常見反對意見的回應">infra 投資的商業論證</a> → 模組九</td>
      </tr>
      <tr>
          <td>接手前人專案</td>
          <td>繼承了別人建的環境、要接管維運</td>
          <td><a href="/blog/infra/takeover/" data-link-title="接手維運：別人建的環境怎麼接管" data-link-desc="接手前人的專案時，怎麼在不搞壞東西的前提下盤點現況、建立維運能力、逐步正規化 — 從無 SSH 的 FTP 環境到有半套 IaC 的雲端環境都適用">接手維運</a>（依環境類型選篇）→ 模組負一 → 模組一</td>
      </tr>
      <tr>
          <td>環境升級</td>
          <td>需要升級 runtime / 平台 / DB / OS</td>
          <td><a href="/blog/infra/upgrade/upgrade-framework/" data-link-title="升級的共通操作框架" data-link-desc="任何環境或系統升級的四階段模型：差異評估、平行環境驗證、分批切換、退役舊環境，以及貫穿全程的升級紀律">升級框架</a> → 依升級類型選篇 → 模組五（stateful 保護）</td>
      </tr>
      <tr>
          <td>斷網環境</td>
          <td>實體隔離或無法連網的環境</td>
          <td><a href="/blog/infra/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">斷網通用原則</a> → 依面向選篇（IaC / 容器 / 監控）</td>
      </tr>
      <tr>
          <td>推不動的困局</td>
          <td>技術做了一半、上層不買單</td>
          <td>模組九 → 模組零</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>terraform plan / apply</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/terraform-plan-apply/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/terraform-plan-apply/</guid><description>&lt;p>&lt;code>terraform plan&lt;/code> 和 &lt;code>terraform apply&lt;/code> 是 Terraform 操作基礎設施的兩個核心指令。plan 比對三方（state 檔、雲端現況、HCL 描述）產出差異報告，告訴使用者「如果 apply 會發生什麼」，但不做任何改動。apply 執行 plan 算出的差異，在雲端建立、修改或刪除資源。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>plan/apply 的分離是 IaC 可審查性的基礎。模組七（PR 流程）的核心機制就是「PR 觸發 plan → plan 結果貼回 PR → reviewer 看 plan 再決定要不要 apply」。這個「先看再動」的流程跟手動操作（直接在 Console 改）的根本差別。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>需要理解 plan/apply 的情境包括：第一次跑 Terraform、review 別人的 infra PR（看 plan 輸出）、排查 drift（plan 在沒有 code 變更的情況下顯示差異）、或決定一次 apply 是否安全。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>plan 輸出的三種動作標記：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>標記&lt;/th>
 &lt;th>意義&lt;/th>
 &lt;th>風險&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>+&lt;/code>&lt;/td>
 &lt;td>新增資源&lt;/td>
 &lt;td>低（新建不影響現有）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>~&lt;/code>&lt;/td>
 &lt;td>修改資源（in-place update）&lt;/td>
 &lt;td>中（看改什麼，改 tag 低風險、改 instance type 可能重啟）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>-/+&lt;/code>&lt;/td>
 &lt;td>先刪後建（forces replacement）&lt;/td>
 &lt;td>高（stateful 資源如 RDS 代表資料遺失）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>-&lt;/code>&lt;/td>
 &lt;td>刪除資源&lt;/td>
 &lt;td>高（不可逆）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>review plan 時最需要警惕的是 &lt;code>-/+&lt;/code>（forces replacement）——看起來只是改一個屬性，但某些屬性的修改會觸發資源重建（例如 RDS 的 &lt;code>identifier&lt;/code> 改名）。&lt;/p>
&lt;p>plan 與 apply 之間可能有時間差。如果 plan 之後、apply 之前有人手動改了雲端資源，apply 時的實際行為可能跟 plan 預期的不同。多數團隊在 apply 階段會重跑一次 plan 並要求結果一致。&lt;/p>
&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>&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>&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>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p><code>terraform plan</code> 和 <code>terraform apply</code> 是 Terraform 操作基礎設施的兩個核心指令。plan 比對三方（state 檔、雲端現況、HCL 描述）產出差異報告，告訴使用者「如果 apply 會發生什麼」，但不做任何改動。apply 執行 plan 算出的差異，在雲端建立、修改或刪除資源。</p>
<h2 id="概念位置">概念位置</h2>
<p>plan/apply 的分離是 IaC 可審查性的基礎。模組七（PR 流程）的核心機制就是「PR 觸發 plan → plan 結果貼回 PR → reviewer 看 plan 再決定要不要 apply」。這個「先看再動」的流程跟手動操作（直接在 Console 改）的根本差別。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>需要理解 plan/apply 的情境包括：第一次跑 Terraform、review 別人的 infra PR（看 plan 輸出）、排查 drift（plan 在沒有 code 變更的情況下顯示差異）、或決定一次 apply 是否安全。</p>
<h2 id="設計責任">設計責任</h2>
<p>plan 輸出的三種動作標記：</p>
<table>
  <thead>
      <tr>
          <th>標記</th>
          <th>意義</th>
          <th>風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>+</code></td>
          <td>新增資源</td>
          <td>低（新建不影響現有）</td>
      </tr>
      <tr>
          <td><code>~</code></td>
          <td>修改資源（in-place update）</td>
          <td>中（看改什麼，改 tag 低風險、改 instance type 可能重啟）</td>
      </tr>
      <tr>
          <td><code>-/+</code></td>
          <td>先刪後建（forces replacement）</td>
          <td>高（stateful 資源如 RDS 代表資料遺失）</td>
      </tr>
      <tr>
          <td><code>-</code></td>
          <td>刪除資源</td>
          <td>高（不可逆）</td>
      </tr>
  </tbody>
</table>
<p>review plan 時最需要警惕的是 <code>-/+</code>（forces replacement）——看起來只是改一個屬性，但某些屬性的修改會觸發資源重建（例如 RDS 的 <code>identifier</code> 改名）。</p>
<p>plan 與 apply 之間可能有時間差。如果 plan 之後、apply 之前有人手動改了雲端資源，apply 時的實際行為可能跟 plan 預期的不同。多數團隊在 apply 階段會重跑一次 plan 並要求結果一致。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">State</a></li>
<li><a href="/blog/infra/knowledge-cards/drift/" data-link-title="Drift（設定漂移）" data-link-desc="IaC 的 state 與雲端實際狀態之間的不一致，通常因為有人繞過 IaC 直接在 Console 改設定">Drift</a></li>
<li><a href="/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC</a></li>
</ul>
]]></content:encoded></item><item><title>AMI</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/ami/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/ami/</guid><description>&lt;p>AMI（Amazon Machine Image）是 EC2 instance 的完整映像快照。它包含作業系統、已安裝的軟體、設定檔、磁碟內容——從一個 AMI 啟動新的 instance，得到的是跟拍照時完全一樣的環境。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>AMI 在 infra 系列裡有兩個角色。第一個是接手維運時的保險——對 VM 建一個 AMI 等於把整台機器拍下來，做任何改動前都有一個可回退的基線。第二個是環境標準化——把裝好軟體的 instance 做成 AMI（golden image），之後開新機器都從這個 AMI 啟動，確保每台機器的基線一致。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>需要理解 AMI 的情境包括：接手一台不確定裡面裝了什麼的 EC2（先拍 AMI 再動）、要在另一個 region 或帳號複製一台同樣的機器、OS 升級時要保留舊環境作為 rollback、或設計 auto-scaling 的 launch template（需要指定 AMI）。&lt;/p>
&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>建立 AMI&lt;/td>
 &lt;td>對現有 instance 拍照&lt;/td>
 &lt;td>&lt;code>--no-reboot&lt;/code> 避免服務中斷，但檔案系統一致性略低&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>從 AMI 啟動 instance&lt;/td>
 &lt;td>複製環境&lt;/td>
 &lt;td>新 instance 有新的 IP、hostname、instance ID&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨 region 複製 AMI&lt;/td>
 &lt;td>災難復原或多 region 部署&lt;/td>
 &lt;td>複製是非同步的、完成後才能在目標 region 使用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>共享 AMI&lt;/td>
 &lt;td>跨帳號使用同一個映像&lt;/td>
 &lt;td>需要設定 AMI 的 launch permission&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>AMI 包含 EBS snapshot——AMI 的儲存成本就是底層 EBS snapshot 的成本（按儲存量計費）。不再使用的 AMI 要記得 deregister 並刪除對應的 snapshot，否則持續計費。&lt;/p>
&lt;p>跟 container image 的差別：AMI 是整台 VM 的映像（含 OS、kernel、系統套件），container image 只包含應用程式和它的依賴（共用 host OS 的 kernel）。AMI 以 GB 計（通常 8-50 GB），container image 以 MB 計（通常 50-500 MB）。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/ec2/" data-link-title="EC2" data-link-desc="AWS 的虛擬機器服務，提供可隨時啟停的運算實例，組成包含 AMI、instance type、EBS、security group 與 IAM role">EC2&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/ebs/" data-link-title="EBS" data-link-desc="掛在 EC2 instance 上的持久化區塊儲存（虛擬磁碟），支援 snapshot 快照備份，跟 instance 獨立生命週期">EBS&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>AMI（Amazon Machine Image）是 EC2 instance 的完整映像快照。它包含作業系統、已安裝的軟體、設定檔、磁碟內容——從一個 AMI 啟動新的 instance，得到的是跟拍照時完全一樣的環境。</p>
<h2 id="概念位置">概念位置</h2>
<p>AMI 在 infra 系列裡有兩個角色。第一個是接手維運時的保險——對 VM 建一個 AMI 等於把整台機器拍下來，做任何改動前都有一個可回退的基線。第二個是環境標準化——把裝好軟體的 instance 做成 AMI（golden image），之後開新機器都從這個 AMI 啟動，確保每台機器的基線一致。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>需要理解 AMI 的情境包括：接手一台不確定裡面裝了什麼的 EC2（先拍 AMI 再動）、要在另一個 region 或帳號複製一台同樣的機器、OS 升級時要保留舊環境作為 rollback、或設計 auto-scaling 的 launch template（需要指定 AMI）。</p>
<h2 id="設計責任">設計責任</h2>
<table>
  <thead>
      <tr>
          <th>操作</th>
          <th>用途</th>
          <th>注意事項</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>建立 AMI</td>
          <td>對現有 instance 拍照</td>
          <td><code>--no-reboot</code> 避免服務中斷，但檔案系統一致性略低</td>
      </tr>
      <tr>
          <td>從 AMI 啟動 instance</td>
          <td>複製環境</td>
          <td>新 instance 有新的 IP、hostname、instance ID</td>
      </tr>
      <tr>
          <td>跨 region 複製 AMI</td>
          <td>災難復原或多 region 部署</td>
          <td>複製是非同步的、完成後才能在目標 region 使用</td>
      </tr>
      <tr>
          <td>共享 AMI</td>
          <td>跨帳號使用同一個映像</td>
          <td>需要設定 AMI 的 launch permission</td>
      </tr>
  </tbody>
</table>
<p>AMI 包含 EBS snapshot——AMI 的儲存成本就是底層 EBS snapshot 的成本（按儲存量計費）。不再使用的 AMI 要記得 deregister 並刪除對應的 snapshot，否則持續計費。</p>
<p>跟 container image 的差別：AMI 是整台 VM 的映像（含 OS、kernel、系統套件），container image 只包含應用程式和它的依賴（共用 host OS 的 kernel）。AMI 以 GB 計（通常 8-50 GB），container image 以 MB 計（通常 50-500 MB）。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/ec2/" data-link-title="EC2" data-link-desc="AWS 的虛擬機器服務，提供可隨時啟停的運算實例，組成包含 AMI、instance type、EBS、security group 與 IAM role">EC2</a></li>
<li><a href="/blog/infra/knowledge-cards/ebs/" data-link-title="EBS" data-link-desc="掛在 EC2 instance 上的持久化區塊儲存（虛擬磁碟），支援 snapshot 快照備份，跟 instance 獨立生命週期">EBS</a></li>
</ul>
]]></content:encoded></item><item><title>Composer</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/composer/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/composer/</guid><description>&lt;p>Composer 是 PHP 的套件管理工具，角色等同於 Node.js 的 npm、Python 的 pip、Go 的 go mod。它負責宣告專案需要哪些第三方套件、鎖定每個套件的確切版本、以及把套件安裝到專案目錄裡。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>接手 PHP 專案時，Composer 是判斷「專案依賴了什麼、版本有沒有已知漏洞」的入口。專案根目錄通常有三個 Composer 相關的檔案：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>檔案&lt;/th>
 &lt;th>角色&lt;/th>
 &lt;th>進 Git？&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>composer.json&lt;/code>&lt;/td>
 &lt;td>宣告依賴（套件名稱 + 版本範圍）&lt;/td>
 &lt;td>是&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>composer.lock&lt;/code>&lt;/td>
 &lt;td>鎖定確切版本（含所有 transitive 依賴）&lt;/td>
 &lt;td>是&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>vendor/&lt;/code>&lt;/td>
 &lt;td>安裝的套件目錄&lt;/td>
 &lt;td>否（.gitignore 排除、由 &lt;code>composer install&lt;/code> 重建）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>接手專案時如果根目錄有 &lt;code>composer.json&lt;/code> 但沒有 &lt;code>vendor/&lt;/code>，代表需要先跑 &lt;code>composer install&lt;/code> 才能讓專案運作。如果連 &lt;code>composer.lock&lt;/code> 都沒有，代表套件版本沒有鎖定——每次安裝可能拿到不同版本。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>兩個常用指令的差別：&lt;/p>
&lt;ul>
&lt;li>&lt;code>composer install&lt;/code>：按 &lt;code>composer.lock&lt;/code> 安裝確切版本。用於部署和接手——確保每台機器安裝的版本一致。&lt;/li>
&lt;li>&lt;code>composer update&lt;/code>：重新解析 &lt;code>composer.json&lt;/code> 的版本範圍、更新到最新的符合版本、改寫 &lt;code>composer.lock&lt;/code>。用於主動升級依賴。&lt;/li>
&lt;/ul>
&lt;p>接手時的關鍵操作：&lt;/p>
&lt;ul>
&lt;li>&lt;code>composer audit&lt;/code>：掃描已安裝套件的已知安全漏洞&lt;/li>
&lt;li>&lt;code>composer outdated&lt;/code>：列出可更新的套件及其最新版本&lt;/li>
&lt;/ul>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/dotenv/" data-link-title=".env" data-link-desc="存放環境變數的純文字檔案，把機密值從程式碼分離出來">.env&lt;/a>：Composer 管套件、.env 管設定值，兩者都是 PHP 專案的基礎設施&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/php-ini/" data-link-title="php.ini / .user.ini" data-link-desc="PHP 的執行期設定檔，控制記憶體上限、上傳大小、錯誤報告等 runtime 行為">php.ini / .user.ini&lt;/a>：Composer 需要 PHP CLI 執行，php.ini 的 memory_limit 和 max_execution_time 會影響 Composer 能不能跑完&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Composer 是 PHP 的套件管理工具，角色等同於 Node.js 的 npm、Python 的 pip、Go 的 go mod。它負責宣告專案需要哪些第三方套件、鎖定每個套件的確切版本、以及把套件安裝到專案目錄裡。</p>
<h2 id="概念位置">概念位置</h2>
<p>接手 PHP 專案時，Composer 是判斷「專案依賴了什麼、版本有沒有已知漏洞」的入口。專案根目錄通常有三個 Composer 相關的檔案：</p>
<table>
  <thead>
      <tr>
          <th>檔案</th>
          <th>角色</th>
          <th>進 Git？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>composer.json</code></td>
          <td>宣告依賴（套件名稱 + 版本範圍）</td>
          <td>是</td>
      </tr>
      <tr>
          <td><code>composer.lock</code></td>
          <td>鎖定確切版本（含所有 transitive 依賴）</td>
          <td>是</td>
      </tr>
      <tr>
          <td><code>vendor/</code></td>
          <td>安裝的套件目錄</td>
          <td>否（.gitignore 排除、由 <code>composer install</code> 重建）</td>
      </tr>
  </tbody>
</table>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>接手專案時如果根目錄有 <code>composer.json</code> 但沒有 <code>vendor/</code>，代表需要先跑 <code>composer install</code> 才能讓專案運作。如果連 <code>composer.lock</code> 都沒有，代表套件版本沒有鎖定——每次安裝可能拿到不同版本。</p>
<h2 id="設計責任">設計責任</h2>
<p>兩個常用指令的差別：</p>
<ul>
<li><code>composer install</code>：按 <code>composer.lock</code> 安裝確切版本。用於部署和接手——確保每台機器安裝的版本一致。</li>
<li><code>composer update</code>：重新解析 <code>composer.json</code> 的版本範圍、更新到最新的符合版本、改寫 <code>composer.lock</code>。用於主動升級依賴。</li>
</ul>
<p>接手時的關鍵操作：</p>
<ul>
<li><code>composer audit</code>：掃描已安裝套件的已知安全漏洞</li>
<li><code>composer outdated</code>：列出可更新的套件及其最新版本</li>
</ul>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/dotenv/" data-link-title=".env" data-link-desc="存放環境變數的純文字檔案，把機密值從程式碼分離出來">.env</a>：Composer 管套件、.env 管設定值，兩者都是 PHP 專案的基礎設施</li>
<li><a href="/blog/infra/knowledge-cards/php-ini/" data-link-title="php.ini / .user.ini" data-link-desc="PHP 的執行期設定檔，控制記憶體上限、上傳大小、錯誤報告等 runtime 行為">php.ini / .user.ini</a>：Composer 需要 PHP CLI 執行，php.ini 的 memory_limit 和 max_execution_time 會影響 Composer 能不能跑完</li>
</ul>
]]></content:encoded></item><item><title>mysqldump</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/mysqldump/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/mysqldump/</guid><description>&lt;p>mysqldump 是 MySQL 和 MariaDB 內建的命令列備份工具，把整個資料庫（或指定的表）匯出成一份包含 CREATE TABLE 和 INSERT 語句的 SQL 純文字檔。還原時把這份檔案餵給 &lt;code>mysql&lt;/code> client 就能重建資料。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>mysqldump 是有 SSH 存取（或 remote MySQL 存取）時的主要備份手段。比 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/phpmyadmin/" data-link-title="phpMyAdmin" data-link-desc="Web 介面的 MySQL / MariaDB 管理工具，透過瀏覽器操作資料庫">phpMyAdmin&lt;/a> 的匯出更可靠——不受 web server 的 timeout 和記憶體限制影響，可以處理數 GB 的資料庫。沒有 SSH 的環境只能退回 phpMyAdmin 匯出。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>接手時如果 server 上有 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/cron/" data-link-title="cron" data-link-desc="Unix/Linux 的排程工作系統，按時間表自動執行指令。接手維運時要盤點所有 cron job">cron&lt;/a> job 在跑 mysqldump，代表前任有做自動備份——確認輸出的 dump 檔案存在哪、保留幾天、有沒有被驗證過能還原。如果沒有任何 mysqldump cron，代表備份可能只靠 phpMyAdmin 手動匯出或完全沒做。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>常用的 flag 組合：&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">mysqldump -u user -p &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> --single-transaction &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> --routines &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> --triggers &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> dbname &amp;gt; dump-&lt;span class="k">$(&lt;/span>date +%Y%m%d&lt;span class="k">)&lt;/span>.sql&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Flag&lt;/th>
 &lt;th>作用&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>--single-transaction&lt;/code>&lt;/td>
 &lt;td>InnoDB 表不鎖表匯出（用一致性快照），生產備份必備&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>--routines&lt;/code>&lt;/td>
 &lt;td>含 stored procedure 和 function&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>--triggers&lt;/code>&lt;/td>
 &lt;td>含 trigger&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>--quick&lt;/code>&lt;/td>
 &lt;td>逐行讀取、不把整個表載入記憶體，大表必備&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&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">mysql -u user -p dbname &amp;lt; dump-20260626.sql&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>mysqldump 產出的是邏輯備份（SQL 語句），還原速度取決於資料量——幾百 MB 以內分鐘級，數 GB 可能要半小時以上。需要更快的備份/還原（物理備份），要用 Percona XtraBackup 或 MySQL Enterprise Backup。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/phpmyadmin/" data-link-title="phpMyAdmin" data-link-desc="Web 介面的 MySQL / MariaDB 管理工具，透過瀏覽器操作資料庫">phpMyAdmin&lt;/a>：無 SSH 時的替代備份手段&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/cron/" data-link-title="cron" data-link-desc="Unix/Linux 的排程工作系統，按時間表自動執行指令。接手維運時要盤點所有 cron job">cron&lt;/a>：搭配 cron 做定期自動備份&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>mysqldump 是 MySQL 和 MariaDB 內建的命令列備份工具，把整個資料庫（或指定的表）匯出成一份包含 CREATE TABLE 和 INSERT 語句的 SQL 純文字檔。還原時把這份檔案餵給 <code>mysql</code> client 就能重建資料。</p>
<h2 id="概念位置">概念位置</h2>
<p>mysqldump 是有 SSH 存取（或 remote MySQL 存取）時的主要備份手段。比 <a href="/blog/infra/knowledge-cards/phpmyadmin/" data-link-title="phpMyAdmin" data-link-desc="Web 介面的 MySQL / MariaDB 管理工具，透過瀏覽器操作資料庫">phpMyAdmin</a> 的匯出更可靠——不受 web server 的 timeout 和記憶體限制影響，可以處理數 GB 的資料庫。沒有 SSH 的環境只能退回 phpMyAdmin 匯出。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>接手時如果 server 上有 <a href="/blog/infra/knowledge-cards/cron/" data-link-title="cron" data-link-desc="Unix/Linux 的排程工作系統，按時間表自動執行指令。接手維運時要盤點所有 cron job">cron</a> job 在跑 mysqldump，代表前任有做自動備份——確認輸出的 dump 檔案存在哪、保留幾天、有沒有被驗證過能還原。如果沒有任何 mysqldump cron，代表備份可能只靠 phpMyAdmin 手動匯出或完全沒做。</p>
<h2 id="設計責任">設計責任</h2>
<p>常用的 flag 組合：</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">mysqldump -u user -p <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --single-transaction <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --routines <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --triggers <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  dbname &gt; dump-<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>.sql</span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>Flag</th>
          <th>作用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>--single-transaction</code></td>
          <td>InnoDB 表不鎖表匯出（用一致性快照），生產備份必備</td>
      </tr>
      <tr>
          <td><code>--routines</code></td>
          <td>含 stored procedure 和 function</td>
      </tr>
      <tr>
          <td><code>--triggers</code></td>
          <td>含 trigger</td>
      </tr>
      <tr>
          <td><code>--quick</code></td>
          <td>逐行讀取、不把整個表載入記憶體，大表必備</td>
      </tr>
  </tbody>
</table>
<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">mysql -u user -p dbname &lt; dump-20260626.sql</span></span></code></pre></div><p>mysqldump 產出的是邏輯備份（SQL 語句），還原速度取決於資料量——幾百 MB 以內分鐘級，數 GB 可能要半小時以上。需要更快的備份/還原（物理備份），要用 Percona XtraBackup 或 MySQL Enterprise Backup。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/phpmyadmin/" data-link-title="phpMyAdmin" data-link-desc="Web 介面的 MySQL / MariaDB 管理工具，透過瀏覽器操作資料庫">phpMyAdmin</a>：無 SSH 時的替代備份手段</li>
<li><a href="/blog/infra/knowledge-cards/cron/" data-link-title="cron" data-link-desc="Unix/Linux 的排程工作系統，按時間表自動執行指令。接手維運時要盤點所有 cron job">cron</a>：搭配 cron 做定期自動備份</li>
</ul>
]]></content:encoded></item><item><title>Reverse Proxy</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/reverse-proxy/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/reverse-proxy/</guid><description>&lt;p>Reverse proxy 是一個坐在後端服務前面、代替它接收外部請求的中介層。外部 client 連的是 reverse proxy 的位址，reverse proxy 根據規則把請求轉發到實際處理的內部服務，再把回應傳回給 client。Client 不知道（也不需要知道）後面有幾台服務、跑在哪裡。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>nginx 和 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB&lt;/a> 都扮演 reverse proxy 角色。差別在層級：nginx 通常部署在應用層（跟應用伺服器同一台或同一個 VPC 內），ALB 是雲端平台提供的受管服務。兩者的核心功能相同——接收外部流量、轉發到後端、回傳結果。&lt;/p>
&lt;p>跟 forward proxy 的方向相反：forward proxy 代替 client 發送請求（client 在內網、proxy 幫它出去）；reverse proxy 代替 server 接收請求（server 在內網、proxy 幫它面對外部）。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>接手時如果 server 上跑著 nginx 但應用程式用的是 PHP-FPM 或 Node.js，nginx 多半扮演 reverse proxy——它接 HTTP/HTTPS 請求、轉發給後端的 application server。設定檔裡的 &lt;code>proxy_pass&lt;/code>（nginx）或 &lt;code>ProxyPass&lt;/code>（Apache）就是 reverse proxy 的轉發規則。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>reverse proxy 常承擔的功能：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>功能&lt;/th>
 &lt;th>說明&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>TLS 終結&lt;/td>
 &lt;td>HTTPS 的加解密在 proxy 層處理，後端服務只收 HTTP&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>負載平衡&lt;/td>
 &lt;td>把請求分配到多台後端（round-robin、least-connection）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>路由分流&lt;/td>
 &lt;td>依 URL path 導到不同後端服務（/api → backend、/ → frontend）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>靜態檔案快取&lt;/td>
 &lt;td>圖片、CSS、JS 由 proxy 直接回應、不轉發到後端&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>安全過濾&lt;/td>
 &lt;td>擋掉異常請求、限制請求速率、加安全標頭&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/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB&lt;/a>：雲端的受管 reverse proxy + 負載平衡器&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/nginx/" data-link-title="nginx" data-link-desc="高效能 Web Server 與 Reverse Proxy，以集中設定檔取代 Apache 的 .htaccess 分散設定">nginx&lt;/a>：最常見的 reverse proxy 軟體&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Reverse proxy 是一個坐在後端服務前面、代替它接收外部請求的中介層。外部 client 連的是 reverse proxy 的位址，reverse proxy 根據規則把請求轉發到實際處理的內部服務，再把回應傳回給 client。Client 不知道（也不需要知道）後面有幾台服務、跑在哪裡。</p>
<h2 id="概念位置">概念位置</h2>
<p>nginx 和 <a href="/blog/infra/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB</a> 都扮演 reverse proxy 角色。差別在層級：nginx 通常部署在應用層（跟應用伺服器同一台或同一個 VPC 內），ALB 是雲端平台提供的受管服務。兩者的核心功能相同——接收外部流量、轉發到後端、回傳結果。</p>
<p>跟 forward proxy 的方向相反：forward proxy 代替 client 發送請求（client 在內網、proxy 幫它出去）；reverse proxy 代替 server 接收請求（server 在內網、proxy 幫它面對外部）。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>接手時如果 server 上跑著 nginx 但應用程式用的是 PHP-FPM 或 Node.js，nginx 多半扮演 reverse proxy——它接 HTTP/HTTPS 請求、轉發給後端的 application server。設定檔裡的 <code>proxy_pass</code>（nginx）或 <code>ProxyPass</code>（Apache）就是 reverse proxy 的轉發規則。</p>
<h2 id="設計責任">設計責任</h2>
<p>reverse proxy 常承擔的功能：</p>
<table>
  <thead>
      <tr>
          <th>功能</th>
          <th>說明</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>TLS 終結</td>
          <td>HTTPS 的加解密在 proxy 層處理，後端服務只收 HTTP</td>
      </tr>
      <tr>
          <td>負載平衡</td>
          <td>把請求分配到多台後端（round-robin、least-connection）</td>
      </tr>
      <tr>
          <td>路由分流</td>
          <td>依 URL path 導到不同後端服務（/api → backend、/ → frontend）</td>
      </tr>
      <tr>
          <td>靜態檔案快取</td>
          <td>圖片、CSS、JS 由 proxy 直接回應、不轉發到後端</td>
      </tr>
      <tr>
          <td>安全過濾</td>
          <td>擋掉異常請求、限制請求速率、加安全標頭</td>
      </tr>
  </tbody>
</table>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB</a>：雲端的受管 reverse proxy + 負載平衡器</li>
<li><a href="/blog/infra/knowledge-cards/nginx/" data-link-title="nginx" data-link-desc="高效能 Web Server 與 Reverse Proxy，以集中設定檔取代 Apache 的 .htaccess 分散設定">nginx</a>：最常見的 reverse proxy 軟體</li>
</ul>
]]></content:encoded></item><item><title>Database Migration</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/database-migration/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/database-migration/</guid><description>&lt;p>Database migration 是用版本化的腳本管理資料庫 schema 變更的做法。每次 schema 變更（加欄位、改索引、拆表、改資料型別）寫成一份獨立的 migration 檔案，按順序套用。這讓 schema 的演進跟程式碼一樣有版本歷史、可追蹤、可在新環境重現。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>migration 解決的問題是「資料庫的 schema 怎麼從 A 狀態安全地變成 B 狀態」。沒有 migration 時，schema 變更靠在 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/phpmyadmin/" data-link-title="phpMyAdmin" data-link-desc="Web 介面的 MySQL / MariaDB 管理工具，透過瀏覽器操作資料庫">phpMyAdmin&lt;/a> 或 CLI 手動執行 SQL，改了什麼只存在操作者的記憶裡。有 migration 時，每次變更都是 repo 裡的一份檔案，跟程式碼一起 commit、一起 review。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>接手專案時，如果 repo 裡有 &lt;code>migrations/&lt;/code> 目錄（或框架特定的路徑如 Laravel 的 &lt;code>database/migrations/&lt;/code>、Rails 的 &lt;code>db/migrate/&lt;/code>），代表專案使用 migration。如果 repo 裡只有一份 &lt;code>schema.sql&lt;/code> 或完全沒有 schema 相關檔案，代表 schema 變更是手動的——這時候建立 migration 紀律是接手後的優先事項之一。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>每份 migration 檔案包含兩個方向：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>UP&lt;/strong>（套用）：執行 schema 變更的 SQL&lt;/li>
&lt;li>&lt;strong>DOWN&lt;/strong>（回退）：撤銷這次變更的 SQL（不是所有變更都能完美回退，如刪除欄位後資料就沒了）&lt;/li>
&lt;/ul>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">-- migrations/2026-06-26-001-add-users-email-verified.sql
&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="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- UP
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">users&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ADD&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">COLUMN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">email_verified&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">BOOLEAN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DEFAULT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FALSE&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- DOWN
&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 class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">users&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DROP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">COLUMN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">email_verified&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>常用的 migration 工具：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>工具&lt;/th>
 &lt;th>語言 / 框架&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Laravel Migration&lt;/td>
 &lt;td>PHP / Laravel&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Rails Migration&lt;/td>
 &lt;td>Ruby / Rails&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Flyway&lt;/td>
 &lt;td>Java / 跨語言（純 SQL）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Liquibase&lt;/td>
 &lt;td>Java / 跨語言（XML / YAML / SQL）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>golang-migrate&lt;/td>
 &lt;td>Go&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>手動 SQL 檔案&lt;/td>
 &lt;td>無框架時的最低限度方案&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>沒有框架時，用日期 + 序號命名 SQL 檔案（&lt;code>2026-06-26-001-描述.sql&lt;/code>），搭配一張 &lt;code>migration_log&lt;/code> 表記錄哪些已經套用過，就是最低限度的 migration 系統。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/rds/" data-link-title="RDS" data-link-desc="AWS 的受管關聯式資料庫服務，代管備份、更新與 failover，讓使用者專注在 schema 和查詢">RDS&lt;/a>：migration 在 production 資料庫上執行時要格外小心——大表的 ALTER TABLE 可能鎖表&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/mysqldump/" data-link-title="mysqldump" data-link-desc="MySQL / MariaDB 的 CLI 備份工具，把資料庫匯出成 SQL 語句的純文字檔">mysqldump&lt;/a>：執行 migration 前先做一次完整備份&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Database migration 是用版本化的腳本管理資料庫 schema 變更的做法。每次 schema 變更（加欄位、改索引、拆表、改資料型別）寫成一份獨立的 migration 檔案，按順序套用。這讓 schema 的演進跟程式碼一樣有版本歷史、可追蹤、可在新環境重現。</p>
<h2 id="概念位置">概念位置</h2>
<p>migration 解決的問題是「資料庫的 schema 怎麼從 A 狀態安全地變成 B 狀態」。沒有 migration 時，schema 變更靠在 <a href="/blog/infra/knowledge-cards/phpmyadmin/" data-link-title="phpMyAdmin" data-link-desc="Web 介面的 MySQL / MariaDB 管理工具，透過瀏覽器操作資料庫">phpMyAdmin</a> 或 CLI 手動執行 SQL，改了什麼只存在操作者的記憶裡。有 migration 時，每次變更都是 repo 裡的一份檔案，跟程式碼一起 commit、一起 review。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>接手專案時，如果 repo 裡有 <code>migrations/</code> 目錄（或框架特定的路徑如 Laravel 的 <code>database/migrations/</code>、Rails 的 <code>db/migrate/</code>），代表專案使用 migration。如果 repo 裡只有一份 <code>schema.sql</code> 或完全沒有 schema 相關檔案，代表 schema 變更是手動的——這時候建立 migration 紀律是接手後的優先事項之一。</p>
<h2 id="設計責任">設計責任</h2>
<p>每份 migration 檔案包含兩個方向：</p>
<ul>
<li><strong>UP</strong>（套用）：執行 schema 變更的 SQL</li>
<li><strong>DOWN</strong>（回退）：撤銷這次變更的 SQL（不是所有變更都能完美回退，如刪除欄位後資料就沒了）</li>
</ul>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- migrations/2026-06-26-001-add-users-email-verified.sql
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- UP
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">COLUMN</span><span class="w"> </span><span class="n">email_verified</span><span class="w"> </span><span class="nb">BOOLEAN</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="k">FALSE</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="c1">-- DOWN
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">users</span><span class="w"> </span><span class="k">DROP</span><span class="w"> </span><span class="k">COLUMN</span><span class="w"> </span><span class="n">email_verified</span><span class="p">;</span></span></span></code></pre></div><p>常用的 migration 工具：</p>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>語言 / 框架</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Laravel Migration</td>
          <td>PHP / Laravel</td>
      </tr>
      <tr>
          <td>Rails Migration</td>
          <td>Ruby / Rails</td>
      </tr>
      <tr>
          <td>Flyway</td>
          <td>Java / 跨語言（純 SQL）</td>
      </tr>
      <tr>
          <td>Liquibase</td>
          <td>Java / 跨語言（XML / YAML / SQL）</td>
      </tr>
      <tr>
          <td>golang-migrate</td>
          <td>Go</td>
      </tr>
      <tr>
          <td>手動 SQL 檔案</td>
          <td>無框架時的最低限度方案</td>
      </tr>
  </tbody>
</table>
<p>沒有框架時，用日期 + 序號命名 SQL 檔案（<code>2026-06-26-001-描述.sql</code>），搭配一張 <code>migration_log</code> 表記錄哪些已經套用過，就是最低限度的 migration 系統。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/rds/" data-link-title="RDS" data-link-desc="AWS 的受管關聯式資料庫服務，代管備份、更新與 failover，讓使用者專注在 schema 和查詢">RDS</a>：migration 在 production 資料庫上執行時要格外小心——大表的 ALTER TABLE 可能鎖表</li>
<li><a href="/blog/infra/knowledge-cards/mysqldump/" data-link-title="mysqldump" data-link-desc="MySQL / MariaDB 的 CLI 備份工具，把資料庫匯出成 SQL 語句的純文字檔">mysqldump</a>：執行 migration 前先做一次完整備份</li>
</ul>
]]></content:encoded></item><item><title>Prometheus</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/prometheus/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/prometheus/</guid><description>&lt;p>Prometheus 是開源的 metrics 收集與告警系統。它用 pull 模式運作——定期從被監控的 target（應用程式、伺服器、資料庫）的 HTTP endpoint 拉取指標，存進本地的時序資料庫。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Prometheus 在 infra 監控層負責「收集與儲存指標」。它搭配 &lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/grafana/" data-link-title="Grafana" data-link-desc="開源的監控視覺化平台，從 Prometheus / Loki / Elasticsearch 等資料源建立 dashboard">Grafana&lt;/a> 做視覺化（Prometheus 自己的 UI 只有基礎的 query 介面）、搭配 Alertmanager 做告警路由（Prometheus 偵測異常、Alertmanager 決定通知誰）。斷網環境裡它是取代 Datadog / New Relic 的預設方案——不需要連外、self-hosted、社群龐大。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>系統需要 Prometheus 的訊號是：需要追蹤隨時間變化的數值指標（CPU 使用率、request 延遲、佇列深度、錯誤率），且這些指標要能查詢歷史趨勢和設定告警閾值。如果只需要 log（文字紀錄），Loki 或 ELK 更適合；Prometheus 處理的是結構化的數值 metrics。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>使用 Prometheus 時要決定：scrape interval（多久拉一次、預設 15 秒）、retention（資料保留多久、預設 15 天）、哪些 target 要監控（service discovery 或靜態設定）、告警規則的閾值和評估窗口。斷網環境的額外考量是 storage capacity——所有資料留在本地磁碟、沒有 cloud auto-scale。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/grafana/" data-link-title="Grafana" data-link-desc="開源的監控視覺化平台，從 Prometheus / Loki / Elasticsearch 等資料源建立 dashboard">Grafana&lt;/a>：視覺化 Prometheus 的指標&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Prometheus 是開源的 metrics 收集與告警系統。它用 pull 模式運作——定期從被監控的 target（應用程式、伺服器、資料庫）的 HTTP endpoint 拉取指標，存進本地的時序資料庫。</p>
<h2 id="概念位置">概念位置</h2>
<p>Prometheus 在 infra 監控層負責「收集與儲存指標」。它搭配 <a href="/blog/infra/knowledge-cards/grafana/" data-link-title="Grafana" data-link-desc="開源的監控視覺化平台，從 Prometheus / Loki / Elasticsearch 等資料源建立 dashboard">Grafana</a> 做視覺化（Prometheus 自己的 UI 只有基礎的 query 介面）、搭配 Alertmanager 做告警路由（Prometheus 偵測異常、Alertmanager 決定通知誰）。斷網環境裡它是取代 Datadog / New Relic 的預設方案——不需要連外、self-hosted、社群龐大。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>系統需要 Prometheus 的訊號是：需要追蹤隨時間變化的數值指標（CPU 使用率、request 延遲、佇列深度、錯誤率），且這些指標要能查詢歷史趨勢和設定告警閾值。如果只需要 log（文字紀錄），Loki 或 ELK 更適合；Prometheus 處理的是結構化的數值 metrics。</p>
<h2 id="設計責任">設計責任</h2>
<p>使用 Prometheus 時要決定：scrape interval（多久拉一次、預設 15 秒）、retention（資料保留多久、預設 15 天）、哪些 target 要監控（service discovery 或靜態設定）、告警規則的閾值和評估窗口。斷網環境的額外考量是 storage capacity——所有資料留在本地磁碟、沒有 cloud auto-scale。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/grafana/" data-link-title="Grafana" data-link-desc="開源的監控視覺化平台，從 Prometheus / Loki / Elasticsearch 等資料源建立 dashboard">Grafana</a>：視覺化 Prometheus 的指標</li>
</ul>
]]></content:encoded></item><item><title>Grafana</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/grafana/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/grafana/</guid><description>&lt;p>Grafana 是開源的監控視覺化平台。它本身不收集或儲存資料——它連接外部資料源（&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/prometheus/" data-link-title="Prometheus" data-link-desc="開源的 metrics 收集與告警系統，用 pull 模式從 target 拉取指標，斷網環境的預設監控方案">Prometheus&lt;/a>、Loki、Elasticsearch、MySQL 等），提供查詢介面和可自訂的儀表板。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Grafana 在監控體系裡負責「讓指標和 log 變成人可以讀的畫面」。Prometheus 收集指標、Loki 收集 log、Grafana 把兩者的資料用圖表、表格、熱力圖呈現。不同角色看不同 dashboard——DevOps 看資源健康、開發者看應用指標、管理層看 SLA 達成率。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>系統需要 Grafana 的訊號是：已經有 Prometheus 或其他資料源在收集指標，但需要一個視覺化介面來建 dashboard、設告警（Grafana 也有自己的告警功能）、分享給團隊。如果只需要 CLI 查詢，PromQL 直接在 Prometheus 跑就好。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>使用 Grafana 時要決定：dashboard 的組織（按服務、按環境、按角色）、資料源的連線設定、使用者權限（viewer / editor / admin）、告警通知管道（email / Slack / webhook）。斷網環境裡 Grafana 的 plugin 需要離線安裝（&lt;code>grafana-cli --pluginUrl&lt;/code> 指向本地檔案）。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/prometheus/" data-link-title="Prometheus" data-link-desc="開源的 metrics 收集與告警系統，用 pull 模式從 target 拉取指標，斷網環境的預設監控方案">Prometheus&lt;/a>：Grafana 最常見的 metrics 資料源&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Grafana 是開源的監控視覺化平台。它本身不收集或儲存資料——它連接外部資料源（<a href="/blog/infra/knowledge-cards/prometheus/" data-link-title="Prometheus" data-link-desc="開源的 metrics 收集與告警系統，用 pull 模式從 target 拉取指標，斷網環境的預設監控方案">Prometheus</a>、Loki、Elasticsearch、MySQL 等），提供查詢介面和可自訂的儀表板。</p>
<h2 id="概念位置">概念位置</h2>
<p>Grafana 在監控體系裡負責「讓指標和 log 變成人可以讀的畫面」。Prometheus 收集指標、Loki 收集 log、Grafana 把兩者的資料用圖表、表格、熱力圖呈現。不同角色看不同 dashboard——DevOps 看資源健康、開發者看應用指標、管理層看 SLA 達成率。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>系統需要 Grafana 的訊號是：已經有 Prometheus 或其他資料源在收集指標，但需要一個視覺化介面來建 dashboard、設告警（Grafana 也有自己的告警功能）、分享給團隊。如果只需要 CLI 查詢，PromQL 直接在 Prometheus 跑就好。</p>
<h2 id="設計責任">設計責任</h2>
<p>使用 Grafana 時要決定：dashboard 的組織（按服務、按環境、按角色）、資料源的連線設定、使用者權限（viewer / editor / admin）、告警通知管道（email / Slack / webhook）。斷網環境裡 Grafana 的 plugin 需要離線安裝（<code>grafana-cli --pluginUrl</code> 指向本地檔案）。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/prometheus/" data-link-title="Prometheus" data-link-desc="開源的 metrics 收集與告警系統，用 pull 模式從 target 拉取指標，斷網環境的預設監控方案">Prometheus</a>：Grafana 最常見的 metrics 資料源</li>
</ul>
]]></content:encoded></item><item><title>HashiCorp Vault</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/vault/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/vault/</guid><description>&lt;p>HashiCorp Vault 是機密管理系統，集中存放和控制對敏感資料（密碼、API key、TLS 私鑰、資料庫憑證）的存取。每一次讀取都有稽核紀錄、每一份機密都有存取政策、憑證可以設定自動輪替。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Vault 在 infra 裡負責「機密值的集中管理」。跟直接把密碼寫在環境變數或設定檔的差別是：Vault 提供存取控制（只有被授權的身分能讀特定 secret）、稽核軌跡（誰在什麼時候讀了什麼）、以及動態 secret（每次請求產生一組臨時憑證、用完即銷毀）。&lt;/p>
&lt;p>連網環境通常用雲端的 secret manager（AWS Secrets Manager、GCP Secret Manager）。斷網環境沒有雲端服務可用、Vault 是 self-hosted 的替代方案。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>系統需要 Vault 的訊號是：多個服務共用同一組資料庫密碼且密碼寫在設定檔裡、沒有人知道上次輪替是什麼時候、或是稽核要求「列出誰能存取哪些機密」而答不出來。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>使用 Vault 時要決定：unseal 方式（連網用 cloud auto-unseal、斷網用 Shamir&amp;rsquo;s secret sharing——需要 N 把 key 中的 M 把才能解鎖）、storage backend（Consul、PostgreSQL、filesystem）、認證方式（人用 LDAP/OIDC、機器用 AppRole）、secret engine 的選擇（KV 存靜態值、PKI 簽發憑證、database 動態產生 DB 帳號）。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM&lt;/a>：Vault 的存取政策跟 IAM 的 policy 概念類似&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/ssl-tls/" data-link-title="SSL / TLS" data-link-desc="加密 client 與 server 之間通訊的協定，讓 HTTPS 成為可能。TLS 是 SSL 的後繼者，但 SSL 憑證的稱呼仍廣泛使用">SSL/TLS&lt;/a>：Vault 的 PKI engine 可以當內部 CA 簽發憑證&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>HashiCorp Vault 是機密管理系統，集中存放和控制對敏感資料（密碼、API key、TLS 私鑰、資料庫憑證）的存取。每一次讀取都有稽核紀錄、每一份機密都有存取政策、憑證可以設定自動輪替。</p>
<h2 id="概念位置">概念位置</h2>
<p>Vault 在 infra 裡負責「機密值的集中管理」。跟直接把密碼寫在環境變數或設定檔的差別是：Vault 提供存取控制（只有被授權的身分能讀特定 secret）、稽核軌跡（誰在什麼時候讀了什麼）、以及動態 secret（每次請求產生一組臨時憑證、用完即銷毀）。</p>
<p>連網環境通常用雲端的 secret manager（AWS Secrets Manager、GCP Secret Manager）。斷網環境沒有雲端服務可用、Vault 是 self-hosted 的替代方案。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>系統需要 Vault 的訊號是：多個服務共用同一組資料庫密碼且密碼寫在設定檔裡、沒有人知道上次輪替是什麼時候、或是稽核要求「列出誰能存取哪些機密」而答不出來。</p>
<h2 id="設計責任">設計責任</h2>
<p>使用 Vault 時要決定：unseal 方式（連網用 cloud auto-unseal、斷網用 Shamir&rsquo;s secret sharing——需要 N 把 key 中的 M 把才能解鎖）、storage backend（Consul、PostgreSQL、filesystem）、認證方式（人用 LDAP/OIDC、機器用 AppRole）、secret engine 的選擇（KV 存靜態值、PKI 簽發憑證、database 動態產生 DB 帳號）。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM</a>：Vault 的存取政策跟 IAM 的 policy 概念類似</li>
<li><a href="/blog/infra/knowledge-cards/ssl-tls/" data-link-title="SSL / TLS" data-link-desc="加密 client 與 server 之間通訊的協定，讓 HTTPS 成為可能。TLS 是 SSL 的後繼者，但 SSL 憑證的稱呼仍廣泛使用">SSL/TLS</a>：Vault 的 PKI engine 可以當內部 CA 簽發憑證</li>
</ul>
]]></content:encoded></item><item><title>Harbor</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/harbor/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/harbor/</guid><description>&lt;p>Harbor 是開源的 container image registry，由 CNCF 孵化。它在 Docker Registry 的基礎上加了企業級功能：Web UI、角色型存取控制（RBAC）、映像漏洞掃描（內建 Trivy）、映像簽章驗證、以及跨 registry 的映像複製。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Harbor 在容器生態裡負責「映像的儲存、分發和安全把關」。連網環境裡這個角色通常由 Docker Hub、AWS ECR 或 GCR 擔任。斷網環境沒有公開 registry 可用、Harbor 是 self-hosted 的替代——所有 base image 和應用 image 都推進 Harbor、所有 docker pull 都從 Harbor 拉。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>系統需要 Harbor 的訊號是：團隊開始用容器部署服務、且環境無法連到公開 registry（斷網或受限網路）、或需要在 pull 時自動掃描漏洞。如果只是幾個人在開發機上用 Docker、Docker Registry（無 UI、無掃描）就夠了。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>使用 Harbor 時要決定：project 的組織（按團隊、按環境、按產品線）、使用者認證（本地帳號 or LDAP 整合）、漏洞掃描政策（push 時自動掃、block 有 Critical CVE 的 image）、映像保留政策（保留最近 N 個 tag、自動清理舊 image）、以及 storage backend（本地磁碟或 NFS）。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/ecs/" data-link-title="ECS" data-link-desc="AWS Elastic Container Service — 受管的容器編排服務，用 task definition 描述容器配置、由平台負責排程與健康管理">ECS&lt;/a>：ECS task 從 registry 拉 image&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/fargate/" data-link-title="Fargate" data-link-desc="AWS ECS 的無伺服器執行模式，由 AWS 代管運算實例，不需要管 EC2 capacity 或 AMI 更新">Fargate&lt;/a>：Fargate task 同樣需要 registry&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Harbor 是開源的 container image registry，由 CNCF 孵化。它在 Docker Registry 的基礎上加了企業級功能：Web UI、角色型存取控制（RBAC）、映像漏洞掃描（內建 Trivy）、映像簽章驗證、以及跨 registry 的映像複製。</p>
<h2 id="概念位置">概念位置</h2>
<p>Harbor 在容器生態裡負責「映像的儲存、分發和安全把關」。連網環境裡這個角色通常由 Docker Hub、AWS ECR 或 GCR 擔任。斷網環境沒有公開 registry 可用、Harbor 是 self-hosted 的替代——所有 base image 和應用 image 都推進 Harbor、所有 docker pull 都從 Harbor 拉。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>系統需要 Harbor 的訊號是：團隊開始用容器部署服務、且環境無法連到公開 registry（斷網或受限網路）、或需要在 pull 時自動掃描漏洞。如果只是幾個人在開發機上用 Docker、Docker Registry（無 UI、無掃描）就夠了。</p>
<h2 id="設計責任">設計責任</h2>
<p>使用 Harbor 時要決定：project 的組織（按團隊、按環境、按產品線）、使用者認證（本地帳號 or LDAP 整合）、漏洞掃描政策（push 時自動掃、block 有 Critical CVE 的 image）、映像保留政策（保留最近 N 個 tag、自動清理舊 image）、以及 storage backend（本地磁碟或 NFS）。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/ecs/" data-link-title="ECS" data-link-desc="AWS Elastic Container Service — 受管的容器編排服務，用 task definition 描述容器配置、由平台負責排程與健康管理">ECS</a>：ECS task 從 registry 拉 image</li>
<li><a href="/blog/infra/knowledge-cards/fargate/" data-link-title="Fargate" data-link-desc="AWS ECS 的無伺服器執行模式，由 AWS 代管運算實例，不需要管 EC2 capacity 或 AMI 更新">Fargate</a>：Fargate task 同樣需要 registry</li>
</ul>
]]></content:encoded></item><item><title>Helm</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/helm/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/helm/</guid><description>&lt;p>Helm 是 Kubernetes 的套件管理工具。它用 chart（一組模板檔案 + 預設值）把多個 K8s 資源（Deployment、Service、ConfigMap、Ingress 等）打包成一個可安裝、可升級、可回退的單位。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Helm 在 K8s 生態裡的角色類似 apt 在 Linux、npm 在 Node.js——把「安裝一個應用」從「逐一 apply 多個 YAML」變成「一條 &lt;code>helm install&lt;/code> 指令」。chart 可以參數化（values.yaml），同一份 chart 在不同環境用不同參數部署。&lt;/p>
&lt;p>公開 chart 從 Artifact Hub 下載。斷網環境裡用 &lt;code>helm pull&lt;/code> 在外部下載 chart tarball、搬進內網、從本地檔案安裝，或用 Harbor 的 OCI chart 支援當內部 chart registry。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>系統需要 Helm 的訊號是：用 K8s 部署的應用超過 3 個、每個應用由 5+ 個 K8s 資源組成、且需要在多個環境（dev/staging/prod）用不同參數部署同一套定義。如果只有 1-2 個簡單應用、直接 &lt;code>kubectl apply&lt;/code> 就好。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>使用 Helm 時要決定：chart 的粒度（一個 chart = 一個微服務 or 一整個平台）、values 的組織（per-environment values file）、chart 版本管理（chart version vs app version）、以及升級策略（&lt;code>helm upgrade --atomic&lt;/code> 失敗自動回退）。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/ecs/" data-link-title="ECS" data-link-desc="AWS Elastic Container Service — 受管的容器編排服務，用 task definition 描述容器配置、由平台負責排程與健康管理">ECS&lt;/a>：ECS 是非 K8s 的容器編排替代&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Helm 是 Kubernetes 的套件管理工具。它用 chart（一組模板檔案 + 預設值）把多個 K8s 資源（Deployment、Service、ConfigMap、Ingress 等）打包成一個可安裝、可升級、可回退的單位。</p>
<h2 id="概念位置">概念位置</h2>
<p>Helm 在 K8s 生態裡的角色類似 apt 在 Linux、npm 在 Node.js——把「安裝一個應用」從「逐一 apply 多個 YAML」變成「一條 <code>helm install</code> 指令」。chart 可以參數化（values.yaml），同一份 chart 在不同環境用不同參數部署。</p>
<p>公開 chart 從 Artifact Hub 下載。斷網環境裡用 <code>helm pull</code> 在外部下載 chart tarball、搬進內網、從本地檔案安裝，或用 Harbor 的 OCI chart 支援當內部 chart registry。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>系統需要 Helm 的訊號是：用 K8s 部署的應用超過 3 個、每個應用由 5+ 個 K8s 資源組成、且需要在多個環境（dev/staging/prod）用不同參數部署同一套定義。如果只有 1-2 個簡單應用、直接 <code>kubectl apply</code> 就好。</p>
<h2 id="設計責任">設計責任</h2>
<p>使用 Helm 時要決定：chart 的粒度（一個 chart = 一個微服務 or 一整個平台）、values 的組織（per-environment values file）、chart 版本管理（chart version vs app version）、以及升級策略（<code>helm upgrade --atomic</code> 失敗自動回退）。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/ecs/" data-link-title="ECS" data-link-desc="AWS Elastic Container Service — 受管的容器編排服務，用 task definition 描述容器配置、由平台負責排程與健康管理">ECS</a>：ECS 是非 K8s 的容器編排替代</li>
</ul>
]]></content:encoded></item><item><title>Infra 知識卡</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/</guid><description>&lt;p>Infra 知識卡收錄基礎設施領域的核心術語。每張卡自包含、可獨立閱讀，讀者可以從任何一張卡進入、透過鄰卡連結導航到相關概念。&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/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB&lt;/a>&lt;/td>
 &lt;td>Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/cidr/" data-link-title="CIDR（Classless Inter-Domain Routing）" data-link-desc="用前綴長度表示 IP 地址範圍的表示法，決定 VPC 與 subnet 的地址空間大小">CIDR&lt;/a>&lt;/td>
 &lt;td>用前綴長度表示 IP 地址範圍的表示法，決定 VPC 與 subnet 的地址空間大小&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/cloudtrail/" data-link-title="CloudTrail" data-link-desc="AWS 的 API 層稽核日誌服務，記錄誰在什麼時候對什麼資源做了什麼操作">CloudTrail&lt;/a>&lt;/td>
 &lt;td>AWS 的 API 層稽核日誌服務，記錄誰在什麼時候對什麼資源做了什麼操作&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&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>&lt;/td>
 &lt;td>IaC 的 state 與雲端實際狀態之間的不一致，通常因為繞過 IaC 直接在 Console 改設定&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/ecs/" data-link-title="ECS" data-link-desc="AWS Elastic Container Service — 受管的容器編排服務，用 task definition 描述容器配置、由平台負責排程與健康管理">ECS&lt;/a>&lt;/td>
 &lt;td>AWS 受管容器編排服務，用 task definition 描述容器配置、由平台負責排程與健康管理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM&lt;/a>&lt;/td>
 &lt;td>雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC&lt;/a>&lt;/td>
 &lt;td>用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="讓 private subnet 的資源主動對外連線、同時不被外部入站觸及的網路地址轉換服務">NAT Gateway&lt;/a>&lt;/td>
 &lt;td>讓 private subnet 的資源主動對外連線、同時不被外部入站觸及&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/oidc/" data-link-title="OIDC 聯合" data-link-desc="讓 CI/CD 平台用短期 token 取代長期 access key 存取雲端資源的身分聯合機制">OIDC 聯合&lt;/a>&lt;/td>
 &lt;td>讓 CI/CD 平台用短期 token 取代長期 access key 存取雲端資源&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group&lt;/a>&lt;/td>
 &lt;td>掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">State&lt;/a>&lt;/td>
 &lt;td>IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">Subnet&lt;/a>&lt;/td>
 &lt;td>VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有通往網際網路的路徑&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/vpc/" data-link-title="VPC（Virtual Private Cloud）" data-link-desc="雲端帳號內的一塊邏輯隔離私有網段，是所有網路切分的起點與容器">VPC&lt;/a>&lt;/td>
 &lt;td>雲端帳號內的一塊邏輯隔離私有網段，是所有網路切分的起點與容器&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/checkov/" data-link-title="checkov" data-link-desc="開源的 IaC 靜態安全掃描工具，在不建立資源的前提下比對已知的壞寫法與安全反模式">checkov&lt;/a>&lt;/td>
 &lt;td>IaC 靜態安全掃描工具，比對 HCL 裡的已知壞寫法與安全反模式&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/deletion-protection/" data-link-title="Deletion Protection" data-link-desc="雲端平台提供的防誤刪機制，開啟後刪除操作需要先顯式關閉保護才能執行">Deletion Protection&lt;/a>&lt;/td>
 &lt;td>防止誤刪 stateful 資源的平台級保護機制，開啟後刪除需先顯式關閉保護&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/fargate/" data-link-title="Fargate" data-link-desc="AWS ECS 的無伺服器執行模式，由 AWS 代管運算實例，不需要管 EC2 capacity 或 AMI 更新">Fargate&lt;/a>&lt;/td>
 &lt;td>AWS ECS 的無伺服器容器執行模式，不需管理 EC2 instance&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/remote-state-backend/" data-link-title="Remote State Backend" data-link-desc="把 Terraform state 從本地搬到團隊共享儲存的機制，同時滿足持久保存、並行鎖與敏感值保護">Remote State Backend&lt;/a>&lt;/td>
 &lt;td>團隊共享、有鎖、有加密的 state 存放機制&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/route-table/" data-link-title="Route Table" data-link-desc="掛在 subnet 上的流量轉送規則，決定封包離開 subnet 後往哪走">Route Table&lt;/a>&lt;/td>
 &lt;td>subnet 的流量轉送規則，決定封包離開 subnet 後往哪走&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/scp/" data-link-title="SCP (Service Control Policy)" data-link-desc="AWS Organizations 層級的權限天花板，套用到 OU 後連管理員都越不過">SCP&lt;/a>&lt;/td>
 &lt;td>Organizations 層級的權限天花板，連管理員都越不過&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/trust-policy/" data-link-title="Trust Policy" data-link-desc="IAM role 的信任關係設定，規定哪個身分被允許 assume 這個 role">Trust Policy&lt;/a>&lt;/td>
 &lt;td>IAM role 的信任關係設定，控制誰能 assume 這個 role&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/environment-separation/" data-link-title="環境分離" data-link-desc="把同一套基礎設施定義複製成多份隔離的執行實例，各有獨立 state 與故障半徑">環境分離&lt;/a>&lt;/td>
 &lt;td>把同一套基礎設施定義複製成多份隔離的執行實例，各有獨立 state 與故障半徑&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/phpmyadmin/" data-link-title="phpMyAdmin" data-link-desc="Web 介面的 MySQL / MariaDB 管理工具，透過瀏覽器操作資料庫">phpMyAdmin&lt;/a>&lt;/td>
 &lt;td>Web 介面的 MySQL / MariaDB 管理工具，無 SSH 環境的主要 DB 管理入口&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/filezilla/" data-link-title="FileZilla" data-link-desc="跨平台的 FTP/SFTP client，提供目錄同步瀏覽和檔案比較功能">FileZilla&lt;/a>&lt;/td>
 &lt;td>跨平台 FTP/SFTP client，提供目錄同步瀏覽和檔案比較功能&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/cpanel/" data-link-title="cPanel" data-link-desc="Web 主機管理面板，提供 PHP 版本切換、cron、email、SSL、備份等功能的圖形介面">cPanel&lt;/a>&lt;/td>
 &lt;td>Web 主機管理面板，整合 PHP 版本切換、cron、email、SSL、備份的圖形介面&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/htaccess/" data-link-title=".htaccess" data-link-desc="Apache Web Server 的目錄層級設定檔，控制 URL rewrite、存取權限、PHP 設定覆寫與安全標頭">.htaccess&lt;/a>&lt;/td>
 &lt;td>Apache 的目錄層級設定檔，控制 URL rewrite、存取權限、PHP 設定覆寫&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/dotenv/" data-link-title=".env" data-link-desc="存放環境變數的純文字檔案，把機密值從程式碼分離出來">.env&lt;/a>&lt;/td>
 &lt;td>存放環境變數的純文字檔案，把機密值從程式碼分離出來&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/php-ini/" data-link-title="php.ini / .user.ini" data-link-desc="PHP 的執行期設定檔，控制記憶體上限、上傳大小、錯誤報告等 runtime 行為">php.ini / .user.ini&lt;/a>&lt;/td>
 &lt;td>PHP 的執行期設定檔，控制記憶體上限、上傳大小、錯誤報告等 runtime 行為&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/composer/" data-link-title="Composer" data-link-desc="PHP 的套件管理工具，管理專案的第三方依賴、版本鎖定與安全掃描">Composer&lt;/a>&lt;/td>
 &lt;td>PHP 的套件管理工具，管理第三方依賴、版本鎖定與安全掃描&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/mysqldump/" data-link-title="mysqldump" data-link-desc="MySQL / MariaDB 的 CLI 備份工具，把資料庫匯出成 SQL 語句的純文字檔">mysqldump&lt;/a>&lt;/td>
 &lt;td>MySQL/MariaDB 的 CLI 備份工具，把資料庫匯出成 SQL 純文字檔&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/reverse-proxy/" data-link-title="Reverse Proxy" data-link-desc="代替後端服務接收外部請求、再轉發到內部服務的中介層，承擔 TLS 終結、負載平衡與路由分流">Reverse Proxy&lt;/a>&lt;/td>
 &lt;td>代替後端服務接收外部請求的中介層，承擔 TLS 終結、負載平衡與路由分流&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/database-migration/" data-link-title="Database Migration" data-link-desc="用版本化的 SQL 腳本管理資料庫 schema 的變更歷程，讓 schema 變更可追蹤、可重現、可回退">Database Migration&lt;/a>&lt;/td>
 &lt;td>用版本化的 SQL 腳本管理資料庫 schema 的變更歷程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/prometheus/" data-link-title="Prometheus" data-link-desc="開源的 metrics 收集與告警系統，用 pull 模式從 target 拉取指標，斷網環境的預設監控方案">Prometheus&lt;/a>&lt;/td>
 &lt;td>開源的 metrics 收集與告警系統，用 pull 模式從 target 拉取指標&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/grafana/" data-link-title="Grafana" data-link-desc="開源的監控視覺化平台，從 Prometheus / Loki / Elasticsearch 等資料源建立 dashboard">Grafana&lt;/a>&lt;/td>
 &lt;td>開源的監控視覺化平台，從 Prometheus / Loki 等資料源建立 dashboard&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/vault/" data-link-title="HashiCorp Vault" data-link-desc="機密管理系統，集中存放密碼、API key、TLS 私鑰，提供存取控制、稽核和自動輪替">HashiCorp Vault&lt;/a>&lt;/td>
 &lt;td>機密管理系統，集中存放密碼與 API key，提供存取控制與稽核&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/harbor/" data-link-title="Harbor" data-link-desc="開源的 container image registry，支援映像掃描、RBAC、複製，斷網環境取代 Docker Hub 的方案">Harbor&lt;/a>&lt;/td>
 &lt;td>開源的 container image registry，支援映像掃描、RBAC、複製&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/helm/" data-link-title="Helm" data-link-desc="Kubernetes 的套件管理工具，用 chart 打包一組 K8s 資源的部署定義">Helm&lt;/a>&lt;/td>
 &lt;td>Kubernetes 的套件管理工具，用 chart 打包一組 K8s 資源部署定義&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table></description><content:encoded><![CDATA[<p>Infra 知識卡收錄基礎設施領域的核心術語。每張卡自包含、可獨立閱讀，讀者可以從任何一張卡進入、透過鄰卡連結導航到相關概念。</p>
<p>知識卡的職責是建立術語的語意錨點。教學模組負責情境推導與操作判準，知識卡負責「這個詞是什麼、什麼時候會碰到、使用時要決定什麼」。兩者互相引用但各自完整。</p>
<h2 id="卡片清單">卡片清單</h2>
<table>
  <thead>
      <tr>
          <th>卡片</th>
          <th>說明</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB</a></td>
          <td>Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/cidr/" data-link-title="CIDR（Classless Inter-Domain Routing）" data-link-desc="用前綴長度表示 IP 地址範圍的表示法，決定 VPC 與 subnet 的地址空間大小">CIDR</a></td>
          <td>用前綴長度表示 IP 地址範圍的表示法，決定 VPC 與 subnet 的地址空間大小</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/cloudtrail/" data-link-title="CloudTrail" data-link-desc="AWS 的 API 層稽核日誌服務，記錄誰在什麼時候對什麼資源做了什麼操作">CloudTrail</a></td>
          <td>AWS 的 API 層稽核日誌服務，記錄誰在什麼時候對什麼資源做了什麼操作</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/drift/" data-link-title="Drift（設定漂移）" data-link-desc="IaC 的 state 與雲端實際狀態之間的不一致，通常因為有人繞過 IaC 直接在 Console 改設定">Drift</a></td>
          <td>IaC 的 state 與雲端實際狀態之間的不一致，通常因為繞過 IaC 直接在 Console 改設定</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/ecs/" data-link-title="ECS" data-link-desc="AWS Elastic Container Service — 受管的容器編排服務，用 task definition 描述容器配置、由平台負責排程與健康管理">ECS</a></td>
          <td>AWS 受管容器編排服務，用 task definition 描述容器配置、由平台負責排程與健康管理</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/iam/" data-link-title="IAM（Identity and Access Management）" data-link-desc="雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」">IAM</a></td>
          <td>雲端平台的授權系統，回答「某個身分能不能對某個資源做某件事」</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC</a></td>
          <td>用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="讓 private subnet 的資源主動對外連線、同時不被外部入站觸及的網路地址轉換服務">NAT Gateway</a></td>
          <td>讓 private subnet 的資源主動對外連線、同時不被外部入站觸及</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/oidc/" data-link-title="OIDC 聯合" data-link-desc="讓 CI/CD 平台用短期 token 取代長期 access key 存取雲端資源的身分聯合機制">OIDC 聯合</a></td>
          <td>讓 CI/CD 平台用短期 token 取代長期 access key 存取雲端資源</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group</a></td>
          <td>掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/state/" data-link-title="State（IaC 狀態檔）" data-link-desc="IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照，是比對差異與排定操作順序的依據">State</a></td>
          <td>IaC 工具用來記錄每個納管資源在雲端真實樣貌的快照</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">Subnet</a></td>
          <td>VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有通往網際網路的路徑</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/vpc/" data-link-title="VPC（Virtual Private Cloud）" data-link-desc="雲端帳號內的一塊邏輯隔離私有網段，是所有網路切分的起點與容器">VPC</a></td>
          <td>雲端帳號內的一塊邏輯隔離私有網段，是所有網路切分的起點與容器</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/checkov/" data-link-title="checkov" data-link-desc="開源的 IaC 靜態安全掃描工具，在不建立資源的前提下比對已知的壞寫法與安全反模式">checkov</a></td>
          <td>IaC 靜態安全掃描工具，比對 HCL 裡的已知壞寫法與安全反模式</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/deletion-protection/" data-link-title="Deletion Protection" data-link-desc="雲端平台提供的防誤刪機制，開啟後刪除操作需要先顯式關閉保護才能執行">Deletion Protection</a></td>
          <td>防止誤刪 stateful 資源的平台級保護機制，開啟後刪除需先顯式關閉保護</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/fargate/" data-link-title="Fargate" data-link-desc="AWS ECS 的無伺服器執行模式，由 AWS 代管運算實例，不需要管 EC2 capacity 或 AMI 更新">Fargate</a></td>
          <td>AWS ECS 的無伺服器容器執行模式，不需管理 EC2 instance</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/remote-state-backend/" data-link-title="Remote State Backend" data-link-desc="把 Terraform state 從本地搬到團隊共享儲存的機制，同時滿足持久保存、並行鎖與敏感值保護">Remote State Backend</a></td>
          <td>團隊共享、有鎖、有加密的 state 存放機制</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/route-table/" data-link-title="Route Table" data-link-desc="掛在 subnet 上的流量轉送規則，決定封包離開 subnet 後往哪走">Route Table</a></td>
          <td>subnet 的流量轉送規則，決定封包離開 subnet 後往哪走</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/scp/" data-link-title="SCP (Service Control Policy)" data-link-desc="AWS Organizations 層級的權限天花板，套用到 OU 後連管理員都越不過">SCP</a></td>
          <td>Organizations 層級的權限天花板，連管理員都越不過</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/trust-policy/" data-link-title="Trust Policy" data-link-desc="IAM role 的信任關係設定，規定哪個身分被允許 assume 這個 role">Trust Policy</a></td>
          <td>IAM role 的信任關係設定，控制誰能 assume 這個 role</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/environment-separation/" data-link-title="環境分離" data-link-desc="把同一套基礎設施定義複製成多份隔離的執行實例，各有獨立 state 與故障半徑">環境分離</a></td>
          <td>把同一套基礎設施定義複製成多份隔離的執行實例，各有獨立 state 與故障半徑</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/phpmyadmin/" data-link-title="phpMyAdmin" data-link-desc="Web 介面的 MySQL / MariaDB 管理工具，透過瀏覽器操作資料庫">phpMyAdmin</a></td>
          <td>Web 介面的 MySQL / MariaDB 管理工具，無 SSH 環境的主要 DB 管理入口</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/filezilla/" data-link-title="FileZilla" data-link-desc="跨平台的 FTP/SFTP client，提供目錄同步瀏覽和檔案比較功能">FileZilla</a></td>
          <td>跨平台 FTP/SFTP client，提供目錄同步瀏覽和檔案比較功能</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/cpanel/" data-link-title="cPanel" data-link-desc="Web 主機管理面板，提供 PHP 版本切換、cron、email、SSL、備份等功能的圖形介面">cPanel</a></td>
          <td>Web 主機管理面板，整合 PHP 版本切換、cron、email、SSL、備份的圖形介面</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/htaccess/" data-link-title=".htaccess" data-link-desc="Apache Web Server 的目錄層級設定檔，控制 URL rewrite、存取權限、PHP 設定覆寫與安全標頭">.htaccess</a></td>
          <td>Apache 的目錄層級設定檔，控制 URL rewrite、存取權限、PHP 設定覆寫</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/dotenv/" data-link-title=".env" data-link-desc="存放環境變數的純文字檔案，把機密值從程式碼分離出來">.env</a></td>
          <td>存放環境變數的純文字檔案，把機密值從程式碼分離出來</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/php-ini/" data-link-title="php.ini / .user.ini" data-link-desc="PHP 的執行期設定檔，控制記憶體上限、上傳大小、錯誤報告等 runtime 行為">php.ini / .user.ini</a></td>
          <td>PHP 的執行期設定檔，控制記憶體上限、上傳大小、錯誤報告等 runtime 行為</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/composer/" data-link-title="Composer" data-link-desc="PHP 的套件管理工具，管理專案的第三方依賴、版本鎖定與安全掃描">Composer</a></td>
          <td>PHP 的套件管理工具，管理第三方依賴、版本鎖定與安全掃描</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/mysqldump/" data-link-title="mysqldump" data-link-desc="MySQL / MariaDB 的 CLI 備份工具，把資料庫匯出成 SQL 語句的純文字檔">mysqldump</a></td>
          <td>MySQL/MariaDB 的 CLI 備份工具，把資料庫匯出成 SQL 純文字檔</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/reverse-proxy/" data-link-title="Reverse Proxy" data-link-desc="代替後端服務接收外部請求、再轉發到內部服務的中介層，承擔 TLS 終結、負載平衡與路由分流">Reverse Proxy</a></td>
          <td>代替後端服務接收外部請求的中介層，承擔 TLS 終結、負載平衡與路由分流</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/database-migration/" data-link-title="Database Migration" data-link-desc="用版本化的 SQL 腳本管理資料庫 schema 的變更歷程，讓 schema 變更可追蹤、可重現、可回退">Database Migration</a></td>
          <td>用版本化的 SQL 腳本管理資料庫 schema 的變更歷程</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/prometheus/" data-link-title="Prometheus" data-link-desc="開源的 metrics 收集與告警系統，用 pull 模式從 target 拉取指標，斷網環境的預設監控方案">Prometheus</a></td>
          <td>開源的 metrics 收集與告警系統，用 pull 模式從 target 拉取指標</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/grafana/" data-link-title="Grafana" data-link-desc="開源的監控視覺化平台，從 Prometheus / Loki / Elasticsearch 等資料源建立 dashboard">Grafana</a></td>
          <td>開源的監控視覺化平台，從 Prometheus / Loki 等資料源建立 dashboard</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/vault/" data-link-title="HashiCorp Vault" data-link-desc="機密管理系統，集中存放密碼、API key、TLS 私鑰，提供存取控制、稽核和自動輪替">HashiCorp Vault</a></td>
          <td>機密管理系統，集中存放密碼與 API key，提供存取控制與稽核</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/harbor/" data-link-title="Harbor" data-link-desc="開源的 container image registry，支援映像掃描、RBAC、複製，斷網環境取代 Docker Hub 的方案">Harbor</a></td>
          <td>開源的 container image registry，支援映像掃描、RBAC、複製</td>
      </tr>
      <tr>
          <td><a href="/blog/infra/knowledge-cards/helm/" data-link-title="Helm" data-link-desc="Kubernetes 的套件管理工具，用 chart 打包一組 K8s 資源的部署定義">Helm</a></td>
          <td>Kubernetes 的套件管理工具，用 chart 打包一組 K8s 資源部署定義</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>給非工程背景決策者的 infra 說明</title><link>https://tarrragon.github.io/blog/infra/09-driving-adoption/infra-explained-for-non-engineers/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/09-driving-adoption/infra-explained-for-non-engineers/</guid><description>&lt;p>工程團隊說「我們需要花時間做 infra」，對參與資源決策的人來說，這句話的翻譯常常是「花時間做一件看不到產出的事」。產品畫面不會變、使用者不會感覺到差異、營收報表上找不到對應的數字。這篇文章從管理角度說明 infra 在處理什麼營運問題、不處理的代價怎麼累積、以及出事後的補救成本為什麼比事前高。&lt;/p>
&lt;h2 id="工程團隊說的-infra-在處理什麼">工程團隊說的 infra 在處理什麼&lt;/h2>
&lt;p>infra（infrastructure，基礎設施）是讓應用程式能運作的底層資源與管理機制。工程團隊說「做 infra」時，處理的是五個營運層面的問題，每個問題都對應一種管理風險：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>問題&lt;/th>
 &lt;th>工程術語&lt;/th>
 &lt;th>管理風險&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>系統壞了能不能重建&lt;/td>
 &lt;td>IaC / state&lt;/td>
 &lt;td>核心服務中斷後的恢復時間是分鐘級還是天級&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>誰能存取什麼資源&lt;/td>
 &lt;td>IAM / 權限&lt;/td>
 &lt;td>一次憑證外洩是否等於所有資料暴露&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>測試操作會不會影響正式客戶&lt;/td>
 &lt;td>環境分離&lt;/td>
 &lt;td>工程師在測試環境犯的錯是否可能直接波及生產&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>出事後能不能查到誰改了什麼&lt;/td>
 &lt;td>變更紀錄&lt;/td>
 &lt;td>事故排查是靠系統紀錄還是靠口頭回憶&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>雲端帳單花在哪裡、能不能歸屬&lt;/td>
 &lt;td>tagging&lt;/td>
 &lt;td>成本是一筆公共支出還是可拆解到產品線&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這五件事的共同特徵是平時完全不被感知。感知到的時刻通常是出事的時刻 — 一次無法重建的當機、一次稽核要求交不出的存取紀錄、一筆無法解釋的雲端帳單。&lt;/p>
&lt;h2 id="不做的代價怎麼累積">不做的代價怎麼累積&lt;/h2>
&lt;p>infra 的投入是可見的（工程師時間），不做的代價是隱藏的。隱藏代價分散在不同科目、由不同人在不同時間點承擔，所以在任何一次預算會議上都不會以完整形態出現。把它拆開：&lt;/p>
&lt;p>&lt;strong>恢復能力的缺口&lt;/strong>。系統的建置方式如果只存在某個工程師的記憶裡，這個人不在座位的期間就是系統的恢復能力空窗。一個有環境描述檔的系統，重建是一條指令的事（分鐘級）；一個純手動建出來的系統，重建要靠逐一比對設定來還原（天級）。兩者在正常運作時看起來完全一樣，差別只在出事那一刻的恢復速度。&lt;/p>
&lt;p>&lt;strong>人員依賴的脆弱性&lt;/strong>。「只有某個人知道怎麼改」這句話翻譯成管理語言是「這個人是營運連續性的單點故障」。他離職、請長假、或單純忙不過來的時候，團隊就失去安全改動系統底層的能力。把建置方式寫成程式碼後，新人讀程式碼就能理解系統結構，交接從口頭傳承變成文件閱讀。&lt;/p>
&lt;p>&lt;strong>不可見的持續支出&lt;/strong>。沒有資源盤點與標籤的雲端帳號，會累積「沒有人記得還開著」的資源 — 測試完沒關的機器、下線服務遺留的資料庫、實驗用的儲存空間。個別金額不大，但持續計費、沒人負責、也沒人會主動去清（因為不知道關了會不會影響什麼）。多數團隊第一次盤點時會發現 10-30% 的月費花在沒有人認領的資源上。&lt;/p>
&lt;p>&lt;strong>合規準備的反覆成本&lt;/strong>。外部稽核（SOC 2、ISO 27001、客戶安全問卷）要求「列出所有對外暴露的服務」「提供權限變更紀錄」「證明生產環境的變更有經過審查」。手動環境每次回應這些要求都是一次人工考古（一到兩週的工程師時間）。有環境描述檔和變更紀錄的系統，回應同樣的問題是跑幾條查詢（幾小時）。稽核是週期性的，準備成本的差距每年都會兌現。&lt;/p>
&lt;h2 id="出事的處理與補救">出事的處理與補救&lt;/h2>
&lt;p>事前做和事後補的成本差距是非線性的。幾個具體場景：&lt;/p>
&lt;p>&lt;strong>憑證外洩&lt;/strong>。一把長期有效的存取金鑰如果外流，攻擊者能用它存取金鑰對應的所有資源。補救需要：撤銷外洩的金鑰、找出所有使用它的系統同步更換（而「所有使用的地方」在手動環境裡通常沒有完整清單）、評估外洩期間有沒有被異常存取、通知可能受影響的客戶。事前用短期自動過期的憑證取代長期金鑰，外洩的衝擊從「不定期限的完整存取權」縮到「幾分鐘後自動失效的短暫存取」。&lt;/p>
&lt;p>&lt;strong>生產環境誤操作&lt;/strong>。測試環境和生產環境沒有隔離的系統，一次操作失誤可能直接影響正式客戶。補救需要：判斷受影響範圍、修復資料、對外溝通。事前做好環境分離，測試環境的操作從物理上接觸不到生產資料。&lt;/p>
&lt;p>&lt;strong>無法重建的系統中斷&lt;/strong>。核心服務掛了，但它是手動建出來的、沒有環境描述檔。補救是逐一比對雲端管理介面上的設定，試圖還原出跟原來一樣的環境 — 但沒有人能確定「跟原來一模一樣」，因為沒有紀錄記載原來長什麼樣。恢復時間以天計，期間服務不可用。&lt;/p>
&lt;p>這些場景的共同結構是：事前投入的成本是固定的（幾週工程師時間），事後補救的成本隨影響範圍和持續時間膨脹。&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>地基級&lt;/td>
 &lt;td>出事不可逆或補救代價極高&lt;/td>
 &lt;td>憑證管理、權限管控、刪除保護&lt;/td>
 &lt;td>一次事故就可能超過全年投入&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>營運級&lt;/td>
 &lt;td>出事可恢復但反覆消耗&lt;/td>
 &lt;td>環境分離、變更紀錄、環境描述檔&lt;/td>
 &lt;td>每次事故和每次稽核都多花時間&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>優化級&lt;/td>
 &lt;td>不做也不出事，做了提高效率&lt;/td>
 &lt;td>自動化護欄、成本標籤、進階治理&lt;/td>
 &lt;td>持續的小額浪費與人工重複&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>地基級的工作延後風險最高，營運級的工作每次事故都在付利息，優化級的工作可以等到地基穩了再做。跟工程團隊確認「這次提案裡哪些是地基級」，是判斷優先級的起點。&lt;/p>
&lt;h2 id="常問的問題">常問的問題&lt;/h2>
&lt;h3 id="已經在雲端了為什麼還需要額外做">已經在雲端了，為什麼還需要額外做？&lt;/h3>
&lt;p>在雲端代表公司已經租用了運算資源，但租用資源跟管理資源是兩件事。資源的存取控制、環境隔離、變更紀錄、備份策略 — 這些都需要主動設定。很多公司「上雲」之後，資源是工程師在管理介面上一個一個手動建出來的，沒有描述檔、沒有盤點、沒有分區設計。infra 要補的正是管理層。&lt;/p>
&lt;h3 id="投入多少工程師時間">投入多少工程師時間？&lt;/h3>
&lt;p>分階段做。第一階段（1-2 週）處理地基級的三件事：憑證安全、權限收斂、有狀態資源的保護。第二階段（2-3 週）建立環境描述檔和環境分離。第三階段（持續但零星）加上自動化護欄和成本標籤。每個階段獨立交付價值，不需要一次投入全部時間。具體的階段拆法對應&lt;a href="https://tarrragon.github.io/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">成熟度階梯&lt;/a>（從全手動到全程式碼治理的五階分級）。&lt;/p>
&lt;h3 id="出事了能不能事後補">出事了能不能事後補？&lt;/h3>
&lt;p>地基級的工作事後補的代價遠高於事前。一把憑證進了版本控制歷史就永久留存，撤銷金鑰只是第一步，清除歷史和輪替所有受影響的存取是更大的工程。環境描述檔和變更紀錄的事後補救代價相對線性 — 越晚開始、需要回頭整理的資源越多，但不至於跳崖式暴漲。判斷依據是：這件事出了問題，補救成本是隨時間固定的、還是隨時間加速的？後者該現在做。&lt;/p>
&lt;h3 id="怎麼判斷工程團隊做得怎樣">怎麼判斷工程團隊做得怎樣？&lt;/h3>
&lt;p>幾個可以追蹤的指標：目前有多少比例的資源被環境描述檔管理（覆蓋率）？測試環境跟生產環境是否完全隔離？變更是否走審查流程？主要維護者如果不在，其他人能不能靠描述檔安全地做小幅修改？這些指標從「否」翻成「是」，就是 infra 投入的階段性交付。&lt;/p>
&lt;h2 id="延伸閱讀">延伸閱讀&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/infra/09-driving-adoption/infra-business-justification/" data-link-title="infra 投資的商業論證" data-link-desc="用成本、風險、速度三條論述線把 infra 投資翻譯成商業語言，附一頁簡報邏輯與常見反對意見的回應">infra 投資的商業論證&lt;/a>：成本、風險、速度三條論述線的數字化框架&lt;/li>
&lt;li>→ &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 鐵律則劃出早期團隊該優先鋪的地基">模組零：infra 是什麼&lt;/a>：工程面的責任邊界與成熟度階梯&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/infra/09-driving-adoption/trust-alignment-knowledge-sharing/" data-link-title="怎麼把 infra 推動起來 — 信任赤字、期望值對齊與知識共享" data-link-desc="技術正確不等於推得動 — infra 在商業優先級裡吃虧的結構性原因，以及用可回退切片、期望值對齊與知識分散來跨過組織關卡">怎麼把 infra 推動起來&lt;/a>：信任赤字、期望值對齊與知識共享&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>工程團隊說「我們需要花時間做 infra」，對參與資源決策的人來說，這句話的翻譯常常是「花時間做一件看不到產出的事」。產品畫面不會變、使用者不會感覺到差異、營收報表上找不到對應的數字。這篇文章從管理角度說明 infra 在處理什麼營運問題、不處理的代價怎麼累積、以及出事後的補救成本為什麼比事前高。</p>
<h2 id="工程團隊說的-infra-在處理什麼">工程團隊說的 infra 在處理什麼</h2>
<p>infra（infrastructure，基礎設施）是讓應用程式能運作的底層資源與管理機制。工程團隊說「做 infra」時，處理的是五個營運層面的問題，每個問題都對應一種管理風險：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>工程術語</th>
          <th>管理風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>系統壞了能不能重建</td>
          <td>IaC / state</td>
          <td>核心服務中斷後的恢復時間是分鐘級還是天級</td>
      </tr>
      <tr>
          <td>誰能存取什麼資源</td>
          <td>IAM / 權限</td>
          <td>一次憑證外洩是否等於所有資料暴露</td>
      </tr>
      <tr>
          <td>測試操作會不會影響正式客戶</td>
          <td>環境分離</td>
          <td>工程師在測試環境犯的錯是否可能直接波及生產</td>
      </tr>
      <tr>
          <td>出事後能不能查到誰改了什麼</td>
          <td>變更紀錄</td>
          <td>事故排查是靠系統紀錄還是靠口頭回憶</td>
      </tr>
      <tr>
          <td>雲端帳單花在哪裡、能不能歸屬</td>
          <td>tagging</td>
          <td>成本是一筆公共支出還是可拆解到產品線</td>
      </tr>
  </tbody>
</table>
<p>這五件事的共同特徵是平時完全不被感知。感知到的時刻通常是出事的時刻 — 一次無法重建的當機、一次稽核要求交不出的存取紀錄、一筆無法解釋的雲端帳單。</p>
<h2 id="不做的代價怎麼累積">不做的代價怎麼累積</h2>
<p>infra 的投入是可見的（工程師時間），不做的代價是隱藏的。隱藏代價分散在不同科目、由不同人在不同時間點承擔，所以在任何一次預算會議上都不會以完整形態出現。把它拆開：</p>
<p><strong>恢復能力的缺口</strong>。系統的建置方式如果只存在某個工程師的記憶裡，這個人不在座位的期間就是系統的恢復能力空窗。一個有環境描述檔的系統，重建是一條指令的事（分鐘級）；一個純手動建出來的系統，重建要靠逐一比對設定來還原（天級）。兩者在正常運作時看起來完全一樣，差別只在出事那一刻的恢復速度。</p>
<p><strong>人員依賴的脆弱性</strong>。「只有某個人知道怎麼改」這句話翻譯成管理語言是「這個人是營運連續性的單點故障」。他離職、請長假、或單純忙不過來的時候，團隊就失去安全改動系統底層的能力。把建置方式寫成程式碼後，新人讀程式碼就能理解系統結構，交接從口頭傳承變成文件閱讀。</p>
<p><strong>不可見的持續支出</strong>。沒有資源盤點與標籤的雲端帳號，會累積「沒有人記得還開著」的資源 — 測試完沒關的機器、下線服務遺留的資料庫、實驗用的儲存空間。個別金額不大，但持續計費、沒人負責、也沒人會主動去清（因為不知道關了會不會影響什麼）。多數團隊第一次盤點時會發現 10-30% 的月費花在沒有人認領的資源上。</p>
<p><strong>合規準備的反覆成本</strong>。外部稽核（SOC 2、ISO 27001、客戶安全問卷）要求「列出所有對外暴露的服務」「提供權限變更紀錄」「證明生產環境的變更有經過審查」。手動環境每次回應這些要求都是一次人工考古（一到兩週的工程師時間）。有環境描述檔和變更紀錄的系統，回應同樣的問題是跑幾條查詢（幾小時）。稽核是週期性的，準備成本的差距每年都會兌現。</p>
<h2 id="出事的處理與補救">出事的處理與補救</h2>
<p>事前做和事後補的成本差距是非線性的。幾個具體場景：</p>
<p><strong>憑證外洩</strong>。一把長期有效的存取金鑰如果外流，攻擊者能用它存取金鑰對應的所有資源。補救需要：撤銷外洩的金鑰、找出所有使用它的系統同步更換（而「所有使用的地方」在手動環境裡通常沒有完整清單）、評估外洩期間有沒有被異常存取、通知可能受影響的客戶。事前用短期自動過期的憑證取代長期金鑰，外洩的衝擊從「不定期限的完整存取權」縮到「幾分鐘後自動失效的短暫存取」。</p>
<p><strong>生產環境誤操作</strong>。測試環境和生產環境沒有隔離的系統，一次操作失誤可能直接影響正式客戶。補救需要：判斷受影響範圍、修復資料、對外溝通。事前做好環境分離，測試環境的操作從物理上接觸不到生產資料。</p>
<p><strong>無法重建的系統中斷</strong>。核心服務掛了，但它是手動建出來的、沒有環境描述檔。補救是逐一比對雲端管理介面上的設定，試圖還原出跟原來一樣的環境 — 但沒有人能確定「跟原來一模一樣」，因為沒有紀錄記載原來長什麼樣。恢復時間以天計，期間服務不可用。</p>
<p>這些場景的共同結構是：事前投入的成本是固定的（幾週工程師時間），事後補救的成本隨影響範圍和持續時間膨脹。</p>
<h2 id="哪些該現在做哪些可以排後面">哪些該現在做、哪些可以排後面</h2>
<p>工程團隊提出的 infra 工作可以按「事後補救成本的陡峭程度」分級：</p>
<table>
  <thead>
      <tr>
          <th>分級</th>
          <th>特徵</th>
          <th>對應的工作</th>
          <th>延後的代價</th>
      </tr>
  </thead>
  <tbody>
      <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>地基級的工作延後風險最高，營運級的工作每次事故都在付利息，優化級的工作可以等到地基穩了再做。跟工程團隊確認「這次提案裡哪些是地基級」，是判斷優先級的起點。</p>
<h2 id="常問的問題">常問的問題</h2>
<h3 id="已經在雲端了為什麼還需要額外做">已經在雲端了，為什麼還需要額外做？</h3>
<p>在雲端代表公司已經租用了運算資源，但租用資源跟管理資源是兩件事。資源的存取控制、環境隔離、變更紀錄、備份策略 — 這些都需要主動設定。很多公司「上雲」之後，資源是工程師在管理介面上一個一個手動建出來的，沒有描述檔、沒有盤點、沒有分區設計。infra 要補的正是管理層。</p>
<h3 id="投入多少工程師時間">投入多少工程師時間？</h3>
<p>分階段做。第一階段（1-2 週）處理地基級的三件事：憑證安全、權限收斂、有狀態資源的保護。第二階段（2-3 週）建立環境描述檔和環境分離。第三階段（持續但零星）加上自動化護欄和成本標籤。每個階段獨立交付價值，不需要一次投入全部時間。具體的階段拆法對應<a href="/blog/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">成熟度階梯</a>（從全手動到全程式碼治理的五階分級）。</p>
<h3 id="出事了能不能事後補">出事了能不能事後補？</h3>
<p>地基級的工作事後補的代價遠高於事前。一把憑證進了版本控制歷史就永久留存，撤銷金鑰只是第一步，清除歷史和輪替所有受影響的存取是更大的工程。環境描述檔和變更紀錄的事後補救代價相對線性 — 越晚開始、需要回頭整理的資源越多，但不至於跳崖式暴漲。判斷依據是：這件事出了問題，補救成本是隨時間固定的、還是隨時間加速的？後者該現在做。</p>
<h3 id="怎麼判斷工程團隊做得怎樣">怎麼判斷工程團隊做得怎樣？</h3>
<p>幾個可以追蹤的指標：目前有多少比例的資源被環境描述檔管理（覆蓋率）？測試環境跟生產環境是否完全隔離？變更是否走審查流程？主要維護者如果不在，其他人能不能靠描述檔安全地做小幅修改？這些指標從「否」翻成「是」，就是 infra 投入的階段性交付。</p>
<h2 id="延伸閱讀">延伸閱讀</h2>
<ul>
<li>→ <a href="/blog/infra/09-driving-adoption/infra-business-justification/" data-link-title="infra 投資的商業論證" data-link-desc="用成本、風險、速度三條論述線把 infra 投資翻譯成商業語言，附一頁簡報邏輯與常見反對意見的回應">infra 投資的商業論證</a>：成本、風險、速度三條論述線的數字化框架</li>
<li>→ <a href="/blog/infra/00-infra-mindset/infra-responsibility-maturity/" data-link-title="infra 的責任邊界、成熟度階梯與 day 1 鐵律" data-link-desc="基礎設施承擔五個面向的責任，每一面都有獨立的失效模式；成熟度階梯用來對齊現況而非追求滿分，day 1 鐵律則劃出早期團隊該優先鋪的地基">模組零：infra 是什麼</a>：工程面的責任邊界與成熟度階梯</li>
<li>→ <a href="/blog/infra/09-driving-adoption/trust-alignment-knowledge-sharing/" 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/personal-project-to-infra/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/00-infra-mindset/personal-project-to-infra/</guid><description>&lt;p>任何一次雲端部署都會用到基礎設施元件 — 網路隔離、存取控制、儲存、身分認證。即使從來沒有手動設定過這些東西，雲端平台也會用預設值替你建立它們。這篇文章把那些藏在預設值裡的 infra 元件逐一攤開，說明各自解決什麼問題，以及不管理它們時會在什麼時間點造成什麼後果。&lt;/p>
&lt;h2 id="每次部署都會觸及的四個元件">每次部署都會觸及的四個元件&lt;/h2>
&lt;p>在 AWS Console 上建立一台 EC2 instance 時，精靈流程的每一步各對應一個 infra 元件。Console 把它們包進填表流程裡，讓建立動作看起來只是「選規格 → 按確認 → 機器出現」，但每一步的選擇都在決定這台機器的網路位置、存取邊界與儲存策略。&lt;/p>
&lt;h3 id="vpc-與-subnet">VPC 與 subnet&lt;/h3>
&lt;p>Network settings 那一步，Console 預設選一個 default VPC。VPC（Virtual Private Cloud）是雲端帳號裡的一塊邏輯隔離網段 — 裡面的機器彼此可達，外部流量要經過明確的入口才進得來。subnet 是 VPC 裡再切出來的子區域，決定機器落在哪個可用區（availability zone）以及對外暴露的程度。&lt;/p>
&lt;p>default VPC 在每個 region 自動存在，它的特性是所有 subnet 都是 public（有對外路由）、security group 預設接受部分入站流量。這組預設值讓部署能快速完成，但它的隱含假設是「所有資源都可以對外」— 把資料庫放進 default VPC 時，資料庫的網路位置跟對外的 web server 在同一層，沒有隔離。&lt;/p>
&lt;h3 id="security-group">Security group&lt;/h3>
&lt;p>同一個精靈流程會出現 security group 選項。security group 是掛在機器網路介面上的防火牆規則，決定哪些來源 IP、哪些 port 的流量可以進出。&lt;/p>
&lt;p>預設建立的 security group 通常開放 SSH（port 22）給 &lt;code>0.0.0.0/0&lt;/code> — 任何 IP 都能嘗試連線。對一台短期測試機來說，這讓操作者能連進去；對一台開始承載服務的機器來說，全球的自動掃描工具會在上線幾分鐘內開始對 SSH port 嘗試登入。這條規則是功能正確的（SSH 能連），但安全邊界是開放的（誰都能試）。&lt;/p>
&lt;h3 id="iam">IAM&lt;/h3>
&lt;p>登入 Console 本身就用到了 IAM（Identity and Access Management）。IAM 管理「誰能對哪些資源做什麼操作」。首次註冊時使用的 root account 擁有帳號內所有權限，用 root 做日常操作等於每次都拿著能開所有門的萬能鑰匙。&lt;/p>
&lt;p>開發者與 IAM 的第一個交集通常是 access key — 一組靜態憑證，讓 CLI 工具或部署腳本能用程式化方式操作雲端資源。這把 key 被存進 &lt;code>~/.aws/credentials&lt;/code> 或專案的 &lt;code>.env&lt;/code> 檔後，它就是一個有權限的身分憑證，決定了持有者能動多少東西。key 沒有到期時間，權限範圍取決於它綁定的 IAM user 或 role 被授予了什麼 policy。&lt;/p>
&lt;h3 id="儲存">儲存&lt;/h3>
&lt;p>EC2 附帶的 EBS volume 是儲存層 infra。預設大小通常是 8 GB，預設沒有加密，預設沒有快照排程。磁碟裡只有 OS 跟應用程式時，壞了重建即可。一旦上面開始跑資料庫、存使用者檔案，磁碟裡就有了不可重建的狀態，「壞了重建」這個退路就消失了。&lt;/p>
&lt;h3 id="預設值的共同特性">預設值的共同特性&lt;/h3>
&lt;p>VPC、subnet、security group、IAM、EBS — 這些在每次部署時全部自動存在或被預設建立。預設值的設計目標是「讓部署能完成」，而非「讓環境安全且可管理」。兩者之間的落差會在特定時間點浮現。&lt;/p>
&lt;h2 id="不管理這些元件的後果">不管理這些元件的後果&lt;/h2>
&lt;p>infra 元件不被管理時，後果不會立刻出現 — 它們在特定條件觸發時一次浮現。以下是依觸發頻率排列的常見情境。&lt;/p>
&lt;h3 id="環境無法重建">環境無法重建&lt;/h3>
&lt;p>帳號需要遷移、機器需要在另一個 region 重建、或者某個資源損壞需要從頭來過。這時才發現：security group 開了哪些規則、RDS 的 parameter group 改了哪些值、S3 bucket 的 CORS policy 怎麼設的 — 這些設定散落在 Console 各頁面，唯一的重建方式是逐頁翻 Console 比對。&lt;/p>
&lt;p>可重建性的判準：能不能在空白帳號裡，不靠記憶、不靠翻舊帳號 Console，把環境完整重建出來。&lt;/p>
&lt;h3 id="憑證外洩">憑證外洩&lt;/h3>
&lt;p>access key 被推進 git 歷史 — &lt;code>.env&lt;/code> 檔忘記加進 &lt;code>.gitignore&lt;/code>，一次 push 就把 key 送上了公開 repo。GitHub 上有自動掃描工具在監控 commit，從 push 到 key 被利用可能只需要幾分鐘。常見的攻擊操作是在帳號裡開大量高規格 instance 跑礦機，帳單可以在幾小時內衝到數千美元。&lt;/p>
&lt;p>即使立刻撤銷 key，git 歷史裡的 key 還在 — 每個 clone 過 repo 的人都有一份副本。回退代價取決於 key 的權限範圍：如果綁的是 AdministratorAccess，攻擊者能做的事等於帳號擁有者能做的所有事。&lt;/p>
&lt;h3 id="誤刪資源">誤刪資源&lt;/h3>
&lt;p>在 Console 清理資源時刪錯一個 security group，另一台還在跑的機器引用了它 — 網路規則瞬間歸零，服務斷線。Console 沒有「刪了會影響什麼」的預覽，確認按下去就生效。&lt;/p></description><content:encoded><![CDATA[<p>任何一次雲端部署都會用到基礎設施元件 — 網路隔離、存取控制、儲存、身分認證。即使從來沒有手動設定過這些東西，雲端平台也會用預設值替你建立它們。這篇文章把那些藏在預設值裡的 infra 元件逐一攤開，說明各自解決什麼問題，以及不管理它們時會在什麼時間點造成什麼後果。</p>
<h2 id="每次部署都會觸及的四個元件">每次部署都會觸及的四個元件</h2>
<p>在 AWS Console 上建立一台 EC2 instance 時，精靈流程的每一步各對應一個 infra 元件。Console 把它們包進填表流程裡，讓建立動作看起來只是「選規格 → 按確認 → 機器出現」，但每一步的選擇都在決定這台機器的網路位置、存取邊界與儲存策略。</p>
<h3 id="vpc-與-subnet">VPC 與 subnet</h3>
<p>Network settings 那一步，Console 預設選一個 default VPC。VPC（Virtual Private Cloud）是雲端帳號裡的一塊邏輯隔離網段 — 裡面的機器彼此可達，外部流量要經過明確的入口才進得來。subnet 是 VPC 裡再切出來的子區域，決定機器落在哪個可用區（availability zone）以及對外暴露的程度。</p>
<p>default VPC 在每個 region 自動存在，它的特性是所有 subnet 都是 public（有對外路由）、security group 預設接受部分入站流量。這組預設值讓部署能快速完成，但它的隱含假設是「所有資源都可以對外」— 把資料庫放進 default VPC 時，資料庫的網路位置跟對外的 web server 在同一層，沒有隔離。</p>
<h3 id="security-group">Security group</h3>
<p>同一個精靈流程會出現 security group 選項。security group 是掛在機器網路介面上的防火牆規則，決定哪些來源 IP、哪些 port 的流量可以進出。</p>
<p>預設建立的 security group 通常開放 SSH（port 22）給 <code>0.0.0.0/0</code> — 任何 IP 都能嘗試連線。對一台短期測試機來說，這讓操作者能連進去；對一台開始承載服務的機器來說，全球的自動掃描工具會在上線幾分鐘內開始對 SSH port 嘗試登入。這條規則是功能正確的（SSH 能連），但安全邊界是開放的（誰都能試）。</p>
<h3 id="iam">IAM</h3>
<p>登入 Console 本身就用到了 IAM（Identity and Access Management）。IAM 管理「誰能對哪些資源做什麼操作」。首次註冊時使用的 root account 擁有帳號內所有權限，用 root 做日常操作等於每次都拿著能開所有門的萬能鑰匙。</p>
<p>開發者與 IAM 的第一個交集通常是 access key — 一組靜態憑證，讓 CLI 工具或部署腳本能用程式化方式操作雲端資源。這把 key 被存進 <code>~/.aws/credentials</code> 或專案的 <code>.env</code> 檔後，它就是一個有權限的身分憑證，決定了持有者能動多少東西。key 沒有到期時間，權限範圍取決於它綁定的 IAM user 或 role 被授予了什麼 policy。</p>
<h3 id="儲存">儲存</h3>
<p>EC2 附帶的 EBS volume 是儲存層 infra。預設大小通常是 8 GB，預設沒有加密，預設沒有快照排程。磁碟裡只有 OS 跟應用程式時，壞了重建即可。一旦上面開始跑資料庫、存使用者檔案，磁碟裡就有了不可重建的狀態，「壞了重建」這個退路就消失了。</p>
<h3 id="預設值的共同特性">預設值的共同特性</h3>
<p>VPC、subnet、security group、IAM、EBS — 這些在每次部署時全部自動存在或被預設建立。預設值的設計目標是「讓部署能完成」，而非「讓環境安全且可管理」。兩者之間的落差會在特定時間點浮現。</p>
<h2 id="不管理這些元件的後果">不管理這些元件的後果</h2>
<p>infra 元件不被管理時，後果不會立刻出現 — 它們在特定條件觸發時一次浮現。以下是依觸發頻率排列的常見情境。</p>
<h3 id="環境無法重建">環境無法重建</h3>
<p>帳號需要遷移、機器需要在另一個 region 重建、或者某個資源損壞需要從頭來過。這時才發現：security group 開了哪些規則、RDS 的 parameter group 改了哪些值、S3 bucket 的 CORS policy 怎麼設的 — 這些設定散落在 Console 各頁面，唯一的重建方式是逐頁翻 Console 比對。</p>
<p>可重建性的判準：能不能在空白帳號裡，不靠記憶、不靠翻舊帳號 Console，把環境完整重建出來。</p>
<h3 id="憑證外洩">憑證外洩</h3>
<p>access key 被推進 git 歷史 — <code>.env</code> 檔忘記加進 <code>.gitignore</code>，一次 push 就把 key 送上了公開 repo。GitHub 上有自動掃描工具在監控 commit，從 push 到 key 被利用可能只需要幾分鐘。常見的攻擊操作是在帳號裡開大量高規格 instance 跑礦機，帳單可以在幾小時內衝到數千美元。</p>
<p>即使立刻撤銷 key，git 歷史裡的 key 還在 — 每個 clone 過 repo 的人都有一份副本。回退代價取決於 key 的權限範圍：如果綁的是 AdministratorAccess，攻擊者能做的事等於帳號擁有者能做的所有事。</p>
<h3 id="誤刪資源">誤刪資源</h3>
<p>在 Console 清理資源時刪錯一個 security group，另一台還在跑的機器引用了它 — 網路規則瞬間歸零，服務斷線。Console 沒有「刪了會影響什麼」的預覽，確認按下去就生效。</p>
<p>資料庫的誤刪代價更大。RDS instance 被刪除時如果沒有開啟刪除保護、沒有 snapshot，資料永久消失。手動環境裡沒有自動防護，保護要靠人記得去開。</p>
<h3 id="變更不可追溯">變更不可追溯</h3>
<p>某次改了 security group 規則讓某個 API 能通，隔週另一個服務斷線。排查時發現是那條規則影響了未知的依賴，但沒有變更紀錄，「上次改了什麼」只存在改動者的記憶裡。Console 不標記規則的新增時間，要查得去 CloudTrail 翻 API 呼叫日誌。</p>
<h2 id="多人協作時的放大效應">多人協作時的放大效應</h2>
<p>一個人操作時，所有隱性知識都在自己腦裡。第二個人加入時，這套隱性知識立刻變成障礙。</p>
<p>身分管理的第一個問題是：共用 access key 還是建新的 IAM user。共用 key 代表兩人的操作在 CloudTrail 裡無法區分是誰做的；建新 user 需要決定權限範圍 — 給太寬怕誤操作，給太窄什麼都做不了。</p>
<p>變更衝突是第二個問題。Console 沒有鎖機制 — 兩人可以同時打開同一個 security group 的編輯頁面，各自修改不同規則，後存的覆蓋先存的，沒有提示。一人改了設定沒通知另一人，排查時不確定「這條規則是原本就有的還是新加的」。</p>
<p>這些問題的共同根源是環境狀態只存在於 Console 和個別人的記憶裡，沒有所有人都能讀到的、可比對差異的事實來源。Infrastructure as Code（IaC）把環境描述寫進程式碼，讓事實來源從記憶變成 repo 裡可以 diff、可以 review 的檔案 — 這是<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="依規模遞增的-infra-需求">依規模遞增的 infra 需求</h2>
<p>infra 的複雜度隨服務的使用者數量、團隊大小與合規要求遞增，但核心責任在每個規模都相同：讓環境可被理解、可被重建、可被安全地變更。</p>
<p>單人運維時，infra 的最小需求是盤點（知道環境裡有什麼）、描述（能重建）、憑證管理（access key 不外洩）。這三件事不需要 Terraform — 一份手動清單、固定命名規則、把 key 換成短期憑證，就覆蓋了最高代價的風險。做法見<a href="/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一：還沒有 infra 的環境</a>。</p>
<p>多人協作時，需要變更可追溯和最小權限。IaC 在這個階段開始產生收益，因為「從程式碼看環境」比「翻 Console」快，而且程式碼可以 review。做法見<a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一</a>。</p>
<p>服務有營收、團隊超過十人時，需要環境分離（dev 與 prod 不互相干擾）、自動化護欄（變更走 PR 流程）、可觀測性（出事時查得到）。這些能力疊加在前面兩層之上。完整的能力階梯見<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>：手動環境怎麼守底線、降低未來納管成本</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>：access key 的風險與替代方案</li>
</ul>
]]></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>