Git 部署方案完全解析:從 post-receive Hook 狂想曲到現代自動化發布架構

在早期網站建置與運維的工作流程中,FTP / SFTP 曾是無數開發者發布程式碼的唯一手段。

然而,手動拖曳檔案上傳存在著致命的工程隱患:

  1. 人為失誤與檔案遺漏:常常忘記到底修改了哪些檔案,漏傳一隻設定檔或樣式表便直接導致線上環境崩潰。
  2. 缺乏版本追蹤與原子性:上傳過程中若網路中斷,伺服器便會處於新舊檔案混雜的「半生不熟」狀態;且一旦出錯,無法一鍵回滾(Rollback)。
  3. 多人協作衝突覆蓋:團隊成員之間互相覆蓋彼此剛上傳的檔案,造成難以追查的詭異 Bug。

隨著版本控制工具 Git 的全面普及,一個自然而然的革命性構想浮現了:「如果我們每次在終端機打 git push 時,伺服器就能自動完成檔案更新與部署,該有多好?」

這就是經典的 Git Push 部署方案(Git-driven Deployment)。


一、核心原理:Git Hooks 與裸儲存庫(Bare Repository)

Git 不僅僅是一個代碼歷史庫,更是一個事件驅動的自動化系統。Git 提供了多種在特定生命週期觸發的腳本機制——Git Hooks。

其中,最適合負責部署任務的便是運行在伺服器端的 post-receive hook:當遠端儲存庫成功接收並寫入所有被 push 的 commits 之後,該腳本會被自動觸發執行。

flowchart LR
    subgraph LocalDev ["本地開發環境 (Local Machine)"]
        WorkTree["工作目錄 (Working Tree)"] -- "git push prod-server main" --> SSH["SSH 加密通道"]
    end

    subgraph RemoteServer ["遠端伺服器 (Production Server)"]
        direction TB
        BareRepo["Git 裸儲存庫 (Bare Repository)<br/>/var/repo/site.git"]
        Hook["post-receive Hook 腳本"]
        WebRoot["網站公開根目錄 (Web Root)<br/>/var/www/site"]

        BareRepo -->|"接收完成觸發"| Hook
        Hook -->|"git --work-tree 檢出覆蓋"| WebRoot
    end

    SSH --> BareRepo

二、推薦實戰架構:裸儲存庫與網站根目錄分離

在早期的粗糙做法中,有人會直接在 /var/www/site 執行 git init,並設定 receive.denyCurrentBranch ignore。但這種做法存在重大資安風險:如果不小心讓 Nginx / Apache 存取到了 .git 目錄,駭客就能下載你的整個源碼庫與歷史記錄!

最標準且安全的架構,是將 裸儲存庫(Bare Repo,僅存 Git 物件,沒有工作目錄) 與 網站根目錄(Web Root) 實體分開:

1. 伺服器端環境設定

假設伺服器配置如下:

  • 裸儲存庫路徑:/var/repo/project.git
  • 網站公開目錄:/var/www/production.com
# 1. 建立裸儲存庫(Bare Repository)
sudo mkdir -p /var/repo/project.git
cd /var/repo/project.git
sudo git init --bare

# 2. 建立網站實際運行的根目錄
sudo mkdir -p /var/www/production.com

# 3. 確保部署使用者(如 deploy 或 www-data)擁有這兩個目錄的讀寫權限
sudo chown -R deploy:deploy /var/repo/project.git
sudo chown -R deploy:deploy /var/www/production.com

2. 編寫 post-receive Hook 腳本

進入裸儲存庫的 hooks 目錄,建立並編輯 post-receive:

cat << 'EOF' > /var/repo/project.git/hooks/post-receive
#!/usr/bin/env bash
set -e

# 定義目標目錄與裸儲存庫路徑
TARGET="/var/www/production.com"
GIT_DIR="/var/repo/project.git"
BRANCH="main"

echo "==== 收到 Git Push,開始自動部署 ===="

# 讀取 push 的分支資訊
while read oldrev newrev ref
do
    # 僅在 push 到目標分支 (如 main) 時才觸發部署
    if [[ "$ref" =~ .*/$BRANCH$ ]]; then
        echo "正在將分支 $BRANCH 檢出至 $TARGET ..."
        git --work-tree="$TARGET" --git-dir="$GIT_DIR" checkout -f "$BRANCH"
        
        # 執行後續建置與服務重啟任務 (可選)
        cd "$TARGET"
        # composer install --no-dev --optimize-autoloader
        # npm install --production && npm run build
        # sudo systemctl reload php-fpm
        
        echo "==== 部署成功完成! ===="
    else
        echo "非 $BRANCH 分支,跳過部署。"
    fi
done
EOF

# 賦予執行權限(關鍵步驟!)
chmod +x /var/repo/project.git/hooks/post-receive

3. 本地端關聯與一鍵發布

在開發者的本地專案目錄中,將伺服器添加為新的 Git Remote:

# 添加名為 prod 的遠端主機
git remote add prod [email protected]:/var/repo/project.git

# 往後只要一行指令,即可一鍵完成代碼發布!
git push prod main

終端機會即時回傳伺服器端 post-receive 的執行日誌,你可以清楚看見檢出進度與服務重啟的狀態。


三、部署架構的十年演化史

從 2013 年純粹的 post-receive 腳本,到今天的現代雲原生部署,這十年間前端與後端部署經歷了四個世代的演變:

flowchart TD
    G1["第一代:手動 FTP / SFTP<br/>• 人工手動拖曳檔案<br/>• 零版本追蹤、極易遺漏出錯"]
    G2["第二代:Git Push Hook<br/>• 利用 post-receive 自動檢出<br/>• 具備版本控制,但直接覆蓋線上目錄"]
    G3["第三代:原子化軟連結 (Zero-Downtime Symlink)<br/>• Capistrano / Deployer 模式<br/>• releases/ + current 軟連結切換,秒級回滾"]
    G4["第四代:現代 CI/CD 與容器化 (GitOps)<br/>• GitHub Actions / Docker / K8s<br/>• 獨立建置測試、產物鏡像不可變部署"]

    G1 --> G2 --> G3 --> G4

1. 為什麼單純的 git checkout -f 在複雜專案中不夠用?

當專案規模變大,包含依賴安裝(composer install / npm install)、編譯建置(Webpack / Vite 打包)或資料庫遷移(Migration)時,直接在運行的目錄上覆蓋檔案會帶來兩大痛點:

  • 瞬時服務中斷(Downtime):在執行 npm run build 的數十秒內,訪客可能讀到編譯一半的 JS 檔案,導致白屏。
  • 建置失敗無路可退:如果建置過程中記憶體不足拋錯,線上目錄已經被覆寫,很難瞬間還原。

2. 解決之道:原子化軟連結發布(Zero-Downtime Releases)

為了解決這個問題,以 Capistrano、PHP 的 Deployer 為代表的「原子發布模式」應運而生(許多現代靜態網站與本部落格的部署腳本也採用相同架構):

/var/www/site/
├── current -> /var/www/site/releases/20261104120000  (指向最新版本的軟連結)
├── releases/
│   ├── 20261104110000/                              (前一個版本)
│   └── 20261104120000/                              (本次新發布版本)
└── shared/                                          (跨版本共享的持久目錄)
    ├── .env
    └── storage/

部署腳本會先在 releases/<timestamp> 建立全新目錄、檢出代碼、完成所有編譯與測試;一切確認無誤後,以一條極速且具備原子性(Atomic)的指令切換軟連結:

ln -sfn /var/www/site/releases/20261104120000 /var/www/site/current

若新版有致命問題,只要將 current 重新連結回上一版本的目錄,便能在 0.1 秒內完成無損回滾(Rollback)。


四、生產環境的五大安全與維運守則

  1. 嚴防 .git 目錄洩漏: 若使用非裸倉庫模式,務必在 Nginx 中封鎖存取:
    location ~ /\.git {
        deny all;
        return 404;
    }
  2. 避免以 root 執行部署: 建立專用的低權限使用者(例如 deploy),並透過 SSH Key(~/.ssh/authorized_keys)進行免密碼登入,關閉密碼認證。
  3. 機密檔案(.env)絕對不可推進 Git: 敏感的 API Token、資料庫密碼應保存在伺服器本機的共享目錄(如 shared/.env),部署腳本在發布時再透過軟連結引入。
  4. 清理歷史 Releases 版本: 每次發布都會保留完整歷史目錄,務必在部署腳本中加入清理機制,僅保留最近 3 到 5 個版本(ls -dt releases/* | tail -n +6 | xargs rm -rf),避免硬碟空間被撐爆。
  5. 現代演進:與 GitHub Actions 結合: 在現代工作流中,git push 通常推往 GitHub / GitLab,由 CI/CD 執行測試與打包後,再透過 SSH/Rsync 自動將產物部署至伺服器。這種分離讓生產伺服器不需要安裝複雜的編譯工具鏈,更加安全輕量。

結語

從 2013 年初探 git push 與 post-receive 的興奮感,到如今成為每位工程師必備的 CI/CD 直覺,Git 徹底改變了軟體交付的面貌。

無論未來的工具鏈如何更迭(從 Docker 容器到 Serverless),「以版本控制為單一事實來源(Single Source of Truth)、以自動化腳本驅動部署、以無中斷機制守護生產環境」 的核心哲學將永遠歷久彌新。