AI & Data Dreamers練功房
寶箱片場規則
正片・課程手冊

課程手冊17 - 每日資料線:Airflow 排程與 Composer

本章對應 EP20,是課程最後一章。前置:第 16 章做完(兩台 VM、Cloud SQL、Secret Manager 都在,只是停著)。

資料庫搬上雲了、密碼也交給 Secret Manager 保管了,剩最後一件事:資料線還要人手動推——爬新資料要有人發任務、BigQuery 的 app 層要有人重算。本章用自架 Airflow 把「觸發爬蟲雙寫+重算分析層」排成每個交易日自動執行的一條線,再對照託管版的 Cloud Composer,最後完成雲端環境的驗證與資源清理。

做完本章有兩條選讀路線:想把 API 開到網路上,看補充H(用 Cloud Run 部署 API);想讓每次 push 自動跑測試、自動部署,看補充I(CI 與 CD:GitHub Actions)。

本章用到的工具與服務

工具/服務 類型 在本章做什麼
Compute Engine(GCE) GCP 服務 VM1 自架 Airflow,跑每個交易日的排程
BigQuery GCP 服務 排程重算 stage/app 分析層的地方;雙寫的 raw 資料已經在裡面
Cloud Composer GCP 服務 託管版 Airflow,講師示範與自架對照,示範完刪除
Cloud Logging GCP 服務 Composer 3 的任務 log 集中在這裡讀
存取範圍(scopes) GCP 功能 VM 機器層的舊式權限閘門,與 IAM 疊加的第二道
gcloud/bq CLI 指令工具 開工 SOP、操作 Composer、驗證資料
Airflow 既有工具 第 10 章以來同一套 image 與 DAG

做完這一章你會

  1. 在 GCE 上自架 Airflow 跑每日排程,跑通完整雲端資料線:觸發爬蟲雙寫(Cloud SQL+BigQuery raw)→ 重算 stage/app 分析層
  2. 說得出雙寫架構下排程的分工:為什麼不再需要「搬資料」的 task、為什麼 app 層需要每日重算
  3. 說得出 Cloud Composer 是什麼、跟自架 Airflow 怎麼選
  4. 完成雲端環境的最終驗證與資源清理

先搞懂

完整資料線:雙寫架構下,排程剩兩件事

雙寫讓資料一寫入就同時落在 Cloud SQL 和 BigQuery raw——「把資料從 A 搬到 B」的排程工作直接消失了。每天還需要做的只剩兩件事:①讓爬蟲抓當天的新資料(雙寫自動落兩邊);②重算 BigQuery 的分析層(app 是實體表,不會自己更新)。本章的 DAG 就編排這兩件事:

flowchart TD
    AF["VM1 自架 Airflow"] -.->|"①每個交易日 20:00 發爬蟲任務"| MQ["RabbitMQ(VM1)"]
    MQ -->|任務| W["VM2 worker 爬蟲"]
    W -->|雙寫| SQL[("Cloud SQL<br/>OLTP:營運")]
    W -->|雙寫| RAW[("BigQuery raw<br/>append 落地")]
    AF -.->|"②等 worker 消化後重算"| T["transform:stage view+app 實體表"]
    T --> APP[("BigQuery app<br/>趨勢表、大盤摘要")]
    APP -->|讀取| LS["Looker Studio<br/>BI 儀表板(第 15 章 Step 5 已接)"]
    RAW --> T

看這張圖要分清楚兩種線。實線是資料實際流動的路徑:worker 雙寫落地、transform 從 raw 算出 stage 與 app、Looker Studio 讀 app。虛線是觸發:Airflow 不在資料路徑上,資料不經過它,它只負責在排定的時間發任務、跑重算。

三層各自的「新鮮度」也不一樣,這決定了排程要重算誰(第 15 章的規矩在這裡變成排程設計):

層 形態 新資料進來後 排程要做什麼
raw 實體表 雙寫當下就有了 不用管
stage view 查詢時即時反映 raw 不用管(DAG 仍冪等重建定義,重跑無害)
app 實體表(CTAS) 停在上次重算的樣子 每日重算——排程存在的主因

一步一步

指令裡的 {...} 是佔位符,換值時大括號要一起刪(第 16 章開工段的同一條慣例)。本章 C-6 的探測 DAG 是 Python 檔,裡面的 {VM1內部IP} 佔位符同樣要換乾淨再上傳。

開工前先喚醒系統。照第 16 章收工段的 SOP,外加一步驗證:

# 1. Cloud SQL 喚醒
gcloud sql instances patch stock-mysql --activation-policy=ALWAYS

# 2. 驗證兩台 VM 的存取範圍(本章 Airflow 要寫 BigQuery;第 14/16 章建機時已給)
gcloud compute instances describe stock-crawler-vm --zone=asia-east1-b \
  --format="value(serviceAccounts[].scopes)"
# ['https://www.googleapis.com/auth/cloud-platform']

# 3. 兩台 VM 開機
gcloud compute instances start stock-crawler-vm stock-crawler-vm2 --zone=asia-east1-b

# 4. 查新的外部 IP(每次開機都會換)
gcloud compute instances list

# 5. 用新 IP 重跑授權網路
gcloud sql instances patch stock-mysql \
  --authorized-networks={VM1外部IP}/32,{VM2外部IP}/32

# 6. 確認 VM2 的 worker 跟著回來了(compose 的 restart 政策會自動拉起)
gcloud compute ssh stock-crawler-vm2 --zone=asia-east1-b --command='sudo docker ps --format "{{.Names}}"'

# 7. 同步課程 repo(兩台都要 pull;生效方式不同,見下)
gcloud compute ssh stock-crawler-vm --zone=asia-east1-b --command='cd ~/stock-crawler && git pull'
gcloud compute ssh stock-crawler-vm2 --zone=asia-east1-b --command='cd ~/stock-crawler && git pull'

第 7 步兩台的後續動作不一樣,取決於程式碼怎麼進容器(第 15 章 Step 0 的同一條規則):

機器 程式碼怎麼進容器 pull 之後
VM1 Airflow 容器掛載 dags/ 與 crawler/ 不用做,pull 完即生效
VM2 worker image 用 COPY 烤進去 pull 顯示有更新時,要 rebuild + up 重建

VM2 有更新時的重建(SSH 進 VM2 執行):

cd ~/stock-crawler
sudo docker builder prune -af    # 先清舊 build 快取騰空間,磁碟緊的機器尤其要做
sudo docker compose -f docker-compose-local.yml build worker_twse worker_tpex
sudo docker compose -f docker-compose-local.yml up -d --no-deps worker_twse worker_tpex
sudo docker logs crawler_twse --tail 3    # 要看到 ready.

第 2 步是舊識:**存取範圍(scopes)**是 VM 機器層的舊式閘門——就算 IAM 角色有權限,scopes 沒開放的操作一樣做不了(第 14 章 Part F 講過)。照著課程建機的話兩台 VM 都帶著 cloud-platform,這裡查一下就好。輸出不是 cloud-platform 的話(例如 VM 是在加上 --scopes 參數之前建的),要趁停機補改——scopes 只能在停機狀態下修改:

# 只有驗證沒過才需要做;改完再開機
gcloud compute instances set-service-account stock-crawler-vm \
  --zone=asia-east1-b --scopes=cloud-platform

開機後才發現 scopes 不對的話,就得再停一次機(IP 會再換一次,授權網路也要重設)——這正是「建機時就給對」的價值。

Part A:讓 Airflow 多一個身分——會寫 BigQuery 的排程者

Airflow 從第 16 章 Part A 起就在 VM1 上跑著(發任務的中樞)。本章它多了一個工作:自己動手重算 BigQuery 分析層(transform task)——這需要 GCP_PROJECT_ID,而第 14 章 H-3 已經把它寫進 VM1 的 .env,這裡確認一下就好。兩個學員步驟:

A-1 確認 image 還在(第 14 章 H-2 build 過的那顆):

# 在 VM1 的 ~/stock-crawler
git pull
sudo docker images | grep stock-airflow

看不到的話(例如跑過 docker system prune -af 清理磁碟,它會把「沒有容器在用」的 image 全部清掉),重 build:sudo docker build -f airflow/Dockerfile -t stock-airflow:latest .(約 10 分鐘)。等待的時間可以先讀 Part C 開頭的 Composer 對照表。

A-2 確認 Airflow 拿得到專案 ID(第 14 章 H-3 就寫進 VM1 的 .env 了;compose-all 的 Airflow 服務同樣有 GCP_PROJECT_ID: ${GCP_PROJECT_ID:-} 插值):

# 在 VM1 的 ~/stock-crawler:先確認 .env 裡有這一行(第 14 章 H-3 寫進去的)
grep GCP_PROJECT_ID .env
# 沒有的話補上:echo "GCP_PROJECT_ID=$(gcloud config get-value project)" >> .env

# 確認 Airflow 容器真的拿到了
sudo docker exec airflow-scheduler env | grep GCP_PROJECT_ID
# 沒印出來就重跑 up 讓容器重建(環境變數是容器建立時寫入的,restart 不會更新)
sudo docker compose -f docker-compose-all.yml up -d \
  rabbitmq flower airflow-postgres airflow-init airflow-webserver airflow-scheduler

注意這裡沒有 GOOGLE_APPLICATION_CREDENTIALS 這個變數。第 15 章在本機需要金鑰檔,是因為你的筆電對 GCP 來說沒有身分;VM 本身有服務帳戶這個身分,Google 的程式庫會自動採用。在 VM 上執行的程式不需要金鑰檔,這跟第 16 章 worker 讀 Secret Manager 是同一個機制。

Part B:觸發完整資料線

等 Airflow init 完成(docker ps 看 webserver healthy),觸發每日資料線的 DAG:

sudo docker exec airflow-scheduler airflow dags unpause stock_bigquery_etl_dag
sudo docker exec airflow-scheduler airflow dags trigger stock_bigquery_etl_dag

這支 DAG(airflow/dags/stock_crawler_etl_bigquery_dag.py)就是「先搞懂」那張圖的程式版,六個 task 一條直線:

task 做什麼 對應章節
start_daily_pipeline 起點訊息 —
send_crawler_tasks 把整批股票的爬蟲任務 apply_async 到 twse 佇列——worker 收到就雙寫 發任務=第 12 章串法二;雙寫=第 15 章讀碼段⓪
wait_for_workers 等 worker 消化(教學版固定等待;真實系統用 sensor 盯完成訊號) 發任務是非同步的——第 1 章 .delay() 的老課題
create_stage_layer 冪等重建 stage view(去重定義) 第 15 章 Step 3 的 SQL 包成函式
create_app_layer CTAS 重算 app 兩張成品表 第 15 章 Step 4 的 SQL 包成函式
end_daily_pipeline 收尾訊息 —

注意這裡沒有「搬資料」的 task——舊版課程在這裡放了一支 sync_mysql_to_bigquery(整表從 MySQL 複製進 BigQuery);雙寫讓資料寫入當下就在 raw 了,這個搬運工作整個消失。DAG 掛著 schedule_interval="0 20 * * 1-5"——每個交易日 20:00 自動跑,手動觸發只是驗收用。

一段一段讀懂這支 DAG

先解決一個容易混淆的點:檔名與 DAG id 不同。檔案叫 stock_crawler_etl_bigquery_dag.py,它定義的 dag_id 是 stock_bigquery_etl_dag——dags list、dags trigger、UI 上認的都是 dag_id,跟檔名無關。

① import 段——一支 DAG、兩種分工:

# 發任務用的 Celery task(發到佇列, 不在 Airflow 裡執行爬蟲——串法二)
from crawler.tasks_crawler_finmind import crawler_finmind
from crawler.stock_bigquery_data_transform import (
    create_stage_layer,
    create_app_layer,
)

兩行 import 對應這支 DAG 的兩種工作方式,這是全章最重要的設計:

import 進來的 是什麼 誰執行 對應串法
crawler_finmind Celery task 物件 VM2 的 worker——DAG 只把訊息發進佇列 第 12 章串法二(Airflow 只發任務)
create_stage_layer / create_app_layer 一般 Python 函式 Airflow 自己——在 scheduler 的 task 行程裡直接對 BigQuery 下 SQL 第 12 章串法一(Airflow 親自執行)

爬蟲用串法二,因為它量大、要水平擴充,交給 worker 池;transform 用串法一,因為它只是對 BigQuery 發幾句 SQL、真正的計算在 BigQuery 那端,Airflow 行程只是等結果,不需要 worker。同一支 DAG 裡兩種串法並存,依工作的性質選。

② STOCK_IDS 與 send_crawler_tasks()——producer 搬進了 DAG:

def send_crawler_tasks():
    for stock_id in STOCK_IDS:
        crawler_finmind.apply_async(kwargs={"stock_id": stock_id}, queue="twse")
        print(f"已發送 {stock_id} 爬蟲任務")
  • 這個函式就是第 16 章 producer DAG 的迴圈主體:清單(要抓什麼)+發送(丟進佇列)。
  • 用 apply_async(queue="twse") 不用 .delay():分流版 worker 只聽 twse/tpex 兩個佇列,.delay() 會把任務發到沒人消費的預設佇列,卡在 RabbitMQ 裡不動(第 12 章 producer DAG 同一個理由)。
  • 發完立刻返回——task 綠了只代表「訊息送出去了」,爬蟲成敗要看 Flower 或 worker log。

③ default_args——套用到每個 task 的預設值:

參數 值 意思
owner data-team 負責團隊標記,UI 上顯示用
start_date 2024-01-01 排程起算日——配合 catchup=False,不會回頭補跑
retries / retry_delay 1 次 / 1 分鐘 單一 task 失敗自動重試一次
execution_timeout 1 小時 單一 task 跑超過即視為逾時失敗

retries 有一個要想清楚的地方:send_crawler_tasks 如果發到一半掛掉重試,前半批任務會被重複發送,worker 重複執行、append 出重複列——這不是錯誤而是這套架構的天性,靠第 6 章的去重(stage view)吸收。

④ DAG 本體參數:

參數 值 為什麼
schedule_interval "0 20 * * 1-5" 「分 時 日 月 星期」——週一到週五 20:00(第 9 章 cron 語法);容器時區已設 Asia/Taipei
catchup False 不補跑 start_date 到現在的歷史區間——股價 API 一次抓整段,補跑只會重複
max_active_runs 1 同時最多一個 run——避免上一輪還在寫、下一輪又開始,兩輪交錯寫入

⑤ 六個 task 的實作選擇:

  • start / end 用 BashOperator 印訊息——純粹的頭尾標記,方便在 log 與 Graph 上看出一次 run 的邊界。end 顯式寫了 trigger_rule="all_success"(其實是預設值):上游任何一個失敗,end 就不會跑,一眼看出這輪沒走完。
  • wait_for_workers 是 sleep 90 的固定等待——教學版的取捨。發任務是非同步的(第 1 章 .delay() 的老課題),DAG 必須等 worker 把資料寫完才能重算,否則 transform 算的是舊資料。固定等待的缺點是「猜時間」:等太短,這一輪的新資料沒進到 app 層(不會報錯,下一輪自然補上);真實系統用 sensor 盯完成訊號(例如輪詢 raw 層的最新日期),等到條件成立才放行。
  • create_stage_layer 冪等重建 view——重跑一百次結果相同(第 15 章 Step 3);create_app_layer CTAS 先刪後建重算實體表(第 15 章 Step 4)——它就是這條排程存在的主因,app 是實體表,不重算就停在昨天。

⑥ 依賴鏈——一條直線,>> 讀作「做完才輪到」:

start_task >> send_tasks >> wait_for_workers >> transform_stage >> transform_app >> end_task

stage 在 app 前面不是裝飾:app 的 SQL 讀的是 stage view,順序反了 app 會用舊定義的 stage 算。

unpause 之後 Graph 上可能突然多出一個你沒有觸發的 run。那是排程 DAG 被 unpause 時補跑的最近一期,即使 catchup=False 也會跑這一期,屬於正常行為。

到瀏覽器開 http://{VM1外部IP}:8081(帳密 admin/admin,跟第 14 章 Part I 開的同一個 port),進入這支 DAG 的 Graph 分頁,六個 task 應該全部是綠色的 success;左側格狀圖每一直行是一次 run(unpause 補跑的那期也在裡面):

每日資料線 DAG 六個 task 全綠

等 run 全綠後,用 bq 驗證整條線真的動了(本機或 VM 都能跑):

# 三層筆數:raw 因為這次 run 的雙寫而增加;stage 去重後穩定;app 重算完成
bq query --nouse_legacy_sql \
  "SELECT 'raw' AS layer, COUNT(*) AS n FROM raw.TaiwanStockPrice
   UNION ALL SELECT 'stage', COUNT(*) FROM stage.stock_price_daily
   UNION ALL SELECT 'app.trend', COUNT(*) FROM app.stock_trend_analysis
   ORDER BY layer"

# app 層的技術指標有值
bq query --nouse_legacy_sql \
  "SELECT stock_id, trade_date, ROUND(ma5,2) AS ma5, ROUND(ma20,2) AS ma20
   FROM app.stock_trend_analysis
   WHERE ma20 IS NOT NULL ORDER BY trade_date DESC LIMIT 4"

第二段查詢的輸出長這樣——每列都有 ma5、ma20 的數值,就是 create_app_layer 剛重算出來的成品:

排程後 app 層 MA 值查詢結果

判讀:raw 的筆數比觸發前多(worker 雙寫進來的新一輪 append);stage 的筆數不隨重跑膨脹(去重 view 的效果——同一天的重複列被折疊);app 兩張表是剛剛 create_app_layer 重算出來的最新版。Cloud SQL 那半邊照第 16 章 Part F 的方式驗,筆數同步增加。

到這裡完整資料線就串起來了:排程發任務 → worker 雙寫(Cloud SQL+raw)→ transform 重算(stage/app),第 15 章接好的 Looker Studio 儀表板下次重新整理就會顯示新資料。這條線之後每個交易日 20:00 自動執行,前提是 VM1 開著。

要讓它每天實際執行,VM1 必須一直開著,每月費用約 NT$1,600。課程的做法是上課期間手動觸發驗證,確認排程設定正確即可。這也正是 Composer 這類託管服務的價值之一:執行排程的機器由 Google 維護。

Part C:Composer 示範(講師操作,學員選做)

把 Part B 剛跑過的 stock_bigquery_etl_dag 原封不動搬到 Composer 上執行一次,用來對照託管與自架的差別。做完立刻刪除環境,它按小時計費。

雙寫版的 DAG 第一個 task 要發任務到 VM1 的 RabbitMQ——Composer 跑在 Google 託管的環境裡,本來不在你的 VPC 內。這個網路功課用「建立環境時掛上你的 VPC」(C-2 的 --network/--subnetwork 兩個參數)一次解決:掛上之後 Composer 打過來的流量走你子網路的內部 IP,default-allow-internal 天生放行——不用查它的對外 IP、不用開任何防火牆。C-6 只剩驗證。

Composer:託管版的 Airflow

排程主線用的是 GCE 上自架的 Airflow 容器,跟第 10 章以來同一套 image 和 DAG。GCP 也有託管版本,叫 Cloud Composer。這是第 16 章「託管 vs 自架」的同一道選擇題,換到排程工具上:

自架 Airflow(本章主線) Cloud Composer
機器與維運 你的 VM,你自己維護 Google 負責(底層是 GKE)
費用 VM 費用(已經在付,不會多花) 最小環境每月數百美元起
建置時間 image build 約 10 分鐘 環境建置 20-30 分鐘
DAG 部署方式 放進 dags/ 目錄 上傳到指定的 Cloud Storage bucket
套件與環境變數 寫進 Dockerfile 與 compose,一次處理完 各自透過 Composer 的介面與指令設定
不用的時候 VM stop 就不計費,資料設定都留著 沒有停機選項,只能刪除環境
適合的情況 學習、小規模、預算有限 DAG 數量多、團隊規模大、不希望自己維護 scheduler

課程的安排:主線用自架(學員不用額外付費),Composer 由講師示範——Part C 會把主線這支 stock_bigquery_etl_dag 原封不動搬到 Composer 上跑一次,DAG 的程式碼一行都不改,示範完立刻刪除環境。額度充足的同學可以照 Part C 的步驟自己走一遍,做完務必刪除。

先講結論,Part C 會逐步驗證:編排邏輯在兩邊通用,要各自準備的是執行環境——程式碼怎麼進去、套件怎麼裝、連線設定怎麼給。

兩個版本軸:Composer 世代 vs Airflow 版本

Composer 的版本有兩個獨立的軸,選版本時最容易在這裡混淆:

  • Composer 世代(第 2 代 / 第 3 代):講的是 Google 那側的基礎設施怎麼架。
  • Airflow 版本(2 / 3):講的是環境裡跑的軟體是哪一版。
代管 Airflow 第 2 代(Composer 2) 代管 Airflow 第 3 代(Composer 3)
可跑的 Airflow 只有 Airflow 2 Airflow 2 或 Airflow 3(映像檔版本決定)
基礎設施 跑在你專案看得到的 GKE 上 GKE 完全由 Google 代管,你的專案裡看不到
任務 log 存 bucket 送 Cloud Logging(C-6 讀 log 的方式因此不同)
課程用哪個 — 這個,映像選 composer-3-airflow-2

順帶一提名稱:這個產品在 Console 上已改名「Managed Service for Apache Airflow(原為 Cloud Composer)」,左側選單叫「代管 Airflow」——文件和指令(gcloud composer ...)仍沿用 Composer 這個名字,搜尋時兩個名稱都認得就好。更早還有一個 Composer 1,已淘汰,建立選單上看不到了。

課程選「第 3 代 + Airflow 2」:世代選 3 是要新的基礎設施;Airflow 選 2 是因為自架環境跑的就是 Airflow 2,同一支 DAG 兩邊通用的前提是軟體同版。建立表單的映像檔版本下拉裡,Airflow 3 的映像排在最上面(例如 composer-3-airflow-3.2.2),認明 composer-3-airflow-2 開頭的才選——選到 Airflow 3,課程的 DAG 一上傳就會 import error。

Airflow 2 與 Airflow 3 差在哪(為什麼課程的 DAG 在 3 上會壞)

Airflow 3 是 2025 年的大版本,重點放在架構重整,對 DAG 程式碼有不相容的改動。跟課程直接相關的四條:

改動 Airflow 2 的寫法(課程用的) Airflow 3
排程參數 schedule_interval="0 20 * * 1-5" 參數改名 schedule,舊名移除
Operator import 路徑 from airflow.operators.python_operator import PythonOperator(1.x 相容路徑) 相容路徑移除,DAG 直接 import error
時間語意 context 裡的 execution_date 改名 logical_date,舊名移除
任務讀資料庫 task 行程可直連 Airflow 的 metadata DB 拆掉直連,worker 一律走 API——自訂程式碼碰過 metadata DB 的都要改

除了不相容改動,3 還帶來新東西:全新的 React 介面、DAG 版本管理(UI 上看得到每次部署的差異)、事件驅動排程(Dataset 改名 Asset 並擴充)。判斷句是:新專案從 Airflow 3 開始沒問題;既有的 Airflow 2 專案(例如本課程)升級前要先盤點上表四條——這也是為什麼 Composer 3 同時供應兩種映像,讓存量的 Airflow 2 用戶不被逼著升級。

C-1 啟用 API 並授權服務帳戶

gcloud services enable composer.googleapis.com iamcredentials.googleapis.com

gcloud projects add-iam-policy-binding {你的專案ID} \
  --member="serviceAccount:{專案編號}-compute@developer.gserviceaccount.com" \
  --role="roles/composer.worker"

同一件事的 Console 版:「≡ → API 和服務 → 程式庫」分別搜尋 Cloud Composer API 與 IAM Service Account Credentials API,各按一次「啟用」(跟第 16 章 C-1 啟用 SQL Admin API 同一個頁面與流程)。

Composer 除了自己的 API,還相依 iamcredentials.googleapis.com。只開前者,建立環境時會被擋下來:

FAILED_PRECONDITION: Please enable all APIs Cloud Composer depends on.
List of APIs: iamcredentials.googleapis.com.

環境要跑在一個服務帳戶身分下,這裡沿用第 14 章就存在的 Compute Engine 預設服務帳戶,補上 composer.worker 角色。

C-2 建立環境

gcloud composer environments create stock-composer \
  --location=asia-east1 \
  --image-version=composer-3-airflow-2 \
  --environment-size=small \
  --service-account={專案編號}-compute@developer.gserviceaccount.com \
  --network=default --subnetwork=default \
  --async

--network=default --subnetwork=default 是本章的關鍵參數:把環境掛進你的 VPC(Composer 會在你的專案建立一個 network attachment,並在子網路裡保留 4 個 IP)。掛上之後,DAG 連你 VPC 裡的服務(VM1 的 RabbitMQ)走內部網路,跟 VM2 連 VM1 是同一條路——這讓 C-6 的網路功課幾乎消失。不帶這兩個參數的話,Composer 從公網連進來、來源 IP 是 Google 自動配發且查不到的值,就得走「探測 IP+開防火牆白名單」的繞路(C-6 附註)。

建立時忘了帶網路參數也不用重建——Composer 3 允許對現有環境事後補掛(一次約 5-10 分鐘的環境更新)。注意 update 跟 create 不同,必須用完整資源路徑,短名會報 Failed to parse subnetwork:

gcloud composer environments update stock-composer --location=asia-east1 \
  --network=projects/{你的專案ID}/global/networks/default \
  --subnetwork=projects/{你的專案ID}/regions/asia-east1/subnetworks/default

--async 讓指令立刻返回,環境在背景建立,需要 20-30 分鐘——它在準備一整套跑在 GKE 上的 Airflow。查進度:

gcloud composer environments list --locations=asia-east1

同一件事的 Console 版(表單看懂欄位、實際建立擇一即可):≡ →「代管 Airflow」→「建立環境」下拉,先選世代——「代管 Airflow 第 3 代」(Part C 開頭「兩個版本軸」小節的 Composer 3):

建立環境入口兩代選單

表單的預設值與要改的欄位(對照 CLI 參數):

表單欄位 預設值 要改成 對應 CLI 參數
名稱 空 stock-composer 第一個參數
位置 us-central1 asia-east1 --location
映像檔版本 composer-3-airflow-2.x(安全) 保持 airflow-2 開頭的 --image-version
服務帳戶 需選擇 Compute Engine 預設服務帳戶 --service-account
環境資源 小 保持「小」 --environment-size=small
網路設定 未掛 VPC 選 default 網路與子網路 --network / --subnetwork

映像檔版本下拉是本表單最大的坑:Airflow 3 的映像排在最上面,預設值雖然是 airflow-2,手滑選到 airflow-3 課程 DAG 一上傳就 import error(Part C 開頭的 Airflow 2/3 對照表):

映像版本下拉

表單填好的樣子

表單的「網路設定」區塊就是 --network/--subnetwork 的表單版——點「顯示網路設定」展開後,選 default 網路與子網路:

建立表單網路區塊

STATE 從 CREATING 變成 RUNNING 才算建好。建好之後 Console 上(≡ →「代管 Airflow」)看得到這一列——服務版本 3、Airflow 版本、以及右邊三個入口:Airflow 網頁伺服器(C-3 那個網址)、DAG 清單、DAGs 資料夾(就是下一步要上傳的那個 bucket):

Composer 環境清單

這裡有一個要先知道的限制:建立中的環境刪不掉。

ERROR: Cannot delete environment in state CREATING.
Environment must be in RUNNING or ERROR state.

也就是說按下建立之後就沒有反悔的餘地,那 20-30 分鐘的費用一定會發生。這一點跟 VM 和 Cloud SQL 很不一樣,下面的 C-8 會再說明。

C-3 查出這個環境的 DAGs bucket 與網頁介面

gcloud composer environments describe stock-composer --location=asia-east1 \
  --format="value(config.dagGcsPrefix,config.airflowUri)"

第一個值是 DAGs bucket 路徑,長得像 gs://asia-east1-stock-composer-{編號}-bucket/dags;第二個是 Airflow 網頁介面的網址,開起來跟你自架的那個介面一模一樣。

C-4 上傳 DAG 與 crawler 模組

自架環境能跑爬蟲 DAG,是因為 compose 檔用 volume 把 ../crawler 掛進容器(第 10 章設定的)。Composer 沒有這個掛載,所以 crawler 要自己送上去。

stock_crawler_etl_bigquery_dag.py(檔名與它定義的 DAG id stock_bigquery_etl_dag 不同,清單與觸發認的都是 DAG id)開頭 import 的就是它:

from crawler.tasks_crawler_finmind import crawler_finmind
from crawler.stock_bigquery_data_transform import (
    create_stage_layer,
    create_app_layer,
)

Composer 會把 DAGs 資料夾加進 PYTHONPATH,所以把整個 crawler 目錄放進去,DAG 就 import 得到:

BUCKET=gs://asia-east1-stock-composer-{編號}-bucket/dags

# DAG 本身
gcloud storage cp airflow/dags/stock_crawler_etl_bigquery_dag.py $BUCKET/

# crawler 模組整包(DAG 靠它 import)
gcloud storage cp -r crawler $BUCKET/

上傳的 crawler/ 就是你本機這一份,不需要任何修改——所有環境相關的值(專案 ID、主機位址)都走環境變數,下一步在 Composer 上設定。上傳結果在 Console 也看得到:環境清單那一列的「DAGs 資料夾」連結會開到 bucket 的 dags/,DAG 檔和 crawler/ 目錄並排躺在裡面:

DAGs 資料夾內容

Composer 大約一到兩分鐘同步一次 bucket,再由 Airflow 解析。等 DAG 出現:

gcloud composer environments run stock-composer --location=asia-east1 dags list

這裡會發現主線 DAG 沒有出現——清單裡只有其他 DAG,stock_bigquery_etl_dag 不在。DAG import 失敗時就是這個症狀:清單沒有它、UI 上也看不到。查 import 錯誤:

gcloud composer environments run stock-composer --location=asia-east1 dags list-import-errors
/home/airflow/gcs/dags/stock_crawler_etl_bigquery_dag.py | Traceback (most recent call last):
|   File "/home/airflow/gcs/dags/crawler/tasks_crawler_finmind.py", line 15, in <module>
|     from crawler.worker import app
|   File "/home/airflow/gcs/dags/crawler/worker.py", line 6, in <module>
|     from loguru import logger
| ModuleNotFoundError: No module named 'loguru'

traceback 由下往上讀,三層就找到根因:

  1. DAG 要 import tasks_crawler_finmind(它負責發 Celery 任務)
  2. 這支又 import crawler.worker
  3. 而 worker.py 用了 loguru——Composer 3 的映像沒有內建這個套件

自架環境不會踩到這個坑:套件是 docker build 時照 pyproject.toml 整包裝進 image 的。Composer 的映像裝什麼由 Google 決定,程式用到清單外的套件就要自己補。查內建清單、補裝套件:

# 先看映像內建了什麼(pandas、PyMySQL、SQLAlchemy、google-cloud-bigquery 都在,loguru 不在)
gcloud composer environments list-packages stock-composer --location=asia-east1

# 補裝——這又是一次環境更新,指令要等它跑完(**十幾分鐘**,不是幾秒)
gcloud composer environments update stock-composer --location=asia-east1 \
  --update-pypi-package=loguru

補套件為什麼要等這麼久:Composer 3 的環境跑在 GKE 上,加一個套件等於重建映像再滾動更新所有元件,跟自架環境改一行 pyproject.toml 重 build 的量級完全不同。指令跑完前 environments describe 的 STATE 會停在 UPDATING。

同一件事的 Console 版:環境詳情頁的「PyPi 套件」分頁——「編輯」加一列 loguru、儲存,等的還是同一次環境更新:

PyPI 套件分頁

更新完成後重跑 dags list,stock_bigquery_etl_dag 出現,代表 crawler 匯入成功。這一步是託管環境的第一課:「環境裡有什麼套件」不再由你的 Dockerfile 決定——先查清單、缺的用它的介面補、每補一次等一次更新。

C-5 套用環境變數

DAG 在 Composer 上需要的環境變數只有兩個:發任務要知道 RabbitMQ 在哪(RABBITMQ_HOST)、transform 要知道 BigQuery 專案(GCP_PROJECT_ID)。自架環境 .env 裡的 MYSQL_* 這裡不用設——用到它們的程式碼(upload_data_to_mysql)只在 VM2 的 worker 上執行,DAG 從頭到尾不連 MySQL。

兩個值照第 16 章 Part E 的紀律用指令查、不手抄。RABBITMQ_HOST 填 VM1 的內部 IP——C-2 掛了 VPC,Composer 連過來走內部網路,跟 VM2 連 VM1 是同一顆 IP:

RMQ=$(gcloud compute instances describe stock-crawler-vm --zone=asia-east1-b --format='value(networkInterfaces[0].networkIP)')
PROJ=$(gcloud config get-value project)

gcloud composer environments update stock-composer --location=asia-east1 \
  --update-env-variables=RABBITMQ_HOST=$RMQ,GCP_PROJECT_ID=$PROJ

這是一次環境更新,要等幾分鐘,不是改完立刻生效。設定結果在環境詳情頁的「環境變數」分頁看得到(在這頁用「編輯」改也等效,一樣觸發環境更新):

環境變數分頁

漏掉 GCP_PROJECT_ID 的話,config.py 拿到空字串,兩個 transform task 會因為表名少了專案段(`.stage.stock_price_daily`)而報 SQL 語法錯誤——task 紅掉,補上環境變數重觸發即可。(雙寫那半邊不受 Composer 的變數影響:實際寫入的是 VM2 的 worker,它讀的是自己 .env 裡的專案 ID。)

C-6 驗證網路:Composer 連得到 RabbitMQ 嗎

C-2 掛了 VPC 之後,這一節沒有任何門要開——Composer 打過來的是你子網路的內部流量,default-allow-internal 天生放行 5672;Cloud SQL 更是從頭到尾不關 DAG 的事(連它的是 VM2 的 worker,第 16 章就授權好了)。但「應該通」和「真的通」是兩回事,用一支一次性的探測 DAG 驗證:

# probe_network_dag.py:驗證 Composer 經 VPC attachment 連得到 VM1 的 RabbitMQ
from datetime import datetime

from airflow import DAG
from airflow.operators.python import PythonOperator


def try_rabbitmq():
    import socket

    s = socket.socket()
    s.settimeout(15)
    try:
        s.connect(("{VM1內部IP}", 5672))
        print("MQ_CONNECT=OK")
    except Exception as e:
        print(f"MQ_CONNECT=FAIL {type(e).__name__}: {e}")
    finally:
        s.close()


with DAG("probe_network_dag", start_date=datetime(2024, 1, 1),
         schedule_interval=None, catchup=False) as dag:
    PythonOperator(task_id="try_rabbitmq", python_callable=try_rabbitmq)

只做 TCP 連線測試、不帶帳號密碼——把「網路通不通」跟「帳密對不對」分開驗證。注意它連線失敗 task 也不會紅(結果印進 log,不丟例外),OK 或 FAIL 要到 log 裡看。

這支檔案課程 repo 已附好:gcp/probe_network_dag.py——把裡面的 {VM1內部IP} 換成你的值即可,不用手打整支程式。它刻意不放 airflow/dags/(那個目錄掛載進自架 Airflow,放了會讓兩邊都看到這支 Composer 專用工具)。

上傳、觸發、讀 log。Composer 3 的任務 log 送到 Cloud Logging,不放在 bucket 裡:

gcloud storage cp gcp/probe_network_dag.py {你的DAGs bucket}/

gcloud composer environments run stock-composer --location=asia-east1 \
  dags trigger -- probe_network_dag

gcloud logging read 'resource.type="cloud_composer_environment" AND textPayload:"MQ_CONNECT="' \
  --limit=1 --freshness=10m --format="value(textPayload)"
# MQ_CONNECT=OK

OK 就往 C-7 走。FAIL TimeoutError 的話回頭檢查兩件事:C-2 建立時有沒有帶 --network/--subnetwork(環境詳情頁「環境設定」分頁看得到網路欄位)、探測 DAG 裡填的是不是 VM1 的內部 IP(10.x 開頭)。

重新觸發之後如果讀到的還是上一次的結果,是 Cloud Logging 還沒收到新 log。--freshness 縮短到 2m 再讀一次,或等一分鐘。

附註——沒掛 VPC 的環境怎麼辦(舊路,並且認識標準做法):環境沒掛 VPC 時,Composer 從公網連進來,來源 IP 是 Google 自動配發的值,describe 與 Console 都查不到、也不保證固定——只能用探測 DAG 自問(加一個開 ifconfig.me 的 task)、再把問到的 IP 加進防火牆白名單,示範完刪規則。這條繞路就是「沒走正門」的代價。至於連 Cloud SQL 的正門則是 Cloud SQL Connector(cloud-sql-python-connector,IAM 授權+TLS,完全不需要 IP 白名單)——本課程的 DAG 不連 Cloud SQL 所以用不上,真實系統要連就用它,不要玩 IP。

C-7 觸發並驗證

gcloud composer environments run stock-composer --location=asia-east1 \
  dags trigger -- stock_bigquery_etl_dag

# 看整個 run 的狀態
gcloud composer environments run stock-composer --location=asia-east1 \
  dags list-runs -- -d stock_bigquery_etl_dag -o plain

# 看六個 task 各自的狀態
gcloud composer environments run stock-composer --location=asia-east1 \
  tasks states-for-dag-run -- stock_bigquery_etl_dag {run_id} -o plain

六個 task 全部 success,回頭用 Part B 的三層查詢驗證:raw 又多了一輪雙寫、app.stock_trend_analysis 是 Composer 那次 run 重算的——同一支 DAG、同一份 crawler、同一個 Cloud SQL 與 BigQuery,換的只是誰在跑 scheduler。

也可以直接開 C-3 查到的網頁介面(要用你的 Google 帳號登入一次),用 UI 觸發與觀察,操作方式跟自架的完全相同——連版面都一樣,差別只在頁尾多一行 Environment Name:

Composer 上的 DAG 六個 task 全綠

順帶認識「網路沒通」在 Airflow 上的長相(下圖來自一個沒掛 VPC、也還沒開白名單的環境):send_crawler_tasks 紅色 failed(連不到 VM1 的 5672),下游四個 task 全是橘色的 upstream_failed;網路接對之後整排綠。C-6 的驗證先做,就是為了不讓主線 DAG 死在這裡:

授權前後兩次 run 的對照

C-8 刪除環境

gcloud composer environments delete stock-composer --location=asia-east1 --quiet
gcloud composer environments list --locations=asia-east1   # 清單空了才是真的停止計費

環境刪除時,C-2 掛 VPC 用的 network attachment 會一併自動刪除——沒有殘留的網路資源要收。

Composer 沒有「停機」這個選項。 VM 可以 stop、Cloud SQL 可以設 activation-policy=NEVER、Cloud Run 沒流量自動縮到零——它們停下來之後資料和設定都還在,要用再開回來。Composer 只有 create、update、delete 三種操作,環境存在就計費,唯一的省錢方式是刪掉,而刪掉就什麼都不留。

這也是為什麼「示範完立刻刪除」不是建議而是必要步驟。

這次示範帶出的三件事

一、套件不一定要自己裝,但要先查。 C-4 已經真的踩過一次:DAG 需要的 pandas、PyMySQL、SQLAlchemy、google-cloud-bigquery、pyarrow 映像都內建,偏偏 loguru 沒有——查清單、補裝、等一次環境更新,DAG 才出現。同一份 list-packages 清單也會告訴你版本可能跟你本機不同——例如 SQLAlchemy 是 1.4 而不是專案 pyproject.toml 寫的 2.x,程式用到 2.0 才有的寫法就會出問題。FinMind 也不在清單裡,要跑會呼叫 FinMind API 的其他爬蟲 DAG 就得比照補裝。

二、「環境裡有什麼」變成要透過它的介面管理。 自架時 docker build 一次處理完的事——程式碼進 image、套件裝進 image、環境變數寫在 compose——在 Composer 上拆成三件獨立的事:上傳程式碼到 bucket、在設定頁裝套件、用 update 指令改環境變數。每一件都是一次操作,套件與環境變數還各自要等一次環境更新。這是託管服務的隱藏成本:省下維護機器的力氣,換來一套要學的管理介面。

三、「DAG 兩邊通用」要講得精確一點。 編排邏輯——DAG 的結構、任務依賴、排程設定、Operator 用法——完全通用,這次連一行都沒改。要各自準備的是執行環境:程式碼怎麼進去、套件怎麼裝、連線設定怎麼給、網路怎麼接(本章用掛 VPC 一次解決)。評估要不要換到託管服務時,會花時間的是後面那一半。

團體專案上雲:本章設定的團隊版

前置:第 14 章〈團體專案上雲〉做完。

先說這一節在做什麼。 本章你把排程跑起來了:Airflow 每個交易日自動觸發同步,資料線不再需要人。但排程系統有個特性——它是全組共用的單點:一個人改壞 DAG,全組的排程一起停;一個人忘了停機,錢從開專案者的額度一直流。換成團體專題,四件事要先講好:

  1. scopes 誰驗、出問題誰補改?——照課程建機的話 scopes 已經到位,但補改只能停機做,多人環境會出現「A 停機改設定、B 正在等開機」的衝突,要指定人 →(T-1)
  2. Airflow 的 admin/admin 要換嗎?——要。白名單裡有全組的 IP,介面被看到的面變大了 →(T-2)
  3. 排程要每天真的跑嗎?——這是錢的問題不是技術問題:真的每天跑=VM 一直開著計費,要全組同意 →(T-3)
  4. DAG 誰都能改,怎麼避免改壞?——立規矩:修改走 git,不直接在 VM 上動檔案 →(T-4)

前兩個是設定(做一次就好),後兩個是約定(要全組遵守)——排程系統的團隊化,一半靠設定、一半靠紀律。

層 解決的問題 要做什麼 誰做 段落
機器 scopes 補改要停機、多人操作會互相干擾 開工 SOP 驗證;不對時由開專案者停機補改 開專案者 T-1
介面帳密 admin/admin 暴露面變大 CLI 或 UI 改密碼;進階:每人一個帳號 開專案者 T-2
費用 每天真的跑=持續計費 全組選定執行模式 全組約定 T-3
變更管理 DAG 改壞全組停擺 修改一律走 git 全組約定 T-4

T-1 scopes 的驗證與補改都歸開專案者

照第 14/16 章的建機指令,兩台 VM 的 scopes 已是 cloud-platform——開工 SOP 第 2 步驗一下就好。會需要補改的是「VM 在加上 --scopes 參數之前就建好」的小組:補改只能停機做、改一次全 VM 生效——這是機器層設定,跟哪位組員操作無關,指定開專案者處理,避免「A 停機改 scopes 的同時 B 正在等機器開」。指令與輸出:

gcloud compute instances set-service-account {VM名} --zone=asia-east1-b --scopes=cloud-platform
# Updated [https://www.googleapis.com/compute/v1/projects/{專案ID}/zones/asia-east1-b/instances/{VM名}].

# 驗證 scopes 已改
gcloud compute instances describe {VM名} --zone=asia-east1-b --format="value(serviceAccounts[].scopes)"
# ['https://www.googleapis.com/auth/cloud-platform']

Console 的核對位置:Compute Engine → VM 執行個體 → 點 VM 名稱 → 詳細資訊頁往下捲到「API 與身分識別管理」段——「Cloud API 存取權範圍」顯示「允許所有 Cloud API 的完整存取權」就是 cloud-platform 生效的樣子(VM 停機中也看得到):

VM 詳細頁 API 存取權範圍

T-2 Airflow 的 admin/admin 要換掉

課程沿用預設帳密,防線是 8081 只對白名單 IP 開;團體專案的白名單有全組的 IP,Web 介面被看到的面變大了。用 Airflow CLI 改(在 VM1 上執行,一條指令):

sudo docker exec airflow-webserver airflow users reset-password -u admin -p '{全組共用的強密碼}'
# User "admin" password reset successfully

改完用瀏覽器驗證:舊的 admin/admin 會被拒、新密碼進得去——

Airflow 舊密碼登入被拒

Airflow 新密碼登入成功

不想下指令的話,UI 介面也能改:登入後點右上角使用者頭像 → Your Profile(或直接開 http://{VM1外部IP}:8081/users/userinfo/),個人資料頁左下角有「Reset my password」按鈕:

Airflow 個人資料頁

按下去進到重設表單,輸入兩次新密碼按 Save 即完成。表單上方寫著「this application does not check this for you」——Airflow 不幫你檢查密碼強度,弱密碼它照收,強度要自己把關:

Airflow 重設密碼表單

新帳密的存放比照第 14 章步驟 6:寫在 VM 上的共用位置(例如 .env 同目錄的說明檔),不走聊天室。

再進一步:每人一個帳號,取代全組共用。 Airflow 支援多使用者,一條指令開一個帳號:

sudo docker exec airflow-webserver airflow users create \
  -u alice -p '{她自己的密碼}' -f Alice -l Chen -r Admin -e alice@example.com
# User "alice" created with role "Admin"

sudo docker exec airflow-webserver airflow users list
# id | username | email             | first_name | last_name | roles
# 1  | admin    | admin@example.com | Apache     | Airflow   | Admin
# 2  | alice    | alice@example.com | Alice      | Chen      | Admin

好處是留下紀錄:誰觸發了哪次 run、誰改了哪個設定,UI 的稽核資訊對得到人——DAG 改壞的時候(T-4 的情境)不用猜是誰動的。組員退出專題時 airflow users delete -u alice 收回。小組規模的取捨:兩三個人共用一組密碼還管得動,四五個人以上建議直接每人一個帳號。

T-3 「每天自動跑」是費用決策,要全組同意

排程真的每天執行的前提是 VM1 一直開著——這筆錢算在開專案者頭上(第 14 章步驟 1)。兩種模式全組選一個:上課/驗收期間手動觸發、平時停機(課程做法,費用趨近零);或 demo 前一週讓它真的每天跑(接受該週的 VM 費用,週末照停)。費用量級用第 14 章「從帳單看花費」補充的報表確認。

T-4 DAG 的修改走 git,不直接在 VM 上改

多人共用一台 VM 時,dags/ 目錄裡的檔案誰都能動;改壞了一個 DAG,全組的排程一起停。規矩跟程式碼一樣:本機改 → push → VM 上 git pull——出問題時 git log 找得到是哪次改動(補充I 的 CI 就是這條規矩的自動檢查版)。

收工:兩種收法

今天先收工(課程還沒結束)——照第 16 章三停:SQL NEVER、兩台 VM stop。

課程結束、確定不再使用——照順序全部刪除,刪完帳單歸零:

順序 資源 指令/位置 為什麼是這個順序
1 Composer 環境(如果有建立) gcloud composer environments delete stock-composer --location=asia-east1 費用最高,而且沒有停機選項,只能刪除
2 Cloud Run 服務(若做過補充H) gcloud run services delete stock-api --region=asia-east1 閒置本來就縮零不計費,結束時一併刪除
3 兩台 VM gcloud compute instances delete ... 磁碟跟著 VM 一起消失
4 Cloud SQL gcloud sql instances delete stock-mysql 儲存費
5 Artifact Registry(若做過補充H) gcloud artifacts repositories delete stock-repo image 儲存費
6 BigQuery dataset bq rm -r -d raw/stage/app/lab(各跑一次) 儲存費(免費層內,可留最後)
7 Spanner 試用機 gcloud spanner instances delete stock-spanner-trial 本來就 $0;純粹收乾淨(額度不會退還,先確定真的不玩了)
8 Secret gcloud secrets delete mysql-password 費用趨近零
9 整個專案(終極選項) Console → IAM與管理 → 設定 → 關閉 上面全部一次帶走;30 天寬限期內可反悔還原

檢查:這一章做完的狀態

  • VM1 的 Airflow 起得來,stock_bigquery_etl_dag 六個 task 全綠
  • 觸發後 raw 筆數增加、stage 筆數不隨重跑膨脹、app.stock_trend_analysis 的 MA 值是最新重算的
  • 說得出為什麼這條 DAG 裡沒有「搬資料」的 task
  • 說得出 Composer 與自架的取捨
  • 收工(三停),或課程結束的資源全部刪除

想一想

  1. Airflow 的 DAG 掛著 0 20 * * 1-5 這個排程,但 VM1 關機時它不會執行。要讓它每天真的跑,你有哪些選擇?各自的成本是多少?
  2. Composer 每月數百美元,自架 Airflow 用現有的 VM 幾乎不多花錢。什麼情況下這筆錢是值得付的?
  3. create_app_layer 用的是 CTAS 全表重算。資料量大十倍後該怎麼改?(提示:raw 是日期分區表,MA20 只需要每支股票最近的一段歷史——重算可以只涵蓋受新資料影響的區間,再 MERGE 回成品表)
  4. 雙寫已經讓 raw 即時更新,為什麼 app 層不干脆也做成 view,讓排程完全消失?(提示:第 15 章「View vs Table」的取捨——報表每次開啟都重算整段視窗函數的代價)

練習

  1. 照第 16 章的做法建第二顆 secret rabbitmq-password,只授權給 VM 的服務帳戶,再照 D-4 的做法把它取出寫進 .env(echo "WORKER_PASSWORD=$(gcloud secrets versions access latest --secret=rabbitmq-password)" >> .env),compose 檔的 worker 環境變數補上對應的插值行
  2. 對 stock_bigquery_etl_dag 的 schedule_interval 解讀:"0 20 * * 1-5" 是什麼時間?改成「每小時」怎麼寫?(第 9 章 cron 語法的複習)

排錯

症狀 原因 處理
airflow up 報 stock-airflow image 不存在 之前跑過 prune -af,把沒在用的 image 清掉了 A-1 重 build
瀏覽器連 8081 逾時 你的對外 IP 換了,防火牆規則 allow-stock-web 還是舊 IP curl -4 ifconfig.me 查目前 IP,再 gcloud compute firewall-rules update allow-stock-web --source-ranges={新IP}/32
DAG 全綠但 raw 筆數沒增加 send_crawler_tasks 只負責發任務——worker 那端沒起來、或 worker 沒拿到 GCP_PROJECT_ID(log 印「BQ 未設定,略過雲端寫入」) 查 VM2 的 worker 容器狀態與 env;Flower 看任務是否被消化
transform task 紅掉、SQL 錯誤裡表名少了專案段 Airflow 容器沒拿到 GCP_PROJECT_ID(.env 漏了那行,或加了沒重跑 up) 補 .env 重新 up(A-2),重觸發 DAG
transform task 報 Not found: Dataset raw 這個專案還沒有雙寫過任何資料(raw 是第一次雙寫時建的) 先讓爬蟲跑過一輪(第 14 章 H-3/H-4),或等 DAG 的 wait 之後重試
worker 報 403 Quota exceeded: ... partition modifications 同一天反覆觸發全量爬蟲,分區表的每日分區修改配額被燒完(第 15 章排錯表同一條) 等隔天重置;MySQL/Cloud SQL 那半邊不受影響,隔天排程恢復正常
unpause 後多一個沒觸發過的 run 排程 DAG unpause 會補跑最近一期(catchup=False 也一樣) 正常現象;不想要就在 unpause 前先 trigger 手動 run 驗證
BigQuery 寫入 403 VM scopes 不含 BigQuery(建機時沒給 --scopes=cloud-platform) 停機 → set-service-account --scopes=cloud-platform → 開機 → 重授權 Cloud SQL(開工 SOP 第 2 步的補改流程)
Composer 建立回報 FAILED_PRECONDITION: Please enable all APIs 只開了 composer.googleapis.com,還相依 iamcredentials.googleapis.com 兩個一起 enable 後重下建立指令
Composer 環境刪不掉,回報 Cannot delete environment in state CREATING 建立中的環境不能刪 等 STATE 變成 RUNNING 再刪;建立那 20-30 分鐘的費用無法迴避
DAG 上傳了但 dags list 看不到 DAG import 失敗——crawler 模組沒一起上傳,或用到映像沒有的套件(例如 loguru) dags list-import-errors 看 traceback;缺模組就 gcloud storage cp -r crawler {bucket}/dags/、缺套件就 --update-pypi-package 補裝(C-4 的流程)
import error 指向 airflow.operators.python_operator 這類路徑 環境映像選到 Airflow 3(建立表單下拉最上面就是 airflow-3 的映像),1.x 相容 import 路徑在 3 已移除 Airflow 大版本不能原地換——刪掉環境重建,映像認明 composer-3-airflow-2 開頭(Part C 開頭的版本軸小節)
Composer 上任務報 Access Denied: Project your-project-id 沒設 GCP_PROJECT_ID 環境變數,config.py 退回預設值 environments update --update-env-variables=GCP_PROJECT_ID=...
Composer 上 send_crawler_tasks 連 RabbitMQ 逾時 環境沒掛 VPC(C-2 漏了 --network/--subnetwork),或 RABBITMQ_HOST 沒填 VM1 內部 IP 環境詳情「環境設定」分頁確認網路欄位;沒掛就重建(attachment 不能事後隨意補),變數照 C-5 用內部 IP
找不到 Composer 的任務 log Composer 3 的 log 送到 Cloud Logging,不在 bucket gcloud logging read 'resource.type="cloud_composer_environment"'

本章總結

  • 金鑰檔只有 GCP 外面的程式才需要;VM 上的程式用自己的服務帳戶身分,不需要金鑰
  • 雙寫讓「搬資料」的排程工作消失;每日資料線剩兩件事:觸發爬蟲雙寫、重算分析層——排程真正在養的是 app 這種不會自己更新的實體表
  • 完整資料線:排程發任務 → worker 雙寫(Cloud SQL+BigQuery raw)→ transform 重算(stage/app)→ Looker Studio
  • Composer 是託管版 Airflow:編排邏輯兩邊通用(同一支 DAG 一行都不用改),要各自準備的是執行環境——程式碼上傳、套件安裝、環境變數、網路(建立時掛上你的 VPC,內部互連就通,不用查 IP 開白名單)
  • Composer 沒有停機選項,只有 create/update/delete,環境存在就計費——示範完必須刪除
  • 資源清理照順序刪:費用高的先刪,關閉整個專案是最後手段(30 天內可還原)

主線十七章的內容到此結束。起點是一支印出「發送任務」的 Celery 腳本,終點是一條具備佇列分流、失敗重試、去重冪等、容器化、多機分工、託管資料庫、機密管理與每日排程的雲端資料管線。兩章選讀把它再往外推一步:補充H 讓 API 對外服務、補充I 讓每次 push 自動測試與部署。這門課要傳達的不是這條管線本身,而是拆解它的方法——之後你會需要自己設計別的管線。