Dcard Data Monorepo 指南

這份指南帶你從零上手 dcard-data monorepo — Dcard Data Team 的 Python services + shared libs 集中管理的地方。看完大概 10 分鐘,會知道它長什麼樣、怎麼新增服務、PR 合進去後 CI 如何只跑受影響的部分。

總覽

一句話:UV workspace + just + 客製 affected detection + CircleCI dynamic config

Package manager

UV workspace — 所有 services 和 libs 共用一個 uv.lock,確保 deps 版本一致。

Task runner

justjustfile)— 統一 build / test / lint / format 入口。

Linter / Formatter

Ruff — 設定在 root pyproject.toml,所有 workspace members 共用。

CI / CD

CircleCI dynamic config — 分析 git diff 後只跑受影響的 services + libs。

目錄結構

dcard-data/
├── pyproject.toml          # UV workspace root
├── uv.lock                 # 統一 lock file
├── justfile                # 入口任務
│
├── libs/                   # 共用 Python packages
│   ├── dcard-api/          # FastAPI scaffolding (app/metrics/health)
│   ├── dcard-bigtable/     # Bigtable client utilities
│   ├── dcard-mongo/        # MongoDB client utilities
│   ├── dcard-observability/ # Prometheus metrics utilities
│   └── dcard-redis/        # Redis/RediSearch client utilities
│
├── services/               # 獨立部署的 services
│   ├── ad-cdc-sync/        # CDC 同步廣告素材到 RediSearch (consumer)
│   ├── ad-product-ranking/ # 動態商品排序 (api)
│   ├── feast-grpc/         # Feast gRPC feature store server (api/gRPC)
│   ├── youji-api/          # DAD 平台 API (api)
│   └── youji-audience-sketch/ # 受眾估算 Spark job (spark)
│
├── scripts/                # Build 和 CI 工具
│   ├── init-api-service.sh # 建立新 API service
│   └── affected.py         # Git diff 偵測受影響的 packages
│
└── .circleci/config.yml    # setup workflow,跳轉到 dynamic config

重要檔案

檔案角色
pyproject.toml (root)定義 workspace members,[tool.uv.workspace.members = ["libs/*", "services/*"]。ruff 設定也在這裡。
uv.lock所有 packages 解析後的版本總和。任何一個 member 改 deps 都會更新這裡。
.dockerignore.git.venv、caches、IDE 配置排除在 build context 外,image build 才會快。

環境設定

# 裝 UV
curl -LsSf https://astral.sh/uv/install.sh | sh      # or: brew install uv

# 裝 just
brew install just

# Clone
git clone git@github.com:Dcard/Dcard-Data.git
cd dcard-data

# 裝所有 workspace deps(一次裝好、所有 service / lib 共用一個 venv)
uv sync --all-packages

# 或只裝特定 service 的 scope
uv sync --dev --package youji-api

just 指令

just --list 列出所有可用指令。最常用的:

指令作用
just test <service>單一 service 跑 pytest
just test-lib <lib>單一 lib 跑 pytest
just test-affected跑 git diff 偵測到的受影響 services + libs
just lint <service> / lint-lib <lib>Ruff check + format --check
just lint-affected受影響的 packages 一起 lint
just format <service> / format-lib <lib>Ruff format 自動修
just sync <service> / sync-all同步 deps(剛拉 main 或改 pyproject 後用)
just build <service>本地 Docker build(tag = short sha)
just init-api-service <name>scaffold 新 API service(見下)
just affected / ci-config看 git diff 偵測到什麼 / 看產生的 CircleCI YAML

Services

youji-api api

DAD 平台 API:audience estimation、tag sorting、CPC suggestions。團隊:ad-recommendation。

ad-product-ranking api

動態商品廣告輪播排序。

ad-cdc-sync consumer

CDC consumer:pgcapture → RediSearch,同步廣告素材、候選池、reserve bid price。

youji-audience-sketch spark

Theta sketch Spark job。部署方式:打包 artifact 上 GCS,不走 k8s。

feast-grpc api / gRPC

Feast-based gRPC feature store server + 客製 Redis online store + DataHub lineage connector。團隊:ml-infra。

每個 service 結構一致:

services/<name>/
├── pyproject.toml          # workspace member
├── Dockerfile              # multi-stage, uv pinned 0.5
├── service.yml             # 部署 metadata(type / image / 資源名 / 健檢)
├── src/<package_name>/     # 大多 service 用 src layout
└── tests/

Libs

只有在「多個 service 要共用的程式碼」才抽成 lib。用 workspace dep 引入:

# services/my-service/pyproject.toml
dependencies = [
    "dcard-api",
    "dcard-mongo",
]

[tool.uv.sources]
dcard-api = { workspace = true }
dcard-mongo = { workspace = true }
Lib用途
dcard-apiFastAPI app scaffold:create_app()/healthz/metrics(支援 gunicorn 多 worker 的 multiproc 聚合)、gunicorn 設定。
dcard-bigtableSync + Async Bigtable client。含 warmup() 方法,解決冷啟動 gRPC channel 建立慢的問題(後面詳述)。
dcard-mongoMongoDB client:sync + async,連 init_client 的 direct-connection 判斷、metrics hook 都處理好。
dcard-redisRedis / Redis Cluster / RediSearch base client。
dcard-observabilityPrometheus metric helpers:create_operation_recorder(成功 / 失敗 / 延遲)、create_request_metrics、connection pool monitor、metrics HTTP server。
為什麼不全抽成 lib? 早抽會讓 service 之間綁在一起,改一個 lib 就觸動所有下游。當第 2 個 service 需要同一段 code 時再抽

新增 API service

just init-api-service my-new-service

會在 services/my-new-service/ 產出:

  • pyproject.toml — 已 workspace + dcard-api dep
  • Dockerfile — multi-stage、uv 0.5 pinned、非 root user、BuildKit cache mount、PYTHONDONTWRITEBYTECODE / PYTHONUNBUFFERED
  • service.ymltype: api、扁平 deploy.image + deploy.resource_name
  • docker-entrypoint.shexec gunicorn ...(PID 1 讓 SIGTERM 直接到 gunicorn,graceful shutdown)
  • src/my_new_service/main.py — 用 dcard_api.create_app() 起 FastAPI
  • tests/test_health.py — 最基本的 /healthz 測試
consumer / spark 沒有 scaffolding。 直接複製 ad-cdc-sync(consumer)或 youji-audience-sketch(spark)當範本比較快。

Affected 偵測

scripts/affected.py 是整個 CI 動態行為的核心。它分析 git diff,告訴你哪些 packages(services + libs)受影響。

變更位置                              受影響
─────────────────────────────────── ──────────────────────────
services/<X>/...                     service X
libs/<Y>/...                         lib Y + transitive 依賴 Y 的所有 libs + services
pyproject.toml / .python-version     所有 services + libs

Lib 之間有 dependency(例如 dcard-api → dcard-observability),偵測會做傳遞閉包:改 dcard-observability 會拉出 dcard-apidcard-mongodcard-redisdcard-bigtable 等 lib,加上這些 lib 下游所有 services。

# 手動試偵測
just affected

# 看會產生什麼 CircleCI YAML
just ci-config
⚠️ main branch 特例:在 main 上 HEAD 等於 origin/mainorigin/main...HEAD diff 是空的。script 會自動 fallback 成 HEAD~1(merge commit 的 first parent),才看得到這次 merge 帶進來的改動。

Pipeline 流程

每個受影響的 service 都會走完整的 build → deploy → approve → deploy prod:

test-<service>  →  build-<service>  →  deploy-staging-<service>
                                           ↓
                                   approve-production-<service> (manual hold)
                                           ↓
                                   deploy-production-<service>

test-lib-<lib>   (libs 有自己獨立 test job,跟 services 並行)
TypeBuildDeploy
api / consumerDocker image → Artifact Registrygenkit/deploy → k8s deployment
spark打包 src artifact上傳到 GCS bucket (staging / production 分別)

Docker image 一次打兩個 tag::latest + :<git-sha>。Production rollback 時用 SHA 可以精準回到特定 commit:

kubectl --context=<prod-cluster> \
  set image deployment/<name> \
  <container>=asia-east1-docker.pkg.dev/dcard-data/modules/<image>:<old-sha>

service.yml

這是 service 向 CI 自我介紹的檔案。affected.py 讀它來決定要產什麼 build / deploy job。

# 最常見:HTTP API
name: my-service
team: ad-recommendation
type: api

deploy:
  image: dcard-my-service             # 在 Artifact Registry 的 image 名
  resource_name: dcard-my-service     # k8s deployment 名字(=container name)
# Consumer(一個 image 多個 k8s deployment — 拆不同訊息 stream)
type: consumer
deploy:
  image: dcard-ad-cdc-sync
  resource_names:
    - dcard-youji-ad-cdc-sync-adset
    - dcard-youji-ad-cdc-sync-candidate
    - dcard-youji-ad-cdc-sync-reserve-bid-price
# gRPC 或非預設 port + 健檢設定
type: api

deploy:
  image: dcard-feast-grpc
  resource_name: dcard-feast-grpc
  container_port: 50051                 # 非 8000 的主要服務 port
  healthcheck:
    port: 2112                          # 健檢 port 跟服務 port 不同時填
    path: /health
# Spark job
type: spark
deploy:
  gcs_bucket_staging: dcard-ad-targeting-staging-asia
  gcs_bucket_production: dcard-ad-targeting-asia
  gcs_prefix: dataproc/my_job
  artifacts:
    - { type: file, src: src/main.py }
    - { type: zip,  src: src/utils/, name: utils.zip }

Docker 慣例

所有 service 的 Dockerfile 都遵循一套 pattern(由 init script 產、手動維護):

  • Build from repo root — 因為要能讀到 workspace 的 pyproject.toml + uv.lockdocker build -f services/<name>/Dockerfile .(路徑給 -f,context 給 .)。
  • Pinned uv 版本COPY --from=ghcr.io/astral-sh/uv:0.5 /uv /bin/uv,不用 :latest
  • BuildKit cache mountRUN --mount=type=cache,target=/root/.cache/uv uv sync ...,CI 多次 build 時 cache 命中。
  • Non-root useruseradd -m -u 1000 appUSER app
  • Exec gunicorn — entrypoint.sh 用 exec gunicorn ... 讓 gunicorn 當 PID 1,k8s SIGTERM graceful shutdown 才有效。
  • Python env varsPYTHONDONTWRITEBYTECODE=1PYTHONUNBUFFERED=1
  • 只 COPY 需要的 libs — 不是整個 libs/ 都塞進 image,只複製該 service 有用到的。

Bigtable warmup

用到 dcard-bigtable 的 service,第一個請求會踩 Bigtable gRPC channel 冷啟動 — channel 建立 + auth token + routing metadata 動輒上百毫秒,超過 lib default 的 150ms timeout 就會回 500。

解法:在 FastAPI lifespan 裡加一行 warmup:

from dcard_bigtable import AsyncBigtableClientBase

bigtable = AsyncBigtableClientBase(project=...)

@asynccontextmanager
async def lifespan(app):
    # ... other init ...
    await bigtable.warmup("feature-store", bigtable_table_id)
    yield

warmup() 會 issue 一個 read_rows(limit=1),用寬鬆的 5s timeout 把 channel + auth 全部預熱一次。失敗會 log warning 但不 block startup。

註:對於會做 prefix scan(掃大量 row)的 endpoint,除了 warmup 之外還要考慮把 timeout 調寬 — scan 本身就比 point lookup 慢。可以透過 AsyncBigtableClientBase(timeout=2.0)BIGTABLE_OPERATION_TIMEOUT_SECONDS env 設定 service-level default。

疑難排解

uv sync 失敗

rm -rf .venv
uv sync --all-packages

改 lib 了,CI 卻沒跑我想跑的 service 測試

檢查 services/<name>/pyproject.toml 是否明確宣告 workspace dep:

dependencies = ["dcard-mongo"]
[tool.uv.sources]
dcard-mongo = { workspace = true }

沒宣告的話 affected detector 看不到關聯。

CI 上 build 成功但 pod crash

多半是 non-root user 權限問題或 env 缺失。先 docker run --entrypoint sh <image> -c 'id && env' 看 user 是 1000 + 該有的 env 都在;再用 kubectl logs 看 pod startup log。

Production deploy 要手動 approve

這是設計。Staging auto-deploy;Production 卡在 approve-production-<service> 這個 hold job,透過 CircleCI UI 或 POST /api/v2/workflow/<wid>/approve/<job-id> 放行。放行後自動 rolling update。

Dependabot 告警

pyproject.toml 或某個 service 的 pyproject 裡升級版本後,uv lock 重產。lock 變動會自動觸發 affected detector 把全部 services 都拉進來跑一輪測試。

← 這份文件靜態 host 在 vibehost。repo 本體在 github.com/Dcard/Dcard-Data。有問題找 ad-recommendation / ml-infra。