<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>環境與系統升級：帶電施工的遷移操作 on Tarragon</title><link>https://tarrragon.github.io/blog/infra/upgrade/</link><description>Recent content in 環境與系統升級：帶電施工的遷移操作 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 26 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/infra/upgrade/index.xml" rel="self" type="application/rss+xml"/><item><title>升級的共通操作框架</title><link>https://tarrragon.github.io/blog/infra/upgrade/upgrade-framework/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/upgrade/upgrade-framework/</guid><description>&lt;p>環境與系統升級的核心約束是系統在升級過程中要持續服務客戶。這個約束排除了「關機 → 換版本 → 開機」的簡單路徑，取而代之的操作模式是四個階段：評估新舊版本的差異、在旁邊建一個新環境驗證、把流量分批切過去、確認沒問題後退役舊環境。這四個階段不管升級的對象是 runtime 版本、資料庫引擎、作業系統還是整個平台，框架相同，差異落在每個階段的具體操作與風險點。&lt;/p>
&lt;h2 id="phase-1差異評估">Phase 1：差異評估&lt;/h2>
&lt;p>差異評估的產出是一份 change manifest——列出所有已知的新舊差異、每項的風險等級、以及需要的應對措施。這份清單是後續所有階段的依據：平行環境要驗證清單上的每一項、切換策略要先處理高風險項、退役前要確認清單上的所有相容性問題都已解決。&lt;/p>
&lt;h3 id="差異的三個維度">差異的三個維度&lt;/h3>
&lt;p>第一個維度是目標本身的變化。版本升級要看 changelog、breaking changes list、deprecated features list。平台遷移要看兩個平台的功能差異（共享主機沒有的 cron 彈性、VPS 有的 SSH 存取）。資料庫升級要看 SQL 語法差異、預設行為變更（如 MySQL 8.0 的 &lt;code>caching_sha2_password&lt;/code> 預設認證方式）。&lt;/p>
&lt;p>第二個維度是依賴關係。升級 PHP 版本時，所有 Composer 套件都可能受影響；升級 MySQL 時，ORM 的 SQL 生成可能不相容；遷移平台時，原本靠主機面板設定的 cron job 要改用系統 crontab 或雲端排程。依賴關係沒列完整，平行環境的測試就會漏掉受影響的元件。&lt;/p>
&lt;p>第三個維度是過渡期的雙版本相容性。升級不是瞬間完成的——在切換的過程中，系統的某些部分跑新版本、某些部分跑舊版本。這段期間兩個版本必須能共存：資料庫的 schema 要同時相容新舊版本的程式碼、API 的回應格式要讓新舊版本的客戶端都能處理、session 格式要能跨版本延續。&lt;/p>
&lt;h3 id="風險分級">風險分級&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>風險等級&lt;/th>
 &lt;th>定義&lt;/th>
 &lt;th>應對方式&lt;/th>
 &lt;th>範例&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>低&lt;/td>
 &lt;td>向後相容、不需改 code&lt;/td>
 &lt;td>平行環境驗證即可&lt;/td>
 &lt;td>PHP 8.x 的效能改善&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>中&lt;/td>
 &lt;td>需要改 code 但改動明確&lt;/td>
 &lt;td>先改 code、確認新舊版本都能跑&lt;/td>
 &lt;td>deprecated function 替換&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>高&lt;/td>
 &lt;td>行為變更、可能影響商業邏輯&lt;/td>
 &lt;td>需要完整的功能測試 + 人工驗證&lt;/td>
 &lt;td>浮點數精度變更、排序預設值變更&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>阻塞&lt;/td>
 &lt;td>無法在新版本運作、沒有替代方案&lt;/td>
 &lt;td>必須在升級前解決或決定放棄升級&lt;/td>
 &lt;td>依賴的套件不支援新版本&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每一項差異分級後，高風險和阻塞項決定升級的可行性與時程。阻塞項超過團隊能處理的量時，升級可能需要拆成多個階段（先升到中間版本、再升到目標版本）或延後。&lt;/p>
&lt;h3 id="時程與管理層報告">時程與管理層報告&lt;/h3>
&lt;p>差異評估的時程通常佔整個升級的 20-30%——看起來「還沒開始做」但這段時間的產出（change manifest）決定了後面所有階段的範圍。向管理層報告時用 change manifest 的風險分級表：「共 N 項差異，其中 X 項低風險、Y 項中風險、Z 項高風險、W 項阻塞。中高風險項的處理估計 M 天，阻塞項的替代方案評估需要額外 K 天。」&lt;/p>
&lt;h2 id="phase-2平行環境驗證">Phase 2：平行環境驗證&lt;/h2>
&lt;p>平行環境驗證的責任是用事實證明「新版本在跟 production 相同的條件下能正常運作」。它的產出是一份驗證報告——每一項 change manifest 上的差異都標上「已驗證通過 / 有問題待修 / 不影響」。沒有這份報告就切換，等於在賭新版本會正常。&lt;/p>
&lt;h3 id="建立平行環境">建立平行環境&lt;/h3>
&lt;p>平行環境跟 production 越相似，驗證結果越可信。理想狀態是完全複製 production 的架構（同規格、同設定、同網路拓撲），只差目標元件的版本不同。成本限制下的折衷是用縮小版（較小的 instance、較少的資料量），但關鍵設定（PHP 模組、MySQL 參數、安全設定）必須跟 production 一致。&lt;/p>
&lt;p>資料的處理要特別注意。用 production 的資料副本驗證最可靠（能觸發真實的邊界狀況），但如果資料含 PII，需要先脫敏處理。另一個選項是用 staging 環境的資料，但要確認 staging 的 schema 跟 production 一致——schema drift 會讓驗證結果失真。&lt;/p>
&lt;h3 id="驗證清單">驗證清單&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>驗證項目&lt;/th>
 &lt;th>方法&lt;/th>
 &lt;th>通過標準&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>應用程式啟動&lt;/td>
 &lt;td>部署到新環境、觀察 log&lt;/td>
 &lt;td>無 fatal error、所有服務啟動成功&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>自動化測試&lt;/td>
 &lt;td>跑完整測試套件&lt;/td>
 &lt;td>通過率跟舊環境一致&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>關鍵業務流程&lt;/td>
 &lt;td>人工操作核心流程（登入、下單、金流）&lt;/td>
 &lt;td>每個步驟的結果正確&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>效能比對&lt;/td>
 &lt;td>同樣的 workload 打新舊環境&lt;/td>
 &lt;td>回應時間差異 &amp;lt; 10%（或可解釋）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>相容性問題&lt;/td>
 &lt;td>逐一驗證 change manifest 的中高風險項&lt;/td>
 &lt;td>每項有「通過」或「已修」的紀錄&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>外部整合&lt;/td>
 &lt;td>第三方 API callback、webhook、email&lt;/td>
 &lt;td>外部服務能正常與新環境互動&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="平行期的時間長度">平行期的時間長度&lt;/h3>
&lt;p>平行環境跑多久才能切換？取決於業務週期。如果系統有月結、季結的批次處理，平行環境至少要跑過一次完整週期。電商系統要跑過至少一個促銷活動。沒有明顯週期的系統，一到兩週的平行驗證通常足夠發現主要問題。&lt;/p>
&lt;h2 id="phase-3分批切換">Phase 3：分批切換&lt;/h2>
&lt;p>分批切換的核心原則是不一次切 100%——先把最低風險的流量導到新環境，觀察一段時間確認正常，再逐步增加比例。&lt;/p>
&lt;h3 id="切換策略">切換策略&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>策略&lt;/th>
 &lt;th>適用環境&lt;/th>
 &lt;th>操作方式&lt;/th>
 &lt;th>回退速度&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>DNS 權重切換&lt;/td>
 &lt;td>有多組 server 的環境&lt;/td>
 &lt;td>Route 53 weighted routing 或類似機制，逐步調整新舊比例&lt;/td>
 &lt;td>分鐘級（改 DNS 權重）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Blue-green&lt;/td>
 &lt;td>有 load balancer 的環境&lt;/td>
 &lt;td>新舊環境各掛在不同 target group，LB 切換指向&lt;/td>
 &lt;td>秒級（切 target group）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Canary&lt;/td>
 &lt;td>容器化或 serverless 環境&lt;/td>
 &lt;td>新版本只接 5% → 20% → 50% → 100% 流量&lt;/td>
 &lt;td>秒級（調整 weight）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>維護窗口&lt;/td>
 &lt;td>共享主機（無 LB）&lt;/td>
 &lt;td>公告停機時間、切換、驗證、恢復服務&lt;/td>
 &lt;td>分鐘級（FTP 上傳舊版）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>共享主機通常只能用維護窗口策略——沒有 load balancer 做流量分配、沒有 DNS 權重可調。維護窗口的關鍵是時間規劃：備份（15 分鐘）→ 切換（30 分鐘）→ 驗證（30 分鐘）→ 恢復或回退（15 分鐘），在窗口內必須完成全部步驟，超時就回退。&lt;/p></description><content:encoded><![CDATA[<p>環境與系統升級的核心約束是系統在升級過程中要持續服務客戶。這個約束排除了「關機 → 換版本 → 開機」的簡單路徑，取而代之的操作模式是四個階段：評估新舊版本的差異、在旁邊建一個新環境驗證、把流量分批切過去、確認沒問題後退役舊環境。這四個階段不管升級的對象是 runtime 版本、資料庫引擎、作業系統還是整個平台，框架相同，差異落在每個階段的具體操作與風險點。</p>
<h2 id="phase-1差異評估">Phase 1：差異評估</h2>
<p>差異評估的產出是一份 change manifest——列出所有已知的新舊差異、每項的風險等級、以及需要的應對措施。這份清單是後續所有階段的依據：平行環境要驗證清單上的每一項、切換策略要先處理高風險項、退役前要確認清單上的所有相容性問題都已解決。</p>
<h3 id="差異的三個維度">差異的三個維度</h3>
<p>第一個維度是目標本身的變化。版本升級要看 changelog、breaking changes list、deprecated features list。平台遷移要看兩個平台的功能差異（共享主機沒有的 cron 彈性、VPS 有的 SSH 存取）。資料庫升級要看 SQL 語法差異、預設行為變更（如 MySQL 8.0 的 <code>caching_sha2_password</code> 預設認證方式）。</p>
<p>第二個維度是依賴關係。升級 PHP 版本時，所有 Composer 套件都可能受影響；升級 MySQL 時，ORM 的 SQL 生成可能不相容；遷移平台時，原本靠主機面板設定的 cron job 要改用系統 crontab 或雲端排程。依賴關係沒列完整，平行環境的測試就會漏掉受影響的元件。</p>
<p>第三個維度是過渡期的雙版本相容性。升級不是瞬間完成的——在切換的過程中，系統的某些部分跑新版本、某些部分跑舊版本。這段期間兩個版本必須能共存：資料庫的 schema 要同時相容新舊版本的程式碼、API 的回應格式要讓新舊版本的客戶端都能處理、session 格式要能跨版本延續。</p>
<h3 id="風險分級">風險分級</h3>
<table>
  <thead>
      <tr>
          <th>風險等級</th>
          <th>定義</th>
          <th>應對方式</th>
          <th>範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>低</td>
          <td>向後相容、不需改 code</td>
          <td>平行環境驗證即可</td>
          <td>PHP 8.x 的效能改善</td>
      </tr>
      <tr>
          <td>中</td>
          <td>需要改 code 但改動明確</td>
          <td>先改 code、確認新舊版本都能跑</td>
          <td>deprecated function 替換</td>
      </tr>
      <tr>
          <td>高</td>
          <td>行為變更、可能影響商業邏輯</td>
          <td>需要完整的功能測試 + 人工驗證</td>
          <td>浮點數精度變更、排序預設值變更</td>
      </tr>
      <tr>
          <td>阻塞</td>
          <td>無法在新版本運作、沒有替代方案</td>
          <td>必須在升級前解決或決定放棄升級</td>
          <td>依賴的套件不支援新版本</td>
      </tr>
  </tbody>
</table>
<p>每一項差異分級後，高風險和阻塞項決定升級的可行性與時程。阻塞項超過團隊能處理的量時，升級可能需要拆成多個階段（先升到中間版本、再升到目標版本）或延後。</p>
<h3 id="時程與管理層報告">時程與管理層報告</h3>
<p>差異評估的時程通常佔整個升級的 20-30%——看起來「還沒開始做」但這段時間的產出（change manifest）決定了後面所有階段的範圍。向管理層報告時用 change manifest 的風險分級表：「共 N 項差異，其中 X 項低風險、Y 項中風險、Z 項高風險、W 項阻塞。中高風險項的處理估計 M 天，阻塞項的替代方案評估需要額外 K 天。」</p>
<h2 id="phase-2平行環境驗證">Phase 2：平行環境驗證</h2>
<p>平行環境驗證的責任是用事實證明「新版本在跟 production 相同的條件下能正常運作」。它的產出是一份驗證報告——每一項 change manifest 上的差異都標上「已驗證通過 / 有問題待修 / 不影響」。沒有這份報告就切換，等於在賭新版本會正常。</p>
<h3 id="建立平行環境">建立平行環境</h3>
<p>平行環境跟 production 越相似，驗證結果越可信。理想狀態是完全複製 production 的架構（同規格、同設定、同網路拓撲），只差目標元件的版本不同。成本限制下的折衷是用縮小版（較小的 instance、較少的資料量），但關鍵設定（PHP 模組、MySQL 參數、安全設定）必須跟 production 一致。</p>
<p>資料的處理要特別注意。用 production 的資料副本驗證最可靠（能觸發真實的邊界狀況），但如果資料含 PII，需要先脫敏處理。另一個選項是用 staging 環境的資料，但要確認 staging 的 schema 跟 production 一致——schema drift 會讓驗證結果失真。</p>
<h3 id="驗證清單">驗證清單</h3>
<table>
  <thead>
      <tr>
          <th>驗證項目</th>
          <th>方法</th>
          <th>通過標準</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>應用程式啟動</td>
          <td>部署到新環境、觀察 log</td>
          <td>無 fatal error、所有服務啟動成功</td>
      </tr>
      <tr>
          <td>自動化測試</td>
          <td>跑完整測試套件</td>
          <td>通過率跟舊環境一致</td>
      </tr>
      <tr>
          <td>關鍵業務流程</td>
          <td>人工操作核心流程（登入、下單、金流）</td>
          <td>每個步驟的結果正確</td>
      </tr>
      <tr>
          <td>效能比對</td>
          <td>同樣的 workload 打新舊環境</td>
          <td>回應時間差異 &lt; 10%（或可解釋）</td>
      </tr>
      <tr>
          <td>相容性問題</td>
          <td>逐一驗證 change manifest 的中高風險項</td>
          <td>每項有「通過」或「已修」的紀錄</td>
      </tr>
      <tr>
          <td>外部整合</td>
          <td>第三方 API callback、webhook、email</td>
          <td>外部服務能正常與新環境互動</td>
      </tr>
  </tbody>
</table>
<h3 id="平行期的時間長度">平行期的時間長度</h3>
<p>平行環境跑多久才能切換？取決於業務週期。如果系統有月結、季結的批次處理，平行環境至少要跑過一次完整週期。電商系統要跑過至少一個促銷活動。沒有明顯週期的系統，一到兩週的平行驗證通常足夠發現主要問題。</p>
<h2 id="phase-3分批切換">Phase 3：分批切換</h2>
<p>分批切換的核心原則是不一次切 100%——先把最低風險的流量導到新環境，觀察一段時間確認正常，再逐步增加比例。</p>
<h3 id="切換策略">切換策略</h3>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>適用環境</th>
          <th>操作方式</th>
          <th>回退速度</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DNS 權重切換</td>
          <td>有多組 server 的環境</td>
          <td>Route 53 weighted routing 或類似機制，逐步調整新舊比例</td>
          <td>分鐘級（改 DNS 權重）</td>
      </tr>
      <tr>
          <td>Blue-green</td>
          <td>有 load balancer 的環境</td>
          <td>新舊環境各掛在不同 target group，LB 切換指向</td>
          <td>秒級（切 target group）</td>
      </tr>
      <tr>
          <td>Canary</td>
          <td>容器化或 serverless 環境</td>
          <td>新版本只接 5% → 20% → 50% → 100% 流量</td>
          <td>秒級（調整 weight）</td>
      </tr>
      <tr>
          <td>維護窗口</td>
          <td>共享主機（無 LB）</td>
          <td>公告停機時間、切換、驗證、恢復服務</td>
          <td>分鐘級（FTP 上傳舊版）</td>
      </tr>
  </tbody>
</table>
<p>共享主機通常只能用維護窗口策略——沒有 load balancer 做流量分配、沒有 DNS 權重可調。維護窗口的關鍵是時間規劃：備份（15 分鐘）→ 切換（30 分鐘）→ 驗證（30 分鐘）→ 恢復或回退（15 分鐘），在窗口內必須完成全部步驟，超時就回退。</p>
<h3 id="切換期間的監控">切換期間的監控</h3>
<p>切換開始後要密切觀察的指標：</p>
<ul>
<li><strong>錯誤率</strong>：5xx / 4xx 比例相對於切換前的基線</li>
<li><strong>回應時間</strong>：p50 和 p99 相對於基線</li>
<li><strong>業務指標</strong>：轉換率、訂單數、付款成功率（如果適用）</li>
<li><strong>外部整合</strong>：第三方 callback 是否正常</li>
</ul>
<h3 id="回退觸發條件">回退觸發條件</h3>
<p>在切換前就定義好回退條件，避免事故發生時還要開會決定要不要退：</p>
<ul>
<li>錯誤率超過基線的 2 倍持續 5 分鐘 → 回退</li>
<li>核心業務流程失敗（登入、結帳、金流） → 立刻回退</li>
<li>回應時間超過基線的 3 倍持續 10 分鐘 → 回退</li>
</ul>
<p>回退不是失敗——它是風險控制機制的正常運作。回退後排查問題、修正、重新走 Phase 2 驗證、再嘗試切換。</p>
<h3 id="切換的通知">切換的通知</h3>
<table>
  <thead>
      <tr>
          <th>對象</th>
          <th>通知時機</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>內部團隊</td>
          <td>切換前 24 小時 + 切換開始時</td>
          <td>切換時間、影響範圍、回退計畫</td>
      </tr>
      <tr>
          <td>客戶（如有 SLA）</td>
          <td>切換前 1 週</td>
          <td>預計維護窗口、預期影響</td>
      </tr>
      <tr>
          <td>外部 vendor</td>
          <td>切換前 1 週</td>
          <td>endpoint 變更（如有）、IP 變更（如有）</td>
      </tr>
  </tbody>
</table>
<h2 id="phase-4退役舊環境">Phase 4：退役舊環境</h2>
<p>切換完成後不要立刻刪掉舊環境——保留 1-2 週的冷備。這段時間處理長尾問題：DNS 快取還沒更新的客戶端、排程任務還指向舊 endpoint 的外部系統、舊環境上可能還有未遷移的資料。</p>
<h3 id="退役前的檢查">退役前的檢查</h3>
<ul>
<li>舊環境的存取 log 是否歸零？（有流量代表還有東西指向它）</li>
<li>所有 cron job 是否都已在新環境運行？</li>
<li>外部系統的 webhook / callback URL 是否都已更新？</li>
<li>舊環境上有沒有需要歸檔的資料？（log、上傳檔案、備份快照）</li>
</ul>
<h3 id="退役步驟">退役步驟</h3>
<ol>
<li>停止舊環境的應用服務（但不刪除）</li>
<li>觀察 1 週——如果有問題可以快速重啟</li>
<li>匯出需要保留的資料（log、uploaded files）</li>
<li>刪除舊環境的運算資源（VM、容器）</li>
<li>保留舊環境的最後一份備份 30 天，作為最後的保險</li>
<li>清理舊環境的 DNS 記錄、SSL 憑證、IAM 角色</li>
</ol>
<h2 id="貫穿全程的升級紀律">貫穿全程的升級紀律</h2>
<h3 id="一次只升一個東西">一次只升一個東西</h3>
<p>同時升級 PHP 版本 + 遷移到新主機 + 重構資料庫 schema，出問題時無法判斷是哪個變更造成的。每次升級只改一個主要元件，穩定後再升下一個。如果業務壓力要求一次完成，至少在 Phase 2 的驗證環境裡逐一引入、逐一確認。</p>
<h3 id="每個階段轉換前備份">每個階段轉換前備份</h3>
<p>Phase 1 結束前備份 production 現況、Phase 3 切換前備份、Phase 4 退役前備份。三份備份各自獨立、各自有還原驗證。備份不只是「做了」——要實際測試過還原，確認備份的完整性。</p>
<h3 id="記錄每一步">記錄每一步</h3>
<p>每個升級操作記錄在 repo 的 changelog 裡：什麼時間、誰做的、改了什麼、觀察到什麼結果。升級出問題時，changelog 是回溯「上一步做了什麼」的唯一依據。</p>
<h3 id="在平行階段就練習回退">在平行階段就練習回退</h3>
<p>不要等到 Phase 3 切換時才第一次嘗試回退。在 Phase 2 的平行環境裡，刻意從新版本切回舊版本一次，確認回退路徑能走通、回退後服務能正常恢復。回退的演練跟升級的驗證同等重要。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/takeover/" data-link-title="接手維運：別人建的環境怎麼接管" data-link-desc="接手前人的專案時，怎麼在不搞壞東西的前提下盤點現況、建立維運能力、逐步正規化 — 從無 SSH 的 FTP 環境到有半套 IaC 的雲端環境都適用">接手維運</a>：接手後穩定維運的下一步常是升級</li>
<li>→ <a href="/blog/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a>：stateful 資源（RDS、S3）的升級涉及特殊的備份與切換策略</li>
<li>→ <a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>：升級期間的變更紀錄對齊治理紀律</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/" data-link-title="模組七：infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">模組七：infra 走 PR 流程</a>：升級涉及的 IaC 變更走 PR review</li>
</ul>
]]></content:encoded></item><item><title>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>平台遷移</title><link>https://tarrragon.github.io/blog/infra/upgrade/platform-migration/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/upgrade/platform-migration/</guid><description>&lt;p>平台遷移改變的是系統跑在哪裡，不是系統跑什麼。應用程式碼不動，改變的是網路拓樸、儲存位置、運算環境與存取方式。遷移成功的判準是應用程式在新平台上以等同或更好的效能運作，且舊平台可以被安全退役。&lt;/p>
&lt;p>遷移的核心約束是帶電施工——系統在搬遷過程中要持續服務。這決定了操作模式：在新平台建起平行環境、驗證通過後用 DNS 切換流量、確認沒問題再拆舊環境。每一步都保留回退到舊環境的能力，直到新環境穩定運行一段時間。&lt;/p>
&lt;h2 id="遷移路徑的常見組合">遷移路徑的常見組合&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>路徑&lt;/th>
 &lt;th>獲得&lt;/th>
 &lt;th>失去&lt;/th>
 &lt;th>主要變動&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>共享主機 → VPS&lt;/td>
 &lt;td>SSH、cron 彈性、自訂軟體安裝&lt;/td>
 &lt;td>主機商代管的面板、email、自動備份&lt;/td>
 &lt;td>需要自己管 OS、web server、SSL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>VPS → 雲端&lt;/td>
 &lt;td>Auto-scaling、managed DB、IaC、多 AZ&lt;/td>
 &lt;td>固定月費的簡單計費&lt;/td>
 &lt;td>計費模型改按用量、運維複雜度上升&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>地端 → 雲端&lt;/td>
 &lt;td>彈性擴縮、不管硬體&lt;/td>
 &lt;td>對硬體的直接控制&lt;/td>
 &lt;td>網路重新設計、合規審查、資料主權確認&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每條路徑的遷移工程量級不同：共享主機 → VPS 是最輕的（應用層搬家）、地端 → 雲端是最重的（整個基礎設施重建）。選擇遷移路徑時先確認商業目標——如果目標是「能裝自訂軟體」，共享主機 → VPS 就夠了，不需要一步跳到雲端。&lt;/p>
&lt;h2 id="共享主機--vps-遷移">共享主機 → VPS 遷移&lt;/h2>
&lt;h3 id="遷移前的記錄">遷移前的記錄&lt;/h3>
&lt;p>把共享主機的所有設定記下來，作為 VPS 上重建的 checklist。需要記錄的項目：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>項目&lt;/th>
 &lt;th>記錄方式&lt;/th>
 &lt;th>用途&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>PHP 版本與模組&lt;/td>
 &lt;td>&lt;code>phpinfo()&lt;/code> 匯出&lt;/td>
 &lt;td>VPS 上安裝對應版本&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Cron jobs&lt;/td>
 &lt;td>主機面板截圖或匯出&lt;/td>
 &lt;td>VPS 上重建 crontab&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Email 帳號與轉發規則&lt;/td>
 &lt;td>面板匯出&lt;/td>
 &lt;td>另外處理（見下方）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>DNS 記錄（A / CNAME / MX）&lt;/td>
 &lt;td>域名管理介面匯出&lt;/td>
 &lt;td>切換時需要&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>SSL 憑證&lt;/td>
 &lt;td>簽發者、到期日&lt;/td>
 &lt;td>VPS 上重新簽發或遷移&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>.htaccess 規則&lt;/td>
 &lt;td>從站台下載&lt;/td>
 &lt;td>轉換成 nginx 設定&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>接手維運模組的&lt;a href="https://tarrragon.github.io/blog/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">環境設定拍照&lt;/a>有更完整的盤點方法。&lt;/p>
&lt;h3 id="vps-環境建立">VPS 環境建立&lt;/h3>
&lt;p>VPS 上從零安裝 web stack：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># Ubuntu 22.04 為例&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">sudo apt update &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> sudo apt upgrade -y
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1"># Web server&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">sudo apt install nginx -y
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1"># PHP（對齊共享主機的版本）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">sudo apt install php8.1-fpm php8.1-mysql php8.1-curl php8.1-mbstring php8.1-gd php8.1-xml -y
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1"># MySQL&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">sudo apt install mysql-server -y
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="c1"># SSL（Let&amp;#39;s Encrypt）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">sudo apt install certbot python3-certbot-nginx -y
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">sudo certbot --nginx -d example.com -d www.example.com&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>安裝完成後用 &lt;code>php -m&lt;/code> 比對共享主機的 phpinfo 記錄，確認所有模組都已安裝。缺少的模組用 &lt;code>apt install php8.1-&amp;lt;module&amp;gt;&lt;/code> 補上。&lt;/p>
&lt;h3 id="資料搬移">資料搬移&lt;/h3>





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Ubuntu 22.04 為例</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">sudo apt update <span class="o">&amp;&amp;</span> sudo apt upgrade -y
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># Web server</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">sudo apt install nginx -y
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># PHP（對齊共享主機的版本）</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">sudo apt install php8.1-fpm php8.1-mysql php8.1-curl php8.1-mbstring php8.1-gd php8.1-xml -y
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"># MySQL</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">sudo apt install mysql-server -y
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"># SSL（Let&#39;s Encrypt）</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">sudo apt install certbot python3-certbot-nginx -y
</span></span><span class="line"><span class="ln">15</span><span class="cl">sudo certbot --nginx -d example.com -d www.example.com</span></span></code></pre></div><p>安裝完成後用 <code>php -m</code> 比對共享主機的 phpinfo 記錄，確認所有模組都已安裝。缺少的模組用 <code>apt install php8.1-&lt;module&gt;</code> 補上。</p>
<h3 id="資料搬移">資料搬移</h3>





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># .htaccess: RewriteEngine On / RewriteRule ^(.*)$ index.php/$1
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># nginx 等價：
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span><span class="k">location</span> <span class="s">/</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="kn">try_files</span> <span class="nv">$uri</span> <span class="nv">$uri/</span> <span class="s">/index.php?</span><span class="nv">$query_string</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># .htaccess: Options -Indexes
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># nginx 等價：
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="k">autoindex</span> <span class="no">off</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># .htaccess: deny from all (某目錄)
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># nginx 等價：
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span><span class="k">location</span> <span class="p">~</span> <span class="sr">/\.env</span> <span class="p">{</span> <span class="kn">deny</span> <span class="s">all</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>轉換後在本地或 staging 驗證每條規則的行為是否一致。WordPress、Laravel 等框架有現成的 nginx 設定範例可參考。</p>
<h3 id="email-處理">Email 處理</h3>
<p>共享主機通常附帶 email 服務（用主機面板建 email 帳號）。VPS 預設不含 email。三個處理方式：</p>
<ul>
<li>自架 email server（Postfix + Dovecot）：維運成本高、不推薦除非有特殊需求</li>
<li>改用第三方 email 服務（Google Workspace / Zoho Mail）：設定 MX 記錄指向服務商</li>
<li>只轉發（不收信）：應用程式的寄信功能改用 SMTP relay（SendGrid / Mailgun）</li>
</ul>
<p>DNS 的 MX 記錄要在切換前就改好指向新的 email 服務，否則切換後 email 會中斷。</p>
<h3 id="ssl-自動續期">SSL 自動續期</h3>
<p>共享主機的 SSL 通常由主機商代管續期。VPS 上用 Let&rsquo;s Encrypt 的 certbot 會自動設定 systemd timer 或 cron 做續期，但要驗證它確實在跑：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 確認 certbot 的自動續期排程存在</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sudo systemctl list-timers <span class="p">|</span> grep certbot
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 模擬續期測試（不實際續期）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">sudo certbot renew --dry-run</span></span></code></pre></div><h2 id="vps--雲端遷移">VPS → 雲端遷移</h2>
<h3 id="服務盤點與雲端對照">服務盤點與雲端對照</h3>
<p>VPS 上的每個 process 都需要對應到雲端的服務：</p>
<table>
  <thead>
      <tr>
          <th>VPS 上的角色</th>
          <th>雲端對應</th>
          <th>備註</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>nginx + PHP-FPM</td>
          <td>ECS Fargate / EC2 + ALB</td>
          <td>容器化或直接搬</td>
      </tr>
      <tr>
          <td>MySQL</td>
          <td>RDS</td>
          <td>managed DB、自動備份</td>
      </tr>
      <tr>
          <td>cron jobs</td>
          <td>EventBridge + Lambda / ECS task</td>
          <td>排程觸發的獨立 task</td>
      </tr>
      <tr>
          <td>背景 worker</td>
          <td>ECS service / SQS + Lambda</td>
          <td>依工作模式選型</td>
      </tr>
      <tr>
          <td>檔案儲存</td>
          <td>S3 + CloudFront</td>
          <td>上傳檔案搬到物件儲存</td>
      </tr>
  </tbody>
</table>
<h3 id="自動化遷移工具">自動化遷移工具</h3>
<p>AWS Application Migration Service（MGN）可以自動化 VM workload 的搬遷——把現有 server 的 block-level data 持續複製到 AWS、切換時啟動 EC2 instance。適合大量 VM 的 lift-and-shift，但不處理應用層的重構（nginx config、cron 轉 EventBridge 等仍需手動）。單台 VM 的遷移用 MGN 反而比手動 dump/restore 多一層設定成本，適用場景是同時搬 5 台以上。</p>
<h3 id="iac-的導入時機">IaC 的導入時機</h3>
<p>VPS → 雲端是導入 IaC 的最佳時機——新環境從零建起，沒有歷史包袱。用 Terraform 描述 VPC、subnet、RDS、ECS、ALB 等資源，讓新環境可重現（見<a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一：最小可行 IaC</a>）。遷移完成後，這套 IaC 直接成為持續維運的基礎。</p>
<h3 id="資料庫遷移">資料庫遷移</h3>
<p>小型資料庫（&lt; 10GB）：mysqldump + 匯入 RDS，遷移期間短暫唯讀即可。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 從 VPS dump</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">mysqldump -u user -p --single-transaction site_db <span class="p">|</span> gzip &gt; site_db.sql.gz
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 匯入 RDS</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">gunzip -c site_db.sql.gz <span class="p">|</span> mysql -h rds-endpoint.region.rds.amazonaws.com -u admin -p site_db</span></span></code></pre></div><p>大型資料庫（&gt; 10GB 或需要零停機）：使用 AWS DMS（Database Migration Service）做持續複寫，VPS 上的 MySQL 作為 source、RDS 作為 target，DMS 做初始全量複製後持續同步增量，切換時把應用指向 RDS 端點。</p>
<h3 id="網路設計">網路設計</h3>
<p>雲端環境的網路要在遷移前規劃好。VPC、subnet、security group 的設計見<a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>。VPS 上的 iptables 規則要映射成 security group 規則——iptables 的每條 accept 對應一條 SG ingress rule，但 SG 不支援 deny（用「不開就是 deny」的白名單模式）。</p>
<h2 id="資料同步策略">資料同步策略</h2>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>停機時間</th>
          <th>複雜度</th>
          <th>適用場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>一次性 dump + restore</td>
          <td>分鐘到小時級</td>
          <td>低</td>
          <td>資料 &lt; 10GB、可接受維護窗口</td>
      </tr>
      <tr>
          <td>持續複寫（DMS / 邏輯複寫）</td>
          <td>秒級（切換瞬間）</td>
          <td>高</td>
          <td>資料大、不允許停機</td>
      </tr>
      <tr>
          <td>檔案 rsync 增量同步</td>
          <td>取決於差異量</td>
          <td>低</td>
          <td>靜態檔案、上傳內容</td>
      </tr>
  </tbody>
</table>
<p>選擇策略時先問兩個問題：資料量多大（決定 dump 時間）、業務能接受多長的唯讀或停機窗口（決定要不要持續複寫）。</p>
<p>對於上傳檔案（圖片、文件），遷移到雲端時通常從本地檔案系統搬到 S3：</p>





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 確認當前 TTL</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">dig example.com +short +ttlid</span></span></code></pre></div><h3 id="切換操作">切換操作</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 更新 A record 指向新平台的 IP / ALB endpoint</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 如果用 Route 53：</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">aws route53 change-resource-record-sets --hosted-zone-id Z123 --change-batch <span class="s1">&#39;{
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s1">  &#34;Changes&#34;: [{&#34;Action&#34;: &#34;UPSERT&#34;, &#34;ResourceRecordSet&#34;: {
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s1">    &#34;Name&#34;: &#34;example.com&#34;, &#34;Type&#34;: &#34;A&#34;,
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s1">    &#34;AliasTarget&#34;: {&#34;HostedZoneId&#34;: &#34;Z456&#34;, &#34;DNSName&#34;: &#34;alb-xxx.region.elb.amazonaws.com&#34;, &#34;EvaluateTargetHealth&#34;: true}
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="s1">  }}]
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="s1">}&#39;</span></span></span></code></pre></div><h3 id="切換後監控">切換後監控</h3>
<p>切換後的驗證窗口至少等 2 倍 TTL（短 TTL 設 300 秒的話，至少等 10 分鐘）。在這段時間內：</p>
<ul>
<li>新平台：監控 HTTP 狀態碼、回應時間、錯誤率</li>
<li>舊平台：觀察流量是否遞減到零（仍有流量代表 DNS 還沒完全傳播）</li>
<li>功能驗證：跑一次關鍵流程（登入、查詢、交易）</li>
</ul>
<h3 id="回退">回退</h3>
<p>如果新平台出問題，回退方式是把 DNS 切回舊平台的 IP。回退的生效時間等於當前的 TTL——這正是切換前降低 TTL 的理由。舊平台在 DNS 切換後要保留至少 72 小時（全球 DNS 快取最慢的清除時間），確認完全沒有流量後再退役。</p>
<h3 id="切換後收尾">切換後收尾</h3>
<p>穩定運行 1-2 週後：</p>
<ul>
<li>把 DNS TTL 恢復到正常值（3600 秒）</li>
<li>退役舊平台（關機 → 保留快照 → 一個月後刪除）</li>
<li>更新文件：新環境的存取方式、部署流程、監控端點</li>
</ul>
<h2 id="時程與管理層溝通">時程與管理層溝通</h2>
<table>
  <thead>
      <tr>
          <th>遷移路徑</th>
          <th>典型時程</th>
          <th>主要風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>共享主機 → VPS</td>
          <td>1-2 週</td>
          <td>.htaccess 轉換、email 處理、SSL 續期</td>
      </tr>
      <tr>
          <td>VPS → 雲端</td>
          <td>2-4 週</td>
          <td>資料庫遷移、網路設計、IaC 建立</td>
      </tr>
      <tr>
          <td>地端 → 雲端</td>
          <td>4-8 週</td>
          <td>網路重建、合規審查、資料主權</td>
      </tr>
  </tbody>
</table>
<p>向管理層溝通時的關鍵訊息：「應用程式碼不變、改的是運行環境。風險集中在資料搬移和 DNS 切換這兩個步驟，兩者都有回退路徑。」</p>
<p>成本變化也要提前說明：共享主機 → VPS 的月費通常持平或略增（$5-30/月）；VPS → 雲端的月費取決於資源用量，初期可能增加 50-200%（換到的是彈性和 managed 服務），但可以透過 reserved instance 和 rightsizing 後續優化。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/upgrade/upgrade-framework/" data-link-title="升級的共通操作框架" data-link-desc="任何環境或系統升級的四階段模型：差異評估、平行環境驗證、分批切換、退役舊環境，以及貫穿全程的升級紀律">升級的共通操作框架</a>：評估差異 → 平行環境 → 切換 → 退役的四階段模型</li>
<li>→ <a href="/blog/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">接手維運：無 SSH 的 FTP 環境</a>：遷移前的環境盤點方法</li>
<li>→ <a href="/blog/infra/01-minimal-iac/" data-link-title="模組一：最小可行 IaC — state 地基與 Console 唯讀鐵律" data-link-desc="Terraform / OpenTofu 選型、remote state 與 lock，以及「Console 只能看不能改」鐵律">模組一：最小可行 IaC</a>：雲端遷移是導入 IaC 的最佳時機</li>
<li>→ <a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>：雲端環境的 VPC / subnet 設計</li>
</ul>
]]></content:encoded></item><item><title>資料庫大版本升級</title><link>https://tarrragon.github.io/blog/infra/upgrade/database-major-upgrade/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/upgrade/database-major-upgrade/</guid><description>&lt;p>資料庫大版本升級是所有升級類型中風險最高的一種，因為資料庫承載的是不可重建的狀態。Runtime 升級（PHP 5.6→8.x）改壞了可以切回舊版本重新部署（切換 PHP 版本即可回退）；平台遷移（共享主機→雲端）改壞了可以把 DNS 切回去（TTL 期間內生效）。資料庫升級改壞了，回退手段是從備份還原——而還原需要時間，還原期間服務不可用，且還原點之後的寫入會遺失。這個不對稱決定了資料庫升級的操作模式：每一步都需要驗證通過才進下一步，且每一步都有明確的回退路徑。&lt;/p>
&lt;h2 id="升級前的相容性評估">升級前的相容性評估&lt;/h2>
&lt;p>大版本升級不只是換一個二進位檔——新版本可能改變 SQL 行為、儲存格式、認證方式與預設值。在動任何生產資源之前，先在本地或測試環境把相容性問題找出來。&lt;/p>
&lt;h3 id="mysql-57--80-的常見破壞性變更">MySQL 5.7 → 8.0 的常見破壞性變更&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>變更項&lt;/th>
 &lt;th>影響&lt;/th>
 &lt;th>檢查方式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>GROUP BY&lt;/code> 隱式排序移除&lt;/td>
 &lt;td>依賴 &lt;code>GROUP BY&lt;/code> 順序的查詢結果可能改變&lt;/td>
 &lt;td>搜尋沒有 &lt;code>ORDER BY&lt;/code> 的 &lt;code>GROUP BY&lt;/code> 查詢&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>預設字元集 utf8 → utf8mb4&lt;/td>
 &lt;td>欄位長度與索引大小計算改變，索引可能超過限制&lt;/td>
 &lt;td>檢查 &lt;code>VARCHAR(255)&lt;/code> + 唯一索引的欄位&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>認證方式改為 caching_sha2&lt;/td>
 &lt;td>舊版 client / driver 可能無法連線&lt;/td>
 &lt;td>確認應用程式的 MySQL driver 版本支援 caching_sha2_password&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>保留字新增（RANK、ROW_NUMBER）&lt;/td>
 &lt;td>用這些字當欄位名或別名的查詢會報語法錯&lt;/td>
 &lt;td>&lt;code>grep -rn &amp;quot;RANK|ROW_NUMBER|GROUPS|CUME_DIST&amp;quot; --include=&amp;quot;*.sql&amp;quot;&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>JSON 函式行為變更&lt;/td>
 &lt;td>&lt;code>JSON_MERGE&lt;/code> 改名為 &lt;code>JSON_MERGE_PRESERVE&lt;/code>、行為語意不同&lt;/td>
 &lt;td>搜尋 &lt;code>JSON_MERGE&lt;/code> 呼叫&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="postgresql-大版本升級的檢查點">PostgreSQL 大版本升級的檢查點&lt;/h3>
&lt;p>PostgreSQL 的大版本升級相對穩定，但仍有需要確認的項目：extension 版本是否跟新 PostgreSQL 版本相容（特別是 PostGIS、pg_partman、timescaledb 這類複雜 extension）、&lt;code>pg_upgrade&lt;/code> 的 &lt;code>--check&lt;/code> 模式可以在不實際升級的前提下驗證相容性。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># PostgreSQL: 升級前 dry-run 檢查&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">pg_upgrade --old-datadir /var/lib/postgresql/13/main &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --new-datadir /var/lib/postgresql/16/main &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --old-bindir /usr/lib/postgresql/13/bin &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --new-bindir /usr/lib/postgresql/16/bin &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --check&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="應用程式層的查詢相容性">應用程式層的查詢相容性&lt;/h3>
&lt;p>把應用程式的所有 SQL 查詢（ORM 產生的也算）對新版本跑一遍。重點是行為變更而非語法錯誤——語法錯誤會立刻報錯、容易抓；行為變更（排序結果不同、型別轉換規則不同）不會報錯、但結果錯誤。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># MySQL 升級前檢查工具&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">mysqlcheck --all-databases --check-upgrade
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">mysql_upgrade --upgrade-system-tables --dry-run&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>ORM 和 database driver 也要確認版本支援。PHP 的 &lt;code>mysqli&lt;/code> 在 PHP 7.4+ 預設支援 caching_sha2_password、但舊版不支援。Node.js 的 &lt;code>mysql2&lt;/code> 原生支援、但 &lt;code>mysql&lt;/code>（舊套件）不支援。Python 的 &lt;code>mysqlclient&lt;/code> 1.4+ 支援。&lt;/p>
&lt;h2 id="備份升級前的保險">備份：升級前的保險&lt;/h2>
&lt;p>升級前的備份不是日常備份——它是一份明確的、經過驗證的、標記為「升級前保險點」的快照。&lt;/p>
&lt;h3 id="備份操作">備份操作&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># MySQL: 完整 dump（InnoDB 用 --single-transaction 避免鎖表）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">mysqldump --all-databases --single-transaction --routines --triggers &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --set-gtid-purged&lt;span class="o">=&lt;/span>OFF &amp;gt; pre-upgrade-&lt;span class="k">$(&lt;/span>date +%Y%m%d-%H%M&lt;span class="k">)&lt;/span>.sql
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1"># PostgreSQL: 完整 dump&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">pg_dumpall &amp;gt; pre-upgrade-&lt;span class="k">$(&lt;/span>date +%Y%m%d-%H%M&lt;span class="k">)&lt;/span>.sql&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>RDS 環境：在升級操作前手動建立 snapshot，而非依賴自動備份。自動備份在升級過程中可能被新的快照覆蓋，手動 snapshot 不會被自動清除。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">aws rds create-db-snapshot &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --db-instance-identifier mydb-prod &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --db-snapshot-identifier pre-upgrade-&lt;span class="k">$(&lt;/span>date +%Y%m%d&lt;span class="k">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="備份驗證">備份驗證&lt;/h3>
&lt;p>備份存在不等於備份可用。驗證方式是把備份還原到一台獨立的測試實例、確認資料完整：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 還原到測試實例&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">mysql -h test-instance -u admin -p &amp;lt; pre-upgrade-20260626-1400.sql
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 驗證關鍵表的 row count&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">mysql -h test-instance -e &lt;span class="s2">&amp;#34;SELECT COUNT(*) FROM orders; SELECT COUNT(*) FROM users;&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>記錄還原時間：「從這份備份還原到可服務狀態需要 N 分鐘/小時」。這個數字是升級失敗時的停機時間下限——管理層需要這個數字來評估升級的風險。&lt;/p></description><content:encoded><![CDATA[<p>資料庫大版本升級是所有升級類型中風險最高的一種，因為資料庫承載的是不可重建的狀態。Runtime 升級（PHP 5.6→8.x）改壞了可以切回舊版本重新部署（切換 PHP 版本即可回退）；平台遷移（共享主機→雲端）改壞了可以把 DNS 切回去（TTL 期間內生效）。資料庫升級改壞了，回退手段是從備份還原——而還原需要時間，還原期間服務不可用，且還原點之後的寫入會遺失。這個不對稱決定了資料庫升級的操作模式：每一步都需要驗證通過才進下一步，且每一步都有明確的回退路徑。</p>
<h2 id="升級前的相容性評估">升級前的相容性評估</h2>
<p>大版本升級不只是換一個二進位檔——新版本可能改變 SQL 行為、儲存格式、認證方式與預設值。在動任何生產資源之前，先在本地或測試環境把相容性問題找出來。</p>
<h3 id="mysql-57--80-的常見破壞性變更">MySQL 5.7 → 8.0 的常見破壞性變更</h3>
<table>
  <thead>
      <tr>
          <th>變更項</th>
          <th>影響</th>
          <th>檢查方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>GROUP BY</code> 隱式排序移除</td>
          <td>依賴 <code>GROUP BY</code> 順序的查詢結果可能改變</td>
          <td>搜尋沒有 <code>ORDER BY</code> 的 <code>GROUP BY</code> 查詢</td>
      </tr>
      <tr>
          <td>預設字元集 utf8 → utf8mb4</td>
          <td>欄位長度與索引大小計算改變，索引可能超過限制</td>
          <td>檢查 <code>VARCHAR(255)</code> + 唯一索引的欄位</td>
      </tr>
      <tr>
          <td>認證方式改為 caching_sha2</td>
          <td>舊版 client / driver 可能無法連線</td>
          <td>確認應用程式的 MySQL driver 版本支援 caching_sha2_password</td>
      </tr>
      <tr>
          <td>保留字新增（RANK、ROW_NUMBER）</td>
          <td>用這些字當欄位名或別名的查詢會報語法錯</td>
          <td><code>grep -rn &quot;RANK|ROW_NUMBER|GROUPS|CUME_DIST&quot; --include=&quot;*.sql&quot;</code></td>
      </tr>
      <tr>
          <td>JSON 函式行為變更</td>
          <td><code>JSON_MERGE</code> 改名為 <code>JSON_MERGE_PRESERVE</code>、行為語意不同</td>
          <td>搜尋 <code>JSON_MERGE</code> 呼叫</td>
      </tr>
  </tbody>
</table>
<h3 id="postgresql-大版本升級的檢查點">PostgreSQL 大版本升級的檢查點</h3>
<p>PostgreSQL 的大版本升級相對穩定，但仍有需要確認的項目：extension 版本是否跟新 PostgreSQL 版本相容（特別是 PostGIS、pg_partman、timescaledb 這類複雜 extension）、<code>pg_upgrade</code> 的 <code>--check</code> 模式可以在不實際升級的前提下驗證相容性。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># PostgreSQL: 升級前 dry-run 檢查</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">pg_upgrade --old-datadir /var/lib/postgresql/13/main <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>           --new-datadir /var/lib/postgresql/16/main <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>           --old-bindir /usr/lib/postgresql/13/bin <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>           --new-bindir /usr/lib/postgresql/16/bin <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>           --check</span></span></code></pre></div><h3 id="應用程式層的查詢相容性">應用程式層的查詢相容性</h3>
<p>把應用程式的所有 SQL 查詢（ORM 產生的也算）對新版本跑一遍。重點是行為變更而非語法錯誤——語法錯誤會立刻報錯、容易抓；行為變更（排序結果不同、型別轉換規則不同）不會報錯、但結果錯誤。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># MySQL 升級前檢查工具</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">mysqlcheck --all-databases --check-upgrade
</span></span><span class="line"><span class="ln">3</span><span class="cl">mysql_upgrade --upgrade-system-tables --dry-run</span></span></code></pre></div><p>ORM 和 database driver 也要確認版本支援。PHP 的 <code>mysqli</code> 在 PHP 7.4+ 預設支援 caching_sha2_password、但舊版不支援。Node.js 的 <code>mysql2</code> 原生支援、但 <code>mysql</code>（舊套件）不支援。Python 的 <code>mysqlclient</code> 1.4+ 支援。</p>
<h2 id="備份升級前的保險">備份：升級前的保險</h2>
<p>升級前的備份不是日常備份——它是一份明確的、經過驗證的、標記為「升級前保險點」的快照。</p>
<h3 id="備份操作">備份操作</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># MySQL: 完整 dump（InnoDB 用 --single-transaction 避免鎖表）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">mysqldump --all-databases --single-transaction --routines --triggers <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --set-gtid-purged<span class="o">=</span>OFF &gt; pre-upgrade-<span class="k">$(</span>date +%Y%m%d-%H%M<span class="k">)</span>.sql
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># PostgreSQL: 完整 dump</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">pg_dumpall &gt; pre-upgrade-<span class="k">$(</span>date +%Y%m%d-%H%M<span class="k">)</span>.sql</span></span></code></pre></div><p>RDS 環境：在升級操作前手動建立 snapshot，而非依賴自動備份。自動備份在升級過程中可能被新的快照覆蓋，手動 snapshot 不會被自動清除。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws rds create-db-snapshot <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --db-instance-identifier mydb-prod <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --db-snapshot-identifier pre-upgrade-<span class="k">$(</span>date +%Y%m%d<span class="k">)</span></span></span></code></pre></div><h3 id="備份驗證">備份驗證</h3>
<p>備份存在不等於備份可用。驗證方式是把備份還原到一台獨立的測試實例、確認資料完整：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 還原到測試實例</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">mysql -h test-instance -u admin -p &lt; pre-upgrade-20260626-1400.sql
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 驗證關鍵表的 row count</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">mysql -h test-instance -e <span class="s2">&#34;SELECT COUNT(*) FROM orders; SELECT COUNT(*) FROM users;&#34;</span></span></span></code></pre></div><p>記錄還原時間：「從這份備份還原到可服務狀態需要 N 分鐘/小時」。這個數字是升級失敗時的停機時間下限——管理層需要這個數字來評估升級的風險。</p>
<h2 id="平行驗證策略">平行驗證策略</h2>
<p>在生產環境切換之前，先在新版本的平行環境上跑完所有驗證。平行驗證的目標是讓切換那一刻的風險降到最低——切換時已經知道新版本在相同資料和相同負載下的行為。</p>
<h3 id="建立平行環境">建立平行環境</h3>
<table>
  <thead>
      <tr>
          <th>方式</th>
          <th>適用情境</th>
          <th>資料同步方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Read replica + 版本升級</td>
          <td>RDS 環境、支援跨版本 replica</td>
          <td>RDS 原生複寫</td>
      </tr>
      <tr>
          <td>Logical replication</td>
          <td>需要跨大版本</td>
          <td>pg_logical / binlog → 新實例</td>
      </tr>
      <tr>
          <td>Dump / restore</td>
          <td>任何環境、資料量可控</td>
          <td>一次性 dump + 增量 binlog 回放</td>
      </tr>
  </tbody>
</table>
<h3 id="驗證項目">驗證項目</h3>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>方法</th>
          <th>通過標準</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>應用程式測試套件</td>
          <td>對新版本實例跑完整測試</td>
          <td>0 failure</td>
      </tr>
      <tr>
          <td>查詢效能</td>
          <td>對比兩個版本的 slow query log</td>
          <td>p99 延遲無顯著退化（&lt;10% 差異）</td>
      </tr>
      <tr>
          <td>資料一致性</td>
          <td>關鍵表 row count + checksum</td>
          <td>完全一致</td>
      </tr>
      <tr>
          <td>連線行為</td>
          <td>應用程式連新版本、觀察連線池</td>
          <td>無 authentication failure</td>
      </tr>
      <tr>
          <td>備份還原</td>
          <td>從新版本做一次 dump + restore</td>
          <td>還原成功、資料完整</td>
      </tr>
  </tbody>
</table>
<p>平行驗證至少跑一週。時間越長、覆蓋到的邊界情境越多——月結批次、週期性報表、低頻排程任務都可能觸發只在特定條件下才出現的相容性問題。</p>
<h2 id="切換策略">切換策略</h2>
<p>切換策略的選擇取決於三個變數的取捨：操作複雜度、停機時間、回退速度。</p>
<h3 id="in-place-升級">In-place 升級</h3>
<p>直接在原實例上升級版本。RDS 的操作是修改 engine version、等待升級完成。</p>
<ul>
<li><strong>停機</strong>：升級期間實例不可用（MySQL 5.7→8.0 在 RDS 上約 10-30 分鐘，視資料量而定）</li>
<li><strong>回退</strong>：從 pre-upgrade snapshot 還原，需要 snapshot restore 時間（分鐘到小時級）</li>
<li><strong>適用</strong>：可接受計畫性停機的環境、資料量不大</li>
</ul>
<h3 id="blue-green-切換">Blue-green 切換</h3>
<p>在新版本上建立獨立實例、透過 replication 同步資料、切換應用程式的連線端點。</p>
<ul>
<li><strong>停機</strong>：接近零（DNS TTL 或 endpoint 切換的傳播時間）</li>
<li><strong>回退</strong>：把連線端點切回舊實例，舊實例持續運行</li>
<li><strong>複雜度</strong>：需要維護兩個實例的同步、切換時要處理複寫延遲</li>
<li><strong>適用</strong>：不能接受停機的 production 環境</li>
</ul>
<p>RDS 從 2022 年開始提供原生的 Blue/Green Deployments 功能，簡化了同步與切換的操作：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws rds create-blue-green-deployment <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --blue-green-deployment-name mydb-upgrade <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --source arn:aws:rds:ap-northeast-1:123456789012:db:mydb-prod <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --target-engine-version 8.0.35</span></span></code></pre></div><h3 id="read-replica-升級後提升">Read replica 升級後提升</h3>
<p>建立指定新版本的 read replica，replica 同步完成後提升為獨立實例，應用程式切換連線。</p>
<ul>
<li><strong>停機</strong>：提升 replica 的幾秒 + 連線切換</li>
<li><strong>回退</strong>：舊 primary 仍在，切回即可</li>
<li><strong>限制</strong>：不是所有版本組合都支援跨版本 replica</li>
</ul>
<h3 id="選型判準">選型判準</h3>
<table>
  <thead>
      <tr>
          <th>考量</th>
          <th>In-place</th>
          <th>Blue-green</th>
          <th>Replica 提升</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>操作複雜度</td>
          <td>低</td>
          <td>中</td>
          <td>中</td>
      </tr>
      <tr>
          <td>停機時間</td>
          <td>10-30 分鐘</td>
          <td>接近零</td>
          <td>幾秒</td>
      </tr>
      <tr>
          <td>回退速度</td>
          <td>慢（snapshot restore）</td>
          <td>快（切回舊端點）</td>
          <td>快（切回舊 primary）</td>
      </tr>
      <tr>
          <td>成本</td>
          <td>最低</td>
          <td>升級期間雙倍</td>
          <td>升級期間雙倍</td>
      </tr>
  </tbody>
</table>
<h2 id="升級後的驗證與監控">升級後的驗證與監控</h2>
<p>切換完成後的 48-72 小時是觀察期。這段時間舊實例保持可用狀態，直到確認新版本穩定才退役。</p>
<h3 id="切換後立即驗證">切換後立即驗證</h3>
<ol>
<li>應用程式的所有關鍵路徑可正常操作（登入、查詢、寫入、交易）</li>
<li>連線池行為正常（沒有持續的 authentication failure 或 connection reset）</li>
<li>排程任務（cron job、背景 worker）正常連線並執行</li>
</ol>
<h3 id="效能監控">效能監控</h3>
<p>比較升級前後的關鍵指標：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 觀察升級後的 slow query 數量</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">mysql -e <span class="s2">&#34;SHOW GLOBAL STATUS LIKE &#39;Slow_queries&#39;;&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 比較 p99 延遲（需要 application-level metrics）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># CloudWatch: DBInstanceIdentifier → ReadLatency, WriteLatency</span></span></span></code></pre></div><p>升級後效能退化的常見原因：optimizer 行為改變（新版本選了不同的執行計畫）、buffer pool 冷啟動（升級後快取是空的、前幾小時延遲偏高是正常的）。如果 48 小時後延遲仍未回到基線，檢查 slow query log 找出退化的具體查詢。</p>
<h3 id="舊實例退役">舊實例退役</h3>
<p>觀察期結束、新版本確認穩定後：</p>
<ol>
<li>停止舊實例的 replication（如果仍在同步）</li>
<li>保留舊實例的 final snapshot</li>
<li>刪除舊實例（先確認 deletion protection 關閉是刻意的、不是誤操作）</li>
<li>更新文件：記錄升級日期、版本號、升級過程中遇到的問題</li>
</ol>
<h2 id="時程與管理層溝通">時程與管理層溝通</h2>
<table>
  <thead>
      <tr>
          <th>升級類型</th>
          <th>典型時程</th>
          <th>停機窗口</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Minor version（5.7.x → 5.7.y）</td>
          <td>2-4 小時計畫維護</td>
          <td>10-15 分鐘</td>
      </tr>
      <tr>
          <td>Major version（5.7 → 8.0）in-place</td>
          <td>1-2 週（評估 + 驗證 + 切換 + 監控）</td>
          <td>10-30 分鐘</td>
      </tr>
      <tr>
          <td>Major version blue-green</td>
          <td>2-3 週（含平行運行期）</td>
          <td>接近零</td>
      </tr>
  </tbody>
</table>
<p>向管理層說明時的關鍵框架：資料是不可重建的，升級策略是「在旁邊建一個新版本的資料庫、驗證它在相同資料和相同負載下行為正確、然後切過去」。多出來的時間買的是「切換那一刻的信心」和「出問題時能快速回退」——兩者對生產服務都是必要的保險。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/upgrade/upgrade-framework/" data-link-title="升級的共通操作框架" data-link-desc="任何環境或系統升級的四階段模型：差異評估、平行環境驗證、分批切換、退役舊環境，以及貫穿全程的升級紀律">升級的共通操作框架</a>：四階段模型的通用說明</li>
<li>→ <a href="/blog/infra/05-core-services/stateful-protection-dependency/" data-link-title="Stateful 資源保護與跨服務依賴表達" data-link-desc="stateful 資源的保護策略（multi-AZ、備份、刪除保護）、stateful 與 stateless 的操作差異，以及用 output 與 data source 表達服務間依賴">Stateful 資源保護與依賴表達</a>：multi-AZ、備份、deletion protection 的 IaC 描述</li>
<li>→ <a href="/blog/infra/takeover/legacy-database-backup-migration/" data-link-title="無 SSH 環境的資料庫備份與變更管理" data-link-desc="在只有 phpMyAdmin 或有限遠端連線的無 SSH 環境裡，怎麼建立可靠的資料庫備份策略、schema 變更紀律與還原演練流程">無 SSH 環境的資料庫備份與變更管理</a>：接手環境的資料庫備份策略</li>
</ul>
]]></content:encoded></item><item><title>OS 與基礎軟體更換</title><link>https://tarrragon.github.io/blog/infra/upgrade/os-base-software-upgrade/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/upgrade/os-base-software-upgrade/</guid><description>&lt;p>作業系統到達 end-of-life（EOL）後不再收到安全修補——每一個新發現的漏洞都會永久敞開。EOL OS 上跑的服務不是「可能有風險」，而是「風險只會隨時間單調增加」。遷移的問題是何時做和怎麼做，不是要不要做。&lt;/p>
&lt;h2 id="eol-風險評估">EOL 風險評估&lt;/h2>
&lt;p>EOL 在操作層面的意義是三件事同時停止：安全修補（CVE 不再被回填到該版本的 patch release）、核心更新（kernel 的錯誤修正與硬體支援停止）、套件庫維護（官方 repository 凍結或下架，新裝套件或更新依賴都做不到）。&lt;/p>
&lt;h3 id="風險時間軸">風險時間軸&lt;/h3>
&lt;p>EOL 是一段逐漸惡化的過程，而非單一時間點：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>階段&lt;/th>
 &lt;th>事件&lt;/th>
 &lt;th>影響&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>宣告&lt;/td>
 &lt;td>官方公布 EOL 日期（通常提前 1-2 年）&lt;/td>
 &lt;td>開始規劃遷移的訊號&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>正式 EOL&lt;/td>
 &lt;td>最後一個安全修補發布&lt;/td>
 &lt;td>新 CVE 不再有 patch&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>套件庫凍結&lt;/td>
 &lt;td>官方 mirror 停止同步或下架&lt;/td>
 &lt;td>&lt;code>yum update&lt;/code> / &lt;code>apt update&lt;/code> 失敗&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>合規失效&lt;/td>
 &lt;td>稽核認定執行環境不符標準&lt;/td>
 &lt;td>PCI DSS / SOC 2 / ISO 27001 判定不合規&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="常見的-eol-情境">常見的 EOL 情境&lt;/h3>
&lt;p>CentOS 7 在 2024 年 6 月結束支援，但仍有大量 production 環境在使用。CentOS 8 在 2021 年 12 月被轉向 CentOS Stream，打破了原本預期到 2029 年的支援承諾，迫使使用者重新選型。Ubuntu 18.04 的標準支援在 2023 年 4 月結束，Canonical 提供 ESM（Extended Security Maintenance）付費延長到 2028 年，但 ESM 只涵蓋 main 套件庫。&lt;/p>
&lt;p>ESM 或類似的付費延長支援（RHEL 的 ELS、CentOS 的第三方 TuxCare）是「買時間做遷移」的合理策略——付月費取得額外 2-5 年的安全修補，讓團隊有餘裕規劃平行建置而非被迫緊急遷移。Ubuntu Pro 免費涵蓋 5 台 instance 的 ESM，超過才需要付費。ESM 是給遷移專案爭取時間的保險，而非長期方案——延長支援的套件覆蓋範圍通常比標準期窄。&lt;/p>
&lt;p>合規的影響很直接：PCI DSS 要求所有面對持卡人資料的系統都執行在有安全修補支援的軟體上；SOC 2 和 ISO 27001 的定期稽核會檢查作業系統的支援狀態。在 EOL OS 上跑的 production 環境會讓稽核結果出現 finding，需要額外的補償控制（compensating control）才能通過——而補償控制的維護成本通常高於遷移本身。&lt;/p>
&lt;h2 id="目標-os-選型">目標 OS 選型&lt;/h2>
&lt;p>選型看四個維度：LTS 發布週期（支援年限多長）、社群與商業支援（問題能不能查到答案、能不能買付費支援）、套件可用性（應用層需要的 runtime 和 library 在官方 repo 裡有沒有）、團隊熟悉度（操作指令和設定路徑的學習成本）。&lt;/p>
&lt;h3 id="常見選擇">常見選擇&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>OS&lt;/th>
 &lt;th>支援週期&lt;/th>
 &lt;th>適用情境&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Ubuntu 22.04 / 24.04 LTS&lt;/td>
 &lt;td>5 年標準 + 5 年 ESM&lt;/td>
 &lt;td>社群最大、套件最新、學習資源最多&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Debian 12 (Bookworm)&lt;/td>
 &lt;td>~5 年&lt;/td>
 &lt;td>穩定性優先、更新保守&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Amazon Linux 2023&lt;/td>
 &lt;td>5 年&lt;/td>
 &lt;td>AWS 生態深度整合、EC2 預設選項&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Rocky Linux 9 / AlmaLinux 9&lt;/td>
 &lt;td>~10 年&lt;/td>
 &lt;td>CentOS 替代、RHEL 相容&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="同家族-vs-跨家族">同家族 vs 跨家族&lt;/h3>
&lt;p>CentOS → Rocky Linux / AlmaLinux 是同家族遷移：套件名稱、設定路徑、init 系統（systemd）幾乎不變，應用層的改動最少。CentOS → Ubuntu 是跨家族遷移：套件管理從 yum/dnf 換成 apt、設定路徑從 &lt;code>/etc/httpd/&lt;/code> 變成 &lt;code>/etc/apache2/&lt;/code>、某些服務名稱不同。&lt;/p>
&lt;p>同家族遷移的優勢是應用層風險低——多數設定檔可以直接搬過去。跨家族遷移的優勢是可以借機切到更活躍的生態（Ubuntu 的社群回答量和第三方套件支援在多數指標上領先），代價是設定檔要全面調整。&lt;/p>
&lt;p>選型判準：如果團隊已經有 Ubuntu 經驗、或其他系統已經跑 Ubuntu，統一到 Ubuntu 的長期維護成本較低。如果團隊對 RHEL 系操作更熟、或有 RHEL 付費支援合約，Rocky/Alma 是阻力最小的路。&lt;/p></description><content:encoded><![CDATA[<p>作業系統到達 end-of-life（EOL）後不再收到安全修補——每一個新發現的漏洞都會永久敞開。EOL OS 上跑的服務不是「可能有風險」，而是「風險只會隨時間單調增加」。遷移的問題是何時做和怎麼做，不是要不要做。</p>
<h2 id="eol-風險評估">EOL 風險評估</h2>
<p>EOL 在操作層面的意義是三件事同時停止：安全修補（CVE 不再被回填到該版本的 patch release）、核心更新（kernel 的錯誤修正與硬體支援停止）、套件庫維護（官方 repository 凍結或下架，新裝套件或更新依賴都做不到）。</p>
<h3 id="風險時間軸">風險時間軸</h3>
<p>EOL 是一段逐漸惡化的過程，而非單一時間點：</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>事件</th>
          <th>影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>宣告</td>
          <td>官方公布 EOL 日期（通常提前 1-2 年）</td>
          <td>開始規劃遷移的訊號</td>
      </tr>
      <tr>
          <td>正式 EOL</td>
          <td>最後一個安全修補發布</td>
          <td>新 CVE 不再有 patch</td>
      </tr>
      <tr>
          <td>套件庫凍結</td>
          <td>官方 mirror 停止同步或下架</td>
          <td><code>yum update</code> / <code>apt update</code> 失敗</td>
      </tr>
      <tr>
          <td>合規失效</td>
          <td>稽核認定執行環境不符標準</td>
          <td>PCI DSS / SOC 2 / ISO 27001 判定不合規</td>
      </tr>
  </tbody>
</table>
<h3 id="常見的-eol-情境">常見的 EOL 情境</h3>
<p>CentOS 7 在 2024 年 6 月結束支援，但仍有大量 production 環境在使用。CentOS 8 在 2021 年 12 月被轉向 CentOS Stream，打破了原本預期到 2029 年的支援承諾，迫使使用者重新選型。Ubuntu 18.04 的標準支援在 2023 年 4 月結束，Canonical 提供 ESM（Extended Security Maintenance）付費延長到 2028 年，但 ESM 只涵蓋 main 套件庫。</p>
<p>ESM 或類似的付費延長支援（RHEL 的 ELS、CentOS 的第三方 TuxCare）是「買時間做遷移」的合理策略——付月費取得額外 2-5 年的安全修補，讓團隊有餘裕規劃平行建置而非被迫緊急遷移。Ubuntu Pro 免費涵蓋 5 台 instance 的 ESM，超過才需要付費。ESM 是給遷移專案爭取時間的保險，而非長期方案——延長支援的套件覆蓋範圍通常比標準期窄。</p>
<p>合規的影響很直接：PCI DSS 要求所有面對持卡人資料的系統都執行在有安全修補支援的軟體上；SOC 2 和 ISO 27001 的定期稽核會檢查作業系統的支援狀態。在 EOL OS 上跑的 production 環境會讓稽核結果出現 finding，需要額外的補償控制（compensating control）才能通過——而補償控制的維護成本通常高於遷移本身。</p>
<h2 id="目標-os-選型">目標 OS 選型</h2>
<p>選型看四個維度：LTS 發布週期（支援年限多長）、社群與商業支援（問題能不能查到答案、能不能買付費支援）、套件可用性（應用層需要的 runtime 和 library 在官方 repo 裡有沒有）、團隊熟悉度（操作指令和設定路徑的學習成本）。</p>
<h3 id="常見選擇">常見選擇</h3>
<table>
  <thead>
      <tr>
          <th>OS</th>
          <th>支援週期</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Ubuntu 22.04 / 24.04 LTS</td>
          <td>5 年標準 + 5 年 ESM</td>
          <td>社群最大、套件最新、學習資源最多</td>
      </tr>
      <tr>
          <td>Debian 12 (Bookworm)</td>
          <td>~5 年</td>
          <td>穩定性優先、更新保守</td>
      </tr>
      <tr>
          <td>Amazon Linux 2023</td>
          <td>5 年</td>
          <td>AWS 生態深度整合、EC2 預設選項</td>
      </tr>
      <tr>
          <td>Rocky Linux 9 / AlmaLinux 9</td>
          <td>~10 年</td>
          <td>CentOS 替代、RHEL 相容</td>
      </tr>
  </tbody>
</table>
<h3 id="同家族-vs-跨家族">同家族 vs 跨家族</h3>
<p>CentOS → Rocky Linux / AlmaLinux 是同家族遷移：套件名稱、設定路徑、init 系統（systemd）幾乎不變，應用層的改動最少。CentOS → Ubuntu 是跨家族遷移：套件管理從 yum/dnf 換成 apt、設定路徑從 <code>/etc/httpd/</code> 變成 <code>/etc/apache2/</code>、某些服務名稱不同。</p>
<p>同家族遷移的優勢是應用層風險低——多數設定檔可以直接搬過去。跨家族遷移的優勢是可以借機切到更活躍的生態（Ubuntu 的社群回答量和第三方套件支援在多數指標上領先），代價是設定檔要全面調整。</p>
<p>選型判準：如果團隊已經有 Ubuntu 經驗、或其他系統已經跑 Ubuntu，統一到 Ubuntu 的長期維護成本較低。如果團隊對 RHEL 系操作更熟、或有 RHEL 付費支援合約，Rocky/Alma 是阻力最小的路。</p>
<h2 id="遷移策略原地升級-vs-平行建置">遷移策略：原地升級 vs 平行建置</h2>
<h3 id="原地升級">原地升級</h3>
<p>在現有伺服器上直接換 OS 版本。做法是用 OS 提供的升級工具（如 <code>do-release-upgrade</code>、<code>leapp</code>）在跑著的系統上切換。</p>
<p>風險集中在升級過程中系統處於不確定狀態——kernel 換了但 userland 還沒、init 系統切了但服務設定還指向舊路徑。如果中途失敗、伺服器可能開不了機，而 rollback 意味著從備份還原整台機器。原地升級只在同 OS 家族的小版本升級（如 Ubuntu 20.04 → 22.04）且有完整 VM 快照保底時才值得考慮。</p>
<h3 id="平行建置">平行建置</h3>
<p>在旁邊建一台新 OS 的伺服器、安裝應用層、遷移資料、用 DNS 或 load balancer 切換流量。舊伺服器保留作為 rollback 目標，確認新環境穩定後再退役。</p>
<p>平行建置的成本是短期多付一台伺服器的費用（通常是幾天到幾週）。收益是：升級失敗時舊伺服器完好無損、切回去只需要改 DNS 或 LB 的 target；新伺服器可以在切換前充分測試、不影響線上服務；整個過程可以在非尖峰時段進行。</p>
<p>對多數環境來說平行建置是預設策略。原地升級只在無法多開一台伺服器（預算極度受限、或裸機硬體無備品）時才退而求其次。</p>
<h2 id="應用層的遷移清單">應用層的遷移清單</h2>
<p>新 OS 上要重建整個應用執行環境。以下是逐項需要確認的面向：</p>
<h3 id="web-伺服器">Web 伺服器</h3>
<p>如果新舊 OS 都用 Apache，設定檔的路徑可能不同（RHEL 系 <code>/etc/httpd/conf.d/</code>、Debian 系 <code>/etc/apache2/sites-available/</code>），模組載入方式也不同（<code>LoadModule</code> 指令 vs <code>a2enmod</code> 工具）。逐一比對現有的 VirtualHost 設定、rewrite 規則、SSL 設定。</p>
<p>如果同時換成 nginx，見下一節。</p>
<h3 id="runtime-版本對齊">Runtime 版本對齊</h3>
<p>新 OS 的官方 repo 裡的 PHP / Node / Python 版本可能跟舊 OS 不同。Ubuntu 22.04 預設 PHP 8.1、如果應用需要 PHP 7.4 要加第三方 PPA（如 ondrej/php）。確認所有 PHP extension（mysqli、curl、gd、mbstring、redis）在新 OS 上都有對應的套件名稱且已安裝。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 舊伺服器：列出所有已載入的 PHP module</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">php -m &gt; old-php-modules.txt
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 新伺服器：比對缺了什麼</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">php -m &gt; new-php-modules.txt
</span></span><span class="line"><span class="ln">6</span><span class="cl">diff old-php-modules.txt new-php-modules.txt</span></span></code></pre></div><h3 id="資料庫客戶端程式庫">資料庫客戶端程式庫</h3>
<p>應用連接 MySQL / PostgreSQL 用的 client library（<code>libmysqlclient</code>、<code>libpq</code>）版本要跟資料庫伺服器相容。跨大版本（MySQL 5.7 client → MySQL 8.0 server）通常向前相容，但反過來可能有驗證方式不匹配的問題（如 MySQL 8.0 的 <code>caching_sha2_password</code> 預設驗證方式）。</p>
<h3 id="cron-jobs">Cron jobs</h3>
<p>從舊伺服器匯出 crontab（<code>crontab -l</code>），在新伺服器重建。如果舊 OS 使用 <code>/etc/cron.d/</code> 的檔案式 cron，確認新 OS 的 cron daemon 支援同樣的格式。Cron 的環境變數（PATH、MAILTO）在不同 OS 可能有不同預設。</p>
<h3 id="日誌路徑">日誌路徑</h3>
<p>Apache 的預設 log 路徑在 RHEL 系是 <code>/var/log/httpd/</code>、Debian 系是 <code>/var/log/apache2/</code>。應用程式如果 hardcode 了日誌路徑，要在新 OS 上對齊。同時確認 logrotate 的設定在新 OS 上存在且正確。</p>
<h3 id="檔案權限與使用者">檔案權限與使用者</h3>
<p>不同 OS 的 web server 執行使用者不同（RHEL 的 <code>apache</code>、Debian 的 <code>www-data</code>）。如果應用依賴特定使用者名稱的檔案權限（如 upload 目錄的 owner），遷移後要調整 <code>chown</code>。</p>
<h3 id="服務管理">服務管理</h3>
<p>現代 OS 都使用 systemd。但如果舊 OS 還有 sysvinit 腳本（<code>/etc/init.d/</code>），遷移時要轉換成 systemd unit file。轉換的核心是把 init 腳本的 start/stop/restart 邏輯對應到 systemd 的 <code>ExecStart</code>、<code>ExecStop</code>、<code>Restart</code> 欄位。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># /etc/systemd/system/myapp.service</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">My Application</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="na">After</span><span class="o">=</span><span class="s">network.target mysql.service</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">simple</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="na">User</span><span class="o">=</span><span class="s">www-data</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/usr/bin/php /var/www/myapp/worker.php</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="na">Restart</span><span class="o">=</span><span class="s">on-failure</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="na">RestartSec</span><span class="o">=</span><span class="s">5</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="k">[Install]</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="na">WantedBy</span><span class="o">=</span><span class="s">multi-user.target</span></span></span></code></pre></div><h2 id="基礎軟體切換apache--nginx">基礎軟體切換（Apache → nginx）</h2>
<p>如果已經在為 OS 遷移建新伺服器，同時切換 web server 是成本最低的時機——反正設定檔要重寫、不如一次到位。分開做的話要拆兩次遷移、測兩次、承受兩次風險。</p>
<h3 id="htaccess--nginx-設定轉換">.htaccess → nginx 設定轉換</h3>
<p>Apache 的 .htaccess 是分散式設定——每個目錄可以有自己的 <code>.htaccess</code>，Apache 在每次請求時逐層讀取。nginx 沒有這個機制，所有設定集中在 <code>/etc/nginx/</code> 的設定檔裡。</p>
<p>轉換的第一步是找出所有 .htaccess 檔案：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">find /var/www/ -name <span class="s2">&#34;.htaccess&#34;</span> -exec <span class="nb">echo</span> <span class="s2">&#34;=== {} ===&#34;</span> <span class="se">\;</span> -exec cat <span class="o">{}</span> <span class="se">\;</span></span></span></code></pre></div><p>常見的轉換對應：</p>
<table>
  <thead>
      <tr>
          <th>Apache .htaccess</th>
          <th>nginx 對應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>RewriteRule ^old$ /new [R=301]</code></td>
          <td><code>rewrite ^/old$ /new permanent;</code></td>
      </tr>
      <tr>
          <td><code>RewriteCond %{HTTPS} off</code> + <code>RewriteRule</code></td>
          <td><code>if ($scheme = http) { return 301 https://...; }</code></td>
      </tr>
      <tr>
          <td><code>Options -Indexes</code></td>
          <td><code>autoindex off;</code>（通常是預設）</td>
      </tr>
      <tr>
          <td><code>php_flag engine off</code></td>
          <td><code>location /uploads/ { deny all; }</code> 或不傳給 PHP</td>
      </tr>
      <tr>
          <td><code>&lt;Files .env&gt;</code> + <code>Deny from all</code></td>
          <td><code>location ~ /\.env { deny all; }</code></td>
      </tr>
      <tr>
          <td><code>AuthType Basic</code> + <code>.htpasswd</code></td>
          <td><code>auth_basic</code> + <code>auth_basic_user_file</code></td>
      </tr>
  </tbody>
</table>
<h3 id="平行測試">平行測試</h3>
<p>在新伺服器上同時安裝 nginx（port 80）和 Apache（port 8080）。用 curl 比對兩者的回應：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 比對首頁</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">diff &lt;<span class="o">(</span>curl -s http://new-server/<span class="o">)</span> &lt;<span class="o">(</span>curl -s http://new-server:8080/<span class="o">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 比對一個有 rewrite 規則的 URL</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">diff &lt;<span class="o">(</span>curl -sI http://new-server/old-path<span class="o">)</span> &lt;<span class="o">(</span>curl -sI http://new-server:8080/old-path<span class="o">)</span></span></span></code></pre></div><p>回應一致後再把 Apache 移除。重點比對項：HTTP status code（rewrite 的 301/302）、response body（PHP 輸出）、response header（cache control、security header）。</p>
<h3 id="常見陷阱">常見陷阱</h3>
<p>.htaccess 的分散式設定在 WordPress 或其他 CMS 中常被用來動態控制 URL rewrite。WordPress 的 permalink 功能依賴根目錄的 <code>.htaccess</code>，切到 nginx 需要在設定檔裡加 <code>try_files $uri $uri/ /index.php?$args;</code> 才能讓 permalink 運作。其他 CMS（Drupal、Laravel）也有各自的 nginx 設定範例，通常在官方文件裡可以找到。</p>
<h2 id="時程與管理層溝通">時程與管理層溝通</h2>
<p>OS 遷移（平行建置）的時程取決於應用層的複雜度：</p>
<table>
  <thead>
      <tr>
          <th>環境複雜度</th>
          <th>時程估算</th>
          <th>典型特徵</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>簡單</td>
          <td>1-2 週</td>
          <td>單一 web app、標準 LAMP/LEMP stack</td>
      </tr>
      <tr>
          <td>中等</td>
          <td>2-3 週</td>
          <td>多個服務、自訂套件、cron 密集</td>
      </tr>
      <tr>
          <td>複雜</td>
          <td>3-4 週</td>
          <td>多台伺服器、叢集、自建 daemon</td>
      </tr>
  </tbody>
</table>
<p>跟管理層溝通時用三個框架：</p>
<p><strong>為什麼現在做</strong>：「目前的 OS 已經停止安全修補，每個月不遷移等於多一個月的曝險窗口。如果有合規要求（PCI DSS / SOC 2），下次稽核會被標記。」</p>
<p><strong>做什麼</strong>：「在旁邊建一台新 OS 的伺服器，把應用搬過去、驗證通過後切換。舊伺服器保留一到兩週作為 rollback。」</p>
<p><strong>花多久和多少錢</strong>：「工程師時間 1-3 週（依複雜度）。多一台伺服器的費用只有切換期間的短期成本。不做的隱藏成本是安全事故的潛在損失和合規罰款。」</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/upgrade/upgrade-framework/" data-link-title="升級的共通操作框架" data-link-desc="任何環境或系統升級的四階段模型：差異評估、平行環境驗證、分批切換、退役舊環境，以及貫穿全程的升級紀律">升級的共通操作框架</a>：四階段模型（評估差異 → 平行環境 → 分批切換 → 退役）</li>
<li>→ <a href="/blog/infra/upgrade/platform-migration/" data-link-title="平台遷移" data-link-desc="FTP 面板主機到 VPS、VPS 到雲端、地端到雲端的遷移路徑 — 資料同步策略、DNS 切換、驗證與回退">平台遷移</a>：如果 OS 遷移同時伴隨平台搬遷（地端 → 雲端）</li>
<li>→ <a href="/blog/infra/upgrade/runtime-version-upgrade/" data-link-title="Runtime 版本升級" data-link-desc="PHP / Node.js / Python 大版本升級的相容性評估、本地驗證、分批部署策略與常見陷阱">Runtime 版本升級</a>：PHP / Node 版本升級常伴隨 OS 遷移</li>
<li>→ <a href="/blog/infra/takeover/" data-link-title="接手維運：別人建的環境怎麼接管" data-link-desc="接手前人的專案時，怎麼在不搞壞東西的前提下盤點現況、建立維運能力、逐步正規化 — 從無 SSH 的 FTP 環境到有半套 IaC 的雲端環境都適用">接手維運</a>：接手一個 EOL OS 的環境後的下一步</li>
</ul>
]]></content:encoded></item></channel></rss>