跳到主要內容

Web Day 32 CI/CD:GitHub Actions 測試與自動部署

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

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...