跳到主要內容

DE Day 37 部署:GitHub Actions 排程與通知

DE Day 37 部署:GitHub Actions 排程與通知

執行需求:CPU 可跑。今天是端到端管線系列的部署篇(雲端排程路線)。昨天我們用 Docker + APScheduler 把管線容器化、用 docker-compose 啟動排程機器。今天要走另一條路:用 GitHub Actions 排程。GitHub Actions 對公開 repo 完全免費(每月 2,000 分鐘),對我們每天跑一次、每次 2 分鐘的管線綽綽有餘。這條部署路徑適合「不想自己管機器」、「程式碼已經在 GitHub 上」的團隊。讀完這篇,你會有一個零成本(或極低成本)的雲端排程管線。

引言

昨天介紹的 Docker 部署需要一台「自己的機器」:可以是公司內部的主機、可以是雲端的 VM、可以是一台閒置的舊筆電。但並非每個團隊都有這種資源——很多個人開發者或小型團隊只有 GitHub 帳號,沒有自己的主機。今天介紹的 GitHub Actions 路線正好解決這個問題:把管線的 YAML 寫進 repo、由 GitHub 每天自動觸發、完全不用管機器。

GitHub Actions 的限制我們也要先說清楚。第一,單次任務最長 6 小時——對每天跑 2 分鐘的我們沒問題,但要跑「ETL 數小時」的場景就不適合。第二,每月 2,000 分鐘免費(公開 repo 完全免費,私人 repo 也有 2,000 分鐘)——按我們的用量每月只用 60 分鐘,遠低於上限。第三,沒有持久化儲存——每次任務都是新環境,DuckDB 檔案不能像 Docker 那樣用 volume 持久化,要用 GitHub Actions 的 artifact 或外部儲存(如 S3、GCS)保存。

針對第三個限制,我們會用 GitHub Actions 的 actions/upload-artifact 把 DuckDB 檔案與 Parquet 落地檔打包成 artifact、保留 7–30 天。這不是長期儲存方案(artifact 大小有限制),但足夠支撐「每天的管線產出可以跨任務傳遞」。如果要長期保存,需要把 DuckDB 同步到 S3/GCS 之類的物件儲存。

GitHub Actions 的核心觀念

GitHub Actions 的核心概念有四個:workflow、job、step、runner。Workflow 是 YAML 檔(放在 .github/workflows/ 目錄),定義「什麼時候觸發、做什麼事」。Job 是 workflow 裡的「一組工作」,每個 job 跑在一個全新的 runner(VM)環境。Step 是 job 裡的「一個動作」,可以是 uses: actions/checkout@v4(拉程式碼)或 run: python ingest.py(執行命令)。

觸發條件是 workflow 的靈魂。對「每天跑一次的管線」來說,常見的觸發是 schedule:(cron 表達式,與昨天的 APScheduler 類似)加上 workflow_dispatch:(手動觸發,方便測試)。Cron 表達式在 GitHub Actions 的時區是 UTC,這點要特別注意——我們設定「每天 UTC 01:00」對應到「台灣時間 09:00」。

Secrets 是另一個關鍵設定。所有不該寫進 YAML 的敏感資料(URL、密碼、token)都應該放在 GitHub repo 的 Settings → Secrets and variables → Actions 裡,然後在 YAML 用 ${{ secrets.SECRET_NAME }} 讀取。Secrets 不會被 log 出現,也不會被 fork 出去的 repo 讀到,是 12-factor 的標準做法。

在寫正式的 workflow 之前,我們先看 GitHub Actions 的最小可行範例,幫助理解「YAML → 任務」的對應關係:

# .github/workflows/hello.yml:最簡單的 GitHub Actions 範例
name: Hello World
on: [push]
jobs:
  hello:
    runs-on: ubuntu-latest
    steps:
      - name: 印出 hello
        run: echo "Hello from GitHub Actions"
      - name: 列出 Python 版本
        run: python3 --version

這份 YAML 做的事:每次 push 到 main 分支時,在 ubuntu runner 上跑兩個 step——印出 hello 與列出 Python 版本。on: [push] 是觸發條件;runs-on: ubuntu-latest 指定 runner 環境;steps: 區塊列出要做的事。把這份 YAML 推到 GitHub repo 後,到 Actions 頁面就會看到 hello 工作在跑。這是最直觀的「Hello World」,也是 GitHub Actions 入門的第一個範例。

接下來我們把這個範例擴展成實際的資料管線。先定義幾個「變數」讓 workflow 容易調整:

# 在 workflow 開頭定義 env 區塊
env:
  PYTHON_VERSION: "3.13"
  DUCKDB_VERSION: "1.4.1"
  POLARS_VERSION: "1.33.0"
  PANDAS_VERSION: "2.3.0"
  PYARROW_VERSION: "18.0.0"
  TZ: Asia/Taipei

jobs:
  example:
    runs-on: ubuntu-latest
    steps:
      - name: 印出設定
        run: |
          echo "Python:$PYTHON_VERSION"
          echo "DuckDB:$DUCKDB_VERSION"
          echo "時區:$TZ"
          date

這個 env: 區塊把常用版本號集中在 workflow 開頭。日後要升級 DuckDB 1.4 → 1.5,只要改這個值一次、整個 workflow 都會跟著改。注意 TZ: Asia/Taipei 的設定會影響所有 step 的時區(log 的時間戳記、Python 的 datetime.now() 等),避免 cron 觸發時間混亂。

另一個重要的延伸是「在 GitHub Actions 跑整合測試」。除了每天的排程管線,我們可以額外加一個 workflow 在「push 到 main 時」跑整合測試,確保程式碼改動沒有破壞現有功能:

# .github/workflows/test.yml:每次 push 觸發整合測試
name: Integration Tests
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4

      - name: 安裝 uv
        run: pip install uv

      - name: 設定 Python
        run: |
          uv venv --python 3.13
          source .venv/bin/activate
          uv pip install -r requirements.txt
          uv pip install pytest pytest-cov

      - name: 跑測試
        run: |
          source .venv/bin/activate
          pytest tests/ -v --cov=.

      - name: 上傳覆蓋率報告
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: htmlcov/
          retention-days: 7

這份 workflow 在每次 push 與 PR 觸發,跑 pytest 整合測試並產生覆蓋率報告。if: always() 確保即使測試失敗、覆蓋率報告仍會上傳,讓開發者可以第一時間看到「哪段程式碼沒被測試覆蓋」。

回到主要 pipeline.yml,如果想加上「每天第一次跑完時自動建立 PR 把 DuckDB 上傳到 repo」,可以用 peter-evans/create-pull-request@v6:

  upload_to_repo:
    runs-on: ubuntu-latest
    needs: [quality]
    if: github.event_name == 'schedule'
    steps:
      - uses: actions/checkout@v4

      - name: 下載 mart artifact
        uses: actions/download-artifact@v4
        with:
          name: mart-${{ github.run_id }}

      - name: 把 DuckDB 複製到 snapshots 目錄
        run: |
          mkdir -p snapshots/$(date +%Y-%m-%d)
          cp warehouse/de-journey.duckdb snapshots/$(date +%Y-%m-%d)/

      - name: 建立 PR
        uses: peter-evans/create-pull-request@v6
        with:
          commit-message: "chore: 新增 $(date +%Y-%m-%d) 的 DuckDB 快照"
          title: "Daily DuckDB snapshot $(date +%Y-%m-%d)"
          branch: snapshot/$(date +%Y-%m-%d)
          body: |
            自動建立:每日 DuckDB 快照
            - 採集:${{ needs.ingest.result }}
            - 轉換:${{ needs.transform.result }}
            - 品質:${{ needs.quality.result }}

這份 workflow 把每天跑完的 DuckDB 檔案打包成「每日快照 PR」,開發者可以 review 內容、確認沒問題後合併進 main 分支。這是「資料版本控制」的輕量版——比起直接 commit DuckDB 檔案到 main,PR 模式讓我們可以檢查變更是否符合預期。實務上大型團隊會用 dbt + 資料倉儲做更完整的版本控制,但對小型團隊來說這個簡單方案已經足夠。

最後,把 GitHub Actions 與 Day 36 的 Docker 方案做個對比,幫助讀者選擇合適的部署路徑:

# 部署方案比較表(用 Python dict 表示)

deploy_options = {
    "GitHub Actions": {
        "成本": "免費(公開 repo)/ $0(私人 repo 2,000 分鐘/月)",
        "環境一致性": "中等(每次任務是新 VM)",
        "持久化": "artifact 7-30 天;長期需 S3",
        "適合場景": "個人開發者、小型團隊、低頻率任務",
        "限制": "單次 6 小時、無法常駐服務",
    },
    "Docker + APScheduler": {
        "成本": "VM 主機費用(依機器等級)",
        "環境一致性": "高(容器映像完整封裝)",
        "持久化": "Volume 持久化、無時間限制",
        "適合場景": "中型團隊、需要常駐服務",
        "限制": "需要自己管 VM、處理安全更新",
    },
    "Airflow 3.x(Day 28)": {
        "成本": "需要一台 VM 或容器平台",
        "環境一致性": "高",
        "持久化": "依賴後端資料庫(PostgreSQL)",
        "適合場景": "大型團隊、複雜工作流、多任務依賴",
        "限制": "學習曲線陡、需要管理多個元件",
    },
}
for name, props in deploy_options.items():
    print(f"
=== {name} ===")
    for k, v in props.items():
        print(f"  {k}: {v}")

這個比較表說明「沒有最好的部署方案、只有最適合的」。GitHub Actions 適合「輕量、低頻率、不需要常駐服務」的場景;Docker 適合「需要常駐、需要長期持久化」的場景;Airflow 適合「複雜工作流、需要視覺化監控」的場景。實務上 80% 的小型團隊從 GitHub Actions 開始,等到管線變複雜再升級到 Docker 或 Airflow。

完整實作:GitHub Actions workflow

把 .github/workflows/pipeline.yml 寫好。整個 workflow 約 80 行,包含三個 job:採集、轉換與品質、通知:

# de-journey/.github/workflows/pipeline.yml
name: DE Pipeline Daily Run

on:
  # 排程:每天 UTC 01:00 跑(對應台灣時間 09:00)
  schedule:
    - cron: "0 1 * * *"
  # 手動觸發:方便測試與臨時重跑
  workflow_dispatch:
    inputs:
      from_stage:
        description: "從哪個階段開始跑"
        required: false
        default: "ingest"
        type: choice
        options:
          - ingest
          - transform
          - quality
          - notify

# 取消舊的同類觸發(避免排程重疊)
concurrency:
  group: de-pipeline
  cancel-in-progress: false

jobs:
  # 第一個 job:採集
  ingest:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - name: 拉取程式碼
        uses: actions/checkout@v4

      - name: 安裝 uv
        run: pip install uv

      - name: 設定 Python 環境
        run: |
          uv venv --python 3.13
          source .venv/bin/activate
          uv pip install "duckdb==1.4.1" "polars==1.33.0" "pandas==2.3.0" \
                          "pyarrow==18.0.0" "httpx==0.28.1" "tenacity==9.0.0"

      - name: 採集與落地
        env:
          DATASET_URL_COMPANY_BASIC: ${{ secrets.DATASET_URL_COMPANY_BASIC }}
          DATASET_URL_COMPANY_CHANGE: ${{ secrets.DATASET_URL_COMPANY_CHANGE }}
        run: |
          source .venv/bin/activate
          python -m pipelines.ingest
          python -m pipelines.verify_landing

      - name: 上傳 artifact
        uses: actions/upload-artifact@v4
        with:
          name: warehouse-${{ github.run_id }}
          path: |
            warehouse/
            data/
          retention-days: 7

這個 workflow 的結構對應 Day 36 的容器化設計:第一個 job「ingest」做採集與落地;第二個 job 接收第一個 job 的 artifact 做轉換;第三個 job 跑品質檢查並在失敗時發通知。我們把 job 分開而不是寫成一個,是因為「分開的 job 可以平行或獨立重跑」——例如今天只想重跑品質檢查,可以手動觸發第三個 job 而不重跑 ingest。

幾個重點設定說明:第一,secrets.DATASET_URL_COMPANY_BASIC 從 GitHub repo 的 Secrets 讀取,不會被 log 看到;第二,actions/upload-artifact@v4 把 warehouse/ 與 data/ 打包成 artifact、保留 7 天;第三,concurrency: 區塊防止「昨天的管線還沒跑完、今天的又觸發」造成 race condition。

接下來是「轉換」與「品質」job:

  # 第二個 job:轉換與建模
  transform:
    runs-on: ubuntu-latest
    needs: ingest
    timeout-minutes: 20
    steps:
      - name: 拉取程式碼
        uses: actions/checkout@v4

      - name: 下載 ingest 的 artifact
        uses: actions/download-artifact@v4
        with:
          name: warehouse-${{ github.event.inputs.run_id || github.run_id }}

      - name: 安裝套件
        run: |
          pip install uv
          uv venv --python 3.13
          source .venv/bin/activate
          uv pip install "duckdb==1.4.1" "polars==1.33.0" "pandas==2.3.0"

      - name: 轉換與建模
        run: |
          source .venv/bin/activate
          python -m pipelines.transform_basic
          python -m pipelines.transform_change
          python -m pipelines.build_marts

      - name: 上傳轉換後的 artifact
        uses: actions/upload-artifact@v4
        with:
          name: mart-${{ github.run_id }}
          path: warehouse/
          retention-days: 7

這個 job 用 needs: ingest 表達「依賴 ingest 跑完才執行」。actions/download-artifact@v4 把前一個 job 上傳的 warehouse/ 與 data/ 下載回來,繼續跑轉換。注意 warehouse-${{ github.event.inputs.run_id || github.run_id }} 這個表達式:如果手動觸發時有指定 run_id,就下載那次的 artifact;否則下載當前 run 的——這個設計讓「手動重跑」可以選擇重跑哪一天。

第三個 job 跑品質檢查與通知:

  # 第三個 job:品質檢查與通知
  quality:
    runs-on: ubuntu-latest
    needs: [ingest, transform]
    timeout-minutes: 10
    steps:
      - name: 拉取程式碼
        uses: actions/checkout@v4

      - name: 下載 mart artifact
        uses: actions/download-artifact@v4
        with:
          name: mart-${{ github.event.inputs.run_id || github.run_id }}

      - name: 安裝套件
        run: |
          pip install uv
          uv venv --python 3.13
          source .venv/bin/activate
          uv pip install "duckdb==1.4.1"

      - name: 品質檢查
        id: check
        run: |
          source .venv/bin/activate
          python -m pipelines.check_quality
        continue-on-error: true

      - name: 模擬通知
        if: steps.check.outcome == 'failure'
        run: |
          source .venv/bin/activate
          python -m pipelines.notify

      - name: 失敗時上傳 log
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: logs-${{ github.run_id }}
          path: logs/
          retention-days: 30

這個 job 用了幾個 GitHub Actions 的進階設定:id: check 給 step 一個識別碼,後面的 step 可以用 steps.check.outcome 判斷它是否失敗;continue-on-error: true 讓品質檢查失敗時不中斷整個 workflow(這樣通知 step 還能執行);if: failure() 在整個 job 失敗時上傳 log 檔案,方便事後除錯。

pipelines.notify 在這裡仍然是「模擬」——Day 33 的設計會把通知訊息寫到 logs/notifications/,然後 actions/upload-artifact@v4 把 logs/ 上傳成 artifact、保留 30 天。這讓我們可以在 GitHub Actions 的 UI 上看到「今天寄了什麼通知」,而不需要真的串接 SMTP / Slack / LINE。

完整實作:把 DuckDB 同步到長期儲存

GitHub Actions 的 artifact 只保留 7–30 天,不適合長期儲存。如果要讓 mart.dim_company 與 mart.fact_company_change 可以被 Day 34 的 Streamlit 儀表板長期讀取,需要把它們同步到外部物件儲存(S3、GCS、Azure Blob)。下面用 AWS S3 當範例:

# 安裝 AWS CLI(用 pip 安裝 Python 版,比 apt 簡單)
pip install awscli
# 設定憑證(從 GitHub Secrets 讀取)
export AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY"
export AWS_DEFAULT_REGION="ap-northeast-1"
# 上傳 DuckDB 檔案到 S3
aws s3 cp warehouse/de-journey.duckdb \
  s3://my-de-bucket/warehouse/de-journey.duckdb \
  --storage-class STANDARD_IA

這段 bash 指令可以用 Python boto3 改寫、用更精細的錯誤處理。但對小型管線來說,AWS CLI 的 aws s3 cp 就夠用。注意 STANDARD_IA 是「Infrequent Access」儲存類別,比預設 STANDARD 便宜 40%、但取回要額外收費——對「每天更新一次、其他時間偶爾查詢」的 DuckDB 檔案剛好。

如果要更完整的版本,可以用 Python 寫一支 sync_to_s3.py:

"""de-journey/pipelines/sync_to_s3.py:把 DuckDB 與 Parquet 同步到 S3。"""
import os
from pathlib import Path

import boto3

from pipelines.common import DUCKDB_PATH, DATA_DIR, WAREHOUSE_DIR


def upload_file(s3, local: Path, bucket: str, key: str) -> None:
    """上傳單一檔案到 S3。"""
    if not local.exists():
        print(f"跳過不存在的檔案:{local}")
        return
    s3.upload_file(str(local), bucket, key)
    print(f"上傳:{local} -> s3://{bucket}/{key}")


def main() -> int:
    bucket = os.environ["S3_BUCKET"]
    s3 = boto3.client("s3")

    # 上傳 DuckDB 檔案

    upload_file(s3, DUCKDB_PATH, bucket, "warehouse/de-journey.duckdb")

    # 上傳所有 Parquet 分區

    for parquet in DATA_DIR.rglob("*.parquet"):
        key = f"data/{parquet.relative_to(DATA_DIR)}"
        upload_file(s3, parquet, bucket, key)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

這支腳本用 boto3 上傳 DuckDB 與所有 Parquet 檔案到 S3。S3_BUCKET 從環境變數讀取(在 GitHub Actions 的 secrets 裡設定)。實務上你會希望上傳加上一個「日期前綴」讓歷史版本可以回溯——例如 s3://my-bucket/warehouse/2025-12-06/de-journey.duckdb,這樣可以回溯到任意一天的快照。

接著看「手動觸發」的進階用法。當我們要臨時重跑管線時,workflow_dispatch 接受使用者輸入:

on:
  workflow_dispatch:
    inputs:
      from_stage:
        description: "從哪個階段開始跑"
        required: false
        default: "ingest"
        type: choice
        options:
          - ingest
          - transform
          - quality
          - notify
      force:
        description: "是否強制重跑(忽略快取)"
        required: false
        default: "false"
        type: boolean

jobs:
  pipeline:
    runs-on: ubuntu-latest
    steps:
      - name: 顯示使用者輸入
        run: |
          echo "從 ${{ inputs.from_stage }} 開始跑"
          echo "強制重跑:${{ inputs.force }}"

這份 workflow 在 GitHub Actions UI 上會顯示「Run workflow」按鈕,按下去會跳出輸入框讓使用者選 from_stage 與 force。type: choice 提供下拉選單;type: boolean 提供勾選框。在 step 裡可以用 ${{ inputs.NAME }} 讀取使用者輸入。force 參數可以搭配 actions/cache 一起用:當 force=true 時清除快取、force=false 時用快取。

另一個實用的設計是「共用步驟抽成 composite action」。當多個 workflow 都需要「安裝 uv + 設定 Python」時,把這些步驟抽到 .github/actions/setup-python-uv/action.yml:

# .github/actions/setup-python-uv/action.yml
name: "Setup Python with uv"
description: "安裝 Python 3.13 與 uv,並建立虛擬環境"
runs:
  using: "composite"
  steps:
    - name: 安裝 uv
      shell: bash
      run: pip install uv

    - name: 建立虛擬環境
      shell: bash
      run: uv venv --python 3.13

    - name: 安裝套件
      shell: bash
      run: |
        source .venv/bin/activate
        uv pip install duckdb==1.4.1 polars==1.33.0 pandas==2.3.0                         pyarrow==18.0.0 httpx==0.28.1 tenacity==9.0.0

其他 workflow 就可以用 uses: ./.github/actions/setup-python-uv 引用這個 composite action,不需要重複寫安裝步驟。這個設計對「管線越來越多」的情境特別有用——把 3 個 workflow 共用的 20 行安裝步驟壓縮成 1 行引用。

如果想用 Python 寫一支「GitHub Actions 本地模擬器」,可以在本機先測試 workflow 邏輯再推到 GitHub:

"""de-journey/local_action_runner.py:在本機模擬 GitHub Actions 的 step 執行。"""
import os
import subprocess
from pathlib import Path

# 讀取 workflow YAML 的 steps 區塊

workflow_path = Path(".github/workflows/pipeline.yml")

# 設定環境變數(模擬 GitHub Actions 注入的變數)

os.environ.update({
    "DATASET_URL_COMPANY_BASIC": "http://localhost:9999/basic.csv",
    "DATASET_URL_COMPANY_CHANGE": "http://localhost:9999/change.csv",
    "TZ": "Asia/Taipei",
})

# 執行 pipeline 的核心三個 stage

for stage in ["ingest", "transform", "quality"]:
    print(f"=== 執行 {stage} ===")
    result = subprocess.run(
        ["python", "-m", f"pipelines.{stage}"],
        capture_output=True, text=True,
    )
    print(result.stdout)
    if result.returncode != 0:
        print(f"FAIL: {stage} 失敗")
        print(result.stderr)
        break
print("=== 完成 ===")

這支腳本讓你在本機用 Python 模擬 GitHub Actions 的執行環境(環境變數、依序執行 step)。當你改完 workflow YAML 還沒推上 GitHub 時,可以用它先驗證邏輯——能省下 5–10 分鐘的 push + Actions 等待時間。

另一個值得介紹的概念是「GitHub Actions 的矩陣測試」。如果我們要在多個 Python 版本、DuckDB 版本、OS 上跑同一個管線,可以用 strategy.matrix:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.13", "3.14"]
        duckdb-version: ["1.4.1", "1.4.0"]
    steps:
      - uses: actions/checkout@v4
      - name: 安裝 Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - name: 安裝 DuckDB ${{ matrix.duckdb-version }}
        run: pip install duckdb==${{ matrix.duckdb-version }}
      - name: 跑測試
        run: pytest tests/

這份 workflow 會跑 2×2 = 4 個 job,分別在 Python 3.13/3.14 與 DuckDB 1.4.1/1.4.0 的組合上跑測試。當我們在 Day 2 升級套件版本時,可以用這個矩陣驗證「舊版本仍能跑、新版本也能跑」,避免不小心把支援範圍縮窄。實務上我們會建議在「正式 release」前跑一次矩陣測試,日常 push 只要跑最新的版本組合即可。

常見錯誤與踩雷

錯誤一:cron 表達式用台灣時間,結果每天少 8 小時。常見症狀:明明設定每天 09:00 跑,實際上每天 17:00 才跑。對應排查方向:GitHub Actions 的 cron 表達式時區是 UTC。要在台灣時間 09:00 跑,要寫成 UTC 01:00(即 0 1 * * *)。

錯誤二:把 secrets 寫進 YAML,洩漏到公開 repo。對應排查方向:所有敏感資料(URL、密碼、token)都放 GitHub repo 的 Settings → Secrets 裡;YAML 只用 ${{ secrets.NAME }} 引用。GitHub 會自動遮罩 secrets 在 log 中的顯示。

錯誤三:actions/download-artifact@v4 找不到檔案。常見症狀:Unable to find any artifacts for the associated workflow。對應排查方向:artifact 名稱 warehouse-${{ github.run_id }} 必須與上傳時一致;如果用 workflow_dispatch 觸發、且指定了別的 run_id,就要用 github.event.inputs.run_id 而不是 github.run_id。

錯誤四:GitHub Actions 免費額度用完、任務自動排隊。對應排查方向:公開 repo 完全免費,不用擔心額度;私人 repo 每月 2,000 分鐘(按 ubuntu runner 算),我們的管線每天 2 分鐘、每月 60 分鐘,遠低於上限。

錯誤五:Artifact 太大、導致上傳失敗。對應排查方向:GitHub Actions 單一 artifact 上限 500 MB;總儲存上限 2 GB(公開 repo)/ 500 MB(私人 repo 免費方案)。如果 Parquet 分區累積太多,可以加 compact_partitions.py(Day 35)定期合併;或者把 DuckDB 與 Parquet 同步到 S3、Artifact 只保留最新的 7 天。

效能與實務提醒

GitHub Actions 排程器的效能瓶頸在「runner 冷啟動」。每次任務啟動都要花 5–15 秒拉 image、安裝依賴。我們的管線每次 2 分鐘,冷啟動佔了 10–25% 的時間。可以考慮用 ubuntu-latest 預裝的環境(已經有 Python 3.10)來節省時間,但 DuckDB 1.4 需要 Python 3.13,仍要重灌。

實務上有幾個重要的設計取捨。第一,管線與通知解耦:把 ingest/transform 放一個 job、quality/notify 放另一個 job;品質檢查失敗時只影響 notify job、不會讓前面的 ingest/transform 重跑。第二,Artifact 命名帶 run_id:每個 workflow run 的 artifact 名稱都帶 ${{ github.run_id }},避免不同 run 的 artifact 互相覆蓋。第三,用 composite action 抽出共用步驟:當 workflow 越來越多時,把「安裝 uv + 設定 Python」這種重複步驟抽成 composite action,放到 .github/actions/setup-python-uv/action.yml,每個 workflow 用 uses: ./.github/actions/setup-python-uv 引用。

另一個工程建議:把 GitHub Actions workflow 的 `runs-on` 與 `timeout-minutes` 設得保守一點。我們的 ingest job 用 ubuntu-latest(2 vCPU、7 GB RAM)與 timeout-minutes: 30,對每天 2 分鐘的管線綽綽有餘;如果超時,GitHub Actions 會自動 cancel 並標記為失敗。

最後,我們介紹「GitHub Actions 排程的監控」。當排程沒有觸發時(可能是 cron 寫錯、或是 GitHub 端出問題),我們需要有方法知道:

# .github/workflows/heartbeat.yml:每小時檢查排程是否正常
name: Heartbeat
on:
  schedule:
    - cron: "0 * * * *"  # 每小時跑一次
  workflow_dispatch:

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - name: 拉取程式碼
        uses: actions/checkout@v4

      - name: 檢查上次 pipeline run 是否在 24 小時內
        run: |
          LAST_RUN=$(gh run list --workflow=pipeline.yml --limit=1 --json createdAt -q '.[0].createdAt')
          LAST_EPOCH=$(date -d "$LAST_RUN" +%s)
          NOW_EPOCH=$(date +%s)
          DIFF=$(( (NOW_EPOCH - LAST_EPOCH) / 3600 ))
          if [ "$DIFF" -gt 24 ]; then
            echo "ERROR: 上次 pipeline run 是 $DIFF 小時前"
            exit 1
          fi
          echo "OK: 上次 pipeline run 是 $DIFF 小時前"
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

這個 heartbeat workflow 每小時跑一次,檢查 pipeline workflow 是否在最近 24 小時內有跑過。如果超過 24 小時沒跑(可能是排程被 GitHub 暫停、或 cron 表達式有誤),heartbeat 就會失敗並觸發通知。gh CLI 是 GitHub 官方的命令列工具,可以從 workflow 內查詢其他 workflow 的狀態。secrets.GITHUB_TOKEN 是 GitHub 自動產生的 token,不需要額外設定。

把 GitHub Actions 與其他監控工具(PagerDuty、Opsgenie)整合也是常見做法——當 heartbeat 失敗時,可以用 actions/notify-slack 或類似 action 把訊息送到 Slack/Discord/PagerDuty,整個監控鏈就接通了。

另一個實用的延伸是「GitHub API 監控 workflow 狀態」。我們可以用 Python 寫一支監控腳本,搭配 GitHub Actions 的 schedule:

"""de-journey/monitor_workflow.py:用 GitHub API 檢查 workflow 狀態。"""
import os

import httpx

REPO = os.environ.get("GITHUB_REPOSITORY", "owner/de-journey")
TOKEN = os.environ["GITHUB_TOKEN"]
WORKFLOW_FILE = "pipeline.yml"


def fetch_recent_runs(limit: int = 10) -> list:
    """取得 pipeline workflow 最近 N 次的執行狀態。"""
    url = f"https://api.github.com/repos/{REPO}/actions/workflows/{WORKFLOW_FILE}/runs"
    headers = {"Authorization": f"Bearer {TOKEN}"}
    r = httpx.get(url, headers=headers, params={"per_page": limit})
    r.raise_for_status()
    return r.json()["workflow_runs"]


def main() -> int:
    runs = fetch_recent_runs()
    failures = [r for r in runs if r["conclusion"] == "failure"]
    print(f"最近 {len(runs)} 次執行中有 {len(failures)} 次失敗")
    for r in failures[:3]:
        print(f"  - {r['display_title']} ({r['created_at']})")
    return 0 if not failures else 1


if __name__ == "__main__":
    raise SystemExit(main())

這支腳本用 GitHub API 取得 pipeline workflow 的最近執行狀態,當有失敗時主動通知。它可以獨立排程(每天跑一次),也可以接到 Day 33 的通知系統。這是「從 GitHub 外部監控 GitHub Actions」的標準做法,比依賴 email 通知更靈活。

"""de-journey/setup_github_secrets.py:本機輔助工具,列出需要設定的 secrets。"""
SECRETS_NEEDED = [
    ("DATASET_URL_COMPANY_BASIC", "data.gov.tw 公司登記基本資料下載 URL"),
    ("DATASET_URL_COMPANY_CHANGE", "data.gov.tw 公司變更登記下載 URL"),
    ("S3_BUCKET", "DuckDB 與 Parquet 同步的 S3 bucket 名稱(選填)"),
    ("AWS_ACCESS_KEY_ID", "AWS 存取金鑰(同步到 S3 時需要)"),
    ("AWS_SECRET_ACCESS_KEY", "AWS 秘密金鑰(同步到 S3 時需要)"),
]


def main() -> None:
    print("需要到 GitHub repo 的 Settings > Secrets and variables > Actions 設定:")
    print()
    for name, desc in SECRETS_NEEDED:
        print(f"  - {name}: {desc}")
    print()
    print("設定方式:gh secret set NAME --body 'value'")


if __name__ == "__main__":
    main()

這支小工具列出所有需要設定的 secrets 與說明。團隊新成員加入時可以 python setup_github_secrets.py 看到完整清單,避免「漏設一個 secret 導致 workflow 失敗」的常見問題。

另一個延伸是「用 Python 動態產生 GitHub Actions workflow」。當我們的資料集數量增加、需要為每個資料集產生獨立的 workflow 時,可以用 Jinja2 樣板:

"""de-journey/generate_workflows.py:根據 DATASETS 字典動態產生 workflow YAML。"""
from pathlib import Path

from pipelines.common import DATASETS

TEMPLATE = """name: DE Pipeline ({{ dataset }})
on:
  schedule:
    - cron: "{{ cron }}"
  workflow_dispatch:
jobs:
  run:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install uv
      - run: uv venv --python 3.13 && source .venv/bin/activate
      - run: uv pip install duckdb==1.4.1
      - name: 執行 {{ dataset }}
        env:
          DATASET_URL_{{ dataset_upper }}: ${{ secrets.DATASET_URL_{{ dataset_upper }} }}
        run: python -m pipelines.ingest --datasets {{ dataset }}
"""

workflows_dir = Path(".github/workflows")
workflows_dir.mkdir(parents=True, exist_ok=True)

# 為每個資料集產生獨立 workflow

cron_times = ["0 1 * * *", "0 2 * * *"]  # 每個資料集錯開 1 小時

for i, name in enumerate(DATASETS):
    yaml = TEMPLATE.replace("{{ dataset }}", name)
    yaml = yaml.replace("{{ dataset_upper }}", name.upper())
    yaml = yaml.replace("{{ cron }}", cron_times[i % len(cron_times)])
    (workflows_dir / f"pipeline-{name}.yml").write_text(yaml, encoding="utf-8")
    print(f"產生 workflow:pipeline-{name}.yml")

這支腳本根據 DATASETS 字典動態產生每個資料集獨立的 workflow。當資料集數量從 2 個變成 5 個時,不用手寫 5 份 YAML——只要改 DATASETS 字典、執行 generate_workflows.py 就會自動產生。注意 cron_times 把每個 workflow 錯開 1 小時,避免同時觸發造成 GitHub 端的併發限制。

小結

今天用 GitHub Actions 完成了管線的雲端部署。我們寫了 .github/workflows/pipeline.yml,包含 ingest、transform、quality 三個 job;用 secrets 注入 URL、用 artifact 跨任務傳遞 DuckDB;當品質檢查失敗時,自動觸發模擬通知並上傳 log;最後介紹了用 S3 同步 DuckDB 做長期儲存的方案。重點回顧:第一,GitHub Actions 是「零成本」的雲端排程方案,每月 2,000 分鐘對小型管線綽綽有餘;第二,cron 表達式時區是 UTC,要記得減 8 小時;第三,secrets 是敏感資料的標準存放處,不寫進 YAML;第四,artifact 用 ${{ github.run_id }} 命名避免覆蓋;第五,跨任務傳遞資料用 actions/upload-artifact 與 actions/download-artifact。

明天 Day 38 會進入監控與資料合約篇:把 Day 32–Day 35 的品質監控延伸到「資料合約」的概念——明確定義「管線輸出什麼格式、什麼頻率、什麼品質」。這是端到端管線從「能用」到「可被依賴」的關鍵一步。

結語

今天的重點是「用雲端服務取代自己的機器」。GitHub Actions 對個人開發者與小型團隊特別划算——不用花錢買主機、不用學 Docker、只要寫一份 YAML 就能讓 GitHub 每天自動跑管線。當管線的價值被驗證、需要更高階的部署時,可以再升級到 Day 36 的 Docker 方案或雲端的 Airflow(Day 28)。

明天 Day 38 會介紹「監控與資料合約」:把管線的輸出當成一個「產品」,明確定義它的 SLA(服務等級協議)、SLO(服務等級目標)、以及違反時的處理流程。這是端到端管線從「內部工具」變成「組織級基礎設施」的關鍵一步。

延伸資源

  • GitHub Actions 官方文件(2025):https://docs.github.com/en/actions。on.schedule、workflow_dispatch、secrets、artifact 都是本篇的核心 API。
  • GitHub Actions 的 cron 表達式說明:https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule。時區是 UTC;本篇的 0 1 * * * 對應台灣時間 09:00。
  • AWS S3 官方文件(2025):https://docs.aws.amazon.com/s3/。aws s3 cp 是上傳 DuckDB 檔案到 S3 的標準指令。
  • boto3 官方文件(2025):https://boto3.amazonaws.com/v1/documentation/api/latest/index.html。本篇的 sync_to_s3.py 用 boto3 上傳檔案。
  • 12-Factor App 的「設定分離」原則:https://12factor.net/config。GitHub Actions 的 secrets 是 12-factor 的標準做法。
  • Day 33 失敗通知與重跑:本篇的「模擬通知」沿用 Day 33 的設計,沒有真的串接 SMTP / Slack / LINE,避免測試環境誤寄。

留言

這個網誌中的熱門文章

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 建構深度學習模型。 開發者與研究人員 :想更深入了...