<?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>接手維運：別人建的環境怎麼接管 on Tarragon</title><link>https://tarrragon.github.io/blog/infra/takeover/</link><description>Recent content in 接手維運：別人建的環境怎麼接管 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 26 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/infra/takeover/index.xml" rel="self" type="application/rss+xml"/><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>有 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>有半套 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>無 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>程式碼版控與 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>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>無 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>State 修復與清理</title><link>https://tarrragon.github.io/blog/infra/takeover/partial-iac-state-repair/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/takeover/partial-iac-state-repair/</guid><description>&lt;p>接手一個有半套 IaC 的環境時，state 是工具對現實的唯一記憶，但這份記憶可能已經失真——有些記錄對應的雲端資源已經不存在、有些雲端資源從來沒被記錄、有些記錄的屬性跟現實對不上。在動任何資源之前，先把 state 修到一個可信的狀態，是所有後續操作的前提。&lt;/p>
&lt;h2 id="診斷-state-的健康狀態">診斷 state 的健康狀態&lt;/h2>
&lt;p>&lt;code>terraform plan&lt;/code> 的輸出是診斷 state 健康度的主要工具。在不做任何 code 變更的前提下跑 plan，輸出的每一行差異都代表 state 與現實的落差：&lt;/p>





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





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





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





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





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





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





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





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





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





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





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





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




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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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