Web Day 32 CI/CD:GitHub Actions 測試與自動部署
執行需求:CPU 可跑。今天是上線章節的第二天,我們要把 Day 16 到 Day 19 學過的測試、Day 30 寫好的 Dockerfile、Day 31 設好的 PostgreSQL,全部串到 GitHub Actions 上。整篇會做三件事:第一,用 uv 0.7 在 GitHub 提供的免費 runner 上跑 pytest;第二,用 docker buildx 多平台建置 image 並推到 GitHub Container Registry;第三,當 main 分支有新的 tag 時自動部署到示範主機。今天所有的工作流程檔(YAML)都能直接複製貼上,只需要把佔位符換成你的 repo 名稱就能用。
引言
寫完測試、寫好 Dockerfile、設好 docker-compose.yml 之後,下一個問題是「怎麼讓這些事情在每次 push 程式碼時自動跑」。CI(Continuous Integration,持續整合)負責「程式碼進來時跑測試、建 image、確認沒壞」,CD(Continuous Deployment,持續部署)負責「測試通過後把程式碼推到正式環境」。GitHub Actions 是 GitHub 內建的 CI/CD 服務,對公開 repo 完全免費,私有 repo 每月有 2000 分鐘的額度,個人或小團隊做 side project 幾乎用不完。
今天不會把整套部署流程寫到「按下按鈕就部署正式機」的完整程度——那需要搭配雲端服務(Hetzner、Fly.io、自架 k8s 等),屬於 Day 41 之後的範疇。今天先把 CI 與 image 推送做好,並寫一份半自動的 CD 流程:tag 觸發後自動 build image、推到 GHCR,最後用 SSH 在你自己的示範主機上 `docker compose pull && docker compose up -d`。這個模式對獨立開發者與小團隊非常實用,後續要換成任何雲端服務都只要改最後一段 SSH 指令。
GitHub Actions 的基本觀念
GitHub Actions 用 YAML 設定「工作流程(workflow)」,檔案放在 `.github/workflows/` 目錄下,每個檔案代表一個獨立的工作流程。一個 workflow 由多個 job 組成,每個 job 由多個 step 組成,每個 step 是一個 shell 指令或預先打包好的 action。
工作流程的核心觀念是「事件觸發」:當 push、pull_request、release、schedule 等事件發生時,GitHub 會派出 runner(執行個體)來跑 workflow。公開 repo 用 GitHub 託管的 ubuntu-latest runner,每分鐘消耗 1 單位;私有 repo 免費額度內則不收費。本系列所有 workflow 都用 ubuntu-latest,因為後面要跑 docker buildx,Linux 核心才能正常運作。
另一個常見機制是「secrets 與環境變數」。API key、註冊 token、SSH private key 等敏感資料放進 repo settings 的 Secrets,workflow 用 `${{ secrets.NAME }}` 引用,絕對不要寫死在 YAML 檔裡。今天會用到 GHCR_TOKEN(其實可以用內建的 GITHUB_TOKEN)、SSH_KEY 與 SSH_HOST 三個 secrets。
第一個 workflow:跑測試
我們從最簡單的工作流程開始:每次 push 或 pull_request 時,跑 pytest。這個 workflow 是整個 CI 體系的地基,所有後續步驟都假設「測試已經通過」。
.github/workflows/test.yml 檔案內容如下:
name: test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
pytest:
runs-on: ubuntu-latest
timeout-minutes: 10
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: booking
POSTGRES_PASSWORD: booking_test_pw
POSTGRES_DB: booking_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U booking -d booking_test"
--health-interval 5s
--health-timeout 3s
--health-retries 10
env:
DATABASE_URL: postgresql+psycopg://booking:booking_test_pw@localhost:5432/booking_test
steps:
- uses: actions/checkout@v4
- name: 安裝 uv
uses: astral-sh/setup-uv@v5
with:
version: "0.7"
- name: 設定 Python
run: uv python install 3.13
- name: 同步依賴
run: uv sync --all-extras
- name: 跑遷移
run: uv run alembic upgrade head
- name: 跑測試
run: uv run pytest -q --maxfail=1
這份 workflow 有幾個關鍵設計:services 區塊在 runner 內啟動 Postgres 17 容器,並用 health-cmd 確認服務可用才讓後續 step 跑;`uv sync --all-extras` 會裝好開發依賴(pytest、httpx、ruff 等),這比手寫 pip install 更穩定;`uv run pytest -q --maxfail=1` 是 pytest 的安靜模式 + 第一次失敗就停止,避免 CI 浪費時間跑一堆已知壞掉的測試。`timeout-minutes: 10` 是保險,避免某個 step 卡住把整個 job 拖到 6 小時後才失敗。
GitHub Actions 的 services 是「service container」機制:每個 service 都跑在獨立的 container 裡,並透過 port 與主 job 通訊。`5432:5432` 把 Postgres 的 5432 port 對應到 runner 主機,DATABASE_URL 才能用 `localhost:5432` 連線。注意這個機制只在 runner 是 Linux 時有效,macOS 與 Windows runner 不支援 service container,所以今天一律用 ubuntu-latest。
第二個 workflow:建置並推送 image
測試通過後,下一步是把 Docker image 建置出來並推送到 registry。這個 workflow 在 main 分支被合併後觸發(避免每次 PR 都建 image 浪費額度)。
.github/workflows/image.yml 檔案內容如下:
name: image
on:
push:
branches: [main]
tags: ["v*.*.*"]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: 登入 GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 設定 buildx
uses: docker/setup-buildx-action@v3
- name: 計算 metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,format=short
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: 建置並推送
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
這份 workflow 展示了 GitHub Actions 處理 Docker image 的標準做法:先用 docker/login-action 登入 GHCR(GitHub Container Registry),再用 docker/setup-buildx-action 啟用多平台建置,再用 docker/metadata-action 自動產生多個 tag(分支名、commit SHA、semver 版本號),最後用 docker/build-push-action 一次完成 build 與 push。
幾個值得說明的細節:`cache-from: type=gha` 與 `cache-to: type=gha,mode=max` 用 GitHub Actions 內建的 cache backend,把 layer 快取在 GH Actions 內部,下次 build 不用從頭下載;`permissions: packages: write` 是必要的,否則預設的 token 沒有寫入 package registry 的權限;`tags` 欄位用 metadata-action 展開成多個 tag,例如 `ghcr.io/owner/repo:main`、`ghcr.io/owner/repo:sha-abc123`、`ghcr.io/owner/repo:1.2.3`,可以依需求挑選。
第三個 workflow:tag 觸發部署
當你 push 一個像 `v1.2.0` 的 tag 時,我們希望自動部署到示範主機。這個 workflow 用 SSH 連到主機、執行 `docker compose pull && docker compose up -d`。正式環境通常會搭配 k8s 或藍綠部署,這裡先示範最直覺的 SSH 部署。
.github/workflows/deploy.yml 檔案內容如下:
name: deploy
on:
push:
tags: ["v*.*.*"]
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 10
environment: production
steps:
- uses: actions/checkout@v4
- name: SSH 到示範主機並重啟
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
cd /srv/booking-api
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=50 api
這份 workflow 用 appleboy/ssh-action 連到主機執行部署指令。`environment: production` 會在 GitHub UI 上提示「這是正式環境部署」,可以加上 required reviewers 與 wait timer,這是 GitHub 內建的部署保護機制,比自己寫審核流程簡單很多。`secrets.SSH_HOST` 是主機 IP 或網域,`SSH_USER` 是登入帳號,`SSH_KEY` 是對應的 SSH private key,這三個都要在 repo settings 的 Secrets 設定好。
部署腳本本身很單純:先切到 compose 檔所在的目錄、pull 最新 image、用 -d 背景啟動、最後用 ps 與 logs 確認狀態。`docker compose pull` 會從 GHCR 拉最新的 image,搭配昨天 Day 31 的 compose 設定就能無痛切換。正式環境如果要更謹慎,可以先用 `docker compose up -d --no-deps --build` 重建,或加上 healthcheck 等待、跑 smoke test 等,這是後續可以再擴充的部分。
實際操作:從零跑一次完整流程
三個 workflow 檔放好之後,commit 進 main 分支就會自動觸發 test 與 image。整個流程大約 3 到 5 分鐘,第一次會比較久(要下載 image base 與套件),之後有 cache 大約 1 分鐘內就能跑完。底下這支小腳本可以在本地端快速驗證 workflow 設定是否正確——用 act 工具可以本機模擬 GitHub Actions 環境,不用每改一次就 push 一次。
"""scripts/validate_workflow.py:簡單讀取 workflows 並檢查必要欄位。"""
import os
from pathlib import Path
import yaml
REQUIRED_TOP_KEYS = {"name", "on", "jobs"}
def validate(path: Path) -> list[str]:
errors: list[str] = []
with path.open(encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if not isinstance(data, dict):
return [f"{path} 不是字典格式"]
missing = REQUIRED_TOP_KEYS - data.keys()
if missing:
errors.append(f"{path.name} 缺少頂層欄位:{missing}")
if "jobs" in data:
for job_name, job in data["jobs"].items():
if "runs-on" not in job:
errors.append(f"{path.name} job {job_name} 缺 runs-on")
if "steps" not in job and "uses" not in job:
errors.append(f"{path.name} job {job_name} 缺 steps 或 reusable workflow")
return errors
if __name__ == "__main__":
workflows_dir = Path(".github/workflows")
if not workflows_dir.exists():
print("找不到 .github/workflows")
raise SystemExit(1)
all_errors: list[str] = []
for yaml_file in sorted(workflows_dir.glob("*.yml")):
all_errors.extend(validate(yaml_file))
if all_errors:
for line in all_errors:
print(f" X {line}")
raise SystemExit(1)
print("所有 workflow 結構檢查通過")
這支腳本用 PyYAML 把所有 workflow 檔讀進來,檢查必要的頂層欄位(name、on、jobs)是否齊全,並驗證每個 job 都有 runs-on 與 steps。雖然 GitHub Actions 自己的 schema 驗證更完整,但這支腳本可以接進 pre-commit hook,在 commit 前先擋掉最基本的錯誤,省下 CI 一次失敗的時間。
第二支腳本示範怎麼在本地端用 subprocess 模擬 GitHub Actions 的 step:把所有 step 序列化成 shell 指令、在本機跑、把輸出寫進 log。這對「在 PR 還沒進 GitHub 就想驗證 workflow」特別有用。
"""scripts/dry_run_steps.py:把 workflow 的 steps 攤成本機 shell 指令,方便預覽。"""
import os
import subprocess
import sys
from pathlib import Path
import yaml
def render_steps(workflow: dict, job_name: str) -> list[str]:
"""把指定 job 的 steps 攤成 list of shell command。"""
job = workflow["jobs"][job_name]
lines: list[str] = []
for step in job.get("steps", []):
if "run" in step:
lines.append(f"# {step.get('name', step.get('uses', 'step'))}")
lines.append(step["run"])
elif "uses" in step:
lines.append(f"# uses: {step['uses']}(略,本機無法模擬)")
return lines
def main() -> int:
workflow_file = Path(sys.argv[1])
job_name = sys.argv[2] if len(sys.argv) > 2 else next(iter(yaml.safe_load(workflow_file.read_text(encoding="utf-8"))["jobs"]))
data = yaml.safe_load(workflow_file.read_text(encoding="utf-8"))
cmds = render_steps(data, job_name)
for line in cmds:
print(line)
return 0
if __name__ == "__main__":
sys.exit(main())
這支腳本只負責「印出來給你看」,並不會真的執行,但能讓你在 PR 之前先檢查每個 step 的順序與 shell 指令是否合理。實務上要把這個工具用得淋漓盡致,建議搭配 `make dry-run` 之類的 Makefile target,讓 CI 的修改可以先在本機預覽。
第三支腳本是 SSH 部署前的 sanity check:先用本地端的 Python 程式驗證要部署的 compose 檔沒有語法錯誤,再開始實際部署。這能擋下「docker-compose.yml 寫壞、SSH 過去之後 docker compose up 失敗」的窘境。
"""scripts/pre_deploy_check.py:部署前先驗證 compose 檔與必要設定。"""
import os
import subprocess
import sys
from pathlib import Path
import yaml
REQUIRED_KEYS = ("services", "version")
def load_compose(path: Path) -> dict:
return yaml.safe_load(path.read_text(encoding="utf-8"))
def check_env(compose: dict) -> list[str]:
errors: list[str] = []
for service_name, service in compose.get("services", {}).items():
env = service.get("environment") or {}
missing = [k for k in ("DATABASE_URL",) if k not in env and k not in os.environ]
if missing:
errors.append(f"service {service_name} 缺少 {missing}")
return errors
def check_image_tag(compose: dict, expected_tag: str) -> list[str]:
errors: list[str] = []
for service_name, service in compose.get("services", {}).items():
image = service.get("image", "")
if expected_tag not in image:
errors.append(f"service {service_name} image 不含 {expected_tag}:{image}")
return errors
if __name__ == "__main__":
compose_path = Path(sys.argv[1] if len(sys.argv) > 1 else "docker-compose.yml")
expected = os.environ.get("EXPECTED_TAG", "dev")
compose = load_compose(compose_path)
errors = check_env(compose) + check_image_tag(compose, expected)
if errors:
for line in errors:
print(f" X {line}")
sys.exit(1)
print(f"compose {compose_path} 檢查通過(target tag={expected})")
這支腳本的概念是「部署前先用程式驗證設定檔」。`check_env` 確認每個 service 都拿到必要的環境變數;`check_image_tag` 確認 image tag 與預期一致,避免「部署到一半才發現 tag 寫錯」。實務上可以接到 deploy workflow 的第一個 step,先跑 `python scripts/pre_deploy_check.py`,失敗就直接 fail,不浪費 SSH 連線時間。
最後一支小工具是把「在 GitHub 上觸發 workflow」包成 Python 函式。當你想從程式碼(例如另一個內部系統)自動觸發部署時,這比打 curl 還要直覺:
"""scripts/trigger_deploy.py:用 GitHub API 觸發 deploy workflow。"""
import os
import sys
import httpx
REPO = os.environ.get("GITHUB_REPO", "owner/booking-api")
WORKFLOW_FILE = os.environ.get("WORKFLOW_FILE", "deploy.yml")
REF = os.environ.get("REF", "main")
def trigger(tag: str) -> dict:
token = os.environ["GH_TOKEN"]
url = f"https://api.github.com/repos/{REPO}/actions/workflows/{WORKFLOW_FILE}/dispatches"
headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}
payload = {"ref": REF, "inputs": {"tag": tag}}
res = httpx.post(url, headers=headers, json=payload, timeout=10)
res.raise_for_status()
return {"status": res.status_code, "tag": tag}
if __name__ == "__main__":
if not (len(sys.argv) >= 2):
print("用法:python trigger_deploy.py v1.2.3")
sys.exit(2)
result = trigger(sys.argv[1])
print(f"已觸發 {REPO} 的 {WORKFLOW_FILE},tag={result['tag']},status={result['status']}")
這支腳本示範了 GitHub 官方 API 的 workflow_dispatch 端點:用 PAT(Personal Access Token)或 GitHub App 安裝 token 呼叫 workflow_dispatch 端點,就可以從外部觸發 workflow。配合昨天的 deploy workflow(已包含 `workflow_dispatch` 觸發),這支腳本能讓內部系統在「驗收測試通過」後自動觸發正式部署。今天先以「用 PAT 為例」展示,正式環境建議改用 GitHub App 以提升安全性。
第五支小工具是把 GitHub Actions 的執行狀態抓下來做儀表板。當你的團隊分散好幾個 repo,CI 結果散落在 GitHub UI 各處時,這支腳本可以集中顯示:
"""scripts/workflow_status.py:列出最近 N 次 workflow run 的狀態。"""
import os
from datetime import datetime
import httpx
REPO = os.environ.get("GITHUB_REPO", "owner/booking-api")
def fetch_runs(per_page: int = 10) -> list[dict]:
token = os.environ["GH_TOKEN"]
url = f"https://api.github.com/repos/{REPO}/actions/runs?per_page={per_page}"
headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}
res = httpx.get(url, headers=headers, timeout=10)
res.raise_for_status()
return res.json()["workflow_runs"]
def render(runs: list[dict]) -> None:
for run in runs:
started = datetime.fromisoformat(run["created_at"].replace("Z", "+00:00"))
duration = (datetime.fromisoformat(run["updated_at"].replace("Z", "+00:00")) - started).total_seconds()
print(f"{run['name']:25s} {run['conclusion'] or run['status']:10s} {duration:6.1f}s {run['html_url']}")
if __name__ == "__main__":
render(fetch_runs())
這支腳本呼叫 GitHub 的 list workflow runs 端點,把最近 10 次 workflow 執行的狀態、結論、耗時、URL 印成表格。實務上你可以把這支程式包成定時任務,每小時跑一次並把結果推到 Slack 或 Discord webhook;當某次 build 失敗時,團隊就能在第一時間收到通知。對 side project 來說這個工具可以省略,但對三人以上的團隊就非常實用。
第六支是用 httpx 對部署後的服務做 smoke test,確認 healthz 與一個關鍵的 GET 端點都正常:
"""scripts/post_deploy_smoke.py:部署後對服務做最低限度的健康檢查。"""
import sys
import httpx
BASE_URL = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8000"
def check(path: str, expected_status: int = 200) -> bool:
res = httpx.get(f"{BASE_URL}{path}", timeout=5)
ok = res.status_code == expected_status
marker = "OK" if ok else "FAIL"
print(f"[{marker}] {path} 狀態 {res.status_code}")
return ok
def main() -> int:
paths = ["/healthz", "/", "/api/v1/items?limit=1"]
results = [check(p) for p in paths]
return 0 if all(results) else 1
if __name__ == "__main__":
sys.exit(main())
這支 smoke test 可以在 deploy workflow 的最後一步呼叫 `python scripts/post_deploy_smoke.py https://api.example.com`,失敗就讓 deploy workflow 整個失敗。比起單純的 `curl /healthz`,它能驗證多個關鍵端點、帶 timeout、退出碼能直接被 CI 接收。對正式環境來說,這層保險能擋下「image 跑起來了但資料庫連不上」的常見情境。
另外想在本機模擬 runner 環境的話,可以用 `act`(nektos/act 開源工具)。安裝之後執行 `act -j test` 會用 Docker 模擬 GitHub Actions 的 ubuntu-latest runner,跑 test workflow 的 pytest 步驟。這對快速迭代很有用,但要注意 act 跑出來的結果與真正的 GitHub runner 不完全相同(特別是 secrets 與 OIDC token 機制),正式上線前還是要在 GitHub 上跑一次確認。
沒有 GitHub 時的替代流程
如果你不想把程式碼放到 GitHub,或是公司規定用 GitLab / Bitbucket / 自架 Gitea,這套 workflow 的概念都能移植。GitLab CI 用 `.gitlab-ci.yml`、Bitbucket Pipelines 用 `bitbucket-pipelines.yml`、Gitea Actions 幾乎完全相容 GitHub Actions 語法(只差少數第三方 action)。核心觀念一樣:事件觸發 → 跑 job → 串步驟。GitLab CI 還內建 container registry,bitbucket 也可以整合 Docker Hub,整個 image 推送流程同樣能在這些平台上跑。
完全不想用雲端 CI 的話,可以在本機或自家伺服器架一套 Drone CI 或 Woodpecker CI,用 Docker compose 部署,runner 跑在自家 k8s 或裸機上。今天示範的 workflow 內容只要改 `runs-on` 欄位就能對接,pytest、docker build、SSH 部署這三段邏輯都不需要動。Drone CI 用 `.drone.yml`,語法跟 GitHub Actions 類似但用 `pipeline` 取代 `jobs`,社群資源也相當豐富。
另一個輕量替代是「pre-commit + 部署腳本」:用 pre-commit 框架在每次 commit 時跑 ruff + pytest,部署則寫一支 deploy.sh 用 rsync 同步程式碼、用 docker compose 重啟。這套「準 CI」適合 side project 或個人開發,缺點是沒辦法阻擋別人跳過本地檢查直接 push。如果你是團隊裡唯一寫程式的人,這套就夠用了;如果有兩個人以上,建議至少弄個雲端 CI 來擋下跳過審查的合併。
常見錯誤與踩雷
把 secrets 寫死在 YAML 裡。這是最常見也最嚴重的錯誤。GitHub 會主動掃描公開 repo 的 secrets 模式(例如 AWS Access Key、Slack Token),但內網 IP、自家 SSH 密碼、自訂 API key 等不會被掃到。一旦洩漏,輕則服務被濫用,重則主機被入侵。永遠把敏感資料放進 repo settings 的 Secrets,並用 `${{ secrets.NAME }}` 引用。
忘了給 GITHUB_TOKEN 套權限。預設的 GITHUB_TOKEN 只有 read 權限,要推送 package 必須在 workflow 頂層或 job 層加 `permissions: packages: write`。如果忘記,docker push 會回 403 forbidden,錯誤訊息通常很模糊,記得第一時間檢查權限。
service container 的健康檢查太寬鬆。services 的 options 用 health-cmd 時,預設 health-interval 是 0(不檢查),很多人會誤以為加上就會自動等健康。但其實預設的 start_period 與 interval 都很短,Postgres 還沒跑完 initdb 就被當作 healthy,導致 pytest 第一個連線就失敗。務必把 health-interval 設成 5 秒、retries 設成 10 以上,讓 Postgres 有足夠時間初始化。
tag 與分支衝突導致重複部署。如果在 main 分支 push 了一個 v1.0.0 tag,會同時觸發 image workflow(因為 on.push.tags 涵蓋 tag)與 deploy workflow。如果 image 還在建置就 ssh 去 deploy,會抓到上一版 image。解法是讓 deploy 加上 `needs: [build]` 或用 environment 的 required reviewers 設定 wait timer,給 image 推送一點緩衝時間。
docker buildx 的 cache 設定不對。`cache-from: type=gha` 預設會從 main 分支的 cache 拉,但如果你在 PR 第一次跑 build、main 還沒建過,就會 miss cache、退化成從頭 build。在 PR context 下用 `cache-from: type=gha,scope=pr-${{ github.event.pull_request.number }}` 給每個 PR 獨立 cache,可以改善這個狀況。
效能與實務提醒
GitHub Actions 的免費額度對公開 repo 沒問題,但私有 repo 每月 2000 分鐘其實很容易用完(特別是建 Docker image 一次就 5 分鐘起跳)。兩個省額度的技巧:第一,用 `concurrency` 欄位取消同分支的舊 run,避免一推就疊五個 build;第二,把 build matrix 縮到最少,例如 Python 版本不要同時測 3.12 / 3.13 / 3.14,只測一個就好。concurrent cancel 也能避免「舊 run 還在跑就把 cache 蓋掉、新 run 抓不到 cache」的窘境。
Docker image 的快取策略直接影響 build 時間。`cache-from: type=gha` 是 GitHub 內建的快取後端,免費額度內沒有額外成本;如果想要跨 repo 共用快取,可以用 `type=registry` 把 cache 推到 GHCR 的另一個 repo,這對 monorepo 特別有用。另外一個常見優化是把 `pip install` 拆成「裝 build dependencies → 裝 wheel-only packages → 裝 no-binary packages」三層,後者只有在真的有 C 編譯需求時才會被重建。
部署流程最重要的一個原則是「部署失敗要能 rollback」。今天的 SSH 部署沒有實作 rollback,正式環境建議加上:在部署前先把當前版本標記(`docker tag ... previous`),如果 healthcheck 失敗就用 `docker compose up -d previous` 退回。這個觀念在 Day 34 的健康檢查章節會再延伸,搭配 healthcheck 與 monitoring 一起做,就能達到「部署失敗時自動回滾」的效果。
最後一個提醒:GitHub Actions 的 workflow 檔案本身也是程式碼,記得進版控、用 review 流程。對 YAML 的修改應該跟改 Python 程式一樣經過 PR 審核,否則今天不小心把 trigger 從 main 改成 `*` 就糟糕了。對敏感的 workflow(例如會推送 image 或部署正式機),可以用 GitHub 的 environment 機制加上 required reviewers,讓每一次部署都需要人工確認。
小結
今天我們用三個 workflow 檔把 CI 與 CD 接好:test.yml 在 push 時跑 pytest,image.yml 在 main 合併時建置並推送 image 到 GHCR,deploy.yml 在 tag 觸發時用 SSH 部署到示範主機。重點觀念包括:service container 怎麼在 runner 內啟動 Postgres、buildx 與 metadata-action 怎麼搭配自動產生多 tag、以及 secrets 與權限的正確設定方式。我們也寫了六支輔助腳本涵蓋 workflow 結構檢查、本機模擬 runner、pre-deploy 設定驗證、deploy workflow 觸發、CI 狀態儀表板與部署後 smoke test,這些在正式環境都會一個個派上用場。文末也提供了不用 GitHub 時的替代方案,包括 GitLab CI、Gitea Actions、自架 Drone CI 以及純 shell + pre-commit 的準 CI 方案。明天我們會進入 Caddy 反向代理章節,把 HTTPS 與域名接好。
結語
CI/CD 是把「寫好的程式」變成「實際運行的服務」的最後一哩路。今天把這條路打通一半:測試自動化、image 自動化、部署半自動化,並寫了六支腳本協助日常維運:workflow 結構檢查、本機模擬 runner、pre-deploy 設定驗證、deploy workflow 觸發、CI 狀態儀表板與部署後 smoke test。明天,我們會在這個基礎上用 Caddy 加上 HTTPS 與反向代理,讓對外的網址能用憑證加密、流量能被正確分發,部署才算真的完整。
延伸資源
- GitHub Actions 官方文件:
https://docs.github.com/actions - docker/build-push-action 說明:
https://github.com/docker/build-push-action - uv 在 CI 的使用範例:
https://docs.astral.sh/uv/guides/integration/github/ - appleboy/ssh-action 部署範例:
https://github.com/appleboy/ssh-action - Postgres 在 GitHub Actions service container 的官方建議:
https://docs.github.com/actions/using-containerized-services/creating-postgresql-service-containers
留言
張貼留言