<?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>Php on Tarragon</title><link>https://tarrragon.github.io/blog/tags/php/</link><description>Recent content in Php on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 26 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/php/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>Runtime 版本升級</title><link>https://tarrragon.github.io/blog/infra/upgrade/runtime-version-upgrade/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/upgrade/runtime-version-upgrade/</guid><description>&lt;p>Runtime 版本升級改變的是既有程式碼的執行環境。程式碼是針對某個版本的行為寫的——函式存不存在、預設值是什麼、型別檢查嚴不嚴格——新版本可能移除函式、改變預設行為、引入更嚴格的型別系統。升級的工作量不在「切換版本」這個動作本身（多數環境只需要改一個設定），而在「讓既有程式碼在新版本下行為正確」的驗證與修正。&lt;/p>
&lt;p>本篇以 PHP 為主要範例（legacy 升級最常見的情境），Node.js 和 Python 的對應工具在各段併列。&lt;/p>
&lt;h2 id="相容性評估">相容性評估&lt;/h2>
&lt;p>升級前要先知道「現有程式碼跟新版本有多少不相容」。不相容的類型分四種：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>類型&lt;/th>
 &lt;th>範例（PHP 7→8）&lt;/th>
 &lt;th>影響&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>移除的函式&lt;/td>
 &lt;td>&lt;code>each()&lt;/code>、&lt;code>create_function()&lt;/code>、&lt;code>mysql_*&lt;/code> 系列&lt;/td>
 &lt;td>呼叫直接 fatal error&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>改變的預設行為&lt;/td>
 &lt;td>&lt;code>error_reporting&lt;/code> 預設含 &lt;code>E_DEPRECATED&lt;/code>、字串比較更嚴格&lt;/td>
 &lt;td>行為靜默改變、不一定報錯&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>更嚴格的型別&lt;/td>
 &lt;td>內部函式的參數型別檢查從警告升級為 TypeError&lt;/td>
 &lt;td>之前能跑的呼叫現在拋例外&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>擴充模組可用性&lt;/td>
 &lt;td>&lt;code>json&lt;/code> 從可選變內建、&lt;code>mcrypt&lt;/code> 已移除&lt;/td>
 &lt;td>部分功能無法使用&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="php-相容性掃描">PHP 相容性掃描&lt;/h3>
&lt;p>PHPCompatibility 是 PHP_CodeSniffer 的規則集，可以自動掃描程式碼裡哪些寫法在目標版本不相容：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 安裝&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">composer global require phpcompatibility/php-compatibility
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 掃描：目標版本 8.0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">phpcs --standard&lt;span class="o">=&lt;/span>PHPCompatibility &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --runtime-set testVersion 8.0 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --extensions&lt;span class="o">=&lt;/span>php &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> -p &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> src/&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>掃描結果會列出每一處不相容的位置、原因和嚴重度。常見的命中包括：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">FILE: src/legacy/Database.php
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">----------------------------------------------------------------------
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">FOUND 3 ERRORS:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> 42 | ERROR | Function mysql_connect() is removed since PHP 7.0
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> 89 | ERROR | Function each() is removed since PHP 8.0
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">156 | ERROR | Curly brace access syntax is deprecated since PHP 7.4
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">----------------------------------------------------------------------&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>php -l&lt;/code> 可以做基本的語法檢查，但它只抓語法錯誤、抓不到 deprecated 函式和行為變更。PHPCompatibility 掃描的覆蓋面更廣。&lt;/p>
&lt;h3 id="php-升級的高頻修改項">PHP 升級的高頻修改項&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>項目&lt;/th>
 &lt;th>PHP 5.6→7.x&lt;/th>
 &lt;th>PHP 7.x→8.x&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>資料庫連線&lt;/td>
 &lt;td>&lt;code>mysql_*&lt;/code> → &lt;code>mysqli_*&lt;/code> 或 PDO&lt;/td>
 &lt;td>—&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>陣列遍歷&lt;/td>
 &lt;td>—&lt;/td>
 &lt;td>&lt;code>each()&lt;/code> → &lt;code>foreach&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>字串存取&lt;/td>
 &lt;td>—&lt;/td>
 &lt;td>&lt;code>$str{0}&lt;/code> → &lt;code>$str[0]&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>錯誤處理&lt;/td>
 &lt;td>&lt;code>set_error_handler&lt;/code> 行為變更&lt;/td>
 &lt;td>內部函式 TypeError 取代 warning&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>建構函式&lt;/td>
 &lt;td>同名建構函式 deprecated&lt;/td>
 &lt;td>同名建構函式 removed&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>正則表達式&lt;/td>
 &lt;td>&lt;code>ereg_*&lt;/code> → &lt;code>preg_*&lt;/code>&lt;/td>
 &lt;td>—&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>加密&lt;/td>
 &lt;td>&lt;code>mcrypt_*&lt;/code> → &lt;code>openssl_*&lt;/code> 或 sodium&lt;/td>
 &lt;td>—&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="nodejs-相容性掃描">Node.js 相容性掃描&lt;/h3>





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





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># Python 2→3：用 2to3 掃描&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">2to3 --no-diffs -w src/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># Python 3.x 小版本：用 pyupgrade&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">pip install pyupgrade
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">pyupgrade --py310-plus src/**/*.py&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Python 2→3 的修改量通常很大（print 語法、unicode 處理、dict 方法），是接近重寫等級的升級。Python 3.x 之間的升級相對溫和，主要是 deprecation 移除和 typing 語法的演進。&lt;/p></description><content:encoded><![CDATA[<p>Runtime 版本升級改變的是既有程式碼的執行環境。程式碼是針對某個版本的行為寫的——函式存不存在、預設值是什麼、型別檢查嚴不嚴格——新版本可能移除函式、改變預設行為、引入更嚴格的型別系統。升級的工作量不在「切換版本」這個動作本身（多數環境只需要改一個設定），而在「讓既有程式碼在新版本下行為正確」的驗證與修正。</p>
<p>本篇以 PHP 為主要範例（legacy 升級最常見的情境），Node.js 和 Python 的對應工具在各段併列。</p>
<h2 id="相容性評估">相容性評估</h2>
<p>升級前要先知道「現有程式碼跟新版本有多少不相容」。不相容的類型分四種：</p>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>範例（PHP 7→8）</th>
          <th>影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>移除的函式</td>
          <td><code>each()</code>、<code>create_function()</code>、<code>mysql_*</code> 系列</td>
          <td>呼叫直接 fatal error</td>
      </tr>
      <tr>
          <td>改變的預設行為</td>
          <td><code>error_reporting</code> 預設含 <code>E_DEPRECATED</code>、字串比較更嚴格</td>
          <td>行為靜默改變、不一定報錯</td>
      </tr>
      <tr>
          <td>更嚴格的型別</td>
          <td>內部函式的參數型別檢查從警告升級為 TypeError</td>
          <td>之前能跑的呼叫現在拋例外</td>
      </tr>
      <tr>
          <td>擴充模組可用性</td>
          <td><code>json</code> 從可選變內建、<code>mcrypt</code> 已移除</td>
          <td>部分功能無法使用</td>
      </tr>
  </tbody>
</table>
<h3 id="php-相容性掃描">PHP 相容性掃描</h3>
<p>PHPCompatibility 是 PHP_CodeSniffer 的規則集，可以自動掃描程式碼裡哪些寫法在目標版本不相容：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 安裝</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">composer global require phpcompatibility/php-compatibility
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 掃描：目標版本 8.0</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">phpcs --standard<span class="o">=</span>PHPCompatibility <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --runtime-set testVersion 8.0 <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --extensions<span class="o">=</span>php <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  -p <span class="se">\
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="se"></span>  src/</span></span></code></pre></div><p>掃描結果會列出每一處不相容的位置、原因和嚴重度。常見的命中包括：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">FILE: src/legacy/Database.php
</span></span><span class="line"><span class="ln">2</span><span class="cl">----------------------------------------------------------------------
</span></span><span class="line"><span class="ln">3</span><span class="cl">FOUND 3 ERRORS:
</span></span><span class="line"><span class="ln">4</span><span class="cl"> 42 | ERROR | Function mysql_connect() is removed since PHP 7.0
</span></span><span class="line"><span class="ln">5</span><span class="cl"> 89 | ERROR | Function each() is removed since PHP 8.0
</span></span><span class="line"><span class="ln">6</span><span class="cl">156 | ERROR | Curly brace access syntax is deprecated since PHP 7.4
</span></span><span class="line"><span class="ln">7</span><span class="cl">----------------------------------------------------------------------</span></span></code></pre></div><p><code>php -l</code> 可以做基本的語法檢查，但它只抓語法錯誤、抓不到 deprecated 函式和行為變更。PHPCompatibility 掃描的覆蓋面更廣。</p>
<h3 id="php-升級的高頻修改項">PHP 升級的高頻修改項</h3>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>PHP 5.6→7.x</th>
          <th>PHP 7.x→8.x</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>資料庫連線</td>
          <td><code>mysql_*</code> → <code>mysqli_*</code> 或 PDO</td>
          <td>—</td>
      </tr>
      <tr>
          <td>陣列遍歷</td>
          <td>—</td>
          <td><code>each()</code> → <code>foreach</code></td>
      </tr>
      <tr>
          <td>字串存取</td>
          <td>—</td>
          <td><code>$str{0}</code> → <code>$str[0]</code></td>
      </tr>
      <tr>
          <td>錯誤處理</td>
          <td><code>set_error_handler</code> 行為變更</td>
          <td>內部函式 TypeError 取代 warning</td>
      </tr>
      <tr>
          <td>建構函式</td>
          <td>同名建構函式 deprecated</td>
          <td>同名建構函式 removed</td>
      </tr>
      <tr>
          <td>正則表達式</td>
          <td><code>ereg_*</code> → <code>preg_*</code></td>
          <td>—</td>
      </tr>
      <tr>
          <td>加密</td>
          <td><code>mcrypt_*</code> → <code>openssl_*</code> 或 sodium</td>
          <td>—</td>
      </tr>
  </tbody>
</table>
<h3 id="nodejs-相容性掃描">Node.js 相容性掃描</h3>





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Composer：檢查哪些套件需要更新</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">composer outdated
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 檢查各套件是否支援目標 PHP 版本</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">composer why-not php 8.2</span></span></code></pre></div><p><code>composer why-not</code> 會列出哪些套件的 <code>require.php</code> 限制不允許目標版本。這些套件要先升級到支援新版本的版號，才能升 PHP。</p>
<p>如果某個套件已經不再維護且不支援新 PHP 版本，要評估替代方案或 fork 修改。這個評估的工作量可能佔整個升級的大部分時間。</p>
<h2 id="分批部署策略">分批部署策略</h2>
<h3 id="有獨立環境控制的情境vps--雲端">有獨立環境控制的情境（VPS / 雲端）</h3>
<p>最安全的策略是建一套平行環境跑新版本：</p>
<ol>
<li>用新 PHP 版本建一台新的 VM 或容器</li>
<li>部署相同的程式碼</li>
<li>匯入 prod 資料庫的副本</li>
<li>在新環境跑完整驗證</li>
<li>DNS 或 load balancer 切換流量到新環境</li>
<li>舊環境保留一段時間作為 rollback 目標</li>
</ol>
<p>rollback 是把流量切回舊環境。舊環境在確認新環境穩定之前不要關——保留期至少一週。</p>
<h3 id="面板管理主機無-ssh的情境">面板管理主機（無 SSH）的情境</h3>
<p>面板管理主機（cPanel / Plesk）的 PHP 版本切換通常是 per-domain 的設定：</p>
<ul>
<li><strong>cPanel</strong>：MultiPHP Manager，選域名 → 選 PHP 版本 → Apply</li>
<li><strong>Plesk</strong>：PHP Settings → PHP version 下拉選單</li>
</ul>
<p>切換是即時生效的，rollback 也是即時的（選回舊版本）。但沒有「平行環境驗證」的能力——除非主機商提供 staging subdomain 可以先測。</p>
<p>面板管理主機的升級策略：</p>
<ol>
<li>如果有 staging subdomain：先在 staging 切換版本、驗證、再切 prod</li>
<li>如果沒有：選流量最低的時段切換（如凌晨），切換後立刻驗證關鍵流程，出問題立刻切回</li>
<li>切換前備份（FTP mirror + DB dump），確認 rollback 路徑存在</li>
</ol>
<h3 id="wordpress--框架的版本矩陣">WordPress / 框架的版本矩陣</h3>
<p>WordPress 和主流框架有明確的 PHP 版本支援矩陣。升級 PHP 前要先確認框架版本是否支援目標 PHP 版本：</p>
<table>
  <thead>
      <tr>
          <th>框架</th>
          <th>查詢方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>WordPress</td>
          <td><a href="https://wordpress.org/about/requirements/">官方需求頁</a></td>
      </tr>
      <tr>
          <td>Laravel</td>
          <td>各版本 <code>composer.json</code> 的 <code>require.php</code></td>
      </tr>
      <tr>
          <td>Symfony</td>
          <td><a href="https://symfony.com/releases">Release and support</a> 頁面</td>
      </tr>
  </tbody>
</table>
<p>如果框架不支援目標 PHP 版本，要先升級框架。框架升級和 PHP 升級不要同時做——先升框架、驗證穩定、再升 PHP，每一步都有獨立的 rollback 點。</p>
<h2 id="常見的升級陷阱">常見的升級陷阱</h2>
<h3 id="session-序列化格式">Session 序列化格式</h3>
<p>PHP 的 session 序列化格式在某些版本之間有變更。版本切換後舊 session 檔案可能無法反序列化，使用者會被強制登出。處理方式：</p>
<ul>
<li>在維護窗口切換版本（使用者預期重新登入）</li>
<li>或在切換前清除所有 session 檔案</li>
</ul>
<h3 id="opcache-快取">opcache 快取</h3>
<p>PHP 的 opcache 會快取編譯後的 bytecode。版本切換後如果 opcache 沒清，可能用舊版本編譯的 bytecode 跑在新版本上。切換後的第一件事：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># CLI 方式清除（如果有 SSH）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">php -r <span class="s2">&#34;opcache_reset();&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 或重啟 PHP-FPM / Apache</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">systemctl restart php8.2-fpm</span></span></code></pre></div><h3 id="composer-的-php-版本鎖定">Composer 的 PHP 版本鎖定</h3>
<p><code>composer.lock</code> 裡的套件版本是根據當時的 PHP 版本解析的。PHP 版本變了之後，要重新 <code>composer update</code> 讓 Composer 用新版本重新解析依賴。但 <code>composer update</code> 可能升級其他套件——較安全的做法是 <code>composer update --lock</code> 只更新 lock file 的 metadata、不升級套件版本。</p>
<h3 id="隱性的行為變更">隱性的行為變更</h3>
<p>PHP 8.0 起，字串跟數字的比較規則改了（<code>0 == &quot;foo&quot;</code> 從 <code>true</code> 變 <code>false</code>）。這類變更不會報錯、不會拋例外，程式碼照跑但行為不同。靜態分析抓不到，只有業務邏輯測試能覆蓋。</p>
<p>如果沒有測試套件，至少在切換後的一週內密切監控錯誤日誌和業務指標（訂單數、登入數、API 錯誤率），用業務指標的異常作為行為變更的偵測手段。</p>
<h2 id="時程與管理層溝通">時程與管理層溝通</h2>
<table>
  <thead>
      <tr>
          <th>升級類型</th>
          <th>典型時程</th>
          <th>主要成本來源</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>PHP 小版本（8.0→8.2）</td>
          <td>2-5 天</td>
          <td>依賴更新 + 測試</td>
      </tr>
      <tr>
          <td>PHP 跨大版本（7.4→8.x）</td>
          <td>1-2 週</td>
          <td>函式替換 + 行為驗證</td>
      </tr>
      <tr>
          <td>PHP 跳代（5.6→8.x）</td>
          <td>4-8 週</td>
          <td>大量程式碼修改 + 框架升級</td>
      </tr>
      <tr>
          <td>Node.js 大版本</td>
          <td>3-5 天</td>
          <td>原生模組重編 + API 變更</td>
      </tr>
      <tr>
          <td>Python 2→3</td>
          <td>8-16 週</td>
          <td>接近重寫等級</td>
      </tr>
  </tbody>
</table>
<p>向管理層溝通時要說明：「升級 runtime 版本不只是在伺服器改一個設定。程式碼裡用到的函式和行為在新版本有不同的定義，需要逐一修改和驗證。時程取決於程式碼用了多少舊版本的專屬功能。」</p>
<p>成本參考：PHP 版本升級本身的工具和環境不花錢（PHPCompatibility 開源、Docker 免費、cPanel 版本切換內建）。成本全在工程師時間。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/upgrade/upgrade-framework/" data-link-title="升級的共通操作框架" data-link-desc="任何環境或系統升級的四階段模型：差異評估、平行環境驗證、分批切換、退役舊環境，以及貫穿全程的升級紀律">升級的共通操作框架</a>：四階段模型（評估 → 平行環境 → 切換 → 退役）</li>
<li>→ <a href="/blog/infra/takeover/legacy-php-security-audit/" data-link-title="Legacy PHP 的安全盤點" data-link-desc="接手 legacy PHP 專案後的系統性安全審查：credential 掃描、PHP 版本風險、常見漏洞模式的 grep 偵測、.htaccess 防線、檔案權限、外部依賴與掃描工具">Legacy PHP 的安全盤點</a>：PHP 版本風險評估與漏洞掃描</li>
<li>→ <a href="/blog/infra/takeover/legacy-code-versioning-deployment/" data-link-title="程式碼版控與 FTP 部署紀律" data-link-desc="無 SSH 環境的 PHP 專案的程式碼怎麼從 FTP 拉回來建 Git repo、設定檔怎麼分離、FTP 部署怎麼建立可追蹤的流程、以及怎麼用 CI 取代手動上傳">程式碼版控與 FTP 部署紀律</a>：升級前的 Git 基準線與 rollback 策略</li>
</ul>
]]></content:encoded></item><item><title>無 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>Composer</title><link>https://tarrragon.github.io/blog/infra/knowledge-cards/composer/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/knowledge-cards/composer/</guid><description>&lt;p>Composer 是 PHP 的套件管理工具，角色等同於 Node.js 的 npm、Python 的 pip、Go 的 go mod。它負責宣告專案需要哪些第三方套件、鎖定每個套件的確切版本、以及把套件安裝到專案目錄裡。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>接手 PHP 專案時，Composer 是判斷「專案依賴了什麼、版本有沒有已知漏洞」的入口。專案根目錄通常有三個 Composer 相關的檔案：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>檔案&lt;/th>
 &lt;th>角色&lt;/th>
 &lt;th>進 Git？&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>composer.json&lt;/code>&lt;/td>
 &lt;td>宣告依賴（套件名稱 + 版本範圍）&lt;/td>
 &lt;td>是&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>composer.lock&lt;/code>&lt;/td>
 &lt;td>鎖定確切版本（含所有 transitive 依賴）&lt;/td>
 &lt;td>是&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>vendor/&lt;/code>&lt;/td>
 &lt;td>安裝的套件目錄&lt;/td>
 &lt;td>否（.gitignore 排除、由 &lt;code>composer install&lt;/code> 重建）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>接手專案時如果根目錄有 &lt;code>composer.json&lt;/code> 但沒有 &lt;code>vendor/&lt;/code>，代表需要先跑 &lt;code>composer install&lt;/code> 才能讓專案運作。如果連 &lt;code>composer.lock&lt;/code> 都沒有，代表套件版本沒有鎖定——每次安裝可能拿到不同版本。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>兩個常用指令的差別：&lt;/p>
&lt;ul>
&lt;li>&lt;code>composer install&lt;/code>：按 &lt;code>composer.lock&lt;/code> 安裝確切版本。用於部署和接手——確保每台機器安裝的版本一致。&lt;/li>
&lt;li>&lt;code>composer update&lt;/code>：重新解析 &lt;code>composer.json&lt;/code> 的版本範圍、更新到最新的符合版本、改寫 &lt;code>composer.lock&lt;/code>。用於主動升級依賴。&lt;/li>
&lt;/ul>
&lt;p>接手時的關鍵操作：&lt;/p>
&lt;ul>
&lt;li>&lt;code>composer audit&lt;/code>：掃描已安裝套件的已知安全漏洞&lt;/li>
&lt;li>&lt;code>composer outdated&lt;/code>：列出可更新的套件及其最新版本&lt;/li>
&lt;/ul>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/dotenv/" data-link-title=".env" data-link-desc="存放環境變數的純文字檔案，把機密值從程式碼分離出來">.env&lt;/a>：Composer 管套件、.env 管設定值，兩者都是 PHP 專案的基礎設施&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/infra/knowledge-cards/php-ini/" data-link-title="php.ini / .user.ini" data-link-desc="PHP 的執行期設定檔，控制記憶體上限、上傳大小、錯誤報告等 runtime 行為">php.ini / .user.ini&lt;/a>：Composer 需要 PHP CLI 執行，php.ini 的 memory_limit 和 max_execution_time 會影響 Composer 能不能跑完&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Composer 是 PHP 的套件管理工具，角色等同於 Node.js 的 npm、Python 的 pip、Go 的 go mod。它負責宣告專案需要哪些第三方套件、鎖定每個套件的確切版本、以及把套件安裝到專案目錄裡。</p>
<h2 id="概念位置">概念位置</h2>
<p>接手 PHP 專案時，Composer 是判斷「專案依賴了什麼、版本有沒有已知漏洞」的入口。專案根目錄通常有三個 Composer 相關的檔案：</p>
<table>
  <thead>
      <tr>
          <th>檔案</th>
          <th>角色</th>
          <th>進 Git？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>composer.json</code></td>
          <td>宣告依賴（套件名稱 + 版本範圍）</td>
          <td>是</td>
      </tr>
      <tr>
          <td><code>composer.lock</code></td>
          <td>鎖定確切版本（含所有 transitive 依賴）</td>
          <td>是</td>
      </tr>
      <tr>
          <td><code>vendor/</code></td>
          <td>安裝的套件目錄</td>
          <td>否（.gitignore 排除、由 <code>composer install</code> 重建）</td>
      </tr>
  </tbody>
</table>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>接手專案時如果根目錄有 <code>composer.json</code> 但沒有 <code>vendor/</code>，代表需要先跑 <code>composer install</code> 才能讓專案運作。如果連 <code>composer.lock</code> 都沒有，代表套件版本沒有鎖定——每次安裝可能拿到不同版本。</p>
<h2 id="設計責任">設計責任</h2>
<p>兩個常用指令的差別：</p>
<ul>
<li><code>composer install</code>：按 <code>composer.lock</code> 安裝確切版本。用於部署和接手——確保每台機器安裝的版本一致。</li>
<li><code>composer update</code>：重新解析 <code>composer.json</code> 的版本範圍、更新到最新的符合版本、改寫 <code>composer.lock</code>。用於主動升級依賴。</li>
</ul>
<p>接手時的關鍵操作：</p>
<ul>
<li><code>composer audit</code>：掃描已安裝套件的已知安全漏洞</li>
<li><code>composer outdated</code>：列出可更新的套件及其最新版本</li>
</ul>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/infra/knowledge-cards/dotenv/" data-link-title=".env" data-link-desc="存放環境變數的純文字檔案，把機密值從程式碼分離出來">.env</a>：Composer 管套件、.env 管設定值，兩者都是 PHP 專案的基礎設施</li>
<li><a href="/blog/infra/knowledge-cards/php-ini/" data-link-title="php.ini / .user.ini" data-link-desc="PHP 的執行期設定檔，控制記憶體上限、上傳大小、錯誤報告等 runtime 行為">php.ini / .user.ini</a>：Composer 需要 PHP CLI 執行，php.ini 的 memory_limit 和 max_execution_time 會影響 Composer 能不能跑完</li>
</ul>
]]></content:encoded></item><item><title>Laravel Sanctum 的 Bearer Token 設計剖析：{PK}|{secret} 為什麼這樣設計</title><link>https://tarrragon.github.io/blog/work-log/laravel-sanctum-%E7%9A%84-bearer-token-%E8%A8%AD%E8%A8%88%E5%89%96%E6%9E%90pksecret-%E7%82%BA%E4%BB%80%E9%BA%BC%E9%80%99%E6%A8%A3%E8%A8%AD%E8%A8%88/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/laravel-sanctum-%E7%9A%84-bearer-token-%E8%A8%AD%E8%A8%88%E5%89%96%E6%9E%90pksecret-%E7%82%BA%E4%BB%80%E9%BA%BC%E9%80%99%E6%A8%A3%E8%A8%AD%E8%A8%88/</guid><description>&lt;h2 id="sanctum-pat-這篇要解決什麼">Sanctum PAT 這篇要解決什麼&lt;/h2>
&lt;p>Sanctum PAT 的核心設計是把「找 row」與「比對 secret」拆成兩個責任。Laravel Sanctum 的 Personal Access Token（簡稱 PAT）長這樣：&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">1|abc123def456ghi789jkl012mno345pqr678stu
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">↑ ↑
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">DB 主鍵 真正的祕密&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>豎線前的數字是 &lt;code>personal_access_tokens&lt;/code> 資料表的 primary key、豎線後是高熵隨機字串。這個設計在 Laravel 生態裡很常見、但常被誤解為「業界標準 token 格式」 — 實際上是 Sanctum 特定的設計選擇、跟 GitHub PAT（&lt;code>ghp_...&lt;/code>）、Stripe API Key（&lt;code>sk_live_...&lt;/code>）的設計取捨完全不同。&lt;/p>
&lt;p>本文拆解 Sanctum PAT 三個關鍵設計決策：&lt;/p>
&lt;ol>
&lt;li>為什麼把 PK 公開放進 token&lt;/li>
&lt;li>DB 為什麼只存 hash 不存原文&lt;/li>
&lt;li>constant-time 比對為什麼放在應用層、不放在 DB&lt;/li>
&lt;/ol>
&lt;p>讀完後，你可以用 token 的傳播範圍、撤銷需求與洩漏偵測需求，判斷自己的 application 適合 Sanctum 風格還是其他 token format，並把 hash 儲存與 constant-time 比對原則套用到非 Laravel 環境。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>本文位置&lt;/strong>：本文是 &lt;a href="https://tarrragon.github.io/blog/work-log/api-%E8%AA%8D%E8%AD%89%E7%9A%84%E4%B8%89%E5%B1%A4%E4%BF%A1%E4%BB%BB%E9%82%8A%E7%95%8C%E4%BD%BF%E7%94%A8%E8%80%85%E7%B3%BB%E7%B5%B1%E8%B7%A8%E7%B3%BB%E7%B5%B1-provisioning/" data-link-title="API 認證的三層信任邊界：使用者、系統、跨系統 Provisioning" data-link-desc="API 認證的信任邊界分層（Bearer Token / Shared Secret / Provisioning）：各層的洩漏後果與撤銷方式，以及混用造成的設計失效模式。">API 認證的三層信任邊界&lt;/a> Layer 1 的深入篇。主文聚焦「為什麼要分層」的心智模型、本文聚焦「Sanctum 這個特定實作怎麼設計、為什麼」。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="sanctum-在-laravel-認證生態的位置">Sanctum 在 Laravel 認證生態的位置&lt;/h2>
&lt;p>Laravel 官方提供三套認證套件、各自解的問題不同：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>套件&lt;/th>
 &lt;th>解的問題&lt;/th>
 &lt;th>Token 機制&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>Laravel Breeze&lt;/strong>&lt;/td>
 &lt;td>server-rendered 應用的登入註冊 starter&lt;/td>
 &lt;td>session cookie&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Laravel Sanctum&lt;/strong>&lt;/td>
 &lt;td>SPA / mobile app / 簡單 API token 認證&lt;/td>
 &lt;td>session cookie + PAT（&lt;code>{PK}|{secret}&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Laravel Passport&lt;/strong>&lt;/td>
 &lt;td>完整 OAuth 2.0 server 實作&lt;/td>
 &lt;td>JWT-based access token&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Sanctum 的設計目標是「&lt;strong>比 Passport 簡單、比手刻 token 嚴謹&lt;/strong>」 — 不引入 OAuth 的完整 flow，但解決 token issue、storage、revoke 的常見坑。&lt;code>{PK}|{secret}&lt;/code> 是這個設計目標下的具體 trade-off。&lt;/p>
&lt;hr>
&lt;h2 id="設計決策一為什麼把-pk-公開放進-token">設計決策一：為什麼把 PK 公開放進 token&lt;/h2>
&lt;h3 id="驗證-token-的兩個責任">驗證 token 的兩個責任&lt;/h3>
&lt;p>Server 收到 client 傳來的 token、要做兩件事：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>找到&lt;/strong> DB 裡對應的 row（這個 token 是哪個 user 的）&lt;/li>
&lt;li>&lt;strong>比對&lt;/strong> 確認 token 沒被偽造&lt;/li>
&lt;/ol>
&lt;p>如果 token 只是純隨機字串（沒有 PK 前綴），validation 的 SQL 常會被設計成：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">personal_access_tokens&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">?&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這要求 &lt;code>token&lt;/code> 欄位有 index，且 server 要讓 DB 同時負責 lookup 與 secret 比對。效能通常不是瓶頸，真正的設計問題是 secret 比對落在應用層控制範圍之外。&lt;/p>
&lt;h3 id="db-比對的-timing-不可控">DB 比對的 timing 不可控&lt;/h3>
&lt;p>DB 查詢適合處理索引搜尋，不適合承擔機密字串的 timing-safe 比對。當 &lt;code>WHERE token = ?&lt;/code> 在 DB 執行時，執行時間可能洩漏：&lt;/p></description><content:encoded><![CDATA[<h2 id="sanctum-pat-這篇要解決什麼">Sanctum PAT 這篇要解決什麼</h2>
<p>Sanctum PAT 的核心設計是把「找 row」與「比對 secret」拆成兩個責任。Laravel Sanctum 的 Personal Access Token（簡稱 PAT）長這樣：</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|abc123def456ghi789jkl012mno345pqr678stu
</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">DB 主鍵     真正的祕密</span></span></code></pre></div><p>豎線前的數字是 <code>personal_access_tokens</code> 資料表的 primary key、豎線後是高熵隨機字串。這個設計在 Laravel 生態裡很常見、但常被誤解為「業界標準 token 格式」 — 實際上是 Sanctum 特定的設計選擇、跟 GitHub PAT（<code>ghp_...</code>）、Stripe API Key（<code>sk_live_...</code>）的設計取捨完全不同。</p>
<p>本文拆解 Sanctum PAT 三個關鍵設計決策：</p>
<ol>
<li>為什麼把 PK 公開放進 token</li>
<li>DB 為什麼只存 hash 不存原文</li>
<li>constant-time 比對為什麼放在應用層、不放在 DB</li>
</ol>
<p>讀完後，你可以用 token 的傳播範圍、撤銷需求與洩漏偵測需求，判斷自己的 application 適合 Sanctum 風格還是其他 token format，並把 hash 儲存與 constant-time 比對原則套用到非 Laravel 環境。</p>
<blockquote>
<p><strong>本文位置</strong>：本文是 <a href="/blog/work-log/api-%E8%AA%8D%E8%AD%89%E7%9A%84%E4%B8%89%E5%B1%A4%E4%BF%A1%E4%BB%BB%E9%82%8A%E7%95%8C%E4%BD%BF%E7%94%A8%E8%80%85%E7%B3%BB%E7%B5%B1%E8%B7%A8%E7%B3%BB%E7%B5%B1-provisioning/" data-link-title="API 認證的三層信任邊界：使用者、系統、跨系統 Provisioning" data-link-desc="API 認證的信任邊界分層（Bearer Token / Shared Secret / Provisioning）：各層的洩漏後果與撤銷方式，以及混用造成的設計失效模式。">API 認證的三層信任邊界</a> Layer 1 的深入篇。主文聚焦「為什麼要分層」的心智模型、本文聚焦「Sanctum 這個特定實作怎麼設計、為什麼」。</p></blockquote>
<hr>
<h2 id="sanctum-在-laravel-認證生態的位置">Sanctum 在 Laravel 認證生態的位置</h2>
<p>Laravel 官方提供三套認證套件、各自解的問題不同：</p>
<table>
  <thead>
      <tr>
          <th>套件</th>
          <th>解的問題</th>
          <th>Token 機制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Laravel Breeze</strong></td>
          <td>server-rendered 應用的登入註冊 starter</td>
          <td>session cookie</td>
      </tr>
      <tr>
          <td><strong>Laravel Sanctum</strong></td>
          <td>SPA / mobile app / 簡單 API token 認證</td>
          <td>session cookie + PAT（<code>{PK}|{secret}</code>）</td>
      </tr>
      <tr>
          <td><strong>Laravel Passport</strong></td>
          <td>完整 OAuth 2.0 server 實作</td>
          <td>JWT-based access token</td>
      </tr>
  </tbody>
</table>
<p>Sanctum 的設計目標是「<strong>比 Passport 簡單、比手刻 token 嚴謹</strong>」 — 不引入 OAuth 的完整 flow，但解決 token issue、storage、revoke 的常見坑。<code>{PK}|{secret}</code> 是這個設計目標下的具體 trade-off。</p>
<hr>
<h2 id="設計決策一為什麼把-pk-公開放進-token">設計決策一：為什麼把 PK 公開放進 token</h2>
<h3 id="驗證-token-的兩個責任">驗證 token 的兩個責任</h3>
<p>Server 收到 client 傳來的 token、要做兩件事：</p>
<ol>
<li><strong>找到</strong> DB 裡對應的 row（這個 token 是哪個 user 的）</li>
<li><strong>比對</strong> 確認 token 沒被偽造</li>
</ol>
<p>如果 token 只是純隨機字串（沒有 PK 前綴），validation 的 SQL 常會被設計成：</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">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">personal_access_tokens</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">token</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">?</span></span></span></code></pre></div><p>這要求 <code>token</code> 欄位有 index，且 server 要讓 DB 同時負責 lookup 與 secret 比對。效能通常不是瓶頸，真正的設計問題是 secret 比對落在應用層控制範圍之外。</p>
<h3 id="db-比對的-timing-不可控">DB 比對的 timing 不可控</h3>
<p>DB 查詢適合處理索引搜尋，不適合承擔機密字串的 timing-safe 比對。當 <code>WHERE token = ?</code> 在 DB 執行時，執行時間可能洩漏：</p>
<ul>
<li>B-tree index 的查找路徑長度（同 prefix 的 row 多時、走的 page 不同）</li>
<li>字串比對的短路行為（多數 DB 引擎不保證 constant-time 比對）</li>
<li>Buffer pool hit / miss 造成的時間差</li>
</ul>
<p>攻擊者透過大量探測，可能推斷出有效 token 的部分結構。雖然實務上利用這個 leak 攻擊成本很高，但更穩健的設計原則是：安全機制應放在 application 能明確控制的比對函式，而不是依賴 DB 引擎的實作細節。</p>
<h3 id="sanctum-的解法用-pk-收斂搜尋把比對搬到應用層">Sanctum 的解法：用 PK 收斂搜尋、把比對搬到應用層</h3>
<p><code>{PK}|{secret}</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">client 傳來: &#34;1|abc123...&#34;
</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">   server 拆解
</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">   │ PK = 1       │ ──→ SELECT * FROM tokens WHERE id = 1
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">   │ secret = abc │      （O(log N)、行為穩定）
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">   └──────────────┘
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">       ↓
</span></span><span class="line"><span class="ln">10</span><span class="cl">   拿到該 row 的 hash
</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">   hash_equals(stored_hash, sha256(secret))
</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">   constant-time 比對、不洩漏 timing</span></span></code></pre></div><p>關鍵在於 <strong>DB 只負責「找到單一 row」、不負責「比對機密」</strong>：</p>
<table>
  <thead>
      <tr>
          <th>動作</th>
          <th>由誰處理</th>
          <th>為什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>用 PK 找到 row</td>
          <td>DB（O(log N)）</td>
          <td>PK 是公開資訊、即使 timing 洩漏也沒安全意義</td>
      </tr>
      <tr>
          <td>比對 secret hash 是否相等</td>
          <td>應用層 constant-time</td>
          <td>在控制範圍內、可保證不依輸入內容變化執行時間</td>
      </tr>
  </tbody>
</table>
<h3 id="常見誤解pk-讓查詢變-o1">常見誤解：「PK 讓查詢變 O(1)」</h3>
<p>PK 前綴的主要價值是安全責任切分，不是把查詢從慢變快。很多 Sanctum 教學文章寫「PK 把查詢變 O(1)、避免 full scan」，這個說法忽略了 hash 欄位也能被索引：</p>
<ul>
<li><strong>hash 欄位也能 index</strong> — <code>WHERE token_hash = ?</code> 用 B-tree index 是 O(log N)、不是 full scan</li>
<li><strong>兩條路都是 B-tree index lookup</strong> — token 規模下都不會是效能瓶頸；clustered（PK）跟 secondary（hash）的 IO cost 微差在多數場景可忽略</li>
</ul>
<p>PK 設計的<strong>主要價值在安全可預測性</strong>、效能差距在多數場景可忽略：把比對機密的責任明確劃在「應用層 constant-time 函式」、不依賴 DB 引擎不保證的 timing 行為。</p>
<p>效能差異反而出現在「<strong>hash 欄位是否要 index</strong>」 — 如果用 hash lookup、<code>token_hash</code> 欄位需要 unique index、寫入成本變高；用 PK lookup、<code>token_hash</code> 不需要 index、寫入更輕量。但這在 token 規模通常不是 bottleneck。</p>
<hr>
<h2 id="設計決策二db-只存-hash-的威脅模型">設計決策二：DB 只存 hash 的威脅模型</h2>
<h3 id="威脅模型db-被攻陷">威脅模型：DB 被攻陷</h3>
<p>Token 是 capability credential — 持有即授權。如果 DB 直接存 plaintext token、任何能讀取 DB 的人（SQL injection、備份外流、運維 dump 不小心 push 到 GitHub）都能直接拿 token 假冒使用者發 request。</p>
<p>Sanctum 的做法：</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">// 發放 token
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nv">$plaintext</span> <span class="o">=</span> <span class="nx">Str</span><span class="o">::</span><span class="na">random</span><span class="p">(</span><span class="mi">40</span><span class="p">);</span>  <span class="c1">// Sanctum 預設 40 char、base62 字元集
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="nv">$hash</span> <span class="o">=</span> <span class="nx">hash</span><span class="p">(</span><span class="s1">&#39;sha256&#39;</span><span class="p">,</span> <span class="nv">$plaintext</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">DB</span><span class="o">::</span><span class="na">table</span><span class="p">(</span><span class="s1">&#39;personal_access_tokens&#39;</span><span class="p">)</span><span class="o">-&gt;</span><span class="na">insert</span><span class="p">([</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="s1">&#39;token&#39;</span> <span class="o">=&gt;</span> <span class="nv">$hash</span><span class="p">,</span>           <span class="c1">// DB 只存 hash
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>    <span class="s1">&#39;tokenable_id&#39;</span> <span class="o">=&gt;</span> <span class="nv">$userId</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">]);</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="k">return</span> <span class="nv">$tokenId</span> <span class="o">.</span> <span class="s1">&#39;|&#39;</span> <span class="o">.</span> <span class="nv">$plaintext</span><span class="p">;</span>  <span class="c1">// 只此一次回給 client、之後再也拿不到
</span></span></span></code></pre></div><p>意義：<strong>DB 被 dump 時，攻擊者拿到的是不可直接使用的 hash</strong>。攻擊者要還原 <code>plaintext</code> 需要對 SHA-256 做 preimage attack；對 40 字元高熵隨機字串而言，計算成本實務上不可行。</p>
<h3 id="sha-256-與-bcrypt-的適用差異">SHA-256 與 bcrypt 的適用差異</h3>
<p>密碼儲存用 bcrypt / Argon2 是因為<strong>密碼通常熵低</strong>（人類記得住的東西、entropy 通常 &lt; 40 bit）、要刻意慢、抵抗 offline brute-force。</p>
<p>Token 是<strong>高熵隨機字串</strong>（40 char base62 ≈ 238 bit entropy、比一般人類記得住的 password 高約 6 個數量級的熵）— 攻擊者就算拿到 hash、暴力枚舉 plaintext 的搜尋空間是 <code>62^40 ≈ 10^71</code>、宇宙年齡內試不完。在這個前提下：</p>
<table>
  <thead>
      <tr>
          <th>演算法</th>
          <th>處理時間（每次驗證）</th>
          <th>對 token 是否合理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SHA-256</td>
          <td>~微秒</td>
          <td>完全足夠</td>
      </tr>
      <tr>
          <td>bcrypt（cost=12）</td>
          <td>~250ms</td>
          <td>浪費 CPU、無增益</td>
      </tr>
  </tbody>
</table>
<p>在高熵 token 的前提下，SHA-256 的速度是優點，因為每次 API request 都需要驗證 token。bcrypt 的慢速設計主要服務低熵 password，套到高熵 token 會增加延遲而沒有對應的安全收益。</p>
<h3 id="salt-的適用邊界">Salt 的適用邊界</h3>
<p>bcrypt 用 salt 是為了防 <strong>rainbow table 攻擊</strong>（預算好常見密碼的 hash、查表）。Rainbow table 對「人類選的密碼」有效、對「40 char 高熵 token」無效（搜尋空間太大、預算表的成本超過直接 brute-force）。</p>
<p>所以 Sanctum 對 token 用 unsalted SHA-256，是符合「高熵隨機 token」威脅模型的選擇。若 credential 來源改成人類可記憶密碼，威脅模型就會改變，儲存策略也要回到 password hashing。</p>
<hr>
<h2 id="設計決策三constant-time-比對放在應用層">設計決策三：constant-time 比對放在應用層</h2>
<h3 id="constant-time-比對在解什麼">Constant-time 比對在解什麼</h3>
<p><code>==</code> 或 <code>strcmp</code> 比對字串時、會「<strong>短路</strong>」 — 一發現不同就回傳 false：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 偽程式碼：strcmp 的典型實作
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">for</span> <span class="p">(</span><span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">len</span><span class="p">;</span> <span class="n">i</span><span class="o">++</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">if</span> <span class="p">(</span><span class="n">a</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">!=</span> <span class="n">b</span><span class="p">[</span><span class="n">i</span><span class="p">])</span> <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>  <span class="c1">// ← 在這裡 return、不跑完
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">return</span> <span class="nb">true</span><span class="p">;</span></span></span></code></pre></div><p>攻擊者可量測「server 從收到 request 到回 401」的時間、推斷「前幾個 byte 是對的」：</p>
<table>
  <thead>
      <tr>
          <th>嘗試的 token</th>
          <th>跑了幾個 byte 才 return</th>
          <th>server 回應時間</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>aaaaaaaa...</code></td>
          <td>1（第 1 byte 就錯）</td>
          <td>~1 μs</td>
      </tr>
      <tr>
          <td><code>1aaaaaaa...</code></td>
          <td>2（第 2 byte 才錯）</td>
          <td>~2 μs</td>
      </tr>
      <tr>
          <td><code>1a aaaaa...</code></td>
          <td>3</td>
          <td>~3 μs</td>
      </tr>
  </tbody>
</table>
<p>實務上單次 request 的網路抖動遠大於這幾 μs、但攻擊者可重複幾百萬次取平均、把雜訊濾掉、最終推出整個 hash。這就是 <strong>timing attack</strong>。</p>
<h3 id="constant-time-函式的實作策略">Constant-time 函式的實作策略</h3>
<p>Constant-time 比對的核心是「<strong>不論輸入長什麼樣、都跑完整個比對長度</strong>」：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 偽程式碼：constant-time 比對
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="n">result</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">for</span> <span class="p">(</span><span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">len</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">result</span> <span class="o">|=</span> <span class="n">a</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">^</span> <span class="n">b</span><span class="p">[</span><span class="n">i</span><span class="p">];</span>  <span class="c1">// 用 XOR 累積差異、不 return
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">return</span> <span class="n">result</span> <span class="o">==</span> <span class="mi">0</span><span class="p">;</span></span></span></code></pre></div><p>每次呼叫都跑完整個 loop、結果用 bitwise OR 累積、最後一次性比對。執行時間不依輸入內容變化。</p>
<h3 id="各語言的-constant-time-比對函式">各語言的 constant-time 比對函式</h3>
<table>
  <thead>
      <tr>
          <th>語言</th>
          <th>函式</th>
          <th>注意事項</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>PHP</strong></td>
          <td><code>hash_equals($known, $user_input)</code></td>
          <td>第一個參數要是 known、第二個是 user input</td>
      </tr>
      <tr>
          <td><strong>Python</strong></td>
          <td><code>hmac.compare_digest(a, b)</code></td>
          <td>也可用 <code>secrets.compare_digest</code></td>
      </tr>
      <tr>
          <td><strong>Go</strong></td>
          <td><code>subtle.ConstantTimeCompare(a, b)</code></td>
          <td>回傳 int (0 / 1)、不是 bool</td>
      </tr>
      <tr>
          <td><strong>Ruby</strong></td>
          <td><code>ActiveSupport::SecurityUtils.secure_compare(a, b)</code></td>
          <td>Rails；純 Ruby 用 <code>OpenSSL.fixed_length_secure_compare</code></td>
      </tr>
      <tr>
          <td><strong>Java</strong></td>
          <td><code>MessageDigest.isEqual(a, b)</code></td>
          <td>Java 6+ 保證 constant-time</td>
      </tr>
      <tr>
          <td><strong>Node.js</strong></td>
          <td><code>crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))</code></td>
          <td>兩個 Buffer 長度必須相同、否則 throw</td>
      </tr>
  </tbody>
</table>
<p><strong>失效模式</strong>：用 <code>==</code>、<code>===</code>、<code>strcmp</code>、<code>String.equals</code> 比對 hash，會讓執行時間受到第一個不同 byte 的位置影響。判讀訊號是驗證邏輯直接使用語言的一般字串相等運算；下一步路由是改用標準庫或框架提供的 constant-time 函式。</p>
<h3 id="為什麼不放在-db-層">為什麼不放在 DB 層</h3>
<p>DB 引擎大多不保證 constant-time 比對。MySQL、PostgreSQL 的字串比對為了效能，底層仍可能走短路邏輯；因此「<code>WHERE hash = ?</code>」即使加 index，也不適合被當成 timing-safe 的安全邊界。</p>
<p>Sanctum 的設計把 secret 比對完全搬到應用層用 <code>hash_equals</code> — DB 只負責「用 PK 找到單一 row」、應用層負責「比對 hash」。職責清楚、安全可預測。</p>
<hr>
<h2 id="sanctum-vs-github-pat-vs-stripe-api-key">Sanctum vs GitHub PAT vs Stripe API Key</h2>
<p>三者都是 opaque token（隨機字串、server lookup）、但 format 設計取捨完全不同：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Sanctum <code>{PK}|{secret}</code></th>
          <th>GitHub <code>ghp_xxx</code></th>
          <th>Stripe <code>sk_live_xxx</code></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>找到 row 的方式</strong></td>
          <td>用 PK lookup</td>
          <td>用 hash lookup</td>
          <td>用 hash lookup</td>
      </tr>
      <tr>
          <td><strong>格式可辨識性</strong></td>
          <td>低（看起來像一般字串）</td>
          <td>高（<code>ghp_</code> 前綴）</td>
          <td>高（<code>sk_live_</code> / <code>sk_test_</code> 前綴）</td>
      </tr>
      <tr>
          <td><strong>洩漏掃描</strong></td>
          <td>困難</td>
          <td>容易（GitHub 自己 scan 公開 repo）</td>
          <td>容易（Stripe webhook scan）</td>
      </tr>
      <tr>
          <td><strong>Token type 辨識</strong></td>
          <td>需查 DB</td>
          <td>從前綴直接知道（user / app / OAuth）</td>
          <td>從前綴直接知道（live / test、public / secret）</td>
      </tr>
      <tr>
          <td><strong>適合場景</strong></td>
          <td>單一 Laravel app 內部使用</td>
          <td>對外開放、需要洩漏偵測</td>
          <td>對外開放、多環境（live / test）</td>
      </tr>
  </tbody>
</table>
<h3 id="各自的設計動機">各自的設計動機</h3>
<p><strong>Sanctum</strong>：使用情境是「單一 Laravel application 自己發、自己驗」。Token 不會散落在公開 repo（除非開發者犯錯）、洩漏偵測不是首要需求。把 PK 直接放進 token、換 timing 安全與設計簡潔。</p>
<p><strong>GitHub PAT</strong>：使用情境是「使用者把 token 寫進 CI config、push 到 public repo」。GitHub 把 <code>ghp_</code> 前綴標準化、自家服務（Push Protection、Secret Scanning）會主動 scan 公開 repo、發現 <code>ghp_...</code> pattern 就通知 user 並 revoke。Token 的可辨識性是<strong>洩漏偵測 infrastructure 的一環</strong>、不是浪費字元。</p>
<p><strong>Stripe API Key</strong>：使用情境跨 live 跟 test 環境、且有 public / secret 兩種 key。前綴設計：</p>
<ul>
<li><code>sk_live_</code> — secret key、live 環境（會收真錢）</li>
<li><code>sk_test_</code> — secret key、test 環境</li>
<li><code>pk_live_</code> — publishable key、live 環境（可放 client）</li>
<li><code>pk_test_</code> — publishable key、test 環境</li>
</ul>
<p>工程師看一眼就知道「這把 key 能幹嘛」、避免把 live key 寫進 test config。</p>
<h3 id="怎麼選">怎麼選</h3>
<table>
  <thead>
      <tr>
          <th>你的場景</th>
          <th>建議設計</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單一 Laravel app、token 只內部用</td>
          <td>Sanctum 預設即可</td>
      </tr>
      <tr>
          <td>對外開放 API、token 會散落第三方環境</td>
          <td>學 GitHub / Stripe 加 prefix</td>
      </tr>
      <tr>
          <td>多環境（dev / staging / prod）容易誤用</td>
          <td>加環境 prefix（如 <code>_live_</code>）</td>
      </tr>
      <tr>
          <td>多 token type（user / bot / OAuth）</td>
          <td>加 type prefix</td>
      </tr>
  </tbody>
</table>
<p>表格的判準是 token 會不會離開受控環境。單一 Laravel app 內部使用時，Sanctum 的 PK 前綴足以支撐 lookup 與撤銷；對外 API、第三方整合或多環境部署時，prefix 可提供洩漏掃描與人工辨識訊號。也可以混用成 <code>{prefix}|{PK}|{secret}</code>，同時保留 lookup 收斂與語意辨識。</p>
<hr>
<h2 id="在非-laravel-環境怎麼套用">在非 Laravel 環境怎麼套用</h2>
<p>Sanctum 的三個原則跨語言通用：</p>
<ol>
<li><strong>DB 只存 hash</strong> — 用任何語言的 SHA-256 / SHA-512 即可。Python: <code>hashlib.sha256</code>、Go: <code>crypto/sha256</code>、Node: <code>crypto.createHash('sha256')</code></li>
<li><strong>Lookup 用穩定字段</strong> — 把「找到 row」跟「比對機密」分開、<code>WHERE id = ?</code> 是穩定的、<code>WHERE hash = ?</code> 在 timing 上不可控</li>
<li><strong>應用層 constant-time 比對</strong> — 用本文上面表格列的函式、絕不用 <code>==</code></li>
</ol>
<p>非 Laravel 框架的等效實作：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Python + SQLAlchemy 範例</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">import</span> <span class="nn">secrets</span><span class="o">,</span> <span class="nn">hashlib</span><span class="o">,</span> <span class="nn">hmac</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="k">def</span> <span class="nf">issue_token</span><span class="p">(</span><span class="n">user_id</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">plaintext</span> <span class="o">=</span> <span class="n">secrets</span><span class="o">.</span><span class="n">token_urlsafe</span><span class="p">(</span><span class="mi">32</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="n">hash_value</span> <span class="o">=</span> <span class="n">hashlib</span><span class="o">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">plaintext</span><span class="o">.</span><span class="n">encode</span><span class="p">())</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="n">token</span> <span class="o">=</span> <span class="n">PersonalAccessToken</span><span class="p">(</span><span class="n">user_id</span><span class="o">=</span><span class="n">user_id</span><span class="p">,</span> <span class="nb">hash</span><span class="o">=</span><span class="n">hash_value</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">db</span><span class="o">.</span><span class="n">session</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">token</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">db</span><span class="o">.</span><span class="n">session</span><span class="o">.</span><span class="n">commit</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">token</span><span class="o">.</span><span class="n">id</span><span class="si">}</span><span class="s2">|</span><span class="si">{</span><span class="n">plaintext</span><span class="si">}</span><span class="s2">&#34;</span>  <span class="c1"># 只此一次回給 client</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="k">def</span> <span class="nf">verify_token</span><span class="p">(</span><span class="n">raw_token</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="c1"># production 範例需多一層 try-except 涵蓋 int() 轉型與 DB 例外</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="n">token_id</span><span class="p">,</span> <span class="n">plaintext</span> <span class="o">=</span> <span class="n">raw_token</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s1">&#39;|&#39;</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="n">token</span> <span class="o">=</span> <span class="n">PersonalAccessToken</span><span class="o">.</span><span class="n">query</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="nb">int</span><span class="p">(</span><span class="n">token_id</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">except</span> <span class="p">(</span><span class="ne">ValueError</span><span class="p">,</span> <span class="ne">TypeError</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">token</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="n">expected_hash</span> <span class="o">=</span> <span class="n">hashlib</span><span class="o">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">plaintext</span><span class="o">.</span><span class="n">encode</span><span class="p">())</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">token</span><span class="o">.</span><span class="n">hash</span><span class="p">,</span> <span class="n">expected_hash</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">return</span> <span class="n">token</span><span class="o">.</span><span class="n">user</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// Go + sqlx 範例</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">IssueToken</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">userID</span> <span class="kt">int64</span><span class="p">)</span> <span class="p">(</span><span class="kt">string</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">plaintext</span> <span class="o">:=</span> <span class="nf">generateRandomString</span><span class="p">(</span><span class="mi">40</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">hash</span> <span class="o">:=</span> <span class="nx">sha256</span><span class="p">.</span><span class="nf">Sum256</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="nx">plaintext</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="kd">var</span> <span class="nx">tokenID</span> <span class="kt">int64</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">err</span> <span class="o">:=</span> <span class="nx">db</span><span class="p">.</span><span class="nf">QueryRowContext</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="s">&#34;INSERT INTO personal_access_tokens (user_id, hash) VALUES ($1, $2) RETURNING id&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">userID</span><span class="p">,</span> <span class="nx">hex</span><span class="p">.</span><span class="nf">EncodeToString</span><span class="p">(</span><span class="nx">hash</span><span class="p">[:]),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">).</span><span class="nf">Scan</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">tokenID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;&#34;</span><span class="p">,</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Sprintf</span><span class="p">(</span><span class="s">&#34;%d|%s&#34;</span><span class="p">,</span> <span class="nx">tokenID</span><span class="p">,</span> <span class="nx">plaintext</span><span class="p">),</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="kd">func</span> <span class="nf">VerifyToken</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">raw</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="nx">Token</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nx">parts</span> <span class="o">:=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">SplitN</span><span class="p">(</span><span class="nx">raw</span><span class="p">,</span> <span class="s">&#34;|&#34;</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">parts</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">2</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">ErrInvalidFormat</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">tokenID</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">strconv</span><span class="p">.</span><span class="nf">ParseInt</span><span class="p">(</span><span class="nx">parts</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">64</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">ErrInvalidFormat</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="kd">var</span> <span class="nx">token</span> <span class="nx">Token</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="nx">err</span> <span class="p">=</span> <span class="nx">db</span><span class="p">.</span><span class="nf">GetContext</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">token</span><span class="p">,</span> <span class="s">&#34;SELECT * FROM personal_access_tokens WHERE id = $1&#34;</span><span class="p">,</span> <span class="nx">tokenID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="nx">expectedHash</span> <span class="o">:=</span> <span class="nx">sha256</span><span class="p">.</span><span class="nf">Sum256</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="nx">parts</span><span class="p">[</span><span class="mi">1</span><span class="p">]))</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="nx">storedHash</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">hex</span><span class="p">.</span><span class="nf">DecodeString</span><span class="p">(</span><span class="nx">token</span><span class="p">.</span><span class="nx">Hash</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="k">if</span> <span class="nx">subtle</span><span class="p">.</span><span class="nf">ConstantTimeCompare</span><span class="p">(</span><span class="nx">storedHash</span><span class="p">,</span> <span class="nx">expectedHash</span><span class="p">[:])</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">ErrInvalidToken</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">token</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>兩者的關鍵都是：<code>SELECT WHERE id = ?</code> + 應用層 <code>compare_digest</code> / <code>ConstantTimeCompare</code>、不依賴 DB 比對 hash。</p>
<hr>
<h2 id="收尾">收尾</h2>
<p>Sanctum 的 <code>{PK}|{secret}</code> 是一個<strong>特定情境下的設計取捨</strong>，不是業界通用標準：</p>
<ul>
<li>它假設 token 不會散落到公開環境、所以不需要 prefix-based 洩漏偵測</li>
<li>它把比對機密的責任明確劃在應用層、不依賴 DB 引擎的 timing 行為</li>
<li>它用 SHA-256 + 不加 salt、因為 token 高熵時這個選擇符合威脅模型</li>
</ul>
<p>如果你的場景符合這些假設，Sanctum 的設計可以直接使用。若場景是對外 API、需要洩漏偵測、多環境或多 token type，prefix-based format 會提供更好的操作訊號；儲存原則（hash + constant-time）則跨設計通用。</p>
<p>延伸閱讀：</p>
<ul>
<li><a href="/blog/work-log/api-%E8%AA%8D%E8%AD%89%E7%9A%84%E4%B8%89%E5%B1%A4%E4%BF%A1%E4%BB%BB%E9%82%8A%E7%95%8C%E4%BD%BF%E7%94%A8%E8%80%85%E7%B3%BB%E7%B5%B1%E8%B7%A8%E7%B3%BB%E7%B5%B1-provisioning/" data-link-title="API 認證的三層信任邊界：使用者、系統、跨系統 Provisioning" data-link-desc="API 認證的信任邊界分層（Bearer Token / Shared Secret / Provisioning）：各層的洩漏後果與撤銷方式，以及混用造成的設計失效模式。">API 認證的三層信任邊界</a> — 本文的主篇、Sanctum 在「Layer 1 使用者層」的位置</li>
<li><a href="/blog/work-log/shared-secret-%E5%AE%89%E5%85%A8%E8%BC%AA%E6%9B%BF%E8%A8%AD%E8%A8%88%E9%9B%99%E5%AF%86%E9%81%8E%E6%B8%A1%E6%9C%9F%E8%87%AA%E5%8B%95%E5%8C%96%E8%88%87%E7%B7%8A%E6%80%A5%E6%B5%81%E7%A8%8B/" data-link-title="Shared Secret 安全輪替設計：雙密過渡期、自動化與緊急流程" data-link-desc="系統間 Shared Secret 輪替的核心機制：dual-secret rollover、自動化工具比較（AWS Secrets Manager / Vault / GCP）、緊急 rotation 流程與多 client 環境的失敗模式。">Shared Secret 安全輪替設計</a> — Layer 2 系統間 secret 的輪替議題</li>
<li><a href="/blog/work-log/mtls-%E5%AF%A6%E9%9A%9B%E6%80%8E%E9%BA%BC%E8%A8%AD%E5%AE%9A%E8%88%87%E9%81%8B%E7%B6%ADca-%E9%9A%8E%E5%B1%A4%E6%86%91%E8%AD%89%E7%94%9F%E5%91%BD%E9%80%B1%E6%9C%9F%E6%92%A4%E9%8A%B7%E6%A9%9F%E5%88%B6/" data-link-title="mTLS 實際怎麼設定與運維：CA 階層、憑證生命週期、撤銷機制" data-link-desc="mTLS 落地的運維決策（CA 階層、憑證儲存、撤銷機制）與基礎設施整合（nginx / envoy / service mesh），以及跟 API Key / OAuth 的成本與安全取捨。">mTLS 實際怎麼設定與運維</a> — Layer 2 進階方案的部署細節</li>
</ul>
]]></content:encoded></item></channel></rss>