Celery worker/beat 遷移至 Cloud Run worker_pool + RedBeat scheduler
Context
YiBi 的 Celery worker 目前以 google_cloud_run_v2_service 部署(infra/terraform/modules/cloud-run/main.tf:180)。Cloud Run service 是 HTTP-request-driven 資源:容器必須監聽 $PORT 才能通過 startup probe。但 Celery worker 不是 HTTP 服務,為了騙過 probe,backend 加了一段 CloudRunHealthServer bootstep(backend/src/celery_app.py:52-79)在 port 8080 起一個假 HTTP server,worker_ready 前回 503、之後回 200。
這個「用錯抽象」的 impedance mismatch 衍生出一連串維運陷阱,已累積在 infra/CLAUDE.md:
BETAlaunch stage 會自動插入 TCP startup probe → 無 HTTP sidecar 的 worker 永遠起不來(被迫用 GA)- bash sidecar 與 bootstep 搶 port 8080 →
Address already in use→ worker crash(PR #327+#331 事故) cpu_idle=false逆著 service 的 request-based 計費模型,才能讓 Celery 在無 HTTP request 時有 CPU- GA 手動
startup_probe的 130 秒 cold-start window 調校
同時,celery_app.conf.beat_schedule(celery_app.py:121-134)定義了 3 個每日排程任務(aggregate_daily_usage 01:00、check_license_expiry 02:00、fleet_health_scan 06:00),但 worker 以 celery worker(無 -B)啟動,這些任務從未執行過。不能直接在 worker 加 -B:prod worker max_instance_count=2,水平擴展時每個 pod 都會觸發 beat → 排程任務重複跑。Beat 必須是 singleton。
google_cloud_run_v2_worker_pool 是 GCP 專為非 HTTP 背景工作設計的資源型別,不要求容器監聽 port,但需要 google provider ~> 7.35+;目前 versions.tf(staging / prod / load-balancer 三處)鎖在 ~> 5.0(lock 在 5.45.2)。
對應追蹤票:#308(Phase 1 驗證)、#309(Phase 2 beat)、#310(Phase 3 worker_pool)。
Decision
結論:升級 google provider 至 ~> 7.35+,把 Celery worker 從 google_cloud_run_v2_service 遷移到 google_cloud_run_v2_worker_pool(移除 HTTP health hack);Beat scheduler 採獨立 singleton beat(亦為 worker_pool)+ RedBeat(schedule 狀態存既有 Redis)。
三項決策:
-
資源型別:worker / beat 都用
worker_pool,而非繼續維護 service-with-HTTP-hack。worker_pool 在語意(背景處理)與計費(always-on)上與 Celery 的真實負載對齊,並可整段移除_HealthHandler/CloudRunHealthServerbootstep 與 startup_probe。 -
Beat 方案 A — 獨立 beat + RedBeat:新增
${env}-yibi-beatworker_pool(min=max=1強制 singleton),跑celery beat --scheduler redbeat.RedBeatScheduler。RedBeat 把 schedule、last-run、分散鎖存進既有 Redis(Memorystore),跨 Cloud Run 重啟不失狀態,排程邊界不漏不重;crontab 定義維持在celery_app.py單一真相源。 -
執行順序 #308 → #310 → #309(非票號順序):beat 若以 Cloud Run service 跑會撞上和 worker 相同的 port 8080 probe 問題,且
CloudRunHealthServerbootstep 只掛在steps["worker"]、celery beat命令不會啟動它。因此 #309 排在 #310 之後,beat 直接以 worker_pool 部署即免除 HTTP hack。若 #308 staging 驗證失敗,依 #308 body 約定立即升級 #310。
Alternatives Considered
-
維持 worker-as-service + HTTP hack — 考慮過,因為
現況能跑;但放棄,原因是 port 8080 衝突已造成過 production 事故(PR #327+#331),且infra/CLAUDE.md已累積 ~5 條只為「讓 worker-as-service 不爆炸」的 gotcha,技術債利息持續在付。 -
Beat 方案 B:Cloud Scheduler + Cloud Run Job dispatch — 考慮過,因為
GCP 原生 HA、零 idle 成本;但放棄,原因是排程真相源會分裂成兩處(google_cloud_scheduler_jobTerraform vscelery_app.pycrontab),celery_app.py的beat_schedule變死碼,正是infra/CLAUDE.md反覆警告的 drift 陷阱;且需新增內部 dispatch 端點 + OIDC 驗證。 -
Beat 方案 C:獨立 beat + 內建 PersistentScheduler — 考慮過,因為
最簡單、零新依賴;但放棄,原因是 PersistentScheduler 把 last-run 寫本機檔,在 ephemeral 的 Cloud Run 上重啟即失憶,排程邊界可能漏跑或重跑,需各 task 自身冪等性兜底(現有 task 非全部驗證過)。 -
worker 直接加
-B— 放棄,原因是 prod workermax_instance_count=2,水平擴展時 beat 會多實例觸發 → 排程任務重複執行。
Consequences
好:
- 移除
celery_app.py的_HealthHandler+CloudRunHealthServerbootstep(~58 行)與 startup_probe block - 可清除
infra/CLAUDE.md中 ~5 條 worker-as-service gotcha(BETA TCP probe、bash sidecar 搶 port 8080、GA startup_probe 調校、worker_pool TODO) - 排程任務首次能正常執行;RedBeat 跨重啟保留 last-run,crontab 維持單一真相源
- 計費與 autoscaling 語意正確(worker_pool 本質 always-on 背景處理)
壞 / 代償:
- google provider 跨 2 個 major version(5 → 7),可能對 load-balancer SSL cert(
name_prefixrotation)、cloud-sql、monitoring 等其他資源產生非預期 plan diff,必須 staging-first 攤開檢查 - 新增一個常駐
${env}-yibi-beatworker_pool(min=1 → 24/7 計費) - 新增
celery-redbeat依賴(非 Celery 官方,維護活躍度中等) - CD pipeline 需從
gcloud run services update改為gcloud beta run worker-pools update(仍是betaCLI)
中性:
- worker_pool 遷移需
terraform state mv/moved {}block 避免 destructive replace infra/CLAUDE.md描述舊行為(worker-as-service)的段落須在 #310 落地時同步更新或標記,符合docs/CLAUDE.md「行為決策進 ADR 後同步更新舊 doc」守則
Test Tracking(後續測試追蹤)
驗證以三票的 checklist 為單一真相源,逐 phase 勾選:
- Phase 1(#308):staging CD
deploy-worker綠 →gcloud run services describe staging-yibi-workerrevision READY → 觸發 enqueue 任務確認 worker 消費、不卡 PENDING → 確認CELERY_BROKER_URL/CELERY_RESULT_BACKEND已注入。 - Phase 3(#310):
make staging-plan無破壞性 replace diff → apply → worker_pool 消費任務正常(無 :8080)→pytest tests/unit/ tests/integration/全綠(移除 bootstep 後確認無 import 殘留)→ 人工 approve → prod apply。 - Phase 2(#309):beat worker_pool revision READY(無 HTTP probe)→ 暫調某 task schedule 至近未來時點,確認 beat enqueue + worker 消費 + 結果正確後改回 → RedBeat key 寫進 Redis(
redbeat:*)→ 重啟 beat 後 last-run 保留。