十年 Legacy 專案導入 Git 工作流(四):可回滾的部署,與一場必須演練的回滾


系列文章:這是「十年 Legacy 專案導入 Git 工作流與 CI/CD」四篇的最後一篇。 (一)診斷與分支模型 · (二)CI 第一階段:零誤報 · (三)SQL Migration 版本化 · (四)部署、回滾與 E2E ← 本篇

第一篇的診斷裡,部署那條是這樣寫的:

部署是孤兒腳本:從某一台機器把檔案同步推到自動擴展群組裡的所有機器。 沒有版本、無法回滾,而且擴容出來的新機拿的是映像檔裡的舊碼

三個問題,最後那個最危險——因為它不會在部署當下出事,而是在某天流量上升、系統自動擴容出一台機器的時候出事。那台新機跑著幾個月前的程式碼,對著今天的資料庫。

這篇講怎麼把它改成:上線 = 推一個 tag;出事 = 跑一支腳本在 5 分鐘內回到上一版,而且這件事真的演練過。


第一步:讓系統會自報版本

在做任何部署改造之前,先讓每一台機器能回答「你現在跑哪一版」。

<?php
// 不需登入、不吐敏感資訊;DB 設定沿用專案既有的設定檔,不要另寫帳密
require_once __DIR__ . '/config.php';

header('Content-Type: application/json; charset=utf-8');
$ver = @trim(@file_get_contents(__DIR__ . '/VERSION'));
$ok  = true;
try {
    $pdo = new PDO(DSN, DB_USER, DB_PASS, [PDO::ATTR_TIMEOUT => 3]);
    $pdo->query('SELECT 1');
} catch (Throwable $e) {
    $ok = false;
}
http_response_code($ok ? 200 : 503);
echo json_encode(['ok' => $ok, 'version' => $ver ?: 'unknown']);

VERSION 檔由部署腳本寫入,內容是 tag 或 commit sha。

這支 30 行的檔案解決了三件事:

  1. 擴容出來的新機是不是拿到舊碼,一個 curl 就看得出來——原本這件事根本無從得知。
  2. 部署腳本有了可以驗證的回饋(回 200 才算成功)。
  3. 回滾演練有了可以計時的判準。

兩個設計細節值得注意:DB 設定沿用既有設定檔,不要另寫帳密(否則你會有兩份會不同步的憑證);DB 掛掉時回 503 而不是 200,讓負載平衡器與監控自動反應。


舊腳本的三個 bug:都是「看起來有做,其實沒生效」

改寫之前先讀了原本那支 rsync 腳本,找到三個問題。三個都不會報錯。

sudo 只作用在 echo

sudo echo "..." >> /etc/hosts

這行的 sudo 只提升了 echo 的權限,重導向 >> 仍然是原使用者的權限——所以這行常常根本沒寫進去。

正確寫法:

echo "..." | sudo tee -a /etc/hosts

通用教訓sudo cmd > file 從來不是你以為的意思。重導向由 shell 執行,不在 sudo 的管轄範圍內。

② 產出的檔名跟後續處理的檔名對不上

腳本產出的是 ~/.host,後面卻對 ~/.host.inssed——那段等於沒作用

這和系列第三篇裡「產生 migratelog.sqlrm -f log.sql」是同一類錯誤。檔名寫死在多個地方,就一定會有對不上的一天。

③ 部署目標清單來自 grep /etc/hosts

主機一變就漂。應該直接用雲端 API 查自動擴展群組的私有 IP:

ips=$(aws ec2 describe-instances --region "$REGION" \
  --filters "Name=tag:aws:autoscaling:groupName,Values=$ASG" \
            "Name=instance-state-name,Values=running" \
  --query 'Reservations[].Instances[].PrivateIpAddress' --output text)

通用教訓部署目標要從「當下真實狀態」查,不要從「某份維護中的清單」讀。 清單一定會過期,而過期的症狀是「有台機器沒更新到」——同樣不會報錯。


/var/www/html/
├── releases/
│   ├── v20260916/AppSystem/
│   └── v20260923/AppSystem/
└── current -> releases/v20260923

nginx 設定改一次就好:

root /var/www/html/current/AppSystem;

只保留最近 5 版:

ls -1dt /var/www/html/releases/* | tail -n +6 | xargs -r rm -rf

改造後的部署腳本:

#!/usr/bin/env bash
set -euo pipefail
TAG="${1:?用法: deploy.sh <tag>}"
ASG="AppSystemAMI"; REGION="ap-northeast-2"; DEST="/var/www/html"

echo "$TAG" > AppSystem/VERSION

ips=$(aws ec2 describe-instances --region "$REGION" \
  --filters "Name=tag:aws:autoscaling:groupName,Values=$ASG" \
            "Name=instance-state-name,Values=running" \
  --query 'Reservations[].Instances[].PrivateIpAddress' --output text)

for ip in $ips; do
  echo "→ $ip"
  rsync -az --delete-after --exclude '.git' \
    ./AppSystem/ "ubuntu@$ip:$DEST/releases/$TAG/AppSystem/"

  # 原子切換 symlink,再 reload php-fpm(缺一不可)
  ssh "ubuntu@$ip" "
    ln -sfn $DEST/releases/$TAG $DEST/current.tmp &&
    mv -Tf $DEST/current.tmp $DEST/current &&
    sudo systemctl reload php7.4-fpm
  "

  code=$(curl -s -o /dev/null -w '%{http_code}' "http://$ip/healthz.php")
  [ "$code" = "200" ] || { echo "健康檢查失敗($ip$code),中止"; exit 1; }
done

三個非知道不可的細節

ln -sfn 直接指向已存在的目錄時,會鑽進去建連結。

這是 symlink 部署最陰險的坑。current 已經是一個指向目錄的連結時,ln -sfn new current 不會替換它,而是在 current/ 裡面建一個叫 new 的連結。

所以要先建 current.tmpmv -Tf 覆蓋——這樣切換是原子的,沒有半秒空窗-T 的意思是「把目標當成一般檔案處理,不要當目錄」。

② 切完一定要 reload php-fpm

PHP opcache 記的是解析後的真實路徑,不 reload 會繼續跑舊碼。

這就是 symlink 部署最經典的「明明換了卻沒生效」。你 ls -l 看 symlink 確實指向新版本,但網站還是舊的——因為 opcache 快取的是解析後的絕對路徑,它根本不知道 symlink 換了。

③ nginx 的 root 要一次性改成 current 路徑,之後每次部署都只是換 symlink。


QA 自動部署

合進 release/* 就自動部署,不需要人工按鈕:

name: Deploy QA
on:
  push:
    branches: [ 'release/*' ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: 準備 SSH 金鑰
        run: |
          mkdir -p ~/.ssh && chmod 700 ~/.ssh
          echo "${{ secrets.QA_SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          ssh-keyscan -H "${{ secrets.QA_HOST }}" >> ~/.ssh/known_hosts
      - name: 同步程式碼
        run: |
          rsync -az --delete-after --exclude '.git' --exclude 'sql/' \
            ./AppSystem/ "${{ secrets.QA_USER }}@${{ secrets.QA_HOST }}:/var/www/html/AppSystem/"
      - name: 健康檢查(最多重試 5 次)
        run: |
          for i in $(seq 1 5); do
            code=$(curl -s -o /dev/null -w '%{http_code}' "${{ secrets.QA_URL }}/healthz.php")
            [ "$code" = "200" ] && exit 0
            sleep 5
          done
          echo "::error::QA 健康檢查失敗"; exit 1

部署金鑰的原則:只授權這台、只能寫那個目錄。部署用的憑證是攻擊者最想要的東西,它的權限應該窄到即使外洩也只能做這一件事。

QA 環境還有一個常被忽略的要求:資料要是正式機的去識別化副本。用假資料測不出真問題——例如某家機構獨有的資料組合、某個罕見的欄位值分布。


Production:用權限當核可閘門

這個 Git 平台沒有「required reviewers 才能部署」的環境機制,所以改用權限:

Protected Tags:加一條規則 v*,Allowed users 只放 Tech Lead。 能不能上線 = 能不能推 tag,權限本身就是核可。

workflow 用 on: push: tags: [ 'v*' ] 觸發。

這是我很欣賞的一種務實作法:當工具缺少某個功能時,先看看能不能用既有的權限模型表達同一件事,而不是急著換工具或自己造一套核可系統。

醫療系統不做全自動 prod 部署——合進 main 並打 tag 之後,仍需要人工 approve 才觸發。


回滾腳本

#!/usr/bin/env bash
set -euo pipefail
TARGET="${1:?用法: rollback.sh <tag>}"
D=/var/www/html
[ -d "$D/releases/$TARGET" ] || { echo "$TARGET 不存在"; exit 1; }

ln -sfn "$D/releases/$TARGET" "$D/current.tmp"
mv -Tf "$D/current.tmp" "$D/current"
sudo systemctl reload php7.4-fpm

curl -sf localhost/healthz.php > /dev/null \
  || { echo "回滾後健康檢查仍失敗,請人工介入"; exit 1; }
echo "已回滾到 $TARGET"

因為版本目錄結構已經就位,回滾就只是把 symlink 指回去,秒級完成

注意最後那個健康檢查:回滾之後也要驗證。如果回滾完還是紅的,代表問題不在程式碼版本(可能是資料庫、可能是外部服務),這時候需要人介入,而不是繼續自動化。

但資料庫不會跟著回滾

這是整篇最重要的一句話。

程式碼可以秒回上一版,資料庫不會。所以 migration 必須遵守一條紀律:

一律「只加不刪」。 加欄位可以;刪欄位/改型別要拆成兩次上線——這一版讓程式不再用它,下一版才真的刪。 否則回滾後的舊程式會找不到欄位,比原本的問題更慘

這條紀律的完整形狀是這樣:

動作安全嗎說明
加欄位(有預設值)舊程式不知道它存在,不受影響
加欄位(NOT NULL 無預設)換程式碼之前,舊程式對這張表的寫入會失敗
刪欄位⚠️ 拆兩次第一次上線讓程式不再讀寫它,第二次才 DROP
改型別/改欄位名⚠️ 拆兩次等同「加新的 + 搬資料 + 刪舊的」

「可回滾」不是部署腳本單方面的性質,它是部署腳本與 migration 紀律共同維持的性質。 只做前者會給你一種虛假的安全感——腳本跑得很順,回滾完系統照樣壞。


這兩週真正的產出:一場演練

這是我覺得整份規劃裡最有見地的安排。第 11–12 週的產出不是那支回滾腳本,是一份演練紀錄

在 QA 照表操課並計時,結果寫進 doc/runbook-rollback.md

  1. 記下目前版本:curl QA/healthz.php
  2. 故意部署一個壞版本(例如首頁加 die('boom')
  3. 確認 health check 紅燈、告警有發出來
  4. 執行 bin/rollback.sh <上一版 tag>
  5. 確認站台恢復,且 healthz 的 version 回到上一版
  6. 記錄總耗時,目標 < 5 分鐘;超過就簡化步驟再演練一次

每一步都值得說:

  • 第 2 步「故意部署壞版本」——不製造真實故障,你驗證的只是「腳本語法沒錯」,不是「這套機制能救你」。
  • 第 3 步「確認告警有發出來」——這是最常被跳過的一步。無數團隊在真實故障時才發現告警規則寫錯、或收件人是離職同事。
  • 第 6 步「超過 5 分鐘就簡化步驟再演練」——把演練當成設計回饋,不是驗收儀式。太慢就代表流程本身要改,不是操作的人要練熟一點。

回滾能力不是寫出來的,是演練出來的。 一支從沒在壓力下跑過的回滾腳本,在真正需要它的那天,你不會有勇氣按下去。


E2E Smoke:五條就好

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests/smoke',
  timeout: 60_000,
  retries: 1,
  use: {
    baseURL: process.env.SMOKE_BASE_URL ?? 'http://localhost',
    screenshot: 'only-on-failure',
    trace: 'retain-on-failure',
  },
});
import { test, expect } from '@playwright/test';

test('登入後看得到住民清單', async ({ page }) => {
  await page.goto('/index.php');
  await page.fill('input[name="account"]',  process.env.SMOKE_USER!);
  await page.fill('input[name="password"]', process.env.SMOKE_PASS!);
  await page.click('button[type="submit"]');
  await expect(page).toHaveURL(/module_a/);
  await expect(page.locator('table')).toBeVisible();
});

五條關鍵路徑:登入、住民清單、一張表單存檔、一份列印、一次匯出。

只在 PR base 是 release 分支時才跑,避免拖慢每次 push:

smoke:
  if: startsWith(gitea.base_ref, 'release/')
  runs-on: ubuntu-latest
  container:
    image: mcr.microsoft.com/playwright:v1.56.1-jammy
  steps:
    - uses: actions/checkout@v4
    - run: npm ci
    - run: npx playwright test
      env:
        SMOKE_BASE_URL: ${{ secrets.QA_URL }}
        SMOKE_USER: ${{ secrets.SMOKE_USER }}
        SMOKE_PASS: ${{ secrets.SMOKE_PASS }}

兩個實務卡點:

  • npm ci 需要 lock 檔。這個專案的 package.json 只有兩個 dependency、沒有 lock 檔也沒有 scripts,npm ci 會直接失敗。先在本機跑 npm install 產出 package-lock.json 並提交。
  • 測試帳密走 secrets,且只給 QA 環境的測試帳號

screenshot: 'only-on-failure'trace: 'retain-on-failure' 這兩個設定不要省——E2E 失敗時最痛苦的是「在 CI 上紅、在本機重現不出來」,截圖與 trace 是唯一的線索。


完整驗收

  • 完整走一次「推 tag → migration 全租戶跑完 → 換碼 → health check 綠」
  • 回滾演練紀錄 < 5 分鐘
  • 5 條 smoke 在 release PR 上跑綠,且故意改壞一條路徑時會紅

最後那個「故意改壞要會紅」又出現了——這是整個系列反覆出現的主題。


系列小結:五個貫穿四篇的原則

寫完四篇回頭看,這份 12 週規劃真正的價值不在任何一段設定檔,而在幾個反覆出現的判斷方式:

一、先量再改。 六個數字花不到半天掃出來,直接砍掉三場關於分支模型的會議,並指出真正的頭號風險是 migration 而不是大家想討論的那個。

二、把「歷史問題」和「這次的問題」分開。 CI 只檢查 diff、PHPStan 用 baseline、SQL 用回填 baseline——同一個手法在三個地方出現。這是 Legacy 專案導入任何自動化的通用解法。

三、驗證你的檢查會失敗。 故意寫壞的 PR、故意不冪等的 SQL、故意部署壞版本、故意改壞一條 smoke——四篇裡出現四次。從沒紅過的檢查等於沒有檢查。

四、讓正確做法成為預設。 安全選項不是預設就等於不存在;豁免出口要明確,否則大家會用更糟的方式繞過;合併後自動刪分支,不要靠人記得。

五、前兩週不要改變任何人的日常。 導入失敗最常見的原因是團隊在第一週就感受到成本、卻還沒感受到好處。

還有一句總結,適用於這四篇裡幾乎每一個 bug:

這些問題沒有一個是「不知道正確寫法」造成的。 寫腳本的人知道要防重跑、知道要記版本、知道 sudo 要怎麼用。 問題出在沒有一個機制去驗證這些設計真的生效。

程式碼會說謊——它印出「already applied」然後照跑不誤,它 sudo 了但沒寫進去,它換了 symlink 但跑的還是舊碼。只有可執行的驗收會說實話。


相關文章: