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
just(justfile)— 統一 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-api | FastAPI app scaffold:create_app()、/healthz、/metrics(支援 gunicorn 多 worker 的 multiproc 聚合)、gunicorn 設定。 |
dcard-bigtable | Sync + Async Bigtable client。含 warmup() 方法,解決冷啟動 gRPC channel 建立慢的問題(後面詳述)。 |
dcard-mongo | MongoDB client:sync + async,連 init_client 的 direct-connection 判斷、metrics hook 都處理好。 |
dcard-redis | Redis / Redis Cluster / RediSearch base client。 |
dcard-observability | Prometheus metric helpers:create_operation_recorder(成功 / 失敗 / 延遲)、create_request_metrics、connection pool monitor、metrics HTTP server。 |
新增 API service
just init-api-service my-new-service
會在 services/my-new-service/ 產出:
pyproject.toml— 已 workspace + dcard-api depDockerfile— multi-stage、uv 0.5 pinned、非 root user、BuildKit cache mount、PYTHONDONTWRITEBYTECODE/PYTHONUNBUFFEREDservice.yml—type: api、扁平deploy.image+deploy.resource_namedocker-entrypoint.sh—exec gunicorn ...(PID 1 讓 SIGTERM 直接到 gunicorn,graceful shutdown)src/my_new_service/main.py— 用dcard_api.create_app()起 FastAPItests/test_health.py— 最基本的 /healthz 測試
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-api、dcard-mongo、dcard-redis、dcard-bigtable 等 lib,加上這些 lib 下游所有 services。
# 手動試偵測
just affected
# 看會產生什麼 CircleCI YAML
just ci-config
HEAD 等於 origin/main,origin/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 並行)
| Type | Build | Deploy |
|---|---|---|
api / consumer | Docker image → Artifact Registry | genkit/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.lock。docker build -f services/<name>/Dockerfile .(路徑給-f,context 給.)。 - Pinned uv 版本 —
COPY --from=ghcr.io/astral-sh/uv:0.5 /uv /bin/uv,不用:latest。 - BuildKit cache mount —
RUN --mount=type=cache,target=/root/.cache/uv uv sync ...,CI 多次 build 時 cache 命中。 - Non-root user —
useradd -m -u 1000 app、USER app。 - Exec gunicorn — entrypoint.sh 用
exec gunicorn ...讓 gunicorn 當 PID 1,k8s SIGTERM graceful shutdown 才有效。 - Python env vars —
PYTHONDONTWRITEBYTECODE=1、PYTHONUNBUFFERED=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。
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。