<?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>Retrofit on Tarragon</title><link>https://tarrragon.github.io/blog/tags/retrofit/</link><description>Recent content in Retrofit on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 26 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/retrofit/index.xml" rel="self" type="application/rss+xml"/><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></channel></rss>