YiBi Dev Docs
架構決策 (ADR)

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

  • BETA launch 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_schedulecelery_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)

三項決策:

  1. 資源型別:worker / beat 都用 worker_pool,而非繼續維護 service-with-HTTP-hack。worker_pool 在語意(背景處理)與計費(always-on)上與 Celery 的真實負載對齊,並可整段移除 _HealthHandler / CloudRunHealthServer bootstep 與 startup_probe。

  2. Beat 方案 A — 獨立 beat + RedBeat:新增 ${env}-yibi-beat worker_pool(min=max=1 強制 singleton),跑 celery beat --scheduler redbeat.RedBeatScheduler。RedBeat 把 schedule、last-run、分散鎖存進既有 Redis(Memorystore),跨 Cloud Run 重啟不失狀態,排程邊界不漏不重;crontab 定義維持在 celery_app.py 單一真相源。

  3. 執行順序 #308 → #310 → #309(非票號順序):beat 若以 Cloud Run service 跑會撞上和 worker 相同的 port 8080 probe 問題,且 CloudRunHealthServer bootstep 只掛在 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_job Terraform vs celery_app.py crontab),celery_app.pybeat_schedule 變死碼,正是 infra/CLAUDE.md 反覆警告的 drift 陷阱;且需新增內部 dispatch 端點 + OIDC 驗證。

  • Beat 方案 C:獨立 beat + 內建 PersistentScheduler — 考慮過,因為 最簡單、零新依賴;但放棄,原因是 PersistentScheduler 把 last-run 寫本機檔,在 ephemeral 的 Cloud Run 上重啟即失憶,排程邊界可能漏跑或重跑,需各 task 自身冪等性兜底(現有 task 非全部驗證過)。

  • worker 直接加 -B — 放棄,原因是 prod worker max_instance_count=2,水平擴展時 beat 會多實例觸發 → 排程任務重複執行。

Consequences

  • 移除 celery_app.py_HealthHandler + CloudRunHealthServer bootstep(~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_prefix rotation)、cloud-sql、monitoring 等其他資源產生非預期 plan diff,必須 staging-first 攤開檢查
  • 新增一個常駐 ${env}-yibi-beat worker_pool(min=1 → 24/7 計費)
  • 新增 celery-redbeat 依賴(非 Celery 官方,維護活躍度中等)
  • CD pipeline 需從 gcloud run services update 改為 gcloud beta run worker-pools update(仍是 beta CLI)

中性

  • 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-worker revision 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 保留。

On this page