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,避免測試環境誤寄。
留言
張貼留言