1. 引言: 是時候來動手編碼了
大家好!在第一篇中,我們探討了構建 GitHub Webhook 自動部署系統的原因及所需準備,第二篇則詳細介紹了整體架構及 FastAPI Webhook 服務的核心角色。現在是將腦海中的圖像轉化為實際代碼的時候了。
如果還沒看過前面幾篇的朋友們,可以通過以下鏈接查看。
利用 GitHub Webhook 构建个人自动部署系统
在這第三篇中,我們將完成測試伺服器的基本環境設置,並一起構建能夠安全接收及驗證 GitHub Webhook 請求的 FastAPI Webhook 伺服器的基礎架構。模糊感覺的自動部署系統將更進一步變得真實!
2. 測試伺服器初始環境設置
首先要做的是準備伺服器的環境,以便我們能夠執行代碼。請通過 SSH 登錄伺服器,按照以下步驟進行操作。
2.1. 準備 Python 開發環境
在伺服器上設置 Python 虛擬環境是一個良好的習慣,可以防止項目之間的依賴衝突並保持整潔的開發環境。使用 venv 環境用 pip 安裝所需的包。
# 安裝 FastAPI、Uvicorn 以及讀取 .env 文件所需的 python-dotenv
pip install fastapi uvicorn python-dotenv
2.2. 確認 Git、Docker 和 Docker Compose 的安裝
正如上一篇中提到的,Git、Docker 和 Docker Compose 是自動部署的核心工具。假設在閱讀這篇文章的讀者們已經將這些工具安裝在伺服器上。
如果尚未安裝,請先確認是否安裝:
git --version
docker --version
docker compose version # 或者 docker-compose --version(根據安裝方式而定)
如果未安裝,請參考各工具的官方文檔進行安裝。如果需要 Docker 安裝的詳細指南,可以參考以下文章: Docker 安裝指南
注意: 安裝 Docker 後,必須將當前用戶添加到 Docker 群組中,並在登出後重新登入,以便無需 sudo
就能使用 docker
命令。
sudo usermod -aG docker $USER
請確保在授予 Docker 權限後登出再重新登入,以便變更生效。 使用 docker run hello-world
命令確認是否正常運作且無需 sudo
。
3. 建立 FastAPI Webhook 伺服器基本結構
現在讓我們建立接受 Webhook 請求的 FastAPI 應用的基本架構。在 ~/projects/webhook_server
目錄中創建 main.py
文件並撰寫以下代碼。
3.1. 創建 main.py
文件並初始化 FastAPI 應用
為了方便說明,我將所有邏輯寫在了 main.py
中,但你可以根據實際項目將各個工具函數獨立模組化並進行導入,讓其更適合自己的風格。以下是寫作的範例。
# ~/projects/webhook_server/main.py
import hmac
import hashlib
import os
import logging
import subprocess
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
from dotenv import load_dotenv, dotenv_values
# 加載 .env 文件(用於使用環境變量)
load_dotenv()
# 配置日誌
logging.basicConfig(
level=logging.INFO, # 記錄 INFO 級別以上的日誌
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler("webhook_server.log"), # 將日誌存儲到文件中
logging.StreamHandler() # 並在控制台中輸出日誌
]
)
logger = logging.getLogger(__name__)
app = FastAPI()
# 從 GitHub Webhook Secret 環境變量中加載
GITHUB_WEBHOOK_SECRET = os.getenv("GITHUB_WEBHOOK_SECRET")
# 測試用根端點: 實際運行時應該刪除以提高安全性。
# 一旦公開在互聯網上,各種爬蟲會潛入,所以沒有必要讓人知道這樣的端點存在。
@app.get("/")
async def read_root():
return {"message": "Webhook server is running!"}
# 接收 GitHub Webhook 的端點
@app.post("/webhook")
async def github_webhook(request: Request, background_tasks: BackgroundTasks):
logger.info("Webhook request received.")
# 1. 驗證 GitHub Webhook Secret(第一個安全步驟)
if GITHUB_WEBHOOK_SECRET:
signature = request.headers.get("X-Hub-Signature-256")
if not signature:
logger.warning("Signature header missing. Aborting.")
raise HTTPException(status_code=401, detail="X-Hub-Signature-256 header missing")
body = await request.body()
expected_signature = "sha256=" + hmac.new(
GITHUB_WEBHOOK_SECRET.encode('utf-8'),
body,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected_signature, signature):
logger.warning("Invalid signature. Aborting.")
raise HTTPException(status_code=401, detail="Invalid signature")
else:
logger.info("Signature verified successfully.")
else:
logger.warning("GITHUB_WEBHOOK_SECRET is not set. Skipping signature verification (NOT RECOMMENDED FOR PRODUCTION).")
# 2. 解析 HTTP Body(Payload)
try:
payload = await request.json()
event_type = request.headers.get("X-GitHub-Event")
# 從 GitHub Webhook Payload 中提取倉庫名稱。
# 使用 'name' 代替 'full_name'。
repo_name = payload.get("repository", {}).get("name", "unknown_repository")
ref = payload.get("ref", "unknown_ref")
pusher_name = payload.get("pusher", {}).get("name", "unknown_pusher")
logger.info(f"Event: {event_type}, Repo: {repo_name}, Ref: {ref}, Pusher: {pusher_name}")
# 在背景中執行實際部署邏輯
background_tasks.add_task(handle_deploy, repo_name)
except Exception as e:
logger.error(f"Error parsing webhook payload or processing request: {e}")
raise HTTPException(status_code=400, detail=f"Error processing request: {e}")
# 立即回應 GitHub
return {"message": "Webhook received and processing started in background!"}
# 處理實際部署邏輯的函數
def handle_deploy(repo_name: str):
# 名為 'deployer' 的倉庫是更新 webhook 伺服器自身,所以跳過此步驟
if repo_name == "deployer":
logger.info("⚙️ Deployer self-update skipped.")
return
# 從環境變量中讀取每個倉庫的本地路徑。
# 此字典應該映射你的實際項目名稱和伺服器內的路徑。
# 例如: 環境變量 SAMPLE_PROJECT_1_PATH 中保存該項目的伺服器路徑。
repo_paths = {
"sample_project1": os.getenv("SAMPLE_PROJECT_1_PATH"),
"sample_project2": os.getenv("SAMPLE_PROJECT_2_PATH"),
"sample_project3": os.getenv("SAMPLE_PROJECT_3_PATH"),
}
# 如果倉庫名稱與 Docker Compose 項目的名稱不一致,建議重新映射項目名稱。
# 這樣可以在 Docker Compose 中使用 -p (項目名稱)選項。
# 注意:由於 Docker 的特性,-p 選項中不能使用大寫字母或連字符。
p_name = {
"sample_project1": "p_name_1",
"sample_project2": "p_name_2",
"sample_project3": "p_name_3",
}
# 找到當前 webhook 請求的倉庫本地路徑。
repo_path: Optional[str] = repo_paths.get(repo_name)
# 對於未定義的倉庫,發出警告日誌並退出。
if not repo_path:
logger.warning(f"🚫 Unknown repository: {repo_name}")
return
# 將路徑對象轉換為易於操作的文件系統路徑。
repo_path = Path(repo_path)
# 從該倉庫的 .env 文件中加載 DEBUG 和 COLOR 值。
# 這可以用於區分項目的開發/生產環境,或者動態設置 Docker Compose 項目名稱。
env_path = repo_path / ".env"
env_vars = dotenv_values(env_path) if env_path.exists() else {}
is_debug = env_vars.get("DEBUG", "false").lower() == "true" # 將 "true" 字符串轉換為布爾值
deploy_color = env_vars.get("COLOR", "red") # 默認值為 "red"
project_name = str(deploy_color) + p_name.get(repo_name, repo_name) # Docker Compose 項目名稱
# 根據 DEBUG 值決定將使用的 Docker Compose 文件。
docker_compose_file = "docker-compose.dev.yml" if is_debug else "docker-compose.prod.yml"
# 定義 Docker Compose 文件路徑。
compose_file = repo_path / docker_compose_file
logger.info(f"Pulling latest code for {repo_name} in {repo_path}...")
# 執行 Git Pull:從該倉庫路徑中獲取最新代碼。
# check=True 選項會在命令執行失敗時引發 subprocess.CalledProcessError,幫助進行異常處理。
try:
subprocess.run(["git", "-C", repo_path, "pull"], check=True, capture_output=True, text=True)
logger.info(f"Git pull successful for {repo_name}.")
except subprocess.CalledProcessError as e:
logger.error(f"Git pull failed for {repo_name}: {e.stderr}")
return # 部署失敗時在此退出
logger.info(f"Checking for changes in {repo_name} to decide rebuild...")
# 比較變更: 判斷是否需要重新構建 Docker 映像。
changed = should_rebuild(repo_path)
try:
if changed:
logger.info(f"🚀 Changes detected. Building and deploying {repo_name} with {docker_compose_file}...")
# 如果有變更,使用 --build 選項執行 Docker Compose 以重新構建映像。
subprocess.run(["docker", "compose", "-p", project_name, "-f", compose_file, "up", "-d", "--build"], check=True, capture_output=True, text=True)
logger.info(f"Docker compose build and up successful for {repo_name}.")
else:
logger.info(f"✨ No significant changes. Deploying {repo_name} without rebuilding images using {docker_compose_file}...")
# 如果沒有變更,執行 Docker Compose 不加 --build 選項。
subprocess.run(["docker", "compose", "-p", project_name, "-f", compose_file, "up", "-d"], check=True, capture_output=True, text=True)
logger.info(f"Docker compose up successful for {repo_name}.")
except subprocess.CalledProcessError as e:
logger.error(f"Docker compose failed for {repo_name}: {e.stderr}")
return # 部署失敗時在此退出
logger.info(f"✅ Deployment task for {repo_name} finished.")
# 判斷是否需要重建 Docker 映像的函數
def should_rebuild(repo_path: Path) -> bool:
# 若這些文件發生變更則需要重建 Docker 映像。
trigger_files = [
"requirements.txt", "Dockerfile", "Dockerfile.dev", "Dockerfile.prod",
".dockerignore", ".env" # .env 文件變更也包含在重建觸發器中
]
logger.info(f"Checking git diff for {repo_path}...")
# 使用 Git diff 獲取 HEAD~1(上一個提交)和當前 HEAD 之間變更的文件列表。
# --name-only 選項僅獲取變更文件的名稱。
try:
result = subprocess.run(
["git", "diff", "--name-only", "HEAD~1"],
cwd=repo_path,
capture_output=True, text=True, check=True
)
changed_files_in_git = result.stdout.strip().splitlines()
logger.info(f"Changed files in git: {changed_files_in_git}")
except subprocess.CalledProcessError as e:
logger.error(f"Git diff failed for {repo_path}: {e.stderr}")
# 如果 git diff 失敗,安全起見建議嘗試進行重新構建。
return True
# 檢查變更文件列表中是否包含觸發文件。
if any(f in changed_files_in_git for f in trigger_files):
logger.info("Detected trigger file changes via git diff. Rebuild required.")
return True
# 檢查是否存在手動重建觸發文件(REBUILD_TRIGGER)。
# 若存在,視為強制手動重建並刪除該文件。
rebuild_trigger_file = repo_path / "REBUILD_TRIGGER"
if rebuild_trigger_file.exists():
try:
rebuild_trigger_file.unlink() # 刪除文件
logger.info("Detected REBUILD_TRIGGER file. Rebuild required and trigger file removed.")
return True
except Exception as e:
logger.error(f"Error removing REBUILD_TRIGGER file: {e}")
# 若刪除文件失敗,安全起見也是要嘗試進行重新構建。
return True
logger.info("No rebuild trigger files found. No rebuild required.")
return False
3.2. 定義 /webhook
端點
在上面的代碼中,app.post("/webhook")
裝飾器下定義的異步函數 github_webhook
就是接收 GitHub Webhook 請求的端點。這個函數負責處理進來的 POST
請求,並通過 Request
對象訪問標頭和主體(Payload)。BackgroundTasks
是 FastAPI 強大的功能,可以在立即響應 GitHub 的同時在背景運行像部署這樣的耗時任務。
4. 實現 GitHub Webhook Secret 驗證邏輯
在上面的代碼中,最重要的安全要素是使用 GITHUB_WEBHOOK_SECRET
進行驗證的邏輯。
4.1. Secret
值的重要性
設置 GitHub Webhook 時,可以設置 Secret
值。此值是 GitHub 在發送 Webhook 請求時,將請求主體和 Secret 值組合生成哈希值(Hash),並將其包裝在 X-Hub-Signature-256
標頭中發送。我們的 Webhook 伺服器也以相同的方式生成哈希值,然後將其與 GitHub 發送的哈希值進行比較,以確認請求的確是來自 GitHub,並且數據在傳輸過程中沒有被篡改。
4.2. 使用 X-Hub-Signature-256
標頭進行驗證
如你在代碼中所見,使用 hmac
和 hashlib
模塊來執行 Secret 驗證。
- 設置 Secret: 首先,在
~/projects/webhook_server
目錄中創建.env
文件,並在其中記錄將在 GitHub Webhook 設置時使用的 Secret 值。注意此文件應添加到.gitignore
中以避免上傳至 Git 倉庫。此 Secret 值稍後將在 GitHub 倉庫的 Webhook 設置中使用。
# ~/projects/webhook_server/.env
GITHUB_WEBHOOK_SECRET="請在此輸入 GitHub Webhook 設置時使用的 Secret 值"
- FastAPI 代碼: 在
main.py
中,我們使用python-dotenv
库加载此.env
文件,并用os.getenv()
獲取GITHUB_WEBHOOK_SECRET
的值。隨後,每當 Webhook 請求進來時,我們將通過Request.headers.get("X-Hub-Signature-256")
獲取簽名值,並使用hmac.new()
函數計算預期的簽名值以進行比較。
這段驗證邏輯是針對 Webhook 端點最 必要的防護措施,請務必不要跳過!
5. 配置日誌系統
在代碼中,我們使用 logging
模塊進行了基本的日誌配置。
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler("webhook_server.log"), # 保存到 webhook_server.log 文件中
logging.StreamHandler() # 同時輸出到控制台
]
)
logger = logging.getLogger(__name__)
這樣設置後,每次 Webhook 請求到來時,信息將會記錄到 webhook_server.log
文件中,且同時在終端顯示。當部署邏輯更為複雜時,這個日誌功能將能讓我們輕鬆識別問題發生在哪個步驟。
6. 使用 Uvicorn 運行 Webhook 伺服器並進行本地測試
現在讓我們簡單測試一下編寫的 FastAPI Webhook 伺服器是否正常運行。在測試伺服器的 ~/projects/webhook_server
目錄中,啟用虛擬環境並運行以下命令。
# 確認虛擬環境是否啟用(檢查前面是否有 (venv))
# source venv/bin/activate # 如果未啟用請再次運行此命令
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
--reload 選項會在開發過程中自動重新啟動伺服器,方便進行調試。
訪問瀏覽器 127.0.0.1:8000/docs,確認 API 文檔是否正確生成。如果文檔生成,則應用正在正常運行。如果你知道伺服器的 IP 地址(例如 192.168.1.100
),則可以使用以下 curl
命令發送測試請求。
```bash
將伺服器的 IP 地址替換為 'YOUR_SERVER_IP'。
'YOUR_WEBHOOK_SECRET' 替換為 .env 文件的實際值。
包含一個簡單的虛擬 JSON 數據。
curl -X POST http://YOUR_SERVER_IP:8000/webhook \ -H "Content-Type: application/json" \ -H "X-GitHub-Event: push" \ -H "X-Hub-Signature-256: sha256=$(echo -n '{"repository":{"name":"your-repo"},"ref":"refs/heads/main","pusher":{"name":"test-user"}}' | openssl dgst -sha256 -hmac 'YOUR_WEBHOOK_SECRET' | awk '{print $2}')" \ -d '{"repository":{"name":"your-repo"},"ref":"refs/heads/main","pusher":{"name":"test-user"}}'
```
在終端顯示 Webhook received and processing started in background!
訊息與日誌並確認是否記錄到伺服器日誌文件 (webhook_server.log
) 中。若發生錯誤,可以通過終端的錯誤消息進行調試。
7. 總結: 期待下篇
在這篇文章中,我們完成了測試伺服器的環境搭建,並構建了能安全接收 GitHub Webhook 請求並驗證 Secret 的 FastAPI Webhook 伺服器的基本架構。現在 Webhook 伺服器已準備好安全接收外部請求。
在下一篇中,我們將在今天創建的基礎上,使用 subprocess
模塊實現實際的部署邏輯(Git Pull、判斷 Docker 映像的重建、執行 Docker Compose),並詳細討論如何將此 FastAPI Webhook 伺服器註冊為Systemd 服務,以便於伺服器重啟時自動運行。敬請期待!
目前沒有評論。